diff --git a/.claude/rules/deploy.md b/.claude/rules/deploy.md index c01e72de..6b0077bb 100644 --- a/.claude/rules/deploy.md +++ b/.claude/rules/deploy.md @@ -25,7 +25,8 @@ Reference incident: PR #346 (2026-05-18) deploy → user сам нашёл prod ## Path triggers (Forgejo Actions, `.forgejo/workflows/`) -- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `.forgejo/workflows/deploy.yml` → `deploy.yml` (main Site Finder stack) +- `backend/**`, `frontend/**`, `Caddyfile`, `caddy/**`, `docker-compose.prod.yml`, `data/sql/**`, `ops/glitchtip-auth-forwarder/**`, `ops/db-bootstrap/**`, `ops/docker-prune.sh`, `.forgejo/workflows/deploy.yml` → `deploy.yml` (main Site Finder stack) +- ⚠️ `ops/**` целиком **не** триггерит — только перечисленные подпути. Любой новый файл в `ops/`, который исполняется на VM (cron / шаг деплоя), надо добавлять в `paths:` явно, иначе он не доедет до `/opt/gendesign` и будет молча исполняться в старой версии - trade-in изменения → `deploy-tradein.yml` (отдельный stack; paths-filter base = last deployed SHA → накопленный diff, fail-safe build-all) - `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md` → `.forgejo/workflows/deploy-obsidian.yml` - `docs/**` alone → НЕ триггерит деплой diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index 4c1a4f84..8aba4d11 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -49,6 +49,7 @@ cd frontend && npm install --legacy-peer-deps --no-audit --no-fund - Pre-push check: `git diff main..HEAD -- frontend/package.json frontend/package-lock.json` — если только один из двух тронут → STOP, regen lock. - Imports без deps entry (TypeScript авто-resolve через transitive) — **latent bomb** до first `npm ci`. - Reference incident: PR #344 (2026-05-17) добавил `lucide-react` без regen lockfile → deploy #135 fail → P0 hotfix PR #345 (commit `6ee20294f2`). +- **То же правило для `tradein-mvp/frontend/`** (#2770): там теперь тоже tracked `package-lock.json` + `npm ci` в Dockerfile и в `ci-tradein.yml`. До #2770 лока не было вовсе (лежал `pnpm-lock.yaml`, из которого никто не ставил), и состав зависимостей прод-образа определялся датой сборки. ## Prettier / lint diff --git a/.claude/rules/sql.md b/.claude/rules/sql.md index f221b4ef..136bc01a 100644 --- a/.claude/rules/sql.md +++ b/.claude/rules/sql.md @@ -16,11 +16,43 @@ paths: -- Контекст: что делает файл, зачем, порядок применения, dependencies. BEGIN; +SET LOCAL lock_timeout = '5s'; -- если ниже есть блокирующий DDL, см. § lock_timeout + -- DDL здесь (idempotent) COMMIT; ``` +## lock_timeout при блокирующем DDL (обязательно) + +Любой `ALTER TABLE` / `DROP INDEX` / `CREATE INDEX` (без `CONCURRENTLY`) / +`REFRESH MATERIALIZED VIEW` / `TRUNCATE` обязан нести `SET LOCAL lock_timeout = '5s';` +сразу после `BEGIN`. Гейт: `scripts/check-migration-lock-timeout.py` (бежит в `ci.yml` +на каждом PR) — проверяет и наличие, и место (внутри транзакции, ДО первого DDL). + +**Почему.** Дорого не удержание лока, а ожидание его выдачи. 2026-08-07 `DROP INDEX` +на таблице в 1061 строку ждал ACCESS EXCLUSIVE 29 минут за чужой аналитической +psql-сессией. Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами → за +ним начинают ждать обычные SELECT приложения. `lock_timeout` ограничивает только +ожидание, на работу под локом не влияет. Срабатывание = красный деплой (честный +отказ, повторить позже) вместо тихой очереди перед приложением. + +**Значение 5 s:** снизу ограничено `deadlock_timeout` (1 s на проде) — автоотмена +мешающего autovacuum срабатывает только после того, как ждущий отстоял эту секунду, +поэтому 1-2 s гонялись бы с рутинным autovacuum. Сверху — столько максимум простоит +очередь запросов приложения. + +**`CONCURRENTLY`-формы — НАОБОРОТ, без lock_timeout** (и гейт их не требует): +`CREATE INDEX CONCURRENTLY` ждёт завершения параллельных транзакций через +VirtualXactLock, это ожидание тоже под `lock_timeout`, и таймаут обрывает построение, +оставляя невалидный индекс. По той же причине НЕ задавать `lock_timeout` глобально +в раннере. И только `SET LOCAL`, не голый `SET`: голый доживёт до конца сессии и +обрежет `CONCURRENTLY` ниже по файлу. + +Невалидные индексы (след оборванного CIC) ловит проверка после цикла миграций в +`deploy.yml` / `deploy-tradein.yml`: re-run миграции их НЕ чинит — `CREATE INDEX +CONCURRENTLY IF NOT EXISTS` тихо пропускает битый индекс как существующий. + ## Idempotency (обязательно) - `CREATE TABLE IF NOT EXISTS` diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 11396792..9823ace6 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,25 +44,126 @@ 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 needs: changes if: needs.changes.outputs.backend == 'true' + # Postgres-сервис (#2745). ДО него лэйн был mock-only: DATABASE_URL указывал на + # заведомо мёртвый `localhost:5432/test`, и девять тестов с `_live_session()` + # self-skip'ались — в CI они не бежали НИ РАЗУ. Так и разъехался со схемой + # test_house_dedup_merge (#2740: houses.url стал NOT NULL), а + # test_gar_flats_loader вообще падал до первого утверждения (#2744). + # + # Замер перед включением: полный сьют на mock-лэйне 122с / 3858 passed / 10 skipped, + # тот же сьют против живой БД — 106с / 3867 passed / 1 skipped. Живая БД не + # медленнее, поэтому НЕ добавляем второй job, а чиним этот: один прогон, на + # девять реальных проверок больше. Накладные — только подъём контейнера и + # bootstrap схемы (219 файлов, ~20с). defaults: run: working-directory: ./tradein-mvp/backend env: - # psycopg v3 требует parseable URL на импорте; реального коннекта нет — - # DB-тесты мокаются (mirror deploy-tradein.yml test-job). - DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test + # Имя контейнера уникально на прогон: параллельные PR не дерутся за него. + CI_PG: ci-pg-tradein-${{ github.run_id }} steps: - uses: actions/checkout@v4 + - name: Поднять Postgres и собрать схему tradein + working-directory: . + # ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА. + # Раннер запускает и job, и сервис-контейнеры с `--network host` (видно в + # логе прогона: `docker create image=... network="host"`), а на 5432 того + # же хоста слушает ПРОДОВЫЙ Postgres. Попытка через `services:` + + # `ports: 5432:5432` кончилась тем, что сервис-контейнер не смог занять + # порт, а psql из job'а ушёл В ПРОД и получил + # `password authentication failed for user "tradein"`. То есть + # `localhost:5432` из job'а на этом раннере — боевая база, а не тестовая. + # Поэтому контейнер поднимаем сами, в bridge-сети, БЕЗ публикации порта, + # и ходим по его собственному IP: прод недостижим в принципе, параллельные + # прогоны не конфликтуют, psql берём из самого контейнера. + # + # ОДИН шаг, а не два: между шагами контейнер успевал исчезнуть, и + # bootstrap падал на `container is not running`. + # + # `pg_isready -h 127.0.0.1`, а НЕ через unix-сокет: на время initdb образ + # поднимает ВРЕМЕННЫЙ сервер с listen_addresses='' — по сокету он уже + # отвечает «accepting connections», хотя снаружи БД ещё не существует, а + # впереди рестарт. Проба по TCP зеленеет только на настоящем сервере — + # том самом, к которому пойдут тесты. + # + # postgis, не plain postgres: tests/tasks/test_cadastral_geo_match.py + # проверяет KNN по geometry (PostGIS_Version() в connectivity-probe). + # Имя БД ОБЯЗАНО отличаться от `test`: `_live_session()` считает DSN с + # `localhost:5432/test` заглушкой и вернул бы None — контейнер поднялся + # бы, а тесты всё равно скипались. + run: | + set -u + docker rm -fv "$CI_PG" >/dev/null 2>&1 || true + docker run -d --name "$CI_PG" \ + -e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \ + postgis/postgis:16-3.4 + + ready="" + for _ in $(seq 1 45); do + if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U tradein -q 2>/dev/null; then + ready=1; break + fi + [ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break + sleep 2 + done + if [ -z "$ready" ]; then + echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)" + docker logs --tail 50 "$CI_PG" 2>&1 || true + exit 1 + fi + + ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG") + [ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; } + echo "DATABASE_URL=postgresql+psycopg://tradein:tradein@${ip}:5432/tradein" >> "$GITHUB_ENV" + echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)" + + # Тот же порядок и тот же строгий режим, что в deploy-tradein.yml: + # `ls | sort` + ON_ERROR_STOP=on, падение любой миграции → job RED. + # Никаких «применилось как получилось»: схема в CI либо та же, что на + # проде, либо гейта нет. + docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q -c \ + "CREATE EXTENSION IF NOT EXISTS postgis; + CREATE EXTENSION IF NOT EXISTS pg_trgm; + CREATE ROLE gendesign_reader;" + for sql_file in $(ls -1 tradein-mvp/backend/data/sql/*.sql | sort); do + fname=$(basename "$sql_file") + # ЕДИНСТВЕННОЕ исключение, и оно названо вслух: 077 — не DDL, а + # backfill, читающий foreign table gendesign_rosreestr_deals из БД + # ДРУГОГО стека через postgres_fdw. В CI второй БД нет, USER MAPPING + # создать не из чего. На пустых таблицах backfill всё равно no-op. + if [ "$fname" = "077_dedup_hash_plain_key_backfill.sql" ]; then + echo "⚠ пропускаю $fname — postgres_fdw к БД gendesign, которой в CI нет" + continue + fi + docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q < "$sql_file" \ + || { echo "::error::миграция $fname не применилась"; docker logs --tail 20 "$CI_PG" 2>&1 || true; exit 1; } + done + echo "✓ схема собрана: $(docker exec "$CI_PG" psql -U tradein -d tradein -tAc \ + "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'") таблиц" + - name: Install uv # Официальный standalone-инсталлер. НЕ astral-sh/setup-uv — он ломается # на Forgejo-runner с PEP 668 externally-managed-environment (#666 CI). @@ -81,23 +183,104 @@ jobs: restore-keys: | uv-tradein-${{ runner.os }}- - - name: Sync deps (incl. dev group — pytest) + - name: Sync deps (incl. dev group — pytest, ruff) # Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored # только старый backend/uv.lock) → --frozen детерминирован и зеркалит # Dockerfile (uv sync --frozen --no-dev там). uv находит workspace root # вверх от cwd. run: uv sync --frozen + - name: Lint (ruff check) + # Правила выбраны в tradein-mvp/backend/pyproject.toml ([tool.ruff.lint] + # select = E F I B UP N RUF), но до этого шага их никто не гонял в CI — + # "дерево чистое" было непроверенным утверждением, а не гарантией. + # Версия ruff — та же, что в tradein-mvp/uv.lock (--frozen из шага выше), + # т.е. ровно то, что видит `uv sync --frozen` в Dockerfile. + # Blocking: любое нарушение → job RED (не декоративно). + run: uv run ruff check . + - 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 просто пропустит зелёный. + # + # `-rs` (#2745) — КАЖДЫЙ пропуск печатает свою причину в лог job'а. Без него + # `-q` рисует пропуск точкой `s`, неотличимой на глаз от прогона: ровно так + # девять DB-тестов «шли зелёными», ничего не проверяя. Пропуск, который не + # называет себя вслух, со временем перестаёт быть верным. + run: uv run pytest -q -rs + + - name: Снести тестовый Postgres + # if: always() — контейнер уходит и когда сьют красный, и когда прогон + # отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры. + if: always() + working-directory: . + run: docker rm -fv "$CI_PG" >/dev/null 2>&1 || true + + # Тесты браузерного сайдкара (#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. + # `-rs`: если однажды появится пропуск, он назовёт причину в логе, а не + # растворится в строке точек. + run: pytest -q -rs frontend-checks: runs-on: ubuntu-latest @@ -111,25 +294,48 @@ jobs: - name: Set up Node # Node 20 — major из tradein-mvp/frontend/Dockerfile (node:20-alpine). - # npm-кэш setup-node НЕ настраиваем: в tradein-mvp/frontend нет - # package-lock.json (Dockerfile ставит через npm install), а cache=npm - # без lockfile падает. Кэш wheels/node тут не критичен для type-check/lint. + # cache: npm включён с #2770 — package-lock.json теперь tracked. uses: actions/setup-node@v4 with: node-version: "20" + cache: npm + cache-dependency-path: tradein-mvp/frontend/package-lock.json - - name: Install deps (npm install, no lockfile) - # ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage): - # --legacy-peer-deps — Tailwind/React 19 peer-dep mismatches; - # --no-audit --no-fund — тише и быстрее в CI. `install` (не `ci`): - # в tradein-mvp/frontend НЕТ package-lock.json (есть pnpm-lock.yaml, но - # Dockerfile ставит именно npm install) → `npm ci` упал бы. - run: npm install --legacy-peer-deps --no-audit --no-fund + - name: Install deps (npm ci) + # ТОЧНЫЕ флаги из tradein-mvp/frontend/Dockerfile (deps stage), чтобы гейт + # видел то же дерево, что уедет в образ. `ci`, а не `install` (#2770): до + # него лока не было вовсе (лежал мёртвый pnpm-lock.yaml, из которого никто + # не ставил), и версии в CI и в прод-образе выбирались независимо по дате + # сборки — гейт проверял не тот код, который деплоится. + # + # Правишь package.json — регенерируй лок в том же PR: `npm ci` требует + # точного match и иначе роняет и этот job, и build образа. + run: npm ci --legacy-peer-deps --no-audit --no-fund - name: Type-check (tsc --noEmit) # Blocking: любая TS-ошибка → job RED. run: npm run type-check + - name: Run tests (vitest) + # Blocking (#2766). До этого шага у tradein-фронта не бежало НИ ОДНОЙ + # проверки поведения: лэйн гейтил только типы и статический анализ, а оба + # молчат про то, что видит пользователь — пустое поле, погашенное число, + # отказ по частоте. Инфраструктура не изобретена, а взята у соседнего + # frontend/ (vitest + jsdom + testing-library), где сьют живёт давно. + # + # Пропусков в сьюте нет и быть не должно: сторож пропусков + # (tests/skip_allowlist.txt) — pytest-only, у vitest такого нет, поэтому + # пропуск здесь стал бы ровно тем незаметным «зелёным», который #2722 + # запретил на бэкенде. Тест либо чинится, либо помечается `.fails` + # с причиной В КОДЕ. + run: npm test + - 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..eccedb9e 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -12,8 +12,18 @@ name: CI # единственный real-Postgres тест (tests/sql/ mv_layout) self-skip'ается через # connectivity-probe. PDF-тесты (WeasyPrint) РЕАЛЬНО ИДУТ здесь (libpango # установлен ниже), тогда как на macOS-dev они runtime-skip'аются. -# FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см. -# .github/workflows/ci.yml как образец service-блока. +# +# FUTURE: захочется добавить сюда живой postgis и гонять mv_layout — ⚠️ НЕ через +# `services:` с публикацией порта (#2757). Раннер запускает и job, и сервис- +# контейнеры с `--network host`, а на 5432 этого же хоста слушает БОЕВОЙ +# Postgres: контейнер порт не займёт, а `localhost:5432` из job'а — это прод. +# В #2745 так и вышло, спасло только несовпадение пароля. Образец правильного +# способа (docker run в bridge-сети БЕЗ публикации, готовность по TCP, коннект +# по IP контейнера) — в .forgejo/workflows/ci-tradein.yml, шаг «Поднять Postgres +# и собрать схему tradein». В .github/workflows/ci.yml лежит ровно анти-пример +# (`ports: 5432:5432`) — он безвреден только потому, что GitHub Actions у нас не +# исполняется; копировать оттуда нельзя. Гейт ниже (Guard: host-port collisions) +# уронит сборку, если такая публикация всё же появится. on: # ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709). # WHY: раньше был и push: [feat/**,fix/**,...]. Каждый коммит в ветку с открытым @@ -45,6 +55,25 @@ jobs: frontend: ${{ steps.filter.outputs.frontend }} steps: - uses: actions/checkout@v4 + + - name: "Guard: host-port collisions in workflows (#2757)" + # Шагом в changes-job, а не отдельным job'ом: этот job и так бежит на + # КАЖДОМ PR и уже сделал checkout — гейт стоит ~1с и не занимает + # дефицитный слот раннера. Падение = merge заблокирован. + # python3 есть в образе раннера (catthehacker/ubuntu:act-latest, 3.12.3). + run: | + python3 scripts/check-workflow-ports.py --selftest + python3 scripts/check-workflow-ports.py + + - name: "Guard: блокирующий DDL без lock_timeout (#2752)" + # Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR, + # включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow — + # фильтруется только job backend-tests). Это важно: миграции лежат в ДВУХ + # каталогах, и гейт, видимый лишь одному лэйну, пропускал бы половину. + run: | + python3 scripts/check-migration-lock-timeout.py --selftest + python3 scripts/check-migration-lock-timeout.py + - uses: dorny/paths-filter@v3 id: filter with: @@ -52,6 +81,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/**' @@ -61,6 +98,20 @@ jobs: runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.backend == 'true' + # Postgres-сервис (#2745). Раньше DATABASE_URL указывал на заведомо мёртвый + # хост, и весь tests/sql/ (10 тестов: #17 velocity-alerts, #99 ДДУ-индикатор, + # #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки + # не бежали ни разу с момента написания. + # + # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные + # таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему — + # проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не + # нужен, в отличие от tradein-лэйна. + # + # TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/ + # (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по + # SSH-туннелю. Пустой контейнер дал бы там красноту на пустом месте, поэтому + # integration остаётся честно пропущенным — с причиной в логе (`-rs`). defaults: run: working-directory: backend @@ -68,14 +119,53 @@ jobs: # TESTING=1 активирует RBAC-bypass (app/main.py rbac_guard пропускает # запросы при settings.testing=True) — иначе 401 на всём /api/v1. TESTING: "1" - # Stub DSN: psycopg v3 требует parseable URL на импорте; реального коннекта - # нет — DB-тесты мокаются, real-DB тест (tests/sql/) self-skip'ается через - # connectivity-probe к этому хосту (5432 недоступен → skip). - DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test REDIS_URL: redis://localhost:6379/0 + # Имя контейнера уникально на прогон: параллельные PR не дерутся за него. + CI_PG: ci-pg-backend-${{ github.run_id }} steps: - uses: actions/checkout@v4 + - name: Поднять Postgres для тестов + working-directory: . + # ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА — подробный разбор в + # ci-tradein.yml (тот же раннер). Кратко: job и сервис-контейнеры идут с + # `--network host`, а на 5432 этого хоста слушает ПРОДОВЫЙ Postgres, то + # есть `localhost:5432` из job'а — боевая база. Поднимаем контейнер сами, + # в bridge-сети, без публикации порта, ходим по его IP. + # + # `pg_isready -h 127.0.0.1`, а не через unix-сокет: по сокету отвечает + # ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД + # ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере. + # + # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные + # таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы + # здесь не нужен вовсе, в отличие от tradein-лэйна. + run: | + set -u + docker rm -fv "$CI_PG" >/dev/null 2>&1 || true + docker run -d --name "$CI_PG" \ + -e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \ + postgres:16 + + ready="" + for _ in $(seq 1 45); do + if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U gendesign -q 2>/dev/null; then + ready=1; break + fi + [ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break + sleep 2 + done + if [ -z "$ready" ]; then + echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)" + docker logs --tail 50 "$CI_PG" 2>&1 || true + exit 1 + fi + + ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG") + [ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; } + echo "DATABASE_URL=postgresql+psycopg://gendesign:gendesign@${ip}:5432/gendesign_ci" >> "$GITHUB_ENV" + echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)" + - name: Set up Python uses: actions/setup-python@v5 with: @@ -128,10 +218,13 @@ jobs: # но --ignore — belt-and-suspenders на случай сбора фикстур). # tests/integration self-skip'ается через requires_test_db (skipif на # TEST_DATABASE_URL, который тут не задан) → НЕ игнорим, оно чисто skip'ается. - # tests/sql/ mv_layout self-skip'ается через Postgres-connectivity probe - # (5432 недоступен в этом mock-lane) → SKIP. Это intended. + # tests/sql/ теперь РЕАЛЬНО ИДУТ — postgres-контейнер выше (#2745). # PDF-тесты ИДУТ (libpango выше). Target: 0 failed, skips OK. # + # `-rs` (#2745): каждый оставшийся пропуск печатает причину. Под `-q` без + # него пропуск неотличим от прогона — именно так проверка тихо перестаёт + # исполняться и об этом узнают, когда на неё надо опереться (#2722/#2729/#2740). + # # Coverage-gate (#68): --cov=app меряет покрытие пакета app/. # --cov-fail-under=65 → job RED если покрытие упало ниже baseline # (измерено 2026-06: mock-lane сьют ~71%, см. [tool.coverage] в pyproject; @@ -140,11 +233,18 @@ jobs: # coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge). # term-missing → видно непокрытые строки прямо в job-логе. run: | - uv run pytest -q --ignore=tests/smoke \ + # #2871: код возврата печатаем ЯВНО. Сводка pytest («4647 passed») уходит + # в лог ДО выхода, поэтому зелёная сводка при ненулевом коде выглядит как + # «job упал неизвестно где» — а падал именно этот шаг. Гейт сохраняется: + # ниже `exit $rc`. + rc=0 + uv run pytest -q -rs --ignore=tests/smoke \ --cov=app \ --cov-report=term-missing:skip-covered \ --cov-report=xml:coverage.xml \ - --cov-fail-under=65 + --cov-fail-under=65 || rc=$? + echo "### pytest вернул код $rc" + exit $rc - name: Coverage summary → job output # Дешёвый human-readable итог. Бежит даже если gate упал (if: always) — @@ -153,13 +253,34 @@ jobs: # если переменная пустая/файла нет, печатаем в обычный лог (fallback). if: always() run: | + echo "### шаг «Coverage summary» начался" [ -f coverage.xml ] || { echo "coverage.xml отсутствует — пропускаю summary"; exit 0; } - report="$(uv run coverage report --skip-covered --sort=cover | tail -40)" + # NB (#2871): `coverage report` уважает fail_under из pyproject и выходит с + # кодом 2, когда порог не набран, а `run:` идёт под `bash -eo pipefail` — + # то есть падение ЭТОГО шага гасит зелёный pytest и выглядит как «job упал + # неизвестно где». Разделяем вычисление и вывод, чтобы код возврата был виден. + # `|| cov_rc=$?`, а не отдельная строка: под `set -e` присваивание после + # упавшей команды просто не выполнится, и код возврата снова потеряется. + cov_rc=0 + uv run coverage report --skip-covered --sort=cover > /tmp/cov_report.txt || cov_rc=$? + echo "### coverage report вернул код $cov_rc" + report="$(tail -40 /tmp/cov_report.txt)" if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { echo '```'; echo "$report"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" else echo "$report" fi + echo "### шаг «Coverage summary» закончился успешно" + + - name: Снести тестовый Postgres + # if: always() — контейнер уходит и когда сьют красный, и когда прогон + # отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры. + if: always() + working-directory: . + run: | + echo "### шаг «Снести тестовый Postgres» начался (CI_PG=${CI_PG:-<пусто>})" + docker rm -fv "$CI_PG" >/dev/null 2>&1 || true + echo "### шаг «Снести тестовый Postgres» закончился успешно" frontend-tests: runs-on: ubuntu-latest diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index d2425ca4..640d12ff 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -28,12 +28,28 @@ jobs: frontend: ${{ steps.set-all.outputs.frontend || steps.filter.outputs.frontend }} browser: ${{ steps.set-all.outputs.browser || steps.filter.outputs.browser }} infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }} - scraper: ${{ steps.set-all.outputs.scraper || steps.filter.outputs.scraper }} + # Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE + # в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend. + app_version: ${{ steps.build-meta.outputs.app_version }} + build_sha: ${{ steps.build-meta.outputs.build_sha }} + build_date: ${{ steps.build-meta.outputs.build_date }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + # Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник + # правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки — + # проброшены как build-args в build-backend/build-frontend ниже (см. + # tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile). + # Считается ОДИН раз здесь, а не в каждой job отдельно. + - name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE) + id: build-meta + run: | + echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT" + echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT" + echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + # Resolve base SHA: read last-successfully-deployed SHA from the VPS host file. # The file is written by the deploy job on every successful deploy. # Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD, @@ -91,7 +107,6 @@ jobs: echo "frontend=true" >> "$GITHUB_OUTPUT" echo "browser=true" >> "$GITHUB_OUTPUT" echo "infra=true" >> "$GITHUB_OUTPUT" - echo "scraper=true" >> "$GITHUB_OUTPUT" # Cumulative diff: compare deployed SHA → HEAD so that a fast chain of merges # (e.g. backend #1829 then frontend #1830) doesn't lose earlier changes. @@ -107,28 +122,40 @@ jobs: # scheduler_main импортирует пакет) — kit-only изменение обязано # пересобрать образ, иначе деплой рестартует контейнеры на старом. - 'tradein-mvp/packages/scraper-kit/**' + # APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend + # ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ + # правок кода обязан пересобрать образ, иначе GET /version и колонтитул + # PDF продолжат отдавать старое значение при формально «успешном» деплое. + - 'tradein-mvp/VERSION' frontend: - 'tradein-mvp/frontend/**' + # NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же + # причина, что у backend выше. + - 'tradein-mvp/VERSION' + # /versions статически запекает CHANGELOG.md в билд (см. + # frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ + # frontend/** иначе не долетала бы до образа. + - 'tradein-mvp/CHANGELOG.md' browser: - 'tradein-mvp/browser/**' infra: - 'tradein-mvp/docker-compose.prod.yml' - 'tradein-mvp/deploy/**' - '.forgejo/workflows/deploy-tradein.yml' - scraper: - - 'tradein-mvp/backend/app/services/scrapers/**' - - 'tradein-mvp/backend/app/services/scrape_pipeline.py' - - 'tradein-mvp/backend/app/services/scheduler.py' - - 'tradein-mvp/backend/app/scheduler_main.py' - - 'tradein-mvp/backend/app/tasks/**' - # #2188: scheduler исполняет matching/dedup при каждом scrape-тике — - # без этих путей scraper-контейнер оставался на старом коде - # (2026-07-02: fias-dedup доехал до tradein-backend, но не до - # tradein-scraper). После USE_KIT_SCHEDULER=true kit-код и есть - # scheduler — его правки тоже обязаны пересоздавать контейнер. - - 'tradein-mvp/backend/app/services/matching/**' - - 'tradein-mvp/backend/app/services/house_dedup_merge.py' - - 'tradein-mvp/packages/scraper-kit/**' + # УДАЛЁН фильтр `scraper` (#2679, 2026-08-05). Он был allowlist'ом + # «файлов, которые исполняет планировщик», и перечислял только то, + # что вспомнили. Дважды выстрелило одинаково: + # 2026-07-02 (#2188) — fias-dedup доехал до tradein-backend, но не + # до tradein-scraper; починили ДОБАВЛЕНИЕМ путей (matching/**, + # house_dedup_merge.py) — залатали случай, не механизм; + # 2026-08-05 (#2675) — house_imv_backfill.py + product_handlers.py + # в списке не значились → планировщик час крутил старый код, + # деплой при этом отчитался успехом. + # За июнь-август 48% (193 из 402) backend-мержей не попадали ни в + # один из путей списка, т.е. половина правок доезжала до scraper'а + # только со следующим «удачным» деплоем. Теперь пересоздание + # привязано не к списку файлов, а к факту пересборки образа — + # см. SCRAPER_RECREATE в job deploy. # Quality gate: pytest MUST pass before any image is built/deployed (#666). # Runs the tradein-mvp/backend suite; a red test blocks build + deploy. @@ -164,15 +191,25 @@ 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) убран — проходит в полном - # прогоне (проверено 2026-07-02: 2947 passed / 1 failed). Список обязан - # совпадать с backend-tests в ci-tradein.yml (pre-merge гейт). - run: | - uv run pytest -q \ - --deselect "tests/test_search_api.py::test_search_cache_hit" + # БЕЗ deselect'ов — сьют гоняется целиком, как в ci-tradein.yml. + # + # Здесь жил `--deselect tests/test_search_api.py::test_search_cache_hit` с + # объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит — + # global-state leak из другого модуля». Объяснение было неверным в обеих + # половинах: тест падал и в изоляции тоже (401 vs 200), потому что ходил в + # /api/v1/search БЕЗ заголовка X-Authenticated-User, а RBAC-гард отвечает на + # такое 401. Причина была в самом тесте; заголовок добавлен в #2729, и в + # pre-merge гейте deselect снят тогда же. Здесь строка пережила починку ещё + # на месяц — файл был занят открытым #2680. Тот смержен, долг закрыт. + # + # Не добавлять сюда новые deselect'ы: молча выключенный тест — тот же класс + # дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо + # помечается xfail с причиной В КОДЕ, рядом с самим тестом. + # + # `-rs`: каждый пропуск печатает причину (#2745). Ожидание в этом лэйне — + # 13 пропусков, все объявлены в tests/skip_allowlist.txt; неучтённый + # пропуск роняет прогон через хук в tests/conftest.py. + run: uv run pytest -q -rs build-backend: runs-on: ubuntu-latest @@ -190,10 +227,49 @@ jobs: run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Подобрать протёкшие buildx-билдеры (#2869) + # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно + # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, + # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. + # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца + # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного + # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних + # созданы 13.08 11:57:43 — ровно тот прогон, что упал с + # `no space left on device`. + # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не + # принадлежит живому прогону (самый долгий job — ~17 минут). + run: | + now=$(date +%s); reaped=0; kept=0 + for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do + created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue + ts=$(date -d "$created" +%s 2>/dev/null) || continue + age_h=$(( (now - ts) / 3600 )) + if [ "$age_h" -ge 6 ]; then + echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" + if docker rm -f "$c" >/dev/null 2>&1; then + reaped=$((reaped+1)) + else + echo "buildx: не удалось убрать $c (не фатально)" + fi + docker volume rm "${c}_state" >/dev/null 2>&1 || true + else + kept=$((kept+1)) + fi + done + echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" + df -h / | tail -1 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + id: buildx - name: Build & push tradein-backend + # id + continue-on-error: битый blob в удалённом buildcache-манифесте + # валит весь шаг ДО push нового образа — деплой тогда молча + # пропускается (#2841), хотя собрать образ можно и без кеша. Ретрай + # без cache-from — ниже. + id: build + continue-on-error: true uses: docker/build-push-action@v6 with: # Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit @@ -201,12 +277,65 @@ jobs: context: ./tradein-mvp file: ./tradein-mvp/backend/Dockerfile push: true + # APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см. + # backend/Dockerfile ARG→ENV) — читает app/core/version.py: + # GET /api/v1/trade-in/version + колонтитул PDF-отчёта. + build-args: | + APP_VERSION=${{ needs.changes.outputs.app_version }} + BUILD_SHA=${{ needs.changes.outputs.build_sha }} + BUILD_DATE=${{ needs.changes.outputs.build_date }} cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max tags: | ${{ env.IMAGE_BACKEND }}:latest ${{ env.IMAGE_BACKEND }}:${{ github.sha }} + - name: Retry build & push tradein-backend без кеша (битый buildcache, #2841) + # cache-from опущен (источник падения), cache-to ОСТАВЛЕН (ревью #2841 R2, + # issue #2): успешный ретрай перезаписывает битый buildcache-тег своими + # слоями (mode=max) — это и есть самолечение. Без cache-to здесь порча + # оставалась навсегда, следующий прогон снова падал на том же cache-from. + if: steps.build.outcome == 'failure' + uses: docker/build-push-action@v6 + with: + context: ./tradein-mvp + file: ./tradein-mvp/backend/Dockerfile + push: true + build-args: | + APP_VERSION=${{ needs.changes.outputs.app_version }} + BUILD_SHA=${{ needs.changes.outputs.build_sha }} + BUILD_DATE=${{ needs.changes.outputs.build_date }} + cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max + tags: | + ${{ env.IMAGE_BACKEND }}:latest + ${{ env.IMAGE_BACKEND }}:${{ github.sha }} + + - name: Проверить, что tradein-backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2) + # НЕ полагается на семантику steps.build.outcome/continue-on-error раннера — + # проверяет РЕАЛЬНОЕ состояние registry через buildx (уже настроен выше). + # Если act_runner не заполняет outcome, ретрай выше молча НЕ побежит при + # упавшем build — этот шаг единственный это заметит: манифеста с этим SHA + # не будет → шаг падает БЕЗ continue-on-error → job честно FAILURE → deploy + # ниже пропускается вместо накатки старого :latest на прод. + run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null + + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) + # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. + # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на + # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в + # их `_state`-томах — диск ушёл на 94%, деплой упал с + # `no space left on device`. Убираем явно, `if: always()` и `|| true`, + # чтобы уборка не могла уронить прогон. + if: always() + run: | + name="${{ steps.buildx.outputs.name }}" + if [ -z "$name" ]; then + echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" + exit 0 + fi + echo "buildx: убираю билдер $name" + docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" + build-frontend: runs-on: ubuntu-latest needs: changes @@ -223,10 +352,55 @@ jobs: run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Подобрать протёкшие buildx-билдеры (#2869) + # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно + # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, + # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. + # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца + # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного + # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних + # созданы 13.08 11:57:43 — ровно тот прогон, что упал с + # `no space left on device`. + # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не + # принадлежит живому прогону (самый долгий job — ~17 минут). + run: | + now=$(date +%s); reaped=0; kept=0 + for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do + created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue + ts=$(date -d "$created" +%s 2>/dev/null) || continue + age_h=$(( (now - ts) / 3600 )) + if [ "$age_h" -ge 6 ]; then + echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" + if docker rm -f "$c" >/dev/null 2>&1; then + reaped=$((reaped+1)) + else + echo "buildx: не удалось убрать $c (не фатально)" + fi + docker volume rm "${c}_state" >/dev/null 2>&1 || true + else + kept=$((kept+1)) + fi + done + echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" + df -h / | tail -1 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + id: buildx + + # CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context + # (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста, + # поэтому копируем внутрь ДО build. /versions статически запекает его + # содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage + # комментарий). Не влияет на кэш другого шага — читается только этим. + - name: Stage CHANGELOG.md into frontend build context + run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md - name: Build & push tradein-frontend + # id + continue-on-error — см. tradein-backend (#2841): битый blob в + # удалённом buildcache не должен ронять сборку и молча пропускать деплой. + id: build + continue-on-error: true uses: docker/build-push-action@v6 with: context: ./tradein-mvp/frontend @@ -236,15 +410,62 @@ jobs: # (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в # dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница # уходит в notFound (404), не индексируется и не краулится. + # NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js + # инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит, + # см. frontend/Dockerfile комментарий у соответствующих ARG). build-args: | NEXT_PUBLIC_BASE_PATH=/trade-in NEXT_PUBLIC_API_BASE_URL=/trade-in + NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }} + NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }} + NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }} cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max tags: | ${{ env.IMAGE_FRONTEND }}:latest ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + - name: Retry build & push tradein-frontend без кеша (битый buildcache, #2841) + # См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to + # ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими + # слоями (mode=max), это и есть самолечение. + if: steps.build.outcome == 'failure' + uses: docker/build-push-action@v6 + with: + context: ./tradein-mvp/frontend + push: true + build-args: | + NEXT_PUBLIC_BASE_PATH=/trade-in + NEXT_PUBLIC_API_BASE_URL=/trade-in + NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }} + NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }} + NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }} + cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max + tags: | + ${{ env.IMAGE_FRONTEND }}:latest + ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + + - name: Проверить, что tradein-frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2) + # См. tradein-backend выше — не полагается на steps.build.outcome раннера. + run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null + + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) + # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. + # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на + # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в + # их `_state`-томах — диск ушёл на 94%, деплой упал с + # `no space left on device`. Убираем явно, `if: always()` и `|| true`, + # чтобы уборка не могла уронить прогон. + if: always() + run: | + name="${{ steps.buildx.outputs.name }}" + if [ -z "$name" ]; then + echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" + exit 0 + fi + echo "buildx: убираю билдер $name" + docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" + build-browser: runs-on: ubuntu-latest needs: changes @@ -263,10 +484,47 @@ jobs: run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Подобрать протёкшие buildx-билдеры (#2869) + # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно + # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, + # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. + # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца + # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного + # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних + # созданы 13.08 11:57:43 — ровно тот прогон, что упал с + # `no space left on device`. + # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не + # принадлежит живому прогону (самый долгий job — ~17 минут). + run: | + now=$(date +%s); reaped=0; kept=0 + for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do + created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue + ts=$(date -d "$created" +%s 2>/dev/null) || continue + age_h=$(( (now - ts) / 3600 )) + if [ "$age_h" -ge 6 ]; then + echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" + if docker rm -f "$c" >/dev/null 2>&1; then + reaped=$((reaped+1)) + else + echo "buildx: не удалось убрать $c (не фатально)" + fi + docker volume rm "${c}_state" >/dev/null 2>&1 || true + else + kept=$((kept+1)) + fi + done + echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" + df -h / | tail -1 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + id: buildx - name: Build & push tradein-browser + # id + continue-on-error — см. tradein-backend выше (#2841): битый blob + # в удалённом buildcache не должен ронять сборку и молча пропускать деплой. + id: build + continue-on-error: true uses: docker/build-push-action@v6 with: context: ./tradein-mvp/browser @@ -277,6 +535,41 @@ jobs: ${{ env.IMAGE_BROWSER }}:latest ${{ env.IMAGE_BROWSER }}:${{ github.sha }} + - name: Retry build & push tradein-browser без кеша (битый buildcache, #2841) + # См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to + # ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими + # слоями (mode=max), это и есть самолечение. + if: steps.build.outcome == 'failure' + uses: docker/build-push-action@v6 + with: + context: ./tradein-mvp/browser + push: true + cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max + tags: | + ${{ env.IMAGE_BROWSER }}:latest + ${{ env.IMAGE_BROWSER }}:${{ github.sha }} + + - name: Проверить, что tradein-browser:${{ github.sha }} реально в registry (fail-safe, #2841 R2) + # См. tradein-backend выше — не полагается на steps.build.outcome раннера. + run: docker buildx imagetools inspect ${{ env.IMAGE_BROWSER }}:${{ github.sha }} > /dev/null + + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) + # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. + # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на + # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в + # их `_state`-томах — диск ушёл на 94%, деплой упал с + # `no space left on device`. Убираем явно, `if: always()` и `|| true`, + # чтобы уборка не могла уронить прогон. + if: always() + run: | + name="${{ steps.buildx.outputs.name }}" + if [ -z "$name" ]; then + echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" + exit 0 + fi + echo "buildx: убираю билдер $name" + docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" + deploy: runs-on: ubuntu-latest needs: [changes, test, build-backend, build-frontend, build-browser] @@ -294,21 +587,32 @@ jobs: uses: appleboy/ssh-action@v1.0.3 env: IMAGE_TAG: latest + # Нужен на VPS, чтобы спросить у демона ID подтянутого образа и не + # уходить в drain, когда пересоздавать нечего (см. ниже, #2679). + IMAGE_BACKEND: ${{ env.IMAGE_BACKEND }} GHCR_PAT: ${{ secrets.GHCR_PAT }} - # Phase 0: generic infra edits (compose / workflow / deploy/**) must NOT - # recreate the scraper and SIGKILL a running multi-hour job. Only genuine - # scraper-code paths (the `scraper` paths-filter already covers - # app/services/scrapers/**, scrape_pipeline.py, scheduler.py, - # scheduler_main.py, app/tasks/**) — or a manual workflow_dispatch — - # should trigger a scraper recreate. (infra term intentionally dropped.) - SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || github.event_name == 'workflow_dispatch' }} + # #2679: backend / scraper / tgbot — ОДИН И ТОТ ЖЕ образ + # gendesign-tradein-backend (см. docker-compose.prod.yml: три сервиса, + # одна строка image, разный command). Значит вопрос «пересоздавать ли + # scraper» — это не «трогали ли его файлы», а «мог ли пересобраться + # образ». Условие ОБЯЗАНО совпадать с `if:` джобы build-backend: + # backend || infra || workflow_dispatch. Ровно тогда в реестре мог + # появиться новый :latest, и оставить scraper на старом — значит + # оставить планировщик на старом коде (инцидент #2679). + # + # Раньше здесь стоял «Phase 0»-компромисс: infra-правки намеренно НЕ + # пересоздавали scraper, чтобы не убить многочасовой прогон. Компромисс + # больше не нужен — с #1951 перед recreate'ом идёт graceful drain + # (ждём scrape_runs до 5 мин) + startup-reap осиротевших строк, а сам + # `compose up -d` на неизменившемся образе — no-op. + SCRAPER_RECREATE: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }} GITHUB_SHA: ${{ github.sha }} with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.DEPLOY_PORT }} - envs: IMAGE_TAG,GHCR_PAT,SCRAPER_CHANGED,GITHUB_SHA + envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA script: | set -euo pipefail cd /opt/gendesign @@ -431,6 +735,35 @@ jobs: done echo "All migrations applied." + # (3b) Невалидные индексы после цикла (#2752). Оборванный + # CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false: + # планировщик им НЕ пользуется (проверено — Seq Scan), а поддержка + # на записи всё равно платится. Молчит это так (воспроизведено на + # PostgreSQL 16.4): CIC упал → деплой красный, миграция не помечена + # применённой → следующий деплой прогоняет её заново → `CREATE INDEX + # CONCURRENTLY IF NOT EXISTS` видит битый индекс, печатает + # «relation already exists, skipping», выходит с кодом 0 → миграция + # помечается применённой, а индекс остаётся невалидным навсегда. + # Поэтому проверка не в каждом файле DO-блоком, а одна здесь: она + # ловит и этот путь, и невалидные индексы любого другого + # происхождения (отменённый job, ручной CIC оператором). + # На 2026-08-07 на проде таких индексов 0 — это профилактика. + invalid_idx=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ + psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \ + "SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \ + | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \ + || { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; } + if [ -n "$invalid_idx" ]; then + echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx" + echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой" + echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит" + echo " (тихо пропускает как существующий). Новый app-код НЕ поднят." + echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем" + echo " пересоздать индекс и повторить деплой." + exit 1 + fi + echo "✓ невалидных индексов нет." + # Bootstrap gendesign_reader password from env (post-migration, #976). # SQL migration 101_gendesign_reader_role.sql creates role passwordless; # password lives only in /opt/gendesign/tradein-mvp/.env.runtime. @@ -489,8 +822,43 @@ jobs: # с browser/backend/frontend, отдельного graceful-drain не требует. SERVICES="browser backend frontend tgbot" SCRAPER_STOP_TS="" - if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then - echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)" + scraper_stale="" + if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then + # Пересоздавать нечего — и ждать нечего (#2679). SCRAPER_RECREATE + # истинно и на infra-правках (compose / workflow / deploy/**), а те + # почти всегда собирают ТОТ ЖЕ образ по кэшу: digest не меняется, + # `up -d` выходит no-op — и платить за него пятиминутным drain'ом, + # прерывая многочасовой сбор, не за что. Сравниваем, на том ли + # образе бежит scraper, что уже лежит в локальном демоне. + # ПОРЯДОК ВАЖЕН: только ПОСЛЕ `docker compose pull` (шаг выше) — + # до pull'а под тегом :latest ещё старый образ, сравнение всегда + # «совпало» и drain пропускался бы как раз тогда, когда он нужен. + # Заодно чинит ложный startup-reap: чекпоинт/reap ниже завязаны на + # ЭТОТ же признак и больше не выполняются, когда recreate'а не было + # (иначе живой прогон с heartbeat старше чекпоинта помечался бы + # 'cancelled', продолжая работать). + pulled_image=$(docker image inspect -f '{{.Id}}' "$IMAGE_BACKEND:$IMAGE_TAG" 2>/dev/null || echo "") + running_image=$(docker inspect -f '{{.Image}}' tradein-scraper 2>/dev/null || echo "") + if [ -n "$pulled_image" ] && [ "$pulled_image" = "$running_image" ]; then + echo "→ образ scraper'а не изменился ($pulled_image) — пересоздавать нечего," + echo " drain пропускаем, in-flight прогоны не трогаем" + else + scraper_stale="yes" + fi + # scraper в $SERVICES в обоих случаях: при совпавшем образе `up -d` + # — no-op, но правка самого compose (env/лимиты сервиса) так всё же + # доезжает. Ceiling: такой config-only recreate идёт БЕЗ drain'а — + # страхуют SIGTERM-drain (#1182) + stop_grace_period 120s, а строку + # прогона подчистит периодический 6h zombie-reaper. + SERVICES="$SERVICES scraper" + else + echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем" + echo " (сверка образов ниже всё равно проверит, что он не отстал)" + fi + + if [ -n "$scraper_stale" ]; then + echo "→ новый backend-образ — scraper пересоздаётся вместе с backend (#2679);" + echo " ждём слива in-flight scrape_runs (до 5 мин)" drained="" for i in $(seq 1 30); do # NB: не сливать "psql не ответил" с "0 running" — иначе неудачный @@ -527,15 +895,11 @@ jobs: SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS="" echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}" - - SERVICES="$SERVICES scraper" - else - echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)" fi docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES - if [ "${SCRAPER_CHANGED:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then + if [ -n "$scraper_stale" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом" # NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до # сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD) @@ -653,7 +1017,7 @@ jobs: # снижает шанс поймать контейнер ровно в момент between-restarts # промежуточного "running" внутри crash-loop. # tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES); - # scraper — только когда SCRAPER_CHANGED (см. блок выше) — поэтому + # scraper — только когда SCRAPER_RECREATE (см. блок выше) — поэтому # проверяем только то, что реально входит в текущий $SERVICES. for svc in tgbot scraper; do case " $SERVICES " in @@ -683,6 +1047,49 @@ jobs: echo "→ tradein-$svc running." done + # Сверка образов backend-семейства (#2679) — последняя проверка перед + # маркером «задеплоено». backend/scraper/tgbot бегут ОДИН образ + # gendesign-tradein-backend; backend пересоздаётся на каждом деплое + # (безусловно в $SERVICES) и потому всегда несёт свежий :latest — + # он и есть эталон. Если у scraper или tgbot image ID другой, значит + # контейнер остался на старом коде, а деплой без этой проверки + # отчитался бы успехом: ровно инцидент 2026-08-05 (#2675 доехал до + # tradein-backend, ff98603ba3cc; tradein-scraper остался на + # da26154c64a6 часовой давности — а планировщик, единственный + # исполнитель домовой оценки, живёт именно там). + # Падаем, а не warning'уем: расхождение = правка не работает, и + # узнать об этом надо в момент деплоя, а не через месяц. exit 1 идёт + # ДО записи .tradein-deployed-sha → следующий прогон возьмёт ту же + # базу и пересоберёт всё накопленное (тот же приём, что в health-check). + # «Контейнера нет» и «контейнер отстал» — разные аварии и чинятся + # по-разному, поэтому сообщения различаются явно. + backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "") + image_mismatch="" + if [ -z "$backend_image" ]; then + echo "ERROR: контейнера tradein-backend нет — сверять образы не с чем." + image_mismatch="yes" + fi + for svc in scraper tgbot; do + svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "") + if [ -z "$svc_image" ]; then + echo "ERROR: контейнера tradein-$svc НЕТ (удалён или не создавался) — это не отставший" + echo " образ, а неполный стек: сервис не работает вообще." + image_mismatch="yes" + elif [ -n "$backend_image" ] && [ "$svc_image" != "$backend_image" ]; then + echo "ERROR: tradein-$svc ОТСТАЛ: работает на $svc_image, tradein-backend — на $backend_image" + image_mismatch="yes" + fi + done + if [ -n "$image_mismatch" ]; then + echo "ERROR: backend-семейство не на одном образе — деплой FAILED (#2679)." + echo " Лечение вручную (поднимет отсутствующие, пересоздаст отставшие):" + echo " docker compose -p gendesign-tradein \\" + echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\" + echo " up -d --force-recreate --no-deps backend scraper tgbot" + exit 1 + fi + echo "→ образы совпадают: backend/scraper/tgbot на $backend_image." + # Cleanup старых образов for repo in ghcr.io/lekss361/gendesign-tradein-backend \ ghcr.io/lekss361/gendesign-tradein-frontend; do @@ -697,3 +1104,33 @@ jobs: # The changes job reads this file on the next run to compute cumulative diff. echo "$GITHUB_SHA" > /opt/gendesign/.tradein-deployed-sha echo "→ Deployed SHA marker updated: $GITHUB_SHA" + + # Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:` + # молча (result=skipped), когда `test` или один из build-* падает (например, + # битый blob в buildcache роняет `docker/build-push-action` — до ретрая + # выше, #2841). skipped-job не красит прогон явным «FAILED» так, чтобы это + # было видно на первый взгляд — итог выглядит зелёным/нейтральным, хотя + # tradein-стек на проде не обновился. Эта job бежит ВСЕГДА (`if: always()`, + # кроме отмены прогона) и сама падает, если deploy не завершился success — + # неважно, пропущен он (test/build упали) или упал сам (SSH/миграция/ + # health-check/сверка образов #2679). Красная точка встаёт именно там, где + # решение реально принято, а не там, где она случайно оказалась по цепочке if. + deploy-status: + runs-on: ubuntu-latest + needs: [test, build-backend, build-frontend, build-browser, deploy] + if: always() && !cancelled() + steps: + - name: Итог прогона — деплой обязан быть success, не skipped/failure + run: | + echo "test: ${{ needs.test.result }}" + echo "build-backend: ${{ needs.build-backend.result }}" + echo "build-frontend: ${{ needs.build-frontend.result }}" + echo "build-browser: ${{ needs.build-browser.result }}" + echo "deploy: ${{ needs.deploy.result }}" + if [ "${{ needs.deploy.result }}" != "success" ]; then + echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \ + "Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \ + "Смотри логи test/build-backend/build-frontend/build-browser/deploy выше." + exit 1 + fi + echo "✓ деплой прошёл успешно" diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 9ed960aa..451a6bb4 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -16,6 +16,23 @@ on: - ".forgejo/workflows/deploy.yml" - "data/sql/**" - "ops/glitchtip-auth-forwarder/**" + # Bootstrap-SQL (создание БД auth, ALTER ROLE паролем из env) исполняется шагом + # деплоя ниже — без этого триггера правка bootstrap-файла молча не доезжала бы + # до прода до следующего чужого коммита в backend/. + - "ops/db-bootstrap/**" + # RBAC roles config (auth/roles.yaml, bind-mounted read-only ТОЛЬКО в backend — + # см. docker-compose.prod.yml; worker монтирует лишь ./data и ./reports). + # app.core.auth кэширует парсинг на весь lifetime процесса (@lru_cache) — без + # этого триггера правка ролей вступала бы в силу в случайный момент, только на + # следующий чужой деплой (`up -d --force-recreate --no-deps backend worker beat` + # ниже сбрасывает кэш перезапуском процесса; сам файл в образ не запекается, + # ребилда картинок для этого не нужно). + - "auth/**" + # То же самое, ровно тот же класс бага (#2887): скрипт запускается на VM + # по cron из /opt/gendesign/ops/, куда попадает только через `git reset --hard` + # шага деплоя. Без этой строки правка скрипта лежала бы в main, а cron месяцами + # исполнял бы старую версию — молча и без единого сигнала. + - "ops/docker-prune.sh" workflow_dispatch: concurrency: @@ -67,10 +84,49 @@ jobs: run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Подобрать протёкшие buildx-билдеры (#2869) + # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно + # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, + # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. + # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца + # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного + # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних + # созданы 13.08 11:57:43 — ровно тот прогон, что упал с + # `no space left on device`. + # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не + # принадлежит живому прогону (самый долгий job — ~17 минут). + run: | + now=$(date +%s); reaped=0; kept=0 + for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do + created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue + ts=$(date -d "$created" +%s 2>/dev/null) || continue + age_h=$(( (now - ts) / 3600 )) + if [ "$age_h" -ge 6 ]; then + echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" + if docker rm -f "$c" >/dev/null 2>&1; then + reaped=$((reaped+1)) + else + echo "buildx: не удалось убрать $c (не фатально)" + fi + docker volume rm "${c}_state" >/dev/null 2>&1 || true + else + kept=$((kept+1)) + fi + done + echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" + df -h / | tail -1 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + id: buildx - name: Build & push backend (lean — без Chromium) + # id + continue-on-error: битый blob в удалённом buildcache-манифесте + # (registry cache, не local) валит весь шаг ДО push нового образа — + # деплой тогда молча пропускается (#2841), хотя код собрать можно, просто + # без кеша. cache-from нефатален: при падении ретраим БЕЗ него ниже. + id: build + continue-on-error: true uses: docker/build-push-action@v6 with: context: ./backend @@ -82,6 +138,53 @@ jobs: ${{ env.IMAGE_BACKEND }}:latest ${{ env.IMAGE_BACKEND }}:${{ github.sha }} + - name: Retry build & push backend без кеша (битый buildcache, #2841) + # cache-from опущен (источник падения), а cache-to ОСТАВЛЕН: успешный + # ретрай пушит свежие слои в buildcache-тег и тем самым сам перезаписывает + # битый blob (mode=max — полная перезапись манифеста). Раньше cache-to был + # опущен и здесь тоже — но следующий обычный прогон опять получает cache-from + # на детерминированно битый тег и падает СНОВА: самолечения не было НИКОГДА + # (ревью #2841 R2, issue #2). Если и retry упадёт — шаг красный БЕЗ + # continue-on-error, job честно FAILURE, и deploy ниже корректно + # пропускается (уже настоящая причина, не кеш). + if: steps.build.outcome == 'failure' + uses: docker/build-push-action@v6 + with: + context: ./backend + target: runner + push: true + cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max + tags: | + ${{ env.IMAGE_BACKEND }}:latest + ${{ env.IMAGE_BACKEND }}:${{ github.sha }} + + - name: Проверить, что backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2) + # НЕ полагается на семантику steps.build.outcome/continue-on-error раннера — + # проверяет РЕАЛЬНОЕ состояние registry напрямую через buildx (уже настроен + # выше). Если act_runner не заполняет outcome (не проверено живым прогоном, + # см. ревью), ретрай выше молча НЕ побежит при упавшем build, а этот шаг — + # единственный, кто это заметит: манифеста с этим SHA не будет → шаг падает + # БЕЗ continue-on-error → job честно FAILURE → deploy ниже пропускается + # вместо накатки старого :latest на прод. + run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null + + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) + # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. + # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на + # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в + # их `_state`-томах — диск ушёл на 94%, деплой упал с + # `no space left on device`. Убираем явно, `if: always()` и `|| true`, + # чтобы уборка не могла уронить прогон. + if: always() + run: | + name="${{ steps.buildx.outputs.name }}" + if [ -z "$name" ]; then + echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" + exit 0 + fi + echo "buildx: убираю билдер $name" + docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" + build-worker: runs-on: ubuntu-latest needs: changes @@ -98,10 +201,47 @@ jobs: run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Подобрать протёкшие buildx-билдеры (#2869) + # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно + # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, + # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. + # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца + # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного + # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних + # созданы 13.08 11:57:43 — ровно тот прогон, что упал с + # `no space left on device`. + # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не + # принадлежит живому прогону (самый долгий job — ~17 минут). + run: | + now=$(date +%s); reaped=0; kept=0 + for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do + created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue + ts=$(date -d "$created" +%s 2>/dev/null) || continue + age_h=$(( (now - ts) / 3600 )) + if [ "$age_h" -ge 6 ]; then + echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" + if docker rm -f "$c" >/dev/null 2>&1; then + reaped=$((reaped+1)) + else + echo "buildx: не удалось убрать $c (не фатально)" + fi + docker volume rm "${c}_state" >/dev/null 2>&1 || true + else + kept=$((kept+1)) + fi + done + echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" + df -h / | tail -1 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + id: buildx - name: Build & push worker (с Chromium для Playwright) + # id + continue-on-error — см. build-backend выше (#2841): битый blob в + # удалённом buildcache не должен ронять сборку и молча пропускать деплой. + id: build + continue-on-error: true uses: docker/build-push-action@v6 with: context: ./backend @@ -113,6 +253,45 @@ jobs: ${{ env.IMAGE_WORKER }}:latest ${{ env.IMAGE_WORKER }}:${{ github.sha }} + - name: Retry build & push worker без кеша (битый buildcache, #2841) + # См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН — + # успешный ретрай перезаписывает битый buildcache-тег своими слоями + # (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась + # навсегда — следующий прогон снова падал на том же cache-from. + if: steps.build.outcome == 'failure' + uses: docker/build-push-action@v6 + with: + context: ./backend + target: runner-with-chromium + push: true + cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max + tags: | + ${{ env.IMAGE_WORKER }}:latest + ${{ env.IMAGE_WORKER }}:${{ github.sha }} + + - name: Проверить, что worker:${{ github.sha }} реально в registry (fail-safe, #2841 R2) + # См. backend выше — не полагается на steps.build.outcome раннера, проверяет + # реальное состояние registry, чтобы молча пропущенный ретрай (если outcome + # не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом. + run: docker buildx imagetools inspect ${{ env.IMAGE_WORKER }}:${{ github.sha }} > /dev/null + + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) + # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. + # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на + # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в + # их `_state`-томах — диск ушёл на 94%, деплой упал с + # `no space left on device`. Убираем явно, `if: always()` и `|| true`, + # чтобы уборка не могла уронить прогон. + if: always() + run: | + name="${{ steps.buildx.outputs.name }}" + if [ -z "$name" ]; then + echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" + exit 0 + fi + echo "buildx: убираю билдер $name" + docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" + build-frontend: runs-on: ubuntu-latest needs: changes @@ -129,10 +308,47 @@ jobs: run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Подобрать протёкшие buildx-билдеры (#2869) + # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно + # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, + # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. + # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца + # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного + # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних + # созданы 13.08 11:57:43 — ровно тот прогон, что упал с + # `no space left on device`. + # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не + # принадлежит живому прогону (самый долгий job — ~17 минут). + run: | + now=$(date +%s); reaped=0; kept=0 + for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do + created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue + ts=$(date -d "$created" +%s 2>/dev/null) || continue + age_h=$(( (now - ts) / 3600 )) + if [ "$age_h" -ge 6 ]; then + echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" + if docker rm -f "$c" >/dev/null 2>&1; then + reaped=$((reaped+1)) + else + echo "buildx: не удалось убрать $c (не фатально)" + fi + docker volume rm "${c}_state" >/dev/null 2>&1 || true + else + kept=$((kept+1)) + fi + done + echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" + df -h / | tail -1 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + id: buildx - name: Build & push frontend + # id + continue-on-error — см. build-backend выше (#2841): битый blob в + # удалённом buildcache не должен ронять сборку и молча пропускать деплой. + id: build + continue-on-error: true uses: docker/build-push-action@v6 with: context: ./frontend @@ -146,6 +362,47 @@ jobs: ${{ env.IMAGE_FRONTEND }}:latest ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + - name: Retry build & push frontend без кеша (битый buildcache, #2841) + # См. backend (issue #2, ревью R2): cache-from опущен, cache-to ОСТАВЛЕН — + # успешный ретрай перезаписывает битый buildcache-тег своими слоями + # (mode=max), это и есть самолечение. Без cache-to здесь порча оставалась + # навсегда — следующий прогон снова падал на том же cache-from. + if: steps.build.outcome == 'failure' + uses: docker/build-push-action@v6 + with: + context: ./frontend + push: true + build-args: | + NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }} + NEXT_PUBLIC_ENVIRONMENT=production + cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max + tags: | + ${{ env.IMAGE_FRONTEND }}:latest + ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} + + - name: Проверить, что frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2) + # См. backend выше — не полагается на steps.build.outcome раннера, проверяет + # реальное состояние registry, чтобы молча пропущенный ретрай (если outcome + # не поддержан) честно уронил job вместо зелёного прогона с непушнутым образом. + run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null + + - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) + # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. + # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на + # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в + # их `_state`-томах — диск ушёл на 94%, деплой упал с + # `no space left on device`. Убираем явно, `if: always()` и `|| true`, + # чтобы уборка не могла уронить прогон. + if: always() + run: | + name="${{ steps.buildx.outputs.name }}" + if [ -z "$name" ]; then + echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" + exit 0 + fi + echo "buildx: убираю билдер $name" + docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" + deploy: runs-on: ubuntu-latest needs: [changes, build-backend, build-worker, build-frontend] @@ -305,6 +562,31 @@ jobs: done echo "All migrations applied." + # Невалидные индексы после цикла (#2752). Оборванный CREATE INDEX + # CONCURRENTLY оставляет индекс с indisvalid=false: планировщик им НЕ + # пользуется, а re-run миграции его не чинит — `CREATE INDEX + # CONCURRENTLY IF NOT EXISTS` печатает «relation already exists, + # skipping» и выходит с кодом 0, после чего миграция помечается + # применённой, а индекс остаётся битым навсегда (воспроизведено на + # PostgreSQL 16.4). В data/sql 5 файлов с CREATE INDEX CONCURRENTLY. + # Одна проверка здесь вместо DO-блока в каждом файле; на 2026-08-07 + # на проде таких индексов 0 — это профилактика. + invalid_idx=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \ + "SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \ + | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \ + || { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; } + if [ -n "$invalid_idx" ]; then + echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx" + echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой" + echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит" + echo " (тихо пропускает как существующий). Новый app-код НЕ поднят." + echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем" + echo " пересоздать индекс и повторить деплой." + exit 1 + fi + echo "✓ невалидных индексов нет." + # Set tradein_fdw_reader password from env (post-migration bootstrap). # SQL migration 100_tradein_fdw_role.sql creates role passwordless; # password lives only in /opt/gendesign/backend/.env.runtime. @@ -320,6 +602,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 если файлы не менялись. @@ -374,8 +720,50 @@ jobs: docker image prune -af || true docker builder prune -af || true - # Health check + # Health check — деплой ВАЛИТСЯ, если backend не поднялся (см. #2214, + # уже сделано так в deploy-tradein.yml; ревью #2841 R2 issue #3). + # `curl ... && break` под set -e НЕ мог провалить скрипт: curl — не + # последняя команда &&-списка, а POSIX прямо освобождает от errexit + # все команды AND/OR-списка кроме последней. После 30 неуспешных + # попыток цикл завершался кодом последнего sleep (0) — скрипт тихо + # продолжался, деплой уходил success с мёртвым бэкендом. + healthy="" for i in $(seq 1 30); do - curl -fsS http://localhost:8000/health && break + if curl -fsS http://localhost:8000/health >/dev/null 2>&1; then + healthy="yes"; break + fi sleep 1 done + if [ -z "$healthy" ]; then + echo "ERROR: backend не ответил на /health за 30s — деплой FAILED" + exit 1 + fi + echo "→ backend healthy на /health." + + # Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:` + # молча (result=skipped), когда build падает (например, битый blob в + # buildcache роняет `docker/build-push-action` — до ретрая выше, #2841). + # skipped-job НЕ красит прогон явным «FAILED» так, чтобы это было видно на + # первый взгляд — итог выглядит зелёным/нейтральным, хотя прод не обновился. + # Эта job бежит ВСЕГДА (`if: always()`, кроме отмены прогона) и сама падает, + # если deploy не завершился success — неважно, пропущен он (build упал) или + # упал сам (SSH/миграция/health-check). Красная точка встаёт именно там, где + # решение реально принято, а не там, где она случайно оказалась по цепочке if. + deploy-status: + runs-on: ubuntu-latest + needs: [build-backend, build-worker, build-frontend, deploy] + if: always() && !cancelled() + steps: + - name: Итог прогона — деплой обязан быть success, не skipped/failure + run: | + echo "build-backend: ${{ needs.build-backend.result }}" + echo "build-worker: ${{ needs.build-worker.result }}" + echo "build-frontend: ${{ needs.build-frontend.result }}" + echo "deploy: ${{ needs.deploy.result }}" + if [ "${{ needs.deploy.result }}" != "success" ]; then + echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \ + "Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \ + "Смотри логи build-backend/build-worker/build-frontend/deploy выше." + exit 1 + fi + echo "✓ деплой прошёл успешно" 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/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index a2c3ed7e..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: CI - -on: - push: - branches: - - main - - 'feat/**' - - 'fix/**' - - 'refactor/**' - - 'chore/**' - - 'docs/**' - - 'perf/**' - - 'test/**' - - 'hotfix/**' - pull_request: - branches: [main] - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - backend: - runs-on: ubuntu-latest - services: - postgres: - image: postgis/postgis:16-3.4 - env: - POSTGRES_DB: gendesign - POSTGRES_USER: gendesign - POSTGRES_PASSWORD: gendesign - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U gendesign" - --health-interval 5s - --health-timeout 5s - --health-retries 10 - defaults: - run: - working-directory: backend - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v3 - with: - enable-cache: true - - - name: Set up Python - run: uv python install 3.12 - - - name: Install system deps for geo + WeasyPrint - run: | - sudo apt-get update - sudo apt-get install -y libpq-dev libgdal-dev libproj-dev libgeos-dev \ - libcairo2 libpango-1.0-0 libpangoft2-1.0-0 - - - name: Install Python deps - run: uv sync - - - name: Lint (ruff) - run: uv run ruff check . - - - name: Type check (mypy strict on core) - run: | - uv run mypy \ - app/services/generative \ - app/services/site_finder/scorer.py - - - name: Test (pytest) - run: uv run pytest -q - env: - DATABASE_URL: postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign - - frontend: - runs-on: ubuntu-latest - defaults: - run: - working-directory: frontend - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: frontend/package-lock.json - - run: npm ci || npm install - - run: npm run lint - - run: npm run type-check - - run: npm run build diff --git a/Caddyfile b/Caddyfile index 7e7ce21a..d00dd0e5 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,153 @@ 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. Повторные неудачные попытки ДО +# появления 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 + } + } + + # Короткие адреса юридических документов. Именно они напечатаны ВНУТРИ + # самих документов (оферта ссылается на meraocenka.ru/refund, политика + # возврата — на meraocenka.ru/oferta) и уходят в заявку эквайеру, поэтому + # обязаны резолвиться сами по себе, а не только длинным + # /trade-in/mera-public/. Обратное направление тоже рабочее: длинный + # путь ловит handle ниже — навигация внутри сайта ходит по нему, потому что + # то же поддерево открывается и с gendsgn.ru/trade-in/mera-public, где + # короткого /oferta нет. + # + # `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким + # — модератор эквайера открывает ссылку из заявки и видит ровно тот URL, + # который в ней указан. Каноничность для поисковиков задана отдельно, через + # `alternates.canonical` на каждой из трёх страниц. + # + # Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого + # site-блока — часть периметра (#2545), и превращать его в «любой корневой + # путь проксируется» ради трёх страниц нельзя. + @meraLegalDocs path /oferta /refund /privacy + handle @meraLegalDocs { + rewrite * /trade-in/mera-public{path} + reverse_proxy tradein-frontend:3000 { + 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). @@ -164,25 +369,6 @@ errors.gendsgn.ru { } } -# Uptime Kuma — self-hosted uptime monitoring + public status page (#75 B6-1). -# DNS: A-record status.gendsgn.ru → IP VPS (добавить перед деплоем стека). -# Контейнер из docker-compose.uptime.yml (project gendesign-uptime) на shared -# gendesign_shared network. Если стек не запущен — Caddy отдаёт 502 ТОЛЬКО на -# этом домене, main-сайт не страдает (как obsidian.gendsgn.ru). -# -# ВНИМАНИЕ: status-page НАМЕРЕННО публичен (trust-building для пилотов, issue #75). -# Admin-панель Kuma (/dashboard, /manage-*) защищена собственным логином Kuma — -# НЕ кладём её за caddy/users.caddy.snippet, иначе double-auth сломает setup. -status.gendsgn.ru { - encode zstd gzip - - reverse_proxy uptime-kuma:3001 - - log { - output file /var/log/caddy/status.gendsgn.ru.log - } -} - # Forgejo — self-hosted git (migration 2026-05-16). # DNS: A-record git.gendsgn.ru → IP VPS. # Forgejo container из forgejo-migration/docker-compose.yml на shared diff --git a/README.md b/README.md index 36f5925d..2a1acaa7 100644 --- a/README.md +++ b/README.md @@ -85,12 +85,10 @@ docker-compose.prod.yml main стек (backend, frontend, postgres, redis, work docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru) — отдельный стек, запуск вручную .forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026) - ├── ci.yml lint (ruff) + mypy + pytest на PR + ├── ci.yml lint (ruff) + pytest на PR ├── deploy.yml main → пересборка backend/frontend образов + auto-apply data/sql/*.sql + SSH deploy ├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн + свой _schema_migrations) └── stale-claims.yml авто-снятие протухших claim-меток в bot-пайплайне -.github/workflows/ (остаточные — только obsidian-стек на GitHub) - └── deploy-obsidian.yml obsidian-стек (CouchDB compose changes + bootstrap) ``` --- @@ -158,7 +156,7 @@ docker-compose.uptime.yml Uptime Kuma мониторинг (status.gendsgn.ru **Forgejo Actions deploys** (self-hosted `git.gendsgn.ru`, мигрировано с GitHub Actions 16.05.2026): -- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + mypy (selective strict) + pytest. Блокирует merge при провале. +- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + pytest (coverage gate ≥65%). mypy strict в гейте не гоняется (доступен вручную — `uv run mypy app/services/generative app/services/site_finder/scorer.py`). Блокирует merge при провале. - [`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/**`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, **auto-apply pending `data/sql/NN_*.sql` через `_schema_migrations`** (idempotent, см. ниже про миграции), sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`. - [`.forgejo/workflows/deploy-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн). - [`.forgejo/workflows/deploy-obsidian.yml`](.forgejo/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты). *(до 2026-07-05 ошибочно лежал в `.github/workflows/` — там ни разу не исполнился, см. issue #2416; контейнер держался вручную.)* 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/.env.example b/backend/.env.example index d17d9dd7..192a52d7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -27,7 +27,7 @@ SCRAPE_KN_JITTER_SECONDS=1800 SCRAPE_KN_DEFAULT_REGIONS=66 # Путь к Playwright storage_state.json (commited в git, обновляется --save-state). SCRAPE_KN_STATE_PATH=data/playwright_state.json -# DEPRECATED 2026-05-23: app-level admin auth removed (PR #436, Caddy basic_auth достаточен). -# Reinstate: revert changes in admin_*.py чтобы вернуть AdminTokenAuth dep. -# Переменная сохранена в core/deps.py для быстрого rollback. -SCRAPE_ADMIN_TOKEN= +# SCRAPE_ADMIN_TOKEN удалён в #2775. App-level admin-auth сняли ещё в PR #437, +# а поле держали «для быстрого rollback» — за полтора месяца у него не появилось +# ни одного вызывающего. `/api/v1/admin/*` закрыт middleware rbac_guard +# (app/main.py, role != admin → 403) + Caddy basic_auth (PR #426). diff --git a/backend/.gitignore b/backend/.gitignore index 6350e986..b073c123 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1 +1,3 @@ .coverage +# Артефакт локального прогона с --cov-report=xml (1.2 МБ) — чуть не уехал в коммит. +coverage.xml diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 74bfb5c9..3a8a233a 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2189,12 +2189,21 @@ def analyze_parcel( _effective_weights = {**_POI_WEIGHTS, **_inline_weights} _weights_source = "inline" else: - _effective_weights = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id) - _weights_source = ( - "profile" - if profile_id is not None - else ("user_default" if profile_user_id is not None else "system") - ) + # Метка — из РЕЗУЛЬТАТА резолва, не из того, что клиент прислал (#2811): + # profile_id мог не найтись (нет owner'а в запросе / чужой / удалён), и + # тогда веса системные или дефолтные, а не профильные. + _resolved = _resolve_weights(db, user_id=profile_user_id, profile_id=profile_id) + _effective_weights = _resolved.weights + _weights_source = _resolved.source + + # «Что просили» vs «что получилось»: profile_id echo'ит запрос, флаг говорит, + # был ли запрос удовлетворён. Отдельное поле, а не подмена source на "system" — + # иначе пропадёт разница «профиль не запрашивали» / «запрашивали, но не нашли». + # None когда profile_id не передавали; False когда передали, но применилось + # другое (не найден / чужой / перебит inline-весами). + _requested_profile_applied: bool | None = ( + None if profile_id is None else _weights_source == "profile" + ) # 4) Scoring: weighted sum с distance decay score = 0.0 @@ -2310,12 +2319,31 @@ def analyze_parcel( -- (303 строки = 303 distinct) → COUNT(*) по дедуп-физлотам корректен. SELECT np.domrf_obj_id, - ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub, + -- #2464-D: границы правдоподобия, как в двух соседних запросах + -- по этой же таблице (BETWEEN 30000 AND 600000) — здесь их не было. + -- Замер 13.08 по проду ЧЕРЕЗ ЭТОТ ЖЕ ПУТЬ (physflat-дедуп + + -- маппинг на domrf_obj_id): вне диапазона 204 лота из 2 279 827, + -- из них 118 в 10 замапленных проектах и 86 — в незамапленных. + -- Эффект сегодня МАЛЫЙ: меняются 6 проектов из 308, худший на + -- 2.4%, market_avg_price (среднее средних) 138 056 → 138 008; + -- NULL не появляется нигде. Ставим границы не ради этих 48 ₽, + -- а потому что среднее считается ПО ПРОЕКТУ и один лот держит + -- группу без ограничения сверху: максимум в таблице — + -- 19 198 429 ₽/м² (ЖК «Дебют»), и он вне экрана только потому, + -- что проект пока не замаплен (замаплено 308 имён из 881, список + -- растёт). Одна строка маппинга — и это число на экране. + -- FILTER, а не WHERE: строки нужны целиком, иначе поедут + -- units_sold / units_available, считающие ВСЕ лоты. + ROUND(AVG(oll.price_per_m2_rub) FILTER ( + WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000 + )::numeric, 0) AS avg_price_per_m2_rub, ROUND(AVG(oll.area_pd)::numeric, 1) AS avg_area_pd, COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold, COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available, + -- Считаем ТУ ЖЕ популяцию, что кормит среднее: иначе счётчик + -- обещал бы выборку шире, чем на самом деле участвовала. COUNT(*) FILTER ( - WHERE oll.price_per_m2_rub IS NOT NULL + WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000 ) AS lots_with_price FROM nearby_projects np JOIN obj_lots_latest oll @@ -4085,9 +4113,12 @@ def analyze_parcel( # (None когда вердикт позитивный / нет площади / считать нечего). caveat внутри. "program_alternatives": program_alternatives, # #114/#201: кастомные веса POI — source + applied dict для прозрачности. + # source — что ФАКТИЧЕСКИ применилось; requested_profile_applied — был ли + # удовлетворён запрошенный profile_id (#2811). None = профиль не запрашивали. "weights_profile": { "source": _weights_source, "profile_id": profile_id, + "requested_profile_applied": _requested_profile_applied, "user_id": profile_user_id, "weights_applied": _effective_weights, "inline_weights": _inline_weights, @@ -4203,6 +4234,7 @@ def analyze_parcel( "profile_user_id": profile_user_id, "inline_weights": _inline_weights, "weights_source": _weights_source, + "requested_profile_applied": _requested_profile_applied, "x_session_id": _session_id, }, district=_district_name, 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..0b9ce491 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") @@ -110,9 +146,6 @@ class Settings(BaseSettings): # Path to a pre-captured Playwright storage_state.json (committed in repo, # used by worker to skip cold-start WAF challenge). scrape_kn_state_path: str = "data/playwright_state.json" - # Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls. - # Empty string = endpoint disabled. - scrape_admin_token: str = "" # ── #1945 KN-loader anti-ban (throttle + optional proxy) ────────────────── # DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ @@ -371,5 +404,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/core/deps.py b/backend/app/core/deps.py deleted file mode 100644 index 75fd3b06..00000000 --- a/backend/app/core/deps.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Shared FastAPI dependencies.""" - -from typing import Annotated - -from fastapi import Depends, Header, HTTPException, status - -from app.core.config import settings - - -def verify_admin_token( - x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None, -) -> None: - """Verify admin token header. Raises 503 if not configured, 401 if invalid or missing.""" - if not settings.scrape_admin_token: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="admin disabled — set SCRAPE_ADMIN_TOKEN", - ) - if x_admin_token != settings.scrape_admin_token: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid admin token") - - -AdminTokenAuth = Annotated[None, Depends(verify_admin_token)] diff --git a/backend/app/main.py b/backend/app/main.py index a13903ed..5f6507ed 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.observability.sentry_scrub import scrub_event +from app.services.auth_session import resolve_session_token logger = logging.getLogger(__name__) @@ -70,6 +75,11 @@ if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers): # (middleware, маршруты) видели активный client с самого старта процесса. # GlitchTip не поддерживает profiling — profiles_sample_rate=0.0. if settings.glitchtip_dsn: + # before_send И before_send_transaction — ОБА на scrub_event (#2457-review): + # Starlette-интеграция кладёт request.data на transaction-scope так же, как + # на error-scope, поэтому голый scrub_sensitive_query (только URL) на + # before_send_transaction оставлял бы PII-канал открытым при любом + # glitchtip_traces_sample_rate > 0 (см. sentry_scrub.py module docstring). sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, @@ -77,8 +87,14 @@ if settings.glitchtip_dsn: traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, - before_send=scrub_sensitive_query, - before_send_transaction=scrub_sensitive_query, + # Локальные переменные кадров стека НЕ уходят в мониторинг (#2753). + # Дефолт SDK — True: при любом исключении кадр несёт значения аргументов + # (телефон заявки, адрес, токен) под ПРОИЗВОЛЬНЫМИ именами, а scrub_event + # сверяет ИМЕНА ключей — такое он не ловит по построению. То есть это не + # дополнительная мера, а условие, без которого скраб не полон. + include_local_variables=False, + before_send=scrub_event, + before_send_transaction=scrub_event, integrations=[ StarletteIntegration(), FastApiIntegration(), @@ -97,6 +113,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 +150,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 +364,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 +382,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"}, @@ -222,3 +508,24 @@ async def health() -> dict[str, str]: "environment": settings.environment, "version": app.version, } + + +# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от +# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health +# отдаёт 405. Это боевой прод-эндпоинт: Caddyfile:60 `handle /health { +# reverse_proxy backend:8000 }` — именно ЭТОТ хендлер отвечает на +# `HEAD https://gendsgn.ru/health`, которым бьёт внешний uptime-monitor +# (GlitchTip PING-тип шлёт HEAD, не GET) и не мог отличить "жив" от "мёртв" по +# статусу. media_type="application/json" — Content-Type совпадает с GET; +# Content-Length сознательно НЕ вычисляем под байт GET-ответа (пришлось бы +# дублировать сборку payload) — RFC 9110 §9.3.2 разрешает опускать payload- +# заголовки (Content-Length) для HEAD, требует совпадения только заголовков +# представления (Content-Type). +# include_in_schema=False: HEAD-проба — инфраструктура (uptime-monitor), а не часть +# контракта, по которому фронт генерирует типы. Без этого флага операция попадает в +# app.openapi(), и job `openapi-codegen-check` краснеет, требуя перегенерации +# frontend/src/types/api-types.ts — правки в сгенерированном файле ради маршрута, +# который фронт никогда не вызывает. +@app.head("/health", include_in_schema=False) +async def health_head() -> Response: + return Response(status_code=200, media_type="application/json") diff --git a/backend/app/observability/sentry_scrub.py b/backend/app/observability/sentry_scrub.py index af936c31..66f9c2cc 100644 --- a/backend/app/observability/sentry_scrub.py +++ b/backend/app/observability/sentry_scrub.py @@ -1,17 +1,72 @@ -"""Хук before_send_transaction для GlitchTip/Sentry SDK. +"""Хуки before_send / before_send_transaction для GlitchTip/Sentry SDK. -Redact-ит api keys / tokens из URL-spans перед отправкой — чтобы -секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip -через HttpxIntegration performance-spans. +`scrub_sensitive_query` — redact-ит api keys / tokens из URL-spans перед +отправкой — чтобы секреты (apiKey=..., api_key=..., token=...) не утекали в +GlitchTip через HttpxIntegration performance-spans. + +`scrub_pii_event` — redact-ит consumer-PII (client_name / client_phone / +client_email / phone / email / name / company / message) из events перед +отправкой. `send_default_pii=False` в sentry_sdk.init (проверено на +sentry-sdk 2.58) НЕ покрывает эти поля — это user-data, попадающий в +request.data / extra / contexts (pilot-заявки — `PilotRequestInput` в +`app/api/v1/pilot.py` несёт все 6 полей включая свободный текст `company`/ +`message`, куда чаще всего прилетают телефоны/имена/адреса; чат — свободный +вопрос в `app/schemas/chat.py`), а не PII-заголовки/cookies, которые режет +сам флаг. Портировано из trade-in (`tradein-mvp/backend/app/observability/ +sentry_scrub.py`, #396) — тот же набор ключей (client_name/client_phone/ +client_email — Птица их не использует сегодня, но одинаковый механизм на +оба продукта проще сопровождать), плюс `company`/`message`, специфичные для +`PilotRequestInput` (#2457-review). + +`scrub_event` — composed-хендлер (PII-scrub + URL-secret redact), которым +надо вешать ОБА канала — `before_send` И `before_send_transaction`. +Starlette-интеграция кладёт тело запроса в `request_info["data"]` на +transaction-scope точно так же, как на error-scope (scope-обработчики для +transactions НЕ пропускаются — пропуск бывает только на availability-чеках). +Если повесить PII-scrub только на `before_send`, а `before_send_transaction` +оставить на голом `scrub_sensitive_query` — PII продолжит течь через +transaction-канал при любом `glitchtip_traces_sample_rate > 0` (#2457-review, +воспроизведено: pilot-заявка с реальными данными → ~1/20 попадает в +транзакцию с полным телом). """ from __future__ import annotations +import logging import re from typing import Any +from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.types import Event +logger = logging.getLogger(__name__) + +# Собственный сбой скраба НЕ должен становиться событием мониторинга (#2753). +# LoggingIntegration (event_level=ERROR) превратила бы строку журнала об отказе +# в новое событие, которое снова пойдёт через этот же обработчик; при +# детерминированном сбое это рекурсия — защиты от неё в SDK нет (проверено: +# 1000+ вложенных трассировок за минуту, процесс не завершается). Диагностика +# остаётся в stdout контейнера: текст трассировки значений переменных не несёт. +ignore_logger(__name__) + +_REDACTED = "[REDACTED]" +# Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ +# (client_name/client_phone/client_email/phone/email/name, #396) + company/ +# message — специфичные для PilotRequestInput (app/api/v1/pilot.py) поля +# свободного текста (#2457-review). +_PII_KEYS = frozenset( + { + "client_name", + "client_phone", + "client_email", + "phone", + "email", + "name", + "company", + "message", + } +) + _SENSITIVE_PARAM_RE = re.compile( r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)", re.IGNORECASE, @@ -47,3 +102,63 @@ def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None: request["url"] = _redact(request["url"]) return event + + +def _scrub(obj: Any) -> None: + """Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place).""" + if isinstance(obj, dict): + for key, value in obj.items(): + if isinstance(key, str) and key.lower() in _PII_KEYS: + obj[key] = _REDACTED + else: + _scrub(value) + elif isinstance(obj, list): + for item in obj: + _scrub(item) + + +def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None: + """Redact consumer-PII (см. `_PII_KEYS`) из event (error ИЛИ transaction) + перед отправкой в GlitchTip. + + Обходит `request.data` / `extra` / `contexts` рекурсивно (dict/list), + заменяет значения PII-ключей на [REDACTED] in-place. Возвращает event + (не None) — иначе SDK дропнет отчёт целиком. + """ + if not isinstance(event, dict): + return event + request = event.get("request") + if isinstance(request, dict): + _scrub(request.get("data")) + _scrub(event.get("extra")) + _scrub(event.get("contexts")) + return event + + +def scrub_event(event: Event, hint: dict[str, Any]) -> Event | None: + """Composed `before_send` / `before_send_transaction` handler: PII-scrub + + URL query-secret redact. Вешать ОДИНАКОВО на оба канала — см. module + docstring (#2457-review): transaction-scope несёт `request.data` точно так + же, как error-scope. + + try/except — предохранитель: sentry_sdk оборачивает вызов `before_send` в + `capture_internal_exceptions`, который при исключении внутри хендлера + ТОЛЬКО логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что + редактор упал, — event просто не уйдёт). Наблюдаемость важнее полноты + покрытия редактора: лучше отправить событие в состоянии "сколько успели + отредактировать до сбоя", чем не отправить вообще и молча остаться без + сигнала в мониторинге. + """ + try: + scrub_pii_event(event, hint) + scrub_sensitive_query(event, hint) + except Exception as exc: + # Ни трассировки, ни str(exc): и то и другое способно нести значения из + # ЕЩЁ НЕ ОЧИЩЕННОГО event — то есть страховка утекла бы ровно то, что + # защищает (#2753). Имя класса исключения данных не несёт. Событием + # мониторинга эта строка не станет — см. ignore_logger выше. + logger.error( + "sentry_scrub.scrub_event: handler failed (%s), sending event as-is", + type(exc).__name__, + ) + return event 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/app/services/cadastre/bulk_harvest.py b/backend/app/services/cadastre/bulk_harvest.py index 905d15de..8d89b0da 100644 --- a/backend/app/services/cadastre/bulk_harvest.py +++ b/backend/app/services/cadastre/bulk_harvest.py @@ -30,7 +30,12 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.schemas.nspd_bulk import NSPDBulkFeature, QuarterSnapshot -from app.scrapers.nspd_bulk_client import NSPDBulkClient, NspdBulkServerError +from app.scrapers.nspd_bulk_client import ( + NSPDBulkClient, + NspdBulkRateLimitError, + NspdBulkServerError, + NspdBulkWafError, +) from app.services.cadastre.grid_geometry import generate_grid_click_points, quarter_bbox_3857 logger = logging.getLogger(__name__) @@ -182,6 +187,13 @@ async def harvest_quarter( try: cat_snapshot = await client.search_by_quarter(quarter, category_id=cat_id) result.snapshot_requests += 1 + except (NspdBulkWafError, NspdBulkRateLimitError): + # #2464-A: бан IP / исчерпанные ретраи — НЕ «этот cat не дошёл». + # Контракт harvest_quarter (Raises:) обещает пробросить их наверх, + # а голый except ниже их глотал: прогон доходил до status='done' + # с частичными данными. Прод-замер 13.08: 23 job'а, 50 WAF-блоков, + # 0 упавших — то есть бан ни разу не остановил сбор. + raise except Exception as e: logger.warning( "harvest_quarter: per-cat probe failed cat=%d quarter=%s: %s", @@ -279,6 +291,9 @@ async def harvest_quarter( logger.info( "harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count ) + except (NspdBulkWafError, NspdBulkRateLimitError): + # #2464-A: см. выше — бан пробрасываем, а не превращаем в «слой пуст». + raise except Exception as e: logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e) @@ -399,6 +414,18 @@ async def _grid_walk_category( requests += 1 server_errors += 1 continue + except (NspdBulkWafError, NspdBulkRateLimitError): + # #2464-A: 403 WAF — бан IP, а не «этот cell не дошёл». Продолжать + # обход значит углублять бан и дописать в БД ложный нулевой слой. + # Зеркало уже исправленных nspd_bulk_client.get_features_in_bbox_grid + # и nspd_client.get_features_in_bbox_grid (#2464-G). + logger.warning( + "_grid_walk_category: WAF/rate-limit layer=%d quarter=%s cell=%d — прерываем", + layer_id, + quarter, + idx, + ) + raise except Exception as e: # Прочие (сетевые / parse) ошибки одного cell — тоже не валим квартал, # но это НЕ server-side 500 → не учитываем в server_errors (иначе сеть diff --git a/backend/app/services/exporters/full_report_docx.py b/backend/app/services/exporters/full_report_docx.py index d85d24ca..6d6f139e 100644 --- a/backend/app/services/exporters/full_report_docx.py +++ b/backend/app/services/exporters/full_report_docx.py @@ -869,7 +869,7 @@ def _build_financial_cascade(doc: _DocxDocument, financial: dict[str, Any]) -> N ["Земля", _fmt_money_signed(financial.get("land_rub"))], ["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))], ["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))], - ["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))], + ["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))], ["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))], ["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))], ["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))], diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py index c71725af..6087e4a4 100644 --- a/backend/app/services/exporters/full_report_html.py +++ b/backend/app/services/exporters/full_report_html.py @@ -1338,7 +1338,7 @@ def _build_financial_cascade(financial: dict[str, Any]) -> str: ["Земля", _fmt_money_signed(financial.get("land_rub"))], ["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))], ["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))], - ["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))], + ["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))], ["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))], ["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))], ["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))], diff --git a/backend/app/services/forecasting/confidence_engine.py b/backend/app/services/forecasting/confidence_engine.py index 4ef5b203..3e4aaec3 100644 --- a/backend/app/services/forecasting/confidence_engine.py +++ b/backend/app/services/forecasting/confidence_engine.py @@ -3,10 +3,10 @@ #990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/ #985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов, -покрытие domrf↔objective, глубина истории, шок-окно) в ОДИН отчётный уровень +покрытие рынка ценами Objective, глубина истории, шок-окно) в ОДИН отчётный уровень High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог / -покрытие domrf↔objective 2.5%»). Наполняет слот `ReportConfidence` отчёта #987. +цена известна у 12% ближних ЖК»). Наполняет слот `ReportConfidence` отчёта #987. ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/print/вычислений §9.x — движок ЧИСТЫЙ: берёт уже-посчитанные входы (их кормит сборщик #988) и только @@ -28,8 +28,10 @@ High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, мало сделок → скоростные метрики статистически ненадёжны. • analog_count (ЖК-аналоги, = market_metrics.obj_count) — high≥3 / medium≥2 / 1 → low (точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» — ТЗ §15-пример). - • domrf_coverage — главный риск проекта (domrf↔objective ~2.5%, см. market_metrics - docstring): низкое покрытие → скрытый/будущий слой §9.3 недооценён. + • domrf_coverage — имя историческое: фактически это доля БЛИЖНИХ ЖК (3 км) с ценой + из Objective (`analyze.market_data_coverage_pct`), а не покрытие маппинга + domrf↔objective. Продьюсера для второго нет и не было (#2464-H). Прод 13.08: + медиана 40%, среднее 31.7%. Низкое покрытие → рынок и конкуренция оценены хуже. • history_months — зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / _MIN_OBS=8: короткий ряд → связь rate↔sales / тренды не установлены. • confounded — шок-окно (is_confounded_window, PR2): ряд пересекает структурный @@ -91,9 +93,11 @@ _DEAL_COUNT_LOW: int = 15 _ANALOG_COUNT_HIGH: int = 3 _ANALOG_COUNT_LOW: int = 2 # < этого (т.е. ≤1 ЖК) → low -# domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта ~2.5%). -# high — покрытие плотное; low — слой §9.3 (скрытое/будущее) недооценён. medium-порог -# созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6 (доверяем при покрытии большинства). +# domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1] (имя ключа историческое, +# см. _coverage_factor). high — покрытие плотное; low — рынок оценён по меньшинству ЖК. +# medium-порог созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6. +# NB: пороги подбирались под ожидавшиеся ~2.5% покрытия маппинга, а реальная величина +# другого порядка (медиана 40%) — их стоит пересмотреть отдельно, замером, а не на глаз. _DOMRF_COVERAGE_HIGH: float = 0.6 _DOMRF_COVERAGE_LOW: float = 0.2 @@ -252,23 +256,36 @@ _QUALITY_WORD: dict[Confidence, str] = { def _coverage_factor(coverage: float | None) -> ConfidenceFactor: - """domrf↔objective покрытие ∈ [0,1] → ConfidenceFactor с % в ноте. PURE. + """Покрытие рынка ценами Objective ∈ [0,1] → ConfidenceFactor с % в ноте. PURE. - Главный sparse-риск проекта (~2.5%). Нота показывает покрытие В ПРОЦЕНТАХ - (структурный §15-пример «покрытие domrf↔objective 2.5%»). None → low. + #2464-H: имя фактора историческое (`domrf_coverage`) и говорит про покрытие + маппинга domrf↔objective, но такого продьюсера НЕТ и не было: слот + `supply_layers.domrf_coverage` никто не заполняет (см. явную оговорку в + `orchestrator._summarize_supply_layers`), и значение ВСЕГДА приходит из + `analyze.market_data_coverage_pct` = `competitors_priced / competitors_total`, + то есть доля БЛИЖНИХ ЖК (3 км), у которых есть цена из Objective. + + Замер на проде 13.08: 2074 анализа, min 0% · медиана 40% · среднее 31.7% · + max 70%. Это не «~2.5% покрытия domrf↔objective», как было написано здесь + раньше, — другая величина другого порядка. + + Ключ фактора НЕ переименован намеренно: его читает фронт + (`ForecastConfidenceBlock`, `ConfidencePanel`) как стабильный контракт. + Порог и значение не меняются — правится только то, что читает человек. + None → low. """ level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW) if coverage is None: note = ( - "Доля будущих проектов с известными планировками и площадями неизвестна — " - "оценка будущего предложения и конкуренции менее надёжна" + "Доля ближних ЖК с известной ценой из Objective неизвестна — " + "оценка рынка и конкуренции менее надёжна" ) else: pct = round(float(coverage) * 100.0, 1) note = ( - f"Известные планировки и площади есть у {pct}% будущих проектов " - f"({_QUALITY_WORD[level]}) — от этого зависит точность прогноза " - "будущего предложения и конкуренции" + f"Цена из Objective известна у {pct}% ближних ЖК " + f"({_QUALITY_WORD[level]}) — от этого зависит точность оценки " + "рынка и конкуренции" ) return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note) @@ -294,9 +311,7 @@ def _history_factor(history_months: int | None) -> ConfidenceFactor: "ряде тренды и чувствительность спроса к ставке оцениваются хуже " "(поэтому в 6.2 может остаться один сценарий вместо трёх)" ) - return ConfidenceFactor( - name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note - ) + return ConfidenceFactor(name=_F_HISTORY_MONTHS, value=history_months, level=level, note=note) def _confounded_factor(confounded: bool) -> ConfidenceFactor: @@ -479,7 +494,9 @@ def compute_report_confidence( deal_count_months: окно наблюдения для deal_count (мес) — добавляет «за N мес» в ноту фактора («7 сделок за 6 мес — мало»). None → нота без периода. analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count). - domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта). + domrf_coverage: доля ближних ЖК с ценой из Objective ∈ [0,1]. Имя ключа + историческое — про маппинг domrf↔objective, продьюсера для которого + нет и не было (#2464-H, см. _coverage_factor). history_months: глубина ряда (мес). confounded: True, если окно ряда пересекает шок-период (PR2). advisory: весь стек советующий → cap 'medium' (по умолчанию True; почти всегда). diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index 33c85dd5..9a5d6817 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -203,15 +203,23 @@ def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | None) -> float | None: - """Покрытие domrf↔objective ∈ [0,1] — для domrf_coverage #990. PURE. + """Покрытие рынка ценами Objective ∈ [0,1] — для фактора domrf_coverage. PURE. - Главный sparse-риск проекта (~2.5%). Источники по приоритету (единица ЯВНАЯ - per-branch — НЕ угадываем по величине, иначе настоящий sub-1% процент типа 0.8% - спутался бы с долей 0.8 = 80% и инфлировал бы confidence в exactly near-zero кейсе, - который §15 призван флагать): - • `supply_layers.domrf_coverage` — уже ДОЛЯ ∈ [0,1] (0.025) → берём как есть. - • `analyze.market_data_coverage_pct` — всегда ПРОЦЕНТ (2.5 == 2.5%) → /100 → доля. - Нет сигнала → None (#990 → тянет в low: слой §9.3 недооценён). + Источники по приоритету (единица ЯВНАЯ per-branch — НЕ угадываем по величине, + иначе настоящий sub-1% процент типа 0.8% спутался бы с долей 0.8 = 80%): + • `supply_layers.domrf_coverage` — ДОЛЯ ∈ [0,1] → берём как есть. + • `analyze.market_data_coverage_pct` — ПРОЦЕНТ (40 == 40%) → /100 → доля. + Нет сигнала → None. + + #2464-H, важно для читающего: **первая ветка не исполнялась ни разу**. Слот + `supply_layers.domrf_coverage` никто не заполняет — `_summarize_supply_layers` + в orchestrator это прямо оговаривает («domrf_coverage здесь НЕ выводим — нет + дешёвого продьюсера»). Значит фактически всегда работает вторая ветка, и + величина у неё другая: не «покрытие маппинга domrf↔objective ~2.5%», как + было написано здесь раньше, а доля ближних ЖК (3 км) с ценой из Objective — + замер на проде 13.08 по 2074 анализам: медиана 40%, среднее 31.7%, max 70%. + + Порядок веток оставлен: если продьюсер появится, приоритет у него. """ if supply_layers is not None: coverage = supply_layers.get("domrf_coverage") diff --git a/backend/app/services/scrapers/nspd_client.py b/backend/app/services/scrapers/nspd_client.py index 9742dd43..95a9019b 100644 --- a/backend/app/services/scrapers/nspd_client.py +++ b/backend/app/services/scrapers/nspd_client.py @@ -559,7 +559,11 @@ class NSPDClient: """ # Импортируем здесь чтобы избежать circular import: # nspd_client ← nspd_bulk_client (оба top-level scrapers, не cross-domain) - from app.scrapers.nspd_bulk_client import NSPDBulkClient + from app.scrapers.nspd_bulk_client import ( + NSPDBulkClient, + NspdBulkServerError, + NspdBulkWafError, + ) xmin, ymin, xmax, ymax = bbox width_m = xmax - xmin @@ -607,10 +611,45 @@ class NSPDClient: results = await asyncio.gather(*tasks, return_exceptions=True) features: list[NSPDFeature] = [] - for r in results: - if isinstance(r, Exception): - logger.warning("get_features_in_bbox_grid layer=%d cell error: %s", layer_id, r) + # #2464-G: раньше ЛЮБОЕ исключение ячейки глушилось warning'ом и обход + # возвращал []. Отказ слоя (WAF-бан IP, 5xx на всех ячейках) становился + # неотличим от честного «здесь зон нет» — на проде это 124 дампа из 669 + # с territorial_zones_count=0, из них у 50 legacy-слой данные нашёл. + # Ниже — зеркало уже исправленного близнеца + # nspd_bulk_client.get_features_in_bbox_grid (Issue #252-mirror). + server_errors = 0 + ok_cells = 0 + first_server_error: NspdBulkServerError | None = None + for idx, r in enumerate(results): + if isinstance(r, NspdBulkWafError): + # 403 WAF — бан IP. Пробрасываем немедленно: продолжать обход + # бессмысленно, а пустой результат соврал бы про отсутствие зон. + logger.warning( + "get_features_in_bbox_grid layer=%d cell=%d WAF 403 — прерываем обход: %s", + layer_id, + idx, + r, + ) + raise r + if isinstance(r, NspdBulkServerError): + server_errors += 1 + if first_server_error is None: + first_server_error = r + logger.debug( + "get_features_in_bbox_grid layer=%d cell=%d server error: %s", + layer_id, + idx, + r, + ) continue + if isinstance(r, Exception): + # Сетевые / parse-ошибки одной ячейки: обход не валим и НЕ + # считаем server-side, иначе сеть ложно поднимет layer_failed. + logger.warning( + "get_features_in_bbox_grid layer=%d cell=%d error: %s", layer_id, idx, r + ) + continue + ok_cells += 1 for bulk_feat in r: raw = { "id": bulk_feat.id, @@ -618,6 +657,20 @@ class NSPDClient: "properties": bulk_feat.properties, } features.append(NSPDFeature.from_raw(raw)) + + # Были server-side отказы И ни одна ячейка не прошла — лёг слой или + # весь NSPD. Возврат [] здесь означал бы «зон нет», хотя мы просто + # ничего не узнали. Пробрасываем, чтобы caller отличил одно от другого. + if server_errors > 0 and ok_cells == 0 and first_server_error is not None: + logger.warning( + "get_features_in_bbox_grid layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный " + "(%d server errors, 0 успешных ячеек) — бросаем вместо ложного пустого", + layer_id, + effective_n, + effective_n, + server_errors, + ) + raise first_server_error return features raw_features = asyncio.run(_run_grid()) @@ -679,6 +732,10 @@ class NSPDClient: dict[layerId, list[NSPDFeature]]. Ключи — все запрошенные layerId (пустой list если слой пуст / упал). Стабильная форма для caller'а. """ + # Локальный импорт по той же причине, что в get_features_in_bbox_grid: + # nspd_client ← nspd_bulk_client дало бы circular import на top-level. + from app.scrapers.nspd_bulk_client import NspdBulkServerError + layer_ids = layers if layers is not None else list(RIASURT_SVERDL_LAYERS.keys()) result: dict[int, list[NSPDFeature]] = {} for layer_id in layer_ids: @@ -686,7 +743,15 @@ class NSPDClient: feats = self.get_features_in_bbox_grid( layer_id, bbox_3857, grid_n=grid_n, step_m=step_m ) - except (NspdLiteError, NspdLiteWafError) as exc: + except (NspdLiteError, NspdLiteWafError, NspdBulkServerError) as exc: + # #2464-G: с этой правки grid-walk умеет бросать NspdBulkServerError + # («слой лёг целиком»). Здесь ловим его И оставляем прежнее поведение — + # пустой список на слой, — потому что именно это обещает докстрока + # («пустой list если слой пуст / упал») и на это опирается вызывающий. + # NspdBulkWafError НЕ ловим намеренно: 403 — это бан IP, продолжать + # обход остальных слоёв значит углублять бан. + # Ограничение честно: наружу отсюда «упал» и «пусто» по-прежнему + # неразличимы — у функции нет канала для флага. Отдельным заходом. logger.warning( "get_riasurt_sverdl_in_bbox: layer=%d упал (%s) — пропускаем", layer_id, @@ -840,9 +905,19 @@ class NSPDClient: `layers_fetched` в этом случае содержит только `('search',)`. Raises: - NspdLiteWafError при 403/429 на любом из layer запросов — caller - должен делать backoff. Partial-success НЕ возвращается; вся - операция атомарна (failure → exception). + NspdLiteWafError при 403/429 на legacy-запросах (parcels/buildings) — + caller должен делать backoff. + NspdBulkWafError при 403 на любой ячейке grid-walk-слоя (#2464-G) — + бан IP, обход прерывается сразу. + NspdBulkServerError когда grid-walk-слой сбойный ЦЕЛИКОМ (были 5xx и + ни одна ячейка не прошла) — иначе вернулся бы пустой список, + неотличимый от честного «здесь ничего нет». + + До #2464-G это место обещало атомарность, которой не было: grid-walk + глушил любое исключение ячейки и отдавал []. Теперь обещание верно + для отказа слоя и бана, но partial-success внутри слоя ВОЗМОЖЕН: + если часть ячеек упала по сети, а часть прошла, вернётся то, что + собралось, с warning'ом в лог на каждую упавшую ячейку. Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors, E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity. diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index 9c6b73e9..aaee692b 100644 --- a/backend/app/services/site_finder/best_layouts.py +++ b/backend/app/services/site_finder/best_layouts.py @@ -192,15 +192,30 @@ _INLINE_VELOCITY_SQL = text(""" SELECT a.room_bucket, SUM(a.deals_window) AS deals_window, + -- Здесь COALESCE(...,0) ОСТАЁТСЯ намеренно: TopLayoutRow.avg_area_m2 + -- объявлен как float (не Optional), и NULL ронял бы контракт API. + -- Пустые комнатности получают площадь 0 м², и это тоже неправда — но + -- честный NULL требует правки схемы + перегенерации типов фронта + -- и решения, что писать в area_bin. Отдельным заходом: #2867. COALESCE( SUM(a.area_weighted_sum) / NULLIF(SUM(a.deals_window), 0), 0 )::numeric(10, 2) AS avg_area_m2, - COALESCE( + -- #2464-B: БЕЗ COALESCE(...,0). Сделок за окно нет → делитель NULL → + -- средней цены нет, и это NULL, а не «0 ₽/м²». Схема так и объявлена + -- (TopLayoutRow.avg_price_per_m2_rub: float | None), и Python ниже уже + -- умеет None (пропускает строку во взвешенном роллапе) — но COALESCE + -- делал эту ветку недостижимой. + -- Замер 13.08 по проду, окно 6 месяцев. Сработает ноль или нет — зависит + -- от того, сколько замапленных проектов попало в радиус, поэтому цифры + -- по слоям: у 616 проектов 2083 пары (проект × комнатность), пустых 635; + -- 323 проекта имеют хотя бы одну пустую комнатность, 80 — пустые ВСЕ. + -- При объединении по два пустых остаётся 255 из 1267, по всему городу — + -- ноль. То есть чем беднее окрестность участка, тем чаще выдумывался 0. + ( SUM(a.price_weighted_sum) - / NULLIF(SUM(a.deals_window), 0), - 0 + / NULLIF(SUM(a.deals_window), 0) )::numeric(12, 2) * 1000.0 AS avg_price_per_m2_rub, array_agg(DISTINCT a.project_name) AS matched_project_names, MIN(a.window_start) AS window_start, diff --git a/backend/app/services/site_finder/eesk_reserve_loader.py b/backend/app/services/site_finder/eesk_reserve_loader.py index c396321b..937e3b29 100644 --- a/backend/app/services/site_finder/eesk_reserve_loader.py +++ b/backend/app/services/site_finder/eesk_reserve_loader.py @@ -169,7 +169,16 @@ def _cell(row: tuple, idx: int) -> object: def _pct_share_to_percent(value: object) -> float | None: """Доля загрузки (0.41) → проценты (41.0). Уже-проценты (>1) не трогаем. - В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). Храним в процентах. + В xlsx ЕЭСК степень загрузки хранится ДОЛЕЙ (0..1). + + #2464-B: продакшен-вызывающих у функции СЕЙЧАС НЕТ. Значение колонки E + раньше писалось в `load_index`, но это категориальная колонка + ('open'|'limited'|'closed'|NULL) — число в ней фронт отбрасывает в + «неизвестно» и плодит мусорный бакет в `power_summary.by_load_index`. + Функцию оставляю с тестами: она описывает формат листа, и она понадобится + в тот момент, когда под процент загрузки заведут числовую колонку. + Если такого решения не будет — удалить вместе с тестом, а не держать молча. + None/мусор → None. """ num = parse_reserve_number(value) @@ -214,7 +223,9 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> rows_seen += 1 district = _cell(row, 1) # B - load_pct = _pct_share_to_percent(_cell(row, 4)) # E (доля → %) + # Колонку E (степень загрузки ЦП долей) НЕ читаем и не храним: места + # под неё в power_supply_centers нет — load_index категориальный, + # current_load_mva в мегавольт-амперах (#2464-B, см. UPDATE ниже). reserve = parse_reserve_number(_cell(row, 6)) # G (свободная МВт) name_norm = normalize_sc_name(str(sc_name)) @@ -223,7 +234,6 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> "reserve": reserve, "asof": reserve_asof, "district": str(district).strip() if district else None, - "load_pct": load_pct, "name_norm": name_norm, } @@ -236,10 +246,22 @@ def load_ps_35_220(db: Session, xlsx_bytes: bytes, reserve_asof: date | None) -> reserve_unit = 'МВт', installed_capacity_mva = :installed, district = :district, - load_index = COALESCE( - load_index, - CAST(:load_pct AS text) - ), + -- #2464-B: сюда БОЛЬШЕ НЕ пишем степень загрузки. + -- load_index — категориальная колонка + -- ('open'|'limited'|'closed'|NULL, см. + -- data/sql/180_connection_capacity.sql:35), её + -- заполняет rosseti_wfs_loader._map_load_index. + -- Раньше тут стоял COALESCE(load_index, + -- CAST(:load_pct AS text)) — при пустой ячейке + -- в колонку легло бы число строкой ("72.5"), + -- а фронтовый classifyLoadIndex такое значение + -- отбрасывает в null («неизвестно»), и в + -- power_summary.by_load_index появился бы + -- бакет с именем "72.5". + -- Сегодня не стреляло только потому, что у всех + -- 3416 строк load_index уже заполнен + -- (open 2741 / limited 346 / closed 329, NULL 0) + -- и COALESCE не проваливался. capacity_source = 'eesk_35_220', reserve_asof = :asof WHERE sc_name_norm = :name_norm diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py index a13585fb..644ebe1f 100644 --- a/backend/app/services/site_finder/velocity.py +++ b/backend/app/services/site_finder/velocity.py @@ -36,6 +36,48 @@ from sqlalchemy.orm import Session logger = logging.getLogger(__name__) +# Конкуренты в радиусе — модульная константа (а не inline f-string), чтобы +# integration-тест мог прогнать EXPLAIN по обеим подстановкам `{class_filter}`. +# Ветка с фильтром до #2464-G не парсилась вообще: ссылалась на алиас `o`, +# которого внутри CTE нет (`missing FROM-clause entry for table "o"`). +_COMPETITORS_SQL_TMPL = """ + WITH latest_obj AS ( + SELECT DISTINCT ON (obj_id) + obj_id, + comm_name, + dev_name, + -- #38: эффективный класс — реальный, иначе fallback + COALESCE(obj_class, obj_class_fallback) AS obj_class, + latitude, + longitude, + district_name + FROM domrf_kn_objects + WHERE latitude IS NOT NULL + AND longitude IS NOT NULL + AND region_cd = 66 + {class_filter} + ORDER BY obj_id, snapshot_date DESC NULLS LAST + ) + SELECT + o.obj_id, + o.comm_name, + o.dev_name, + o.obj_class, + o.district_name, + ST_Distance( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography + ) AS distance_m + FROM latest_obj o + WHERE ST_DWithin( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography, + :radius_m + ) + ORDER BY distance_m ASC + LIMIT 200 +""" + # Fallback если в БД нет данных за окно months_window (DB-error / пустой _get_ekb_median). # Источник (audit #1871): реальная медиана monthly velocity по ЕКБ — 593-766 м²/мес на # один ЖК. Берём верхнюю границу 750.0 — консервативно (безопаснее переоценки рынка: @@ -173,9 +215,17 @@ def compute_velocity( # только если явно передан. #38: при NULL реального класса используем # obj_class_fallback (yandex_match / price_inference) — реальный obj_class # в приоритете (COALESCE), поведение для размеченных ЖК не меняется. - class_filter = ( - "AND COALESCE(o.obj_class, o.obj_class_fallback) = :obj_class" if obj_class else "" - ) + # Колонки БЕЗ алиаса: фильтр подставляется ВНУТРЬ latest_obj, где FROM — + # голый domrf_kn_objects. Алиас `o` появляется только во внешнем SELECT, + # и `o.obj_class` здесь давал `missing FROM-clause entry for table "o"` + # (#2464-G, прод-EXPLAIN 13.08). Ошибку глотал except ниже → velocity + # молча выпадал из отчёта. Не срабатывало только потому, что единственный + # вызывающий (parcels.py) obj_class не передаёт. + # NB для первого, кто ветку включит: сравнение точное и регистрозависимое, а + # в проде классы с большой буквы и словарь шире ожидаемого — «Комфорт» 870, + # «Типовой» 224, «Бизнес» 95, «Премиум» 13, «Элит» 12, «Стандарт» 9, + # «Элитный» 4 объекта (замер 13.08). Передавать нужно ровно эти строки. + class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :obj_class" if obj_class else "" # SAVEPOINT per query: failure rollbacks ТОЛЬКО savepoint, не outer tx. # db.rollback() здесь НЕЛЬЗЯ — он orphan'ит outer SessionTransaction # (см. PR #155 bot review — SQLAlchemy 2.0 begin_nested context cleanup). @@ -183,45 +233,7 @@ def compute_velocity( with db.begin_nested(): comp_rows = ( db.execute( - text( - f""" - WITH latest_obj AS ( - SELECT DISTINCT ON (obj_id) - obj_id, - comm_name, - dev_name, - -- #38: эффективный класс — реальный, иначе fallback - COALESCE(obj_class, obj_class_fallback) AS obj_class, - latitude, - longitude, - district_name - FROM domrf_kn_objects - WHERE latitude IS NOT NULL - AND longitude IS NOT NULL - AND region_cd = 66 - {class_filter} - ORDER BY obj_id, snapshot_date DESC NULLS LAST - ) - SELECT - o.obj_id, - o.comm_name, - o.dev_name, - o.obj_class, - o.district_name, - ST_Distance( - ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, - ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography - ) AS distance_m - FROM latest_obj o - WHERE ST_DWithin( - ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, - ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography, - :radius_m - ) - ORDER BY distance_m ASC - LIMIT 200 - """ - ), + text(_COMPETITORS_SQL_TMPL.format(class_filter=class_filter)), { "parcel_wkt": parcel_geom_wkt, "radius_m": radius_km * 1000.0, diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py index 08a253d1..7639c02d 100644 --- a/backend/app/services/site_finder/weight_profiles.py +++ b/backend/app/services/site_finder/weight_profiles.py @@ -10,7 +10,7 @@ API surface: - create_profile(db, payload) → WeightProfile - update_profile(db, user_id, profile_id, payload) → WeightProfile | None - delete_profile(db, user_id, profile_id) → bool -- resolve_weights(db, user_id, profile_id) → dict[str, float] +- resolve_weights(db, user_id, profile_id) → ResolvedWeights(weights, source) """ from __future__ import annotations @@ -19,7 +19,7 @@ import json import logging import math from datetime import datetime -from typing import Any +from typing import Any, NamedTuple from pydantic import BaseModel, Field, field_validator from sqlalchemy import text @@ -346,13 +346,34 @@ def delete_profile(db: Any, user_id: str, profile_id: int) -> bool: return True -def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dict[str, float]: - """Вернуть эффективные веса для analyze_parcel. +class ResolvedWeights(NamedTuple): + """Веса + КАКОЙ источник фактически применился (#2811). + + Лестница приоритетов ниже по построению стирает разницу между «взял, что + просили» и «не нашёл, взял что было» — а метка в ответе /analyze строится + именно на этой разнице. Поэтому источник возвращается вместе с весами, а не + выводится вызывающим из своих же входных параметров. NamedTuple, а не голый + dict: старый вызов `w = resolve_weights(...); w["school"]` падает громко, + молча «весами» этот объект не притворится. + """ + + weights: dict[str, float] + source: str # "profile" | "user_default" | "system" + + +def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> ResolvedWeights: + """Вернуть эффективные веса для analyze_parcel + фактический их источник. Порядок приоритетов: - 1. profile_id задан → загрузить именно этот профиль - 2. user_id задан → загрузить default-профиль пользователя - 3. Иначе → вернуть системные значения _SYSTEM_POI_WEIGHTS + 1. profile_id задан → загрузить именно этот профиль → source="profile" + 2. user_id задан → загрузить default-профиль пользователя → source="user_default" + 3. Иначе → системные значения _SYSTEM_POI_WEIGHTS → source="system" + + Запрошенный, но НЕ применённый profile_id — не тишина: warning с + идентификаторами (см. ниже). HTTP-статус на этом не меняем: profile_id для + /analyze — необязательный модификатор, а не адресуемый ресурс; 404 превратил + бы гонку «профиль удалили между списком и анализом» в отказ вместо честно + помеченного ответа. Клиенту хватает source + requested_profile_not_found. """ if profile_id is not None and user_id is not None: profile = get_profile(db, user_id, profile_id) @@ -360,13 +381,26 @@ def resolve_weights(db: Any, user_id: str | None, profile_id: int | None) -> dic logger.debug( "resolve_weights: user=%s profile_id=%s → custom weights", user_id, profile_id ) - return dict(profile.weights) + return ResolvedWeights(dict(profile.weights), "profile") + resolved = ResolvedWeights(dict(_SYSTEM_POI_WEIGHTS), "system") if user_id is not None: profile = get_default_profile(db, user_id) if profile is not None and profile.weights: - logger.debug("resolve_weights: user=%s → default profile weights", user_id) - return dict(profile.weights) + resolved = ResolvedWeights(dict(profile.weights), "user_default") - logger.debug("resolve_weights: returning system defaults") - return dict(_SYSTEM_POI_WEIGHTS) + if profile_id is not None: + # Сюда попадаем, если запрошенный профиль не применился: owner не передан + # (первая ветка требует ОБА аргумента), профиль чужой/удалён, либо weights + # пустые. Раньше это был logger.debug, которого на проде нет, — и оценка + # молча считалась не по тем весам (#2811, ранее #2788). + logger.warning( + "resolve_weights: запрошенный profile_id=%s (user_id=%r) НЕ применён — " + "фактический источник весов %r", + profile_id, + user_id, + resolved.source, + ) + else: + logger.debug("resolve_weights: источник весов %s", resolved.source) + return resolved diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index ff2d812b..5c9eaa82 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -406,16 +406,17 @@ def build_beat_schedule() -> dict: # Catalog-object scrape — наполняет ~25 NULL колонок domrf_kn_objects из SSR-страниц. # kn-API не отдаёт wall_type, energy_eff, ceiling_height_m, parking_* и т.д. - # Вторник 04:00 UTC. batch 300/run → 1532 объекта за ~5 недель полного обновления. + # Вторник 04:00 МСК (crontab в МСК, #1233). batch 300/run → 1532 объекта + # за ~5 недель полного обновления. # # DISABLED 2026-05-24: DOM.РФ WAF дал hard-ban на VPS IP после серии failed # extras-сессий (run 26/27/28). Catalog SSR использует тот же BrowserSession - # + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 UTC) насыпет + # + те же /сервисы/* paths → следующий beat-tick (вт 26.05 04:00 МСК) насыпет # 300 failed SSR fetches и углубит WAF reputation penalty. Возврат после # cooldown 24-48h (проверить через targeted test). # schedule["scrape-kn-catalog-objects-weekly"] = { # "task": "tasks.scrape_kn_catalog_objects.scrape_kn_catalog_objects", - # "schedule": _parse_cron("0 4 * * 2"), # Tuesday 04:00 UTC + # "schedule": _parse_cron("0 4 * * 2"), # вторник 04:00 МСК # "kwargs": {"region_code": 66, "max_objects": 300}, # "options": {"queue": "celery"}, # } @@ -430,10 +431,10 @@ def build_beat_schedule() -> dict: # свежий kn-sweep не наполнил hash, SELECT вернёт 0 строк — включать смысла нет. # Возврат после WAF-cooldown + первого kn-sweep с hash (проверить targeted-тестом). # Разнести по времени с object-scrape (вт 04:00), чтобы не двоить WAF-нагрузку — - # напр. четверг 04:00 UTC. + # напр. четверг 04:00 МСК. # schedule["scrape-kn-catalog-flats-weekly"] = { # "task": "tasks.scrape_kn_catalog_flats.scrape_kn_catalog_flats", - # "schedule": _parse_cron("0 4 * * 4"), # Thursday 04:00 UTC + # "schedule": _parse_cron("0 4 * * 4"), # четверг 04:00 МСК # "kwargs": {"region_code": 66, "max_flats": 300}, # "options": {"queue": "celery"}, # } @@ -542,13 +543,20 @@ def build_beat_schedule() -> dict: } # Cross-load ETL tradein→gendesign (#976 950-E5): tradein.houses → newbuilding_listings. - # Ночной запуск: 00:30 UTC = 03:30 МСК (Celery conf.timezone=Europe/Moscow → crontab в МСК). + # 00:30 МСК ежедневно (Celery conf.timezone=Europe/Moscow → crontab в МСК, #1233). + # Комментарий до #2464-H говорил «00:30 UTC = 03:30 МСК» — считал сдвиг дважды, + # оставшись с эпохи UTC-расписания. Факт по логам beat (10-12.08): «Sending due + # task newbuilding-crossload-nightly» в 21:30 UTC = 00:30 МСК, то есть на три + # часа раньше обещанного. + # Расписание НЕ трогаем: на 00:30 МСК ничего не наложено, а сдвиг на 03:30 МСК + # завёл бы задачу прямо в окно tradein-задания newbuilding_enrich (00:00-01:00 UTC + # = 03:00-04:00 МСК), с которым она делит источник — tradein.houses. # Не в job_settings (технический ETL, не требует конфигурации UI). # Идемпотентен через ON CONFLICT (source, ext_house_id). # Если TRADEIN_DATABASE_URL не задан → warn-log, {"disabled": True} без исключения. schedule["newbuilding-crossload-nightly"] = { "task": "tasks.etl_newbuilding_crossload.etl_newbuilding_crossload", - "schedule": _parse_cron("30 0 * * *"), # 00:30 UTC = 03:30 МСК + "schedule": _parse_cron("30 0 * * *"), # 00:30 МСК "options": {"queue": "celery"}, } diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 1268bfe6..bfba175d 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -15,7 +15,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from app.core.config import settings -from app.observability.sentry_scrub import scrub_sensitive_query +from app.observability.sentry_scrub import scrub_event logger = logging.getLogger(__name__) @@ -23,6 +23,11 @@ logger = logging.getLogger(__name__) # чтобы события из тасков попадали в GlitchTip. SDK безопасен для двойного # вызова — повторный sentry_sdk.init() в одном процессе заменяет клиента. if settings.glitchtip_dsn: + # before_send И before_send_transaction — ОБА на scrub_event (#2457-review, + # см. app/main.py и sentry_scrub.py module docstring): до этого фикса worker + # вообще не скрабил error-события (тут before_send не было), а + # before_send_transaction был на голом scrub_sensitive_query (только URL) — + # оба канала пропускали PII. sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, @@ -30,7 +35,12 @@ if settings.glitchtip_dsn: traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, - before_send_transaction=scrub_sensitive_query, + # Локальные переменные кадров стека НЕ уходят в мониторинг (#2753) — см. + # app/main.py: скраб сверяет ИМЕНА ключей, а имя переменной произвольно. + # В воркере вектор шире: задачи держат в кадрах сырые ответы источников. + include_local_variables=False, + before_send=scrub_event, + before_send_transaction=scrub_event, integrations=[ CeleryIntegration(monitor_beat_tasks=True), SqlalchemyIntegration(), diff --git a/backend/tests/_sentry_wiring_probe.py b/backend/tests/_sentry_wiring_probe.py new file mode 100644 index 00000000..a2c72bf4 --- /dev/null +++ b/backend/tests/_sentry_wiring_probe.py @@ -0,0 +1,146 @@ +"""Проба проводки GlitchTip: запускается ОТДЕЛЬНЫМ процессом из test_sentry_init.py. + +Зачем подпроцесс. `app/main.py` и `app/workers/celery_app.py` зовут +`sentry_sdk.init()` на импорте модуля и только при непустом `GLITCHTIP_DSN`. В +процессе pytest этот путь недостижим (модуль уже в `sys.modules`, DSN пуст), а +если бы и был достижим — глобальный клиент SDK остался бы живым для всех +последующих тестов. Отдельный процесс даёт настоящую инициализацию и умирает +вместе с ней. + +Наружу не уходит ничего: `capture_envelope` подменяется ДО первого события, а +DSN в тесте указывает на несуществующий хост. Значения-маркеры генерируются +случайно на каждый запуск — кадр стека несёт не только переменные, но и строки +исходника, поэтому литерал в коде пробы сделал бы проверку вечно красной. + +stdout — одна строка JSON: counts / scrub_handler_entries / markers / payloads +(тело каждого канала отдельно — см. `main`). +""" + +from __future__ import annotations + +import importlib +import io +import itertools +import json +import sys +import uuid +from typing import Any + +import sentry_sdk + +_FAILURE_CAP = 3 + + +def _fresh(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex}" + + +def _leaking_event(markers: dict[str, str]) -> dict[str, Any]: + """Событие с ПДн в трёх местах, которые закрывает scrub_event.""" + return { + "message": "sentry-wiring-probe", + "level": "error", + "request": { + "data": {"phone": markers["phone"], "message": markers["free_text"]}, + "url": f"https://example.invalid/probe?api_key={markers['url_secret']}", + }, + } + + +def main(module: str) -> int: + importlib.import_module(module) # ← здесь отрабатывает sentry_sdk.init() + + client = sentry_sdk.get_client() + sent: list[str] = [] + + def _record(envelope: Any) -> None: + buf = io.BytesIO() + envelope.serialize_into(buf) + sent.append(buf.getvalue().decode("utf-8", "replace")) + + client.transport.capture_envelope = _record # type: ignore[union-attr,method-assign] + + markers = { + "phone": _fresh("probe-phone"), + "free_text": _fresh("probe-free-text"), + "url_secret": _fresh("probe-url-secret"), + "local_var": _fresh("probe-local-var"), + } + + # 1. Канал ошибок (before_send). + sentry_sdk.capture_event(_leaking_event(markers)) + after_error = len(sent) + + # 2. Канал транзакций (before_send_transaction) — Starlette кладёт + # request.data на transaction-scope так же, как на error-scope. + transaction = _leaking_event(markers) + transaction["type"] = "transaction" + transaction["transaction"] = "sentry-wiring-probe-tx" + transaction["contexts"] = {"trace": {"trace_id": "0" * 32, "span_id": "0" * 16}} + transaction["start_timestamp"] = "2026-01-01T00:00:00.000000Z" + transaction["timestamp"] = "2026-01-01T00:00:01.000000Z" + transaction["spans"] = [] + sentry_sdk.capture_event(transaction) + after_transaction = len(sent) + + # 3. Локальные переменные кадра стека (include_local_variables). Имя + # переменной произвольное — ключевой скраб такое не ловит по построению. + def _raise_with_local() -> None: + applicant_note = markers["local_var"] # noqa: F841 — ради кадра стека + raise RuntimeError("sentry-wiring-probe boom") + + try: + _raise_with_local() + except RuntimeError: + sentry_sdk.capture_exception() + after_exception = len(sent) + + # 4. Сбой самого скраба не должен порождать ВТОРОЕ событие: иначе строка + # журнала об отказе уходит в мониторинг через LoggingIntegration, снова + # попадает в скраб, снова падает — рекурсия (#2753; на коде до фикса + # проверено: не завершается, 1000+ вложенных трассировок за минуту). + # Считаем ВХОДЫ в обработчик; после _FAILURE_CAP перестаём падать, иначе + # проба на сломанном коде висела бы вместо того, чтобы честно покраснеть. + from app.observability import sentry_scrub + + original = sentry_scrub.scrub_pii_event + entries: list[int] = [] + + def _boom(*_a: Any, **_kw: Any) -> Any: + entries.append(1) + if len(entries) > _FAILURE_CAP: + return None + raise RuntimeError("sentry-wiring-probe scrubber failure") + + sentry_scrub.scrub_pii_event = _boom # type: ignore[assignment] + try: + # Без маркеров: это событие по замыслу уходит НЕОЧИЩЕННЫМ ("as-is"). + sentry_sdk.capture_event({"message": "sentry-wiring-probe-failure", "level": "error"}) + finally: + sentry_scrub.scrub_pii_event = original # type: ignore[assignment] + after_scrub_failure = len(sent) + + # Тело каждого канала — отдельно: иначе утечка из одного (напр. локальные + # переменные шага 3 несут те же маркеры, что тело запроса шага 1) красит + # чужую проверку и мешает понять, что именно сломано. + bounds = [0, after_error, after_transaction, after_exception, after_scrub_failure] + names = ["error", "transaction", "exception", "scrub_failure"] + spans = dict(zip(names, itertools.pairwise(bounds), strict=True)) + + print( + json.dumps( + { + "counts": {name: end - start for name, (start, end) in spans.items()}, + "scrub_handler_entries": len(entries), + "markers": markers, + "payloads": { + name: "\n".join(sent[start:end]) for name, (start, end) in spans.items() + }, + } + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1])) diff --git a/backend/tests/api/v1/test_admin_cadastre.py b/backend/tests/api/v1/test_admin_cadastre.py index 3df39895..9d7fdb27 100644 --- a/backend/tests/api/v1/test_admin_cadastre.py +++ b/backend/tests/api/v1/test_admin_cadastre.py @@ -5,36 +5,16 @@ from __future__ import annotations -# scrape_cadastre зависит от app.scrapers.nspd_bulk_client (PR 2/5). -# Используем importlib.util.find_spec вместо прямого import — иначе -# `import app.workers...` пересоздаёт `app` как Python package и -# перебивает FastAPI instance, привязанный в строке выше → AttributeError -# на app.dependency_overrides. -import importlib.util +# NB: `import app.workers...` здесь запрещён — он пересоздаёт `app` как Python +# package и перебивает FastAPI instance, привязанный ниже → AttributeError на +# app.dependency_overrides. Патчим воркер строкой, через patch("app.workers..."). from typing import Any from unittest.mock import MagicMock, patch -import pytest from fastapi.testclient import TestClient from app.main import app -_SCRAPE_CADASTRE_AVAILABLE = ( - importlib.util.find_spec("app.workers.tasks.scrape_cadastre") is not None -) - -requires_scrape_cadastre = pytest.mark.skipif( - not _SCRAPE_CADASTRE_AVAILABLE, - reason=( - "app.scrapers.nspd_bulk_client отсутствует — нужно смержить main (PR #170). " - "Команда: git rebase origin/main" - ), -) - -# Токен для тестов (не реальный) -ADMIN_TOKEN = "test-admin-token" -ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN} - def _make_mock_db(quarters: list[str] | None = None, job_row: dict[str, Any] | None = None): """Создать mock db session с преднастроенными ответами.""" @@ -113,8 +93,6 @@ def _make_sample_job_row() -> dict[str, Any]: # ── Tests ──────────────────────────────────────────────────────────────────── -@requires_scrape_cadastre -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_pilot_returns_job_id() -> None: """POST /cadastre/jobs scope=pilot → job_id + targets_total.""" quarters_50 = [f"66:41:{i:07d}" for i in range(50)] @@ -131,7 +109,6 @@ def test_create_job_pilot_returns_job_id() -> None: response = client.post( "/api/v1/admin/cadastre/jobs", json={"scope": "pilot"}, - headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text @@ -144,8 +121,6 @@ def test_create_job_pilot_returns_job_id() -> None: app.dependency_overrides.clear() -@requires_scrape_cadastre -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_manual_list() -> None: """POST /cadastre/jobs scope=manual_list с явным списком.""" db = _make_mock_db() @@ -164,7 +139,6 @@ def test_create_job_manual_list() -> None: "scope": "manual_list", "quarters": ["66:41:0303161", "66:41:0303162"], }, - headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text @@ -174,8 +148,6 @@ def test_create_job_manual_list() -> None: app.dependency_overrides.clear() -@requires_scrape_cadastre -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_create_job_manual_list_empty_quarters_returns_400() -> None: """scope=manual_list без quarters → 400.""" db = _make_mock_db() @@ -189,14 +161,12 @@ def test_create_job_manual_list_empty_quarters_returns_400() -> None: response = client.post( "/api/v1/admin/cadastre/jobs", json={"scope": "manual_list"}, - headers=ADMIN_HEADERS, ) assert response.status_code == 400 finally: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_list_jobs_returns_list() -> None: """GET /cadastre/jobs → список jobs.""" db = _make_mock_db(job_row=_make_sample_job_row()) @@ -213,7 +183,7 @@ def test_list_jobs_returns_list() -> None: try: client = TestClient(app) - response = client.get("/api/v1/admin/cadastre/jobs", headers=ADMIN_HEADERS) + response = client.get("/api/v1/admin/cadastre/jobs") assert response.status_code == 200, response.text body = response.json() assert isinstance(body, list) @@ -225,7 +195,6 @@ def test_list_jobs_returns_list() -> None: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_get_job_not_found_returns_404() -> None: """GET /cadastre/jobs/9999 → 404.""" db = MagicMock() @@ -239,13 +208,12 @@ def test_get_job_not_found_returns_404() -> None: try: client = TestClient(app) - response = client.get("/api/v1/admin/cadastre/jobs/9999", headers=ADMIN_HEADERS) + response = client.get("/api/v1/admin/cadastre/jobs/9999") assert response.status_code == 404 finally: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_cancel_job_success() -> None: """POST /cadastre/jobs/42/cancel → {cancelled: true}.""" db = MagicMock() @@ -262,7 +230,6 @@ def test_cancel_job_success() -> None: client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs/42/cancel", - headers=ADMIN_HEADERS, ) assert response.status_code == 200, response.text body = response.json() @@ -272,7 +239,6 @@ def test_cancel_job_success() -> None: app.dependency_overrides.clear() -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_cancel_job_not_found_returns_404() -> None: """POST /cadastre/jobs/9999/cancel когда job не найден → 404.""" db = MagicMock() @@ -289,27 +255,7 @@ def test_cancel_job_not_found_returns_404() -> None: client = TestClient(app) response = client.post( "/api/v1/admin/cadastre/jobs/9999/cancel", - headers=ADMIN_HEADERS, ) assert response.status_code == 404 finally: app.dependency_overrides.clear() - - -@pytest.mark.skip( - reason=( - "X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — " - "Caddy basic_auth + RBAC middleware достаточны). Admin endpoint'ы больше не " - "несут verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode " - "RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без " - "реверта security-решения #437. Тест проверял удалённое поведение." - ) -) -def test_create_job_no_token_returns_401() -> None: - """Без X-Admin-Token → 401/503 (устарело: токен-гейт удалён в #437).""" - client = TestClient(app) - response = client.post( - "/api/v1/admin/cadastre/jobs", - json={"scope": "pilot"}, - ) - assert response.status_code in (401, 503) diff --git a/backend/tests/api/v1/test_admin_ekburg_permits.py b/backend/tests/api/v1/test_admin_ekburg_permits.py index 7c75f376..45d9bd85 100644 --- a/backend/tests/api/v1/test_admin_ekburg_permits.py +++ b/backend/tests/api/v1/test_admin_ekburg_permits.py @@ -4,7 +4,11 @@ - валидный запрос без year → scope all_years_2022_2026, task_id в ответе - валидный запрос с year=2026 → scope year_2026 - year < 2022 или > 2030 → 422 -- отсутствие X-Admin-Token → 401/503 + +Авторизация здесь НЕ проверяется и никогда не проверялась: строка «отсутствие +X-Admin-Token → 401/503» стояла в этом докстринге, но соответствующего теста в +файле нет. Гейт `/api/v1/admin/*` — middleware `rbac_guard` (app/main.py), +покрыт tests/test_rbac.py. """ from __future__ import annotations @@ -16,8 +20,6 @@ from fastapi.testclient import TestClient from app.main import app -ADMIN_TOKEN = "test-admin-token" -ADMIN_HEADERS = {"X-Admin-Token": ADMIN_TOKEN} ENDPOINT = "/api/v1/admin/scrape/ekburg-permits" @@ -27,7 +29,6 @@ def _mock_task(task_id: str = "fake-task-id-123") -> MagicMock: return result -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_trigger_refresh_all_returns_task_id() -> None: """POST без year → refresh_all queued, scope=all_years_2022_2026.""" mock_result = _mock_task("task-all-001") @@ -38,7 +39,7 @@ def test_trigger_refresh_all_returns_task_id() -> None: ): mock_refresh_all.apply_async.return_value = mock_result client = TestClient(app) - response = client.post(ENDPOINT, json={}, headers=ADMIN_HEADERS) + response = client.post(ENDPOINT, json={}) assert response.status_code == 200, response.text body = response.json() @@ -47,7 +48,6 @@ def test_trigger_refresh_all_returns_task_id() -> None: assert "queued_at" in body -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_trigger_refresh_year_returns_task_id() -> None: """POST year=2026 → refresh_year queued, scope=year_2026.""" mock_result = _mock_task("task-year-002") @@ -58,7 +58,7 @@ def test_trigger_refresh_year_returns_task_id() -> None: ): mock_refresh_year.apply_async.return_value = mock_result client = TestClient(app) - response = client.post(ENDPOINT, json={"year": 2026}, headers=ADMIN_HEADERS) + response = client.post(ENDPOINT, json={"year": 2026}) assert response.status_code == 200, response.text body = response.json() @@ -67,25 +67,8 @@ def test_trigger_refresh_year_returns_task_id() -> None: @pytest.mark.parametrize("bad_year", [2021, 2031, 1999, 9999]) -@patch("app.core.config.settings.scrape_admin_token", ADMIN_TOKEN) def test_trigger_invalid_year_returns_422(bad_year: int) -> None: """year вне диапазона [2022, 2030] → 422 Unprocessable Entity.""" client = TestClient(app) - response = client.post(ENDPOINT, json={"year": bad_year}, headers=ADMIN_HEADERS) + response = client.post(ENDPOINT, json={"year": bad_year}) assert response.status_code == 422, f"year={bad_year} должен возвращать 422" - - -@pytest.mark.skip( - reason=( - "X-Admin-Token gate удалён в #437 (refactor(security): убрать X-Admin-Token — " - "Caddy basic_auth + RBAC middleware достаточны). trigger_ekburg_permits больше не " - "несёт verify_admin_token dependency; защита — на уровне Caddy/RBAC. В test-mode " - "RBAC bypass'ится (CI-rehab 1/3), поэтому 401/503 здесь больше недостижим без " - "реверта security-решения #437. Тест проверял удалённое поведение." - ) -) -def test_trigger_no_token_returns_401_or_503() -> None: - """Без X-Admin-Token → 401 или 503 (устарело: токен-гейт удалён в #437).""" - client = TestClient(app) - response = client.post(ENDPOINT, json={}) - assert response.status_code in (401, 503), response.text diff --git a/backend/tests/api/v1/test_analyze_competitors_status.py b/backend/tests/api/v1/test_analyze_competitors_status.py index d4fd67be..055a2d34 100644 --- a/backend/tests/api/v1/test_analyze_competitors_status.py +++ b/backend/tests/api/v1/test_analyze_competitors_status.py @@ -110,9 +110,9 @@ class TestCompetitorsSortOrder: sorted_rows = sorted(_ROWS_MIXED, key=_sort_key) first = dict(sorted_rows[0].items()) - assert first["site_status"] == "Строящиеся", ( - f"Первый конкурент должен быть 'Строящиеся', " f"но получили '{first['site_status']}'" - ) + assert ( + first["site_status"] == "Строящиеся" + ), f"Первый конкурент должен быть 'Строящиеся', но получили '{first['site_status']}'" def test_flat_count_desc_would_break_order(self) -> None: """Демонстрирует, что старый ORDER BY flat_count DESC ставил сданные первыми.""" @@ -180,14 +180,44 @@ class TestObjPricingPushdown: #1964: источник агрегатов сменился с сырого objective_lots (alias ol) на physflat-дедуп CTE obj_lots_latest (alias oll) — см. test_obj_pricing_*_physflat ниже. Сами агрегатные выражения и группировка per-obj_id неизменны. + + #2464-D: у среднего цены появились границы правдоподобия (те же, что в двух + соседних запросах по objective_lots) — см. test_price_avg_has_sanity_bounds. """ sql = self._competitor_sql() - assert "ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql + assert "AS avg_price_per_m2_rub" in sql assert "lots_with_price" in sql assert "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql assert "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql assert "GROUP BY np.domrf_obj_id" in sql + def test_price_avg_has_sanity_bounds(self) -> None: + """#2464-D: среднее цены считается по лотам в границах правдоподобия. + + Среднее считается ПО ПРОЕКТУ, поэтому один лот держит группу без ограничения + сверху: максимум в objective_lots — 19.2 млн ₽/м² (замер 13.08). Границы + 30000..600000 уже стоят в двух соседних запросах по этой же таблице; здесь + их не было. Дальше значение уходит в market_avg_price и на экран. + """ + sql = self._competitor_sql() + bounds = "WHERE oll.price_per_m2_rub BETWEEN 30000 AND 600000" + assert ( + f"AVG(oll.price_per_m2_rub) FILTER ( {bounds} )" in sql + ), "среднее цены должно фильтроваться границами правдоподобия (#2464-D)" + # Тот же набор кормит счётчик выборки — иначе счётчик обещает шире, чем + # реально участвовало в среднем. + assert ( + f"COUNT(*) FILTER ( {bounds} ) AS lots_with_price" in sql + ), "lots_with_price должен считать ту же популяцию, что и среднее" + # FILTER, а не WHERE на CTE: строки нужны целиком, иначе границы цены + # молча урежут счётчики продаж/остатка, которые считают ВСЕ лоты. + assert ( + "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql + ), "units_sold не должен зависеть от границ цены" + assert ( + "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql + ), "units_available не должен зависеть от границ цены" + def test_obj_pricing_dedups_physflat_inline(self) -> None: """#1964: obj_pricing агрегирует physflat-дедуп набор (DISTINCT ON), НЕ сырой. diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index ad7a2248..cc61f0ef 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -316,3 +316,77 @@ def test_analyze_inline_weights_beats_profile_id() -> None: finally: app.dependency_overrides.clear() _stop_patches() + + +def test_analyze_missing_profile_is_not_labelled_profile() -> None: + """#2811: profile_id задан, профиль НЕ найден → метка НЕ смеет быть 'profile'. + + Три способа промахнуться мимо профиля (все три воспроизведены живым запросом + на проде 2026-08-10): owner не передан вовсе, чужой профиль, удалённый id. + В mock-БД профилей нет — значит применились системные веса, и ответ обязан + это признать, а не утверждать, что считал по профилю. + """ + from app.core.db import get_db + from app.services.site_finder.weight_profiles import _SYSTEM_POI_WEIGHTS + + for qs in ("profile_id=999999", "profile_id=999999&profile_user_id=nobody"): + db = _make_db_for_analyze() # профилей нет → get_profile/get_default_profile → None + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?{qs}") + assert resp.status_code == 200, resp.text + wp = resp.json()["weights_profile"] + # sanity: веса и правда системные, промах реальный + assert wp["weights_applied"]["tram_stop"] == pytest.approx( + _SYSTEM_POI_WEIGHTS["tram_stop"] + ) + assert wp["source"] != "profile", ( + f"?{qs}: применились системные веса, а метка source='profile' — " + "ответ утверждает то, чего не было (#2811)" + ) + assert wp["source"] == "system" + # «что просили» не теряется: запрошенный id + явный признак промаха + assert wp["profile_id"] == 999999 + assert wp["requested_profile_applied"] is False + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_analyze_found_profile_keeps_label_and_flag() -> None: + """Обратная сторона: профиль найден → source='profile', флаг промаха False.""" + from datetime import UTC, datetime + + import app.services.site_finder.weight_profiles as wp_module + from app.core.db import get_db + from app.services.site_finder.weight_profiles import WeightProfile + + profile = WeightProfile( + id=7, + user_id="user-1", + profile_name="test", + weights={"tram_stop": -0.4}, + is_default=False, + description=None, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + original = wp_module.get_profile + wp_module.get_profile = lambda _db, uid, pid: profile + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?profile_id=7&profile_user_id=user-1") + assert resp.status_code == 200, resp.text + wp = resp.json()["weights_profile"] + assert wp["source"] == "profile" + assert wp["requested_profile_applied"] is True + assert wp["weights_applied"]["tram_stop"] == pytest.approx(-0.4) + finally: + wp_module.get_profile = original + app.dependency_overrides.clear() + _stop_patches() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4a5fb6f2..5276e7b2 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,7 @@ NB: RBAC-гейт (app/main.py `rbac_guard`) требует заголовок ` from __future__ import annotations import os +from pathlib import Path import pytest @@ -40,3 +41,79 @@ def _clear_dependency_overrides(): app.dependency_overrides.clear() except Exception: pass + + +# ── Бюджет пропусков (#2745) ────────────────────────────────────────────────── +# +# Три инцидента за одни сутки (#2722, #2729, #2740) — один класс дефекта: +# проверка, которая тихо не исполняется, со временем перестаёт быть верной, и +# узнают об этом ровно тогда, когда на неё понадобилось опереться. `-rs` в CI +# делает пропуск ГРОМКИМ; этот хук делает его УЧТЁННЫМ — пропущено может быть +# только то, что записано в `tests/skip_allowlist.txt`. Любой новый пропуск (в +# том числе возникший сам: отвалилась зависимость, исчезла БД, кто-то дописал +# skipif «пока починю») роняет прогон, пока его не внесут в список осознанно. +# +# Список — НАДмножество: покрывает и CI (где есть Postgres и native-libs), и +# ноутбук разработчика (где их нет). Запись, чей пропуск не случился, — не +# ошибка; ошибка — пропуск без записи. +# +# Сюда же попадает xfail: pytest рапортует его как skipped, поэтому xfail без +# `strict=True` (зеленеет и когда тест внезапно ПРОШЁЛ) тоже придётся объявить. +# +# Чего хук НЕ видит: `--deselect` / `-k` / `-m` — они убирают тест до прогона, +# отчёта о пропуске не возникает вовсе. Против них работает правило «никаких +# deselect'ов в workflow» (см. .forgejo/workflows/ci-tradein.yml). + +_SKIP_ALLOWLIST_PATH = Path(__file__).parent / "skip_allowlist.txt" +_observed_skips: set[str] = set() + + +def _allowed_skips() -> set[str]: + if not _SKIP_ALLOWLIST_PATH.exists(): + return set() + lines = _SKIP_ALLOWLIST_PATH.read_text(encoding="utf-8").splitlines() + return {stripped for line in lines if (stripped := line.split("#", 1)[0].strip())} + + +def _record_skip(report) -> None: + if report.skipped: + # nodeid без «[параметров]»: список фиксирует, какая ПРОВЕРКА может + # отсутствовать, а не каждую её параметризацию. + _observed_skips.add(report.nodeid.split("[", 1)[0]) + + +def pytest_runtest_logreport(report) -> None: + _record_skip(report) + + +def pytest_collectreport(report) -> None: + # Модульный pytest.skip(allow_module_level=True) приходит сюда, а не в + # logreport, и nodeid у него — путь файла. + _record_skip(report) + + +def pytest_sessionfinish(session, exitstatus) -> None: + unlisted = sorted(_observed_skips - _allowed_skips()) + if not unlisted: + return + head = ( + f"НЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не " + f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:" + ) + print(f"\n{head}") + for nodeid in unlisted: + print(f" - {nodeid}") + print( + "Почини тест либо внеси его в skip_allowlist.txt с причиной — " + "пропуск без записи неотличим от пройденной проверки." + ) + # #2871: под Actions дублируем в ::error:: — иначе сообщение тонет. + # 13.08 этот сторож четыре прогона подряд ронял job'у совершенно правильно, + # а его строка лежала посреди тысячи других (обычный print, по-русски) — + # и поиск по «FAILED / ERROR» её не находил. Причину искали три часа + # в диске, раннере, покрытии и кэше. Сторож, который роняет прогон, + # обязан кричать так, чтобы его нашли. + if os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"): + print(f"::error::{head} " + "; ".join(unlisted)) + if exitstatus == 0: + session.exitstatus = 1 diff --git a/backend/tests/integration/test_analyze_parcels_sql.py b/backend/tests/integration/test_analyze_parcels_sql.py index 3b2e8fff..b50d0577 100644 --- a/backend/tests/integration/test_analyze_parcels_sql.py +++ b/backend/tests/integration/test_analyze_parcels_sql.py @@ -39,6 +39,7 @@ from sqlalchemy.orm import Session from app.api.v1.parcels import _NEIGHBORS_SUMMARY_SQL from app.services.site_finder.ird_overlay_lookup import _IRD_OVERLAP_SQL +from app.services.site_finder.velocity import _COMPETITORS_SQL_TMPL from tests.integration.conftest import requires_test_db # NB: ``pytestmark`` НЕ ставим на модуль — здесь два класса compile-time @@ -103,9 +104,9 @@ class TestNeighborsSummarySql: for kw in forbidden_aliases: # ищем паттерн ``WITH AS (`` или ``, AS (`` — оба # формы CTE-биндинга. - assert f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql, ( - f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)" - ) + assert ( + f"with {kw} as (" not in raw_sql and f", {kw} as (" not in raw_sql + ), f"CTE alias '{kw}' пересекается с PG keyword (см. incident #1195)" # ── parcel_ird_overlaps SQL ────────────────────────────────────────────────── @@ -167,3 +168,39 @@ class TestPsycopg3CastAntipattern: f"{name} содержит psycopg v3 antipattern: {matches}. " f"Используй CAST(:bind AS type) — см. .claude/rules/backend.md." ) + + +# ── velocity: конкуренты в радиусе (#2464-G) ───────────────────────────────── + + +class TestVelocityCompetitorsSql: + """``_COMPETITORS_SQL_TMPL`` из ``app.services.site_finder.velocity``. + + Шаблон подставляется в двух видах, и **вторая подстановка до #2464-G + не парсилась вообще**: фильтр класса ссылался на алиас ``o``, который + существует только во внешнем SELECT, а подставляется фильтр ВНУТРЬ CTE + ``latest_obj`` (FROM domrf_kn_objects, без алиаса) → + ``missing FROM-clause entry for table "o"`` (прод-EXPLAIN 13.08). + + Почему это не падало в проде: единственный вызывающий + (``analyze_parcel``) ``obj_class`` не передаёт → ветка мёртвая. + Падало бы молча — исключение глотает ``except`` в ``compute_velocity``, + и блок velocity просто исчезал бы из отчёта с одной строкой в логе. + + Тест закрывает обе ветки, а не только ту, что сегодня исполняется. + """ + + @requires_test_db + @pytest.mark.integration + @pytest.mark.parametrize( + "class_filter", + ["", "AND COALESCE(obj_class, obj_class_fallback) = :obj_class"], + ids=["no_class_filter", "with_class_filter"], + ) + def test_explain_competitors(self, phantom_check_session: Session, class_filter: str) -> None: + """Обе подстановки шаблона парсятся и планируются против реальной схемы.""" + _explain_text( + phantom_check_session, + _COMPETITORS_SQL_TMPL.format(class_filter=class_filter), + {"parcel_wkt": _EKB_WKT, "radius_m": 3000.0, "obj_class": "комфорт"}, + ) diff --git a/backend/tests/scrapers/test_nspd_grid_walk.py b/backend/tests/scrapers/test_nspd_grid_walk.py index 377ba20c..26d8e625 100644 --- a/backend/tests/scrapers/test_nspd_grid_walk.py +++ b/backend/tests/scrapers/test_nspd_grid_walk.py @@ -190,6 +190,70 @@ class TestGetFeaturesInBboxGrid: # 4 cells: 1 error + 3 good_feat → 1 unique feature assert any(f.feature_id == "feat-ok" for f in result) + # ── #2464-G: отказ слоя больше не маскируется пустым результатом ────────── + + def _grid(self, side_effect: Any, *, grid_n: int = 2) -> list[NSPDFeature]: + """Прогнать grid-walk с подменённым wms_feature_info.""" + mock_client_instance = AsyncMock() + mock_client_instance.wms_feature_info = AsyncMock(side_effect=side_effect) + mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance) + mock_client_instance.__aexit__ = AsyncMock(return_value=None) + with patch( + "app.scrapers.nspd_bulk_client.NSPDBulkClient", + return_value=mock_client_instance, + ): + return NSPDClient().get_features_in_bbox_grid( + 36328, self.BBOX, grid_n=grid_n, step_m=1.0 + ) + + def test_waf_403_aborts_grid_instead_of_empty_result(self) -> None: + """403 WAF на ячейке — бан IP, обход прерывается. + + До #2464-G исключение глушилось и метод отдавал [] — «зон здесь нет», + неотличимое от честного пустого слоя. На проде это 124 дампа из 669 + с territorial_zones_count=0, у 50 из которых соседний legacy-слой + данные всё-таки нашёл. + """ + from app.scrapers.nspd_bulk_client import NspdBulkWafError + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + raise NspdBulkWafError("HTTP 403 WAF") + + with pytest.raises(NspdBulkWafError): + self._grid(_wms) + + def test_all_cells_5xx_raises_instead_of_empty_result(self) -> None: + """Все ячейки упали с 5xx — слой лёг целиком, а не «пуст».""" + from app.scrapers.nspd_bulk_client import NspdBulkServerError + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + raise NspdBulkServerError("HTTP 500 ServiceException") + + with pytest.raises(NspdBulkServerError): + self._grid(_wms) + + def test_partial_5xx_keeps_data_and_does_not_raise(self) -> None: + """Часть ячеек 5xx, часть прошла — отдаём собранное, не бросаем. + + Контроль к двум тестам выше: правка НЕ превращает любую ошибку в отказ. + Именно этот тест ловил бы обратную крайность — «чуть что, роняем обход». + """ + from app.scrapers.nspd_bulk_client import NspdBulkServerError + + good_feat = _make_bulk_feature("feat-ok", {"cad_num": "66:41:001:1"}) + call_n: list[int] = [0] + + async def _wms(*args: Any, **kwargs: Any) -> list[Any]: + call_n[0] += 1 + if call_n[0] <= 2: + raise NspdBulkServerError("HTTP 500 ServiceException") + return [good_feat] + + result = self._grid(_wms) + assert any( + f.feature_id == "feat-ok" for f in result + ), "успешные ячейки должны попасть в результат, даже если часть слоя упала" + def test_returns_nspd_feature_instances(self) -> None: """Метод возвращает list[NSPDFeature] а не NSPDBulkFeature.""" bulk_feat = _make_bulk_feature("feat-xyz", {"cad_num": "66:41:001:1"}) diff --git a/backend/tests/services/forecasting/test_confidence_engine.py b/backend/tests/services/forecasting/test_confidence_engine.py index 49c48a8d..b946e28f 100644 --- a/backend/tests/services/forecasting/test_confidence_engine.py +++ b/backend/tests/services/forecasting/test_confidence_engine.py @@ -133,19 +133,33 @@ class TestFactorFromCount: assert "12.5 мес истории" in f_frac.note -# ── _coverage_factor — покрытие domrf↔objective в % ──────────────────────────── +# ── _coverage_factor — покрытие рынка ценами Objective в % ───────────────────── class TestCoverageFactor: def test_low_coverage_percent_in_note(self) -> None: - # Главный sparse-риск проекта: 2.5% покрытие → low, % в ноте (структурный §15). + # 2.5% покрытия → low, % в ноте (структурный §15). f = _coverage_factor(0.025) assert f.level == "low" assert f.value == 0.025 assert "2.5%" in f.note # #1963: нота человеческая, без внутр.жаргона «domrf↔objective». assert "domrf" not in f.note - assert "будущ" in f.note # говорит про будущее предложение/проекты + + def test_note_names_what_is_actually_measured(self) -> None: + """#2464-H: нота называет ближние ЖК и цену, а не «будущие проекты». + + Значение фактора ВСЕГДА приходит из `analyze.market_data_coverage_pct` + = competitors_priced / competitors_total, то есть доля ближних ЖК (3 км) + с ценой из Objective. Слот `supply_layers.domrf_coverage`, под который + писалась старая формулировка, никто не заполняет. + """ + f = _coverage_factor(0.4) + assert "ближних ЖК" in f.note, f.note + assert "Objective" in f.note, f.note + assert ( + "будущ" not in f.note + ), "нота обещала «будущие проекты», хотя мерится покрытие ближних ЖК ценами" def test_high_coverage(self) -> None: f = _coverage_factor(0.75) @@ -158,6 +172,14 @@ class TestCoverageFactor: assert "неизвестн" in f.note assert "domrf" not in f.note + def test_factor_key_unchanged(self) -> None: + """Ключ фактора остаётся `domrf_coverage` — его читает фронт. + + Контроль к правке #2464-H: меняем только человеческий текст, не контракт + (ForecastConfidenceBlock / ConfidencePanel маппят имя в RU-подпись). + """ + assert _coverage_factor(0.4).name == "domrf_coverage" + def test_sub_one_percent_fraction_stays_low_not_inflated(self) -> None: # BUG #3 регрессия: 0.8% покрытия как доля = 0.008 → low (sparse-риск виден). # До фикта report_assembler отдавал бы 0.8 → high (мнимые 80% покрытия) — diff --git a/backend/tests/services/scrapers/test_ekb_krt_registry.py b/backend/tests/services/scrapers/test_ekb_krt_registry.py index 2463fc8b..9972e217 100644 --- a/backend/tests/services/scrapers/test_ekb_krt_registry.py +++ b/backend/tests/services/scrapers/test_ekb_krt_registry.py @@ -197,32 +197,74 @@ class TestParseBabushkinaBlock: assert site.krt_kind == "жилой застройки" -class TestMultiSiteCountFromDump: - """Проверка количества площадок из реального дампа страницы.""" +# ── Многоблочная страница: два года, три площадки, повторяющийся номер ──────── +# +# Здесь до #2778 стоял TestMultiSiteCountFromDump, читавший +# `.playwright-mcp/ekb-docs/krt_page_raw.html`. Дампа нет ни в репозитории, ни у +# кого-либо на диске, ни на проде: `ekb_krt_sites.raw_block` хранит не HTML, а +# уже очищенный текст (`block_text[:4000]` в ekb_krt_registry.py:338), поэтому +# восстановить вход парсера из БД нельзя. Оба теста не исполнялись НИГДЕ. +# +# Свойство, ради которого они писались, от дампа не зависит и выражается +# страницей из блоков, которые уже лежат в этом файле: +# • несколько

-секций с годами разбираются в одном проходе; +# • нумерация площадок на реальной странице НАЧИНАЕТСЯ ЗАНОВО В КАЖДОМ ГОДУ, +# поэтому «2.» встречается дважды — и site_name всё равно обязан быть +# уникальным (номер сам по себе ключом не является); +# • блоки лежат внутри контейнера
— это ветка `_extract_krt_section` +# через `find_parent`, которой одиночные блоки выше не касаются вовсе. +# +# ЧЕГО ЭТА ЗАМЕНА НЕ ПРОВЕРЯЕТ (осознанная потеря, зафиксирована в #2778): +# «на живой странице ЕКБ ≥14 площадок». Это утверждение о полноте разбора +# реального документа, синтетической страницей оно не заменяется. На проде +# ekb_krt_sites = 15 строк, то есть по факту сейчас всё разбирается, но +# автоматической проверки этого больше нет — она вернётся, когда в репозитории +# появится образец страницы. +_MULTISITE_PAGE = ( + "
" + + _BRUSNIKA_BLOCK + + _BABUSHKINA_BLOCK + + textwrap.dedent( + """\ +

 2. КРТ нежилой застройки в районе ул. Краснофлотцев:

+

Площадь территории: 3,65 га;
\ +Градостроительный потенциал: 117 640 кв. м;
\ +Всего многоквартирных домов: 13.

+ """ + ) + + "
" +) + + +class TestMultiSitePage: + """Разбор страницы из нескольких блоков — без локального дампа.""" @pytest.fixture(scope="class") - def dump_html(self): - """Загружает локальный дамп страницы. + def sites(self): + return parse_krt_page(_MULTISITE_PAGE) - Тест пропускается если файл отсутствует (CI без дампа). - """ - import os + def test_all_three_sites_parsed(self, sites): + """Три блока в двух годовых секциях → три площадки.""" + assert len(sites) == 3, f"Ожидалось 3 площадки, получено {len(sites)}: {sites}" - dump_path = os.path.join( - os.path.dirname(__file__), - "../../../../.playwright-mcp/ekb-docs/krt_page_raw.html", - ) - if not os.path.exists(dump_path): - pytest.skip("krt_page_raw.html dump not found — skip real-dump test") - with open(dump_path, encoding="utf-8") as fh: - return fh.read() - - def test_site_count_from_dump(self, dump_html): - """Из дампа должно распарситься ≥ 14 площадок (реальных на странице 15).""" - sites = parse_krt_page(dump_html) - assert len(sites) >= 14, f"Expected ≥ 14 sites, got {len(sites)}" - - def test_no_duplicate_site_names(self, dump_html): - sites = parse_krt_page(dump_html) + def test_no_duplicate_site_names(self, sites): + """Номер «2.» встречается дважды (2022 и 2024) — имена всё равно уникальны.""" names = [s.site_name for s in sites] - assert len(names) == len(set(names)), "Дубликаты site_name в результате" + assert sum(n.startswith("2.") for n in names) == 2, names + assert len(names) == len(set(names)), f"Дубликаты site_name: {names}" + + def test_blocks_do_not_leak_into_each_other(self, sites): + """Границы блоков соблюдены: договор и застройщик остались у своей площадки. + + Регресс, который этим ловится: если block_end съезжает, «Брусника» и + договор 1-2/21.7-14 из первого блока прилипают ко второму и третьему. + """ + with_developer = [s for s in sites if s.developer_name] + assert len(with_developer) == 1, [s.developer_name for s in sites] + assert "Брусника" in with_developer[0].developer_name + assert [s.contract_number for s in sites].count("1-2/21.7-14") == 1 + + def test_kinds_are_read_per_block(self, sites): + """Третий блок — «нежилой застройки», не должен унаследовать вид соседа.""" + kinds = sorted(s.krt_kind for s in sites if s.krt_kind) + assert kinds == ["жилой застройки", "жилой застройки", "нежилой застройки"], kinds diff --git a/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py b/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py index 292f3653..662e76a7 100644 --- a/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py +++ b/backend/tests/services/scrapers/test_ekb_ppt_tep_parser.py @@ -202,9 +202,7 @@ class TestParseTable11: # Реальная зона должна быть в результате assert "Зона жилой застройки" in zone_names # Артефакты (zone_name пуст, не is_total) отброшены - non_total_no_zone = [ - r for r in result if not r["zone_name"] and not r.get("is_total") - ] + non_total_no_zone = [r for r in result if not r["zone_name"] and not r.get("is_total")] assert non_total_no_zone == [] # Итого-строка осталась assert any(r.get("is_total") for r in result) @@ -483,15 +481,23 @@ class TestParsePptTepMocked: # ── Тест на реальном PDF-образце (skip в CI) ───────────────────────────────── - -_REAL_PDF_PATH = Path( - "C:/Users/user/source/repos/gendesign/.playwright-mcp/ekb-docs/ppt2018_22823_poyasnit_PPT.pdf" +# +# Путь repo-relative, а не абсолютный `C:/Users/user/source/repos/...`: с +# Windows-путём условие пропуска не выполнялось НИ НА ОДНОЙ машине кроме одного +# старого дев-бокса, т.е. эти 6 тестов были не «пропущены по среде», а мертвы. +# Образец не в git (untracked `.playwright-mcp/`), поэтому в CI пропуск остаётся — +# но теперь он снимается тем, что файл кладут в репо-каталог на любой ОС. +_REAL_PDF_PATH = ( + Path(__file__).resolve().parents[4] + / ".playwright-mcp" + / "ekb-docs" + / "ppt2018_22823_poyasnit_PPT.pdf" ) @pytest.mark.skipif( not _REAL_PDF_PATH.exists(), - reason="Реальный PDF-образец отсутствует (только для локального запуска)", + reason=f"Реальный PDF-образец отсутствует ({_REAL_PDF_PATH}) — только локальный запуск", ) class TestParsePptTepRealPdf: """Интеграционные тесты на реальном образце ppt2018_22823.""" diff --git a/backend/tests/services/test_cadastre_bulk.py b/backend/tests/services/test_cadastre_bulk.py index bea84372..d1af98da 100644 --- a/backend/tests/services/test_cadastre_bulk.py +++ b/backend/tests/services/test_cadastre_bulk.py @@ -1393,6 +1393,65 @@ async def test_grid_walk_marks_layer_failed_when_all_cells_500() -> None: assert layer_failed is True +@pytest.mark.asyncio +async def test_grid_walk_reraises_waf_instead_of_swallowing() -> None: + """#2464-A: 403 WAF прерывает обход, а не превращается в «cell не дошёл». + + Контракт harvest_quarter (Raises:) обещает пробросить NspdBulkWafError, но + голый `except Exception` в цикле ячеек его глотал. Прод-замер 13.08: + 23 job'а в cadastre_jobs, суммарно 50 WAF-блоков — и НИ ОДНОГО упавшего + job'а. То есть бан ни разу не остановил сбор, как обещано. + """ + from app.scrapers.nspd_bulk_client import NspdBulkWafError + from app.services.cadastre.bulk_harvest import _grid_walk_category + + db = _mock_db_grid_bbox() + client = AsyncMock() + client.wms_feature_info = AsyncMock(side_effect=NspdBulkWafError("HTTP 403 WAF")) + + with pytest.raises(NspdBulkWafError): + await _grid_walk_category( + db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3 + ) + + +@pytest.mark.asyncio +async def test_grid_walk_reraises_rate_limit() -> None: + """#2464-A: исчерпанные ретраи — тоже не «пустой слой» (caller может retry).""" + from app.scrapers.nspd_bulk_client import NspdBulkRateLimitError + from app.services.cadastre.bulk_harvest import _grid_walk_category + + db = _mock_db_grid_bbox() + client = AsyncMock() + client.wms_feature_info = AsyncMock(side_effect=NspdBulkRateLimitError("429")) + + with pytest.raises(NspdBulkRateLimitError): + await _grid_walk_category( + db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3 + ) + + +@pytest.mark.asyncio +async def test_grid_walk_still_tolerates_network_error_per_cell() -> None: + """Контроль обратной крайности: сетевая ошибка ячейки обход НЕ роняет. + + Зелёный с обеих сторон правки — проверяет, что #2464-A не превратил любое + исключение в отказ квартала. + """ + from app.services.cadastre.bulk_harvest import _grid_walk_category + + db = _mock_db_grid_bbox() + client = AsyncMock() + client.wms_feature_info = AsyncMock(side_effect=OSError("connection reset")) + + upserted, requests, layer_failed = await _grid_walk_category( + db=db, client=client, quarter="66:41:0303161", layer_id=36368, grid_size=3 + ) + assert upserted == 0 + assert requests == 9 + assert layer_failed is False, "сетевые сбои НЕ должны поднимать layer_failed" + + @pytest.mark.asyncio async def test_grid_walk_layer_not_failed_when_some_cells_ok() -> None: """Issue #252: если хоть один cell прошёл — layer_failed=False (слой жив, просто пуст).""" diff --git a/backend/tests/services/test_weather_cache.py b/backend/tests/services/test_weather_cache.py index f04c2322..c5cab6f1 100644 --- a/backend/tests/services/test_weather_cache.py +++ b/backend/tests/services/test_weather_cache.py @@ -10,8 +10,10 @@ DNS-fail повторяет timeout на каждый analyze. 3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две раздельные таблицы внутри модуля). - 4. SINGLE-FLIGHT под конкурентностью: 16 потоков на ОДИН ключ при cold-start → - ровно ОДИН реальный httpx-вызов (lock + check-then-fetch-then-store). + 4. ШТОРМ НА COLD-START: 16 потоков на ОДИН ключ → сеть зовётся не больше раза на + поток, все получают одно и то же значение, и шторм заканчивается сложившимся + кэшем. Не «ровно один вызов»: single-flight'а тут нет и он снят сознательно + (#1370, см. сам тест). 5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at → следующий вызов идёт по сети заново (а не из устаревшего кэша). @@ -23,6 +25,7 @@ from __future__ import annotations import os import threading +import time from collections.abc import Iterator from typing import Any from unittest.mock import MagicMock, patch @@ -242,9 +245,37 @@ class TestSeparateCachesForForecastAndClimate: class TestConcurrencySafe: - def test_single_flight_cold_start_one_network_call(self) -> None: - """16 потоков на ОДИН ключ при cold-start → ровно один реальный httpx-вызов.""" - # GET имитирует медленный ответ, чтобы потоки реально гонялись за один lock. + def test_cold_start_storm_bounded_and_cache_converges(self) -> None: + """16 потоков на ОДИН ключ при cold-start: сеть зовут не больше раза на поток, + все получают одно и то же значение, и после шторма кэш отвечает без сети. + + ЗДЕСЬ СТОЯЛО `get_call_count == 1` («single-flight под lock'ом»), и это + было требование, которого код НЕ выполняет и выполнять не собирается: + сетевой вызов вынесен ЗА lock сознательно (#1370 — иначе все analyze + сериализуются на время httpx-вызова даже для разных координат), а рядом с + ним написано, что cold-start на один ключ «может породить несколько + параллельных запросов… приемлемо». Тест зеленел не потому, что защита + работает, а потому что при GIL первый поток обычно успевал сложить + результат раньше остальных. + + Замер 2026-08-07, 200 штормов подряд: при дефолтном + `sys.getswitchinterval()` 199 раз вышел 1 вызов и один раз 2 — те самые + ~0.5%, которыми гейт красил ЧУЖИЕ PR-ы (#2781: «ожидался 1 сетевой вызов, + было 2» в диффе про парсер КРТ). При `setswitchinterval(1e-6)`, когда + потоки реально чередуются, больше одного вызова дали 197 штормов из 200, + и в 173 из них вызовов было все 16. То есть утверждение ложно почти + всегда, когда гонка вообще случается, — чинить надо было тест. + + Менять КОД (per-key lock ради настоящего single-flight) сознательно НЕ + стали: поведение объявлено приемлемым в #1370 с обоснованием, лишние + запросы бывают только на cold-start одного ключа и они идемпотентны. + Понадобится — это отдельная задача с отдельным обоснованием, а не + побочный эффект правки теста. + + `time.sleep` в ответе делает гонку НЕслучайной: все 16 успевают пройти + промах кэша до первой записи. Так тест мерит худший случай той самой + уступки, а не везение планировщика. + """ start_barrier = threading.Barrier(16) get_call_count = 0 get_lock = threading.Lock() @@ -253,8 +284,7 @@ class TestConcurrencySafe: nonlocal get_call_count with get_lock: get_call_count += 1 - # Микро-задержка — окно для других потоков добраться до lock'а. - # Не делаем sleep большим, чтобы тест не висел. + time.sleep(0.05) # окно, в котором остальные потоки видят промах return _make_httpx_response(_make_forecast_response()) client_ctx = MagicMock() @@ -276,11 +306,27 @@ class TestConcurrencySafe: t.start() for t in threads: t.join() + storm_calls = get_call_count + # Шторм закончился — кэш обязан отвечать сам. Патч ещё активен, так что + # поход в сеть был бы виден счётчиком, а не отказом коннекта. + after_storm = weather_cache.get_weather_cached(56.84, 60.59) assert len(results) == 16 - assert all(r is not None for r in results) - # Single-flight под lock'ом + check-then-fetch — РОВНО один реальный вызов. - assert get_call_count == 1, f"ожидался 1 сетевой вызов, было {get_call_count}" + assert results[0] is not None + assert all(r == results[0] for r in results), "потоки увидели РАЗНЫЕ значения" + # Потолок — число участников: в сеть идут только промахнувшиеся, по разу + # каждый. Больше — значит кто-то фетчит повторно (retry-петля, потерянная + # запись в кэш); меньше единицы невозможно, кэш был пуст. + assert 1 <= storm_calls <= 16, f"сетевых вызовов {storm_calls} при 16 участниках" + # Ключ ОДИН на всех (last-write wins), и цена шторма платится один раз: + # следующий вызов идёт из кэша. Это и есть то, что #1370 обещает взамен + # снятого single-flight — без этого уступка превращается в дыру. + assert list(weather_cache._FORECAST_CACHE) == [weather_cache._round_key(56.84, 60.59)] + assert after_storm == results[0] + assert get_call_count == storm_calls, ( + f"после шторма кэш обязан отвечать без сети, а вызовов стало " + f"{get_call_count} против {storm_calls}" + ) # ────────────────────────────────────────────────────────────────────────────── diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt new file mode 100644 index 00000000..470eef48 --- /dev/null +++ b/backend/tests/skip_allowlist.txt @@ -0,0 +1,98 @@ +# Объявленные пропуски сьюта backend/. +# +# Пропуск, которого здесь нет, роняет прогон (хук в tests/conftest.py). Смысл: не +# запретить пропуски, а запретить НЕЗАМЕТНЫЕ. Проверка, которая тихо перестала +# исполняться, со временем расходится с кодом, и узнают об этом ровно тогда, +# когда на неё понадобилось опереться (#2722, #2729, #2740). +# +# Формат: один nodeid в строке, без «[параметров]»; `#` — комментарий. +# Прежде чем добавить строку, ответь: почему эту проверку нельзя выполнить ЗДЕСЬ +# и где она выполняется вместо этого. Нет ответа — это не пропуск, а дыра. +# +# Список — НАДмножество сред: на CI (ubuntu + libpango + postgres-сервис) часть +# записей не срабатывает, на ноутбуке без них — срабатывает. Запись, чей пропуск +# не случился, безвредна; пропуск без записи — нет. + +# ── WeasyPrint: нужны native-либы (Pango/cairo/GObject) ─────────────────────── +# В CI ЭТИ ТЕСТЫ ИДУТ: ci.yml ставит libcairo2/libpango-1.0-0/libpangoft2-1.0-0. +# Записи нужны только для macOS-dev, где libgobject-2.0-0 нет. +tests/api/v1/test_parcels_forecast.py::test_export_forecast_pdf_returns_pdf +tests/services/exporters/test_report_pdf.py::TestConfidenceSectionParity::test_dict_shaped_factor_produces_valid_pdf +tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_accepts_as_dict_input +tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_non_trivial_length +tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_pdf_magic_bytes +tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_empty_report_still_valid_pdf +tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_garbage_input_does_not_crash +tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_partial_report_some_sections +tests/services/generative/test_exporters.py::test_pdf_export_produces_pdf_bytes +tests/test_layout_tz_pdf.py + +# ── phantom-column gate: нужна КОПИЯ ПРОДОВОЙ схемы ─────────────────────────── +# Смысл этих тестов — поймать колонку, которой нет в реальной БД, поэтому пустой +# сервис-контейнер их не заменяет: против пустой схемы они дали бы красноту, не +# относящуюся к делу. Условие: TEST_DATABASE_URL на SSH-туннель к проду +# (`ssh -N gendesign` → localhost:15432), см. tests/integration/conftest.py. +# ЗАПУСКАТЬ ВРУЧНУЮ после правок SQL-запросов в app/services/**. +tests/integration/test_analyze_parcels_sql.py::TestIrdOverlapSql::test_explain_ird_overlap +tests/integration/test_analyze_parcels_sql.py::TestVelocityCompetitorsSql::test_explain_competitors +tests/integration/test_analyze_parcels_sql.py::TestNeighborsSummarySql::test_explain_neighbors_summary +tests/integration/test_phantom_columns.py::TestCadGeoTables::test_parcel_centroid_query +tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_avg_price_query +tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_supply_batch_query +tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_full_cte_query +tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_radius_query +tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_velocity_competitor_query_columns +tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rns_columns +tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rve_columns +tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_recent_permits_query +tests/integration/test_phantom_columns.py::TestMvLayoutVelocity::test_velocity_by_room_bucket +tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_corpus_room_month_columns +tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_mapping_columns +tests/integration/test_phantom_columns.py::TestObjectiveTables::test_sold_count_query + +# ── SQL-логика на живой Postgres ────────────────────────────────────────────── +# В CI ЭТИ ТЕСТЫ ИДУТ с #2745 (postgres-сервис в ci.yml); до него не бежали ни +# разу с момента написания. Схема не нужна — строят себе временные таблицы. +# Локально: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=... postgres:16 +tests/sql/test_ddu_price_indicator.py::test_basis_and_previous_index +tests/sql/test_ddu_price_indicator.py::test_packaged_deal_bucketed_by_per_unit_area +tests/sql/test_ddu_price_indicator.py::test_prev_period_value_skips_filtered_quarter +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_hand_computed_weighted_average +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_no_zero_months_weighted_equals_naive +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_nullif_prevents_division_by_zero +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_weighted_differs_from_naive_sparse_project +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_zero_deal_months_skew_naive_avg +tests/sql/test_velocity_alerts.py::test_lookback_anchors_to_latest_data_month +tests/sql/test_velocity_alerts.py::test_sharp_drop_is_flagged + +# ── Живая сеть NSPD (nspd.gov.ru) ───────────────────────────────────────────── +# Ходят в внешний сервис: в гейте это флейк и зависимость от чужого аптайма. +# Запуск вручную: uv run pytest tests/scrapers/ -m slow / -m integration -s +tests/scrapers/test_nspd_bulk_client.py::test_list_objects_in_building_real +tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_empty +tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_real_ekb +tests/scrapers/test_nspd_bulk_client.py::test_wms_feature_info_real +tests/scrapers/test_nspd_grid_walk.py::test_live_nspd_grid_walk_skipped + +# ── Образец, которого нет в git ─────────────────────────────────────────────── +# ДОЛГ, а не норма. Было 8 записей; 2 из них (test_ekb_krt_registry.py:: +# TestMultiSiteCountFromDump) сняты в #2778: дампа `krt_page_raw.html` не +# оказалось НИГДЕ — ни в репозитории, ни на дисках, ни на проде (в БД лежит +# `raw_block` = уже очищенный текст, вход парсера из него не восстановить), — +# зато свойство, которое они проверяли (несколько блоков на странице, уникальные +# site_name), выразимо без дампа и теперь проверяется на многоблочной странице, +# собранной из блоков, уже лежащих в том же тест-файле. +# +# Оставшиеся 6 так не лечатся: они проверяют извлечение таблиц из НАСТОЯЩЕГО PDF +# через pdfplumber, соседний класс TestParsePptTep уже мокает pdfplumber целиком. +# Нужен файл `ppt2018_22823_poyasnit_PPT.pdf` (или рабочий URL на ГИСОГД ЕКБ) — +# запрошен у Anton в #2778, там же список того, что оживёт. Причин не коммитить +# нет: официальный градостроительный документ, без персональных данных. +# После получения — положить в tests/fixtures/, поправить `_REAL_PDF_PATH`, +# убрать эти шесть строк. +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_area +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_rows +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_raw_tables_not_empty +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_tep_has_rows +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_itogo +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_rows 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_admin_weight_profiles.py b/backend/tests/test_admin_weight_profiles.py index da53c58d..5d3bd7fc 100644 --- a/backend/tests/test_admin_weight_profiles.py +++ b/backend/tests/test_admin_weight_profiles.py @@ -6,9 +6,15 @@ - GET /{id} → 200 / 404 - PUT /{id} → 200 / 404 - DELETE /{id} → 204 / 404 -- 401 при отсутствии X-Admin-Token - 422 при невалидных weights (неизвестная категория, вес вне диапазона) +Авторизация здесь НЕ проверяется и никогда не проверялась: строка «401 при +отсутствии X-Admin-Token» стояла в этом докстринге, но соответствующего теста в +файле нет — заголовок просто отправлялся во все запросы и никем не читался +(app-level токен снят в PR #437). Гейт `/api/v1/admin/*` живёт в middleware +`rbac_guard` (app/main.py, `_ADMIN_API_RE` → 403 для role != admin) и покрыт +tests/test_rbac.py. + Mock-based: get_db переопределяется через dependency override. """ @@ -24,9 +30,6 @@ from app.core.db import get_db from app.main import app from app.services.site_finder.weight_profiles import WeightProfile -_ADMIN_TOKEN = "test-admin-token" -_HEADERS = {"X-Admin-Token": _ADMIN_TOKEN} - _NOW = datetime.now(UTC) @@ -50,9 +53,7 @@ def _make_profile( @pytest.fixture() -def client_with_token(monkeypatch: pytest.MonkeyPatch) -> TestClient: - """TestClient с переопределённым SCRAPE_ADMIN_TOKEN.""" - monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) +def client() -> TestClient: return TestClient(app) @@ -76,7 +77,7 @@ def _clear_overrides(): # ── GET list ─────────────────────────────────────────────────────────────────── -def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_list_empty(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET ?user_id= → 200 + пустой список.""" mock = MagicMock() _override_db(mock) @@ -85,10 +86,9 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat "app.api.v1.admin_weight_profiles.list_profiles", lambda db, user_id: [], ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-x"}, - headers=_HEADERS, ) assert r.status_code == 200 assert r.json() == [] @@ -96,9 +96,7 @@ def test_list_empty(client_with_token: TestClient, monkeypatch: pytest.MonkeyPat _clear_overrides() -def test_list_returns_profiles( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_list_returns_profiles(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET ?user_id= → 200 + список профилей.""" profiles = [_make_profile(1, is_default=True), _make_profile(2, profile_name="B")] mock = MagicMock() @@ -108,10 +106,9 @@ def test_list_returns_profiles( "app.api.v1.admin_weight_profiles.list_profiles", lambda db, user_id: profiles, ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 200 body = r.json() @@ -124,7 +121,7 @@ def test_list_returns_profiles( # ── POST create ──────────────────────────────────────────────────────────────── -def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_create_then_get(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """POST создаёт профиль, возвращает его со статусом 201.""" created = _make_profile(42, profile_name="Семейный", weights={"school": 2.0, "park": 1.5}) mock = MagicMock() @@ -134,7 +131,7 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk "app.api.v1.admin_weight_profiles.create_profile", lambda db, payload: created, ) - r = client_with_token.post( + r = client.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", @@ -142,7 +139,6 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk "weights": {"school": 2.0, "park": 1.5}, "is_default": False, }, - headers=_HEADERS, ) assert r.status_code == 201 body = r.json() @@ -153,30 +149,28 @@ def test_create_then_get(client_with_token: TestClient, monkeypatch: pytest.Monk _clear_overrides() -def test_create_validation_unknown_category(client_with_token: TestClient) -> None: +def test_create_validation_unknown_category(client: TestClient) -> None: """POST с неизвестной POI-категорией → 422 (Pydantic validation).""" - r = client_with_token.post( + r = client.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", "profile_name": "Bad", "weights": {"supermarket": 1.0}, # не в ALLOWED_CATEGORIES }, - headers=_HEADERS, ) assert r.status_code == 422 -def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) -> None: +def test_create_validation_weight_out_of_bounds(client: TestClient) -> None: """POST с весом вне [-2, 3] → 422.""" - r = client_with_token.post( + r = client.post( "/api/v1/admin/site-finder/weight-profiles", json={ "user_id": "user-1", "profile_name": "Bad", "weights": {"school": 99.0}, }, - headers=_HEADERS, ) assert r.status_code == 422 @@ -184,7 +178,7 @@ def test_create_validation_weight_out_of_bounds(client_with_token: TestClient) - # ── GET one ──────────────────────────────────────────────────────────────────── -def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_get_profile_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET /{id}?user_id= → 200.""" profile = _make_profile(7) mock = MagicMock() @@ -194,10 +188,9 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo "app.api.v1.admin_weight_profiles.get_profile", lambda db, user_id, profile_id: profile, ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles/7", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 200 assert r.json()["id"] == 7 @@ -205,9 +198,7 @@ def test_get_profile_found(client_with_token: TestClient, monkeypatch: pytest.Mo _clear_overrides() -def test_get_profile_not_found( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_get_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """GET /{id} несуществующего профиля → 404.""" mock = MagicMock() _override_db(mock) @@ -216,10 +207,9 @@ def test_get_profile_not_found( "app.api.v1.admin_weight_profiles.get_profile", lambda db, user_id, profile_id: None, ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 404 finally: @@ -229,7 +219,7 @@ def test_get_profile_not_found( # ── PUT update ───────────────────────────────────────────────────────────────── -def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_update_profile(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """PUT /{id} → 200 + обновлённый профиль.""" updated = _make_profile(3, profile_name="Обновлённый") mock = MagicMock() @@ -239,11 +229,10 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke "app.api.v1.admin_weight_profiles.update_profile", lambda db, user_id, profile_id, payload: updated, ) - r = client_with_token.put( + r = client.put( "/api/v1/admin/site-finder/weight-profiles/3", params={"user_id": "user-1"}, json={"profile_name": "Обновлённый"}, - headers=_HEADERS, ) assert r.status_code == 200 assert r.json()["profile_name"] == "Обновлённый" @@ -251,9 +240,7 @@ def test_update_profile(client_with_token: TestClient, monkeypatch: pytest.Monke _clear_overrides() -def test_update_profile_not_found( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_update_profile_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """PUT /{id} несуществующего → 404.""" mock = MagicMock() _override_db(mock) @@ -262,11 +249,10 @@ def test_update_profile_not_found( "app.api.v1.admin_weight_profiles.update_profile", lambda db, user_id, profile_id, payload: None, ) - r = client_with_token.put( + r = client.put( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, json={"profile_name": "X"}, - headers=_HEADERS, ) assert r.status_code == 404 finally: @@ -276,7 +262,7 @@ def test_update_profile_not_found( # ── DELETE ───────────────────────────────────────────────────────────────────── -def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_delete_success(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """DELETE /{id} → 204.""" mock = MagicMock() _override_db(mock) @@ -285,17 +271,16 @@ def test_delete_success(client_with_token: TestClient, monkeypatch: pytest.Monke "app.api.v1.admin_weight_profiles.delete_profile", lambda db, user_id, profile_id: True, ) - r = client_with_token.delete( + r = client.delete( "/api/v1/admin/site-finder/weight-profiles/5", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 204 finally: _clear_overrides() -def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: +def test_delete_not_found(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """DELETE /{id} несуществующего → 404.""" mock = MagicMock() _override_db(mock) @@ -304,10 +289,9 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon "app.api.v1.admin_weight_profiles.delete_profile", lambda db, user_id, profile_id: False, ) - r = client_with_token.delete( + r = client.delete( "/api/v1/admin/site-finder/weight-profiles/999", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 404 finally: @@ -318,7 +302,7 @@ def test_delete_not_found(client_with_token: TestClient, monkeypatch: pytest.Mon def test_list_include_system_calls_with_system( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch + client: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """GET ?include_system=true вызывает list_profiles_with_system, возвращает presets.""" system_profile = _make_profile( @@ -332,10 +316,9 @@ def test_list_include_system_calls_with_system( "app.api.v1.admin_weight_profiles.list_profiles_with_system", lambda db, user_id: [user_profile, system_profile], ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1", "include_system": "true"}, - headers=_HEADERS, ) assert r.status_code == 200 body = r.json() @@ -348,7 +331,7 @@ def test_list_include_system_calls_with_system( def test_list_without_include_system_does_not_call_with_system( - client_with_token: TestClient, monkeypatch: pytest.MonkeyPatch + client: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: """GET без include_system → list_profiles (только пользовательские профили).""" user_profile = _make_profile(1, user_id="user-1") @@ -364,10 +347,9 @@ def test_list_without_include_system_does_not_call_with_system( "app.api.v1.admin_weight_profiles.list_profiles_with_system", lambda db, user_id: called_with_system.append(True) or [], ) - r = client_with_token.get( + r = client.get( "/api/v1/admin/site-finder/weight-profiles", params={"user_id": "user-1"}, - headers=_HEADERS, ) assert r.status_code == 200 assert len(r.json()) == 1 @@ -399,41 +381,3 @@ def test_list_profiles_with_system_service(monkeypatch: pytest.MonkeyPatch) -> N assert len(captured_params) == 1 assert captured_params[0]["system_user_id"] == SYSTEM_USER_ID assert captured_params[0]["user_id"] == "user-test" - - -# ── Auth ─────────────────────────────────────────────────────────────────────── - - -_TOKEN_REMOVED_REASON = ( - "App-level X-Admin-Token header удалён 2026-05-23 (см. docstring " - "app/api/v1/admin_weight_profiles.py: Caddy basic_auth PR #426 + RBAC достаточны, " - "двойная auth избыточна). Endpoint больше не несёт verify_admin_token dependency. " - "В test-mode RBAC bypass'ится (CI-rehab 1/3) → 401 здесь недостижим без реверта " - "security-решения. Тест проверял удалённое поведение." -) - - -@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON) -def test_unauthorized_no_token(monkeypatch: pytest.MonkeyPatch) -> None: - """Запрос без X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23).""" - monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) - client = TestClient(app) - r = client.get( - "/api/v1/admin/site-finder/weight-profiles", - params={"user_id": "user-1"}, - # без headers — нет X-Admin-Token - ) - assert r.status_code == 401 - - -@pytest.mark.skip(reason=_TOKEN_REMOVED_REASON) -def test_unauthorized_wrong_token(monkeypatch: pytest.MonkeyPatch) -> None: - """Неверный X-Admin-Token → 401 (устарело: токен-гейт удалён 2026-05-23).""" - monkeypatch.setattr("app.core.config.settings.scrape_admin_token", _ADMIN_TOKEN) - client = TestClient(app) - r = client.get( - "/api/v1/admin/site-finder/weight-profiles", - params={"user_id": "user-1"}, - headers={"X-Admin-Token": "wrong-token"}, - ) - assert r.status_code == 401 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_eesk_reserve_loader.py b/backend/tests/test_eesk_reserve_loader.py index df4dff70..3a1c9032 100644 --- a/backend/tests/test_eesk_reserve_loader.py +++ b/backend/tests/test_eesk_reserve_loader.py @@ -184,10 +184,36 @@ def test_load_ps_35_220_parse_and_match() -> None: assert first["installed"] == 40.0 assert first["reserve"] == 15.0 assert first["district"] == "Ленинский" - assert first["load_pct"] == 41.0 # доля 0.41 → 41.0% assert first["asof"] == date(2026, 6, 30) +def test_load_ps_35_220_does_not_write_load_percent() -> None: + """#2464-B: степень загрузки НЕ уходит в UPDATE и не попадает в load_index. + + Раньше значение колонки E писалось как + `load_index = COALESCE(load_index, CAST(:load_pct AS text))`. load_index — + категориальная колонка ('open'|'limited'|'closed'|NULL, + data/sql/180_connection_capacity.sql:35): число строкой фронт отбрасывает + в «неизвестно» (classifyLoadIndex), а в power_summary.by_load_index + появлялся бы бакет с именем вроде "41.0". + + На проде не стреляло только потому, что load_index заполнен у всех строк + (open 2741 / limited 346 / closed 329, NULL 0 — замер верификации 13.08), + и COALESCE не проваливался. + """ + from datetime import date + + db = _FakeSession(scalar_value=None, rowcount=1) + ee.load_ps_35_220(db, _build_ps_workbook(), date(2026, 6, 30)) + + # Комментарии из SQL убираем: слово load_index встречается в пояснении, + # а проверять надо ИСПОЛНЯЕМЫЙ текст, а не прозу вокруг него. + sql_code = "\n".join(line.split("--", 1)[0] for line in str(db.calls[0][0]).splitlines()) + assert "load_index" not in sql_code, sql_code + for _sql, params in db.calls: + assert "load_pct" not in params, params + + def test_load_ps_35_220_unmatched_counted() -> None: """ПС без совпадения (rowcount=0 — напр. не ЕЭСК) → unmatched, не падаем.""" from datetime import date diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index c432abcf..a62f2567 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -9,3 +9,21 @@ def test_health() -> None: assert response.status_code == 200 body = response.json() assert body["status"] == "ok" + + +def test_health_head_ok_no_body() -> None: + """HEAD /health — то, что реально шлёт внешний uptime-monitor через Caddy + (`handle /health { reverse_proxy backend:8000 }`, Caddyfile:60), не GET. + + Starlette не добавляет HEAD автоматически к `@app.get()` (в отличие от + низкоуровневого `Route(methods=["GET"])`) — без явного `@app.head()` + прод-эндпоинт отдаёт 405 на HEAD. + """ + client = TestClient(app) + response = client.head("/health") + assert response.status_code == 200 + assert response.content == b"" + # RFC 9110 §9.3.2 — заголовки представления (Content-Type) должны совпадать + # с GET; Content-Length допустимо не совпадать (payload header field, MAY + # быть опущен для HEAD). + assert response.headers["content-type"] == "application/json" 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/backend/tests/test_sentry_init.py b/backend/tests/test_sentry_init.py index 75538a31..19ff11d1 100644 --- a/backend/tests/test_sentry_init.py +++ b/backend/tests/test_sentry_init.py @@ -2,14 +2,27 @@ Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init() только при непустом GLITCHTIP_DSN, что release-fallback работает корректно, -и что scrub_sensitive_query redact-ит api keys из URL spans. +что scrub_sensitive_query redact-ит api keys из URL spans, что scrub_pii_event +redact-ит consumer-PII (client_name/client_phone/client_email/phone/email/name/ +company/message) из request.data/extra/contexts (#2457-review), и — в конце +файла — что до транспорта не доезжают ни ПДн тела запроса, ни значения +локальных переменных кадра стека, ни второе событие о сбое самого скраба +(#2753, поведение через подставной транспорт вместо поиска подстроки). """ +import json import os +import pathlib +import subprocess +import sys +from functools import lru_cache from unittest.mock import patch +import pytest import sentry_sdk +_BACKEND_ROOT = pathlib.Path(__file__).resolve().parents[1] + def test_sdk_imports_without_error() -> None: """Все интеграции импортируются без ModuleNotFoundError.""" @@ -156,3 +169,331 @@ def test_scrub_handles_missing_spans() -> None: event: dict = {"request": {"url": "https://example.com"}} result = scrub_sensitive_query(event, {}) assert result["request"]["url"] == "https://example.com" + + +# ── scrub_pii_event (портировано из tradein-mvp, #396 / аудит-фикс) ──────────── +# +# send_default_pii=False в sentry_sdk.init НЕ закрывает consumer-PII, попадающий +# в request.data / extra / contexts (лиды pilot.py, chat.py) — это user-data, +# а не PII-заголовки/cookies, которые режет сам флаг (проверено на sentry-sdk +# 2.58). scrub_pii_event закрывает этот вектор ключ-based редактором. + + +def test_scrub_pii_redacts_request_data() -> None: + """scrub_pii_event заменяет client_name/phone/email в request.data.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": { + "data": { + "client_name": "Иван Иванов", + "phone": "+79991234567", + "email": "ivan@example.com", + "address": "Екатеринбург, ул. Ленина 1", + } + } + } + result = scrub_pii_event(event, {}) + data = result["request"]["data"] + assert data["client_name"] == "[REDACTED]" + assert data["phone"] == "[REDACTED]" + assert data["email"] == "[REDACTED]" + # non-PII поле не трогаем + assert data["address"] == "Екатеринбург, ул. Ленина 1" + + +def test_scrub_pii_redacts_pilot_request_company_and_message() -> None: + """scrub_pii_event заменяет company/message — свободный текст + PilotRequestInput (app/api/v1/pilot.py), куда чаще всего прилетают + телефоны/имена/адреса, а не только фиксированные name/phone/email + (#2457-review).""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": { + "data": { + "company": "ООО Ромашка", + "message": "Меня зовут Иван, звоните на +79991234567", + "source": "landing", + } + } + } + result = scrub_pii_event(event, {}) + data = result["request"]["data"] + assert data["company"] == "[REDACTED]" + assert data["message"] == "[REDACTED]" + # non-PII поле не трогаем + assert data["source"] == "landing" + + +def test_scrub_pii_redacts_client_prefixed_keys() -> None: + """Полный набор ключей МЕРЫ (client_name/client_phone/client_email, #396) — + Птица их сегодня не использует, но одинаковый механизм на оба продукта + проще сопровождать (#2457-review).""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "extra": { + "client_name": "Иван", + "client_phone": "+79991234567", + "client_email": "ivan@example.com", + } + } + result = scrub_pii_event(event, {}) + extra = result["extra"] + assert extra["client_name"] == "[REDACTED]" + assert extra["client_phone"] == "[REDACTED]" + assert extra["client_email"] == "[REDACTED]" + + +def test_scrub_pii_redacts_extra() -> None: + """scrub_pii_event заменяет PII-ключи в extra, не трогая остальное.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "extra": { + "phone": "+79990000000", + "email": "x@y.ru", + "name": "Пётр", + "lead_id": 42, + } + } + result = scrub_pii_event(event, {}) + extra = result["extra"] + assert extra["phone"] == "[REDACTED]" + assert extra["email"] == "[REDACTED]" + assert extra["name"] == "[REDACTED]" + assert extra["lead_id"] == 42 + + +def test_scrub_pii_is_case_insensitive() -> None: + """Сверка PII-ключей case-insensitive.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"extra": {"Client_Name": "Анна", "PHONE": "+7900"}} + result = scrub_pii_event(event, {}) + assert result["extra"]["Client_Name"] == "[REDACTED]" + assert result["extra"]["PHONE"] == "[REDACTED]" + + +def test_scrub_pii_redacts_nested_contexts() -> None: + """scrub_pii_event проходит вложенные dict в contexts, не трогая non-PII.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"contexts": {"trace": {"op": "http"}, "lead": {"email": "z@z.ru"}}} + result = scrub_pii_event(event, {}) + assert result["contexts"]["lead"]["email"] == "[REDACTED]" + assert result["contexts"]["trace"]["op"] == "http" + + +def test_scrub_pii_leaves_non_pii_untouched() -> None: + """scrub_pii_event не трогает event без PII-ключей.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = { + "request": {"data": {"region": "66", "area_sqm": 50}}, + "extra": {"job": "geocode"}, + "level": "error", + } + result = scrub_pii_event(event, {}) + assert result["request"]["data"] == {"region": "66", "area_sqm": 50} + assert result["extra"] == {"job": "geocode"} + assert result["level"] == "error" + + +def test_scrub_pii_handles_missing_sections() -> None: + """scrub_pii_event не падает на пустом event.""" + from app.observability.sentry_scrub import scrub_pii_event + + result = scrub_pii_event({}, {}) + assert result == {} + + +def test_scrub_pii_handles_none_and_non_dict_sections() -> None: + """scrub_pii_event не бросает исключений на None/не-dict секциях.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"request": None, "extra": None, "contexts": "not-a-dict"} + result = scrub_pii_event(event, {}) + assert result is event + + +def test_scrub_pii_returns_event_not_none() -> None: + """before_send должен вернуть event (не None) — иначе SDK дропнет отчёт.""" + from app.observability.sentry_scrub import scrub_pii_event + + event: dict = {"request": {"data": {"client_name": "X"}}} + result = scrub_pii_event(event, {}) + assert result is not None + assert result is event + + +# ── scrub_event (composed before_send / before_send_transaction handler) ─────── +# +# scrub_event — ЕДИНЫЙ хендлер, которым в main.py/celery_app.py вешаются ОБА +# канала (before_send И before_send_transaction). До #2457-review composed-хук +# висел только на before_send, а before_send_transaction оставался на голом +# scrub_sensitive_query (только URL) — Starlette-интеграция кладёт request.data +# на transaction-scope так же, как на error-scope, поэтому PII продолжало течь +# через transaction-канал при glitchtip_traces_sample_rate > 0. + + +def test_scrub_event_composes_pii_and_url_secret_scrub() -> None: + """scrub_event применяет PII-scrub (ключ-based) И URL query-string secret + redact (regex) оба разом, не заменяя друг друга — разные классы данных.""" + from app.observability.sentry_scrub import scrub_event + + event: dict = { + "request": { + "data": {"client_name": "Иван"}, + "url": "https://example.com?api_key=supersecret", + } + } + result = scrub_event(event, {}) + assert result is not None + assert result["request"]["data"]["client_name"] == "[REDACTED]" + assert "[REDACTED]" in result["request"]["url"] + assert "supersecret" not in result["request"]["url"] + + +def test_scrub_event_returns_event_not_none() -> None: + """scrub_event всегда возвращает event (не None) — иначе SDK дропнет отчёт.""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"name": "X"}}} + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +def test_scrub_event_survives_scrub_pii_event_exception() -> None: + """try/except в scrub_event — предохранитель: sentry_sdk оборачивает + before_send в capture_internal_exceptions, который при исключении ТОЛЬКО + логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что редактор упал). + Если scrub_pii_event падает — scrub_event обязан вернуть event, а не + пробросить исключение дальше (#2457-review).""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"client_name": "X"}}} + with patch( + "app.observability.sentry_scrub.scrub_pii_event", + side_effect=RuntimeError("boom"), + ): + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +def test_scrub_event_survives_scrub_sensitive_query_exception() -> None: + """То же самое для второго шага композиции (URL-secret redact).""" + from app.observability.sentry_scrub import scrub_event + + event: dict = {"request": {"data": {"name": "X"}}} + with patch( + "app.observability.sentry_scrub.scrub_sensitive_query", + side_effect=RuntimeError("boom"), + ): + result = scrub_event(event, {}) + assert result is not None + assert result is event + + +# ── wiring: ПДн не доходят до транспорта (поведение, а не текст исходника) ───── +# +# До #2753 проводка проверялась поиском подстроки `before_send=scrub_event` в +# файле. Такой гейт зелен и на разорванной проводке: обе точки входа несут +# многострочные комментарии, где те же подстроки встречаются, — достаточно +# удалить сам аргумент, оставив комментарий. Хуже того, подстрока ничего не +# говорит о том, ДОШЛИ ли ПДн до транспорта: их можно выпустить и при живом +# before_send (локальные переменные кадра стека уходят мимо ключевого скраба). +# +# Поэтому проверяем поведение: поднимаем настоящую инициализацию в подпроцессе +# (`tests/_sentry_wiring_probe.py`), подменяем транспорт и смотрим, что до него +# доехало. Наружу не уходит ничего — DSN указывает на несуществующий хост, а +# `capture_envelope` подменён до первого события. + + +@lru_cache(maxsize=2) +def _probe(module: str) -> str: + """Прогнать пробу проводки для точки входа `module`; вернуть JSON-строку.""" + env = { + **os.environ, + "TESTING": "1", + # Синтаксически валидный DSN на несуществующий хост: init отработает, + # сети не будет даже если транспорт когда-нибудь перестанут подменять. + "GLITCHTIP_DSN": "https://probe@localhost.invalid/1", + # Явно: у запуска скрипта в sys.path[0] попадает КАТАЛОГ СКРИПТА (tests/), + # и без этого `import app` уехал бы в editable-установку пакета — то есть + # проба мерила бы чужое дерево, а не то, что рядом с ней лежит. + "PYTHONPATH": os.pathsep.join([str(_BACKEND_ROOT), os.environ.get("PYTHONPATH", "")]), + } + proc = subprocess.run( + [sys.executable, str(_BACKEND_ROOT / "tests" / "_sentry_wiring_probe.py"), module], + cwd=_BACKEND_ROOT, + env=env, + capture_output=True, + text=True, + timeout=300, + check=False, + ) + assert proc.returncode == 0, f"проба упала: {proc.stderr[-3000:]}" + return proc.stdout.strip().splitlines()[-1] + + +@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) +def test_pii_never_reaches_transport(module: str) -> None: + """Оба канала (error И transaction) отдают транспорту событие без ПДн. + + Красный, если из `sentry_sdk.init()` убрать `before_send` ИЛИ + `before_send_transaction` — комментарий с теми же словами не спасает. + """ + probe = json.loads(_probe(module)) + markers = probe["markers"] + + for channel in ("error", "transaction"): + payload = probe["payloads"][channel] + # Контроль «событие вообще доехало»: без него проверка была бы зелёной + # и на пробе, которая молча ничего не отправила. + assert probe["counts"][channel] == 1, f"{module}/{channel}: событие не доехало" + assert "[REDACTED]" in payload, f"{module}/{channel}: скраб не отработал" + + leaked = [key for key in ("phone", "free_text", "url_secret") if markers[key] in payload] + assert leaked == [], f"{module}/{channel}: до транспорта дошли ПДн — {leaked}" + + +@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) +def test_local_variables_never_reach_transport(module: str) -> None: + """`include_local_variables=False`: значения локальных переменных кадра стека + не уходят в мониторинг (#2753). + + Ключевой скраб такое не ловит по построению — имя переменной произвольно, + а сверка идёт по именам. Красный, если флаг убрать из `sentry_sdk.init()` + (в sentry-sdk он по умолчанию `True`). + """ + probe = json.loads(_probe(module)) + payload = probe["payloads"]["exception"] + + assert probe["counts"]["exception"] == 1 + assert "sentry-wiring-probe boom" in payload, "событие с исключением не доехало" + + assert ( + probe["markers"]["local_var"] not in payload + ), f"{module}: значение локальной переменной ушло в мониторинг" + + +@pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) +def test_scrub_failure_does_not_spawn_second_event(module: str) -> None: + """Сбой самого скраба не порождает ВТОРОГО события (#2753). + + `logger` этого модуля внесён в `ignore_logger`, иначе строка журнала об + отказе ушла бы в мониторинг через LoggingIntegration (event_level=ERROR), + снова попала бы в скраб, снова упала — рекурсия, защиты от которой в SDK + нет (проверено на коде до фикса: не завершается). Красный, если + `ignore_logger` убрать: обработчик войдёт повторно. + """ + probe = json.loads(_probe(module)) + + assert ( + probe["scrub_handler_entries"] == 1 + ), "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг" + assert probe["counts"]["scrub_failure"] == 1 diff --git a/backend/tests/test_skip_guard_loud.py b/backend/tests/test_skip_guard_loud.py new file mode 100644 index 00000000..6f83d126 --- /dev/null +++ b/backend/tests/test_skip_guard_loud.py @@ -0,0 +1,44 @@ +"""Проверка, что сторож пропусков кричит под Actions (#2871).""" + +from __future__ import annotations + +import types + +import tests.conftest as ct + + +def _run_guard(monkeypatch, capsys, *, ci: bool, observed: set[str]) -> str: + monkeypatch.setattr(ct, "_observed_skips", observed) + monkeypatch.setattr(ct, "_allowed_skips", lambda: set()) + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + monkeypatch.delenv("CI", raising=False) + if ci: + monkeypatch.setenv("GITHUB_ACTIONS", "true") + session = types.SimpleNamespace(exitstatus=0) + ct.pytest_sessionfinish(session, 0) + return capsys.readouterr().out, session.exitstatus + + +def test_guard_emits_error_annotation_under_actions(monkeypatch, capsys) -> None: + out, rc = _run_guard(monkeypatch, capsys, ci=True, observed={"tests/x.py::test_y"}) + assert "::error::" in out, "под Actions сторож обязан подниматься в аннотации" + assert "tests/x.py::test_y" in out + assert rc == 1 + + +def test_guard_stays_quiet_locally(monkeypatch, capsys) -> None: + """Контроль: локально ::error:: не нужен, человеческое сообщение остаётся.""" + out, rc = _run_guard(monkeypatch, capsys, ci=False, observed={"tests/x.py::test_y"}) + assert "::error::" not in out + assert "НЕУЧТЁННЫЙ ПРОПУСК" in out + assert rc == 1 + + +def test_guard_silent_when_all_skips_declared(monkeypatch, capsys) -> None: + """Контроль: без незадекларированных пропусков сторож молчит и не роняет.""" + monkeypatch.setattr(ct, "_observed_skips", set()) + monkeypatch.setattr(ct, "_allowed_skips", lambda: set()) + session = types.SimpleNamespace(exitstatus=0) + ct.pytest_sessionfinish(session, 0) + assert capsys.readouterr().out == "" + assert session.exitstatus == 0 diff --git a/backend/tests/test_weight_profiles.py b/backend/tests/test_weight_profiles.py index 9cbb97c0..87b62883 100644 --- a/backend/tests/test_weight_profiles.py +++ b/backend/tests/test_weight_profiles.py @@ -8,11 +8,12 @@ Mock-based — без реальной БД. Проверяет: - resolve_weights: нет user_id и profile_id → системные дефолты - resolve_weights: user_id задан, default-профиль есть → его веса - resolve_weights: profile_id задан → его веса -- resolve_weights: профиль не найден → системные дефолты (fallback) +- resolve_weights: профиль не найден → системные дефолты (fallback) + source != profile """ from __future__ import annotations +import logging from unittest.mock import MagicMock import pytest @@ -110,7 +111,8 @@ def test_resolve_weights_system_default() -> None: """Оба аргумента None → возвращаются системные веса.""" db = MagicMock() result = resolve_weights(db, user_id=None, profile_id=None) - assert result == _SYSTEM_POI_WEIGHTS + assert result.weights == _SYSTEM_POI_WEIGHTS + assert result.source == "system" # db не должен вызываться вообще db.execute.assert_not_called() @@ -119,7 +121,7 @@ def test_resolve_weights_system_default_returns_copy() -> None: """Возвращается копия словаря, не ссылка на _SYSTEM_POI_WEIGHTS.""" db = MagicMock() result = resolve_weights(db, user_id=None, profile_id=None) - result["school"] = 999.0 + result.weights["school"] = 999.0 # Оригинал не изменён assert _SYSTEM_POI_WEIGHTS["school"] == 1.5 @@ -156,7 +158,8 @@ def test_resolve_weights_uses_default_profile() -> None: finally: wp_module.get_default_profile = original - assert result == custom_weights + assert result.weights == custom_weights + assert result.source == "user_default" def test_resolve_weights_uses_specific_profile() -> None: @@ -175,7 +178,8 @@ def test_resolve_weights_uses_specific_profile() -> None: finally: wp_module.get_profile = original - assert result == custom_weights + assert result.weights == custom_weights + assert result.source == "profile" def test_resolve_weights_profile_not_found_fallback() -> None: @@ -194,7 +198,9 @@ def test_resolve_weights_profile_not_found_fallback() -> None: wp_module.get_profile = original_get wp_module.get_default_profile = original_default - assert result == _SYSTEM_POI_WEIGHTS + assert result.weights == _SYSTEM_POI_WEIGHTS + # #2811: главное — источник НЕ выдаёт себя за профиль, которого не нашли + assert result.source == "system" def test_resolve_weights_empty_profile_weights_fallback() -> None: @@ -212,4 +218,52 @@ def test_resolve_weights_empty_profile_weights_fallback() -> None: finally: wp_module.get_default_profile = original_default - assert result == _SYSTEM_POI_WEIGHTS + assert result.weights == _SYSTEM_POI_WEIGHTS + assert result.source == "system" + + +def test_resolve_weights_profile_id_without_owner_is_not_profile( + caplog: pytest.LogCaptureFixture, +) -> None: + """#2811 сценарий 1: profile_id есть, user_id нет → первая ветка не выполняется. + + Ровно это жило на проде: ран analysis_runs #4000 от 2026-08-07 — + source='profile', profile_id=1, а tram_stop=-0.5 (системный, у профиля 1 он + -0.4). Метка обязана быть 'system', а промах — попасть в warning. + """ + db = MagicMock() + with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"): + result = resolve_weights(db, user_id=None, profile_id=1) + + assert result.source == "system" + assert result.weights == _SYSTEM_POI_WEIGHTS + assert "profile_id=1" in caplog.text + db.execute.assert_not_called() # профиль даже не искали + + +def test_resolve_weights_missing_profile_falls_to_user_default_not_profile( + caplog: pytest.LogCaptureFixture, +) -> None: + """#2811 сценарий 3: profile_id не найден, но у юзера есть default-профиль. + + Худший вариант: веса НЕ системные, поэтому по значениям подмена вообще не + видна. Метка должна сказать 'user_default', а не 'profile'. + """ + import app.services.site_finder.weight_profiles as wp_module + + default_profile = _make_profile_mock({"school": 2.0}) + db = MagicMock() + original_get = wp_module.get_profile + original_default = wp_module.get_default_profile + wp_module.get_profile = lambda _db, uid, pid: None + wp_module.get_default_profile = lambda _db, uid: default_profile + try: + with caplog.at_level(logging.WARNING, logger="app.services.site_finder.weight_profiles"): + result = resolve_weights(db, user_id="user-1", profile_id=999) + finally: + wp_module.get_profile = original_get + wp_module.get_default_profile = original_default + + assert result.source == "user_default" + assert result.weights == {"school": 2.0} + assert "profile_id=999" in caplog.text 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/docker-compose.prod.yml b/docker-compose.prod.yml index 768c2af9..17d1a79c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -17,11 +17,77 @@ # # Postgres + Redis run alongside the app on the same VM (Discovery mode). # Volumes are shared with docker-compose.yml so switching between files preserves data. +# +# ── logging: journald (#2761) ──────────────────────────────────────────────── +# До этого у стека НЕ БЫЛО потолка вообще: дефолтный json-file растёт без границ +# и живёт в /var/lib/docker/containers// (умирает вместе с контейнером). +# Тот же anchor и тот же драйвер, что у trade-in (#2758/#2741) — намеренно ОДИН +# способ на обе половины, расхождение двух стеков дороже в поддержке. +# +# Замер прод 2026-08-06 (МБ/сутки = размер json-file / возраст контейнера): +# glitchtip-worker 31.9 ← 2.59 ГБ накоплено, 81% всего роста стека +# postgres 2.2 ← 162 МБ за 74 дня +# backend 1.9 · worker 1.3 · beat 0.6 · caddy 0.5 · остальные <0.5 +# ИТОГО ~39 МБ/сутки +# Бюджет journald (замер там же, сообщение самого systemd-journald): +# "System Journal ... is 2.2G, max 4.0G" — потолок 4G ЭМПИРИЧЕСКИ подтверждён +# (journald.conf пуст, все дефолты; 10% от 145G = 14.5G, но капается 4G). +# Системный поток 2.2G/103 суток ≈ 22 МБ/сутки. После этой правки +# 22 + 39 + tradein(единицы) ≈ 65 МБ/сутки → 4096/65 ≈ 60 суток глубины. +# Дисковый эффект ОТРИЦАТЕЛЬНЫЙ (в нашу пользу): 4G — это потолок с +# самовытеснением, а сегодня glitchtip-worker растёт БЕЗ потолка; плюс +# пересоздание контейнера удаляет его json-file → разово освобождает ~2.6 ГБ. +# +# КАК ЧИТАТЬ (проверено на проде 2026-08-06, ровно тем доступом, что есть): +# docker logs gendesign-backend-1 # как и раньше — только текущий контейнер +# # История через пересоздания: журнал принадлежит root:systemd-journal, а +# # deploy-юзер gendesign состоит в docker/sudo, но НЕ в adm/systemd-journal, и +# # sudo просит пароль (`sudo -n` молча падает) → голый journalctl даёт +# # "No entries". Рабочий однострочник — через docker-группу: +# docker run --rm -v /:/host:ro alpine chroot /host sh -c \ +# 'TZ=UTC journalctl -t gendesign-backend-1 -o short-iso --since "2026-08-07 00:00"' +# # TZ=UTC обязателен: --since/--until разбираются в ЛОКАЛЬНОМ времени хоста +# # (+03), и флаг --utc на это НЕ влияет — он меняет только вывод (#2760). +# # По метке контейнера: CONTAINER_NAME=gendesign-backend-1 (или CONTAINER_ID= +# # — так читается лог УЖЕ УДАЛЁННОГО контейнера). +# Владельцу стоит разово выдать `usermod -aG adm gendesign` — тогда journalctl +# заработает напрямую (host-config, не этот файл). До этого правка не регрессия. +# +# ⚠️ Blast radius ПЕРВОГО деплоя: log-driver — свойство создания контейнера, так +# что `compose up -d` пересоздаст ВСЁ. backend/worker/beat/caddy/forwarder и так +# force-recreate'ятся каждым деплоем (см. deploy.yml) — ИНКРЕМЕНТ этой правки: +# postgres (~10с даунтайма), redis (брокер celery), osrm + osrm-walk (перезагрузка +# MLD-графа в RAM), frontend, glitchtip-web/worker. Разово, деплоить в окно без +# ночных прогонов. +# Ceiling: journald рейт-лимитит 10000 сообщений / 30s на сервис (дефолт) — при +# флуде в журнал попадёт "Suppressed N messages". Текущий пик (glitchtip-worker +# 31.9 МБ/сутки ≈ 3 строки/с) ниже лимита на три порядка; если появится — это +# host drop-in journald.conf.d, не этот файл. +# tag: имя контейнера, а не ID — SYSLOG_IDENTIFIER стабилен между пересозданиями. +# +# ⚠️ НЕ переносить этот anchor в корневой docker-compose.yml: он для локальной +# разработки, а в Docker Desktop (macOS/Windows) journald в VM нет — контейнеры +# просто не стартуют. Ceiling для dev-логов при нужде — json-file max-size. +x-logging: &default-logging + driver: journald + options: + tag: "{{.Name}}" services: postgres: image: postgis/postgis:16-3.4 + logging: *default-logging restart: unless-stopped + # #2812: /dev/shm под dynamic_shared_memory_type=posix. Умолчание Docker — 64 МБ, + # и параллельные планы кладут туда свои DSM-сегменты. Прод-замер 2026-08-10: + # база постоянно держит ~9.8 МиБ (DSA кумулятивной статистики pgstat), один + # параллельный запрос Объектива берёт ~15.4 МиБ → 4-й одновременный не влезает + # в 64 МиБ и падает `DiskFull: could not resize shared memory segment`. Ровно это + # и случилось: 6 отказов за 1.2 с (market_metrics / sales_series / special_indices). + # 1 ГиБ = ~65 таких запросов; потолок celery (--concurrency=8) + request-path ≈ 12. + # tmpfs выделяется ПО ФАКТУ: значение — потолок, не резерв (0 Б до первого запроса). + # Rollback = убрать строку (снова 64 МиБ) + пересоздать контейнер. + shm_size: 1gb environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} @@ -46,6 +112,7 @@ services: redis: image: redis:7-alpine + logging: *default-logging restart: unless-stopped volumes: - redis_data:/data @@ -54,6 +121,40 @@ services: interval: 10s timeout: 3s retries: 5 + # #2709: redis вводится в gendesign_shared, чтобы tradein-backend вообще МОГ + # его достать. До этого redis жил только в gendesign_default, а tradein — в + # gendesign_shared + tradein-net: общей сети НЕТ, поэтому REDIS_URL там не + # резолвился НИ ПОД КАКИМ именем. Это была не «забытая переменная», а + # отсутствующая связность (см. #2709). + # + # Почему общий инстанс, а не свой redis в стеке trade-in: deploy-tradein.yml + # поднимает стек как `up -d --no-deps $SERVICES`, где SERVICES — + # ЗАХАРДКОЖЕННЫЙ список (browser backend frontend tgbot [scraper]). Новый + # сервис в tradein-compose в этот список не попадает и `--no-deps` его не + # подтянет → контейнер просто никогда бы не стартовал, а REDIS_URL указывал + # бы в пустоту. Правка того списка = правка deploy-tradein.yml, который + # сейчас заморожен (#2680 ждёт человека). Общий инстанс обходит это целиком. + # + # aliases: тот же приём, что уже применён к postgres выше — стабильное имя + # gendesign-redis вместо compose-зависимого gendesign-redis-1. + # ⚠️ `default` ОБЯЗАН быть перечислен явно: как только у сервиса появляется + # блок networks:, неявная привязка к default пропадает, и backend/worker/ + # beat/glitchtip потеряли бы брокер (та же грабля описана у postgres). + # + # Разделение ключей — по НОМЕРУ БД, инстанс общий: + # db0 — gendesign (celery-брокер + кэши бэкенда), 2166 ключей + # db1 — trade-in (SearchCache) ← вводится здесь + # db2 — glitchtip (см. REDIS_URL ниже) + # Ceiling: maxmemory=0 / noeviction на инстансе НЕ трогаем — allkeys-lru на + # брокере celery вытеснял бы поставленные в очередь таски. Значит tradein + # обязан ставить TTL на каждый ключ (он ставит: SET ... ex=ttl). Если + # tradein когда-нибудь начнёт писать без TTL, упрётся весь инстанс, включая + # celery. Тогда — отдельный инстанс, а не смена политики вытеснения. + networks: + default: {} + shared: + aliases: + - gendesign-redis # OSRM routing engine (#39 — site-finder /analyze road/walking distances to POI # вместо straight-line ST_Distance). INFRA-only здесь: интеграция в /analyze — @@ -76,6 +177,7 @@ services: # отдельно (docs/osrm-routing.md). osrm: image: osrm/osrm-backend:latest + logging: *default-logging restart: unless-stopped command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}.osrm volumes: @@ -103,6 +205,7 @@ services: # (backend не depends_on, флаг use_osrm_distances OFF). Backend ходит к http://osrm-walk:5000. osrm-walk: image: osrm/osrm-backend:latest + logging: *default-logging restart: unless-stopped command: osrm-routed --algorithm mld --max-table-size 8000 /data/${OSRM_REGION:-sverdlovsk}-foot.osrm volumes: @@ -118,6 +221,7 @@ services: backend: image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest} + logging: *default-logging restart: unless-stopped # .env.runtime пишется deploy.yml через SSH (SENTRY_RELEASE=$IMAGE_TAG). # required: false — compose не падает если файла нет (первый деплой). @@ -153,6 +257,7 @@ services: frontend: image: ghcr.io/lekss361/gendesign-frontend:${IMAGE_TAG:-latest} + logging: *default-logging restart: unless-stopped ports: - "127.0.0.1:3000:3000" @@ -172,6 +277,7 @@ services: worker: # Отдельный chromium-образ (+200 МБ Playwright). См. backend/Dockerfile target=runner-with-chromium. image: ghcr.io/lekss361/gendesign-worker:${IMAGE_TAG:-latest} + logging: *default-logging restart: unless-stopped env_file: - path: ./backend/.env @@ -201,6 +307,7 @@ services: beat: # Lean backend-образ (без Chromium) — beat только триггерит таски в Redis. image: ghcr.io/lekss361/gendesign-backend:${IMAGE_TAG:-latest} + logging: *default-logging restart: unless-stopped env_file: - path: ./backend/.env @@ -219,6 +326,7 @@ services: glitchtip-web: image: glitchtip/glitchtip:6.1.6 container_name: glitchtip-web + logging: *default-logging # profiles: ["glitchtip"] keeps this service from starting on plain `compose up -d`. # Bootstrap script activates the profile after DB + secrets are ready. # On subsequent deploys, set COMPOSE_PROFILES=glitchtip in /opt/gendesign/.env. @@ -233,9 +341,19 @@ services: REDIS_URL: redis://redis:6379/2 SECRET_KEY: ${GLITCHTIP_SECRET} PORT: "8080" - EMAIL_URL: consolemail:// + # Почта отключена по умолчанию: consolemail:// печатает письмо в stdout и + # никуда его не отправляет. Реальный адрес приходит из /opt/gendesign/.env + # (GLITCHTIP_EMAIL_URL) — в репозитории пароля почтового ящика быть не должно. + # + # ВАЖНО про схему DSN (django-environ, парсер GlitchTip): для порта 465 с + # implicit SSL нужна схема smtp+ssl://, а НЕ smtps:// — вторая помечена + # deprecated и включает STARTTLS (EMAIL_USE_TLS), то есть 465 с ней рвёт + # соединение. Для 587/STARTTLS схема — smtp+tls://. + # smtp+ssl://alerts%40meraocenka.ru:ПАРОЛЬ@smtp.beget.com:465 + # Логин — почтовый адрес целиком, @ в нём кодируется как %40. + EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://} GLITCHTIP_DOMAIN: https://errors.gendsgn.ru - DEFAULT_FROM_EMAIL: errors@gendsgn.ru + DEFAULT_FROM_EMAIL: ${GLITCHTIP_FROM_EMAIL:-errors@gendsgn.ru} ENABLE_USER_REGISTRATION: "true" ENABLE_ORGANIZATION_CREATION: "false" restart: always @@ -254,6 +372,7 @@ services: glitchtip-worker: image: glitchtip/glitchtip:6.1.6 container_name: glitchtip-worker + logging: *default-logging profiles: ["glitchtip"] depends_on: postgres: @@ -266,12 +385,22 @@ services: REDIS_URL: redis://redis:6379/2 SECRET_KEY: ${GLITCHTIP_SECRET} CELERY_WORKER_AUTOSCALE: "1,3" + # Письма и веб-хуки шлёт celery, то есть ИМЕННО этот контейнер, а не web. + # До этой правки почтовых переменных здесь не было вовсе: настройка одного + # glitchtip-web не дала бы ни одного отправленного письма — worker брал + # умолчания образа. Значения обязаны совпадать с web (см. комментарий там). + EMAIL_URL: ${GLITCHTIP_EMAIL_URL:-consolemail://} + DEFAULT_FROM_EMAIL: ${GLITCHTIP_FROM_EMAIL:-errors@gendsgn.ru} + # Нужен для абсолютных ссылок внутри писем и веб-хуков: без него + # уведомление приходит со ссылкой в никуда. + GLITCHTIP_DOMAIN: https://errors.gendsgn.ru restart: always mem_limit: 384m networks: [default] caddy: image: caddy:2 + logging: *default-logging restart: unless-stopped ports: - "80:80" @@ -310,6 +439,7 @@ services: # deploy.yml запускает: docker compose build glitchtip-auth-forwarder build: ./ops/glitchtip-auth-forwarder container_name: gendesign-auth-forwarder + logging: *default-logging restart: unless-stopped environment: GLITCHTIP_DSN: ${GLITCHTIP_DSN} diff --git a/docs/Secrets_Rotation_Policy.md b/docs/Secrets_Rotation_Policy.md index cdcc39d5..96dcfe6c 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,13 +62,14 @@ | `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** | | `GLITCHTIP_SECRET` | `.env` | Django `SECRET_KEY` GlitchTip | **F** (app secret) | | `OBJECTIVE_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета на VPS | **D** | | `OPENAI_API_KEY` | `backend/.env.runtime` | Зеркало CI-секрета (только если non-empty) | **D** | -| `SCRAPE_ADMIN_TOKEN` | `backend/.env` | **DEPRECATED** (PR #436): app-level admin auth удалён, заменён Caddy basic_auth. Поле оставлено в `core/deps.py` для быстрого rollback | **F** (legacy, см. §3) | +| ~~`SCRAPE_ADMIN_TOKEN`~~ | — | **УДАЛЁН** (#2775): не секрет и не credential. См. §3 | — | ### 1.3 Прод runtime — tradein стек (`/opt/gendesign/tradein-mvp/backend/.env.runtime`) @@ -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. @@ -210,18 +210,28 @@ Bootstrap-роли (`tradein_fdw_reader`, `gendesign_reader`): --- -## 3. Особый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance) +## 3. Закрытый случай: `SCRAPE_ADMIN_TOKEN` (issue #78 acceptance) -Issue #78 просит «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime». -**Статус токена: DEPRECATED** — app-level admin-auth был удалён в PR #436 -(`backend/.env.example:30`), доступ к админ-эндпоинтам теперь закрыт Caddy basic_auth. -Поле оставлено в `core/deps.py` только для быстрого rollback. +Issue #78 просил «тестовую ротацию `SCRAPE_ADMIN_TOKEN` без downtime». +**Ротировать нечего: переменной больше нет.** -**Вывод:** активной ротации не требуется — токен ни на что не влияет, пока -`AdminTokenAuth` dep не реинстейтнут. Если/когда его вернут, он попадает в класс **F** -(процедура: `sed` в `backend/.env.runtime` → `up -d --force-recreate --no-deps backend beat`, -downtime отсутствует). Фактический прод-прогон ротации — операционное действие -(Anton), не выполняется в рамках этого PR. +История. App-level admin-auth сняли в PR #437 (заголовок `X-Admin-Token` убран +со всех эндпоинтов), UI ввода токена — в PR #442. Поле `scrape_admin_token` и +dep `AdminTokenAuth` оставили «для быстрого rollback» — и они пролежали так до +#2775, полтора месяца, не имея ни одного вызывающего: разбор AST по всему +репозиторию нашёл `verify_admin_token` и `AdminTokenAuth` ровно в одном месте — +в объявлении в `core/deps.py`. Rollback, ради которого поле держали, всё равно +означал бы правку всех `admin_*.py` (dep-то нигде не проставлен), то есть +хранение поля не экономило ничего. Удалено вместе с `core/deps.py`, +`SCRAPE_ADMIN_TOKEN` из `.env.example` и остатками в тестах. + +Что закрывает `/api/v1/admin/*` сейчас — **два слоя, оба живые**: +1. `rbac_guard` в `backend/app/main.py`: `_ADMIN_API_RE` → 403 `admin only` + для роли ≠ admin. Покрыт `backend/tests/test_rbac.py` (24 теста). +2. Caddy basic_auth на весь `gendsgn.ru` (PR #426). + +Строку `SCRAPE_ADMIN_TOKEN=` в прод-`.env` удалять не обязательно: +`Settings.model_config` — `extra="ignore"`, лишняя переменная безвредна. `JWT_SECRET` (упомянут в #78 «после B3-4») в кодовой базе **ещё отсутствует** — добавить в реестр (класс **F**) при внедрении JWT-аутентификации. diff --git a/frontend/src/app/legacy/site-finder/page.tsx b/frontend/src/app/legacy/site-finder/page.tsx index bd7144c7..61e580a8 100644 --- a/frontend/src/app/legacy/site-finder/page.tsx +++ b/frontend/src/app/legacy/site-finder/page.tsx @@ -14,7 +14,7 @@ import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab"; import { LandTab } from "@/components/site-finder/LandTab"; import { MarketTab } from "@/components/site-finder/MarketTab"; import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; -import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; +import { buildAnalyzeOptions, useSiteAnalysis } from "@/hooks/useSiteAnalysis"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useConnectionPoints } from "@/hooks/useConnectionPoints"; import { useCustomPois } from "@/hooks/useCustomPois"; @@ -22,6 +22,7 @@ import { POI_DEFAULT_WEIGHTS, type PoiCategoryKey, } from "@/lib/api/weightProfiles"; +import { useMe } from "@/lib/useMe"; // SiteMap imports Leaflet which requires browser APIs — load without SSR const SiteMap = dynamic( @@ -129,16 +130,16 @@ function SiteFinderContent() { // Ref to skip the initial mount effect (we only re-analyze on actual changes). const weightsChangeInitializedRef = useRef(false); - const [profileUserId, setProfileUserId] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_user_id") ?? ""), - ); - const [adminToken] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_token") ?? ""), - ); + // Владелец weight-профилей — вошедший пользователь (#2782). Было: два значения + // из localStorage (`admin_user_id` из удалённого в #442 инпута и `admin_token`, + // который сервер не читает с #437) — оба недостижимы без DevTools. + // + // 🔴 profileUserId нужен НЕ только панели: analyze с одним `profile_id` без + // `profile_user_id` бэкенд резолвит в СИСТЕМНЫЕ веса, отдавая при этом + // `weights_profile.source = "profile"` (проверено на проде: profile_id=1 без + // user_id → tram_stop −0.5 вместо −0.4 из профиля). То есть выбранный профиль + // молча не применялся бы, а UI показывал бы его ползунки. + const profileUserId = useMe().data?.username ?? ""; // Lazy init: считаем initialTab один раз на mount (useState всё равно // игнорирует initializer после первого render — не тратим CPU). const [tab, setTabState] = useState(() => { @@ -192,12 +193,7 @@ function SiteFinderContent() { setIsochrones(undefined); mutate({ cad: currentData.cad_num, - options: - profileId != null - ? { profileId } - : currentProfileUserId - ? { profileUserId: currentProfileUserId, weights } - : { weights }, + options: buildAnalyzeOptions(weights, profileId, currentProfileUserId), }); // mutate is stable from useMutation — safe to omit from deps. // data?.cad_num — dep, чтобы при завершении ПЕРВИЧНОГО analyze (cad_num @@ -216,12 +212,11 @@ function SiteFinderContent() { // slider values are always respected even without a saved profile (#201). mutate({ cad: cadNum, - options: - activeProfileId != null - ? { profileId: activeProfileId } - : profileUserId - ? { profileUserId, weights: currentWeights } - : { weights: currentWeights }, + options: buildAnalyzeOptions( + currentWeights, + activeProfileId, + profileUserId, + ), }); } @@ -329,46 +324,9 @@ function SiteFinderContent() { {/* Weight profile panel — collapsible, below header */}
- {/* Optional user-id field for profile CRUD (shown only when adminToken present) */} - {!!adminToken && ( -
- - { - setProfileUserId(e.target.value); - if (typeof window !== "undefined") { - localStorage.setItem("admin_user_id", e.target.value); - } - }} - /> -
- )} {/* Recalculation indicator — shown while re-analyze is in-flight after weights change (data already loaded, pendingWeightsChange set). */} diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index 03be7b0d..41cd3e4d 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -15,7 +15,12 @@ import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5At import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast"; import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept"; import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives"; -import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api"; +import { + AnalyzeWeightsContext, + adaptEgrn, + useParcelAnalyzeQuery, +} from "@/lib/site-finder-api"; +import type { PoiCategoryKey } from "@/lib/api/weightProfiles"; import type { ParcelAnalysis, PendingConceptProgram, @@ -29,7 +34,40 @@ interface Props { // ── Page Content (client — needs TanStack Query) ─────────────────────────────── +/** + * Обёртка над телом страницы: держит применённые в §4.1 POI-веса и кладёт их в + * контекст ВЫШЕ всех вызовов useParcelAnalyzeQuery (#2790). Своё состояние + * нельзя было оставить в теле: собственный вызов useParcelAnalyzeQuery читал бы + * контекст «сверху», то есть null, и страница разъехалась бы на два разных + * анализа — свой у шапки, свой у секций. + * + * null = веса не применяли → запрос как раньше, без тела. + */ export function AnalysisPageContent({ cad }: Props) { + const [appliedWeights, setAppliedWeights] = useState | null>(null); + + return ( + + + + ); +} + +function AnalysisPageBody({ + cad, + appliedWeights, + onWeightsApply, +}: Props & { + appliedWeights: Record | null; + onWeightsApply: (weights: Record) => void; +}) { const [horizon, setHorizon] = useState(12); const queryClient = useQueryClient(); @@ -216,8 +254,15 @@ export function AnalysisPageContent({ cad }: Props) { {/* ── Группа «Стройка и рынок» ──────────────────────────────── */} - {/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */} - + {/* 4. Рынок и конкуренты — IMPLEMENTED in A7. Веса POI из §4.1 + поднимаем сюда: «Применить» меняет ключ analyze-запроса → скор + пересчитывается по ползункам во ВСЕХ секциях (#2790). */} + {/* 5. Атмосфера — IMPLEMENTED in A11 */} diff --git a/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx new file mode 100644 index 00000000..4cc7e35e --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/__tests__/AnalysisPageContent.weights.test.tsx @@ -0,0 +1,159 @@ +/** + * #2790 п.1 — «Применить» у весов POI в §4.1 ничего не применяло. + * + * Состояние весов жило в `Section31Settings` и читалось только обратно в ту же + * панель: до `/analyze` оно не доезжало никогда (слова `weights` в + * AnalysisPageContent не было вовсе). Пользователь двигал ползунки, жал + * «Применить» и получал ТОТ ЖЕ скор, посчитанный по системным весам. + * + * Тест идёт живым путём: рендерит настоящую страницу с настоящей §4.1 и + * настоящим `useParcelAnalyzeQuery` (замокан только тяжёлый обвес — карты, + * прогноз, концепция) и смотрит, что уходит в сеть. На коде до фикса второй + * POST /analyze не случается вообще → красный. + */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AnalysisPageContent } from "../AnalysisPageContent"; + +// Тяжёлые секции не участвуют в контракте «ползунки → запрос»: они тянут +// Leaflet / ECharts / собственные poll-запросы. §3 (настройки + панель весов) — +// НАСТОЯЩАЯ, как и useParcelAnalyzeQuery: они и есть предмет теста. +vi.mock("@/components/site-finder/ChatDock", () => ({ ChatDock: () => null })); +vi.mock("@/components/site-finder/GateVerdictBanner", () => ({ + GateVerdictBanner: () => null, +})); +vi.mock("@/components/site-finder/HorizonSelector", () => ({ + HorizonSelector: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section1ParcelInfo", () => ({ + Section1ParcelInfo: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section2NetworksUtilities", () => ({ + Section2NetworksUtilities: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section4Estimate", () => ({ + Section4Estimate: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section5Atmosphere", () => ({ + Section5Atmosphere: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section6Forecast", () => ({ + Section6Forecast: () => null, +})); +vi.mock("@/components/site-finder/analysis/Section7Concept", () => ({ + Section7Concept: () => null, +})); +vi.mock("@/components/site-finder/analysis/SectionAlternatives", () => ({ + SectionAlternatives: () => null, +})); +vi.mock("@/components/site-finder/BestLayoutsBlock", () => ({ + BestLayoutsBlock: () => null, +})); + +const CAD = "66:41:0702017:131"; + +const ANALYSIS = { + cad_num: CAD, + score: 18.91, + district: { district_name: "Чкаловский" }, + egrn: null, + competitors: [], +}; + +/** Тела всех POST /analyze в порядке отправки. undefined = запрос без тела. */ +const analyzeBodies: Array | undefined> = []; + +const fetchMock = vi.fn(); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + analyzeBodies.length = 0; + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input, init) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/analyze")) { + const raw = init?.body; + analyzeBodies.push( + typeof raw === "string" + ? (JSON.parse(raw) as Record) + : undefined, + ); + return jsonResponse(ANALYSIS); + } + if (url.includes("/api/v1/me")) { + return jsonResponse({ + username: "admin", + role: "admin", + allowed_paths: ["/**"], + deny_paths: [], + }); + } + if (url.includes("/weight-profiles")) { + return jsonResponse([]); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +function renderPage() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +} + +/** Ползунок конкретной категории по подписи строки в панели весов. */ +function sliderFor(label: string): HTMLInputElement { + const row = screen.getByText(label).closest("div"); + if (!row) throw new Error(`не нашёл строку ползунка «${label}»`); + const input = row.querySelector('input[type="range"]'); + if (!input) throw new Error(`в строке «${label}» нет ползунка`); + return input as HTMLInputElement; +} + +describe("§4.1 «Применить» доносит веса до /analyze (#2790)", () => { + it("отправляет ползунки в тело повторного analyze", async () => { + renderPage(); + + // Первичный анализ — без весов (ничего не применяли): тело не шлём вовсе, + // бэкенд считает по системным. Это же и baseline для «стало другим». + await waitFor(() => expect(analyzeBodies.length).toBe(1)); + expect(analyzeBodies[0]).toBeUndefined(); + + fireEvent.click(await screen.findByText("POI Веса")); + fireEvent.change(sliderFor("Парки"), { target: { value: "3" } }); + fireEvent.change(sliderFor("Трамвайные ост. (−)"), { + target: { value: "-2" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Применить" })); + + // Главное утверждение: analyze уходит ЗАНОВО и несёт ровно те веса, что + // выставлены ползунками. До фикса второго запроса не было — красный здесь. + await waitFor(() => expect(analyzeBodies.length).toBe(2)); + const applied = analyzeBodies[1]?.weights as Record; + expect(applied.park).toBe(3); + expect(applied.tram_stop).toBe(-2); + // Нетронутые категории уходят как есть — бэкенд мержит поверх системных, + // но панель отправляет полный набор, чтобы ответ совпадал с ползунками. + expect(applied.school).toBe(1.5); + }); +}); diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index e4c70c5f..31117249 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -10,8 +10,8 @@ /* ===================== SCOPED TOKENS (dark) ===================== */ .pticaRoot[data-theme="dark"] { --font-ui: - var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", - system-ui, sans-serif; + var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI", system-ui, + sans-serif; --font-mono: var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace, monospace; @@ -907,31 +907,6 @@ letter-spacing: 0.08em; } -/* ===================== PLACEHOLDER PANEL ===================== */ -.placeholderPanel { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - text-align: center; - min-height: 240px; - border-style: dashed; -} -.placeholderPanel .soon { - font-size: 22px; - letter-spacing: 0.12em; - color: var(--text-soft); - font-weight: 700; - text-transform: uppercase; -} -.placeholderPanel p { - font-size: 10px; - color: var(--text-soft); - margin: 0; - max-width: 280px; -} - /* ===================== STATE SCREENS ===================== */ .stateScreen { display: grid; diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx index 14448afb..8e9d02f9 100644 --- a/frontend/src/components/concept/ConceptVariantsResult.tsx +++ b/frontend/src/components/concept/ConceptVariantsResult.tsx @@ -526,14 +526,15 @@ function VariantPanel({ ? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. " : ""} НДС: жильё и услуги застройщика по ДДУ освобождены (ст. 149 НК - РФ); НДС начисляется только на паркинг (нежилые машиноместа). - Входной НДС по строительству уже учтён в себестоимости. Налог на - прибыль — 25% (с 2025 года). Цена продажи жилья —{" "} + РФ); НДС начисляется на нежилое — паркинг (машиноместа) и + коммерцию 1-го этажа. Входной НДС по строительству уже учтён в + себестоимости. Налог на прибыль — 25% (с 2025 года). Цена продажи + жилья —{" "} {financial.price_is_calibrated ? `калибрована по рынку (${priceSourceCaption(financial)})` : "норматив класса (нет рыночных данных по участку)"} - ; себестоимость СМР и цена паркинга — нормативные ориентиры. - Коммерческие и офисные площади не учитываются. + ; себестоимость СМР, цена паркинга и цена нежилого — нормативные + ориентиры. {financial.financing_enabled && financial.financing_is_simplified ? " Финансирование упрощено: весь кассовый разрыв покрыт " + "кредитом по ставке-нормативу, проценты капитализируются, " + @@ -656,7 +657,7 @@ function FinancialCascadeTable({ financial }: { financial: FinancialModel }) { value={formatMoneyCompact(financial.gross_margin_rub)} /> , profileId: number | null, ) => void; - /** - * If provided, enables save/load from DB. - * Must be non-empty for CRUD functionality. - */ - userId?: string; - /** Admin token for CRUD API calls. */ - adminToken?: string; } // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -66,12 +61,7 @@ function weightsEqual( // ── Component ───────────────────────────────────────────────────────────────── -export function WeightProfilePanel({ - currentWeights, - onWeightsChange, - userId, - adminToken, -}: Props) { +export function WeightProfilePanel({ currentWeights, onWeightsChange }: Props) { const [open, setOpen] = useState(false); // Local draft weights — editable before "Применить" @@ -89,11 +79,19 @@ export function WeightProfilePanel({ const [saveName, setSaveName] = useState(""); const [saveDefault, setSaveDefault] = useState(false); - // Profiles query (only when userId + adminToken provided) - const canUseCrud = !!userId && !!adminToken; - const profilesQuery = useWeightProfiles(userId ?? "", adminToken ?? ""); + // Владелец профилей — вошедший пользователь (#2782). Раньше user_id вводили + // руками, а CRUD был заперт на `adminToken` из localStorage, которого негде было + // взять: поле ввода удалили в #442, а сервер перестал читать X-Admin-Token ещё в + // #437. Профили и так per-user, так что личность берём оттуда же, откуда её берут + // RouteGuard и Topbar — из /api/v1/me (тот же queryKey, запрос не дублируется). + // В проде username всегда есть: до страницы не пустит Caddy basic_auth. Пусто + // бывает только в dev без Caddy (/me → 401) — тогда CRUD выключен. + const { data: me } = useMe(); + const userId = me?.username ?? ""; + const canUseCrud = !!userId; + const profilesQuery = useWeightProfiles(userId); - const createMutation = useCreateProfile(adminToken ?? ""); + const createMutation = useCreateProfile(); // ── Handlers ──────────────────────────────────────────────────────────────── @@ -113,11 +111,22 @@ export function WeightProfilePanel({ } function handleApply() { - onWeightsChange({ ...draft }, selectedProfileId); + // Системный пресет не адресуем через profile_id: resolve_weights() ищет + // профиль в области ВЛАДЕЛЬЦА, а владелец пресета — `__system__`, не + // текущий пользователь. Бэкенд его не найдёт, тихо возьмёт дефолтные веса и + // отрапортует `weights_profile.source = "profile"` (#2782). Поэтому для + // пресета отдаём profileId = null — вызывающая сторона пошлёт inline-веса, + // а они ровно те, что на ползунках. + const selected = profiles.find((p) => p.id === selectedProfileId) ?? null; + const addressableId = + selected && selected.user_id !== SYSTEM_PROFILE_USER_ID + ? selected.id + : null; + onWeightsChange({ ...draft }, addressableId); } const handleSaveProfile = useCallback(async () => { - if (!canUseCrud || !userId || !adminToken) { + if (!canUseCrud || !userId) { setShowSaveDialog(false); return; } @@ -136,15 +145,7 @@ export function WeightProfilePanel({ } catch { // Error visible through createMutation.error } - }, [ - canUseCrud, - userId, - adminToken, - saveName, - draft, - saveDefault, - createMutation, - ]); + }, [canUseCrud, userId, saveName, draft, saveDefault, createMutation]); // ── Derived ───────────────────────────────────────────────────────────────── @@ -242,6 +243,7 @@ export function WeightProfilePanel({
@@ -268,6 +270,7 @@ export function WeightProfilePanel({ {profiles.map((p) => ( ))} @@ -278,10 +281,13 @@ export function WeightProfilePanel({
)} - {/* Hint when no crud */} + {/* Hint when no crud. В проде недостижимо (без входа страницу не отдаёт + Caddy basic_auth) — остаётся для dev-запуска без прокси, где + /api/v1/me отвечает 401. Текст называет причину, а не действие: + вводить тут больше нечего. */} {!canUseCrud && (

- Укажите User ID и Admin Token для сохранения профилей. + Пользователь не определён — сохранение профилей недоступно.

)} @@ -362,6 +368,9 @@ export function WeightProfilePanel({ }} > Новый профиль +

+ Сохранится для пользователя {userId} — другие его не увидят. +

(); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +const PROFILE = { + id: 1, + user_id: "admin", + profile_name: "Мой профиль", + weights: { ...POI_DEFAULT_WEIGHTS, park: 2.5 }, + is_default: true, + description: null, + created_at: "2026-05-15T05:30:51Z", + updated_at: "2026-05-15T05:30:51Z", +}; + +function renderPanel() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + {}} + /> + , + ); +} + +/** Раскрыть свёрнутую панель (в закрытом виде тело не рендерится). */ +async function openPanel() { + const { default: userEvent } = await import("@testing-library/user-event"); + await userEvent.setup().click(screen.getByText("POI Веса")); +} + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockImplementation(async (input) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/api/v1/me")) { + return jsonResponse({ + username: "admin", + role: "admin", + allowed_paths: ["/**"], + deny_paths: [], + }); + } + if (url.includes("/weight-profiles")) { + return jsonResponse([PROFILE]); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("WeightProfilePanel — владелец профилей из сессии (#2782)", () => { + it("включает CRUD без пропсов: показывает профили вошедшего пользователя", async () => { + renderPanel(); + await openPanel(); + + // Профили грузятся по user_id из /me, без ручного ввода и без токена. + await waitFor(() => + expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(), + ); + expect(screen.getByText("Профиль:")).toBeTruthy(); + }); + + it("не обещает поля, которого нет: старой подсказки про Admin Token больше нет", async () => { + renderPanel(); + await openPanel(); + + await waitFor(() => + expect(screen.getByRole("option", { name: /Мой профиль/ })).toBeTruthy(), + ); + expect(screen.queryByText(/Admin Token/i)).toBeNull(); + expect(screen.queryByText(/Укажите User ID/i)).toBeNull(); + }); + + it("не шлёт X-Admin-Token — сервер его не читает с #437", async () => { + renderPanel(); + await openPanel(); + + await waitFor(() => { + const crudCall = fetchMock.mock.calls.find(([input]) => + String(input).includes("/weight-profiles"), + ); + expect(crudCall).toBeTruthy(); + const headers = new Headers( + (crudCall?.[1] as RequestInit | undefined)?.headers, + ); + expect(headers.has("X-Admin-Token")).toBe(false); + }); + }); +}); + +describe("buildAnalyzeOptions — profileId не ходит без владельца (#2782)", () => { + const weights = { ...POI_DEFAULT_WEIGHTS }; + + it("выбранный профиль уходит вместе с profileUserId", () => { + expect(buildAnalyzeOptions(weights, 7, "admin")).toEqual({ + profileId: 7, + profileUserId: "admin", + }); + }); + + it("без пользователя profileId не отправляется — иначе бэкенд тихо возьмёт системные веса", () => { + const options = buildAnalyzeOptions(weights, 7, ""); + expect(options.profileId).toBeUndefined(); + expect(options.weights).toEqual(weights); + }); + + it("без профиля уходят inline-веса и владелец для default-профиля", () => { + expect(buildAnalyzeOptions(weights, null, "admin")).toEqual({ + profileUserId: "admin", + weights, + }); + }); +}); diff --git a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx b/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx deleted file mode 100644 index b72e33c9..00000000 --- a/frontend/src/components/site-finder/analysis/AnalysisBreadcrumb.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { ChevronRight } from "lucide-react"; - -interface AnalysisBreadcrumbProps { - cadNum: string; -} - -export function AnalysisBreadcrumb({ cadNum }: AnalysisBreadcrumbProps) { - return ( - - ); -} diff --git a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx b/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx deleted file mode 100644 index 487f5cd6..00000000 --- a/frontend/src/components/site-finder/analysis/AnalysisSidebar.tsx +++ /dev/null @@ -1,266 +0,0 @@ -"use client"; - -import React, { useEffect, useRef, useState } from "react"; -import { ExternalLink } from "lucide-react"; - -// ── Types ───────────────────────────────────────────────────────────────────── - -interface SubSection { - id: string; - label: string; -} - -interface NavSection { - id: string; - label: string; - sub?: SubSection[]; -} - -// ── Config ──────────────────────────────────────────────────────────────────── - -const NAV_SECTIONS: NavSection[] = [ - { id: "section-1", label: "1. Объект" }, - { id: "section-2", label: "2. Земля и риски" }, - { - id: "section-3", - label: "3. Рынок", - sub: [ - { id: "section-3-1", label: "3.1 Настройки выборки" }, - { id: "section-3-2", label: "3.2 Планировки" }, - { id: "section-3-3", label: "3.3 Остатки и скорость" }, - ], - }, - { id: "section-4", label: "4. Оценка" }, - { id: "section-5", label: "5. Атмосфера" }, - { - id: "section-6", - label: "6. Прогноз", - sub: [ - { id: "section-6-1", label: "6.1 Прогноз по горизонтам" }, - { id: "section-6-2", label: "6.2 Сценарии" }, - { id: "section-6-3", label: "6.3 Уверенность" }, - { id: "section-6-4", label: "6.4 Рекомендация по продукту" }, - { id: "section-6-5", label: "6.5 Прозрачность скоринга" }, - { id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" }, - ], - }, -]; - -// All section IDs in scroll order (for IntersectionObserver) -const ALL_SECTION_IDS: string[] = NAV_SECTIONS.flatMap((s) => - s.sub ? [s.id, ...s.sub.map((sub) => sub.id)] : [s.id], -); - -// ── Component ───────────────────────────────────────────────────────────────── - -export function AnalysisSidebar() { - const [activeId, setActiveId] = useState(ALL_SECTION_IDS[0]); - const observerRef = useRef(null); - - // Scrollspy via IntersectionObserver - useEffect(() => { - const candidates = ALL_SECTION_IDS.map((id) => - document.getElementById(id), - ).filter((el): el is HTMLElement => el !== null); - - if (candidates.length === 0) return; - - // Track which sections are visible; pick topmost visible one - const visible = new Set(); - - observerRef.current = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - visible.add(entry.target.id); - } else { - visible.delete(entry.target.id); - } - }); - - // Pick the topmost section that is currently visible - const next = ALL_SECTION_IDS.find((id) => visible.has(id)); - if (next) setActiveId(next); - }, - { - root: null, - // Trigger when section top enters top 60% of viewport - rootMargin: "-8px 0px -40% 0px", - threshold: 0, - }, - ); - - candidates.forEach((el) => observerRef.current!.observe(el)); - - return () => { - observerRef.current?.disconnect(); - }; - }, []); - - function handleAnchorClick( - e: React.MouseEvent, - targetId: string, - ) { - e.preventDefault(); - const el = document.getElementById(targetId); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "start" }); - } - setActiveId(targetId); - } - - return ( - - ); -} diff --git a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx b/frontend/src/components/site-finder/analysis/MassingEconomics.tsx deleted file mode 100644 index 272c6239..00000000 --- a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx +++ /dev/null @@ -1,410 +0,0 @@ -"use client"; - -/** - * MassingEconomics — LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b, - * epic #1953). - * - * Driven by the interactive 3D MassingScene: every time the user drags the - * этажность / секций sliders, Section7Concept maps the scene's `computeModel` - * result + the analysis context into a `MassingProgram` and hands it here via - * `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and - * render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость / - * прибыль / ROI). - * - * Robustness: - * • debounce — slider drags fire many programs; only the settled one is sent. - * • latest-wins — an in-flight request is superseded by a newer one via a - * monotonic request id; a stale response is dropped, never overwriting a - * fresher result (mutateAsync + id guard, no UI flicker from out-of-order). - * • last-good — on a failed recompute we keep the last successful values and - * show a subtle inline note rather than blanking the panel. - * • skeleton — a plain grey fade KPI grid while the FIRST recompute is in - * flight (no shimmer, per ui-conventions). - * - * Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but - * this strip uses the light KPI tokens via the shared KpiCard. - */ - -import { useEffect, useRef, useState } from "react"; -import { AlertTriangle } from "lucide-react"; - -import { KpiCard } from "@/components/analytics/KpiCard"; -import { Section } from "@/components/analytics/Section"; -import { - priceSourceCaption, - useRecomputeMassing, - type FinancialModel, - type MassingProgram, - type MassingRecomputeOutput, - type Teap, -} from "@/lib/concept-api"; - -const DEBOUNCE_MS = 250; - -// ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ───────── - -const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }); - -/** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */ -function formatMoneyCompact(rub: number): string { - const abs = Math.abs(rub); - if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`; - if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`; - return `${nf.format(Math.round(rub))} ₽`; -} - -function formatInt(n: number): string { - return nf.format(Math.round(n)); -} - -function formatPct(fraction: number): string { - return `${(fraction * 100).toFixed(1)}%`; -} - -function formatFar(far: number): string { - return far.toLocaleString("ru-RU", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); -} - -// ── KPI grid ─────────────────────────────────────────────────────────────────── - -interface KpiGridProps { - teap: Teap; - financial: FinancialModel; - /** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */ - farTarget: number; - /** True → факт-КСИТ превышает регламентный потолок (model.over). */ - ksitOver: boolean; - /** Dim the strip while a fresher recompute is in flight (last-good values). */ - stale: boolean; -} - -function KpiGrid({ - teap, - financial, - farTarget, - ksitOver, - stale, -}: KpiGridProps) { - const netPositive = - financial.net_profit_rub > 0 - ? true - : financial.net_profit_rub < 0 - ? false - : null; - - return ( -
- {/* ТЭП */} -
- - - - -
- - {/* Финмодель */} -
- - - - - 0 - ? true - : financial.npv_rub < 0 - ? false - : null, - }} - hint={`Сумма дисконтированных помесячных денежных потоков по графику стройки и продаж (ставка дисконта ${formatPct( - financial.discount_rate_used, - )} годовых).`} - /> - financial.discount_rate_used - ? true - : false, - }} - hint="Годовая внутренняя ставка доходности тех же денежных потоков (ставка, при которой NPV = 0)." - /> - -
-
- ); -} - -// ── Skeleton (grey fade, no shimmer — ui-conventions) ────────────────────────── - -function SkeletonGrid() { - const cells = Array.from({ length: 7 }); - return ( - @@ -259,8 +256,8 @@ function Section31Settings({ Профиль весов POI
@@ -769,7 +766,12 @@ function applyFilters( // ── Section 3 wrapper ───────────────────────────────────────────────────────── -export function Section3SettingsAndCompetitors({ cad, data }: Props) { +export function Section3SettingsAndCompetitors({ + cad, + data, + weights, + onWeightsApply, +}: Props) { const [filters, setFilters] = useState({ radiusKm: 2, onlyUnderConstruction: false, @@ -821,7 +823,12 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) { {/* Sub-sections */}
- + {/* Competitor table — moved before 3.2/3.3 for context */} {filteredCompetitors.length > 0 && ( diff --git a/frontend/src/components/site-finder/analysis/UserAvatar.tsx b/frontend/src/components/site-finder/analysis/UserAvatar.tsx deleted file mode 100644 index 38760a3c..00000000 --- a/frontend/src/components/site-finder/analysis/UserAvatar.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Building2 } from "lucide-react"; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function getStoredOrgId(): string | null { - // Guard against SSR — localStorage not available on server - if (typeof window === "undefined") return null; - try { - return localStorage.getItem("gd_org_id"); - } catch { - return null; - } -} - -function orgInitials(orgId: string): string { - // Build a 2-letter monogram from org ID string - const parts = orgId - .toUpperCase() - .replace(/[^A-ZА-Я0-9]/gu, " ") - .split(" ") - .filter(Boolean); - if (parts.length === 0) return "??"; - if (parts.length === 1) return parts[0].slice(0, 2); - return parts[0][0] + parts[1][0]; -} - -// ── Component ───────────────────────────────────────────────────────────────── - -export function UserAvatar() { - const [orgId, setOrgId] = useState(null); - - // Hydration-safe: read localStorage after mount - useEffect(() => { - setOrgId(getStoredOrgId()); - }, []); - - const displayLabel = orgId ?? "Demo Org"; - const initials = orgId ? orgInitials(orgId) : "DO"; - - return ( -
- {/* Avatar circle */} -
- {orgId ? ( - - {initials} - - ) : ( - - )} -
- - {/* Org name — hidden on narrow viewports via maxWidth trick */} - - {displayLabel} - -
- ); -} diff --git a/frontend/src/components/site-finder/ptica/PticaPlaceholderPanel.tsx b/frontend/src/components/site-finder/ptica/PticaPlaceholderPanel.tsx deleted file mode 100644 index a0a088c7..00000000 --- a/frontend/src/components/site-finder/ptica/PticaPlaceholderPanel.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -/** - * PticaPlaceholderPanel — honest "В разработке" panel for cockpit tabs/sections - * that aren't wired in INCREMENT 1 (Scenarios / Reports / Compare). - */ - -import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css"; - -interface Props { - label: string; - hint?: string; -} - -export function PticaPlaceholderPanel({ label, hint }: Props) { - return ( -
-
{label}
- {hint &&

{hint}

} -
- ); -} diff --git a/frontend/src/hooks/useSiteAnalysis.ts b/frontend/src/hooks/useSiteAnalysis.ts index 1ffd3a7e..745ccdb1 100644 --- a/frontend/src/hooks/useSiteAnalysis.ts +++ b/frontend/src/hooks/useSiteAnalysis.ts @@ -12,11 +12,7 @@ import type { ParcelAnalysis } from "@/types/site-finder"; // /fetch-status every 2s, re-triggers analyze когда status=ready. export type FetchStatus = - | "ready" - | "fetching" - | "failed" - | "not_in_nspd" - | "invalid_format"; + "ready" | "fetching" | "failed" | "not_in_nspd" | "invalid_format"; export interface FetchStatusResponse { status: FetchStatus; @@ -52,6 +48,34 @@ export interface AnalyzeOptions { weights?: Record | null; } +/** + * Собрать options для POST /analyze из состояния панели весов POI. + * + * 🔴 `profileId` уходит на бэкенд ТОЛЬКО в паре с `profileUserId`. Причина не + * стилистическая: `resolve_weights()` ищет профиль как `get_profile(db, user_id, + * profile_id)` — при `user_id=None` условие `profile_id is not None and user_id is + * not None` не выполняется, и веса тихо падают на системные. Ответ при этом + * рапортует `weights_profile.source = "profile"`, то есть врёт (проверено на проде + * #2782: `profile_id=1` без `profile_user_id` → `tram_stop −0.5` вместо `−0.4` из + * профиля). Юзер бы видел ползунки профиля и score, посчитанный по другим весам. + * + * Когда пользователь неизвестен (dev без Caddy: /api/v1/me → 401) — шлём inline + * `weights`. Они всегда равны ползункам панели, так что расхождения нет. + */ +export function buildAnalyzeOptions( + weights: Record, + profileId: number | null, + profileUserId: string, +): AnalyzeOptions { + if (profileId != null && profileUserId) { + return { profileId, profileUserId }; + } + if (profileUserId) { + return { profileUserId, weights }; + } + return { weights }; +} + /** * Custom hook для analyze flow с graceful on-demand fetch fallback. * diff --git a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts index f84f2033..81f0e074 100644 --- a/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts +++ b/frontend/src/lib/__tests__/useParcelAnalyzeQuery.test.ts @@ -16,6 +16,7 @@ * directly with a real AbortSignal and a per-URL `fetch` stub, under fake * timers, and assert on abort behaviour + the happy path. */ +import { renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // ── Capture the options passed to useQuery ─────────────────────────────────── @@ -117,12 +118,15 @@ const CAD = "66:41:0701045:42"; * polling queryFn. Reads `captured.options` via a fresh binding so TS control- * flow doesn't pin it (the hook mutates it opaquely through the mock). * - * `useQuery` is fully mocked (it just records its options, no React state), so - * the rules-of-hooks invariant does not apply to this call — disable locally. + * Хук зовём через `renderHook`, а не напрямую: с #2790 он читает применённые + * веса из `AnalyzeWeightsContext` (`useContext`), а вне рендера у React нет + * dispatcher'а → «Cannot read properties of null». `useQuery` по-прежнему + * замокан и просто записывает options; провайдера над хуком нет, значит + * контекст = null, то есть ровно тот случай «весов не применяли», который этот + * тест и гоняет. */ function getQueryFn(): CapturedQueryOptions["queryFn"] { - // eslint-disable-next-line react-hooks/rules-of-hooks - useParcelAnalyzeQuery(CAD, 12); + renderHook(() => useParcelAnalyzeQuery(CAD, 12)); const options = captured.options; if (options === null) throw new Error("useQuery options not captured"); return options.queryFn; diff --git a/frontend/src/lib/api/weightProfiles.ts b/frontend/src/lib/api/weightProfiles.ts index f2619dfa..1534e8ce 100644 --- a/frontend/src/lib/api/weightProfiles.ts +++ b/frontend/src/lib/api/weightProfiles.ts @@ -27,14 +27,18 @@ export interface WeightProfileCreate { description?: string | null; } -export interface WeightProfileUpdate { - profile_name?: string; - weights?: Record; - is_default?: boolean; - description?: string | null; -} - // ── Constants ───────────────────────────────────────────────────────────────── + +/** + * Владелец системных пресетов (Эконом / Комфорт / Бизнес) — mirrors + * `SYSTEM_USER_ID` в backend/app/services/site_finder/weight_profiles.py. + * Профили с этим user_id общие для всех и НЕ адресуемы через `profile_id`: + * `resolve_weights()` ищет профиль в области владельца, у чужого пользователя + * его не найдёт и молча вернёт системные веса с ответом `source="profile"` + * (#2782). Их веса уходят в analyze inline — см. WeightProfilePanel. + */ +export const SYSTEM_PROFILE_USER_ID = "__system__"; + // ALLOWED_CATEGORIES — mirrors backend weight_profiles.py ALLOWED_CATEGORIES. // Keep in sync with backend; source of truth is `_POI_WEIGHTS` in parcels.py. @@ -91,35 +95,44 @@ export const POI_WEIGHT_MAX = 3; const BASE_PATH = "/api/v1/admin/site-finder/weight-profiles"; -function profilesHeaders(adminToken: string): HeadersInit { - return { "X-Admin-Token": adminToken }; -} +// Никакого X-Admin-Token: сервер перестал его читать в #437, а последний +// `verify_admin_token` удалён в #2775. Заголовок отправлялся ещё год после этого и +// ничего не решал — проверено живым запросом на проде (#2782): один и тот же 200 +// с корректным токеном, с мусорным и без заголовка вовсе. +// +// Реальная защита `/api/v1/admin/*` — два живых слоя, оба прод-проверены: +// 1) Caddy basic_auth на gendsgn.ru → без валидных кред 401 ещё на периметре +// (подставленный клиентом X-Authenticated-User туда же не проходит); +// 2) app/main.py rbac_guard → `role != "admin"` даёт 403 "admin only". // ── Hooks ───────────────────────────────────────────────────────────────────── -/** List all weight profiles for a given user_id. */ -export function useWeightProfiles(userId: string, adminToken: string) { +/** + * Профили пользователя + системные пресеты (#2790). + * + * `include_system=true` домешивает в конец списка три общих пресета (Эконом / + * Комфорт / Бизнес, засеяны `data/sql/100_user_weight_profiles_default_seed.sql`). + * Без него у пользователя без своих профилей дропдаун пустой — пресеты лежали в + * проде с 16.05.2026 и не были видны никому. + */ +export function useWeightProfiles(userId: string) { return useQuery({ queryKey: ["weight-profiles", userId], queryFn: () => apiFetch( - `${BASE_PATH}?user_id=${encodeURIComponent(userId)}`, - { - headers: profilesHeaders(adminToken), - }, + `${BASE_PATH}?user_id=${encodeURIComponent(userId)}&include_system=true`, ), - enabled: !!userId && !!adminToken, + enabled: !!userId, }); } /** Create a new weight profile. Invalidates the list query on success. */ -export function useCreateProfile(adminToken: string) { +export function useCreateProfile() { const qc = useQueryClient(); return useMutation({ mutationFn: (payload) => apiFetch(BASE_PATH, { method: "POST", - headers: profilesHeaders(adminToken), body: JSON.stringify(payload), }), onSuccess: (_, variables) => { @@ -130,43 +143,9 @@ export function useCreateProfile(adminToken: string) { }); } -/** Update an existing weight profile by id. */ -export function useUpdateProfile( - userId: string, - profileId: number, - adminToken: string, -) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (payload) => - apiFetch( - `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, - { - method: "PUT", - headers: profilesHeaders(adminToken), - body: JSON.stringify(payload), - }, - ), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] }); - }, - }); -} - -/** Delete a weight profile by id. Resolves on success (backend returns 204 No Content). */ -export function useDeleteProfile(userId: string, adminToken: string) { - const qc = useQueryClient(); - return useMutation({ - mutationFn: (profileId) => - apiFetch( - `${BASE_PATH}/${profileId}?user_id=${encodeURIComponent(userId)}`, - { - method: "DELETE", - headers: profilesHeaders(adminToken), - }, - ), - onSuccess: () => { - void qc.invalidateQueries({ queryKey: ["weight-profiles", userId] }); - }, - }); -} +// useUpdateProfile / useDeleteProfile здесь больше нет (#2790 п.3). Их не звали +// ниоткуда: в UI есть список и создание, кнопок «переименовать» / «удалить» нет. +// Спрос за 3 месяца по проду: 1 профиль на всю базу (`admin`, создан 15.05.2026, +// updated_at = created_at) + 3 системных пресета — ни одного изменения и ни +// одной попытки удаления. PUT/DELETE-эндпоинты живы и покрыты тестами бэкенда; +// понадобится UI — хуки вернутся из истории (мертвее они там не станут). diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts index a3e4ff60..e16a5fe0 100644 --- a/frontend/src/lib/concept-api.ts +++ b/frontend/src/lib/concept-api.ts @@ -330,7 +330,7 @@ export interface MassingRecomputeOutput { * rule; uses the shared `apiFetch` (base URL + session header + Content-Type). * * Stage 2b drives this off the 3D MassingScene's `onModelChange` (debounced), - * with latest-wins sequencing handled by the caller (see MassingEconomics). + * with latest-wins sequencing handled by the caller. */ export function useRecomputeMassing() { return useMutation({ diff --git a/frontend/src/lib/nspdLinks.ts b/frontend/src/lib/nspdLinks.ts index 773ada1a..7e7918d2 100644 --- a/frontend/src/lib/nspdLinks.ts +++ b/frontend/src/lib/nspdLinks.ts @@ -7,7 +7,7 @@ * и иметь один безопасный канал кодирования кадастрового номера. * * Существующие потребители паттерна (до централизации): NspdZoningBlock, - * NspdOpportunityBlock, ParcelDrawer, AnalysisSidebar. + * NspdOpportunityBlock, ParcelDrawer. */ /** diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index ca64a76d..ebd0542b 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -9,6 +9,7 @@ */ import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { createContext, useContext } from "react"; import { HTTPError, apiFetch, apiFetchWithStatus } from "@/lib/api"; import { abortableSleep } from "@/lib/abortableSleep"; import type { @@ -503,9 +504,36 @@ export interface PoiScoreResponse { const ANALYZE_POLL_INTERVAL_MS = 2000; const ANALYZE_POLL_MAX_ITERATIONS = 60; // 60 × 2s = 2 min hard cap +/** + * Применённые в §4.1 POI-веса (#2790). `null` = ничего не применяли → запрос + * уходит без тела, как и раньше (бэкенд считает по системным весам). + * + * Почему контекст, а не проп: на странице анализа `useParcelAnalyzeQuery(cad)` + * зовут ШЕСТЬ мест (§1, §2, §4, §5, сама страница, /ptica) — все они делят один + * ключ кэша `["parcel-analyze", cad, horizon]` и один дорогой (10-30 c) запрос. + * Если веса доедут только до части из них, ключи разойдутся: половина страницы + * покажет скор по одним весам, половина по другим, и /analyze уйдёт дважды. + * Контекст держит всех потребителей ключа на одном значении по построению — + * забыть прокинуть проп в новую секцию нельзя. + */ +export const AnalyzeWeightsContext = createContext | null>(null); + export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { + const weights = useContext(AnalyzeWeightsContext); + // Стабильный кусок ключа: порядок ключей объекта не гарантирован, сортируем. + // null (весов не применяли) оставляем null — ключ тогда совпадает с ключом до + // #2790, кэш не сбрасывается на ровном месте. + const weightsKey = weights + ? JSON.stringify(Object.entries(weights).sort()) + : null; + return useQuery({ - queryKey: ["parcel-analyze", cad, horizon], + // Префикс ["parcel-analyze", cad] сохранён: по нему инвалидируют custom-POI + // мутации (useCustomPois) — они матчатся по префиксу, любой хвост подойдёт. + queryKey: ["parcel-analyze", cad, horizon, weightsKey], // TanStack Query v5 passes an AbortSignal in the queryFn context; it aborts // on unmount and whenever the queryKey changes (смена cad/horizon). Thread // it through the POST/GET fetches and check it before each poll iteration so @@ -522,11 +550,19 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { cad, )}/analyze?horizon=${horizon}`; + // Inline POI-веса (#201) из §4.1. Шлём именно inline, а не profile_id: + // тело запроса == ползункам панели, и ответ рапортует source="inline" — + // расхождению между показанными весами и посчитанным скором взяться + // неоткуда (в отличие от profile_id, см. #2782). + const analyzeInit: RequestInit = weights + ? { method: "POST", signal, body: JSON.stringify({ weights }) } + : { method: "POST", signal }; + // First request — POST /analyze. apiFetchWithStatus surfaces the 202 // Accepted code instead of treating it as a successful payload. const first = await apiFetchWithStatus< ParcelAnalyzeResponse | AnalyzeAcceptedResponse - >(analyzeUrl, { method: "POST", signal }); + >(analyzeUrl, analyzeInit); // 200 → geometry was cached, full analysis is ready. if (first.status === 200) { @@ -553,7 +589,7 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) { // rather than returning the stub (symmetry with the first request). const second = await apiFetchWithStatus< ParcelAnalyzeResponse | AnalyzeAcceptedResponse - >(analyzeUrl, { method: "POST", signal }); + >(analyzeUrl, analyzeInit); if (second.status === 200) { return second.body as ParcelAnalyzeResponse; } 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/ops/docker-prune.sh b/ops/docker-prune.sh new file mode 100755 index 00000000..34a0d30d --- /dev/null +++ b/ops/docker-prune.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# Периодическая уборка docker-мусора на прод-VM. +# +# ЗАЧЕМ. 2026-08-15 диск был занят на 76% (110 из 145 ГБ). Разбор показал 201 +# том-сироту на 12.6 ГБ: 125 анонимных — каталоги данных PostgreSQL от тестовых +# прогонов CI, 76 — окружения задач Forgejo Actions. Прод-данных среди них не +# было ни одного. +# +# Корневая причина анонимных томов устранена отдельно: ci.yml и ci-tradein.yml +# снимали свой postgres через `docker rm -f` БЕЗ `-v`, поэтому контейнер уходил, +# а его том оставался. Теперь там `docker rm -fv`. Этот скрипт — страховка: он +# подбирает то, что runner не убрал за собой, и то, что накопилось раньше. +# +# ЧТО ИМЕННО УДАЛЯЕТСЯ (осознанно консервативно): +# - остановленные контейнеры старше 24ч; +# - висячие (dangling) образы старше 7 суток; +# - тома-сироты ТОЛЬКО двух известных форм: 64-символьный hex (анонимные) и +# FORGEJO-ACTIONS-TASK-*. Именованные тома со смыслом (gendesign_postgres_data, +# tradein-postgres-data, *_caddy_*, couchdb, redis и любые будущие) не трогаются +# НИКОГДА — даже если в моменте оказались отцеплены. Голый `docker volume prune` +# такой разницы не делает, поэтому здесь он намеренно не используется; +# - зависшие (running, но фактически брошенные) job-контейнеры раннера Forgejo +# Actions старше JOB_CONTAINER_MAX_AGE_HOURS. 2026-08-15: живьём на проде +# обнаружены три штуки в статусе Up 4-8 недель (раннер не убрал контейнер +# после прерванного/упавшего workflow — task killed, рестарт раннера в +# процессе job'а и т.п.). CI job физически не идёт сутками, поэтому что +# угодно с этим именем старше порога — гарантированный мусор, а не активная +# задача. `docker container prune` их не видит: тот фильтрует только +# status=exited, а эти контейнеры формально Up. +# +# Usage (cron на прод-VM; `bash <путь>`, а не голый путь — тогда снятый +x не ломает). +# Лог в /tmp — как у соседних записей в том же crontab (backup.sh, backfill'ы): +# 0 4 * * 0 bash /opt/gendesign/ops/docker-prune.sh >> /tmp/gendesign-docker-prune.log 2>&1 +# +# Воскресенье 04:00 UTC — свободный слот: рядом 03:30 backup.sh, 04:30 backup +# tradein, 05:00+ backfill'ы. +# +# Раз в неделю достаточно: после устранения корневой причины (docker rm -fv в CI) +# копятся только тома runner'а. DRY_RUN=1 — показать, что удалится, не трогая. + +set -euo pipefail + +DRY_RUN="${DRY_RUN:-0}" +STOPPED_AGE="${STOPPED_AGE:-24h}" +IMAGE_AGE="${IMAGE_AGE:-168h}" +# Job CI никогда не идёт сутками — что угодно с именем job-контейнера раннера +# старше этого порога снимается безусловно (см. секцию 4 ниже). +JOB_CONTAINER_MAX_AGE_HOURS="${JOB_CONTAINER_MAX_AGE_HOURS:-24}" + +log() { printf '%s %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$*"; } + +disk_used_pct() { df --output=pcent / | tail -1 | tr -dc '0-9'; } + +before_pct="$(disk_used_pct)" +log "старт: диск занят ${before_pct}%" + +if [[ "$DRY_RUN" == "1" ]]; then + log "DRY_RUN=1 — только показываю" +fi + +# ── 1. остановленные контейнеры ─────────────────────────────────────────────── +if [[ "$DRY_RUN" == "1" ]]; then + # `until` поддерживает только `prune`, у `ls` его нет («invalid filter 'until'»), + # поэтому в dry-run считаем ВСЕ остановленные — это верхняя оценка. + log "остановленных контейнеров всего (удалятся только старше ${STOPPED_AGE}): \ +$(docker container ls -aq --filter "status=exited" | wc -l)" +else + log "контейнеры: $(docker container prune -f --filter "until=${STOPPED_AGE}" \ + 2>&1 | tail -1)" +fi + +# ── 2. висячие образы ───────────────────────────────────────────────────────── +if [[ "$DRY_RUN" == "1" ]]; then + log "висячих образов: $(docker image ls -qf dangling=true | wc -l)" +else + log "образы: $(docker image prune -f --filter "until=${IMAGE_AGE}" 2>&1 | tail -1)" +fi + +# ── 3. тома-сироты известных форм ───────────────────────────────────────────── +# Отбираем ПОИМЁННО, а не через `docker volume prune`: тот снёс бы любой +# отцепленный именованный том, включая боевой, если контейнер в моменте пересоздаётся. +mapfile -t candidates < <( + docker volume ls -qf dangling=true \ + | grep -E '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true +) + +skipped="$(docker volume ls -qf dangling=true \ + | grep -vE '^([0-9a-f]{64}|FORGEJO-ACTIONS-TASK-.*)$' || true)" +if [[ -n "$skipped" ]]; then + log "ПРОПУЩЕНЫ (именованные, руками): $(echo "$skipped" | tr '\n' ' ')" +fi + +if [[ "${#candidates[@]}" -eq 0 ]]; then + log "томов-сирот известных форм нет" +elif [[ "$DRY_RUN" == "1" ]]; then + log "томов к удалению: ${#candidates[@]}" +else + removed=0 + for v in "${candidates[@]}"; do + if docker volume rm "$v" >/dev/null 2>&1; then + removed=$((removed + 1)) + fi + done + log "томов удалено: ${removed} из ${#candidates[@]}" +fi + +# ── 4. зависшие job-контейнеры раннера Forgejo Actions ─────────────────────── +# Фильтр по имени — ЯКОРЬ на начало (`^FORGEJO-ACTIONS-TASK-`), не "содержит +# подстроку": `docker ps --filter name=` матчит как regex, поэтому `^...` +# гарантирует точный префикс, а не случайное совпадение где-то в середине +# имени сервисного контейнера. Долгоживущие сервисные контейнеры (forgejo, +# forgejo-runner*, gendesign-*, tradein-*, couchdb) под этот префикс не +# подпадают вообще — но ниже всё равно есть explicit-skip как страховка на +# случай будущего переименования, а не молчаливая надежда на то, что фильтр +# никогда не ошибётся. +# +# Возраст — из `docker inspect .State.StartedAt` (RFC3339), НЕ из текстового +# "Up 4 weeks" в выводе `docker ps`: тот округляет к ближайшей крупной единице +# и не пригоден для сравнения с порогом в часах. +mapfile -t job_ids < <(docker ps -aq --filter "name=^FORGEJO-ACTIONS-TASK-" || true) + +job_removed=0 +job_candidates=0 +if [[ "${#job_ids[@]}" -eq 0 ]]; then + log "зависших job-контейнеров нет" +else + for id in "${job_ids[@]}"; do + name="$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's#^/##' || true)" + [[ -z "$name" ]] && continue + + case "$name" in + forgejo | forgejo-runner* | gendesign-* | tradein-* | couchdb) + log "job-контейнеры: ПРОПУЩЕН сервисный '${name}' (не должен был пройти фильтр имени)" + continue + ;; + esac + + started_at="$(docker inspect --format '{{.State.StartedAt}}' "$id" 2>/dev/null || true)" + [[ -z "$started_at" || "$started_at" == "0001-01-01T00:00:00Z" ]] && continue + + started_epoch="$(date -u -d "$started_at" +%s 2>/dev/null || echo 0)" + [[ "$started_epoch" -eq 0 ]] && continue + + now_epoch="$(date -u +%s)" + age_hours=$(((now_epoch - started_epoch) / 3600)) + [[ "$age_hours" -lt "$JOB_CONTAINER_MAX_AGE_HOURS" ]] && continue + + job_candidates=$((job_candidates + 1)) + size="$(docker ps -a --filter "id=${id}" --size --format '{{.Size}}' 2>/dev/null \ + | awk '{print $1}' || true)" + + if [[ "$DRY_RUN" == "1" ]]; then + log "job-контейнеры: [dry-run] снял бы '${name}' (возраст ${age_hours}ч, writable-слой ${size:-?})" + continue + fi + + if docker rm -f "$id" >/dev/null 2>&1; then + job_removed=$((job_removed + 1)) + log "job-контейнеры: снят '${name}' (возраст ${age_hours}ч, writable-слой ${size:-?} освобождён)" + else + log "job-контейнеры: НЕ удалось снять '${name}' (id ${id:0:12})" + fi + done + + if [[ "$job_candidates" -eq 0 ]]; then + log "job-контейнеры: ${#job_ids[@]} шт., ни один не старше порога ${JOB_CONTAINER_MAX_AGE_HOURS}ч" + elif [[ "$DRY_RUN" == "1" ]]; then + log "job-контейнеры: к снятию ${job_candidates} из ${#job_ids[@]}" + else + log "job-контейнеры: снято ${job_removed} из ${job_candidates} кандидатов (порог ${JOB_CONTAINER_MAX_AGE_HOURS}ч)" + fi +fi + +after_pct="$(disk_used_pct)" +log "готово: диск занят ${after_pct}% (было ${before_pct}%)" + +# Сигнал в лог, если места всё равно мало — повод посмотреть глазами. +if [[ "$after_pct" -ge 85 ]]; then + log "ВНИМАНИЕ: диск занят ${after_pct}% — уборки уже недостаточно" +fi diff --git a/ops/glitchtip-auth-forwarder/forwarder.py b/ops/glitchtip-auth-forwarder/forwarder.py index 8fc3c5b4..79c782f3 100644 --- a/ops/glitchtip-auth-forwarder/forwarder.py +++ b/ops/glitchtip-auth-forwarder/forwarder.py @@ -8,6 +8,13 @@ Persistent offset в /state/offset.json — не дублируем при resta Throttle: при >10 401 events за 60s — однократный digest event (чтобы не флудить GlitchTip storm'ом); индивидуальные events во время storm пропускаются. +before_send=_drop_basic_auth_noise (glitchtip-noise фикс): все события отсюда +дропаются перед отправкой в GlitchTip — 401 от неаутентифицированного запроса +не ошибка сервиса, это боты сканируют закрытый basic_auth'ом сайт. Раньше это +был крупнейший источник шума в трекере (3 738 issue). Скрипт по-прежнему тэйлит +лог и печатает `[forwarder] 401 event sent: ...` в stdout (docker logs) — просто +больше не шлёт эти события в issue-трекер. Смотри `_drop_basic_auth_noise` docstring. + Реальный Caddy JSON access log (v2) структура: { "level": "info", @@ -73,6 +80,41 @@ _shutdown = False _last_exc_sent: float = 0.0 _EXC_THROTTLE_S: float = 300.0 +# event_type-теги, которыми emit_event/emit_digest помечают КАЖДОЕ отправляемое +# событие (см. scope.set_tag("event_type", ...) ниже) — используются как ключ +# для before_send-фильтра. +_BASIC_AUTH_EVENT_TYPES = frozenset({"basic_auth_failed", "basic_auth_storm"}) + + +def _drop_basic_auth_noise(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg] + """before_send-фильтр: 401 неаутентифицированного basic_auth-запроса — НЕ + ошибка сервиса, а expected-поведение сканеров-ботов, ломящихся в закрытый + basic_auth'ом gendsgn.ru (`GET /wp-admin/install.php` и подобное). До этого + фикса emit_event/emit_digest слали КАЖДЫЙ такой 401 individual-событием (или + storm-digest) в GlitchTip — remote_ip в message/тегах раздувал кардинальность + (3 738 issue, 2 019 различных заголовков, топ — 222 события на «GET + /wp-admin/install.p…»), топя содержательные алерты (OperationalError, sweep + failures) в шуме сканеров. + + Дропаем НА ИСТОЧНИКЕ (before_send), не постфактум-чисткой issue-трекера — + так шум не появляется вообще, а не изредка удаляется руками. Фильтруем по + тегу `event_type`, который ставят ТОЛЬКО emit_event/emit_digest — необработанные + исключения самого форвардера (`capture_exception` в конце `main()`, реальный + баг скрипта) этот тег не несут и проходят фильтр как есть (см. `except + Exception` ниже в `main()`). + """ + tags = event.get("tags") + event_type = None + if isinstance(tags, dict): + event_type = tags.get("event_type") + elif isinstance(tags, list): + # sentry_sdk в некоторых версиях сериализует tags как list[tuple[str, str]] + # вместо dict — на всякий случай поддерживаем обе формы. + event_type = dict(tags).get("event_type") if tags else None + if event_type in _BASIC_AUTH_EVENT_TYPES: + return None + return event + def _signal_handler(signum: int, frame: object) -> None: global _shutdown @@ -221,6 +263,7 @@ def main() -> None: traces_sample_rate=0.0, attach_stacktrace=False, send_default_pii=False, + before_send=_drop_basic_auth_noise, # Отключаем интеграции которые не нужны тонкому sidecar default_integrations=False, ) diff --git a/ops/glitchtip-auth-forwarder/test_forwarder.py b/ops/glitchtip-auth-forwarder/test_forwarder.py new file mode 100644 index 00000000..a9c089b3 --- /dev/null +++ b/ops/glitchtip-auth-forwarder/test_forwarder.py @@ -0,0 +1,77 @@ +"""Тесты для `_drop_basic_auth_noise` (before_send-фильтр, glitchtip-noise). + +Раньше форвардер слал КАЖДЫЙ basic_auth 401 (сканеры-боты, ломящиеся в закрытый +basic_auth'ом gendsgn.ru) individual-событием в GlitchTip — 3 738 issue, 2 019 +различных заголовков (remote_ip раздувал кардинальность), топя содержательный +сигнал. `_drop_basic_auth_noise` дропает эти события НА ИСТОЧНИКЕ (before_send), +но НЕ должен трогать unhandled-ошибки самого форвардера (реальный баг скрипта — +`capture_exception` без `event_type`-тега, аналог "500 должен пройти"). +""" + +from __future__ import annotations + +import os + +# DSN обязателен на module-level (`os.environ["GLITCHTIP_DSN"]`, fail-fast) — задаём +# ДО импорта forwarder.py, иначе импорт падает KeyError. +os.environ.setdefault("GLITCHTIP_DSN", "http://test@localhost/1") + +from forwarder import _BASIC_AUTH_EVENT_TYPES, _drop_basic_auth_noise + + +def test_drops_individual_basic_auth_401() -> None: + """emit_event() тегирует event_type=basic_auth_failed — 401 от бота-сканера, + не ошибка сервиса, должен быть отброшен (return None).""" + event = { + "tags": {"event_type": "basic_auth_failed", "remote_ip": "95.165.147.218"}, + "message": "basic_auth 401 — GET /wp-admin/install.php from 95.165.147.218", + } + assert _drop_basic_auth_noise(event, {}) is None + + +def test_drops_basic_auth_storm_digest() -> None: + """emit_digest() тегирует event_type=basic_auth_storm — тоже 401-класс, тоже + не ошибка сервиса, дропаем.""" + event = { + "tags": {"event_type": "basic_auth_storm"}, + "message": "basic_auth storm — 15 failed attempts in 60s", + } + assert _drop_basic_auth_noise(event, {}) is None + + +def test_drops_when_tags_serialized_as_list_of_tuples() -> None: + """Некоторые версии sentry_sdk сериализуют tags как list[tuple[str, str]] + вместо dict — фильтр обязан поддерживать обе формы.""" + event = {"tags": [("event_type", "basic_auth_failed")]} + assert _drop_basic_auth_noise(event, {}) is None + + +def test_passes_through_forwarder_own_crash() -> None: + """500-аналог: unhandled exception самого форвардера (capture_exception в + конце main(), реальный баг скрипта — напр. PermissionError на STATE_FILE) не + несёт event_type-тег → должен пройти НЕТРОНУТЫМ, не быть молча проглоченным + вместе с ботовым шумом.""" + event = { + "level": "error", + "exception": {"values": [{"type": "PermissionError", "value": "denied"}]}, + } + out = _drop_basic_auth_noise(dict(event), {}) + assert out == event + + +def test_passes_through_event_without_tags() -> None: + event: dict = {"message": "something unrelated"} + out = _drop_basic_auth_noise(dict(event), {}) + assert out == event + + +def test_passes_through_unrelated_tag_value() -> None: + event = {"tags": {"event_type": "something_else"}} + out = _drop_basic_auth_noise(dict(event), {}) + assert out == event + + +def test_basic_auth_event_types_are_exactly_the_two_emitters_use() -> None: + """Явная фиксация словаря — emit_event → basic_auth_failed, + emit_digest → basic_auth_storm (см. forwarder.py).""" + assert _BASIC_AUTH_EVENT_TYPES == frozenset({"basic_auth_failed", "basic_auth_storm"}) diff --git a/ops/journald-gendesign.conf.example b/ops/journald-gendesign.conf.example new file mode 100644 index 00000000..414f8236 --- /dev/null +++ b/ops/journald-gendesign.conf.example @@ -0,0 +1,36 @@ +# systemd-journald drop-in — cap persistent journal disk usage on prod VPS. +# +# ЗАМЕР 2026-08-15 (ssh gendesign, read-only): `/var/log` занимал 3.1G. Наивная +# первая проверка `journalctl --disk-usage` показала только 174M и навела на +# ложный след «основной объём — не journald». На деле `journalctl --disk-usage`, +# запущенный НЕ из группы systemd-journal/adm, недосчитывает — он не может +# полноценно перечислить архивные *.journal файлы без прав на чтение. Прямой +# `du -sh /var/log/journal` дал 2.5G — это ~80% всего `/var/log`, ровно 100 +# файлов по ~48M в /var/log/journal//. Второй по размеру вклад — +# традиционный rsyslog (syslog/syslog.1/auth.log/kern.log/ufw.log/dmesg/btmp, +# ~0.6G) — те уже ротируются через logrotate (видны .1/.4.gz копии), отдельного +# вмешательства не требуют и вне scope этого файла. +# +# В /etc/systemd/journald.conf на проде НЕТ SystemMaxUse (все ключи закомменчены +# дефолтами) — без явного лимита journald довольствуется default-правилом +# «до 10% файловой системы», на VPS с диском ~145G это фактически безлимит. +# +# УСТАНОВКА НА СЕРВЕРЕ (руками, deploy.yml этот файл НЕ подхватывает — +# systemd-конфиги вне /opt/gendesign, деплой синкает только сам репозиторий): +# sudo mkdir -p /etc/systemd/journald.conf.d +# sudo cp /opt/gendesign/ops/journald-gendesign.conf.example \ +# /etc/systemd/journald.conf.d/gendesign-max-use.conf +# sudo systemctl restart systemd-journald +# +# `restart systemd-journald` применяет лимит немедленно — journald сам +# провакуумит существующие архивные файлы вниз до SystemMaxUse (ожидаемый +# эффект: /var/log/journal схлопнется примерно с 2.5G до ~500M). Это НЕ +# `docker volume rm` / `caddy reload` — под общий deploy-guard не подпадает, +# но всё равно на живом проде: делает user сам после ревью PR. +# +# Значение 500M — консервативный запас на 4 vCPU/4-16G VPS с активным CI +# (docker/forgejo-runner логи в journald тоже льются). При необходимости +# больше retention для дебага — поднять SystemMaxUse, не удалять файл. + +[Journal] +SystemMaxUse=500M diff --git a/ops/restore.sh b/ops/restore.sh old mode 100644 new mode 100755 diff --git a/scripts/check-migration-lock-timeout.py b/scripts/check-migration-lock-timeout.py new file mode 100644 index 00000000..92b1e3ff --- /dev/null +++ b/scripts/check-migration-lock-timeout.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Гейт: новая миграция с блокирующим DDL обязана нести `SET LOCAL lock_timeout` (#2752). + +ПОЧЕМУ. 2026-08-07 миграция 250 (`DROP INDEX` на таблице в 1061 строку) встала +на боевой БД: сам DROP берёт лок за миллисекунды, но ЖДАЛ его выдачи 29 минут за +чужой аналитической psql-сессией; вторая попытка деплоя — ещё 16 минут. Опасность +не в простое деплоя: ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми +запросами, поэтому обычный SELECT приложения по той же таблице начинает ждать за +ним. В тот раз обошлось, но `lock_timeout` не стоял НИ В ОДНОЙ миграции обоих +data/sql — то есть следующий блокирующий DDL повторил бы это. + +`SET LOCAL` ограничивает ТОЛЬКО ожидание лока, не работу под ним: длинный +CREATE INDEX он не оборвёт, а очередь — не соберёт. Срабатывание = красный деплой +(ON_ERROR_STOP=on) вместо тихой очереди перед приложением. + +ПОЧЕМУ НЕ ОДНИМ `lock_timeout` В РАННЕРЕ (проверено, а не предположено). Вариант +«задать один раз перед циклом миграций» отвергнут замером на PostgreSQL 16.4: +`PGOPTIONS="-c lock_timeout=5s"` действительно доезжает до сервера (`SHOW +lock_timeout` → 5s), но session-wide значение ОБРЫВАЕТ `CREATE INDEX +CONCURRENTLY` — тот ждёт завершения параллельных транзакций через VirtualXactLock, +и это ожидание тоже под lock_timeout. В замере CIC упал через 5 s, когда встречная +сессия просто держала открытую транзакцию (ACCESS SHARE — с CIC вообще не +конфликтует), и ОСТАВИЛ невалидный индекс. То есть runner-wide значение +изготавливало бы ровно ту аварию, от которой заведена проверка невалидных +индексов в deploy-workflow'ах. Блокирующий DDL и CONCURRENTLY хотят +противоположной политики, поэтому granularity — файл, а не раннер. + +ЧТО ТРЕБУЕТСЯ ОТ ФАЙЛА: `SET LOCAL` (не голый `SET`: голый доживёт до конца +сессии и обрежет CIC в том же файле), ПОСЛЕ `BEGIN` (вне транзакции `SET LOCAL` +молча ничего не делает, только WARNING) и ДО первого блокирующего стейтмента. + +ГРАНДФАЗЕРИНГ: миграции ниже порога уже применены на проде, а применённые файлы +задним числом не переписываются. Гейт смотрит только вперёд. + +Запуск: python3 scripts/check-migration-lock-timeout.py [--selftest] +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +# каталог миграций -> минимальный NN, с которого правило обязательно. +# data/sql: последняя на 2026-08-07 — 188_*; tradein: 250_* (та самая). +SQL_DIRS: dict[str, int] = { + "data/sql": 189, + "tradein-mvp/backend/data/sql": 250, +} + +# DDL, берущий лок, который конфликтует с трафиком приложения (ACCESS EXCLUSIVE, +# у CREATE INDEX / REFRESH MV — SHARE / ACCESS EXCLUSIVE). Всё это может встать +# в очередь и увести за собой запросы приложения. +BLOCKING = re.compile( + r"\b(?:" + r"ALTER\s+TABLE|ALTER\s+MATERIALIZED\s+VIEW|" + r"DROP\s+INDEX|CREATE\s+(?:UNIQUE\s+)?INDEX|REINDEX|" + r"DROP\s+(?:MATERIALIZED\s+)?VIEW|REFRESH\s+MATERIALIZED\s+VIEW|" + r"DROP\s+TABLE|TRUNCATE|CLUSTER|VACUUM\s+FULL" + r")\b", + re.IGNORECASE, +) +# CONCURRENTLY-форма НЕ требует lock_timeout и не терпит его (см. шапку). +# Исключение по-стейтментно, не по-файлово: файл с CIC И с ALTER TABLE +# по-прежнему обязан прикрыть свой ALTER. +CONCURRENTLY = re.compile(r"\bCONCURRENTLY\b", re.IGNORECASE) + +BEGIN_STMT = re.compile(r"^\s*(?:BEGIN|START\s+TRANSACTION)\b", re.IGNORECASE) +SET_LOCAL_LT = re.compile(r"^\s*SET\s+LOCAL\s+lock_timeout\b", re.IGNORECASE) +SET_BARE_LT = re.compile(r"^\s*SET\s+(?!LOCAL\b)(?:SESSION\s+)?lock_timeout\b", re.IGNORECASE) +NN_PREFIX = re.compile(r"^(\d+)") + + +def strip_noise(sql: str) -> str: + """Убирает `--` и `/* */` комментарии, а тела строковых литералов заменяет на + пробелы (сохраняя длину и переводы строк — номера строк не съезжают). + + Дословный текст литералов не нужен, а вреден: в COMMENT ON ... IS '...' + легко встречается слово ALTER TABLE, и без затирания гейт ловил бы прозу. + Тела $$...$$ (DO-блоки) НЕ затираются — там живёт исполняемый DDL. + """ + out: list[str] = [] + i, n = 0, len(sql) + while i < n: + ch = sql[i] + nxt = sql[i + 1] if i + 1 < n else "" + if ch == "-" and nxt == "-": + while i < n and sql[i] != "\n": + out.append(" ") + i += 1 + elif ch == "/" and nxt == "*": + depth = 1 # в PostgreSQL блочные комментарии вложенные + out.append(" ") + i += 2 + while i < n and depth: + if sql[i] == "/" and i + 1 < n and sql[i + 1] == "*": + depth += 1 + out.append(" ") + i += 2 + elif sql[i] == "*" and i + 1 < n and sql[i + 1] == "/": + depth -= 1 + out.append(" ") + i += 2 + else: + out.append("\n" if sql[i] == "\n" else " ") + i += 1 + elif ch == "'": + out.append("'") + i += 1 + while i < n: + if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'": + out.append(" ") + i += 2 + continue + if sql[i] == "'": + break + out.append("\n" if sql[i] == "\n" else " ") + i += 1 + if i < n: + out.append("'") + i += 1 + else: + out.append(ch) + i += 1 + return "".join(out) + + +def scan(sql: str) -> list[str]: + """-> список претензий к файлу; пустой список = файл в порядке.""" + clean = strip_noise(sql) + statements = clean.split(";") + + first_blocking: int | None = None + blocking_text = "" + for idx, stmt in enumerate(statements): + if BLOCKING.search(stmt) and not CONCURRENTLY.search(stmt): + first_blocking = idx + blocking_text = " ".join(stmt.split())[:80] + break + if first_blocking is None: + return [] + + set_local = next((i for i, s in enumerate(statements) if SET_LOCAL_LT.search(s)), None) + if set_local is None: + if any(SET_BARE_LT.search(s) for s in statements): + return [ + f"`SET lock_timeout` без LOCAL при блокирующем DDL ({blocking_text}). " + "Голый SET живёт до конца сессии и обрежет CREATE INDEX CONCURRENTLY " + "в этом же файле. Нужен `SET LOCAL lock_timeout = '5s';` внутри BEGIN." + ] + return [ + f"блокирующий DDL без lock_timeout ({blocking_text}). Добавь первой " + "строкой после BEGIN: `SET LOCAL lock_timeout = '5s';` — иначе DDL встанет " + "в очередь за чужой сессией и уведёт за собой запросы приложения (#2752)." + ] + + problems: list[str] = [] + if not any(BEGIN_STMT.search(s) for s in statements[:set_local]): + problems.append( + "`SET LOCAL lock_timeout` стоит ВНЕ транзакции (нет BEGIN выше). " + "Вне блока транзакции SET LOCAL молча ничего не делает (только WARNING)." + ) + if set_local > first_blocking: + problems.append( + f"`SET LOCAL lock_timeout` стоит ПОСЛЕ блокирующего DDL ({blocking_text}) — " + "к моменту DDL он ещё не действует. Подними его сразу под BEGIN." + ) + return problems + + +def selftest() -> None: + ok = "BEGIN;\nSET LOCAL lock_timeout = '5s';\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n" + assert scan(ok) == [], scan(ok) + + # красное: ровно случай 250 до фикса + bad = "BEGIN;\nDROP INDEX IF EXISTS foo_idx;\nCOMMIT;\n" + assert len(scan(bad)) == 1 and "без lock_timeout" in scan(bad)[0] + assert scan("BEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n") + assert scan("BEGIN;\nALTER TABLE t ADD CONSTRAINT c CHECK (x > 0);\nCOMMIT;\n") + assert scan("BEGIN;\nALTER TABLE t DROP COLUMN IF EXISTS x;\nCOMMIT;\n") + assert scan("BEGIN;\nCREATE INDEX IF NOT EXISTS i ON t (c);\nCOMMIT;\n") + + # красное: правильная строка, но в местах, где она не действует + assert "ВНЕ транзакции" in scan("SET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\n")[0] + late = "BEGIN;\nALTER TABLE t ADD COLUMN x int;\nSET LOCAL lock_timeout='5s';\nCOMMIT;\n" + assert any("ПОСЛЕ блокирующего DDL" in p for p in scan(late)) + bare = "BEGIN;\nSET lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n" + assert "без LOCAL" in scan(bare)[0] + + # зелёное: CONCURRENTLY-формы, им lock_timeout вреден (обрывает CIC) + assert scan("CREATE INDEX CONCURRENTLY IF NOT EXISTS i ON t (c);\n") == [] + assert scan("DROP INDEX CONCURRENTLY IF EXISTS i;\n") == [] + assert scan("REFRESH MATERIALIZED VIEW CONCURRENTLY mv;\n") == [] + # ...но CONCURRENTLY в файле не прощает соседний блокирующий DDL + mixed = "CREATE INDEX CONCURRENTLY i ON t (c);\nBEGIN;\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n" + assert scan(mixed), "CONCURRENTLY не должен амнистировать ALTER TABLE в том же файле" + mixed_ok = ( + "CREATE INDEX CONCURRENTLY i ON t (c);\n" + "BEGIN;\nSET LOCAL lock_timeout='5s';\nALTER TABLE t ADD COLUMN x int;\nCOMMIT;\n" + ) + assert scan(mixed_ok) == [], scan(mixed_ok) + + # зелёное: DDL, которого нет — он в комментарии или в строковом литерале + assert scan("-- ALTER TABLE t ADD COLUMN x int;\nSELECT 1;\n") == [] + assert scan("/* DROP INDEX foo; */\nSELECT 1;\n") == [] + assert scan("/* /* вложенный */ ALTER TABLE t ADD COLUMN x int; */\nSELECT 1;\n") == [] + assert scan("COMMENT ON INDEX i IS 'не заводить второй: ALTER TABLE тут проза';\n") == [] + assert scan("COMMENT ON INDEX i IS 'кавычка внутри '' и DROP INDEX проза';\n") == [] + # зелёное: не-DDL миграции (backfill/seed) правила не касаются + assert scan("BEGIN;\nUPDATE t SET x = 1 WHERE x IS NULL;\nCOMMIT;\n") == [] + assert scan("BEGIN;\nINSERT INTO t (x) VALUES (1) ON CONFLICT DO NOTHING;\nCOMMIT;\n") == [] + print("selftest OK") + + +def main() -> int: + if "--selftest" in sys.argv: + selftest() + return 0 + + failed = False + checked = 0 + for dirname, min_nn in SQL_DIRS.items(): + sql_dir = Path(dirname) + if not sql_dir.is_dir(): + print(f"::error::{sql_dir} не найден — запускать из корня репозитория") + return 1 + for path in sorted(sql_dir.glob("*.sql")): + m = NN_PREFIX.match(path.name) + if not m or int(m.group(1)) < min_nn: + continue # применено на проде до внедрения гейта — не переписываем + checked += 1 + for problem in scan(path.read_text(encoding="utf-8")): + failed = True + print(f"::error file={path}::{problem}") + if failed: + return 1 + print(f"✓ блокирующий DDL прикрыт lock_timeout (проверено новых миграций: {checked})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-workflow-ports.py b/scripts/check-workflow-ports.py new file mode 100644 index 00000000..887c1169 --- /dev/null +++ b/scripts/check-workflow-ports.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Гейт: сервис-контейнер сборки не должен публиковать порт, занятый на VPS (#2757). + +ПОЧЕМУ. Forgejo-раннер запускает и job, и сервис-контейнеры с `--network host` +(проверено: `docker inspect FORGEJO-ACTIONS-TASK-... -f {{.HostConfig.NetworkMode}}` +→ `host`). На том же хосте слушают боевые сервисы. Поэтому обычный приём +`services: postgres` + публикация `5432:5432` не поднимает тестовую БД, а тихо +уводит job В ПРОД: занять порт сервис-контейнер не может, а `localhost:5432` из +job'а — это боевой Postgres. В #2745 отказ спас только несовпадение пароля. + +Список занятых портов — не догадки, а замер `ss -ltnp` на bot-server 2026-08-06. +Опровергнутая по ходу предпосылка issue: Redis / CouchDB / GlitchTip НА ХОСТЕ НЕ +СЛУШАЮТ (в `docker ps` у них `6379/tcp`, `5984/tcp`, `8000/tcp` без `->`, они +живут в bridge-сетях) — сервис-контейнер с их портами ни с чем не столкнётся, +и запрещать их значило бы ронять сборку на ровном месте. + +Правильный способ дать сборке БД — как в ci-tradein.yml: явный `docker run` +в bridge-сети БЕЗ публикации порта + строка подключения по IP контейнера. + +Запуск: python3 scripts/check-workflow-ports.py [--selftest] +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +# host-порт -> кто его уже занял (ss -ltnp на bot-server, 2026-08-06) +RESERVED: dict[int, str] = { + 22: "sshd хоста", + 53: "systemd-resolved (127.0.0.53)", + 80: "gendesign-caddy-1 (0.0.0.0:80)", + 443: "gendesign-caddy-1 (0.0.0.0:443)", + 2222: "forgejo, git-over-ssh (0.0.0.0:2222)", + 3000: "gendesign-frontend-1 (127.0.0.1:3000)", + 5432: "gendesign-postgres-1 — БОЕВАЯ БД (127.0.0.1:5432)", + 8000: "gendesign-backend-1 — боевой API (127.0.0.1:8000)", +} + +WORKFLOW_DIR = Path(".forgejo/workflows") + +# "8080:80", "127.0.0.1:5432:5432", "5432:5432/tcp" -> (host_port, container_port) +PORT_PAIR = re.compile(r"(?:(?:\d{1,3}\.){3}\d{1,3}:)?(\d{1,5}):(\d{1,5})(?:/(?:tcp|udp))?") +PUBLISH_FLAG = re.compile(r"(?:^|\s)(?:-p|--publish)[=\s]+(\S+)") + + +def scan(text: str) -> list[tuple[int, str, int]]: + """-> [(номер строки, строка, занятый host-порт)] для публикаций в ports:/-p.""" + hits: list[tuple[int, str, int]] = [] + in_ports = False + ports_indent = 0 + + for lineno, raw in enumerate(text.splitlines(), 1): + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue # комментарий ничего не запускает (в них живут анти-примеры) + indent = len(raw) - len(raw.lstrip()) + + if in_ports and (not stripped.startswith("- ") or indent <= ports_indent): + in_ports = False + + candidates: list[str] = [] + if stripped.startswith("ports:"): + rest = stripped[len("ports:") :].strip() + if rest: + candidates.append(rest) # инлайн-форма: ports: ["5432:5432"] + else: + in_ports, ports_indent = True, indent + elif in_ports: + candidates.append(stripped) + candidates += PUBLISH_FLAG.findall(raw) # docker run -p / --publish + + for candidate in candidates: + for host_port, _container_port in PORT_PAIR.findall(candidate): + if int(host_port) in RESERVED: + hits.append((lineno, stripped, int(host_port))) + return hits + + +def selftest() -> None: + assert scan(" ports:\n - 5432:5432\n") == [(2, "- 5432:5432", 5432)] + assert scan(' ports: ["127.0.0.1:8000:8000"]\n')[0][2] == 8000 + assert scan(" docker run -p 3000:3000 nginx\n")[0][2] == 3000 + assert scan(" docker run --publish=443:443 nginx\n")[0][2] == 443 + # безопасные формы и посторонние двоеточия — не должны ронять сборку + assert scan(" ports:\n - 5432\n") == [] + assert scan(" ports:\n - 15432:5432\n") == [] + assert scan(" image: postgis/postgis:16-3.4\n") == [] + assert scan(' run: journalctl --since "2026-08-07 22:00"\n') == [] + assert scan(" # ports:\n # - 5432:5432\n") == [] + assert scan(" env:\n DATABASE_URL: pg://u:p@localhost:5432/test\n") == [] + # выход из блока ports: по отступу + assert scan(" ports:\n - 9999:9999\n image: x\n - 80:80\n") == [] + print("selftest OK") + + +def main() -> int: + if "--selftest" in sys.argv: + selftest() + return 0 + + if not WORKFLOW_DIR.is_dir(): + print(f"::error::{WORKFLOW_DIR} не найден — запускать из корня репозитория") + return 1 + + failed = False + for path in sorted(WORKFLOW_DIR.glob("*.yml")): + for lineno, line, port in scan(path.read_text(encoding="utf-8")): + failed = True + print( + f"::error file={path},line={lineno}::публикация порта {port} — он занят " + f"на VPS ({RESERVED[port]}). Раннер работает в сети хоста: контейнер " + f"порт не займёт, а job уйдёт в этот прод-сервис (#2757). Поднимай " + f"сервис через `docker run` в bridge-сети БЕЗ публикации и ходи по IP " + f"контейнера — образец в .forgejo/workflows/ci-tradein.yml. || {line}" + ) + if failed: + return 1 + print("✓ ни один workflow не публикует занятый на VPS порт") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh new file mode 100644 index 00000000..535381e4 --- /dev/null +++ b/scripts/smoke-mera-perimeter.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Регресс-тест публичного B2C-периметра МЕРА (ЭТАП 1 плана B2C-запуска). +# +# Проверяет инварианты периметра (см. корневой Caddyfile): +# 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг). +# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 — +# политика ПДн, на которую ссылается футер. +# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL +# напечатаны внутри самих юридических документов и уходят эквайеру. +# 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 + +# 1c. Короткие адреса юридических документов. Это НЕ дубль проверки 1b: именно +# эти три URL напечатаны внутри самих документов и уходят в заявку +# эквайеру — если rewrite выпадет из Caddyfile, оферта будет ссылаться на +# 404, и заявку завернут. Проверяем все три поимённо, потому что и в +# Caddyfile они перечислены поимённо (allowlist, не шаблон). +check "meraocenka.ru/oferta — public 200" "$BASE_MERA/oferta" 200 +check "meraocenka.ru/refund — public 200" "$BASE_MERA/refund" 200 +check "meraocenka.ru/privacy — public 200" "$BASE_MERA/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 + +# 6. Платёжный периметр (PR-D2) — готовит почву под PR-D3 (роутер) и PR-D4 +# (Caddy), но САМ НИЧЕГО НЕ ОТКРЫВАЕТ. Ожидаем закрытое состояние С ОБЕИХ +# СТОРОН прямо сейчас: +# - meraocenka.ru вообще не проксирует /trade-in/api/* (allowlist-by-default, +# см. проверку 2) — 404 от Caddy, до бэкенда не доходит; +# - gendsgn.ru проксирует /trade-in/api/* в tradein-backend, но rbac_guard +# (`_PUBLIC_PATHS` в app/core/rbac.py — ЭТОТ PR её не трогает) не знает +# платёжные пути и требует X-Authenticated-User → 401 анониму. +# Если один из этих чек-ов вдруг перестанет быть 404/401 РАНЬШЕ мержа +# PR-D3/PR-D4 — это и есть преждевременная утечка периметра, которую ловит +# этот смоук (канарейка: осознанно станет красной, когда PR-D3/PR-D4 явно +# откроют эти пути — тогда ожидания здесь надо обновить вместе с ними). +check "meraocenka.ru payments/notify — must 404 (Caddy не проксирует, PR-D4)" \ + "$BASE_MERA/trade-in/api/v1/trade-in/payments/notify" 404 +check "meraocenka.ru payments/checkout — must 404 (Caddy не проксирует, PR-D4)" \ + "$BASE_MERA/trade-in/api/v1/trade-in/payments/checkout" 404 +check "trade-in payments/notify — 401 anonymous (rbac закрыт до PR-D3)" \ + "$BASE_MAIN/trade-in/api/v1/trade-in/payments/notify" 401 +check "trade-in payments/checkout — 401 anonymous (rbac закрыт до PR-D3)" \ + "$BASE_MAIN/trade-in/api/v1/trade-in/payments/checkout" 401 + +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/.gitignore b/tradein-mvp/.gitignore index 233693bc..a746885d 100644 --- a/tradein-mvp/.gitignore +++ b/tradein-mvp/.gitignore @@ -15,7 +15,10 @@ frontend/node_modules/ frontend/.next/ frontend/out/ frontend/dist/ -frontend/package-lock.json +# frontend/package-lock.json — TRACKED с #2770: Dockerfile и CI ставят `npm ci`, +# которому лок обязателен. Правишь frontend/package.json — регенерируй лок +# (`npm install --legacy-peer-deps --no-audit --no-fund`) в том же PR, иначе +# `npm ci` роняет сборку образа и весь deploy откатывается. frontend/next-env.d.ts # Docker diff --git a/tradein-mvp/CHANGELOG.md b/tradein-mvp/CHANGELOG.md new file mode 100644 index 00000000..0e70377d --- /dev/null +++ b/tradein-mvp/CHANGELOG.md @@ -0,0 +1,46 @@ +# История версий «МЕРА» + +Формат по мотивам [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/) и +[Semantic Versioning](https://semver.org/lang/ru/). Заголовок версии — ровно +`## ` (машинно читается страницей истории версий). + +## 2.1.0 — 2026-08-10 + +Первая версия с явным версионированием. Номер продолжает ряд, который до этого +показывался в отчётах, — чтобы он не пошёл назад для тех, кто уже видел прежние +отчёты. + +### Добавлено + +- Оценка стоимости квартиры по объявлениям (Авито, Циан, Яндекс.Недвижимость) и + реальным сделкам Росреестра — медиана, диапазон цены и цены за м², уровень + уверенности в оценке. +- PDF-отчёт по оценке под брендом «МЕРА»: обложка с диапазоном цены, состав + аналогов и сделок, формирование выкупной стоимости. +- Аналитика по дому — история размещений объявлений и продаж в доме. +- История прошлых оценок в личном кабинете, автодополнение адреса при поиске. +- Личный кабинет: вход/выход, дашборд менеджера (сотрудники, квоты, история). +- Чат поддержки на сайте, в том числе без входа в личный кабинет. +- Публичный лендинг «МЕРА». +- Номер версии продукта в подвале интерфейса и в шапке PDF-отчёта, а также эта + страница истории версий. + +### Изменено + +- Дизайн PDF-отчёта переработан в фирменный HUD-стиль «МЕРА» вместо более + раннего технического макета. + +### Исправлено + +- Студии больше не оцениваются как однокомнатные квартиры. Раньше в выборе + комнатности не было варианта «Студия», из-за чего для студии подбирались + однокомнатные аналоги — их рядом почти нет, и оценка не выдавалась. +- Оценка больше не блокируется, если рядом мало аналогов. Теперь подбор + автоматически расширяется (студии, срок объявлений, новостройки, радиус), + а над результатом показывается предупреждение о сниженной точности и о том, + какие параметры пришлось расширить. +- Восстановлены блоки «сделки по улице» и «продажи против объявлений»: для части + адресов улица не распознавалась, и разделы оставались пустыми. +- PDF-отчёт стабильно формируется ровно на 4 страницах без пустых листов. +- Устранены неточности в отчёте: пустой «Год постройки», дублирующиеся блоки + на обложке, некорректные допущения о сроке экспозиции. 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 +269,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 +278,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 +318,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( @@ -356,6 +374,56 @@ async def geocode_missing( } +def _cian_verify_state_error(state: dict[str, Any] | None) -> HTTPException | None: + """Маппинг исхода cian_session_svc.verify_session() на HTTP-ответ админки. + + verify_session() возвращает 4 разных исхода (см. докстринг сервиса) плюс успех — + их нельзя схлопывать в один "cookies invalid", иначе бан по IP выглядит так же, + как протухшие куки, и человек в момент инцидента перезаливает заведомо валидные + куки вместо починки egress/прокси (инцидент 2026-08-10). + + Sentinel'ы сравниваются через `is`, НЕ `==` — так требует докстринг verify_session. + + Возвращает None, если state — это успешно распаршенный state dict (в т.ч. случай + "успех, но userId не найден" — этот случай caller должен обработать отдельно). + """ + if state is cian_session_svc.VERIFY_BAN_SENTINEL: + return HTTPException( + status_code=503, + detail=( + "Cian заблокировал наш IP (HTTP 403, TLS/bot-fingerprint ban). " + "Куки, скорее всего, валидны — блокировка не про них. " + "Нужно чинить egress: проверить SCRAPER_PROXY_URL и баны в " + "scrape_proxy_source_bans. Перезаливать куки бесполезно. " + "(CIAN_PROXY_URL — мёртвая переменная, снята в #2616.)" + ), + ) + if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL: + return HTTPException( + status_code=503, + detail=( + "Cian временно недоступен (5xx или сетевой сбой при проверке кук). " + "Повторите проверку позже. Куки не трогать — источник просто не ответил." + ), + ) + if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL: + return HTTPException( + status_code=500, + detail=( + "Cian изменил вёрстку/схему страницы — auth-state не найден/не " + "распарсился (scraper_kit.cian_state_parser.extract_state, MFE " + "header-frontend). Нужен инженерный фикс парсера, перезалив кук " + "проблему НЕ решит." + ), + ) + if state is None: + return HTTPException( + status_code=401, + detail="Куки протухли или сессия разлогинена на cian.ru — перезалейте куки.", + ) + return None + + @router.post("/scrape/cian/upload-cookies", status_code=200) async def upload_cian_cookies( cookies: dict[str, str], @@ -388,15 +456,21 @@ async def upload_cian_cookies( ) state = await cian_session_svc.verify_session(cleaned) - if state is None: - raise HTTPException( - status_code=401, - detail="Cookies invalid or session not authenticated on cian.ru", - ) + verify_error = _cian_verify_state_error(state) + if verify_error is not None: + raise verify_error + assert state is not None # narrowed by _cian_verify_state_error above user_id = state.get("user", {}).get("userId") if not user_id: - raise HTTPException(status_code=400, detail="Authenticated state missing userId") + raise HTTPException( + status_code=400, + detail=( + "Cian подтвердил аутентификацию (state распарсился), но userId в " + "ответе не найден — структура state неожиданная, куки тут ни при " + "чём, смотрите server logs." + ), + ) cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned) return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)} @@ -463,15 +537,21 @@ async def cian_auto_login( ) state = await cian_session_svc.verify_session(cleaned) - if state is None: - raise HTTPException( - status_code=401, - detail="Logged in but session not authenticated (cookies rejected by cian.ru)", - ) + verify_error = _cian_verify_state_error(state) + if verify_error is not None: + raise verify_error + assert state is not None # narrowed by _cian_verify_state_error above user_id = state.get("user", {}).get("userId") if not user_id: - raise HTTPException(status_code=400, detail="Authenticated state missing userId") + raise HTTPException( + status_code=400, + detail=( + "Cian подтвердил аутентификацию (state распарсился), но userId в " + "ответе не найден — структура state неожиданная, куки тут ни при " + "чём, смотрите server logs." + ), + ) cian_session_svc.save_session(db, account_user_id=int(user_id), cookies=cleaned) return {"ok": True, "userId": user_id, "cookieCount": len(cleaned)} @@ -483,6 +563,12 @@ async def test_cian_auth( ) -> dict: """Проверить что текущие сохранённые Cian cookies ещё валидны. + reason различает 5 исходов (см. cian_session_svc.verify_session докстринг): + "banned_403" — куки, вероятно, ОК, блокирован IP; "source_unavailable" — + Cian недоступен, куки ни при чём; "markup_changed" — вёрстка Cian сменилась, + нужен фикс парсера; "session_expired_or_invalid" — куки реально протухли; + "no_session_in_db" / "encryption_key_not_configured" — конфигурация/данных нет. + Returns: {"authenticated": bool, "userId": , "reason": } """ if not settings.cookie_encryption_key: @@ -493,10 +579,14 @@ async def test_cian_auth( return {"authenticated": False, "userId": None, "reason": "no_session_in_db"} state = await cian_session_svc.verify_session(cookies) + if state is cian_session_svc.VERIFY_BAN_SENTINEL: + return {"authenticated": False, "userId": None, "reason": "banned_403"} + if state is cian_session_svc.VERIFY_SOURCE_UNAVAILABLE_SENTINEL: + return {"authenticated": False, "userId": None, "reason": "source_unavailable"} + if state is cian_session_svc.VERIFY_MARKUP_CHANGED_SENTINEL: + return {"authenticated": False, "userId": None, "reason": "markup_changed"} if state is None: return {"authenticated": False, "userId": None, "reason": "session_expired_or_invalid"} - if state.get("_ban"): - return {"authenticated": False, "userId": None, "reason": "banned_403"} user_id = state.get("user", {}).get("userId") return {"authenticated": True, "userId": user_id, "reason": None} @@ -1577,8 +1667,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 +1696,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 +1724,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() @@ -1857,9 +1977,16 @@ async def scrape_cian_detail( Without it → debug-only (no DB write). """ _assert_allowed_url(offer_url) + from scraper_kit.cian_exceptions import CianBlockedError from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment - enrichment = await fetch_detail(offer_url, config=RealScraperConfig()) + try: + enrichment = await fetch_detail(offer_url, config=RealScraperConfig()) + except CianBlockedError as exc: + # #2700: 403 теперь исключение (узел снимается с выдачи Циану). Ad-hoc ручке + # нужен внятный ответ, а не 500: «страницу не разобрали» и «нас не пустили с + # этого узла» — разные новости для того, кто дёргает ручку руками. + raise HTTPException(502, f"Cian заблокировал наш узел: {exc}") from exc if enrichment is None: raise HTTPException(404, f"Could not parse Cian detail page: {offer_url}") @@ -1906,14 +2033,17 @@ async def scrape_cian_newbuilding( save_newbuilding_enrichment, ) - enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig()) + enrichment = await fetch_newbuilding( + zhk_url, config=RealScraperConfig(), proxy_provider=_kit_proxy_provider() + ) if enrichment is None: raise HTTPException(404, f"Could not parse Cian newbuilding page: {zhk_url}") saved = False if house_id is not None: - # save_newbuilding_enrichment — sync (def, returns None); await на sync-функции - # раньше поднимал TypeError на любом вызове с house_id. + # save_newbuilding_enrichment — sync (def, не корутина); await на sync-функции + # раньше поднимал TypeError на любом вызове с house_id. Возвращаемый счёт + # записанного (#2807) этой ручке не нужен — она отвечает фактом сохранения. save_newbuilding_enrichment(db, house_id, enrichment) saved = True @@ -2100,7 +2230,10 @@ class HouseIMVBackfillRequest(BaseModel): ) only_status: str = Field( default="pending", - description="Обрабатывать дома с этим imv_status. 'transient_error' — retry.", + description=( + "Обрабатывать дома с этим imv_status. По умолчанию 'pending' + автоповтор " + "'transient_error' на половине пакета; явное значение = только этот статус." + ), ) house_id: int | None = Field( default=None, @@ -2137,7 +2270,12 @@ async def scrape_house_imv_backfill( batch_size: сколько домов обработать за запуск (default 50). request_delay_sec: пауза между IMV-вызовами (default 5s). ВАЖНО: Avito IMV реагирует на частые запросы с datacenter-IP. Не снижать < 3s. - only_status: по умолчанию 'pending'. Для retry failed — 'transient_error'. + only_status: по умолчанию 'pending' — и тогда половина пакета сама уходит на + повтор домов в 'transient_error' с непотраченным лимитом попыток (#2674: + раньше повтор существовал только как этот параметр, и за 41 прогон его + не передали ни разу — 1390 домов застряли навсегда). Явное значение + отключает автоповтор и обрабатывает РОВНО указанный статус, включая + дома, исчерпавшие лимит (imv_transient_attempts >= 3). house_id: обработать один дом (debug). Примечание по прокси: Avito IMV использует собственную curl_cffi-сессию. @@ -2169,16 +2307,35 @@ 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' (не отдал наш браузерный сайдкар) или 'unknown' (#2764 — причина не + # установлена; раньше такие прогоны молча получали 'platform'). Без него + # оператор видит только «забанен» и делает вывод «площадка нас палит» на 80% + # наших же отказов. + ban_kind: str | None = None params: dict | None = None counters: dict | None = None total_seen: int | None = None @@ -2194,6 +2351,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,31 +2376,36 @@ 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).""" - return { - "avito": settings.avito_proxy_url, - "cian": settings.cian_proxy_url, - "yandex": settings.yandex_proxy_url, - }.get(source) +def _provider_proxy_url(db: Session, source: str) -> str | None: + """Узел, который РЕАЛЬНО получит трафик этого источника (#2830). + Раньше здесь стоял `settings.scraper_proxy_url` — одна и та же статичная + переменная для всех трёх источников. После #2825/#2831 egress выбирается из + `scrape_proxies` по запросу и с учётом `scrape_proxy_source_bans`, то есть + страница показывала один узел, а трафик шёл через другой — слепое пятно ровно + того класса, который спрятал инцидент 2026-08-10 (месяц сбора через узел, + забаненный и Avito, и Cian), только теперь на диагностической странице. -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) + Тот же резолвер, что у боевых ad-hoc путей (`cian_session.verify_session`, + `*_detail_backfill`) — не «похожая логика», иначе страница снова начнёт + расходиться с трафиком. + + Вердикт пулу отсюда НЕ уходит и уходить не должен (#2805): резолвер read-only, + lease не берёт, а ipify-проба ниже проверяет доступность ipify через узел, а не + его репутацию у Авито/Циана — присваивать узлу отказ по чужой пробе значит + выдавать ему чужой бан. + """ + try: + return resolve_proxy_url(db, source) + except ProxyPoolExhaustedError: + # Пул не пуст, но для source не осталось ни одного здорового небаненного узла. + # resolve_proxy_url уже написал error с разбивкой; здесь отдаём None — пусть + # страница покажет «—», а не статичный env-узел (зелёная строка на месте + # отказа хуже пустой). + return None def _parse_proxy_host_port(proxy_url: str | None) -> tuple[str | None, int | None]: @@ -2257,7 +2425,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 +2441,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 +2456,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 +2473,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" @@ -2333,17 +2521,23 @@ async def _probe_current_ip(proxy_url: str | None) -> str | None: @router.get("/scraper/health", response_model=ScraperHealthResponse) -async def scraper_health() -> ScraperHealthResponse: +async def scraper_health( + db: Annotated[Session, Depends(get_db)], +) -> ScraperHealthResponse: """Сводный health для единой scrapers-страницы: fetch_mode + browser + провайдеры. - 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 — узел, который пул отдаст ЭТОМУ источнику + сейчас (#2830, см. `_provider_proxy_url`; пусто = ни одного небаненного + здорового узла), 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с. """ - proxy_urls = {s: _provider_proxy_url(s) for s in _ROTATABLE_SOURCES} + proxy_urls = {s: _provider_proxy_url(db, s) for s in _ROTATABLE_SOURCES} browser, *ips = await asyncio.gather( _probe_browser_health(), @@ -2359,7 +2553,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 +2565,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 +2660,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 +2680,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). @@ -2532,7 +2695,8 @@ _DQ_LISTING_FIELDS: list[tuple[str, str]] = [ ("lon", "lon IS NOT NULL"), ("kitchen_area_m2", "kitchen_area_m2 IS NOT NULL"), ("living_area_m2", "living_area_m2 IS NOT NULL"), - ("ceiling_height", "ceiling_height IS NOT NULL"), + # #2699: одна колонка вместо двух. ceiling_height (019) DEPRECATED — писатели + # переведены на ceiling_height_m, исторические значения перенесены (мигр. 238). ("ceiling_height_m", "ceiling_height_m IS NOT NULL"), ("metro_stations", "metro_stations IS NOT NULL AND metro_stations <> '[]'::jsonb"), ] @@ -2546,9 +2710,13 @@ def get_data_quality( Один проход per source через COUNT(*)...FILTER — не N запросов. Поля listings: description, photo_urls, address, lat/lon, kitchen_area_m2, - living_area_m2, ceiling_height (cian), ceiling_height_m (avito), metro_stations. + living_area_m2, ceiling_height_m (все источники, #2699), 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 +2724,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 +2742,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 +2756,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 +2784,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 +2855,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 +2909,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 +2922,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 +2936,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 +2973,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 +3009,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 +3028,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 +3046,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 +3057,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 +3070,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 +3078,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 +3098,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 +3139,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 +3163,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 +3176,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/audit.py b/tradein-mvp/backend/app/api/v1/audit.py index e592d5b1..feac11aa 100644 --- a/tradein-mvp/backend/app/api/v1/audit.py +++ b/tradein-mvp/backend/app/api/v1/audit.py @@ -58,6 +58,12 @@ async def list_accounts( count(*) FILTER (WHERE event_type = 'api_request') AS request_count, count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count FROM user_events + -- Событие без имени — не аккаунт (#2715: `login_verify_saturated` + -- пишется с пустым именем намеренно — отказ случается ДО того, как + -- на имя посмотрели). Без фильтра строка встала бы ПЕРВОЙ (её + -- last_seen_at — момент атаки), а её кнопка в UI раскрывалась бы в + -- /audit/accounts/{username} с `min_length=1`, то есть в ошибку. + WHERE username <> '' GROUP BY username ORDER BY last_seen_at DESC """ @@ -182,12 +188,16 @@ async def analytics_dashboard( db.execute( text( """ + -- NULLIF(username, ''): безымянные события (#2715) — СОБЫТИЯ, они + -- честно входят в total_events, но не люди: count(DISTINCT) их + -- игнорирует по NULL, иначе первая же атака навсегда добавила бы + -- фантомного пользователя в счётчик уникальных. SELECT count(*) AS total_events, - count(DISTINCT username) AS distinct_users, + count(DISTINCT NULLIF(username, '')) AS distinct_users, count(*) FILTER ( WHERE created_at >= now() - INTERVAL '24 hours' ) AS events_last_24h, - count(DISTINCT username) FILTER ( + count(DISTINCT NULLIF(username, '')) FILTER ( WHERE created_at >= now() - INTERVAL '24 hours' ) AS active_users_last_24h FROM user_events @@ -204,7 +214,7 @@ async def analytics_dashboard( """ SELECT date_trunc('day', created_at)::date AS day, count(*) AS events, - count(DISTINCT username) AS users + count(DISTINCT NULLIF(username, '')) AS users -- см. выше (#2715) FROM user_events WHERE created_at >= now() - make_interval(days => CAST(:days AS int)) GROUP BY date_trunc('day', created_at)::date @@ -261,6 +271,7 @@ async def analytics_dashboard( count(*) FILTER (WHERE event_type = 'estimate_request') AS searches, max(created_at) AS last_seen FROM user_events + WHERE username <> '' -- не аккаунт, см. /audit/accounts выше (#2715) GROUP BY username ORDER BY events DESC LIMIT 50 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..af346cde --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/auth.py @@ -0,0 +1,477 @@ +"""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 той же функции. + Отказ по насыщению выдаётся ДО выборки из реестра (#2715): иначе на этом + пути оставалась бы единственная работа, время которой зависит от того, + существует ли имя, — а bcrypt, который эту разницу ровняет, до него уже не + доходит. След инцидента — агрегированный, `_saturated_429`. + - Поверх него — ГЛОБАЛЬНЫЙ счётчик неудач на ИМЯ, без 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 +import time +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, + verify_slots_saturated, +) +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)) + + +# Не чаще одной записи в это окно на ВСЕ отказы по насыщению (#2715). Окно, а не +# запись на запрос, потому что лог у бэкенда общий и ограниченный (docker +# json-file, max-size 20m × max-file 3): при флуде в сотни запросов в секунду +# строка на каждый отказ прокручивает 60 МБ за минуты и выселяет ВСЕ остальные +# логи ровно во время атаки — то есть в момент, когда они нужнее всего. +# Значение не в настройках намеренно: это не тюнинг, а «человек читает лог», и +# крутить его нечем — меньше секунды возвращает исходную проблему, больше +# ухудшает разрешение по времени, не давая взамен ничего. +_SATURATION_REPORT_WINDOW_S = 1.0 + +# Отказов с прошлой записи и когда была прошлая запись (monotonic; None — записи +# ещё не было). Обычные глобалы без лока — по той же причине, что и счётчик +# слотов в `app.core.password`: обе строчки исполняются в потоке событийного +# цикла и между чтением и записью нет `await`. +_saturation_rejected = 0 +_saturation_reported_at: float | None = None + + +def _saturated_429(ip: str) -> HTTPException: + """429 «слоты сверки заняты» + АГРЕГИРОВАННЫЙ след инцидента. + + Событие неудачного входа тут не пишется и бюджет неудач по имени не + тратится сознательно (#2712): пароль не проверялся, это не попытка входа, а + трата бюджета означала бы, что насыщением можно заблокировать чужую учётку. + Но тогда весь инцидент виден ровно здесь, и до #2715 — только строкой в + логе на каждый отклонённый запрос (см. `_SATURATION_REPORT_WINDOW_S`). + + Поэтому на окно приходится одна строка в лог И одно событие + `login_verify_saturated` в `user_events` — с числом отказов, накопленных с + прошлой записи. Событие важнее строки: аудит переживает и ротацию логов, и + редеплой. Первый отказ отчитывается сразу, а не в конце окна: одиночная + аномалия обязана быть видна, даже если продолжения не будет. + + `since_prev_s` в payload — НЕ дубль `created_at`, а единственный способ + прочитать счётчик правильно. Хвост копится, пока не придёт следующий отказ: + атака кончилась в 03:00, 900 отказов остались неотчитанными — и во вторник + одиночный 429 соседа по NAT унёс бы их все в запись, датированную вторником + и подписанную АДРЕСОМ СОСЕДА. С `since_prev_s` видно, что 901 отказ + накоплен за неделю, а не за секунду, и что читать `ip` в этой записи не + надо. `None` — первая запись за жизнь процесса, сравнивать не с чем. + + Уровень ERROR, а не WARNING, — не косметика: бэкенд поднят с + `LoggingIntegration(level=INFO, event_level=ERROR)` (app/main.py), то есть + ровно с ERROR запись становится событием GlitchTip, а WARNING остаётся + строкой в docker-логе, которая умирает с ротацией и редеплоем. Цена + прецедента известна (#2674): монитор писал WARNING про протухшие куки — и + событий было ноль. Спама не будет: запись не чаще раза в окно, и все они + группируются в один issue (шаблон сообщения один). + + Чего это НЕ делает: у GlitchTip-проекта нет ни правил, ни получателей + (#2673), так что уведомление никому не уйдёт — событие будет видно в + интерфейсе, но не в чьём-то телефоне. Проверить доставку поведенчески + сейчас не на чем, и утверждать её здесь было бы враньём. + + `username=""` — не заглушка: имя не пишем ПОТОМУ, что отказ случился до + того, как мы на него посмотрели. Записывай мы присланное, атакующий + наполнял бы аудит строками с любым именем на выбор. Пустое имя — не аккаунт, + и списки аудита его отфильтровывают (`WHERE username <> ''` в + `app/api/v1/audit.py`), иначе оно встало бы первой строкой в списке + аккаунтов и фантомом в `count(DISTINCT username)`. `ip` — адрес последнего + отклонённого запроса, то есть ОБРАЗЕЦ: при распределённом флуде адресов + много, и по одной записи их не восстановить (счётчик — восстановит). + + Потолок объёма: час непрерывной атаки — это 3600 строк в `user_events` + (в таблице за всю её жизнь ~3.4 тысячи), сутки — под 86 тысяч. Retention у + таблицы нет, а `GET /audit/accounts` делает полный `GROUP BY` без фильтра по + времени. То же давление уходит на квоту проекта в GlitchTip — тот же + механизм вытеснения чужого сигнала, только в другом ведре. Дойдёт до этого — + окно агрегации растёт с длительностью атаки (экспонента с потолком, как у + `_throttle_delay_s`), это следующий шаг, а не сегодняшний. + """ + global _saturation_rejected, _saturation_reported_at + + _saturation_rejected += 1 + now = time.monotonic() + since_prev = None if _saturation_reported_at is None else now - _saturation_reported_at + if since_prev is None or since_prev >= _SATURATION_REPORT_WINDOW_S: + rejected, _saturation_rejected = _saturation_rejected, 0 + _saturation_reported_at = now + logger.error( + "login rejected: password verify saturated — %d отказов, " + "с прошлой записи %s с, последний ip=%s", + rejected, + "—" if since_prev is None else f"{since_prev:.1f}", + ip, + ) + schedule_event( + event_type="login_verify_saturated", + username="", + ip=ip, + path="/api/v1/auth/login", + method="POST", + payload={ + "rejected": rejected, + # Считается ДО сдвига `_saturation_reported_at` — иначе всегда 0. + "since_prev_s": None if since_prev is None else round(since_prev, 1), + }, + ) + + # Retry-After 1с — порядок времени одной сверки, не окно соседнего + # `_LOGIN_LIMITER`. Ответ ОДИН И ТОТ ЖЕ для любого имени: отказ приходит до + # сверки и потому ничего не сообщает о том, существует ли учётка. + return HTTPException( + status_code=429, + detail="слишком много попыток входа, попробуйте позже", + headers={"Retry-After": "1"}, + ) + + +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)}, + ) + + # Гейт насыщения — ДО выборки из реестра (#2715). Заведомо отклоняемый + # запрос не берёт соединение из пула и не делает SELECT по имени: под + # насыщением это была бы единственная работа на пути отказа, а значит и + # единственное, чьё время зависит от существования учётки — bcrypt, который + # эту разницу ровняет, до отказанного запроса не доходит вовсе. Решение + # всё равно остаётся за `verify_password_bounded` ниже (тот же предикат, + # `except` под ним никуда не делся) — здесь только экономия похода в базу. + if verify_slots_saturated(ip): + raise _saturated_429(ip) + + 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): слоты проверки заняты, ждать нельзя — + # ждущий держит соединение к БД. Предчек выше сюда почти всё и отсекает, + # но авторитетен ИМЕННО ЭТОТ отказ, поэтому ветка остаётся. Ответ — + # тот же самый и с той же аргументацией, что у предчека: один helper, + # чтобы две ветки не разъехались (одинаковость 429 — часть защиты). + raise _saturated_429(ip) 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/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index a5f1d106..a0eb307a 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -21,6 +21,7 @@ from __future__ import annotations import logging import re +from datetime import UTC, datetime, timedelta from typing import Annotated, Any, Literal from uuid import UUID @@ -30,6 +31,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.api.v1.trade_in import _assert_estimate_access +from app.core.config import settings from app.core.db import get_db logger = logging.getLogger(__name__) @@ -49,16 +51,24 @@ _PHONE_MAX_DIGITS = 15 # Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится # per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась # только в audit-лог (#2497 TODO, теперь закрыт). -_CONSENT_POLICY_VERSION = "2026-07" +# +# Значение = дата утверждения политики (PRIVACY_APPROVAL в frontend/src/app/ +# mera-public/content.ts: «приказом директора № 1 от 13 августа 2026 г.» → +# "2026-08-13"), а не дата этого коммита — версия обязана указывать на редакцию +# ДОКУМЕНТА, на который согласие фактически ссылается (чекбокс теперь линкует +# именно на /mera-public/privacy). test_consent_text_frontend_sync.py проверяет +# это соответствие автоматически, так что рассинхронизация здесь падает в CI. +_CONSENT_POLICY_VERSION = "2026-08-13" -# Снимок точного текста согласия, который видит пользователь при отправке лида. -# Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/ -# trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять -# _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки -# будут нести устаревший snapshot под новой version-меткой. +# Снимок точного текста согласия, который видит пользователь при отправке лида +# (ПЛОСКИЙ текст — без разметки ссылки на политику, которая в LeadForm.tsx рядом +# с этой фразой). Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/ +# src/components/trade-in/v2/LeadForm.tsx) — если текст меняется, здесь нужно +# поднять _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые +# строки будут нести устаревший snapshot под новой version-меткой. _CONSENT_TEXT_SNAPSHOT = ( "Согласен(-на) на обработку персональных данных в соответствии с " - "Федеральным законом «О персональных данных» № 152-ФЗ" + "Политикой обработки персональных данных" ) @@ -115,17 +125,26 @@ async def create_trade_in_lead( # consent_text_snapshot теперь durable-колонки на trade_in_leads (migration 182, # ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет # X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден. + # + # ЭТАП 4 B2C: expires_at (migration 231) — раньше лид хранился бессрочно + # (никакого TTL вообще не было, в отличие от trade_in_estimates.expires_at). + # Считаем на insert-time тем же паттерном, что estimator.py делает для + # trade_in_estimates — retention-период вынесен в settings, не хардкод. + expires_at = datetime.now(tz=UTC) + timedelta(days=settings.trade_in_lead_retention_days) + row = ( db.execute( text( """ INSERT INTO trade_in_leads ( estimate_id, phone, consent, source, user_agent, - client_ip, consent_policy_version, consent_text_snapshot + client_ip, consent_policy_version, consent_text_snapshot, + expires_at ) VALUES ( CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent, - CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot + CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot, + :expires_at ) RETURNING CAST(id AS text), created_at """ @@ -139,6 +158,7 @@ async def create_trade_in_lead( "client_ip": client_ip, "consent_policy_version": _CONSENT_POLICY_VERSION, "consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT, + "expires_at": expires_at, }, ) .mappings() 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/privacy_admin.py b/tradein-mvp/backend/app/api/v1/privacy_admin.py new file mode 100644 index 00000000..9ea0ee9d --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/privacy_admin.py @@ -0,0 +1,79 @@ +"""Admin right-to-erasure endpoint (152-ФЗ) — ЭТАП 4 B2C launch, part C. + +Auth не нужен в этом файле — вся ветка `/api/v1/admin/*` уже гейтится +`rbac_guard` middleware в app/main.py (`_ADMIN_API_RE`, role != admin → 403), +тем же паттерном, что app/api/v1/audit.py. + +Мутационный (DELETE), поэтому осторожно: это НЕ self-service для конечного +пользователя. Оператор поддержки/admin вызывает это ПОСЛЕ того, как убедился +(вне этого API — телефон/estimate-ссылка/переписка), что запрос на удаление +реально пришёл от владельца данных, а не от третьего лица, знающего чей-то +номер телефона. Идентификация анонима — см. app/services/data_erasure.py +module docstring (честно про то, что не всегда разрешимо). +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.db import get_db + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class DataErasureRequest(BaseModel): + """Хотя бы одно поле обязательно — см. erase_person_data ValueError guard.""" + + username: str | None = Field(default=None, max_length=200) + estimate_ids: list[UUID] | None = None + phone: str | None = Field(default=None, max_length=32) + tg_chat_id: int | None = None + + +@router.post("/privacy/erase") +async def erase_person_data_endpoint( + payload: DataErasureRequest, + db: Annotated[Session, Depends(get_db)], +) -> dict[str, dict[str, int]]: + """Физически удалить данные человека по одному или нескольким идентификаторам. + + Идентификаторы (хотя бы один): + - username — B2B-пилот: удаляет ВСЕ его оценки (created_by=username, CASCADE + подчищает фото/IMV-оценки), связанные лиды, веб-чат поддержки. + - estimate_ids — конкретные оценки по UUID (анонимный путь: человек прислал + ссылку/PDF со своим estimate_id) + лиды, привязанные к ним. + - phone — лиды с этим номером телефона (независимо от привязки к оценке). + - tg_chat_id — Telegram-поддержка (@MERAsupport_bot), включая переписку В + ЭТОЙ БД. НЕ удаляет зеркало в Telegram-топике (см. + app/services/data_erasure.py — ВАЖНЫЙ ФАКТ, честно, не скрываем). + + 422 если ни один идентификатор не передан (не даём случайно вызвать + "удали всё" пустым телом). + """ + if not any([payload.username, payload.estimate_ids, payload.phone, payload.tg_chat_id]): + raise HTTPException( + status_code=422, + detail="at least one identifier required: username / estimate_ids / phone / tg_chat_id", + ) + + from app.services.data_erasure import erase_person_data + + counters = await asyncio.to_thread( + erase_person_data, + db, + username=payload.username, + estimate_ids=payload.estimate_ids, + phone=payload.phone, + tg_chat_id=payload.tg_chat_id, + ) + logger.info("admin privacy erase requested -> %s", counters) + return {"deleted": counters} 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 268c3113..18969810 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -5,10 +5,13 @@ from __future__ import annotations +import asyncio import calendar +import json import logging +import math from datetime import UTC, date, datetime, timedelta -from typing import Annotated, Any +from typing import Annotated, Any, Literal from uuid import UUID from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, Response, UploadFile @@ -24,6 +27,8 @@ from app.schemas.trade_in import ( AnalogLot, AvitoImvSummary, CianPriceChangeStats, + CoverageProbeInput, + CoverageProbeResponse, DkpCorridor, HouseAnalyticsKpi, HouseAnalyticsResponse, @@ -96,6 +101,28 @@ def _resolve_quota_identity( return anon_key, settings.anon_estimate_quota_limit +# PR-D1: единственное определение «оценка читаема» — раньше SQL-фильтр (404, +# ниже в get_estimate) и Python-проверка (410, в estimate_pdf) уже разошлись +# по коду ответа; третий потребитель (`/r/`, PR-9) разошёлся бы +# неизбежно без унификации. `retain_until > NOW()` при NULL даёт NULL → false +# в SQL — для всех существующих строк (retain_until IS NULL) поведение не +# меняется вообще. Не копировать это выражение по месту — только через +# константу/хелпер ниже. Payments retention, PR #2754. +ESTIMATE_READABLE_SQL = "(expires_at > NOW() OR retain_until > NOW())" + + +def estimate_readable(expires_at: datetime, retain_until: datetime | None) -> bool: + """Python-зеркало ESTIMATE_READABLE_SQL — та же дизъюнкция, без похода в БД. + + tzinfo-нормализация повторяет прежнюю Python-проверку (estimate_pdf) — + `.replace(tzinfo=UTC)`, не переизобретается. + """ + now = datetime.now(tz=UTC) + if expires_at.replace(tzinfo=UTC) > now: + return True + return retain_until is not None and retain_until.replace(tzinfo=UTC) > now + + def _assert_estimate_access(created_by: str | None, x_authenticated_user: str | None) -> None: """IDOR guard (#690): только владелец оценки или admin могут её читать. @@ -106,7 +133,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 @@ -189,6 +216,239 @@ def _resolve_target_house_id( return None +# ── Revival на GET /estimate/{id} (incident 2026-08-10) ───────────────────── +# Заказчик открыл сохранённую ссылку (?id=...) и увидел «НЕДОСТАТОЧНО ДАННЫХ»: +# запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825) и +# лежит в БД мёртвой (median_price<=0/NULL), хотя тот же адрес/параметры +# сейчас честно считаются. get_estimate() ниже пытается пересчитать такую +# строку ОДИН раз (throttled) через тот же estimate_quality(), что и POST +# /estimate, и пишет результат В ТУ ЖЕ строку (id/ссылка не меняются). Живую +# строку (median_price>0) этот путь не трогает вообще — сохранённая клиенту +# цена неприкосновенна. +def _precision_to_qc_geo(precision: str | None) -> int | None: + """Best-effort обратное отображение к estimator._qc_geo_to_precision. + + AggregatedEstimate наружу отдаёт только бакетированный address_precision + (house/street/approximate), не сырой dadata.qc_geo (0..5) — тот остаётся + приватным для estimate_quality(). При revival нам нужно записать ЧТО-ТО в + колонку dadata_qc_geo, чтобы будущие (уже НЕ revival, обычные) GET той же + теперь-живой строки не откатили address_precision в None. Бакеты 2..5 + (settlement/city/region/unknown) неразличимы ПОСЛЕ _qc_geo_to_precision — + 2 репрезентативно для всех: тот же helper на чтении схлопывает их обратно + в тот же "approximate", наблюдаемое поведение не меняется. + """ + if precision == "house": + return 0 + if precision == "street": + return 1 + if precision == "approximate": + return 2 + return None + + +def _payload_from_dead_row(row: Any) -> TradeInEstimateInput: + """Восстанавливает вход оценки из мёртвой сохранённой строки для revival. + + Только поля, реально персистящиеся в trade_in_estimates при создании + (address/lat/lon/area_m2/rooms/floor/total_floors/year_built/house_type/ + repair_state/has_balcony) — CRM-only поля (ownership_type/has_mortgage) + на расчёт не влияют и не нужны здесь. radius_m НИКОГДА не персистится + (payload.radius_m живёт только в рамках одного POST-запроса, ни главный + INSERT, ни _empty_estimate его не пишут) — None здесь даёт тот же + default-каскад (DEFAULT_RADIUS_M/FALLBACK_RADIUS_M), что у подавляющего + большинства сохранённых строк (явный радиус выбирает меньшинство). + consent=None + require_consent=False у вызывающего — revival не новое + согласие физлица, а служебный recompute уже существующей записи. + """ + return TradeInEstimateInput( + address=row.address, + area_m2=float(row.area_m2), + rooms=row.rooms, + floor=row.floor, + total_floors=row.total_floors, + year_built=row.year_built, + house_type=row.house_type, + repair_state=row.repair_state, + has_balcony=row.has_balcony, + lat=row.lat, + lon=row.lon, + radius_m=None, + consent=None, + ) + + +async def _try_revive_dead_estimate( + db: Session, estimate_id: UUID, row: Any +) -> AggregatedEstimate | None: + """Пытается пересчитать «мёртвую» (median_price<=0/NULL) строку на месте. + + Возвращает свежий AggregatedEstimate (estimate_id ПОДМЕНЁН на исходный — + id/ссылка не меняются) при успехе; None если: (а) throttle ещё не истёк / + заявку уже забрал параллельный запрос — anti-storm через атомарный + conditional `UPDATE ... RETURNING` ниже (тот же паттерн, что + account_quota.increment, #747): WHERE перепроверяет и «мертва ли строка + сейчас», и «давно ли последняя попытка» НЕПОСРЕДСТВЕННО в БД, а не по + значению, прочитанному раньше в Python — TOCTOU-гонка между двумя + параллельными GET невозможна, проигравший просто не дублирует работу; + (б) пересчёт сам дал 0 (по-прежнему недостаточно данных); (в) пересчёт + упал с исключением (сеть/геокод/что угодно). Во всех трёх случаях caller + обязан отдать сохранённую (по-прежнему мёртвую) строку как раньше — НЕ 500. + """ + claim = db.execute( + text( + """ + UPDATE trade_in_estimates + SET revival_attempted_at = NOW() + WHERE id = CAST(:id AS uuid) + AND (median_price <= 0 OR median_price IS NULL) + AND ( + revival_attempted_at IS NULL + OR revival_attempted_at + < NOW() - make_interval(mins => CAST(:throttle AS integer)) + ) + RETURNING id + """ + ), + {"id": str(estimate_id), "throttle": settings.trade_in_revival_throttle_minutes}, + ).fetchone() + db.commit() + if claim is None: + logger.info("estimate revival throttled/lost race: id=%s", estimate_id) + return None + + from app.services.estimator import estimate_quality + + try: + payload = _payload_from_dead_row(row) + result = await estimate_quality( + payload, + db, + created_by=row.created_by, + client_ip=None, + require_consent=False, + ) + except Exception: + logger.exception("estimate revival failed: id=%s address=%r", estimate_id, row.address) + return None + + temp_id = result.estimate_id + if result.median_price_rub <= 0: + logger.info("estimate revival still insufficient data: id=%s", estimate_id) + db.execute( + text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(temp_id)}, + ) + db.commit() + return None + + # estimate_quality() persists under a BRAND NEW uuid (temp_id) — it has no + # notion of "recompute this existing row". Copy the computed OUTPUT fields + # into the ORIGINAL row (id/link contract), then drop the throwaway one. + # INPUT snapshot (address/area/rooms/...) is untouched — it did not change, + # only the outputs were recomputed. + # #incident-2026-08-11: created_at is DELIBERATELY excluded from this SET — + # it is the client's original request date (printed in /history and in + # AggregatedEstimate.created_at, see app/schemas/trade_in.py:317-318), NOT + # a recompute output. It previously got clobbered with the throwaway temp + # row's created_at (=NOW() at recompute time), which also silently + # re-sorted the row to the top of `GET /history ORDER BY created_at DESC`. + # revival_completed_at (migration 256) is the audit trail for "when did a + # revival LAST successfully rewrite this row" — distinct from + # revival_attempted_at (255), which is stamped on every claim regardless + # of outcome (throttle loss / recompute failure included). + db.execute( + text( + """ + UPDATE avito_imv_evaluations + SET estimate_id = CAST(:orig AS uuid) + WHERE estimate_id = CAST(:temp AS uuid) + """ + ), + {"orig": str(estimate_id), "temp": str(temp_id)}, + ) + db.execute( + text( + """ + UPDATE trade_in_estimates SET + median_price = :median_price, + range_low = :range_low, + range_high = :range_high, + median_price_per_m2 = :median_ppm2, + confidence = :confidence, + confidence_explanation = :explanation, + n_analogs = :n_analogs, + analogs = CAST(:analogs_json AS jsonb), + actual_deals = CAST(:deals_json AS jsonb), + sources_used = CAST(:sources_json AS jsonb), + data_freshness_minutes = :freshness, + canonical_address = :canonical_address, + house_cadnum = :house_cadnum, + house_fias_id = :house_fias_id, + dadata_qc_geo = :dadata_qc_geo, + dadata_metro = CAST(:dadata_metro_json AS jsonb), + expected_sold_price = :expected_sold_price, + expected_sold_range_low = :expected_sold_range_low, + expected_sold_range_high = :expected_sold_range_high, + expected_sold_per_m2 = :expected_sold_per_m2, + asking_to_sold_ratio = :asking_to_sold_ratio, + ratio_basis = :ratio_basis, + relaxations = CAST(:relaxations_json AS jsonb), + reliability = :reliability, + revival_completed_at = NOW() + WHERE id = CAST(:id AS uuid) + """ + ), + { + "id": str(estimate_id), + "median_price": result.median_price_rub, + "range_low": result.range_low_rub, + "range_high": result.range_high_rub, + "median_ppm2": result.median_price_per_m2, + "confidence": result.confidence, + "explanation": result.confidence_explanation, + "n_analogs": result.n_analogs, + "analogs_json": json.dumps( + [a.model_dump(mode="json") for a in result.analogs], ensure_ascii=False + ), + "deals_json": json.dumps( + [a.model_dump(mode="json") for a in result.actual_deals], ensure_ascii=False + ), + "sources_json": json.dumps(result.sources_used, ensure_ascii=False), + "freshness": result.data_freshness_minutes, + "canonical_address": result.canonical_address, + "house_cadnum": result.house_cadnum, + "house_fias_id": result.house_fias_id, + "dadata_qc_geo": _precision_to_qc_geo(result.address_precision), + "dadata_metro_json": json.dumps(result.metro_nearest, ensure_ascii=False), + "expected_sold_price": result.expected_sold_price_rub, + "expected_sold_range_low": result.expected_sold_range_low_rub, + "expected_sold_range_high": result.expected_sold_range_high_rub, + "expected_sold_per_m2": result.expected_sold_per_m2, + "asking_to_sold_ratio": result.asking_to_sold_ratio, + "ratio_basis": result.ratio_basis, + "relaxations_json": json.dumps(result.relaxations, ensure_ascii=False), + "reliability": result.reliability, + }, + ) + db.execute( + text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(temp_id)}, + ) + db.commit() + + logger.info( + "estimate revived: id=%s median=%d n=%d confidence=%s reliability=%s", + estimate_id, + result.median_price_rub, + result.n_analogs, + result.confidence, + result.reliability, + ) + # created_at on the returned object must mirror the DB row (untouched + # original request date, NOT the temp row's NOW()) — see UPDATE above. + return result.model_copy(update={"estimate_id": estimate_id, "created_at": row.created_at}) + + @router.post("/estimate", response_model=AggregatedEstimate) async def estimate( payload: TradeInEstimateInput, @@ -238,8 +498,22 @@ async def estimate( # created_by (#656) прокидываем в estimate_quality для скоупа /history — ТОЛЬКО # реальный account username (НЕ anon-ключ квоты): у анонимов нет "аккаунта", # по которому имеет смысл скоупить /history. + # ЭТАП 4 B2C (152-ФЗ): require_consent=True только когда нет + # X-Authenticated-User — сегодня rbac_guard (app/core/rbac.py) уже требует + # этот заголовок на любом non-public пути, так что эта ветка пока + # недостижима в проде (анонимный /estimate ещё не открыт другими частями + # ЭТАП 4/B2C работ) — гейт готов ЗАРАНЕЕ, на момент открытия анонимного + # доступа. client_ip — proof-of-consent (estimate_quality персистит его + # на trade_in_estimates только когда require_consent=True; B2B-пилоты + # остаются NULL, см. estimator.py::_estimate_consent_persist_fields). try: - result = await estimate_quality(payload, db, created_by=x_authenticated_user) + result = await estimate_quality( + payload, + db, + created_by=x_authenticated_user, + client_ip=_client_ip(request), + require_consent=x_authenticated_user is None, + ) except HTTPException: raise except Exception: @@ -320,21 +594,22 @@ def get_estimate( """ row = db.execute( text( - """ + f""" SELECT id, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, data_freshness_minutes, - expires_at, address, lat, lon, + expires_at, retain_until, address, lat, lon, area_m2, rooms, floor, total_floors, year_built, house_type, repair_state, has_balcony, canonical_address, house_cadnum, house_fias_id, dadata_qc_geo, dadata_metro, expected_sold_price, expected_sold_range_low, expected_sold_range_high, expected_sold_per_m2, - asking_to_sold_ratio, ratio_basis, created_by, created_at + asking_to_sold_ratio, ratio_basis, created_by, created_at, + relaxations, reliability FROM trade_in_estimates WHERE id = CAST(:id AS uuid) - AND expires_at > NOW() + AND {ESTIMATE_READABLE_SQL} """ ), {"id": str(estimate_id)}, @@ -345,6 +620,22 @@ def get_estimate( _assert_estimate_access(row.created_by, x_authenticated_user) + # #incident-2026-08-10: строка «мертва» (median_price<=0/NULL) — посчитана + # ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825). Пробуем + # пересчитать её на месте (throttled, race-safe — см. докстринг + # _try_revive_dead_estimate) через тот же путь, что и POST /estimate. + # Живую строку (median_price>0) не трогаем вообще. asyncio.run() — sync↔ + # async мост (тот же паттерн, что app/scheduler_main.py): get_estimate + # остаётся `def` (Starlette гоняет его в threadpool, как сейчас), поэтому + # ОСТАЛЬНЫЕ синхронные db.execute() ниже по функции не переезжают на event + # loop — только сама попытка revival временно занимает свой поток на время + # await estimate_quality(). Любая ошибка расчёта — не 500: revived is None, + # и функция просто продолжает как раньше, отдавая сохранённую строку. + if row.median_price is None or row.median_price <= 0: + revived = asyncio.run(_try_revive_dead_estimate(db, estimate_id, row)) + if revived is not None: + return revived + from app.services.estimator import ( _canonical_sources, _cv_from_ppm2, @@ -354,11 +645,22 @@ def get_estimate( _qc_geo_to_precision, _resolve_target_city, _source_counts, + rehydrate_search_radius_m, ) analogs = [AnalogLot(**a) for a in (row.analogs or [])] actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])] + # #2632: search_radius_m колонкой не персистится — восстанавливаем его из + # того, что персистится (подпись каскада «радиус расширен до N м», иначе + # размах сохранённых аналогов). Без этого GET отдавал null, фронт падал на + # превью-радиус 1 км и рисовал круг, за которым лежат его же пины (прод + # 2026-08-11: 10 из 10 аналогов вне круга, самый дальний — 4381 м). + persisted_relaxations = list(getattr(row, "relaxations", None) or []) + search_radius_m = rehydrate_search_radius_m( + persisted_relaxations, [a.distance_m for a in analogs] + ) + # #2043 (BE-1): CV / счётчики источников на rehydrate — best-effort из # сохранённых analogs (top-N, усечённо: полная выборка не персистится). На # свежей оценке (POST) считаются по полной выборке; здесь — по тому, что есть @@ -443,6 +745,7 @@ def get_estimate( analogs=analogs, actual_deals=actual_deals, expires_at=row.expires_at, + retain_until=row.retain_until, target_address=row.address, target_lat=row.lat, target_lon=row.lon, @@ -480,6 +783,18 @@ def get_estimate( cv=cv, source_counts=source_counts, created_at=row.created_at, + # PR #2823 open follow-up (fixed incident 2026-08-10, migration 255): + # relaxations/reliability теперь персистятся — GET-rehydrate больше не + # теряет красный баннер «точность снижена» при открытии по ссылке. + # getattr defensive: старые in-memory test doubles / любая строка без + # этих колонок (не должно случаться после миграции) деградируют в + # дефолт схемы (ok / []), а не падают AttributeError. + relaxations=persisted_relaxations, + reliability=getattr(row, "reliability", None) or "ok", + # #2632: фактический радиус подбора (реконструкция выше). requested_radius_m + # осознанно НЕ заполняем — payload.radius_m не персистится, и подставить + # сюда дефолт значило бы выдать догадку за то, что просил пользователь. + search_radius_m=search_radius_m, ) @@ -504,14 +819,15 @@ def estimate_pdf( SELECT id, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, data_freshness_minutes, - expires_at, + expires_at, retain_until, address, lat, lon, area_m2, rooms, floor, total_floors, year_built, house_type, repair_state, has_balcony, canonical_address, house_cadnum, house_fias_id, dadata_qc_geo, dadata_metro, expected_sold_price, expected_sold_range_low, expected_sold_range_high, expected_sold_per_m2, - asking_to_sold_ratio, ratio_basis, created_by + asking_to_sold_ratio, ratio_basis, created_by, + relaxations, reliability FROM trade_in_estimates WHERE id = CAST(:id AS uuid) """ @@ -524,8 +840,12 @@ def estimate_pdf( _assert_estimate_access(row.created_by, x_authenticated_user) - if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC): - raise HTTPException(status_code=410, detail="estimate expired (24h TTL)") + # PR-D1: тот же гейт, что в get_estimate (см. ESTIMATE_READABLE_SQL) — раньше + # здесь была независимая Python-проверка expires_at, разошедшаяся с SQL- + # фильтром GET-ручки. "estimate expired (24h TTL)" убрано из текста: при + # годовом retain_until упоминание 24ч в ответе API стало бы ложью. + if not estimate_readable(row.expires_at, row.retain_until): + raise HTTPException(status_code=410, detail="estimate expired") from app.services.estimator import _qc_geo_to_precision @@ -548,6 +868,7 @@ def estimate_pdf( analogs=analogs, actual_deals=actual_deals, expires_at=row.expires_at, + retain_until=row.retain_until, target_address=row.address, target_lat=row.lat, target_lon=row.lon, @@ -567,6 +888,10 @@ def estimate_pdf( house_fias_id=row.house_fias_id, address_precision=_qc_geo_to_precision(row.dadata_qc_geo), metro_nearest=(row.dadata_metro or []), + # migration 255 — та же сноска «точность снижена», что и на JSON GET, + # теперь и в PDF-регенерации сохранённой оценки (см. get_estimate). + relaxations=list(getattr(row, "relaxations", None) or []), + reliability=getattr(row, "reliability", None) or "ok", ) input_snapshot = { "address": row.address, @@ -773,7 +1098,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 @@ -835,7 +1160,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( @@ -845,6 +1178,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, @@ -861,12 +1198,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 ─────────────────────────────── @@ -1891,6 +2231,139 @@ 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% показывался. +# +# ПОШТУЧНЫЙ discount_pct В СТРОКАХ ТАБЛИЦЫ (#2672 п.3). Гейт гасил сводное число, +# а таблица под ним продолжала показывать проценты, посчитанные из ТЕХ ЖЕ пар: +# на живом Космонавтове (2-комн., медиана −37.6% погашена) шесть из первых +# двенадцати строк — от +42% до +77%, и все против одной и той же цены +# предложения. Масштаб на проде 2026-08-07 (427 реальных запросов из +# trade_in_estimates, 135 групп с парами): медиана погашена у 102 групп, и в +# них видно 3 678 строк с процентом — 72.8% всех показываемых процентов. +# +# Гасим строку там, и только там, где причина — свойство САМОЙ ПАРЫ: +# а) объявлений < MIN_DISTINCT_LISTINGS — тогда столбец «разница» это +# столбец цены сделки, поделённый на одну и ту же константу: он не даёт +# ни одного наблюдения сверх уже показанных цен, но выглядит как N торгов; +# б) медиана вне санитарного диапазона — по определению медианы это +# утверждение О СТРОКАХ: половина из них ещё дальше от рынка, чем она. +# «Мало пар» строку НЕ гасит: это свойство ВЫБОРКИ, про отдельную пару оно +# ничего не говорит, а микрокопия «пар всего 4, поэтому процент в строке не +# показываем» была бы ложной причиной. Цена этого исключения — 3 группы / 20 +# строк на проде, где медианы нет, а проценты в строках есть. +# Флаги (а)/(б) считаются НЕЗАВИСИМО от порядка веток гейта: порядок «мало пар +# → одно объявление → диапазон» прячет вторую причину за первой, и на проде 55 +# групп гаснут как «мало пар», хотя стоят ещё и на ОДНОМ объявлении. По ветке +# гейта строки гасились бы не там, где надо. +# Цена на проде: из 5 054 строк с процентом гаснет 3 658 (72.4%), остаётся +# 1 396. Само число «медианный торг» этой правкой НЕ меняется — 33 группы из +# 135 и до, и после (замер обеих версий модуля в одном процессе на ОДНИХ И ТЕХ +# ЖЕ живых парах). Обе цены — сделки и объявления — в строке остаются: +# убирается не данные, а наша подпись «торг» под их разностью. +# +# ПОТОЛОК ГЕЙТА (знать до следующей правки — здесь НЕ чинится): +# 1. Пейринг по УЛИЦЕ, а не по дому — корень всего перечисленного (ADR #721). +# Гейт по различным объявлениям честный промежуточный шаг, а не решение: +# он убирает числа, которые не являются наблюдением, но оставшиеся всё ещё +# сравнивают сделку в одном доме с объявлением в другом. +# 2. В группах, ПРОШЕДШИХ гейт, поштучные проценты остаются как есть — включая +# 426 строк из 1 396 (31%), лежащих вне того же диапазона [−35%, +20%], по +# которому мы гасим медиану. Отдельного порога для ОДНОЙ пары у нас нет: +# диапазон калиброван на медианах групп, а у одной сделки законный разброс +# шире (занижение цены в ДКП — механизм поштучный, не медианный). Считать +# его = вводить некалиброванный порог, чего #2672 прямо предостерегает. +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( @@ -1916,7 +2389,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( @@ -1937,6 +2410,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( @@ -1953,7 +2435,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) ) """ ), @@ -1964,6 +2447,7 @@ def get_sales_vs_listings( "window_days": window_days, "area_tolerance": area_tolerance, "period_months": period_months, + "target_city": target_city, }, ) .mappings() @@ -2019,12 +2503,122 @@ 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} + ) + + # #2672 п.3: те же две проверки, но применённые к КАЖДОЙ СТРОКЕ таблицы, а не + # к сводному числу (обоснование — в шапке секции, блок «ПОШТУЧНЫЙ ПРОЦЕНТ»). + # Считаются ДО гейта, потому что гейт обнуляет median_discount, и порядок его + # веток (мало пар → одно объявление → диапазон) прячет вторую причину за + # первой: на проде 55 групп гасятся как «мало пар», хотя стоят ещё и на ОДНОМ + # объявлении. Для строк важна причина, а не то, какая ветка сработала раньше. + pairs_stand_on_one_listing = n_distinct_listings < SALES_VS_LISTINGS_MIN_DISTINCT_LISTINGS + median_is_implausible = median_discount is not None and not ( + SALES_VS_LISTINGS_SANE_DISCOUNT_MIN_PCT + <= median_discount + <= SALES_VS_LISTINGS_SANE_DISCOUNT_MAX_PCT + ) + + # #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 pairs_stand_on_one_listing: + # Числа стоят В КОНЦЕ клауз намеренно: «различных объявлений всего 1» + # грамматично при любом значении, «на 1 различных объявлений» — нет. + median_discount_explanation = ( + f"Медианный торг не показываем: сделок {len(discounts)}, а разных " + f"объявлений для сравнения всего {n_distinct_listings} — такой процент " + f"говорит о цене одной конкретной квартиры, а не о торге на улице." + ) + elif median_is_implausible: + # Типографский минус (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 + + # #2672 п.3: под погашенной медианой строки таблицы продолжали показывать + # проценты из ТЕХ ЖЕ пар (живой кейс — Космонавтов: +76%, +73%, +63% против + # одной и той же цены предложения). Гасим их там, и только там, где причина — + # свойство самой пары; «мало пар» свойство ВЫБОРКИ, про отдельную строку оно + # ничего не говорит, поэтому одну строку не трогает (обоснование и цена — + # в шапке секции). Обе цены остаются в строке: мы убираем не данные, а нашу + # подпись «торг» под разностью, которой не можем ручаться. + if discounts and (pairs_stand_on_one_listing or median_is_implausible): + if pairs_stand_on_one_listing: + # Оба числа названы совместно с фразой медианы: там «сделок N», здесь + # «одна и та же цена» — читателю видно и сколько строк, и на скольких + # объявлениях они стоят. + row_explanation = ( + "Проценты по каждой сделке тоже не показываем: все они считаются " + "против одной и той же цены объявления." + ) + else: + # Медиана вне диапазона — это утверждение О СТРОКАХ: по определению + # медианы половина из них лежит по дальнюю сторону от неё, т.е. тоже + # вне рыночного диапазона. Значение здесь НЕ повторяем: в ветке + # диапазона оно уже названо предыдущим предложением (вышло бы дважды + # в одном абзаце), а в ветке «мало пар» мы его намеренно не + # показываем — и печатать его в пояснении было бы отказом на словах. + row_explanation = ( + "Проценты по каждой сделке тоже не показываем: половина из них — " + "за пределами того, как торгуется рынок." + ) + for pair in pairs: + pair.discount_pct = None + median_discount_explanation = ( + f"{median_discount_explanation} {row_explanation}" + if median_discount_explanation + else row_explanation + ) + logger.info( + "sales-vs-listings: per-row discount_pct gated street=%r rooms=%d rows=%d " + "distinct_listings=%d reason=%s", + street_name, + rooms, + len(discounts), + n_distinct_listings, + "one_listing" if pairs_stand_on_one_listing else "implausible_median", + ) 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", ) @@ -2038,8 +2632,274 @@ 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", pairs=pairs, ) + + +# ── Coverage probe (#2894) — бесплатный шаг лэндинга, ЦЕНЫ НЕТ ───────────────── +# До оплаты человек видит, СКОЛЬКО похожих квартир продаётся рядом и КАК БЫСТРО +# они уходят — ни одной рублёвой цифры (см. CoverageProbeResponse docstring). +# Один SQL, ноль внешних вызовов, ноль записей — ручка дешёвая специально: её +# планируется открыть анонимам отдельной задачей (#2895, со своим consent- +# гейтом). RBAC здесь НЕ трогаем — путь остаётся закрытым (не в _PUBLIC_PATHS). +# строго 1000м по ТЗ #2894 (НЕ DEFAULT_RADIUS_M эстиматора — тот допускает fallback до 2000) +COVERAGE_RADIUS_M = 1000 +COVERAGE_AREA_TOLERANCE = 0.15 # ±15% площади +COVERAGE_FRESH_DAYS = 14 # объявления не старше 14 дней (тот же канон, что LISTINGS_FRESH_DAYS) + +# MAJOR-2 (независимый ревью #2894): days_on_market на проде заполнена практически +# только у yandex (avito/cian/domklik — 0 заполнено) — возраст известен у меньшинства +# когорты, и на тонких когортах "медиана" считалась по 1-2 объявлениям. Ниже порога +# n_with_age медиану не отдаём (null) — не продуктовое решение, а честность при +# заведомо шумной статистике по единичным точкам. +COVERAGE_MIN_AGE_SAMPLES = 5 +# 15% свежих yandex-строк имеют days_on_market > 365 (максимум 4261) — это почти +# наверняка мёртвое/забытое объявление, которое никто не снял с публикации, а не +# сигнал о реальном времени экспозиции рынка. Отбрасываем как выброс из медианы. +COVERAGE_MAX_AGE_DAYS = 365 + +# Списки городов и пороги — константа РЯДОМ С РУЧКОЙ (issue #2894 требование), не в БД. +COVERAGE_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск") +COVERAGE_YELLOW_CITIES = ("Нижний Тагил", "Каменск-Уральский", "Первоуральск", "Ревда") +COVERAGE_GREEN_MIN_N = 8 +COVERAGE_YELLOW_MIN_N = 12 + + +def _fold_city(name: str) -> str: + """ёЁ→еЕ + casefold — та же normalization-идиома, что для адресов (см. #1774).""" + return name.strip().translate(str.maketrans("ёЁ", "ее")).casefold() + + +_COVERAGE_CITY_THRESHOLDS: dict[str, tuple[str, int]] = { + **{_fold_city(c): (c, COVERAGE_GREEN_MIN_N) for c in COVERAGE_GREEN_CITIES}, + **{_fold_city(c): (c, COVERAGE_YELLOW_MIN_N) for c in COVERAGE_YELLOW_CITIES}, +} + +# Повторная проверка ручки #2894 (2026-08): город раньше резолвился модой +# `listings.city` найденной когорты — оказалось, что `listings.city` это город +# СВИП-контекста скрейпера (миграция 196 — колонка заполняется тем городом, +# который скрейпер обходил, не геокодом самого объявления). Замер на проде: +# в радиусе 1000 м вокруг Берёзовского 90/90 строк имеют city='Екатеринбург'; +# вокруг Ревды 74/74 — city='Первоуральск'. Следствие: продавец в Берёзовском +# видел на лэндинге «Екатеринбург», а сами COVERAGE_GREEN/YELLOW_CITIES для +# городов-спутников были НЕДОСТИЖИМЫ (в БД нет ни одной строки с их city). +# Фикс — детерминированный резолв по координатам ЗАПРОСА (никакого участия +# клиента, никакой моды когорты): ближайший центроид города из списка ниже, +# если он в пределах COVERAGE_CITY_MATCH_RADIUS_KM. +# +# Координаты — константа РЯДОМ С РУЧКОЙ, не таблица в БД: единственный +# существующий кандидат на "готовый реестр городов" — это +# frontend/src/lib/city-registry.ts (OBLAST_CITIES) и backend +# geocoder.py::SVERDLOVSK_OBLAST_CITIES — оба хранят ТОЛЬКО текстовые лейблы +# (city_hint для геокодера), без координат. Заводить миграцию + таблицу ради +# статичного справочника из 8 географических центров населённых пунктов — +# оверинжиниринг; координаты (WGS84, общедоступные центры НП) живут здесь же, +# рядом с порогами, которые они резолвят. +COVERAGE_CITY_MATCH_RADIUS_KM = 25.0 # дальше — город не определён (not_covered) + +_CITY_CENTROIDS_DEG: dict[str, tuple[float, float]] = { + "Екатеринбург": (56.8389, 60.6057), + "Верхняя Пышма": (56.9789, 60.5636), + "Берёзовский": (56.9096, 60.8034), + "Среднеуральск": (56.9848, 60.4759), + "Нижний Тагил": (57.9099, 59.9819), + "Каменск-Уральский": (56.4110, 61.9243), + "Первоуральск": (56.9083, 59.9483), + "Ревда": (56.7986, 59.9298), +} + + +def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Расстояние по большому кругу (км), радиус Земли 6371 км.""" + r_earth_km = 6371.0 + phi1, phi2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlambda = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2 + return 2 * r_earth_km * math.asin(math.sqrt(a)) + + +def _resolve_coverage_city(lat: float, lon: float) -> tuple[str, int, bool]: + """Резолвит (display_city, threshold, is_supported) для пробы покрытия — ПО КООРДИНАТАМ. + + Город = ближайший центроид из `_CITY_CENTROIDS_DEG`, если расстояние до него + < `COVERAGE_CITY_MATCH_RADIUS_KM`; иначе город не определён. Детерминированно + и без участия клиента — см. комментарий над `_CITY_CENTROIDS_DEG` про то, + почему `listings.city` (мода когорты) и `city_hint` (клиентский вход) сюда + больше НЕ допускаются в качестве источника истины. + """ + nearest_city: str | None = None + nearest_km = math.inf + for city, (clat, clon) in _CITY_CENTROIDS_DEG.items(): + distance_km = _haversine_km(lat, lon, clat, clon) + if distance_km < nearest_km: + nearest_km = distance_km + nearest_city = city + + if nearest_city is None or nearest_km > COVERAGE_CITY_MATCH_RADIUS_KM: + return "", 0, False + + display, threshold = _COVERAGE_CITY_THRESHOLDS[_fold_city(nearest_city)] + return display, threshold, True + + +@router.post("/coverage", response_model=CoverageProbeResponse) +def coverage_probe( + payload: CoverageProbeInput, + db: Annotated[Session, Depends(get_db)], +) -> CoverageProbeResponse: + """Бесплатная проба покрытия (issue #2894) — сколько похожих квартир рядом. + + Когорта — тот же дедуп/cap-канон, что radius-тиры в estimator._fetch_analogs + (rn_dup по (source, source_id), rn_addr cap по адресу, реюз тех же + приватных helper'ов эстиматора — импорт локальный, как и в остальных + ручках этого файла, чтобы не тащить тяжёлый app.services.estimator + в module-level import graph): ST_DWithin 1000м, rooms точное совпадение, + area ±15%, scraped_at не старше 14 дней, is_active. + + MAJOR-1 fix (независимый ревью #2894): когорта пробы обязана быть + ПОДМНОЖЕСТВОМ когорты платного эстиматора, не шире её — иначе проба честно + отвечает "ok" там, где платный расчёт увидит 0. Три предиката ниже — тот же + канон, что estimator._COMMON_WHERE (app/services/estimator.py:5441/5460) и + inline-копия Tier W (estimator.py:5910/5916/5932, radius-тир, откуда реально + берутся аналоги на 1000 м): guard новостроек, geo_precision != 'city' + (#769 Part E — city-centroid листинги без реального адреса), price_rub > 0. + + В ответе НЕТ ни одной цены — см. CoverageProbeResponse docstring. + + MAJOR-2 (независимый ревью #2894): days_on_market на проде фактически + заполнена только у ОДНОГО источника (yandex) — это ограничение данных, а + не продуктовое решение. n_with_age в ответе честно считает, по скольким + объявлениям взята медиана; ниже COVERAGE_MIN_AGE_SAMPLES — null (см. поле + в ответе). Значения > COVERAGE_MAX_AGE_DAYS (почти наверняка мёртвое + объявление) в расчёт медианы не берутся. + + #oblast (2026-08): house_placement_history.exposure_days — реальная (не + цензурированная) экспозиция history-строк — НЕ используется здесь: это + house-level архив (join по house_id, не привязан к текущей radius/rooms/ + area когорте один-в-один), а не активные листинги в подобранном радиусе; + сведение двух разных когорт усложнило бы «один дешёвый SQL» без выигрыша + в честности (у нас и так честное имя поля — age активного объявления, не + срок продажи). См. openQuestions PR #2894 при ревью. + + Повторная проверка ручки (2026-08): город больше НЕ берётся из моды + `listings.city` найденной когорты и НЕ зависит от `payload.city_hint` — + оба источника ненадёжны (см. комментарий над `_CITY_CENTROIDS_DEG`). + Город резолвится детерминированно по `payload.lat/lon` через + `_resolve_coverage_city` — `city_hint` в payload остаётся только + информационным полем (см. `CoverageProbeInput.city_hint`), на результат + не влияет. + """ + from app.services.estimator import _RN_DUP_WINDOW, MAX_ANALOGS_PER_ADDRESS + + area_min = payload.area_m2 * (1 - COVERAGE_AREA_TOLERANCE) + area_max = payload.area_m2 * (1 + COVERAGE_AREA_TOLERANCE) + + row = ( + db.execute( + text( + f""" + WITH base AS ( + SELECT + days_on_market, + row_number() OVER ( + PARTITION BY address ORDER BY scraped_at DESC + ) AS rn_addr, +{_RN_DUP_WINDOW} + FROM listings + WHERE is_active = true + AND rooms = :rooms + AND area_m2 BETWEEN :area_min AND :area_max + AND scraped_at > NOW() - (:fresh_days || ' days')::interval + AND ST_DWithin( + geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius + ) + -- MAJOR-1: sync с estimator._COMMON_WHERE (5441) / Tier W (5916) — + AND price_rub > 0 + -- MAJOR-1: sync с estimator._COMMON_WHERE (5460) / Tier W (5932) — + -- guard новостроек, NULL = legacy вторичка до м.011 + AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + -- MAJOR-1: sync с estimator Tier W (5910/5945-5948, #769 Part E) — + -- исключает city-centroid листинги без реального адреса; + -- IS DISTINCT FROM пропускает NULL (неизвестная точность) + AND (geo_precision IS DISTINCT FROM 'city') + ) + SELECT + count(*) AS n_listings, + count(*) FILTER ( + WHERE days_on_market IS NOT NULL + AND days_on_market <= :max_age_days + ) AS n_with_age, + percentile_cont(0.5) WITHIN GROUP (ORDER BY days_on_market) + FILTER ( + WHERE days_on_market IS NOT NULL + AND days_on_market <= :max_age_days + ) AS median_age_days + FROM base + WHERE rn_addr <= :max_per_addr + AND rn_dup = 1 + """ + ), + { + "rooms": payload.rooms, + "area_min": area_min, + "area_max": area_max, + "fresh_days": COVERAGE_FRESH_DAYS, + "lat": payload.lat, + "lon": payload.lon, + "radius": COVERAGE_RADIUS_M, + "max_per_addr": MAX_ANALOGS_PER_ADDRESS, + "max_age_days": COVERAGE_MAX_AGE_DAYS, + }, + ) + .mappings() + .fetchone() + ) + + n_listings = int(row["n_listings"]) if row else 0 + n_with_age = int(row["n_with_age"]) if row and row["n_with_age"] is not None else 0 + median_age = ( + round(row["median_age_days"]) + if row is not None + and row["median_age_days"] is not None + and n_with_age >= COVERAGE_MIN_AGE_SAMPLES + else None + ) + + city, threshold, supported = _resolve_coverage_city(payload.lat, payload.lon) + + if not supported or n_listings == 0: + status: Literal["ok", "thin", "not_covered"] = "not_covered" + # Nit-fix (повторная проверка #2894): threshold неприменим при + # not_covered — см. CoverageProbeResponse.threshold docstring. Раньше + # поддерживаемый (по координатам) город с пустой когортой отдавал + # реальный порог (8/12) вместе с not_covered — противоречило докстрингу. + threshold = 0 + elif n_listings >= threshold: + status = "ok" + else: + status = "thin" + + logger.info( + "coverage probe rooms=%d area=%.1f city=%r status=%s n=%d n_with_age=%d", + payload.rooms, + payload.area_m2, + city, + status, + n_listings, + n_with_age, + ) + + return CoverageProbeResponse( + status=status, + n_listings=n_listings, + median_listing_age_days=median_age, + n_with_age=n_with_age, + radius_m=COVERAGE_RADIUS_M, + city=city, + threshold=threshold, + ) diff --git a/tradein-mvp/backend/app/api/v1/version.py b/tradein-mvp/backend/app/api/v1/version.py new file mode 100644 index 00000000..86729727 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/version.py @@ -0,0 +1,20 @@ +"""GET /api/v1/trade-in/version — build metadata (product version + short SHA + +build date), source `app/core/version.py`. + +Публичный (без авторизации, см. `app/core/rbac.py::_PUBLIC_PATHS`) — это не +секрет, а быстрая справка для клиента/поддержки/смоук-теста, читающая только +process env / уже загруженные при импорте константы (без похода в БД).""" + +from __future__ import annotations + +from fastapi import APIRouter + +from app.core.version import APP_VERSION, BUILD_DATE, BUILD_SHA + +router = APIRouter() + + +@router.get("/version") +def get_version() -> dict[str, str]: + """{"version": "1.0.0", "sha": "a1b2c3d", "built_at": "2026-08-10T12:00:00Z"}.""" + return {"version": APP_VERSION, "sha": BUILD_SHA, "built_at": BUILD_DATE} 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 41808054..f88a5570 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" @@ -375,9 +615,13 @@ class Settings(BaseSettings): cian_valuation_max_rub: float = 500_000_000 # ── #audit-5: data-age guards ───────────────────────────────────────────── - # sber_index_max_age_days: максимальный допустимый возраст последнего месяца - # СберИндекс-серии (дней). Если latest месяц старее — логируем warning. - sber_index_max_age_days: int = 35 + # #2846: sber_index_max_age_days УДАЛЁН (был 35). Порог недостижим по построению + # (period_month — метка первого числа + лаг публикации источника ⇒ пол 46 суток), + # guard был истинным 100% времени. Свежесть СберИндекса теперь считает ровно одно + # место — tasks/sber_freshness_monitor, и считает по отставанию ЗАГРУЗКИ, а порог + # берёт из такта самой загрузки (scrape_schedules.default_params.interval_days), + # так что второму порогу тут больше неоткуда взяться и не с чем разъезжаться. + # extra="ignore" в model_config защищает от startup-краха на leftover env var. # avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка # на тонком рынке (thin_market=True в AvitoImvSummary) + warning. avito_imv_thin_market_threshold: int = 10 @@ -405,6 +649,23 @@ class Settings(BaseSettings): # индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал. estimate_quarter_index_factor_min: float = 0.6 estimate_quarter_index_factor_max: float = 1.8 + # Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local), + # когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде. + # ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED). + # + # Почему dormant. Точность самого резолва измерена (2544 дома ЕКБ, где кадастр + # известен независимо — ответ DaData на адрес, не KNN-подсказка): 92.1% на 25 м, + # 79.8% на 50 м. То есть механизм работоспособен. Но ЭФФЕКТ поправки на точность + # цены НЕ измерен: бэктест-гейт реплеит фикстуру с target_house_cadnum=None и + # координатный резолв не проходит. Точность резолва ≠ польза поправки, а тракт + # денежный — поэтому включение отдельным решением, после замера. + # + # Критерий приёмки (записан ДО факта, 2026-08-12): перезахватить фикстуру с + # заполненным координатным кварталом и получить overall MAPE не хуже 12.63 И + # сегмент эконом не хуже 14.20 при доле затронутых сделок >= 5%. Если к + # 2026-09-12 замер не сделан — флаг и `_lookup_target_quarter_by_coords` удалить, + # а не оставлять «на вырост». + estimate_quarter_from_coords_enabled: bool = False # ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ────────────── # Эстиматор систематически занижает верхние сегменты (live-бэктест n=561, @@ -527,63 +788,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 @@ -603,25 +868,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 = всегда @@ -635,6 +896,64 @@ class Settings(BaseSettings): # допуском на перерыв в работе scraper'а. ENV: AVITO_STALE_TTL_DAYS. avito_stale_ttl_days: int = 10 + # ── ЭТАП 4 B2C launch — retention / erasure (152-ФЗ) ──────────────────── + # trade_in_estimates.expires_at TTL (часы от момента создания). Раньше был + # хардкод `timedelta(hours=24)` в estimator.py (x2: главный INSERT + + # _empty_estimate fallback) — вынесено в настройку, чтобы retention-период + # не требовал правки кода. 24ч — продуктовое решение MVP (оценка живёт + # "сессию" клиента, не архив); юридически обоснованный срок хранения адреса + # физлица для анонимного B2C — решение не инженера, см. итоговый комментарий + # к задаче. ENV: TRADE_IN_ESTIMATE_RETENTION_HOURS. + trade_in_estimate_retention_hours: int = 24 + + # trade_in_leads.expires_at TTL (дни от момента создания, migration 231). + # У trade_in_leads раньше вообще не было срока хранения — лид (телефон + + # согласие) жил в БД бессрочно. 180 дней (6 месяцев) — рабочий default для + # НЕконвертированных маркетинговых лидов (типичный индустриальный диапазон + # 90-180 дней при отсутствии дальнейшего договорного отношения с клиентом); + # если лид конвертировался в реальную сделку/договор — для него должен + # действовать ДРУГОЙ (договорной) срок хранения, но в кодовой базе нет + # механизма отметки "лид конвертирован" — этого разграничения здесь НЕТ, + # см. итоговый комментарий к задаче (конкретный юридически обоснованный + # срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS. + trade_in_lead_retention_days: int = 180 + + # ── Платный отчёт живёт год (retain_until, migration 240, PR #2754) ───── + # trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/ + # СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность + # расчёта, 24ч, глобальный для ВСЕХ строк). НЕ трогает expires_at — см. + # migration 240 докстринг. Отдельная колонка, а не подъём expires_at: + # expires_at печатается в PDF/UI как «актуальность расчёта» и одинаков + # для всех строк, поднять его до года = соврать в документе клиента про + # свежесть цифры + нарушить минимизацию ПДн для неоплаченных B2C-адресов. + # Единственный источник числа «12 месяцев» на фронте — + # `mera-public/content.ts::PAID_REPORT_RETENTION_MONTHS`; текст оферты, + # экран после оплаты и SQL продления retain_until при оплате (платёжный + # код, отдельный PR) обязаны читать его оттуда, а не хардкодить — иначе + # классический исход "в оферте 12 месяцев, в конфиге 365 дней, на экране + # «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS. + trade_in_paid_retention_days: int = 365 + + # ── Revival на GET /estimate/{id} (incident 2026-08-10) ───────────────── + # Throttle повторных попыток пересчёта «мёртвой» (median_price<=0/NULL) + # сохранённой строки — записи, посчитанные ДО фикса оценщика (#oblast-E/F, + # PR #2823/#2825) и навсегда застрявшие с median_price=0. GET пытается + # пересчитать такую строку через тот же estimate_quality(), что и POST + # (app/api/v1/trade_in.py::_try_revive_dead_estimate), не чаще одного раза + # в это число минут на строку — иначе каждый refresh страницы бил бы по + # геокодеру/DaData для объективно мёртвого адреса. 10 минут — компромисс: + # достаточно редко, чтобы не спамить внешние сервисы, достаточно быстро, + # чтобы повторный визит клиента после нашего фикса увидел живую цену. ENV: + # TRADE_IN_REVIVAL_THROTTLE_MINUTES. + trade_in_revival_throttle_minutes: int = 10 + + # Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним + # DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама + # крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) — + # это ограничивает ОДНУ транзакцию, не общий прогресс. ENV: + # TRADE_IN_PURGE_BATCH_SIZE. + trade_in_purge_batch_size: int = 500 + # ── Avito SERP ЕКБ гео-фильтр (per-card city-slug) ───────────────────── # Avito при редких/дорогих комбо (4+ комн.) добивает выдачу «по всей России» # (Москва/Челябинск/Омск и т.д.). Каждая карточка несёт СВОЙ href с city-slug @@ -739,5 +1058,36 @@ class Settings(BaseSettings): # message_thread_id топика внутри support-группы, в который идут зеркала. telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID") + # ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ────────── + # См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит + # роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch. + # PAYMENTS_ENABLED=false (дефолт) держит контур выключенным полностью: + # ни один из последующих PR (C/D/E) не должен активироваться без явного + # включения в .env.runtime прод-стека. + tbank_terminal_key: str = Field(default="", validation_alias="TBANK_TERMINAL_KEY") + # Пароль терминала — участвует в подписи Token (Init) и проверке подписи + # входящих нотификаций. SecretStr по прецеденту auth_db_password (строка + # 197 выше): не должен всплыть в логах/repr/Sentry breadcrumbs. + tbank_password: SecretStr = Field(default=SecretStr(""), validation_alias="TBANK_PASSWORD") + tbank_api_base_url: str = Field( + default="https://securepay.tinkoff.ru", validation_alias="TBANK_API_BASE_URL" + ) + tbank_notification_url: str = Field(default="", validation_alias="TBANK_NOTIFICATION_URL") + tbank_success_url: str = Field(default="", validation_alias="TBANK_SUCCESS_URL") + tbank_fail_url: str = Field(default="", validation_alias="TBANK_FAIL_URL") + # "O" — одностадийная (оплата сразу), "T" — двухстадийная (холд + Confirm). + # Дефолт "T": выбрана схема с холдом (гибрид «Проба → холд → отчёт по + # ссылке», ядро — вариант B) — источник решения `mera-b2c-paid-flow- + # decision.md` §1 в корне репо, НЕ recon-док (тот сам по себе выбирает + # "O" — устарел этим решением). Не переставляй дефолт обратно на "O", не + # сверившись с decision-доком. + tbank_pay_type: Literal["O", "T"] = Field(default="T", validation_alias="TBANK_PAY_TYPE") + tbank_receipt_enabled: bool = Field(default=False, validation_alias="TBANK_RECEIPT_ENABLED") + tbank_taxation: str = Field(default="", validation_alias="TBANK_TAXATION") + tbank_ffd_version: str = Field(default="", validation_alias="TBANK_FFD_VERSION") + # Kill-switch всего контура. false — checkout/notify (появятся в PR-D) + # обязаны отказывать сразу, ничего не вызывая у T-Bank. + payments_enabled: bool = Field(default=False, validation_alias="PAYMENTS_ENABLED") + settings = Settings() 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..374d55a2 --- /dev/null +++ b/tradein-mvp/backend/app/core/password.py @@ -0,0 +1,281 @@ +"""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) + + +def verify_slots_saturated(key: str) -> bool: + """Тот же предикат, по которому отказывает `verify_password_bounded`, но БЕЗ взятия слота. + + Нужен вызывающему ровно затем, чтобы отказать ДО похода в БД (#2715). Гейт + стоял ПОСЛЕ выборки пользователя, и каждый заведомо отклоняемый запрос всё + равно брал соединение из пула и делал SELECT по имени — тогда, когда система + уже перегружена. Хуже того, под насыщением эта выборка оставалась + ЕДИНСТВЕННОЙ работой на пути отказа: bcrypt, который ровняет время ответа + для существующего и несуществующего имени, ниже по течению и до него не + доходит, так что разницу «строка найдена / не найдена» ничто не маскировало. + + Предчек, а не решение: авторитетная проверка остаётся внутри + `verify_password_bounded` — она зовёт ЭТУ ЖЕ функцию, так что разъехаться + двум условиям нечем, и инвариант «одна точка выноса = одна точка учёта» + цел (слот здесь не резервируется и не отдаётся). + + Учитывает и общий потолок, и долю на ключ (#2714) — иначе предчек не + покрывал бы главный случай: при флуде с ОДНОГО адреса первым упирается + именно доля, и большинство отказов снова ходило бы в базу. + """ + return ( + _verify_inflight >= settings.login_password_verify_max_inflight + or _verify_inflight_by_key.get(key, 0) >= _per_key_slot_cap() + ) + + +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 + + # АВТОРИТЕТНАЯ проверка. Вызывающий может спросить то же самое заранее + # (`verify_slots_saturated`, #2715), но решение принимается здесь и только + # здесь — предчек экономит поход в БД, а не заменяет этот отказ. + if verify_slots_saturated(key): + 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..cda736bb 100644 --- a/tradein-mvp/backend/app/core/ratelimit.py +++ b/tradein-mvp/backend/app/core/ratelimit.py @@ -32,6 +32,18 @@ from starlette.middleware.base import BaseHTTPMiddleware from app.core.config import settings +# Платёжная нотификация Т-Банка (PR-D2, готовит почву под PR-D3 — путь ещё +# закрыт rbac до того момента). Сервер-к-серверу, без сессии/X-Authenticated-User +# → в общем лимитере попал бы в один и тот же per-IP ключ с любым другим +# анонимным трафиком с той же исходящей сети банка. Мотив НЕ «банк упрётся в +# лимит» — 300/60с и так щедро — а «429 никогда не должен стать причиной, по +# которой денежное состояние разъехалось»: для банка недоставленная нотификация +# = «доставка не удалась», альтернативного канала нет, а очередь ретраев +# растягивается на сутки. Только точный путь notify — НЕ checkout (тот +# инициирует пользователь с сессией/курсором в браузере, абуз там штатно +# лимитируем как любой другой API-путь). +_PAYMENTS_NOTIFY_PATH = "/api/v1/trade-in/payments/notify" + class RateLimitMiddleware(BaseHTTPMiddleware): """Sliding-window rate limit на /api/v1/*. Health и статика — без лимита.""" @@ -42,6 +54,23 @@ class RateLimitMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] path = request.url.path + # Платёжная нотификация — мимо ОБЩЕГО (per-user/per-IP shared) лимитера, + # но НЕ без лимита вовсе: idiom `_notify_limiter` (`SlidingWindowLimiter`, + # тот же приём, что `support.py:92`/`:319` — узкий per-feature бюджет + # ВМЕСТО общего, не полное отключение защиты). Порог заведомо выше любого + # штатного трафика банка (документированное расписание ретраев неизвестно, + # см. mera-tbank-acquiring-recon.md — берём с кратным запасом), но конечен: + # полное отключение оставило бы путь без backstop против шторма запросов — + # подпись отсекает мусор ПОСЛЕ разбора тела (PR-D3), не до. + if path == _PAYMENTS_NOTIFY_PATH: + retry_after = _notify_limiter.check(_client_ip(request)) + if retry_after is not None: + return JSONResponse( + status_code=429, + content={"detail": "Слишком много запросов. Попробуйте позже."}, + headers={"Retry-After": str(int(retry_after) + 1)}, + ) + return await call_next(request) # Лимитируем только API; health и прочее — пропускаем. if not path.startswith("/api/"): return await call_next(request) @@ -114,8 +143,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 +159,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 за один вызов) — @@ -137,6 +172,16 @@ class SlidingWindowLimiter: return None +# Щедрый бюджет для платёжной нотификации (PR-D2): 3000/60с (50 req/s) — на два +# порядка выше любого правдоподобного трафика банка (тест 400/60с проходит с +# запасом в 7.5×), но конечен — backstop против шторма запросов на путь, где +# подпись проверяется уже ПОСЛЕ разбора тела. Ключ — client IP (у сервер-к- +# серверу вызова нет сессии/X-Authenticated-User). +_NOTIFY_RATE_LIMIT = 3000 +_NOTIFY_RATE_WINDOW_S = 60.0 +_notify_limiter = SlidingWindowLimiter(limit=_NOTIFY_RATE_LIMIT, window_s=_NOTIFY_RATE_WINDOW_S) + + def _client_ip(request: Request) -> str: """Честный клиентский IP при РОВНО ОДНОМ доверенном прокси (Caddy) перед нами. diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index 0596de39..0dd654ff 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,41 @@ 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", + # Версионирование (VERSION-файл + build-args, см. app/core/version.py): + # не секрет, читает только process env — быстрая справка для клиента/ + # поддержки/смоук-теста, не должна требовать сессию. + "/api/v1/trade-in/version", + } +) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед # tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для # scope-проверки восстанавливаем внешний путь. @@ -51,6 +98,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 +177,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 +267,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/core/request_audit.py b/tradein-mvp/backend/app/core/request_audit.py index 7eafefbc..9416b556 100644 --- a/tradein-mvp/backend/app/core/request_audit.py +++ b/tradein-mvp/backend/app/core/request_audit.py @@ -40,7 +40,29 @@ logger = logging.getLogger(__name__) # Зеркалит app.main._PUBLIC_PATHS. Не импортируем напрямую из app.main — оно # импортирует этот модуль (регистрирует middleware), обратный импорт дал бы # циклическую зависимость. -_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) +# +# PR-D2: `/api/v1/trade-in/payments/notify` — заранее в skip-набор (defense-in- +# depth), хотя rbac ещё закрывает этот путь до PR-D3. Причины две: +# 1) сам путь не должен попадать в аудит вообще — тело нотификации содержит +# `Token`/`Pan`/`ExpDate` (см. `app/main.py._before_send`, тот же мотив, что +# и вырезание тела из мониторинга); хоть это middleware само по себе тело +# запроса в payload не пишет (только status_code/path/method), путь не +# должен зависеть от того, что кто-то потом добавит поле "body" в событие; +# 2) НЕ авторизующая проверка: `RequestAuditMiddleware` внешний относительно +# `rbac_guard` и читает сырой `X-Authenticated-User` (см. `main.py` порядок +# middleware) — анонимный POST на notify с подделанным заголовком +# `X-Authenticated-User: admin` иначе писал бы фальшивые события в +# `user_events` с атрибуцией admin, при этом rbac при этом ничего не знает +# (сам гейт отдельно, 401 всё равно вернёт до PR-D3). +_PUBLIC_PATHS = frozenset( + { + "/health", + "/docs", + "/redoc", + "/openapi.json", + "/api/v1/trade-in/payments/notify", + } +) # Методы, меняющие состояние — для /api/v1/admin/* именно они должны попадать в # аудит с атрибуцией (кто именно загрузил куки / включил авто-логин / поправил diff --git a/tradein-mvp/backend/app/core/version.py b/tradein-mvp/backend/app/core/version.py new file mode 100644 index 00000000..44101a3a --- /dev/null +++ b/tradein-mvp/backend/app/core/version.py @@ -0,0 +1,83 @@ +"""Product version metadata — единственный источник правды: `tradein-mvp/VERSION`. + +`APP_VERSION` / `BUILD_SHA` / `BUILD_DATE` обычно приходят как runtime env, +запечённые в образ через build-args в `backend/Dockerfile` +(см. `.forgejo/workflows/deploy-tradein.yml`, job `build-backend`) — там же +ARG'и читают сам `VERSION`-файл, короткий `git rev-parse --short HEAD` и +`date -u +%Y-%m-%dT%H:%M:%SZ`. + +Локальный запуск (`uvicorn app.main:app` без Docker-сборки) не задаёт эти env — +тогда версия читается напрямую из `VERSION` (поиск вверх по дереву каталогов, +см. `_find_version_file`), sha фолбэчит на `"dev"`, дата — на момент импорта +модуля. Ничего здесь не должно падать при отсутствии env (потребитель — +и PDF-колонтитул, и публичный `GET /api/v1/trade-in/version`). + +Номер версии НЕ дублируется больше нигде в коде — читай `APP_VERSION` отсюда. +Раньше рядом существовали два независимых хардкода (`_REPORT_ENGINE_VERSION` +в trade_in_pdf.py, `ui-config.ts`'s `version` на фронте) — оба снесены, PDF и +`/trade-in/v2` теперь показывают ровно один номер, взятый из этого модуля / +`@/lib/buildInfo` соответственно; не заводи третий. +""" + +from __future__ import annotations + +import datetime as dt +import os +from pathlib import Path + +_DEFAULT_VERSION = "0.0.0" +# Сколько уровней родителей проверять в поисках VERSION — с запасом покрывает +# и локальный layout (backend/app/core/version.py → ../../../VERSION == +# tradein-mvp/VERSION, 3 уровня), и Docker runner layout (/app/app/core/ +# version.py → /app/VERSION, 2 уровня, см. backend/Dockerfile COPY VERSION). +_MAX_ANCESTORS = 6 + + +def _find_version_file() -> Path | None: + here = Path(__file__).resolve() + for ancestor in list(here.parents)[:_MAX_ANCESTORS]: + candidate = ancestor / "VERSION" + if candidate.is_file(): + return candidate + return None + + +def _read_version_file() -> str: + path = _find_version_file() + if path is None: + return _DEFAULT_VERSION + try: + text = path.read_text(encoding="utf-8").strip() + except OSError: + return _DEFAULT_VERSION + return text or _DEFAULT_VERSION + + +def _default_build_date() -> str: + return dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# Читаются один раз при импорте модуля (совпадает с паттерном `settings = +# Settings()` в app/core/config.py) — процесс живёт с одним образом/деплоем, +# перечитывать на каждый запрос незачем. +APP_VERSION: str = os.environ.get("APP_VERSION") or _read_version_file() +BUILD_SHA: str = os.environ.get("BUILD_SHA") or "dev" +BUILD_DATE: str = os.environ.get("BUILD_DATE") or _default_build_date() + + +def format_build_date_human(build_date: str = BUILD_DATE) -> str: + """ISO-8601 UTC → `ДД.ММ.ГГГГ` для пользовательского отображения (PDF + колонтитул). Никогда не бросает исключение — при неразборчивой строке + возвращает её как есть (это футер отчёта, не API-контракт).""" + try: + parsed = dt.datetime.fromisoformat(build_date.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return build_date + return parsed.strftime("%d.%m.%Y") + + +def product_version_line(product_name: str = "Мера") -> str: + """`Мера v1.0.0 · a1b2c3d · 10.08.2026` — решение владельца продукта + 2026-08-10 (SemVer + короткий SHA + дата сборки). Используется в PDF + колонтитуле; тот же набор значений отдаёт `GET /api/v1/trade-in/version`.""" + return f"{product_name} v{APP_VERSION} · {BUILD_SHA} · {format_build_date_human()}" diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index 65fb5098..9fa819c4 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -12,7 +12,7 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager import sentry_sdk -from fastapi import FastAPI +from fastapi import FastAPI, Response from fastapi.middleware.cors import CORSMiddleware from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.httpx import HttpxIntegration @@ -23,15 +23,20 @@ from sentry_sdk.integrations.starlette import StarletteIntegration from app.api.v1 import ( admin, audit, + auth, brand, buildings, geocode, lead, me, + privacy_admin, search, support, + team, trade_in, + version, ) +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 @@ -61,17 +66,40 @@ logging.getLogger("httpx").setLevel(logging.WARNING) # worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/ # frontend), отдельного broker нет → мониторить нечего. if settings.glitchtip_dsn: - from app.observability.sentry_scrub import redact_telegram_bot_token + from app.observability.sentry_scrub import ( + redact_telegram_bot_token, + scrub_payment_request_body, + stabilize_retry_error_fingerprint, + ) def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None: - """Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) — - см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск: - теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке - sendMessage, а include_local_variables=False ниже — первый рубеж защиты).""" - scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type] + """Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction + + RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) — + см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага, + тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит + TelegramClient в стек-фреймах при ошибке sendMessage, а + include_local_variables=False ниже — первый рубеж защиты. + + PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные — + режет `request.data` целиком только для `/payments/*`, остальные пути + (extra/contexts/traceback) по-прежнему проходят ключ-based scrub и + token-redaction. Тот же обработчик передан ОБОИМ каналам ниже + (before_send и before_send_transaction) — вчерашний баг в Птице закрыл + только error-канал, transaction-канал остался вообще без обработчика. + + RetryError-стабилизация — этот процесс обслуживает /api/v1/geocode/* + (suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см. + sentry_scrub.stabilize_retry_error_fingerprint.""" + scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type] if scrubbed is None: return None - return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value] + scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type] + if scrubbed is None: + return None + detokened = redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type] + if detokened is None: + return None + return stabilize_retry_error_fingerprint(detokened, hint) # type: ignore[arg-type,return-value] sentry_sdk.init( dsn=settings.glitchtip_dsn, @@ -83,6 +111,10 @@ if settings.glitchtip_dsn: # держит base URL с токеном в локальных переменных стек-фрейма — default # sentry_sdk (True) приложил бы их к traceback открытым текстом. before_send=_before_send, + # PR-D2: тот же обработчик на transaction-канал — traces_sample_rate=0.0 + # сегодня не шлёт трейсы вообще, но это belt-and-suspenders на случай, + # если трейсинг когда-нибудь включат (см. docstring _before_send выше). + before_send_transaction=_before_send, integrations=[ StarletteIntegration(), FastApiIntegration(), @@ -106,6 +138,53 @@ 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`) сконфигурирован") + + # Платёжный контур МЕРЫ (Т-Банк эквайринг, PR-B — схема-only). Роутер/сервис + # ещё не существуют (PR-D), но kill-switch и конфиг уже здесь: если кто-то + # включит PAYMENTS_ENABLED=true в .env.runtime раньше, чем заведёт + # TBANK_TERMINAL_KEY/TBANK_PASSWORD, лучше не поднимать контейнер вообще, + # чем молча остаться без терминала/пароля на боевом трафике оплат. + if settings.payments_enabled and ( + not settings.tbank_terminal_key or not settings.tbank_password.get_secret_value() + ): + raise RuntimeError( + "PAYMENTS_ENABLED=true, но TBANK_TERMINAL_KEY/TBANK_PASSWORD пусты — " + "задай оба в .env.runtime до включения платёжного контура" + ) + # FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server. # Best-effort: failure does not abort startup, just logs. try: @@ -158,13 +237,37 @@ def health() -> dict[str, str]: return {"status": "ok", "environment": settings.environment} +# FastAPI/Starlette НЕ добавляет HEAD автоматически к @app.get() (в отличие от +# raw Starlette Route с methods=["GET"]) — без явного handler'а HEAD /health +# отдаёт 405. NB: наружу через Caddy этот /health НЕ проксируется (только +# /trade-in/api/* → strip_prefix → tradein-backend:8000/api/v1/*), и никакой +# docker healthcheck на него сейчас тоже не настроен (grep по compose-файлам — +# только pg_isready для postgres) — маршрут пока используется лишь тестами. +# Внешний прод-симптом `HEAD gendsgn.ru/health -> 405` чинится в Site Finder +# (backend/app/main.py, за Caddyfile `handle /health`), не здесь. +# media_type="application/json" — Content-Type совпадает с GET; Content-Length +# сознательно НЕ вычисляем под байт GET-ответа (дублировало бы сборку payload) +# — RFC 9110 §9.3.2 разрешает опускать payload-заголовки (Content-Length) для +# HEAD, требует совпадения только заголовков представления (Content-Type). +# include_in_schema=False — по той же причине, что и у Site Finder: HEAD-проба это +# инфраструктура, а не контракт API. Здесь codegen-джоба пока нет, флаг ставим +# симметрично, чтобы схема двух бэкендов не разъезжалась. +@app.head("/health", include_in_schema=False) +def health_head() -> Response: + return Response(status_code=200, media_type="application/json") + + +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"]) +app.include_router(privacy_admin.router, prefix="/api/v1/admin", tags=["admin-privacy"]) app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"]) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) +app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"]) app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"]) app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"]) 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..0920486e 100644 --- a/tradein-mvp/backend/app/observability/sentry_scrub.py +++ b/tradein-mvp/backend/app/observability/sentry_scrub.py @@ -25,10 +25,40 @@ import re from typing import Any from sentry_sdk.types import Event +from tenacity import RetryError _REDACTED = "[REDACTED]" # Ключи consumer-PII (нижний регистр; сверка case-insensitive). -_PII_KEYS = frozenset({"client_name", "client_phone", "client_email", "phone", "email", "name"}) +# PR-D2 (payments perimeter hardening): + платёжные поля Т-Банка (customer_email/ +# customer_phone из checkout, pan/expdate/cardid/rebillid/token/terminalkey из +# notify) — belt-and-suspenders поверх `scrub_payment_request_body` ниже, которая +# вырезает `request.data` для /payments/* целиком: этот словарь всё равно нужен +# для extra/contexts И на случай, если платёжное поле когда-нибудь попадёт в +# error event НЕ через request.data (напр. кто-то положит его в extra вручную). +_PII_KEYS = frozenset( + { + "client_name", + "client_phone", + "client_email", + "phone", + "email", + "name", + "customer_email", + "customer_phone", + "pan", + "expdate", + "cardid", + "rebillid", + "token", + "terminalkey", + } +) + +# Сегмент пути платёжного периметра (notify + checkout + любой будущий +# /payments/* суб-путь) — PR-D2, готовит почву под PR-D3 (эндпоинты ещё не +# существуют). Матчим по сегменту, не по конкретному эндпоинту, чтобы не +# требовать правки этого файла на каждый новый платёжный путь. +_PAYMENTS_URL_SEGMENT = "/api/v1/trade-in/payments/" # Telegram Bot API токен в пути URL: /bot:/. # Матчим ровно этот сегмент (не весь URL) — сохраняет остальной путь/query @@ -48,8 +78,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 +91,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|" @@ -72,6 +106,31 @@ _URL_SECRET_QUERY_RE = re.compile( ) _URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED +# httpx error-message URL query stabilization (GlitchTip-noise review round 2, +# claim #1). `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`) +# bakes the FULL request URL — INCLUDING query string — into the exception message: +# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/ +# search?q=<адрес>&format=json&limit=3'" (воспроизведено эмпирически: httpx.Response +# с params={"q": "<адрес>"} → raise_for_status() → именно этот текст). После +# app/services/geocoder.py `reraise=True` (стабилизирует ТИП исключения — RetryError +# → httpx.HTTPStatusError, см. комментарий у `_nominatim_lookup`) ИМЕННО этот текст +# становится GlitchTip title/value каждого события. `q=<адрес>` — переменная часть +# на КАЖДЫЙ вызов (ночной `geocode_missing_listings` — сотни разных адресов за +# прогон), значит per-address issue-explosion не устранён `reraise=True`, а просто +# переехал с RetryError на HTTPStatusError (тот же механизм: GlitchTip группирует по +# нестабильному тексту сообщения — это же подтверждают исходные 2 462 RetryError-issue, +# невозможные при группировке чисто по stacktrace/culprit). +# +# Отдельная регулярка от `_URL_SECRET_QUERY_RE` намеренно: та бьёт по ИМЕНИ известных +# secret-параметров (security-редактор), здесь — ЛЮБОЙ query string в httpx-стиле +# сообщении "for url '...'" (grouping-стабильность, не секретность — `q` не секрет). +# Режем query целиком (не только конкретные параметры) — host+path остаются +# стабильными для группировки, "for url '...'" — единственная форма, которую бьёт +# regex (не трогает произвольные строки с `?`, см. тест +# test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched). +_HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')") +_HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>" + def _scrub(obj: Any) -> None: """Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place).""" @@ -86,32 +145,34 @@ def _scrub(obj: Any) -> None: _scrub(item) -def _redact_url_secrets_inplace(obj: Any) -> None: - """Рекурсивно (IN-PLACE, как `_scrub`) заменяет значения секрет-подобных - query-параметров (`?token=...`, `?proxy_key=...` и т.п.) на [REDACTED] в - КАЖДОЙ строке event — не ключ-based: секрет утекает через httpx span - `url`/`query` data и через текст исключений (`str(exc)` httpx содержит полный - request URL), а не только через известные PII-поля формы. Мутирует dict/list - на месте (НЕ пересоздаёт структуру, в отличие от `_redact_strings`) — - сохраняет identity верхнеуровневого `event`, на что опирается контракт - `scrub_pii_event`/`before_send` и существующие тесты (`out is event`). +def _regex_redact_inplace(obj: Any, pattern: re.Pattern[str], replacement: str) -> None: + """Рекурсивно (IN-PLACE, как `_scrub`) прогоняет `pattern.sub(replacement, ...)` + по КАЖДОЙ строке event (не ключ-based) — общий обход, переиспользуемый и для + URL-секретов (`_URL_SECRET_QUERY_RE`), и для стабилизации httpx error-message + URL (`_HTTPX_ERROR_URL_QUERY_RE`): в обоих случаях переменные данные утекают + через httpx span `url`/`query` data и через текст исключений (`str(exc)` httpx + содержит полный request URL), а не только через известные PII-поля формы. + Мутирует dict/list на месте (НЕ пересоздаёт структуру, в отличие от + `_redact_strings`) — сохраняет identity верхнеуровневого `event`, на что + опирается контракт `scrub_pii_event`/`before_send` и существующие тесты + (`out is event`). """ if isinstance(obj, dict): for key, value in obj.items(): if isinstance(value, str): - redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value) + redacted = pattern.sub(replacement, value) if redacted != value: obj[key] = redacted else: - _redact_url_secrets_inplace(value) + _regex_redact_inplace(value, pattern, replacement) elif isinstance(obj, list): for i, value in enumerate(obj): if isinstance(value, str): - redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value) + redacted = pattern.sub(replacement, value) if redacted != value: obj[i] = redacted else: - _redact_url_secrets_inplace(value) + _regex_redact_inplace(value, pattern, replacement) # tuple намеренно не обрабатываем: sentry_sdk event — это JSON-совместимая # структура (dict/list/str/int/...), tuple там не встречается, а даже если бы # встретился — он immutable, in-place правка невозможна (см. `_scrub`, тот же @@ -119,16 +180,21 @@ def _redact_url_secrets_inplace(obj: Any) -> None: def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None: - """Redact consumer-PII + URL query-string секретов из error event перед отправкой. + """Redact consumer-PII + URL query-string секретов/nondeterministic-данных из + error event перед отправкой. - Композиция (обе — in-place, сохраняют identity `event`): (1) ключ-based + Композиция (все — in-place, сохраняют identity `event`): (1) ключ-based dict-scrub consumer-PII полей формы (как раньше), (2) full-text regex-проход по ВСЕМУ event, вырезающий значения секрет-подобных query-параметров в любой строке (proxy/API-ключи в исходящих URL сторонних сервисов, напр. mobileproxy - changeip — #security-audit). Второй шаг не завязан на конкретные ключи полей — - ловит секрет в frame locals, breadcrumb, exception message и т.д., где он может - оказаться независимо от include_local_variables/traces_sample_rate. Возвращает - event (не None). + changeip — #security-audit), (3) full-text regex-проход, стабилизирующий httpx + error-message URL (`for url '...?...'`) — убирает переменный query string + (адрес геокодинга и т.п.), от которого GlitchTip group-title плодит issue на + каждый вызов (GlitchTip-noise review round 2, claim #1; см. комментарий у + `_HTTPX_ERROR_URL_QUERY_RE`). (2) и (3) не завязаны на конкретные ключи полей — + ловят секрет/переменные данные в frame locals, breadcrumb, exception message + и т.д., где они могут оказаться независимо от + include_local_variables/traces_sample_rate. Возвращает event (не None). """ if not isinstance(event, dict): return event @@ -137,7 +203,41 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None: _scrub(request.get("data")) _scrub(event.get("extra")) _scrub(event.get("contexts")) - _redact_url_secrets_inplace(event) + _regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT) + _regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT) + return event + + +def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | None: + """Вырезать `event['request']['data']` целиком для платёжных путей (PR-D2). + + Ключ-based `scrub_pii_event` НЕ спасает платёжную нотификацию: sentry_sdk + 2.64 (`integrations/starlette.py`) кладёт ПОЛНОЕ тело запроса в + `event.request.data`, и `send_default_pii=False` этот путь не гейтит — тот + флаг управляет только куками, не телом запроса (проверено живьём на соседнем + продукте). Тело нотификации Т-Банка несёт `Token`/`Pan`/`ExpDate`/`CardId`/ + `RebillId`/`DATA` — банк сам выбирает имена полей, перечислить их все заранее + нельзя, поэтому единственная безопасная стратегия для этого пути — не + отправлять тело целиком, а не пытаться вычистить отдельные ключи. + + Матчим по сегменту `/api/v1/trade-in/payments/` (не по конкретному + эндпоинту) — покрывает notify, checkout и любой будущий суб-путь одним + фильтром, без правки этого файла на каждое расширение платёжного API. + Сравнение регистронезависимое: `_PUBLIC_PATHS` (rbac) — точное множество без + учёта регистра только у Caddy, не у Python, так что нестандартный регистр + пути технически может долететь до обработчика и породить событие. + + Композировать с `scrub_pii_event`/`redact_telegram_bot_token`, а не вместо + них — этот шаг закрывает только `request.data`, extra/contexts и + traceback-locals остаются на ответственности остальных шагов композиции. + """ + if not isinstance(event, dict): + return event + request = event.get("request") + if isinstance(request, dict): + url = request.get("url") + if isinstance(url, str) and _PAYMENTS_URL_SEGMENT in url.lower(): + request.pop("data", None) return event @@ -170,3 +270,61 @@ def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | No if not isinstance(event, dict): return event return _redact_strings(event) # type: ignore[return-value] + + +# ── RetryError fingerprint stabilization (GlitchTip noise-reduction) ──────── +# tenacity.RetryError.__str__() тащит repr() последнего Future +# (`RetryError[]`) — +# memory address объекта, случайный на каждый вызов процесса. Пока geocoder.py +# ретраил Nominatim без `reraise=True`, каждое исчерпание ретраев (Nominatim +# недоступен/rate-limit/403) улетало в GlitchTip как RetryError с этим +# нестабильным текстом → одна и та же причина плодила отдельный issue на КАЖДОЕ +# исчерпание (2 462 issue из 7 461 в трекере на момент фикса). `reraise=True` +# в app/services/geocoder.py устраняет RetryError на этом пути (пробрасывает +# реальное исключение) — но реальное исключение (httpx.HTTPStatusError) само +# несёт нестабильный текст (URL с адресом в query), поэтому group-стабильность +# для geocoder держит НЕ эта функция, а `_HTTPX_ERROR_URL_QUERY_RE` в +# `scrub_pii_event` (см. её комментарий, GlitchTip-noise review round 2 claim #1). +# +# Функция ниже — belt-and-suspenders для ЛЮБОГО кода, который ретраит через +# tenacity БЕЗ `reraise=True` (живой пример на момент фикса: `BaseScraper._http_get` +# в packages/scraper-kit — retry-декоратор НЕ reraise'ит, сознательно оставлен на +# этот фолбэк, а не на URL-стабилизацию: ретраятся listing detail URL БЕЗ query +# string — переменная часть там в ПУТИ (offer id), которую `_HTTPX_ERROR_URL_QUERY_RE` +# не покрывает; см. review round 2 claim #3). Схлопывает RetryError в ОДИН +# persistent issue per (culprit, класс исключения-причины) — culprit обязателен: +# БЕЗ него RetryError с одинаковым типом причины из НЕСВЯЗАННЫХ подсистем (напр. +# geocoder и scraper_kit одновременно ретраят httpx и оба ловят HTTPStatusError) +# схлопнулись бы в ОДИН issue — потеря сигнала хуже исходного шума (review round 2 +# claim #2). Источник culprit — `event["logger"]`: sentry_sdk `LoggingIntegration` +# ставит его в имя logger'а (`logging.getLogger(__name__)`, напр. +# "app.services.geocoder" vs "scraper_kit.providers.yandex.detail") на КАЖДОМ +# `logger.exception(...)`/`logger.error(...)` — стабильно per-модуль, не зависит от +# конкретного запроса. Остальная часть fingerprint собрана ТОЛЬКО из стабильных +# данных — имя типа исключения-причины (небольшой фиксированный словарь вроде +# "HTTPStatusError"/"ConnectTimeout") — НИКАКИХ переменных данных запроса (адрес, +# IP, id объявления и т.п.), иначе проблема повторится в других терминах. +def stabilize_retry_error_fingerprint(event: Event, hint: dict[str, Any]) -> Event | None: + """before_send-хук: схлопывает tenacity.RetryError в один persistent issue per + (источник, тип причины) — РАЗНЫЕ источники (geocoder / scraper_kit / будущий + retry-код) НЕ схлопываются друг с другом, даже если тип причины совпадает. + + Определяет тип exception через `hint["exc_info"]` (реальный объект + исключения, тот же контракт что sentry_sdk передаёт в before_send) — не + парсит уже сериализованный event dict, надёжнее к изменениям формата SDK. + `isinstance` (не сравнение `type(...).__name__` со строкой) — иначе любой + посторонний класс с совпадающим именем ложно матчился бы, а подкласс + `tenacity.RetryError` — промахивался бы. Не-RetryError события возвращает без + изменений (OperationalError, алерты scraper sweep'ов и т.п. фильтр не трогает). + """ + if not isinstance(event, dict): + return event + exc_info = hint.get("exc_info") if isinstance(hint, dict) else None + exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None + if not isinstance(exc_value, RetryError): + return event + cause = exc_value.__cause__ or exc_value.__context__ + cause_type = type(cause).__name__ if cause is not None else "Unknown" + culprit = event.get("logger") or event.get("transaction") or "unknown" + event["fingerprint"] = ["retry-exhausted", str(culprit), cause_type] + return event diff --git a/tradein-mvp/backend/app/scheduler_main.py b/tradein-mvp/backend/app/scheduler_main.py index 3bb8af4a..210b30fa 100644 --- a/tradein-mvp/backend/app/scheduler_main.py +++ b/tradein-mvp/backend/app/scheduler_main.py @@ -44,7 +44,35 @@ if settings.glitchtip_dsn: from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration - from app.observability.sentry_scrub import scrub_pii_event + from app.observability.sentry_scrub import ( + scrub_payment_request_body, + scrub_pii_event, + stabilize_retry_error_fingerprint, + ) + + def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg] + """PR-D2: этот процесс не держит ASGI-приложения (нет `request` в event + сегодня), но payments_confirm/payments_reconcile (PR-E, тот же + `tradein-scraper` контейнер) будут звать Т-Банк API отсюда — belt-and- + suspenders на случай, если платёжные данные когда-нибудь попадут в + `request`/`extra`. Тот же обработчик на оба канала ниже — см. + app/main.py._before_send (идентичный мотив, не дублировать без причины). + + PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise) идут + следом за платёжным body-wipe: этот процесс гоняет + `geocode_missing_listings` (ночной batch, сотни адресов за прогон) — + @retry-декорированные Nominatim-хелперы (app/services/geocoder.py) на + исчерпанных ретраях исторически плодили по отдельному GlitchTip issue + на КАЖДЫЙ адрес (RetryError.__str__() тащит нестабильный repr() Future). + См. sentry_scrub docstring. + """ + scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type] + if scrubbed is None: + return None + scrubbed = scrub_pii_event(scrubbed, hint) + if scrubbed is None: + return None + return stabilize_retry_error_fingerprint(scrubbed, hint) sentry_sdk.init( dsn=settings.glitchtip_dsn, @@ -52,7 +80,8 @@ if settings.glitchtip_dsn: release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown", traces_sample_rate=0.0, send_default_pii=False, - before_send=scrub_pii_event, + before_send=_before_send, + before_send_transaction=_before_send, integrations=[ SqlalchemyIntegration(), HttpxIntegration(), 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 69dee79d..8f245bb0 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 — @@ -42,6 +48,15 @@ class TradeInEstimateInput(BaseModel): has_mortgage: bool | None = None # client_name / client_phone удалены (PII purge #1969, DROP COLUMN 167). + # ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ, migration 229). + # Enforcement (НЕ здесь): app.services.estimator.estimate_quality проверяет + # `created_by is None and not consent -> 422` ДО первого INSERT адреса в + # trade_in_estimates. Здесь поле намеренно `bool | None = None`, а НЕ + # `Literal[True]` (как TradeInLeadInput.consent) — сделать True строго- + # обязательным на уровне Pydantic сломало бы B2B-пилотов: их согласие + # закрыто договором, а не UI-чекбоксом, и их фронт НЕ шлёт это поле вовсе. + consent: bool | None = None + class AnalogLot(BaseModel): address: str @@ -138,7 +153,20 @@ class DkpCorridor(BaseModel): low_ppm2: int # P10 ₽/м² по сделкам (робастный коридор) median_ppm2: int # медиана ₽/м² high_ppm2: int # P90 ₽/м² по сделкам (робастный коридор) - period_months: int # окно поиска сделок + period_months: int # окно ПОИСКА сделок — НЕ возраст данных (см. latest_deal_date) + # #2846: max(deal_date) по ОТОБРАННЫМ сделкам (по тем самым, что дали low/ + # median/high — включая city-wide widen, если сработал), НЕ по всей таблице. + # period_months отвечает на «где искали», а не «насколько свежи сделки»: прод + # 2026-08-12 — окно 12 мес, свежайшая сделка в БД I кв. 2026, и у 8.7% выборок + # даже она отсутствует (свежайшая — IV кв. 2025). Общий max по таблице был бы + # враньём в пользу свежести именно для них. + # Precision — КВАРТАЛ: Rosreestr open dataset пишет deal_date = первый день + # квартала (#1995, _date_precision_for_source). Прод-замер 2026-08-12: 96 974 + # сделки, 9 различных deal_date, day-of-month = 1 у 100%, месяцы ровно + # {01,04,07,10} → метка пачки, а не дата регистрации. Отсюда и форма подписи + # на витрине — «по I кв. 2026», не «12.01.2026» и не «223 дня назад». + # None = сделки без даты (в проде не встречается) — потребитель молчит. + latest_deal_date: date | None = None class PriceTrendPoint(BaseModel): @@ -181,10 +209,27 @@ class AggregatedEstimate(BaseModel): analogs: list[AnalogLot] actual_deals: list[AnalogLot] # реальные продажи last 12 mo expires_at: datetime + # PR-D1: срок жизни ССЫЛКИ/СТРОКИ (оплаченный доступ), НЕ актуальности + # расчёта — тот остаётся expires_at (не путать, см. migration 240). + # NULL = неоплачено (весь текущий трафик, B2B pilots включительно). + retain_until: datetime | None = None # ── Дополнительные метаданные ── 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 + # #2626: True если координаты дал ПОСЛЕДНИЙ тир geocode() — fallback на `houses` + # (см. `app.services.geocoder._local_houses_match`), а не Nominatim/geoportal/ + # cadastral. Значит адрес пользователя не совпал буквально (разговорное/усечённое + # имя улицы или отсутствующий корпус), но был однозначно сопоставлен с домом из + # скрейпленных листингов. Честный сигнал для UI («адрес уточнён автоматически»), + # НЕ персистится в БД (ephemeral, как и `target_city_ambiguous`). + target_address_refined: bool = False sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr'] data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг # абсолютный timestamp самого свежего парсинга аналогов @@ -249,6 +294,27 @@ class AggregatedEstimate(BaseModel): # null — нет данных / оценка не построена # НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него). analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None + # search_radius_m — фактический радиус (метры), по которому реально отбирались + # listings-аналоги (estimator.py, #2632). Может ОТЛИЧАТЬСЯ от requested_radius_m: + # при нехватке аналогов сервер расширяет поиск сам (1 км → 2 км, дальше каскад + # #oblast-F до 3/5 км — только когда пользователь НЕ зафиксировал радиус явно, + # контракт #2044). Фронт рисует круг на карте по ЭТОМУ полю (не по своему + # выбору) — иначе карта врёт о реально использованном радиусе. + # На GET-rehydrate колонки под него нет, поэтому значение ВОССТАНАВЛИВАЕТСЯ + # (estimator.rehydrate_search_radius_m): из persisted-подписи каскада + # «радиус расширен до N м» (точное значение, строки с 2026-08-10), иначе из + # размаха сохранённых аналогов, но не меньше DEFAULT_RADIUS_M. None — у + # _empty_estimate (поиск не выполнялся) и у старых строк без расстояний; + # фронт тогда fallback'ает на выбор пользователя, как раньше. + search_radius_m: int | None = None + # requested_radius_m — радиус, с которого поиск НАЧАЛСЯ: явный выбор + # пользователя (TradeInEstimateInput.radius_m) либо DEFAULT_RADIUS_M, если он + # выбрал «Авто». Отдаётся рядом с фактическим, чтобы ответ нёс ОБЕ величины — + # что просили и что получилось — и потребителю не приходилось выводить + # расхождение из своего локального состояния. None на GET-rehydrate: + # radius_m не персистится, а угадывать «просили 1 км» за пользователя — + # ровно та подмена входа результатом, которую чинит это поле. + requested_radius_m: int | None = None # ── #2002: премиальный дом (флаг, НЕ ценовой сигнал) ── # premium_building — целевой дом признан премиальным. Источник — curated overlay # `premium_buildings_curated` (data/sql/142, AI/human-выверенный класс + false- @@ -284,6 +350,41 @@ class AggregatedEstimate(BaseModel): cv: float | None = None source_counts: dict[str, int] = Field(default_factory=dict) created_at: datetime | None = None + # ── #oblast-F (never-block relaxation cascade, product decision 2026-08-10, + # #oblast-E priority RESTORED same day — see estimator.py module + # docstring for the full 3-way headline-source rule) ────────────────── + # Product requirement: an estimate is ALWAYS surfaced — a thin base sample + # (< HEADLINE_LISTINGS_MIN_N) no longer means "недостаточно данных". First + # estimator.estimate_quality() progressively relaxes the analog SEARCH + # (room-count adjacency → freshness window → novostroyki segment → radius) + # trying to grow the sample past the threshold; if it's STILL thin, + # _price_from_inputs() prefers a usable ДКП deals corridor over a noisy + # thin listings median when one is available (restored #oblast-E + # priority — the Серов repro: 3 listings must not outrank 54 deals), and + # only falls back to the thin listings median itself when no corridor + # exists. Real refusal happens only at genuine zero (no listings AND no + # usable anchor/deals). + # relaxations — RU-подписи КАЖДОГО применённого (реально помогшего) шага + # ослабления, готовые к показу пользователю как честный дисклеймер рядом с + # confidence_explanation. Пусто — базовой (4-tier) выборки хватило, каскад + # не понадобился (обычный случай). Возможные значения (дословно, фронт + # может на них завязываться): "снят фильтр по году постройки", + # "учтены студии", "комнатность ±1", "объявления за 60 дней", + # "учтены новостройки", "площадь ±25%", "радиус расширен до {N} м", + # "оценка по сделкам — мало объявлений рядом" (headline ceded to the ДКП + # deals corridor because the base listings sample was thin — a source + # SWITCH, not a search widening, but surfaced the same way). + # reliability — надёжность итоговой выборки, ПРОИЗВОДНАЯ от n_analogs + # (>=8 → ok; 3..7 → low; <3 → very_low), с доп. даунгрейдом ok→low, если + # relaxations непусто (выборка набралась только ценой ослаблений); капается + # на 'low' (не 'very_low'), когда headline ушёл по сделкам из-за тонкой + # выборки — реальный ДКП-коридор это настоящий сигнал, не «почти ничего». + # НЕ персистится на GET-rehydrate (пусто/"ok" по умолчанию там — известное + # ограничение, каскад не переигрывается из сохранённых analogs). НЕ + # путать с `confidence` (Literal low/medium/high — старая метрика на + # основе уникальных адресов/IQR, см. её собственный докстринг выше). + relaxations: list[str] = Field(default_factory=list) + reliability: Literal["ok", "low", "very_low"] = "ok" # ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку # при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ── area_m2: float | None = None @@ -395,6 +496,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) ─────────────────────── @@ -582,6 +688,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 @@ -643,3 +756,77 @@ class LocationIndexResponse(BaseModel): radius_m: int nearby_poi: list[NearbyPoiOut] poi_status: str + + +class CoverageProbeInput(BaseModel): + """Вход POST /api/v1/trade-in/coverage (issue #2894) — бесплатная проба покрытия. + + lat/lon — координаты, уже разрезолвленные фронтом (тот же контракт, что + TradeInEstimateInput.lat/lon — geocode делает фронт/автокомплит, эта ручка + сама НИКОГО не геокодирует). Город (и, соответственно, порог ok/thin) для + ответа резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon — см. + `app.api.v1.trade_in._resolve_coverage_city`. + + city_hint — ИНФОРМАЦИОННОЕ поле, на результат НЕ влияет (повторная проверка + #2894, 2026-08). Раньше оно участвовало в резолве города как фолбэк — + убрано вместе с модой `listings.city`: оба источника ненадёжны (`city_hint` + — непроверенный клиентский вход, `listings.city` — город свип-контекста + скрейпера, не адреса объявления, см. комментарий в trade_in.py). Поле + оставлено в схеме, потому что фронт его уже шлёт в других ручках того же + автокомплита (см. TradeInEstimateInput.city_hint) — принимаем и молча + игнорируем, чтобы не ронять запрос лишней 422. + """ + + lat: float = Field(ge=-90, le=90) + lon: float = Field(ge=-180, le=180) + rooms: int = Field(ge=0, le=10) # 0 = студия + area_m2: float = Field(gt=10, lt=500) + city_hint: str | None = Field(default=None, max_length=100) + + +class CoverageProbeResponse(BaseModel): + """Ответ POST /api/v1/trade-in/coverage. + + НАМЕРЕННО без единой цены (ни медианы, ни диапазона, ни ₽/м²) — продуктовое + правило issue #2894: бесплатный шаг доказывает, что похожие квартиры есть + и как быстро они уходят, а саму цену продукт продаёт на платном шаге. + + status: + - "ok" — n_listings >= порога для этого города (зелёный/жёлтый список). + - "thin" — когорта непустая, но n_listings < порога. + - "not_covered" — город вне зелёного/жёлтого списка ИЛИ когорта пустая + (n_listings == 0) — независимо от того, поддерживается город или нет. + + median_listing_age_days — ЧЕСТНОЕ имя: возраст АКТИВНОГО объявления + (days_on_market на текущий момент), а НЕ срок до продажи. Цензурированная + выборка (активные объявления ещё висят) всегда завышена относительно + реального времени экспозиции проданных — не путать со «сроком продажи». + ОГРАНИЧЕНИЕ ДАННЫХ (не продуктовое решение, см. coverage_probe docstring): + days_on_market на проде заполнена практически только у источника yandex — + возраст известен у меньшинства строк когорты. n_with_age ниже — честный + счётчик, по скольким объявлениям посчитана медиана; при n_with_age < порога + (COVERAGE_MIN_AGE_SAMPLES) median_listing_age_days принудительно null. + + n_with_age — сколько объявлений когорты реально имеют известный + (non-null, не-выброс) days_on_market и вошли в расчёт медианы. Фронт + обязан иметь возможность не показывать median_listing_age_days при + маленьком n_with_age — цифра "медиана" по 1-2 объявлениям не медиана. + + threshold — n, начиная с которого статус переходит в "ok" для резолвленного + города; 0 всегда, когда status == "not_covered" (порог неприменим — ни для + города вне зелёного/жёлтого списка, ни для поддерживаемого города с пустой + когортой), НЕ только для неподдерживаемого города. + + city — резолвится ИСКЛЮЧИТЕЛЬНО из lat/lon запроса (ближайший центроид из + зелёного/жёлтого списка в пределах `COVERAGE_CITY_MATCH_RADIUS_KM`), не из + `city_hint` и не из моды `listings.city` найденной когорты — см. + `app.api.v1.trade_in._resolve_coverage_city`. + """ + + status: Literal["ok", "thin", "not_covered"] + n_listings: int + median_listing_age_days: int | None + n_with_age: int + radius_m: int + city: str + threshold: int 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_price_history.py b/tradein-mvp/backend/app/services/cian_price_history.py index fdf1518f..0e23310e 100644 --- a/tradein-mvp/backend/app/services/cian_price_history.py +++ b/tradein-mvp/backend/app/services/cian_price_history.py @@ -19,19 +19,40 @@ from dataclasses import dataclass, field # golden-parity была доказана против legacy cian_detail-модуля до его удаления, # #2397 Part E2; extract_state/ScrapedLot parity-тесты убраны вместе с остальным # legacy scrapers-каталогом, #2397 финальный шаг E — kit единственный живой путь). -# RealScraperConfig — тот же read-only адаптер над settings, что и остальные -# kit-инжекции (#2131) — сохраняет proxy-поведение (config.cian_proxy_url) -# идентичным прежнему прямому импорту settings. from scraper_kit.providers.cian.detail import fetch_detail, save_detail_enrichment +from scraper_kit.proxy_errors import NoProxyAvailableError from sqlalchemy import text from sqlalchemy.orm import Session -from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig +from app.services.scraper_adapters import ( + RealMatcherAdapter, + RealProxyProvider, + RealScraperConfig, +) from app.services.scraper_settings import get_scraper_delay logger = logging.getLogger(__name__) +class _PoolCurlConfig(RealScraperConfig): + """RealScraperConfig с принудительно включённым pool-режимом curl (#2830). + + `USE_PROXY_POOL_CURL` задан только контейнеру `scraper` (docker-compose.prod.yml + services.scraper.environment), а этот бэкфилл запускается ручкой + `POST /admin/scrape/cian-price-history` в контейнере `backend`, где переменной нет + → `settings.use_proxy_pool_curl` = False. С ней `providers/_proxy.py::curl_proxy_url` + ИГНОРИРУЕТ переданный `proxy_provider` и уходит на статичный `SCRAPER_PROXY_URL`: + один `proxy_provider=` был бы правкой без эффекта (зелёный тест, нулевой прод). + + Флаг — рубильник раскатки pool-режима для планировщика, а не решение «этому пути + пул не нужен»: инцидент 2026-08-10 (#2830) — ровно про то, что нужен именно ему. + """ + + @property + def use_proxy_pool_curl(self) -> bool: + return True + + @dataclass class CianPriceHistoryResult: checked: int = 0 @@ -60,6 +81,11 @@ async def backfill_cian_price_history( result = CianPriceHistoryResult() t0 = time.time() delay = get_scraper_delay("cian") # default 5.0s + # Egress через пул с учётом `scrape_proxy_source_bans` (#2830): узел выбирает + # `curl_proxy_url` внутри `fetch_detail`, он же на выходе возвращает вердикт + # (mark_banned на CianBlockedError / mark_health / release). + scraper_config = _PoolCurlConfig() + proxy_provider = RealProxyProvider() if listing_id is not None: rows = ( @@ -107,9 +133,27 @@ async def backfill_cian_price_history( url: str = row["source_url"] try: - # config= обязателен — kit fetch_detail без него не читает cian_proxy_url - # (direct connection), а без прокси datacenter-IP блокируется Cian (#806). - enrichment = await fetch_detail(url, config=RealScraperConfig()) + # config= обязателен — без него kit fetch_detail идёт напрямую, а без прокси + # datacenter-IP блокируется Cian (#806). proxy_provider= — узел из пула + # (#2830): раньше здесь был статичный SCRAPER_PROXY_URL, не знающий про + # `scrape_proxy_source_bans`, и 403 от отбитого узла никому не сообщался. + enrichment = await fetch_detail( + url, config=scraper_config, proxy_provider=proxy_provider + ) + except NoProxyAvailableError as exc: + # Fail-closed (#2616): пул пуст/недоступен в проде. Остальные листинги + # упрутся в то же самое — рвём батч сразу, а не 50 раз по 5 секунд с + # логом, который читается как «Циан нас блокирует». + logger.error( + "cian_price_history: нет доступного прокси в пуле (%s) — батч прерван " + "на listing_id=%s (обработано %d из %d)", + exc, + lid, + i, + len(rows), + ) + result.errors += 1 + break except Exception as exc: logger.warning( "cian_price_history: fetch failed listing_id=%s url=%s: %s", diff --git a/tradein-mvp/backend/app/services/cian_session.py b/tradein-mvp/backend/app/services/cian_session.py index d6d68bf2..f40378e2 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 @@ -20,9 +21,15 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.core.config import settings +from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url_sync 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). @@ -146,8 +153,12 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None: try: # proxies: mobile-proxy egress (#806) — Cian блокирует datacenter-IP даже # при валидных DMIR_AUTH cookies. Без прокси verify всегда вернёт 403. - # Пусто (env не задан) → прямое подключение (dev/no-op). - _proxy_url = settings.cian_proxy_url + # Резолвер по источнику (#2825): пул scrape_proxies с учётом + # scrape_proxy_source_bans, fallback на settings.cian_proxy_url только если + # пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/ + # нездоровы для cian -- ProxyPoolExhaustedError (fail-closed, #2616), см. except + # ниже. + _proxy_url = resolve_proxy_url_sync("cian") _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None async with AsyncSession( impersonate="chrome120", @@ -184,6 +195,17 @@ async def verify_session(cookies: dict[str, str]) -> dict[str, Any] | None: logger.info("Cian cookies verified — userId=%s", user.get("userId")) return result + except ProxyPoolExhaustedError as exc: + # Fail-closed (#2616, #2825): пул scrape_proxies не пуст, но все узлы забанены + # ИМЕННО для cian/нездоровы — НЕ уходим на settings.cian_proxy_url (тот самый + # статичный узел мог быть источником бана, см. proxy_egress module docstring). + # Явный отказ вместо слепого прохода через заведомо подозрительный egress. + logger.error( + "Cian cookies verify: пул прокси исчерпан для cian (%s) — verify пропущен, " + "cookies НЕ помечены протухшими, retry на следующем такте", + exc, + ) + return VERIFY_SOURCE_UNAVAILABLE_SENTINEL except Exception as exc: # Сетевой/транспортный сбой (timeout, DNS, connection reset и т.п.) — источник # недоступен, НЕ признак протухших cookies (finding 4). Раньше здесь везде @@ -294,6 +316,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..1f68abf7 100644 --- a/tradein-mvp/backend/app/services/dadata.py +++ b/tradein-mvp/backend/app/services/dadata.py @@ -37,6 +37,12 @@ DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/sugge _DADATA_TIMEOUT_S = 8.0 _DADATA_SUGGEST_TIMEOUT_S = 5.0 +# Троттлинг WARNING «услуга CLEAN выключена на аккаунте» (#dadata-403-noise) — +# статичная конфигурация аккаунта, не транзиентный сбой. Первый раз за процесс +# логируется на WARNING, дальше — DEBUG, чтобы не заливать логи одним и тем же +# сообщением на каждый /estimate (было: logger.error на каждый запрос). +_clean_disabled_warned = False + @dataclass(frozen=True, slots=True) class DadataAddressResult: @@ -172,15 +178,29 @@ async def clean_address(address: str) -> DadataAddressResult | None: # но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ # поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback # (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена. + # + # Это НЕ сбой (аккаунт постоянно живёт с выключенной услугой, enrich_address уже + # graceful-деградирует на suggest — см. ниже) — раньше это било logger.error на + # КАЖДЫЙ пользовательский запрос (164 события/запрос-волна в проде), из-за чего + # ERROR переставал значить «настоящий сбой». WARNING один раз за процесс (дальше — + # DEBUG) сохраняет видимость причины без шума на каждый /estimate. if status == 403 and ( "disabled" in body_preview.lower() or "feature" in body_preview.lower() ): - logger.error( - "dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте " - "(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или " - "полагайся на suggest-fallback (enrich_address). Ответ: %r", - body_preview, - ) + global _clean_disabled_warned + if not _clean_disabled_warned: + logger.warning( + "dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте " + "(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или " + "полагайся на suggest-fallback (enrich_address). Ответ: %r " + "(повторы этого сообщения в рамках процесса логируются на DEBUG)", + body_preview, + ) + _clean_disabled_warned = True + else: + logger.debug( + "dadata: HTTP 403 CLEAN disabled (уже предупреждено WARNING в этом процессе)" + ) else: logger.error( "dadata: HTTP %d — auth/secret rejected. " @@ -343,6 +363,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/data_erasure.py b/tradein-mvp/backend/app/services/data_erasure.py new file mode 100644 index 00000000..05a84bba --- /dev/null +++ b/tradein-mvp/backend/app/services/data_erasure.py @@ -0,0 +1,240 @@ +"""Right-to-erasure mechanism (152-ФЗ) — ЭТАП 4 B2C launch, part C. + +WHY: + trade_in has no self-service "delete my data" endpoint at all. Both B2B + pilots (identified by `created_by` username) and future anonymous B2C + users need SOME way to have their personal data physically removed on + request, not just after their retention TTL expires + (app/tasks/purge_expired_trade_in_data.py handles the TTL path, this + module handles the on-demand path). + +WHO CAN BE IDENTIFIED, HONESTLY: + - B2B pilot (has a `username`): trivially -- `created_by = username` scopes + every estimate they created; leads/support threads follow from there. + - Anonymous person: has NO username. This function can ONLY act on + identifiers the requester can actually supply: + * `estimate_ids` -- if they still have the link/PDF from their estimate + (the UUID in the URL/QR-code IS their proof of "this is mine"). + * `phone` -- if they left a contact-request lead with that phone. + Matched by CANONICAL RU DIGITS on both sides (see + `_ru_phone_norm_sql` below), not an exact string: lead.py stores + `payload.phone` exactly as typed (no E.164 normalization, by + design), so "+7 999 123-45-67", "8 (999) 123-45-67" and + "89991234567" must all find the same row. Covers ONLY the + RU 8-vs-7 trunk-prefix case (exact digit-count identity, no + heuristic truncation) -- see the helper's docstring for why. + * `tg_chat_id` -- if they messaged @MERAsupport_bot directly (their own + Telegram chat id -- not guessable/spoofable by a third party the way + a name or IP would be). + If an anonymous person has NONE of these (e.g. they only remember the + street address, or ran an estimate but never saved anything and didn't + log support contact) -- THIS IS HONESTLY UNRESOLVABLE without additional + identification. There is no username, no stable session, nothing in the + DB schema today that lets a support operator find "the one estimate this + specific stranger made three days ago" among many. Do not paper over + this: an operator facing that case must say so, not silently pick "the + closest match". + +⚠️ TELEGRAM CAVEAT (152-ФЗ, honestly, do not omit): + Every tg_support_messages row was, at send time, ALSO mirrored by the bot + into the support-group Telegram topic (see app/services/tgbot/bridge.py, + 186_tg_support.sql). Deleting `tg_support_users` here only removes the + copy IN THIS DATABASE. The mirrored copy lives in the Telegram supergroup, + outside this function's reach, and is NOT deleted by anything in this + codebase. A complete erasure across the whole chain requires a SEPARATE + manual step (Telegram Bot API `deleteMessage` per `topic_message_id` in + the supergroup) that is out of scope here. Do not cite this function's + return value as proof of full erasure of the Telegram-side copy. + +WHAT ELSE IS *NOT* TOUCHED (known gap, flagged, not silently dropped): + `user_events` (184_user_events.sql) logs `estimate_request` events with a + JSONB payload that includes `address`/`area_m2`/`rooms` and is keyed by + `username` (empty string for anonymous callers today) + `ip_address`, with + NO FK to trade_in_estimates (decoupled/append-only by explicit design -- + see that migration's comment). This function does NOT purge user_events: + it is an audit/analytics log, not an estimate/lead/support record, and + deciding whether "audit trail" is a legitimate 152-ФЗ retention basis that + overrides an erasure request is a legal call, not an engineering one. Flag + it to whoever handles the request; do not assume it is already covered. +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +def _ru_phone_norm_sql(expr: str) -> str: + """SQL-фрагмент: нормализация телефона к каноническому РФ-виду (11 цифр, + ведущая '7'), для сравнения "разного форматирования одного и того же номера" + (deep-review 2026-08-06, MEDIUM + follow-up). + + Два шага: 1) убрать всё, кроме цифр; 2) если получилось РОВНО 11 цифр с + ведущей '8' -- заменить её на '7'. Это ТОЧНОЕ тождество для российской + нумерации (8 и +7 -- один и тот же trunk-префикс), не эвристика: длина + проверяется явно (=11), заменяется РОВНО одна ведущая цифра. Специально + НЕ "последние 10 цифр" -- усечение убрало бы риск ложных совпадений + неточно: оно склеивает номера РАЗНЫХ стран с теми же 10 хвостовыми + цифрами, а удаление ЧУЖИХ данных по erasure-запросу хуже, чем + неудаление своих. Номера другой длины/страны просто не совпадут ни на + этом шаге, ни дальше -- безопасный отказ, не false positive. + + `expr` -- ВСЕГДА статичный SQL-фрагмент (имя колонки или + `CAST(:bind AS type)`), НИКОГДА значение параметра: эта функция строит + структуру запроса из литералов, вызывающих её мест ровно два (см. + _PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL ниже) -- ни один телефон + не попадает в текст SQL напрямую, только через bind-параметр `:phone`. + """ + stripped = f"regexp_replace({expr}, '\\D', '', 'g')" + return ( + f"(CASE WHEN length({stripped}) = 11 AND left({stripped}, 1) = '8' " + f"THEN '7' || substring({stripped} FROM 2) ELSE {stripped} END)" + ) + + +# Предвычисленные один раз -- обе стороны сравнения телефона в erase_person_data +# (колонка trade_in_leads.phone / входной CAST(:phone AS text)). +_PHONE_COLUMN_NORM_SQL = _ru_phone_norm_sql("phone") +_PHONE_PARAM_NORM_SQL = _ru_phone_norm_sql("CAST(:phone AS text)") + + +def erase_person_data( + db: Session, + *, + username: str | None = None, + estimate_ids: Sequence[UUID] | None = None, + phone: str | None = None, + tg_chat_id: int | None = None, +) -> dict[str, int]: + """Physically delete a person's data across trade_in tables. + + At least one identifier is required (raises ValueError otherwise -- callers + MUST pass an explicit identifier, never "erase everything" by omission). + + Order of operations matters: leads are captured/deleted BEFORE estimates, + because trade_in_leads.estimate_id is ON DELETE SET NULL (172) -- once the + estimate row is gone, the join key to find "leads that came from this + person's estimate" is gone too. + + Returns per-table deleted-row counters. Callers own committing the ambient + Session lifecycle in whatever way their layer does (this function DOES + commit itself, mirroring app/tasks/*.py conventions, since this is a + one-shot admin operation, not a request-scoped unit of work shared with + other writes). + """ + if not any([username, estimate_ids, phone, tg_chat_id]): + raise ValueError( + "erase_person_data requires at least one identifier: " + "username / estimate_ids / phone / tg_chat_id" + ) + + counters: dict[str, int] = { + "trade_in_estimates_deleted": 0, + "trade_in_leads_deleted": 0, + "web_support_deleted": 0, + "tg_support_deleted": 0, + } + + # 1. Собрать ПОЛНЫЙ набор estimate_id ДО удаления оценок: явные estimate_ids + # (анонимный путь -- человек прислал ссылку/PDF) + все id с + # created_by=username (B2B-путь). Нужно захватить это СЕЙЧАС -- после + # DELETE FROM trade_in_estimates связанные trade_in_leads.estimate_id + # уйдут в NULL (ON DELETE SET NULL, 172), join станет невозможен. + all_estimate_ids: set[UUID] = set(estimate_ids or []) + if username: + owned = ( + db.execute( + text("SELECT id FROM trade_in_estimates WHERE created_by = :username"), + {"username": username}, + ) + .scalars() + .all() + ) + all_estimate_ids.update(owned) + + # 2. Лиды -- пока estimate_id ещё живой FK (см. п.1), плюс отдельно по + # телефону (лид мог быть оставлен без attach к оценке вовсе). + # + # ⚠️ Телефон сравнивается по КАНОНИЧЕСКОМУ РФ-виду, не литералом + # (deep-review 2026-08-06, MEDIUM + follow-up). app/api/v1/lead.py + # сохраняет payload.phone КАК ПРИСЛАЛИ (намеренно -- полная + # E.164-нормализация вне scope MVP, см. lead.py::_PHONE_PATTERN), + # т.е. одна и та же строка может лежать в БД как "+7 999 123-45-67" + # ИЛИ "89991234567" ИЛИ "8 (999) 123-45-67". Точное `phone = :phone` + # находит строку только если запрашивающий пришлёт БУКВАЛЬНО ТОТ ЖЕ + # формат, каким когда-то ввёл номер -- почти никогда так. Раньше это + # молча удаляло 0 строк и всё равно возвращало 200 "данные удалены": + # для 152-ФЗ ложное подтверждение удаления хуже честной ошибки. + # _PHONE_COLUMN_NORM_SQL / _PHONE_PARAM_NORM_SQL (см. _ru_phone_norm_sql + # выше) снимают форматирование С ОБЕИХ сторон И схлопывают ведущую + # '8' в '7' при 11 цифрах -- покрывает РОВНО RU 8-vs-7 trunk-префикс, + # без усечения до "последних 10 цифр" (риск ложного совпадения с + # номером другой страны -- см. докстринг helper'а). Номера иных + # форматов/длин сравниваются как есть (просто не совпадут). Параметр -- + # CAST(:phone AS text), НЕ конкатенация значения (psycopg v3 / SQL + # injection convention, .claude/rules/backend.md); сам SQL-текст + # собран из СТАТИЧНЫХ фрагментов (_PHONE_*_NORM_SQL), в которых нет + # ни одного значения параметра. + ids_param = [str(i) for i in all_estimate_ids] + result = db.execute( + text( + f""" + DELETE FROM trade_in_leads + WHERE estimate_id = ANY(CAST(:ids AS uuid[])) + OR ( + CAST(:phone AS text) IS NOT NULL + AND {_PHONE_COLUMN_NORM_SQL} = {_PHONE_PARAM_NORM_SQL} + ) + """ + ), + {"ids": ids_param, "phone": phone}, + ) + counters["trade_in_leads_deleted"] = result.rowcount or 0 + + # 3. Оценки (CASCADE подчищает estimate_photos + avito_imv_evaluations). + if all_estimate_ids: + result = db.execute( + text("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": ids_param}, + ) + counters["trade_in_estimates_deleted"] = result.rowcount or 0 + + # 4. Веб-чат поддержки -- ключ username (сайт закрыт Caddy basic_auth, у + # анонима username нет и быть не может, см. 187_web_support_chat.sql). + if username: + result = db.execute( + text("DELETE FROM web_support_threads WHERE username = :username"), + {"username": username}, + ) + counters["web_support_deleted"] = result.rowcount or 0 + + # 5. Telegram-поддержка -- ключ chat_id, ЕДИНСТВЕННЫЙ путь, реально + # доступный анониму без username (см. module docstring). ⚠️ Чистит + # ТОЛЬКО эту БД -- Telegram-топик со своей копией переписки НЕ + # затрагивается, см. ВАЖНЫЙ ФАКТ в docstring выше. + if tg_chat_id is not None: + result = db.execute( + text("DELETE FROM tg_support_users WHERE chat_id = CAST(:chat_id AS bigint)"), + {"chat_id": tg_chat_id}, + ) + counters["tg_support_deleted"] = result.rowcount or 0 + + db.commit() + logger.info( + "erase_person_data: username=%r estimate_ids=%d phone=%s tg_chat_id=%s -> %s", + username, + len(all_estimate_ids), + "" if phone else None, + tg_chat_id, + counters, + ) + return counters + + +__all__: list[str] = ["erase_person_data"] 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 5b09d19d..68e47403 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -33,6 +33,7 @@ from datetime import UTC, date, datetime, timedelta from typing import Any, Literal from uuid import uuid4 +from fastapi import HTTPException from scraper_kit.providers.avito.imv import ( IMVAddressNotFoundError, IMVAuthError, @@ -79,6 +80,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__) @@ -92,6 +94,69 @@ MIN_ANALOGS_PER_SOURCE = 5 # гарантированный минимум на LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней DEALS_PERIOD_MONTHS = 12 # сделки за последний год +# ── ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ) ──────────────── +# Отдельная пара от _CONSENT_POLICY_VERSION/_CONSENT_TEXT_SNAPSHOT в +# app/api/v1/lead.py: там согласие на КОНТАКТ-заявку (обработка телефона для +# CRM), здесь — согласие на САМУ ОЦЕНКУ (обработка адреса + параметров квартиры +# для расчёта стоимости). Разный предмет обработки -> разный текст/версия, даже +# если оба сейчас датированы одним месяцем. +# +# Enforcement: `require_consent` — явный keyword-only флаг у estimate_quality(), +# НЕ вывод из created_by is None. Причина: estimate_quality() — единственная +# ФУНКЦИЯ, но с ДЕСЯТКАМИ прямых вызовов из тестов эстиматора (test_same_ +# building_anchor.py, test_estimator_quarter_index.py и т.д.), почти все зовут +# её как estimate_quality(payload, db) без created_by вообще — они тестируют +# ценовую логику, не auth. Если бы gate триггерился от created_by is None, +# ЛЮБОЙ такой тест внезапно стал бы "анонимным" и падал на 422 (проверено: +# сломал 92 теста при первой попытке этого PR). require_consent=False по +# умолчанию -> НИ ОДИН существующий вызов не меняет поведение. Единственный +# реальный (production) вызывающий — app/api/v1/trade_in.py::estimate() — +# явно передаёт require_consent=(x_authenticated_user is None), т.е. gate +# реально применяется РОВНО там и тогда, где решение принимает rbac (нет +# X-Authenticated-User => анонимный запрос). +# +# СИНХРОННОСТЬ С ФРОНТОМ: сегодня анонимный флоу ещё не открыт (rbac_guard +# требует X-Authenticated-User на ЛЮБОМ non-public path — см. app/core/rbac.py), +# поэтому у этого текста пока НЕТ живого фронтового чекбокса для сверки (в +# отличие от _CONSENT_TEXT_SNAPSHOT в lead.py, см. +# tests/test_consent_text_frontend_sync.py). Когда анонимный /estimate откроется +# и фронт получит свой чекбокс — добавь сюда симметричный sync-тест ПРЕЖДЕ, чем +# полагаться на комментарий (ровно та ошибка, которую эта задача чинит для лидов). +_ESTIMATE_CONSENT_POLICY_VERSION = "2026-07" +_ESTIMATE_CONSENT_TEXT_SNAPSHOT = ( + "Согласен(-на) на обработку персональных данных (адрес объекта и параметры " + "квартиры) в целях предварительной оценки стоимости в соответствии с " + "Федеральным законом «О персональных данных» № 152-ФЗ" +) + + +def _estimate_consent_persist_fields( + require_consent: bool, client_ip: str | None +) -> dict[str, Any]: + """Поля consent/client_ip/policy/snapshot для INSERT в trade_in_estimates. + + require_consent=True -> здесь мы УЖЕ прошли gate в начале estimate_quality() + (payload.consent is True гарантирован), поэтому пишем durable-доказательство. + require_consent=False (B2B-путь, дефолт для всех прочих вызывающих) -> все + четыре NULL: согласие закрыто договором либо вызывающий вообще не участвует + в consent-контракте (внутренние тесты/скрипты) — доказательство здесь не + собиралось и собираться не должно. + """ + if require_consent: + return { + "consent": True, + "client_ip": client_ip, + "consent_policy_version": _ESTIMATE_CONSENT_POLICY_VERSION, + "consent_text_snapshot": _ESTIMATE_CONSENT_TEXT_SNAPSHOT, + } + return { + "consent": None, + "client_ip": None, + "consent_policy_version": None, + "consent_text_snapshot": None, + } + + # #oblast-D (non-EKB deals-headline-fallback): минимум ДКП-сделок, чтобы # _fetch_dkp_corridor доверял СВОЕЙ street-scoped выборке — иначе (тонкая # конкретная улица небольшого города) виджет расширяется до city-wide (см. @@ -101,6 +166,93 @@ DEALS_PERIOD_MONTHS = 12 # сделки за последний год 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 + +# #oblast-F (never-block relaxation cascade, product decision 2026-08-10, live +# repro: Академика Парина 46/5 студия 23.1 м² — rooms=1 exact match gave n=4 +# и попадала под #oblast-E выше, хотя rooms=0 по тому же адресу давал n=34; +# в радиусе 2 км rooms=0 17-29 м² — 327 активных лотов, rooms=1 — всего 10). +# Продукт: НИКОГДА не отказывать в оценке. Если после существующего 4-шагового +# каскада (tier0-когорта → без когорты → radius=fallback → area ±25%) выборка +# всё ещё < HEADLINE_LISTINGS_MIN_N — estimate_quality() продолжает ослаблять +# параметры подбора (см. #oblast-F блок там), от наименее к наиболее +# искажающему: (a) смежность комнатности, (b) свежесть объявлений, (c) сегмент +# (допустить новостройки), (d) радиус. Каждый применённый шаг попадает в +# AggregatedEstimate.relaxations (честный дисклеймер для пользователя) — гейт +# #oblast-E при этом больше НЕ обнуляет медиану (см. _price_from_inputs), а +# только помечает результат как низконадёжный. +RELAX_ROOMS_ADJACENT_DELTA = 1 # #oblast-F (a): rooms>=2 → BETWEEN rooms-1 AND rooms+1 +LISTINGS_FRESH_DAYS_RELAXED = 60 # #oblast-F (b): LISTINGS_FRESH_DAYS 14 → 60 дней +RELAX_RADIUS_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000) +RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум + +# #2632: подпись радиусного ослабления — ЕДИНСТВЕННОЕ место, где число +# фактического радиуса попадает в persisted-состояние (колонка relaxations). +# Отсюда же его читает GET-rehydrate (radius_from_relaxations) — иначе строка и +# парсер разъедутся, и карта опять начнёт рисовать не тот круг. +RADIUS_RELAXATION_PREFIX = "радиус расширен до " + + +def radius_relaxation_label(radius_m: int) -> str: + """RU-подпись шага (d) каскада #oblast-F для AggregatedEstimate.relaxations.""" + return f"{RADIUS_RELAXATION_PREFIX}{radius_m} м" + + +def radius_from_relaxations(relaxations: list[str] | None) -> int | None: + """Обратный разбор подписи: фактический радиус (м) или None, если его нет. + + Нужен на GET-rehydrate: сам search_radius_m колонкой не персистится, но + подпись каскада — да (миграция 255), и в ней лежит ровно то число, по + которому реально отбирались аналоги. + """ + for label in relaxations or []: + if label.startswith(RADIUS_RELAXATION_PREFIX): + digits = label[len(RADIUS_RELAXATION_PREFIX) :].removesuffix(" м").strip() + if digits.isdigit(): + return int(digits) + return None + + +def rehydrate_search_radius_m( + relaxations: list[str] | None, analog_distances_m: list[int | None] +) -> int | None: + """Фактический радиус подбора для СОХРАНЁННОЙ оценки (#2632). + + Колонки под search_radius_m нет, поэтому восстанавливаем из того, что + персистится: + 1) подпись каскада «радиус расширен до N м» — точное значение, то же, + что вернул POST (строки, созданные с 2026-08-10, миграция 255); + 2) иначе — размах сохранённых аналогов, но не меньше DEFAULT_RADIUS_M: + круг обязан вмещать пины, которые сам же и показывает (тот же приём + уже применён во v2 SourcesMap — там радиус круга = max distance_m). + None — расстояний нет вовсе (старые/anchor-строки): вызывающий честно + падает обратно на выбор пользователя, как и до этой правки. + """ + labelled = radius_from_relaxations(relaxations) + if labelled is not None: + return labelled + known = [d for d in analog_distances_m if d is not None] + if not known: + return None + return max(DEFAULT_RADIUS_M, max(known)) + + # #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 = ЕКБ). @@ -200,6 +352,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 пропускается. @@ -286,6 +487,32 @@ def _repair_coefficient(repair_state: str | None) -> float: return _REPAIR_COEF.get(repair_state, 1.0) +# Обратный `_IMV_REPAIR_MAP`: вокабуляр Avito → наш repair_state. +_IMV_REPAIR_MAP_REV: dict[str, str] = {v: k for k, v in _IMV_REPAIR_MAP.items() if k and v} + + +def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | None) -> float: + """#2677: множитель, приводящий домовой IMV-якорь к базису ремонта target'а. + + Домовая строка `house_imv_evaluations` запрошена у Avito с ОДНИМ ремонтом + (`renovation_type`, до #2675 — литерал 'cosmetic' у всех 2685 строк), а медиана + к моменту blend'а уже домножена на `_repair_coefficient(target_repair)`. Сравнивать + и смешивать их напрямую — значит класть два разных базиса на одну шкалу: у клиента + с «требует ремонта» медиана −6%, якорь нет, порог `median×1.15` фактически падает до + ×1.081, а сработавший blend возвращает половину этих −6% обратно вверх. + + Это НЕ заявка на правильность самих коэффициентов (`_REPAIR_COEF` — рыночная + эвристика, см. предупреждение выше): множитель берётся ровно тот, что код УЖЕ + применил к медиане, — чинится расхождение базисов, а не калибровка. + + Неизвестный/незнакомый ремонт с любой стороны → 1.0 (no-op, прежнее поведение). + """ + base = _repair_coefficient(_IMV_REPAIR_MAP_REV.get(anchor_renovation or "")) + if base <= 0: + return 1.0 + return _repair_coefficient(target_repair) / base + + # ── Asking→sold correction ratio lookup (#648 Stage 3) ────────────────────── # Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент # ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.72–0.93). Estimator @@ -313,11 +540,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 @@ -331,7 +569,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: @@ -747,10 +985,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 (ревью): резолв дома идёт ДО раннего возврата по пустой истории. Раньше + возврат стоял первым, и страница, отрисованная идеально, но без единого объявления + в истории, дома не заводила — на проде это 1519 оценок против 1360 домов с + историей, ~10% страниц молча пропускались. Порядок сохранён и после сноса признака + «панорама» (#2674 хвост, мигр. 259): остаточный эффект переноса — сам + match_or_create_house, то есть дом и строка house_sources для таких страниц. Это + тот же вызов, с тем же адресом, что уже отрабатывает на остальных 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() @@ -786,6 +1029,9 @@ def _save_yandex_history_items( result.address, ) + if not result.history_items: + return 0 + rows = [] skipped_area = 0 for item in result.history_items: @@ -867,6 +1113,15 @@ def _save_yandex_history_items( return 0 +# #2674 (хвост): _save_yandex_house_panorama удалена вместе с одноимённой колонкой houses +# (мигр. 259). Гейт «пишем только с отрисованной страницы» работал как задумано — и не мог +# ничего спасти: измерять было нечего. На проде 0 true из 1536 страниц yandex_valuation за +# 24.05–12.08.2026, а живая проверка боевым трактом 13.08.2026 (три адреса ЕКБ, HTTP 200, +# полный HTML) не нашла слова «панорам» ни в одном документе — ни в тексте, ни в атрибутах, +# ни в JSON-стейте. Признака на площадке нет, а поиск подстроки по тексту, где такого слова +# не бывает, гарантирует False механически: о доме этот ноль не говорил ничего. + + # ── #651: IMV / Yandex blend (killer accuracy fix) ───────────────────────────── @@ -880,14 +1135,39 @@ def _fetch_house_imv_anchor( """Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`. В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand - скрейп), `house_imv_evaluations` популирована (~2951 домов, fresh) и keyed по - house_id. Резолвим строку: WHERE house_id = target_house_id, предпочитаем - запись с ближайшими rooms+area (минимизируем |Δrooms|*10 + |Δarea%|), иначе - самую свежую (fetched_at DESC). Best-effort: None при любой ошибке / отсутствии - house_id / пустой таблице — estimator продолжает на гео-tier'ах (no regress). + скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку: + WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort: + None при любой ошибке / отсутствии house_id / пустой таблице — estimator + продолжает на гео-tier'ах (no regress). + + СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний текст обещал «популирована + ~2951 домов, fresh» — неправда по обоим пунктам, #2674): + 2680 строк, одна на дом (house_imv_eval_house_uniq_idx UNIQUE(house_id) — + поэтому ORDER BY ниже выбирает из ≤1 кандидата, а не ранжирует). 2633 из + них (98.2%) старше 40 суток, 2366 — один прогон 2026-05-24; свежих, после + фикса параметров 2026-08-06, всего 47. + Дореформенные 2633 спрошены у Авито с ЛИТЕРАЛОМ renovation_type='cosmetic' + и с дефолтом house_type='panel' при нераспознанном типе: мода ремонта по + объявлениям того же дома совпала лишь у 972 (36.9%), разошлась у 1217 + (46.2%), у 444 моды нет; тип дома разошёлся у 74 из 2633 (46 раз ушла + «панель» вместо «монолита»). + + ЧТО С ЭТИМ РЕШЕНО (замер на проде, 1081 персистированная оценка): + · Гейт «не брать дореформенные строки» — ОТКЛОНЁН: band-совместимая строка + находится у 224 оценок, и все 224 дореформенные. Гейт снял бы якорь у + 224 из 224, то есть просто выключил бы механизм. + · Массовая переоценка — не чинит корень и упирается в очередь: 1628 из + 2633 домов уже в imv_status='ok', а батч берёт только pending/ + transient_error, где сейчас 7144 дома при темпе ~25/прогон. + · Корень — не свежесть, а разные базисы ремонта; починено в #2677 + (`_anchor_repair_factor`), см. ниже. Returns dict {recommended_price, lower_price, higher_price, market_count, - rooms, area_m2} или None. + rooms, area_m2, renovation_type} или None. + + #2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito. + Без него якорь нельзя привести к базису ремонта оцениваемой квартиры + (см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op. """ if target_house_id is None: return None @@ -897,7 +1177,7 @@ def _fetch_house_imv_anchor( text( """ SELECT recommended_price, lower_price, higher_price, - market_count, rooms, area_m2 + market_count, rooms, area_m2, renovation_type FROM house_imv_evaluations WHERE house_id = CAST(:hid AS bigint) AND recommended_price > 0 @@ -1079,6 +1359,80 @@ def _lookup_quarter_indexes( return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows} +# Порог KNN для квартала ЦЕЛИ. 50 м (как у geo-nearest матчинга аналогов, +# tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M) замером НЕ подтвердились — +# точность проверена на 2398 домах ЕКБ, где квартал известен независимо (кадастр +# от DaData по адресу, а не KNN-подсказка), 2026-08-12: +# порог ответ выдан чужой квартал точность покрытие +# 15 м 475 26 94.5% 19.8% +# 25 м 748 59 92.1% 31.2% +# 50 м 1341 271 79.8% 55.9% +# 100 м 2090 644 69.2% 87.2% +# На 50 м каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане +# и 1.447× в p90 (отношение индексов чужого и своего квартала). Берём 25 м: +# точность 92%, и это ровно тот компромисс, который видно в замере, а не +# заимствованная у соседнего модуля константа. +_TARGET_QUARTER_RADIUS_M = 25.0 +# Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на +# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже. +_TARGET_QUARTER_DEG_GATE = 0.0015 + + +def _lookup_target_quarter_by_coords( + db: Session, + lat: float | None, + lon: float | None, +) -> str | None: + """Кадастровый квартал ЦЕЛИ по её собственным координатам (ближайшее здание ≤50 м). + + Зачем: `dadata.house_cadnum` пуст в 15 из 15 применений квартального индекса на + проде (замер 2026-08-12) — то есть свой кадастр цели известен почти никогда. + Тот же geo-nearest приём уже наполняет `listings.building_cadastral_number` + (tasks/cadastral_geo_match) — здесь он применён к координатам САМОЙ цели, а не + к координатам аналога. + + Это ПОДСКАЗКА, не идентичность здания (#2674): для квартального индекса нужен + только 3-й компонент кадастрового номера (квартал), а он у соседних зданий + одного двора совпадает. Возвращаем сразу квартал (`AA:BB:CCCCCCC`), а не + полный кадастр, чтобы вызывающий код не принял его за кадастр дома. + + None, если ЕКБ-зеркало `cad_buildings_local` пусто/недоступно, координат нет + или ближайшее здание дальше _TARGET_QUARTER_RADIUS_M. + """ + if lat is None or lon is None: + return None + try: + row = db.execute( + text( + """ + WITH p AS ( + SELECT ST_SetSRID( + ST_MakePoint(CAST(:lon AS double precision), + CAST(:lat AS double precision)), 4326) AS g + ) + SELECT cb.cad_num + FROM cad_buildings_local cb, p + WHERE ST_DWithin(cb.geom, p.g, CAST(:deg AS double precision)) + AND ST_DistanceSphere(cb.geom, p.g) <= CAST(:radius AS double precision) + ORDER BY cb.geom <-> p.g + LIMIT 1 + """ + ), + { + "lat": lat, + "lon": lon, + "deg": _TARGET_QUARTER_DEG_GATE, + "radius": _TARGET_QUARTER_RADIUS_M, + }, + ).first() + except Exception as exc: + logger.warning("target quarter by coords lookup failed (graceful, no-op): %s", exc) + return None + if row is None: + return None + return _quarter_from_cadastre(str(row[0])) + + def _apply_quarter_index( *, base_median_ppm2: float, @@ -1213,7 +1567,16 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: """#794: monthly {period_month: index_value} for region from sber_price_index. Tries SBER_COEFF_DASHBOARDS in order; returns first non-empty series. {} on any error. - #audit-5a: если latest месяц серии старее sber_index_max_age_days → warning. + + #2846: per-estimate guard свежести отсюда УБРАН. Он сравнивал возраст latest + периода с settings.sber_index_max_age_days=35, а такой возраст недостижим по + построению: period_month — метка ПЕРВОГО числа месяца (≥30 суток уже на + закрытии месяца) плюс лаг публикации источника; на проде за 31 сутки прямых + наблюдений возраст не опускался ниже 46. Guard был истинным 100% времени — + нулевой сигнал в per-estimate логе, который вдобавок не долетал до GlitchTip + (event_level=ERROR). Свежесть теперь мерит ОДНО место — tasks/sber_freshness_monitor, + и мерит отставание ЗАГРУЗКИ (последний полный прогон vs её собственный такт), + а не календарь. """ for dash in SBER_COEFF_DASHBOARDS: try: @@ -1241,21 +1604,6 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows} if not series: continue - # #audit-5a: data-age guard — предупреждаем о stale СберИндексе. - latest = max(series) - today = datetime.now(tz=UTC).date() - age_days = (today - latest).days - if age_days > settings.sber_index_max_age_days: - logger.warning( - "sber_index stale #audit-5a: latest=%s age=%d days" - " (> sber_index_max_age_days=%d) region=%s dash=%s" - " — time-adjustment may be outdated", - latest.isoformat(), - age_days, - settings.sber_index_max_age_days, - region, - dash, - ) return series return {} @@ -1420,6 +1768,10 @@ def _fetch_dkp_corridor( series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION) adjusted: list[float] = [] factors_applied: list[float] = [] + # #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted — + # `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы + # заявленную свежесть коридора, не участвуя в его границах. + latest_deal: date | None = None for r in rows: ppm2 = r["price_per_m2"] if not ppm2: @@ -1431,6 +1783,8 @@ def _fetch_dkp_corridor( factor = _sber_time_factor(series, deal_month) adjusted.append(float(ppm2) * factor) factors_applied.append(factor) + if dd is not None and (latest_deal is None or dd > latest_deal): + latest_deal = dd ppm2_values = sorted(adjusted) # #oblast-D widen: a single street in a small non-EKB town can easily have @@ -1483,6 +1837,7 @@ def _fetch_dkp_corridor( city_rows = [] city_adjusted: list[float] = [] + city_latest: date | None = None for r in city_rows: ppm2 = r["price_per_m2"] if not ppm2: @@ -1493,6 +1848,8 @@ def _fetch_dkp_corridor( deal_month = date(dd.year, dd.month, 1) factor = _sber_time_factor(series, deal_month) city_adjusted.append(float(ppm2) * factor) + if dd is not None and (city_latest is None or dd > city_latest): + city_latest = dd if len(city_adjusted) > len(ppm2_values): logger.info( @@ -1503,6 +1860,10 @@ def _fetch_dkp_corridor( city, ) ppm2_values = sorted(city_adjusted) + # #2846: коридор теперь описывает city-выборку — и возраст обязан + # переехать вместе с числами, иначе подпись осталась бы от street- + # выборки, которую на витрине уже никто не видит. + latest_deal = city_latest if not ppm2_values: return None @@ -1525,6 +1886,7 @@ def _fetch_dkp_corridor( "median_ppm2": int(_percentile(ppm2_values, 0.5)), "high_ppm2": int(_percentile(ppm2_values, 0.90)), "period_months": period_months, + "latest_deal_date": latest_deal, } @@ -1622,6 +1984,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). """ @@ -1722,6 +2098,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, *, @@ -1734,9 +2122,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 ненадёжны.) @@ -1752,7 +2150,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). @@ -1771,11 +2169,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() @@ -1900,12 +2307,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 @@ -2303,6 +2715,28 @@ class PricingResult: # headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV # радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных). cv: float | None = None + # #oblast-E/#oblast-F: >0 когда n листингов было найдено но ниже + # HEADLINE_LISTINGS_MIN_N. С #oblast-F (2026-08-10) больше НЕ обнуляет + # headline/listings_clean — median/n_analogs остаются реальными, поле лишь + # маркирует «низкая надёжность» (confidence='low' + честный explanation, + # см. gate comment ниже). 0 = либо выборка была достаточной, либо аналогов + # вообще не нашлось. + listings_headline_thin_n: int = 0 + # #oblast-E (restored priority, product correction 2026-08-10): True когда + # headline построен из #oblast-D deals-corridor ИМЕННО потому, что базовая + # выборка листингов была тонкой (0 < n < HEADLINE_LISTINGS_MIN_N) И доступен + # достаточно надёжный ДКП-коридор (см. deals-headline-fallback блок ниже). + # Caller (estimate_quality) читает это чтобы (a) добавить relaxation-подпись + # «оценка по сделкам — мало объявлений рядом», (b) закэпить reliability на + # 'low' (не выше). False во всех остальных случаях, включая genuinely-zero + # listings deals-fallback (тот же блок, но без тонкой выборки позади). + deals_headline_due_to_thin_listings: bool = False + + +def _analog_word_dative(n: int) -> str: + """Дательный падеж существительного «аналог» для confidence_explanation + тонкой (#oblast-E) выборки — «построена по N аналогу/аналогам».""" + return "аналогу" if n == 1 else "аналогам" def _price_from_inputs( @@ -2327,10 +2761,17 @@ def _price_from_inputs( ratio_resolver: Callable[[float | None], tuple[float | None, str | None]], quarter_index_lookup: Callable[[str], tuple[float, int] | None], quarter_indexes_lookup: Callable[[list[str]], dict[str, float]], + # Кадастр ЦЕЛИ (DaData) ИЛИ уже снятый по её координатам квартал — используется + # ровно в одном месте, блок #764, и только ради 3-го компонента (квартала). + # Кадастр аналога сюда подставлять нельзя: это квартал соседа, а не цели. target_house_cadnum: str | None, dadata_coarse: bool, geo: GeocodeResult, dadata_qc_geo: int | None, + # #2632: фактический радиус подбора (после каскада #oblast-F) — чтобы + # объяснение называло тот радиус, по которому реально искали. None → + # прежнее поведение (FALLBACK_RADIUS_M) для оффлайн-вызывающих (бэктест). + search_radius_m: int | None = None, ) -> PricingResult: """Deterministic pricing orchestration — pure, synchronous, zero I/O. @@ -2381,10 +2822,82 @@ def _price_from_inputs( n_analogs = 0 cv = None - # 4b. Repair coefficient + # 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above) + # — priority RESTORED 2026-08-10 (product correction on top of #oblast-F): + # 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find, but not trustworthy + # enough to headline on their OWN — a more reliable source should win when + # one exists. Two sub-cases: + # (i) a usable ДКП deals corridor is available (same threshold the + # #oblast-D deals-headline-fallback block below itself requires, + # DEALS_HEADLINE_FALLBACK_MIN_N deals with a positive median) → the + # listings AGGREGATE is suppressed to zero here so that block takes + # over the headline, EXACTLY like original #oblast-E. This is the + # Серов repro this gate exists for: n=3 listings must not outrank a + # 54-deal corridor. `listings_clean` stays intact (never cleared) — + # both for the anchor ghost-anchor guard (#1871) AND so + # estimate_quality() still surfaces these listings as display + # `analogs` cards even though they no longer drive n_analogs/median. + # (ii) no usable corridor → #oblast-F (never-block, 2026-08-10): keep the + # real thin median rather than refusing outright. By the time + # control reaches this function, estimate_quality() has already run + # the #oblast-F relaxation cascade (room-adjacency / freshness / + # novostroyki / radius) trying to grow the sample past the + # threshold — `listings` here is whatever that cascade could find. + # NOTE: `gate_ceded_to_deals` (local, this function only) is DIFFERENT from + # the `deals_headline_due_to_thin_listings` PricingResult field set later — + # this one fires as soon as the gate DECIDES to cede (used below to skip + # the repair-coefficient/explanation blocks safely, regardless of whether + # anchor later overrides); the field fires only once the #oblast-D + # deals-headline-fallback block ACTUALLY builds the headline from deals + # (anchor may still override in between — see that block). + listings_headline_thin_n = 0 + gate_ceded_to_deals = False + # Outward PricingResult field — set True below, ONLY inside the actual + # #oblast-D deals-headline-fallback block, once it fires for THIS reason. + deals_headline_due_to_thin_listings = False + if 0 < n_analogs < HEADLINE_LISTINGS_MIN_N: + listings_headline_thin_n = n_analogs + dkp_corridor_usable = ( + dkp_raw is not None + and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N + and dkp_raw.get("median_ppm2", 0) > 0 + ) + if dkp_corridor_usable: + gate_ceded_to_deals = True + logger.info( + "headline sufficiency gate #oblast-E: n=%d < %d listings, usable ДКП " + "corridor (n=%s) available — suppressing listings-derived median, " + "ceding headline to deals/anchor chain", + n_analogs, + HEADLINE_LISTINGS_MIN_N, + dkp_raw.get("count", 0) if dkp_raw else None, + ) + 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 + else: + logger.info( + "headline sufficiency note #oblast-E: n=%d < %d listings, no usable " + "ДКП corridor — keeping real median, flagged low-reliability " + "(#oblast-F: never suppressed to zero without a fallback source)", + n_analogs, + HEADLINE_LISTINGS_MIN_N, + ) + + # 4b. Repair coefficient — applies to any real (non-zero) median, INCLUDING + # thin-but-kept (#oblast-F case ii) samples — a repair-state adjustment is + # meaningful there. Skipped when the gate ceded the headline to deals + # (gate_ceded_to_deals — median_price is already 0 above; applying a + # coefficient would leave it 0 but still emit a misleading "adjusted for + # repair state" note, same reasoning original #oblast-E used). repair_coef = _repair_coefficient(repair_state) repair_note = "" - if listings_clean and repair_coef != 1.0: + if listings_clean and not gate_ceded_to_deals 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) @@ -2424,7 +2937,23 @@ def _price_from_inputs( fallback_used, area_widened, listings=listings_clean, + radius_used_m=search_radius_m, ) + # #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of + # the gate above (real thin median kept, no usable deals corridor to cede + # to). Case (i) (gate_ceded_to_deals) must NOT set this text — the + # deals-headline-fallback block below writes its OWN "built from + # Rosreestr deals" explanation; setting this first would leave a + # contradictory "Оценка построена по N аналогам" sentence stapled in front + # of it. Stays the final explanation unless a later block (anchor / + # #oblast-D deals-fallback) overwrites it with its OWN honest reasoning. + if listings_headline_thin_n and not gate_ceded_to_deals: + confidence = "low" + explanation = ( + f"Оценка построена по {listings_headline_thin_n} " + f"{_analog_word_dative(listings_headline_thin_n)} — выборка мала, " + "точность снижена." + ) # Tier note — информируем пользователя о качестве house-match tier_note = "" @@ -2587,6 +3116,7 @@ def _price_from_inputs( anchor_total: int | None = None anchor_higher: int | None = None anchor_label: str | None = None + anchor_repair_note: str = "" if imv_anchor is not None and imv_anchor.get("recommended_price"): anchor_total = int(imv_anchor["recommended_price"]) anchor_higher = ( @@ -2605,6 +3135,22 @@ def _price_from_inputs( _imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold ), ) + # #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его + # не правим), а в blend уходит якорь, приведённый к базису ремонта target'а — + # тому же, в котором median_price уже посчитан выше (repair_coef). + _rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state) + if _rep_k != 1.0: + anchor_total = round(anchor_total * _rep_k) + anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None + anchor_repair_note = ", приведённой к состоянию ремонта квартиры" + logger.info( + "imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f" + " → anchor %d", + imv_anchor.get("renovation_type"), + repair_state, + _rep_k, + anchor_total, + ) elif imv_eval is not None and imv_eval.recommended_price: anchor_total = int(imv_eval.recommended_price) anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None @@ -2654,8 +3200,8 @@ def _price_from_inputs( median_price = new_median median_ppm2 = new_ppm2 explanation = (explanation or "") + ( - f" Оценка скорректирована по {anchor_label} " - f"({anchor_used / 1_000_000:.1f} млн ₽)." + f" Оценка скорректирована по {anchor_label}" + f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)." ) sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"}) # Диапазон расширяем даже если медиану не двигали. @@ -2686,13 +3232,16 @@ def _price_from_inputs( and median_price > 0 and area_m2 ): + # target_house_cadnum: свой кадастр цели (DaData) ИЛИ квартал, снятый по + # координатам цели (_lookup_target_quarter_by_coords, вызывающий код). + # Прежде здесь был фолбэк «взять кадастр первого попавшегося аналога» — + # снят: dadata.house_cadnum пуст в 15 из 15 прод-применений, то есть квартал + # цели ВСЕГДА приходил от соседа (замер 2026-08-12: аналог-донор в 201-4087 м + # от цели; индексы кварталов на такой дистанции расходятся в 1.15-1.23 раза + # по медиане и в 1.6-1.8 по p90 — то есть множитель на витрине был чужой). + # Нет своего квартала → поправки нет; факт «квартал цели неизвестен» нельзя + # заменить чужим кварталом, он и есть предмет поправки. target_quarter: str | None = _quarter_from_cadastre(target_house_cadnum) - if target_quarter is None: - for lot in listings_clean: - cq = _quarter_from_cadastre(lot.get("building_cadastral_number")) - if cq is not None: - target_quarter = cq - break if target_quarter is not None: qindex_result = quarter_index_lookup(target_quarter) @@ -2977,9 +3526,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 @@ -2991,16 +3551,35 @@ def _price_from_inputs( n_analogs = 0 confidence = "low" cv = None + # #oblast-E (priority restored 2026-08-10): differentiate "genuinely + # zero listings" from "found some but below HEADLINE_LISTINGS_MIN_N, + # ceded to the deals corridor" (gate above, case i) — the latter must + # NOT claim "рядом нет объявлений" (false — some WERE found, just not + # trusted as headline on their own). `deals_headline_due_to_thin_ + # listings` (returned on PricingResult) tells estimate_quality() this + # was the thin-cession path specifically, so it can (a) append the + # "оценка по сделкам — мало объявлений рядом" relaxation label, (b) + # cap reliability at 'low' — a real deals corridor is a real signal, + # just not a listings-comp one. + 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']} мес.)," " точность ориентировочная." ) + if listings_headline_thin_n: + deals_headline_due_to_thin_listings = True logger.info( "deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline" - " (listings=0, anchor=None)", + " (listings=0 [thin_ceded=%d], anchor=None)", int(median_ppm2), dkp_raw["count"], + listings_headline_thin_n, ) # ── #652: ДКП-коридор реальных сделок (advisory) ───────────────────────── @@ -3132,12 +3711,19 @@ def _price_from_inputs( sources_used_pre=sources_used_pre, listings_clean=listings_clean, cv=cv, + listings_headline_thin_n=listings_headline_thin_n, + deals_headline_due_to_thin_listings=deals_headline_due_to_thin_listings, ) # ── Public ─────────────────────────────────────────────────────────────────── async def estimate_quality( - payload: TradeInEstimateInput, db: Session, created_by: str | None = None + payload: TradeInEstimateInput, + db: Session, + created_by: str | None = None, + client_ip: str | None = None, + *, + require_consent: bool = False, ) -> AggregatedEstimate: """Главная функция — оценка квартиры по реальным данным. @@ -3148,15 +3734,41 @@ async def estimate_quality( AggregatedEstimate с tier classification (T0_per_house / T1_per_street) — frontend может разделять confidence в UI. + Args: + require_consent: ЭТАП 4 B2C consent-before-save gate (keyword-only, + дефолт False — см. _ESTIMATE_CONSENT_* комментарий выше про то, + почему это НЕ выводится из created_by is None). Единственный + реальный вызывающий — app/api/v1/trade_in.py::estimate() — передаёт + True, когда нет X-Authenticated-User (анонимный запрос). + Returns: AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами. + + Raises: + HTTPException(422): require_consent=True без payload.consent=True. + Проверка стоит ПЕРВОЙ строкой тела функции, ДО geocode() и ДО + обоих мест, где адрес попадает в trade_in_estimates (главный + INSERT ниже И _empty_estimate fallback) — адрес физлица не должен + попасть в БД раньше согласия ни при каком исходе оценки. """ - # 1. Geocode (#654: time-budgeted — Yandex/Nominatim retry chain can stack + # ЭТАП 4 B2C launch (152-ФЗ) — consent-before-save. require_consent=False + # (дефолт, весь B2B-путь и все внутренние/тестовые вызовы) — эта проверка + # их не касается. require_consent=True (реально анонимный HTTP-запрос, + # см. Args выше) обязан нести явное согласие ПРЕЖДЕ, чем estimate_id будет + # сгенерирован и адрес уйдёт в БД. + if require_consent and not payload.consent: + raise HTTPException( + status_code=422, + detail="consent required for anonymous estimate request", + ) + + # 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, @@ -3180,8 +3792,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", ) @@ -3190,7 +3806,13 @@ async def estimate_quality( # Без координат не можем искать через PostGIS. Возвращаем low confidence. logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address) return await asyncio.to_thread( - _empty_estimate, payload, db, reason="address_not_geocoded", created_by=created_by + _empty_estimate, + payload, + db, + reason="address_not_geocoded", + created_by=created_by, + client_ip=client_ip, + require_consent=require_consent, ) # 1b. DaData enrichment (PR Q1) — on-demand cleanup для target адреса. @@ -3280,6 +3902,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) @@ -3292,6 +3924,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: @@ -3335,6 +3974,9 @@ async def estimate_quality( house_type=target_house_type, total_floors=payload.total_floors, ) + # #oblast-F: True only when there WAS a cohort (year_built) filter to drop — + # surfaced later as the "снят фильтр по году постройки" relaxation label. + cohort_dropped = cohort_range is not None and len(listings_tier0) < MIN_ANALOGS_TIER_0 area_widened = False if len(listings) < 5: @@ -3356,6 +3998,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% # (актуально для отдалённых районов / новостроек с нестандартной планировкой) @@ -3380,6 +4023,147 @@ async def estimate_quality( fallback_used = True area_widened = True analog_tier = analog_tier_wa + search_radius_m = fallback_radius_m + + # ── #oblast-F: relaxation cascade (never-block estimate, product decision + # 2026-08-10) ────────────────────────────────────────────────────────── + # Product requirement: NEVER refuse an estimate outright. If the 4-tier + # cascade above still leaves the sample thinner than HEADLINE_LISTINGS_MIN_N, + # keep loosening search criteria — least → most distorting — until either + # the sample clears the threshold or we run out of steps. Every step that + # ACTUALLY grew the sample is recorded in `relaxations` (RU labels, surfaced + # via AggregatedEstimate.relaxations + appended to confidence_explanation + # below) so a low-reliability estimate honestly explains why it stretched + # the search. Each step carries FORWARD the relaxations already applied by + # earlier steps (cumulative widening), not just its own single criterion. + relaxations: list[str] = [] + if cohort_dropped: + relaxations.append("снят фильтр по году постройки") + + cur_rooms_min: int | None = None + cur_rooms_max: int | None = None + cur_fresh_days = LISTINGS_FRESH_DAYS + cur_allow_novostroyki = False + cur_area_tolerance = 0.25 if area_widened else AREA_TOLERANCE + + async def _try_relax( + *, + rooms_min: int | None, + rooms_max: int | None, + fresh_days: int, + allow_novostroyki: bool, + radius_m: int, + area_tolerance: float, + ) -> tuple[list[dict[str, Any]], str] | None: + """Один шаг каскада #oblast-F. Возвращает (listings, tier) только если + кандидат СТРОГО больше текущей выборки — иначе релаксация не засчитана + (ничего реально не выиграла) и вызывающий её не применяет.""" + candidate, _, tier = await asyncio.to_thread( + _fetch_analogs, + db, + lat=geo.lat, + lon=geo.lon, + rooms=payload.rooms, + rooms_min=rooms_min, + rooms_max=rooms_max, + area=payload.area_m2, + radius_m=radius_m, + area_tolerance=area_tolerance, + fresh_days=fresh_days, + allow_novostroyki=allow_novostroyki, + full_address=geo.full_address, + target_house_id=target_house_id, + year_built=target_year, + house_type=target_house_type, + total_floors=payload.total_floors, + ) + if len(candidate) > len(listings): + return candidate, tier + return None + + # (a) room-count adjacency — самое дешёвое искажение: студия↔1-комн для + # rooms<=1 (live repro: Академика Парина 46/5, rooms=1 давал n=4, rooms=0 + # тем же адресом — n=34), иначе комнатность ±RELAX_ROOMS_ADJACENT_DELTA. + if len(listings) < HEADLINE_LISTINGS_MIN_N: + if payload.rooms <= 1: + try_rooms_min, try_rooms_max, rooms_label = 0, 1, "учтены студии" + else: + try_rooms_min = payload.rooms - RELAX_ROOMS_ADJACENT_DELTA + try_rooms_max = payload.rooms + RELAX_ROOMS_ADJACENT_DELTA + rooms_label = "комнатность ±1" + rooms_result = await _try_relax( + rooms_min=try_rooms_min, + rooms_max=try_rooms_max, + fresh_days=cur_fresh_days, + allow_novostroyki=cur_allow_novostroyki, + radius_m=search_radius_m, + area_tolerance=cur_area_tolerance, + ) + if rooms_result is not None: + listings, analog_tier = rooms_result + cur_rooms_min, cur_rooms_max = try_rooms_min, try_rooms_max + relaxations.append(rooms_label) + + # (b) свежесть объявлений: LISTINGS_FRESH_DAYS (14) → LISTINGS_FRESH_DAYS_RELAXED (60). + if len(listings) < HEADLINE_LISTINGS_MIN_N: + fresh_result = await _try_relax( + rooms_min=cur_rooms_min, + rooms_max=cur_rooms_max, + fresh_days=LISTINGS_FRESH_DAYS_RELAXED, + allow_novostroyki=cur_allow_novostroyki, + radius_m=search_radius_m, + area_tolerance=cur_area_tolerance, + ) + if fresh_result is not None: + listings, analog_tier = fresh_result + cur_fresh_days = LISTINGS_FRESH_DAYS_RELAXED + relaxations.append("объявления за 60 дней") + + # (c) снять guard listing_segment — допустить новостройки в comp-пул. + if len(listings) < HEADLINE_LISTINGS_MIN_N: + novo_result = await _try_relax( + rooms_min=cur_rooms_min, + rooms_max=cur_rooms_max, + fresh_days=cur_fresh_days, + allow_novostroyki=True, + radius_m=search_radius_m, + area_tolerance=cur_area_tolerance, + ) + if novo_result is not None: + listings, analog_tier = novo_result + cur_allow_novostroyki = True + relaxations.append("учтены новостройки") + + # (d) радиус → max(текущий, RELAX_RADIUS_STEP1_M), затем → RELAX_RADIUS_STEP2_M. + # Пропускается, когда пользователь явно зафиксировал radius_m — тот же + # контракт, что и у существующего radius-fallback выше (#2044: сервер не + # авто-расширяет поиск за пределы выбранного пользователем радиуса). + if len(listings) < HEADLINE_LISTINGS_MIN_N and payload.radius_m is None: + for relax_radius in (max(search_radius_m, RELAX_RADIUS_STEP1_M), RELAX_RADIUS_STEP2_M): + if relax_radius <= search_radius_m: + continue + radius_result = await _try_relax( + rooms_min=cur_rooms_min, + rooms_max=cur_rooms_max, + fresh_days=cur_fresh_days, + allow_novostroyki=cur_allow_novostroyki, + radius_m=relax_radius, + area_tolerance=cur_area_tolerance, + ) + if radius_result is not None: + listings, analog_tier = radius_result + search_radius_m = relax_radius + fallback_used = True + if len(listings) >= HEADLINE_LISTINGS_MIN_N: + break + + # Area/radius relaxations derived from FINAL state (covers both the + # pre-existing Tier B/C radius/area widening above AND step (d) here) — + # a single check avoids double-labelling the same underlying widening. + if area_widened: + relaxations.append("площадь ±25%") + if search_radius_m > base_radius_m: + relaxations.append(radius_relaxation_label(search_radius_m)) # ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ────────────── # #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы @@ -3535,11 +4319,28 @@ async def estimate_quality( # ── Coarse-geo signals ──────────────────────────────────────────────────── dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2 + # ── #764: квартал ЦЕЛИ для квартального индекса ────────────────────────── + # Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) → + # квартал по СОБСТВЕННЫМ координатам цели, ЕСЛИ включён флаг. Кадастр аналога + # сюда больше не попадает вообще (см. блок #764 в _price_from_inputs). + # Флаг dormant: точность резолва измерена (92% на 25 м), эффект поправки на + # точность ЦЕНЫ — нет (бэктест-фикстура его не проходит). См. config. + target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else None + if ( + settings.estimate_quarter_from_coords_enabled + and not target_quarter_cadnum + and geo is not None + ): + target_quarter_cadnum = await asyncio.to_thread( + _lookup_target_quarter_by_coords, db, geo.lat, geo.lon + ) + # ── DB-callable wrappers injected into pure pricing ─────────────────────── 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( @@ -3578,10 +4379,11 @@ async def estimate_quality( ratio_resolver=_ratio_resolver, quarter_index_lookup=_qi_lookup, quarter_indexes_lookup=_qis_lookup, - target_house_cadnum=dadata.house_cadnum if dadata else None, + target_house_cadnum=target_quarter_cadnum, dadata_coarse=dadata_coarse, geo=geo, dadata_qc_geo=dadata.qc_geo if dadata else None, + search_radius_m=search_radius_m, ) # Unpack pricing result @@ -3605,6 +4407,73 @@ async def estimate_quality( listings_clean = pr.listings_clean cv = pr.cv + # #oblast-E (priority restored 2026-08-10): headline ceded to the ДКП deals + # corridor because the base listings sample was thin — a real signal (real + # Rosreestr deals), just not a listings-comp one. Recorded as its own + # relaxation label (distinct from the #oblast-F cascade labels above, which + # describe attempts to grow the LISTINGS sample — this describes switching + # sources entirely). + if pr.deals_headline_due_to_thin_listings: + relaxations.append("оценка по сделкам — мало объявлений рядом") + + # #oblast-F: reliability tier derived from the FINAL n_analogs (post anchor/ + # deals-fallback override above) — independent of `confidence` (older + # unique-address/IQR metric, see AggregatedEstimate docstring). If the + # #oblast-F cascade had to relax anything to get here, an otherwise-"ok" + # sample is downgraded to "low" — the raw count looks fine, but it only + # exists because we widened the search past the user's exact criteria. + if n_analogs >= 8: + reliability: Literal["ok", "low", "very_low"] = "ok" + elif n_analogs >= 3: + reliability = "low" + else: + reliability = "very_low" + if pr.deals_headline_due_to_thin_listings: + # #oblast-E: n_analogs is 0 here (listings-comp count, honestly zero — + # the headline came from deals instead), which would otherwise bucket + # to 'very_low'. Pin to 'low' instead: a 54-deal Rosreestr corridor is + # a real, meaningful signal — "не выше low" (product spec), not + # "почти нет сигнала" (what 'very_low' would imply here). + reliability = "low" + elif relaxations and reliability == "ok": + reliability = "low" + + # #conf-cap (confidence↔reliability consistency, live-prod smoke 2026-08-11): + # `confidence` (unique-address/IQR metric, _compute_confidence above) and + # `reliability` (n_analogs-bucket + #oblast-F relaxation-cascade metric, + # just computed) used to be fully independent — the cascade's room- + # adjacency/freshness/novostroyki steps never set `fallback_used`, so + # _compute_confidence stayed blind to them and kept scoring a wide, + # tight-IQR (post-relaxation) sample as "high", while `reliability` + # honestly read "low" from the SAME relaxations. Live prod repro: n=39, + # confidence='high', reliability='low', 3 relaxations applied — client + # saw a "высокая уверенность" badge next to a "точность снижена" banner + # simultaneously. Fix applied at ONE point, after BOTH values are final + # (NOT spread across the cascade steps above) — see + # _cap_confidence_by_reliability docstring for the exact rule. + original_confidence = confidence + confidence = _cap_confidence_by_reliability(confidence, reliability) + confidence_downgraded_by_reliability = confidence != original_confidence + + if relaxations: + relaxations_detail = "Применены послабления подбора: " + ", ".join(relaxations) + "." + if confidence_downgraded_by_reliability: + # #conf-cap: explanation must LEAD with the accuracy-reduced summary + # when confidence was actually pulled down by the cap above — the + # old ordering opened with "Найдено 39 аналогов ... разброс ±6%" + # (reads as a confident, unqualified headline) and only tacked the + # relaxations sentence on at the very end, which itself contradicted + # the (now-capped) confidence badge just as much as the raw scores did. + explanation = ( + "Оценка построена с расширенными параметрами подбора — точность " + "снижена. " + (explanation or "") + " " + relaxations_detail + ) + else: + # No visible confidence change (e.g. it was already 'low'/'medium' + # pre-cap, or reliability=='ok' downgraded nothing) — keep the + # existing tail-append shape, unaffected by #conf-cap. + explanation = (explanation or "") + " " + relaxations_detail + # 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals. # Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков # исключены — больше не скёюят median вторички ~110-120 К/м². @@ -3621,7 +4490,7 @@ async def estimate_quality( # 6. Сохраняем в trade_in_estimates estimate_id = uuid4() now = datetime.now(tz=UTC) - expires_at = now + timedelta(hours=24) + expires_at = now + timedelta(hours=settings.trade_in_estimate_retention_hours) # #694: когда same-building якорь сработал, headline построен на комплах того # же дома (anchor_comps_used) — показываем ИХ, а не радиусные listings_clean @@ -3693,8 +4562,10 @@ async def estimate_quality( expected_sold_price, expected_sold_range_low, expected_sold_range_high, expected_sold_per_m2, asking_to_sold_ratio, ratio_basis, + relaxations, reliability, created_by, - expires_at + expires_at, + consent, client_ip, consent_policy_version, consent_text_snapshot ) VALUES ( CAST(:id AS uuid), :address, :lat, :lon, @@ -3713,8 +4584,11 @@ async def estimate_quality( :expected_sold_price, :expected_sold_range_low, :expected_sold_range_high, :expected_sold_per_m2, :asking_to_sold_ratio, :ratio_basis, + CAST(:relaxations_json AS jsonb), :reliability, :created_by, - :expires_at + :expires_at, + :consent, CAST(:client_ip AS inet), :consent_policy_version, + :consent_text_snapshot ) """ ), @@ -3764,8 +4638,15 @@ async def estimate_quality( "expected_sold_per_m2": expected_sold_per_m2, "asking_to_sold_ratio": asking_to_sold_ratio, "ratio_basis": ratio_basis, + # #oblast-F / GET-rehydrate (PR #2823 open follow-up): персистим + # relaxations/reliability вместе с median — раньше эти поля жили + # только в POST-ответе, и красный баннер «точность снижена» + # пропадал при открытии оценки по сохранённой ссылке (?id=). + "relaxations_json": json.dumps(relaxations, ensure_ascii=False), + "reliability": reliability, "created_by": created_by, "expires_at": expires_at, + **_estimate_consent_persist_fields(require_consent, client_ip), }, ) @@ -3892,6 +4773,8 @@ async def estimate_quality( target_address=geo.full_address, target_lat=geo.lat, target_lon=geo.lon, + target_city_ambiguous=geo.city_ambiguous, + target_address_refined=geo.address_refined, sources_used=sources_used, data_freshness_minutes=freshness_min, last_scraped_at=last_scraped_at, @@ -3941,6 +4824,12 @@ 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: обе величины рядом — с чего поиск начали (выбор пользователя или + # DEFAULT_RADIUS_M) и на чём закончили. Расходятся, когда сервер сам + # расширил поиск; подменять одно другим нельзя — это ровно тот дефект + # метки, что чинили в #2811. None у _empty_estimate (поиск не выполнялся). + requested_radius_m=base_radius_m, + search_radius_m=search_radius_m, premium_building=premium_building, premium_building_median_ppm2=premium_building_median_ppm2, premium_building_class=premium_building_class, @@ -3950,6 +4839,11 @@ async def estimate_quality( cv=cv, source_counts=source_counts, created_at=now, + # #oblast-F (never-block relaxation cascade) — применённые ослабления + # подбора + производная надёжность выборки (см. reliability computation + # above, независимо от `confidence`). + relaxations=relaxations, + reliability=reliability, ) @@ -4355,11 +5249,27 @@ def _extract_short_addr(full_address: str | None) -> str | None: # Ищет keyword типа улицы (ул./улица/пр./проспект/...) в адресе. # Работает для FORWARD и REVERSE форматов Nominatim. +# #pdf-honesty/#oblast-E-follow-up (live-prod fix 2026-08-10): точка после +# сокращений (ул., пр., пер., ш., наб., пл., мкр.) сделана ОПЦИОНАЛЬНОЙ +# (`\.?`) — DaData (основной источник адресов, дом-уровень геокодинга) отдаёт +# формат БЕЗ точки: "ул Академика Парина", а не "ул. Академика Парина". Старый +# regex требовал точку строго → keyword не матчился НИ НА ОДНОМ DaData-адресе +# → street-deals/sales-vs-listings блоки молчали (WARNING "could not extract +# street") на КАЖДОМ запросе с DaData-геокодингом, не только на репро-адресе. +# Порядок альтернатив принципиален: `ул\.?` идёт ПЕРЕД полным словом `улица` — +# но это безопасно за счёт backtracking Python `re` (NFA, не POSIX longest- +# match): если `ул\.?` матчит только "ул" из "улица" и последующий `\s+` +# после этого не находит пробел (следующий символ — "и"), движок +# откатывается и пробует СЛЕДУЮЩУЮ альтернативу — "улица" — которая матчит +# полностью. Проверено на "ул. X" / "ул X" / "улица X" — все три дают +# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная +# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже +# покрывает оба варианта (с точкой и без). _STREET_KW_RE = re.compile( r"(? NOW() - (:fresh_days || ' days')::interval @@ -4551,8 +5464,13 @@ _COMMON_WHERE = """ -- novostroyki guard (#1186): NULL = legacy вторичка до м.011 -- Исключаем новостройки из comp-пула вторички: девелоперский прайс искажает -- медиану ₽/м². NULL сегмент пропускаем (rosreestr/avito/yandex без сегмента — - -- это вторичка или неклассифицированный объект). - AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + -- это вторичка или неклассифицированный объект). #oblast-F (c): allow_novostroyki + -- пробрасывается как последняя-по-очереди relaxation-ступень (estimate_quality) — + -- дефолт False сохраняет канон-guard byte-identical. + AND ( + CAST(:allow_novostroyki AS boolean) IS TRUE + OR (listing_segment IS NULL OR listing_segment = 'vtorichka') + ) -- #2012 is_apartments hard-filter (флаг estimate_is_apartments_filter_enabled, -- default OFF pending backtest). Флаг выключен ⇒ CAST(... ) IS NOT TRUE ⇒ -- условие прозрачно (byte-identical старому поведению). Включён ⇒ исключает @@ -4607,6 +5525,14 @@ def _fetch_analogs( cohort_year_min: int | None = None, # NEW: lower bound year_built inclusive cohort_year_max: int | None = None, # NEW: upper bound year_built inclusive target_house_id: int | None = None, # #6: canonical house for same-building Tier S + # #oblast-F (never-block relaxation cascade) — все три опциональны, дефолты + # byte-identical старому поведению (exact rooms match / 14 дней / без + # новостроек). estimate_quality() передаёт неполные (widened) значения ТОЛЬКО + # когда базовая выборка тоньше HEADLINE_LISTINGS_MIN_N — см. module docstring. + rooms_min: int | None = None, # #oblast-F (a): None → эффективно = rooms + rooms_max: int | None = None, # #oblast-F (a): None → эффективно = rooms + fresh_days: int = LISTINGS_FRESH_DAYS, # #oblast-F (b): relaxed = LISTINGS_FRESH_DAYS_RELAXED + allow_novostroyki: bool = False, # #oblast-F (c) ) -> tuple[list[dict[str, Any]], bool, str]: """SELECT аналогов — трёхуровневый house-match (S → H → W). @@ -4647,21 +5573,29 @@ def _fetch_analogs( """ area_min = area * (1 - area_tolerance) area_max = area * (1 + area_tolerance) + # #oblast-F (a): None → эффективно exact-match (rooms_min=rooms_max=rooms), + # byte-identical старому `rooms = :rooms`. Caller (estimate_quality) passes a + # widened range only past HEADLINE_LISTINGS_MIN_N thinness. + eff_rooms_min = rooms if rooms_min is None else rooms_min + eff_rooms_max = rooms if rooms_max is None else rooms_max # #1871 P2: (source, source_id) dedup в radius-тирах. rn_dup-окно всегда в SQL # (безвредно без фильтра); статический фрагмент управляет только применением # `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3 # bind-параметры не задействованы, инъекции нет. dup_filter = "AND rn_dup = 1" base_params: dict[str, Any] = { - "rooms": rooms, + "rooms_min": eff_rooms_min, + "rooms_max": eff_rooms_max, "area_min": area_min, "area_max": area_max, - "fresh_days": LISTINGS_FRESH_DAYS, + "fresh_days": fresh_days, "max_per_addr": MAX_ANALOGS_PER_ADDRESS, "cohort_year_min": cohort_year_min, "cohort_year_max": cohort_year_max, # #2012: is_apartments hard-filter — see _COMMON_WHERE comment above. "is_apartments_filter": settings.estimate_is_apartments_filter_enabled, + # #oblast-F (c): allow_novostroyki — see _COMMON_WHERE comment above. + "allow_novostroyki": allow_novostroyki, } # ── Tier S (canonical): same building via house_id_fk ───────────────────── @@ -4986,7 +5920,8 @@ def _fetch_analogs( FROM listings WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) AND (geo_precision IS DISTINCT FROM 'city') - AND rooms = :rooms + -- #oblast-F (a): sync с _COMMON_WHERE — см. комментарий там же. + AND rooms BETWEEN :rooms_min AND :rooms_max AND area_m2 BETWEEN :area_min AND :area_max AND is_active = true AND scraped_at > NOW() - (:fresh_days || ' days')::interval @@ -5003,7 +5938,11 @@ def _fetch_analogs( ) -- novostroyki guard (#1186): NULL = legacy вторичка до м.011 -- Tier W: исключаем новостройки из comp-пула (sync с _COMMON_WHERE). - AND (listing_segment IS NULL OR listing_segment = 'vtorichka') + -- #oblast-F (c): allow_novostroyki relaxation, sync с _COMMON_WHERE. + AND ( + CAST(:allow_novostroyki AS boolean) IS TRUE + OR (listing_segment IS NULL OR listing_segment = 'vtorichka') + ) -- #2012 is_apartments hard-filter, sync с _COMMON_WHERE (см. комментарий -- там же). Флаг выключен ⇒ прозрачно (byte-identical старому поведению). AND ( @@ -5045,16 +5984,18 @@ def _fetch_analogs( "lat": lat, "lon": lon, "radius": radius_m, - "rooms": rooms, + "rooms_min": eff_rooms_min, + "rooms_max": eff_rooms_max, "area_min": area_min, "area_max": area_max, - "fresh_days": LISTINGS_FRESH_DAYS, + "fresh_days": fresh_days, "target_year": year_built, "target_house_type": house_type, "max_per_addr": MAX_ANALOGS_PER_ADDRESS, "cohort_year_min": cohort_year_min, # NEW "cohort_year_max": cohort_year_max, # NEW "is_apartments_filter": settings.estimate_is_apartments_filter_enabled, # #2012 + "allow_novostroyki": allow_novostroyki, # #oblast-F (c) }, ) .mappings() @@ -5529,11 +6470,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], *, @@ -5571,15 +6507,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]: @@ -5887,6 +6821,52 @@ def _downgrade_confidence(confidence: str) -> str: return {"high": "medium", "medium": "low", "low": "low"}.get(confidence, confidence) +_CONFIDENCE_ORDER: dict[str, int] = {"low": 0, "medium": 1, "high": 2} + + +def _cap_confidence_by_reliability(confidence: str, reliability: str) -> str: + """#conf-cap (confidence↔reliability consistency, live-prod smoke 2026-08-11). + + `confidence` (this function's sibling `_compute_confidence` — unique- + address/IQR metric) and `reliability` (estimate_quality — n_analogs-bucket + + #oblast-F relaxation-cascade metric) used to be computed fully + independently. The #oblast-F cascade's room-adjacency/freshness/ + novostroyki steps never set `fallback_used`, so `_compute_confidence` + stayed blind to them and could keep scoring a post-relaxation sample + (wide, tight-IQR) as "high" while `reliability` — which DOES see the + relaxations — honestly read "low". Live prod repro: n_analogs=39, + confidence='high', reliability='low', relaxations=[3 items] — a client + could see a "высокая уверенность" badge next to a "точность снижена" + banner on the SAME estimate. + + Rule (applied ONCE, by the caller, after both values are final — NOT + spread across the relaxation cascade): + reliability == 'very_low' → confidence forced to 'low'. + reliability == 'low' → confidence capped at 'medium' (high→medium; + medium/low pass through unchanged). + reliability == 'ok' → confidence untouched (unaffected — this is + the common, unrelaxed case; byte-identical + to pre-#conf-cap behaviour). + + Never RAISES confidence — only caps/lowers it. Unknown confidence/ + reliability values pass through unchanged (defensive, mirrors + _downgrade_confidence above). + """ + if reliability == "very_low": + return "low" + if reliability == "low": + current = _CONFIDENCE_ORDER.get(confidence) + cap = _CONFIDENCE_ORDER["medium"] + if current is not None and current > cap: + return "medium" + return confidence + + +def _radius_ru(radius_m: int) -> str: + """«2 км» / «5 км» / «1500 м» — целые километры словом, остальное метрами.""" + return f"{radius_m // 1000} км" if radius_m % 1000 == 0 else f"{radius_m} м" + + def _compute_confidence( n_analogs: int, median_ppm2: float, @@ -5895,6 +6875,7 @@ def _compute_confidence( fallback_radius_used: bool, area_widened: bool = False, listings: list[dict] | None = None, + radius_used_m: int | None = None, ) -> tuple[str, str]: """Confidence + explanation string. @@ -5928,7 +6909,12 @@ def _compute_confidence( iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0 notes = [] if fallback_radius_used: - notes.append("расширили радиус до 2 км") + # #2632: НАЗЫВАЕМ фактический радиус. До этого здесь было прибито «2 км» + # — константа старого 1км→2км fallback'а, а каскад #oblast-F уходит до + # 3/5 км: текст утверждал 2 км там, где поиск реально шёл по 5 км + # (прод, 2026-08-11: relaxations «радиус расширен до 5000 м», самый + # дальний аналог 4381 м, объяснение — «расширили радиус до 2 км»). + notes.append(f"расширили радиус до {_radius_ru(radius_used_m or FALLBACK_RADIUS_M)}") if area_widened: notes.append("расширили допуск по площади до ±25%") fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else "" @@ -6078,16 +7064,27 @@ def _deal_to_analog(row: dict[str, Any]) -> AnalogLot: def _empty_estimate( - payload: TradeInEstimateInput, db: Session, *, reason: str, created_by: str | None = None + payload: TradeInEstimateInput, + db: Session, + *, + reason: str, + created_by: str | None = None, + client_ip: str | None = None, + require_consent: bool = False, ) -> AggregatedEstimate: """Fallback когда нет данных для оценки. Сохраняет запись в БД (confidence='low', пустые analogs/deals), чтобы GET /estimate/{id} не возвращал 404. C-4 security audit. + + ЭТАП 4 B2C: этот путь тоже пишет адрес в trade_in_estimates -- consent-gate в + estimate_quality() уже отработал ДО вызова (require_consent=True подразумевает + payload.consent is True), здесь просто персистим то же consent-доказательство, + что и главный путь (_estimate_consent_persist_fields). """ estimate_id = uuid4() now = datetime.now(tz=UTC) - expires_at = now + timedelta(hours=24) + expires_at = now + timedelta(hours=settings.trade_in_estimate_retention_hours) db.execute( text( @@ -6101,8 +7098,10 @@ def _empty_estimate( confidence, confidence_explanation, n_analogs, analogs, actual_deals, sources_used, + relaxations, reliability, created_by, - expires_at + expires_at, + consent, client_ip, consent_policy_version, consent_text_snapshot ) VALUES ( CAST(:id AS uuid), :address, :area, :rooms, :floor, :total_floors, @@ -6112,8 +7111,15 @@ def _empty_estimate( 'low', :explanation, 0, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb, + -- #oblast-F: поиск аналогов вообще не выполнялся (geocode failed / + -- no coords) — каскад послаблений не запускался, relaxations честно + -- пуст; reliability='very_low' зеркалит то, что возвращает Python + -- ниже (см. AggregatedEstimate(..., reliability="very_low")). + '[]'::jsonb, 'very_low', :created_by, - :expires_at + :expires_at, + :consent, CAST(:client_ip AS inet), :consent_policy_version, + :consent_text_snapshot ) """ ), @@ -6133,6 +7139,7 @@ def _empty_estimate( "explanation": reason, "created_by": created_by, "expires_at": expires_at, + **_estimate_consent_persist_fields(require_consent, client_ip), }, ) db.commit() @@ -6157,4 +7164,7 @@ def _empty_estimate( # Адрес не геокодирован (DaData не отрабатывала) → точность неизвестна. address_precision=None, analog_tier=None, # нет данных при empty estimate + # #oblast-F: n_analogs=0 здесь честно — поиск аналогов вообще не выполнялся + # (geocode failed / no coords), а не просто "мало нашлось". + reliability="very_low", ) diff --git a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py index 425dda5f..ef896560 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -51,6 +51,7 @@ from matplotlib.figure import Figure # object API, НЕ pyplot — см. _price from matplotlib.patches import Rectangle from app.core.config import settings +from app.core.version import product_version_line from app.schemas.trade_in import AggregatedEstimate, AnalogLot logger = logging.getLogger(__name__) @@ -229,12 +230,6 @@ _DANGER_SOFT = "#f9eded" # мягкий тон (12% _DANGER на белом) _BORDER = _LINE _BORDER_STRONG = "#b8c8d8" # tokens.line3 — edge карточки/фото, оси графика (сильнее hairline) -# Декоративная версия «движка отчёта» в футере (см. _page_footer) — зеркалит -# tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts::version. Не -# brand-данные (одинаковая для всех white-label брендов) — косметическая деталь -# HUD, а не версия PDF-модуля/API. -_REPORT_ENGINE_VERSION = "v2.0.6" - # Type scale — консолидировано с ~11 разрозненных значений (7/7.5/8/8.5/9/10/ # 11/12/13/14/18pt) до 6 шагов, применяется единообразно на всех 4 страницах. _FS_XS = "8pt" # футеры, дисклеймеры, source badges, sub-captions @@ -243,6 +238,11 @@ _FS_MD = "10.5pt" # базовый текст (body), значения в та _FS_LG = "13pt" # заголовки страниц (h2, PT Serif) _FS_XL = "16pt" # главный заголовок cover (h1, PT Serif) _FS_XXL = "22pt" # крупные ценовые цифры (dual-price блок) +# Намеренное исключение из 6-шаговой шкалы: running-footer — @page margin-box с +# фиксированной высотой (19mm ≈ 53.9pt), делить с mono-мета-строкой/wordmark +# практически нечем (см. _page_footer). 135-ФЗ дисклеймер (Блок 4.2) должен +# влезать в ~380-450 симв. на каждой странице без пятой пустой страницы. +_FS_XXS = "5pt" # ТОЛЬКО 135-ФЗ футер-дисклеймер (_page_footer) — не переиспользовать # ── Embedded fonts (PT Sans / PT Serif, ParaType, SIL OFL 1.1) ────────────── @@ -505,16 +505,48 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type: "ДАТА", report_date.strftime("%d.%m.%Y") ) + # Строка версии продукта («Мера v1.0.0 · a1b2c3d · 10.08.2026») — решение + # владельца продукта 2026-08-10, см. app/core/version.py::product_version_line. + # Отдельная от brand.name строка НАМЕРЕННО: brand.name — white-label вывеска + # реселлера (Практика/PRINZIP), а тут — версия самого продукта «Мера», + # одинаковая для всех брендов. Одна nowrap/overflow:hidden строка под + # существующим masthead-рядом — не растёт по высоте ни при каком контенте + # (клипается по ширине, не переносится), top-margin (25mm) даёт под неё + # запас; см. коммит 42a50cf8 про хрупкость running-header бюджета высоты. + version_html = ( + f'
' + f"{_html.escape(product_version_line())}
" + ) + return ( + f"
" f'
' + f'padding-bottom:6pt;margin-bottom:3pt;">' f"{mark_html}" f'{meta_html}' f"
" + f"{version_html}" + f"
" ) +# Блок 4.2 юр-требований: должен печататься в подвале КАЖДОЙ страницы отчёта +# (не только cover). Текст утверждён владельцем продукта дословно — не менять +# формулировку без явного запроса. Заведён как модульная константа (не inline +# в _page_footer), чтобы не расползалась по нескольким билдерам страниц. +_PDF_135FZ_FOOTER_NOTICE = ( + "Документ содержит индикативный (ориентировочный) расчёт стоимости объекта, " + "сформированный автоматически сервисом «МЕРА». Не является отчётом об оценке " + "по Федеральному закону № 135-ФЗ и не имеет установленной этим законом " + "юридической силы. Не предназначен для использования при ипотечном " + "кредитовании, в судебных разбирательствах, нотариальных действиях и иных " + "случаях, где законом предусмотрено обязательное проведение независимой оценки." +) + + def _page_footer( brand, # type: ignore[no-untyped-def] report_num: str, @@ -529,12 +561,30 @@ def _page_footer( строка 1 — mono meta (№ отчёта / дата / срок действия); тонкая градиентная линия-разделитель; строка 2 — точка акцента + wordmark (brand.name — НЕ - хардкод «МЕРА», white-label остаётся рабочим) + версия движка отчёта. + хардкод «МЕРА», white-label остаётся рабочим); строка 3 — 135-ФЗ дисклеймер + (Блок 4.2, _PDF_135FZ_FOOTER_NOTICE) — печатается на КАЖДОЙ странице, т.к. + footer рендерится один раз как running @page margin-box (см. вызов в + generate_trade_in_pdf), а не per-page. Номер версии продукта здесь + НЕ дублируется — единственное место вывода версии в PDF — running-header + (_page_header → product_version_line()); раньше рядом с wordmark висел + decorative "vN.N.N" (_REPORT_ENGINE_VERSION), не связанный с реальной + версией продукта — расходился с header на каждой странице, снесён. page_note — старый текст footer'а (бренд/подзаголовок/№ страницы/дисклеймер на офер-странице), которого нет в веб-референсе (там нет пагинации). Не удалён — вынесен приглушённой строкой НАД HUD-баром, чтобы не терять полезную для печатного многостраничного отчёта информацию. + + #footer-height-budget (2026-08-14, Блок 4.2): @bottom-center margin-box + высотой = page margin-bottom (см. _build_css). Добавление 135-ФЗ текста + (~440 симв.) потребовало И сжать существующий HUD-хром (margin-top + 6→4pt, padding-top 8→6pt, line-height мета/wordmark строк 1.35→1.15, + градиент-разделитель margin 6pt 0→3pt 0 — экономия ~13pt), И минимально + поднять @page margin-bottom (19mm→21mm, +2mm/+5.67pt) — сжатия одного + подвала было недостаточно без деградации до нечитаемого. Риск: margin-bottom + режет тело КАЖДОЙ из 4 страниц → потенциальный откат к 5-й почти пустой + странице (регрессия, чинившаяся в 42a50cf8) — реальным рендером + (WeasyPrint/Pango, недоступен на Windows-деве) не подтверждено, см. PR. """ note_html = "" if page_note: @@ -566,18 +616,18 @@ def _page_footer( # тела страницы) и был источником сложности; заменён на простую тонкую # градиентную линию-разделитель между строками meta/wordmark. return f""" -
+
{note_html} -
+ color:{_MUTED_2};line-height:1.15;">
ОТЧЁТ {_html.escape(report_num)} ДАТА {report_date.strftime("%d.%m.%Y")} {valid_until_html}
-
@@ -587,11 +637,13 @@ def _page_footer( font-size:{_FS_SM};font-weight:600;letter-spacing:0.28em;color:{_BODY_2}; min-width:0;overflow-wrap:anywhere;"> {_html.escape(brand.name).upper()} - - {_REPORT_ENGINE_VERSION}
+
+ {_html.escape(_PDF_135FZ_FOOTER_NOTICE)} +
""" @@ -1050,6 +1102,19 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s ) report_num = _report_number(estimate) + # PR-D1: «Ссылка доступна до …» — срок жизни ОПЛАЧЕННОГО доступа + # (retain_until), НЕ путать со «Срок действия данных» (expires_at, + # актуальность расчёта) над ней — эта строка не трогается. Рендерится + # ТОЛЬКО когда retain_until IS NOT NULL (неоплаченные — весь текущий + # трафик — не видят этой строки вообще, поведение бит-в-бит текущее). + retain_until_row = ( + f'Ссылка доступна до' + f'' + f"{_mono(estimate.retain_until.date().strftime('%d.%m.%Y'))}" + if estimate.retain_until is not None + else "" + ) + # Короткий адрес (для cover): берём первую часть до запятой full_address = input_snapshot.get("address", "—") address_short = full_address.split(",")[0:3] @@ -1146,6 +1211,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s {_mono(today.strftime("%d.%m.%Y"))} Срок действия данных до {_mono(expires.strftime("%d.%m.%Y"))} + {retain_until_row} Адрес{address} Год постройки {year_label} @@ -1229,11 +1295,71 @@ def _deals_range(deals: list[AnalogLot], fallback: tuple[int, int]) -> tuple[int return min(prices), max(prices) +def _deals_sourced_thin_listings_note_html(estimate: AggregatedEstimate) -> str: + """#pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): honest + footnote for the specific case n_analogs==0 (headline ceded to the ДКП deals + corridor, estimator.py `deals_headline_due_to_thin_listings`) BUT + estimate.analogs is non-empty (the thin listings that triggered the cession + are still shown below as reference cards — never cleared, see estimator.py + #1871 ghost-anchor guard). Same tone/plain-sentence style as the web + LowConfidenceBanner for this scenario. Empty string (no-op) otherwise — + covers both "healthy sample" and "genuinely zero, nothing to show" cases.""" + if estimate.n_analogs != 0 or not estimate.analogs: + return "" + return ( + f'

' + "Оценка построена по зарегистрированным сделкам Росреестра — подходящих " + "объявлений поблизости почти нет. Объявления ниже приведены справочно, " + "для наглядности рынка.

" + ) + + +def _reliability_note_html(estimate: AggregatedEstimate, n_shown: int) -> str: + """#pdf-honesty: surfaces `AggregatedEstimate.relaxations`/`reliability` + (estimator.py #oblast-F cascade + #oblast-E deals-priority) — the web report + already shows this (LowConfidenceBanner); the PDF stayed silent, a + client-visible discrepancy between the two. Empty string (no-op) when + reliability=='ok' and relaxations is empty — the common, unrelaxed case, + byte-identical to the report before these fields existed.""" + if estimate.reliability == "ok" and not estimate.relaxations: + return "" + if estimate.relaxations: + detail = "Подбор аналогов расширен: " + ", ".join( + _html.escape(r) for r in estimate.relaxations + ) + else: + # relaxations пуст, но reliability всё же не 'ok' (напр. тонкая выборка, + # которую каскад ослаблений не смог расширить, см. estimator.py + # #oblast-F) — n_shown, не сырой n_analogs (та же #pdf-honesty логика, + # что и в счётчике выше страницы). + detail = f"Оценка построена по небольшой выборке ({n_shown} шт.)" + return f""" +
+ Точность оценки снижена. + {detail} — данные ниже приведены с этой оговоркой. +
+""" + + # ── Page 2: Listings (market) ──────────────────────────────────────────────── def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg] - n_total = estimate.n_analogs + # #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10): raw + # estimate.n_analogs is the count of listings that drove the HEADLINE math — + # it is deliberately 0 when the headline was ceded to the ДКП deals corridor + # (estimator.py `deals_headline_due_to_thin_listings`), even though the thin + # listings that triggered that cession are still shown below as display cards + # (estimate.analogs — never cleared, see estimator.py #1871 ghost-anchor + # guard comment). Printing raw n_analogs there read as "0 шт." above a + # non-empty examples table — a client-visible contradiction. n_analogs is + # normally >= len(analogs) (analogs is a top-10-capped SUBSET of what + # n_analogs counts, see AnalogLot/AggregatedEstimate docstring) — max() is a + # no-op in that common case (count stays the honest FULL n_analogs) and only + # changes anything in this one pathological case, where it falls back to + # "how many are actually shown" instead of the dishonest zero. + n_total = max(estimate.n_analogs, len(estimate.analogs)) # #1531: убрана строка-дубль «(с учётом ремонта)». Estimator НЕ фильтрует # аналоги по repair_state (coverage listings.repair_state ~2%, см. estimator.py:160), # а лишь применяет ценовой коэффициент к медиане/диапазону — поэтому отдельного @@ -1292,6 +1418,10 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra examples_rows = _examples_rows(top5) heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ") + # #pdf-honesty — see helper docstrings above. Both no-op ("") in the common + # (unrelaxed, non-deals-sourced) case — byte-identical page in that case. + deals_sourced_note = _deals_sourced_thin_listings_note_html(estimate) + reliability_note = _reliability_note_html(estimate, n_total) return f"""
@@ -1306,6 +1436,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra Количество объявлений по аналогичным объектам {_mono(f"{n_total} шт.")} + {deals_sourced_note}
Источники данных
{sources_html}
@@ -1330,6 +1461,7 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra + {reliability_note}

Диапазон цен в объявлениях

@@ -1422,11 +1554,41 @@ def _examples_rows(lots: list[AnalogLot]) -> str: # ── Page 3: Deals ──────────────────────────────────────────────────────────── +_ROMAN_QUARTER = ("I", "II", "III", "IV") + + +def deals_as_of_label(estimate: AggregatedEstimate) -> str | None: + """#2846: «по I кв. 2026» — до какого момента доходят ПОКАЗАННЫЕ сделки. + + Раньше страница печатала «Период сделок: 08.2025 – 08.2026» — окно ПОИСКА, + посчитанное как `today - period_months*30 … today`. Правым концом оно обещало + сделки сегодняшним днём, тогда как свежайшая пачка Росреестра на проде + (замер 2026-08-12) — I кв. 2026. Считаем по estimate.actual_deals, т.е. ровно + по тем сделкам, из которых страница строит диапазон и таблицу примеров. + + Гранулярность — квартал: rosreestr пишет deal_date = первый день квартала + (#1995, ровно то, что помечает AnalogLot.date_precision == "quarter"). + Поэтому «223 дня назад» было бы ЛОЖНОЙ точностью в сторону состаривания — + сделка из этой пачки могла быть и 31 марта. Источник с day-precision (пока + такого нет) подписывается месяцем. + + None — сделок нет либо ни у одной нет даты: подписывать нечего. + """ + dated = [(d.listing_date, d.date_precision) for d in estimate.actual_deals if d.listing_date] + if not dated: + return None + newest, precision = max(dated, key=lambda p: p[0]) + if precision == "day": + return f"по {newest.strftime('%m.%Y')}" + return f"по {_ROMAN_QUARTER[(newest.month - 1) // 3]} кв. {newest.year}" + def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg] n_deals = len(estimate.actual_deals) - today = dt.date.today() - period_start = today - dt.timedelta(days=estimate.period_months * 30) + # #2846: «Период сделок» показывал окно поиска правым концом = сегодня. + # Реальная граница — as-of по показанным сделкам; окна поиска на странице + # больше нет (оно ничего не говорило о данных). None → строку не печатаем. + deals_as_of = deals_as_of_label(estimate) # Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа # (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%», @@ -1503,9 +1665,9 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) - - + {f''' + ''' + if deals_as_of else ""}
Количество сделок по аналогичном объектам {_mono(f"{n_deals} шт.")}
Период сделок - {_mono(f"{period_start.strftime('%m.%Y')} – {today.strftime('%m.%Y')}")}
Сделки{_mono(deals_as_of)}
Источники данных
@@ -1838,7 +2000,11 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def] }} @page {{ size: A4; - margin: 25mm 18mm 19mm 18mm; + /* bottom 19mm→21mm (#footer-height-budget, Блок 4.2): +2mm — минимум, + которого не хватило внутри @bottom-center margin-box (высота margin-box + = margin-bottom) даже после сжатия HUD-хрома _page_footer под 135-ФЗ + дисклеймер на каждой странице. См. арифметику в _page_footer(). */ + margin: 25mm 18mm 21mm 18mm; @top-center {{ content: element(runningHeader); vertical-align: bottom; }} @bottom-center {{ content: element(runningFooter); vertical-align: top; }} }} diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 1bf0817f..2d3c7096 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,30 @@ 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 + # #2626: True если результат дал ПОСЛЕДНИЙ локальный тир — fallback на `houses` + # (скрейпленные листинги, см. `_local_houses_match`) — а не Nominatim/geoportal/ + # cadastral. Срабатывает, когда в тексте адреса опечатка/сокращение улицы + # («Онуфриева» вместо канонического «Начдива Онуфриева» в ГАР) или отсутствует + # корпус («49» вместо реального «49к1») — houses-фолбэк нашёл ОДНОЗНАЧНЫЙ дом по + # нормализованному совпадению. Честный сигнал вызывающему коду «адрес уточнён + # автоматически», НЕ эвристика на корректность — см. `geocode()`/`_local_houses_match`. + # Houses-фолбэк НЕ пишет свой результат в `geocode_cache` (менее надёжный + # источник координат, чем geoportal/cadastral/Nominatim — #2626 review R2 #4), + # поэтому этот сигнал переживает КАЖДЫЙ повторный запрос того же сырого + # адреса. `geocode_cache` вообще не хранит этот флаг (схему не трогаем) — + # если бы houses-хит когда-нибудь попал в кэш, на cache-hit `address_refined` + # вернулся бы `False` (та же судьба у `city_ambiguous` при cache-hit — см. + # `_geocode_resolve`, восстанавливается `replace()` из текущего вызова, а не + # из кэша). + address_refined: bool = False # ── EKB bounding boxes ─────────────────────────────────────────────────────── @@ -82,9 +104,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 +121,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 +172,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 +256,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 +326,245 @@ 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)) + + +# ── Постфактум-инвариант подмены города (#2590) ────────────────────────────── +# Гейты выше (#2582/#2589) стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only тиры. +# Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, и +# тот, не найдя Режа, отдаёт улицу Ленина в Екатеринбурге. Отличить на входе +# «Реж» (город) от «Малышева» (улица) без списка городов нельзя — оба «слово до +# запятой». ПОСЛЕ ответа можно: провайдер сам пишет, какой населённый пункт он +# использовал, и если названный топоним туда не дожил — топоним подменён. +# +# Инвариант (#2590): назван топоним ≠ Екатеринбург + его нет в ответе провайдера +# + ответ лежит внутри ЕКБ ⇒ результат недостоверен. Ни одного имени города в +# коде — только уровни РФ-адреса (страна → регион → район → НП → улица → дом) и +# сам целевой город. +_ADDRESS_SEGMENT_RE = re.compile(r"[,;·]") +# Страна: сегмент выше уровня НП. Единственная константа-топоним помимо целевого +# города — продукт РФ-only, новых значений у неё не появится (в отличие от +# списка городов области, ради ухода от которого всё и делается). +_COUNTRY_RE = re.compile(r"\b(?:росси[яи]|russia)\b") +# Уровень «улица/дом»: дойдя до него, НП уже был бы назван (порядок РФ-адреса +# big→small). Дальше идти нельзя — иначе второй уличный сегмент («малышева, +# мопра» — перекрёсток) читается как топоним и ложно отбраковывается. +_STREET_LEVEL_RE = re.compile( + r"\b(?:ул|улица|пер|переулок|пр|пр-кт|пркт|проспект|б-р|бульвар|ш|шоссе|наб|набережная" + r"|пл|площадь|проезд|тракт|аллея|тупик|туп|линия|кв-л|квартал|стр|строение|дом|корп" + r"|корпус|лит|литера|снт|сад|гск)\b" +) +# Уровни ВЫШЕ и НИЖЕ населённого пункта — пропускаем и идём дальше по сегментам: +# «свердловская обл., г.о. рефтинский» (регион → НП), «мкр-н широкая речка, ул. +# …» (район ВНУТРИ города — его провайдер в ответе обычно не повторяет). +_REGION_LEVEL_RE = re.compile(r"\b(?:обл\.?|область|края|край|республика|респ\.?|ао)\b") +_DISTRICT_LEVEL_RE = re.compile(r"\b(?:р-н|р-он|район|мкр|мкр-н|микрорайон|жк|жилой)\b") +# Слова-ТИПЫ НП (не имя): «пос. Кедровка» → имя «кедровка». Тип не сравнивается +# с ответом — провайдер пишет свой («посёлок» vs «пос.»), имя же обязано дожить. +_LOCALITY_TYPE_WORDS = frozenset( + { + "поселок", + "пос", + "село", + "деревня", + "дер", + "город", + "гор", + "округ", + "муниципальный", + "городской", + "сельское", + "поселение", + "тер", + "территория", + "станция", + "пгт", + "рп", + } +) +_WORD_RE = re.compile(r"[а-я][а-я-]*") + + +def _fold(value: str) -> str: + """lower + ё→е + схлопывание пробелов — общий канон для сравнения топонимов.""" + return " ".join(value.lower().replace("ё", "е").split()) + + +def _claimed_locality(address: str) -> str | None: + """Имя населённого пункта, названное в тексте адреса, или None. + + Структурно, БЕЗ перечисления городов: идём по сегментам в порядке РФ-адреса + (страна → регион → район → НП → улица → дом), пропускаем уровни выше/ниже + НП, останавливаемся на уровне улицы/дома. Первый оставшийся сегмент — имя НП. + + None означает «НП не назван» — это основной трафик формы («Малышева 30»), и + для него инвариант не применяется вовсе. + """ + for segment in _ADDRESS_SEGMENT_RE.split(_fold(address)): + segment = segment.strip() + if not segment: + continue + if any(ch.isdigit() for ch in segment) or _STREET_LEVEL_RE.search(segment): + return None # улица/дом: будь НП назван, он шёл бы раньше + if ( + _COUNTRY_RE.search(segment) + or _REGION_LEVEL_RE.search(segment) + or _DISTRICT_LEVEL_RE.search(segment) + ): + continue + name = " ".join( + w for w in _WORD_RE.findall(segment) if w not in _LOCALITY_TYPE_WORDS and len(w) >= 3 + ) + if name: + return name + return None + + +def _city_substituted(address: str, result: GeocodeResult) -> bool: + """True если провайдер подменил названный в адресе НП Екатеринбургом (#2590). + + Три условия вместе: + 1. в адресе назван НП и это не Екатеринбург (`_claimed_locality`); + 2. этого имени НЕТ в адресе, который вернул провайдер — то есть топоним не + пережил геокодинг; + 3. результат лежит внутри ЕКБ: и по координатам (`EKB_BBOX_TIGHT`), и по + собственному ответу провайдера — он называет Екатеринбург либо не + называет НП вовсе (ЕКБ-only локальные реестры отдают «Улица, дом»; + тогда «внутри ЕКБ» подтверждают координаты). + + Условие 3 и разводит подмену с посёлками в городской черте. «пос. Кедровка, + Советская ул., 5» → ответ «Екатеринбург, Советская улица, 5» — имя не дожило, + и это ПРАВДА подмена: настоящая Кедровка в 20 км от улицы Советской. А + корректный ответ по посёлку («Кедровка, Екатеринбург, …» — Nominatim и Yandex + пишут НП всегда, когда действительно его нашли) имя сохраняет и через фильтр + не проходит. Проверяется не география посёлка, а факт «топоним потерян». + + Известный потолок: НП, чьё имя совпало с уличным токеном ответа («Ачит» ⊂ + «М-12 Ачит-Екатеринбург», «Лесной» ⊂ «Лесной переулок»), считается дожившим — + пропуск, не ложная отбраковка. Обратный потолок: жилрайон ЕКБ, названный без + приставки («пионерский, советская»), понижается до `locality` — честная + деградация, координаты не теряются. + """ + claimed = _claimed_locality(address) + if claimed is None or _EKATERINBURG_RE.search(claimed): + return False + answer = _fold(result.full_address or "") + if any(word in answer for word in claimed.split()): + return False # топоним дожил до ответа — провайдер искал там, где просили + if not is_within_ekb_bbox(result.lat, result.lon): + return False + answer_locality = _claimed_locality(answer) + return answer_locality is None or bool(_EKATERINBURG_RE.search(answer_locality)) + + +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 +574,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 = "лнмссккттпп" @@ -385,13 +742,38 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No return oblast_fallback -@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) -async def _nominatim_lookup(address: str) -> GeocodeResult | None: +# reraise=True (GlitchTip-noise fix): без него tenacity на исчерпанных ретраях +# бросает СВОЙ tenacity.RetryError, чей str() тащит repr() последнего Future +# (`` — адрес объекта в памяти, разный на КАЖДЫЙ вызов). GlitchTip +# группирует по этому нестабильному тексту → одна и та же причина (Nominatim +# недоступен/rate-limit) плодила отдельный issue на каждое исчерпание ретраев +# (2 462 issue из 7 461 в трекере). reraise=True пробрасывает РЕАЛЬНОЕ исключение +# (httpx.HTTPStatusError/TimeoutException) — стабильный ТИП+стек. НО httpx.HTTPStatusError +# сам несёт нестабильный ТЕКСТ (str() содержит полный request URL, включая query +# string с адресом — `for url '...search?q=<адрес>&...'`) — group-стабильность на +# ЭТОМ пути держит `_HTTPX_ERROR_URL_QUERY_RE` в app/observability/sentry_scrub.py +# (`scrub_pii_event`, часть before_send-композиции обоих entrypoint), которая режет +# query string из httpx-style "for url '...'" сообщений (GlitchTip-noise review +# round 2, claim #1 — reraise=True сам по себе НЕ закрывает per-address explosion). +# Отдельно — `stabilize_retry_error_fingerprint` (та же sentry_scrub.py) на случай +# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё +# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit). +@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True) +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 +781,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 +811,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 +822,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 +840,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 +879,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 +898,99 @@ 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] + + +# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум). +@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True) +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 +1161,86 @@ 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 → `_local_houses_match` (#2626, houses-фолбэк) + → None. Тира DaData тут НЕТ. + * `suggest()` : cadastral → DaData → Nominatim (единственный вызов + `_dadata_suggest`). + То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`) + адрес с литерой, неизвестный ни геопорталу, ни Nominatim, ни houses-фолбэку, + даёт 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 +1254,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( @@ -1043,6 +1344,283 @@ def _geoportal_house_match(db: Session, street: str, house: str) -> GeocodeSugge ) +# ── Local `houses` fallback (#2626) — последний тир geocode() ─────────────── +# Мотивация: 28/1084 прод-оценок с lat IS NULL — гарантированный ноль аналогов, +# клиент не получает оценку вовсе. Живые примеры (адрес пользователя → ГАР/houses): +# «ул Крестинского, д 49» — «49» голого нет в houses, есть только «49к1» +# (корпус потерян при вводе, houses id 9980 «улица Крестинского, 49к1»); +# «ул Онуфриева, д 24» — houses называет улицу «Начдива Онуфриева» (ГАР), +# пользователь пишет только последнее слово имени. +# Дом уже ЕСТЬ в `houses` (скрейпленные листинги avito/cian/derived/yandex) с +# координатами — Nominatim и ЕКБ-реестры (geoportal/cad_buildings) эти формы не +# резолвят, а houses чаще содержит именно то написание, которым реально пользуются +# люди (агрегировано из объявлений, а не из официального ГАР). +# +# Номер дома в `houses.address` — СВОБОДНЫЙ текст источников (avito/cian/derived/ +# yandex_valuation): «улица X, 49к1» / «X ул.,88/2» / «X, 44» — БЕЗ единого формата +# и без «д./дом»-маркера, в отличие от `gendesign_cad_buildings.readable_address`. +# Поэтому здесь — собственная, более широкая нормализация номера (со слэшем +# «88/2» и корпусом «49к1»), а НЕ переиспользование `_HOUSE_NUM`/`_norm_house` +# (те заточены под geoportal/cad_buildings реестры, где «/N» и «корпус N» реже). +_LOCAL_HOUSE_TOKEN_RE = re.compile( + r"(\d+(?:\s*/\s*\d+)?(?:\s*-?\s*(?:к|корп\.?|корпус)\.?\s*-?\s*\d+)?(?:\s*-?\s*[а-яё])?)", + re.IGNORECASE, +) + + +def _norm_local_house(raw: str) -> str: + """Канон номера дома для houses-фолбэка. + + «49 к 1» / «49-к1» / «49 корпус 1» → «49к1»; «88 / 2» → «88/2»; «35А» → «35а». + """ + s = raw.strip().lower() + s = re.sub(r"\s+", "", s) + s = re.sub(r"корпус|корп\.?", "к", s) + s = re.sub(r"-(к\d+)", r"\1", s) + s = re.sub(r"-([а-яё])$", r"\1", s) + return s + + +# Хвостовой мусор ПОСЛЕ номера дома — квартира/офис/помещение/подъезд/этаж. +# НЕ включает «корп/корпус/к» (в отличие от `_RE_APT_TAIL` выше) — корпус тут +# ЧАСТЬ номера дома, который должен остаться видимым для `_LOCAL_HOUSE_TOKEN_RE` +# («49к1», «26 к 1» — корпус нельзя терять). Без этой зачистки +# `_extract_local_house_token` (берёт ПОСЛЕДНЕЕ число в строке) находит номер +# квартиры/этажа вместо дома — прод-баг #2626 review R2 #1: «...Педагогическая, +# д 15, кв 11» отдавал дом «11» (координаты ЧУЖОГО здания) вместо «15». +_RE_LOCAL_APT_TAIL = re.compile( + r"[,\s]\s*(?:кв|квартира|оф|офис|пом|помещение|лит|подъезд|этаж)\.?\s*\d.*$", + re.IGNORECASE, +) + + +def _extract_local_house_token(address: str) -> str | None: + """Номер дома из ПОЛЬЗОВАТЕЛЬСКОГО адреса — с учётом «/N» и «корпус N» хвостов, + которые `_parse_street_house`/`_HOUSE_NUM` обрезают (см. коммент у + `_LOCAL_HOUSE_TOKEN_RE`). Берём ПОСЛЕДНЕЕ совпадение — номер дома в русском + адресе почти всегда в хвосте строки. None, если цифр нет вовсе. + + Квартирный/этажный/подъездный хвост зачищается ДО поиска номера + (`_RE_LOCAL_APT_TAIL`) — иначе «последнее число в строке» это номер + квартиры/этажа, а не дома (см. докстринг у `_RE_LOCAL_APT_TAIL`). + """ + s = _RE_POSTAL.sub(" ", " ".join(address.lower().strip().split())).strip(" ,.") + if not s: + return None + s = _RE_LOCAL_APT_TAIL.sub(" ", s).strip(" ,.") + if not s: + return None + matches = list(_LOCAL_HOUSE_TOKEN_RE.finditer(s)) + if not matches: + return None + return _norm_local_house(matches[-1].group(1)) + + +# Маркеры района/города/страны — обрезаются из `houses.address` перед сравнением +# улицы (`_clean_local_house_street`). Хвостовое сравнение (см. ниже) и без этого +# устойчиво к ЛИШНЕМУ префиксу («р-н Ленинский, мкр. Юго-Западный, улица X» всё +# равно оканчивается на «... улица x» и матчит суффиксом), но тип улицы ПОСЛЕ +# имени («Хрустальногорская ул.») ломает суффикс без явной зачистки типа. +# Хвостовой якорь — lookahead на пробел/конец строки, а НЕ `\b`: «ул.» в самом +# конце сегмента (частая форма в houses.address) заканчивается точкой, а `\b` +# сразу после точки на границе строки не срабатывает (оба «символа» не-\w) — +# тип-слово матчилось бы БЕЗ точки, точка оставалась бы висеть («хрустальногорская .») +# и ломала «хвостовое» сравнение улицы (реальный прод-кейс: id 13080 houses). +_LOCAL_HOUSE_STREET_TYPE_RE = re.compile(rf"\b(?:{_STREET_TYPE})\.?(?=\s|$)", re.IGNORECASE) + + +def _clean_local_house_street(segment: str) -> str: + """«Хрустальногорская ул.» / «улица Начдива Онуфриева» → «хрустальногорская» / + «начдива онуфриева»: lower, без типа улицы, схлопнутые пробелы. + + Общая нормализация и для запроса пользователя (уже typeless из + `_parse_street_house`, но повторный проход — no-op), и для `houses.address`. + """ + s = _LOCAL_HOUSE_STREET_TYPE_RE.sub(" ", segment.lower()) + return " ".join(s.split()) + + +def _row_local_house(address: str) -> tuple[str, str] | None: + """Разбирает ОДНУ строку `houses.address` на (street_clean, house_norm). + + Номер дома — ПОСЛЕДНИЙ через-запятую сегмент (во всех живых формах: «X, 49к1», + «X ул.,88/2», «X, 44»), СОВПАДЕНИЕ С НАЧАЛА этого сегмента (не всей строки) — + покрывает и «49к1» целиком, и «35к1 · р-н Академический» (хвостовой мусор + после номера отбрасывается). Известный неполный случай (не встретился в + выборке): номер дома БЕЗ запятой перед ним — вернёт None, строка просто не + станет кандидатом (не ложный матч). + """ + segments = [s.strip() for s in address.split(",") if s.strip()] + if len(segments) < 2: + return None + m = _LOCAL_HOUSE_TOKEN_RE.match(segments[-1]) + if not m: + return None + house_norm = _norm_local_house(m.group(1)) + street_norm = _clean_local_house_street(" ".join(segments[:-1])) + if not street_norm or not house_norm: + return None + return street_norm, house_norm + + +def _street_tail_matches(row_street_norm: str, query_street_norm: str) -> bool: + """True если `query_street_norm` — «хвост» (последнее слово/слова) имени улицы + в `houses` — «онуфриева» находит «начдива онуфриева» (ГАР-каноничное имя), + регистронезависимо. Точное равенство тоже проходит (частый случай — короткие + однословные улицы, «Малышева» == «Малышева»).""" + return row_street_norm == query_street_norm or row_street_norm.endswith(" " + query_street_norm) + + +# «24к1» → «24» (базовый номер варианта с корпусом/слэшем); «44» (голый номер, +# без суффикса) → None. Используется ТОЛЬКО для sibling-guard (см. ниже) — +# отличить «этот дом однозначно к1» от «этого дома несколько корпусов, а у +# нас в вводе просто нет данных, какой именно». +_LOCAL_HOUSE_VARIANT_BASE_RE = re.compile(r"^(\d+)(?:к\d+|/\d+)$") + + +def _local_houses_match(db: Session, street: str, house: str) -> GeocodeSuggestion | None: + """Последний локальный тир `geocode()` (#2626) — fallback на `houses` + (скрейпленные листинги avito/cian/derived/yandex, own DB table, БЕЗ FDW). + + Вызывается ТОЛЬКО когда geoportal/cadastral/Nominatim уже не дали результата. + Допущения, все defensive (при неоднозначности — None, не гадаем): + + 1. Улица матчится «по хвосту» (`_street_tail_matches`) — ловит расхождение + разговорного/сокращённого имени («Онуфриева») и канонического ГАР-имени в + houses («Начдива Онуфриева»). + 2. Координаты строки-кандидата обязаны лежать в широком ЕКБ-bbox + (`is_within_ekb_bbox_wide`) — `houses` НЕ ЕКБ-only реестр (в отличие от + geoportal/cad_buildings): 21% строк с координатами лежат вне области ЕКБ, + местами вплоть до другого региона (#2626 review R2 #2 — прод-пример + «улица Маяковского, 7» в houses это Серов, а не запрошенный + Екатеринбург). `use_local_ekb` в `geocode()` гейтит только ЗАПРОС + пользователя, не страхует от грязной строки-источника. + 3. Номер дома — сперва точное совпадение; нет — пробуем `<номер>к1` (частый + случай: пользователь ввёл «49», у дома есть только корпус «49к1»), но + ТОЛЬКО если среди кандидатов улицы НЕТ других корпусов/дробей этого же + номера («24к2», «24/2» и т.п.) — иначе «к1» такая же угадайка, как и + любой другой корпус, и реальные дома могут быть в 250-400м друг от друга + (#2626 review R2 #3, прод-пример «Начдива Онуфриева, 24»: 24к1/24к2/24к3 + — три разных здания). + 4. ЛЮБОЙ шаг, где кандидатов больше одного (после дедупа по округлённым + координатам — разные source-строки ОДНОГО дома не в счёт), возвращает + None — угадывать нельзя. + + SQL — дешёвый ILIKE-префильтр по последнему слову улицы (нет индекса на + `houses.address`, но тир последний и редкий — не на каждый запрос) с + детерминированным ORDER BY (дедуп по координатам иначе непредсказуемо + выбирал бы, какая из двух ~идентичных source-строк станет ответом — + #2626 review R2 #5); вся точная логика (суффикс улицы, bbox, равенство + номера) — в Python, что и делает её юнит-тестируемой без реальной БД + (см. `test_geocoder_local_houses_fallback.py`). + + Результат этого тира НЕ кэшируется в `geocode_cache` вызывающей стороной + (см. `geocode()`) — `houses`-координаты из скрейпленных объявлений менее + надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый и локальный + (#2626 review R2 #4). + """ + query_street_norm = _clean_local_house_street(street) + if not query_street_norm: + return None + query_house_norm = _norm_local_house(house) + if not query_house_norm: + return None + last_word = query_street_norm.split()[-1] + + try: + rows = db.execute( + text(""" + SELECT address, lat, lon + FROM houses + WHERE address ILIKE CAST('%' || :w || '%' AS text) + AND lat IS NOT NULL AND lon IS NOT NULL + ORDER BY address, id + """), + {"w": last_word}, + ).fetchall() + except Exception: + logger.warning( + "local houses fallback query failed for street=%r house=%r", + street, + house, + exc_info=True, + ) + return None + + # Street-tail + bbox фильтр — один проход, дальше переиспользуется и для + # точного совпадения, и для corpus-1 догадки, и для sibling-guard. + street_rows: list[tuple[str, float, float, str]] = [] # (house_norm, lat, lon, addr) + for r in rows: + parsed = _row_local_house(str(r.address or "")) + if parsed is None: + continue + row_street_norm, row_house_norm = parsed + if not _street_tail_matches(row_street_norm, query_street_norm): + continue + lat, lon = float(r.lat), float(r.lon) + if not is_within_ekb_bbox_wide(lat, lon): + continue + street_rows.append((row_house_norm, lat, lon, str(r.address))) + + def _candidates(house_norm: str) -> list[tuple[str, float, float]]: + out: list[tuple[str, float, float]] = [] + seen_coords: set[tuple[float, float]] = set() + for row_house_norm, lat, lon, addr in street_rows: + if row_house_norm != house_norm: + continue + coord_key = (round(lat, 4), round(lon, 4)) # ~11m — дедуп источников + if coord_key in seen_coords: + continue + seen_coords.add(coord_key) + out.append((addr, lat, lon)) + return out + + exact = _candidates(query_house_norm) + if len(exact) == 1: + addr, lat, lon = exact[0] + return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house") + if len(exact) > 1: + logger.info( + "local houses fallback: %d неоднозначных кандидата для %r %r — skip", + len(exact), + street, + house, + ) + return None + + # Точного номера нет — пробуем «<номер>к1» (корпус потерян при вводе), ТОЛЬКО + # если запрошенный номер — голое число (не пытаемся достраивать «49/2» → «49/2к1»). + if query_house_norm.isdigit(): + corpus1 = f"{query_house_norm}к1" + siblings = { + row_house_norm + for row_house_norm, _lat, _lon, _addr in street_rows + if row_house_norm != corpus1 + and (m := _LOCAL_HOUSE_VARIANT_BASE_RE.match(row_house_norm)) is not None + and m.group(1) == query_house_norm + } + if siblings: + logger.info( + "local houses fallback: корпус-1 %r неоднозначен — есть другие " + "корпуса/дроби %s — skip", + corpus1, + sorted(siblings), + ) + return None + guessed = _candidates(corpus1) + if len(guessed) == 1: + addr, lat, lon = guessed[0] + logger.info("local houses fallback: %r → корпус-1 %r (%s)", house, corpus1, addr) + return GeocodeSuggestion(label=addr, full_address=addr, lat=lat, lon=lon, kind="house") + if len(guessed) > 1: + logger.info( + "local houses fallback: корпус-1 %r неоднозначен (%d кандидата) — skip", + corpus1, + len(guessed), + ) + return None + + def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int = 200) -> str | None: """Reverse lookup via gendesign_cad_buildings FDW. @@ -1086,24 +1664,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 +1707,110 @@ 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: + """Геокодинг с кэшем + постфактум-проверка подмены города (#2590). + + Тонкая обёртка над `_geocode_resolve` (вся тировая цепочка там). Инвариант + применяется ОДНОЙ точкой на выходе — поэтому покрывает разом все источники, + включая попадание в кэш: отравленная запись, записанная до этого фикса, + больше не отдаётся как точная, хотя строка в `geocode_cache` не тронута + (обратимо: откат кода возвращает прежнее поведение, чистить БД не требуется). + + Сработал инвариант → `confidence="locality"` + `city_ambiguous=True`. + `locality` — не косметика: `estimator._geocode_is_coarse` уже трактует его + как «геокодер дошёл только до центра НП» и (а) включает #693 coarse-downgrade + оценки, (б) через `tasks.geocode_missing` проставляет листингу + `geo_precision='city'`, а этот признак исключает листинг из пула аналогов + (`estimator`/`location_index`: `geo_precision IS DISTINCT FROM 'city'`). + То есть объявление, уехавшее координатами в чужой город, перестаёт тянуть + за собой чужие оценки. Координаты НЕ выбрасываются — деградация честная и + видимая, а не отказ. + """ + result = await _geocode_resolve(address, db, city_hint) + if result is None or not _city_substituted(address, result): + return result + logger.warning( + "geocode city substitution (#2590): %r → %r (%.5f, %.5f) provider=%s — " + "названный НП не дожил до ответа, результат внутри ЕКБ; confidence→locality", + address[:80], + (result.full_address or "")[:80], + result.lat, + result.lon, + result.provider, + ) + return replace(result, confidence="locality", city_ambiguous=True) + + +async def _geocode_resolve( + 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 +1827,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 +1852,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 +1876,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 +1884,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 — спим после успешного запроса @@ -1288,14 +1897,58 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None: except Exception: logger.exception("nominatim geocoder failed") + # 4. Local `houses` fallback (#2626) — САМЫЙ ПОСЛЕДНИЙ тир, до возврата None. + # 28/1084 прод-оценок имели lat IS NULL (гарантированный ноль аналогов) — дом + # был в `houses` (скрейпленные листинги), но не в geoportal/cad_buildings и не + # резолвился Nominatim'ом (разговорное/усечённое имя улицы или отсутствующий + # в вводе корпус). См. `_local_houses_match`. EKB-only гейт — тот же, что у + # geoportal/cadastral (houses — преимущественно ЕКБ-трафик, тот же риск + # коллизии улица+дом с другим городом региона, что и мотивировал #2582); + # координаты строки-кандидата ДОПОЛНИТЕЛЬНО проверяются bbox-ом внутри + # `_local_houses_match` (гейт здесь фильтрует только запрос пользователя, + # не грязь в самой таблице — #2626 review R2 #2). + if use_local_ekb and parsed is not None: + local_street, _parsed_house = parsed + local_house = _extract_local_house_token(address) or _parsed_house + hit = await asyncio.to_thread(_local_houses_match, db, local_street, local_house) + if hit is not None: + result = GeocodeResult( + lat=hit.lat, + lon=hit.lon, + full_address=hit.full_address, + provider="cache", # локальный DB-lookup, без внешнего HTTP — как geoportal + confidence="exact", + city_ambiguous=city_ambiguous, + address_refined=True, + ) + # НЕ кэшируем: houses-координаты (скрейпленные листинги) менее + # надёжны, чем geoportal/cadastral/Nominatim, а сам lookup дешёвый + # и локальный — кэш только продлевал бы жизнь возможной ошибке + # источника (#2626 review R2 #4). Побочный эффект: `address_refined` + # переживает КАЖДЫЙ повторный запрос этого сырого адреса, а не + # только первый (было известным пределом до этого фикса). + logger.info( + "geocode local houses fallback: %s → (%.5f, %.5f) [%s]", + addr_norm, + result.lat, + result.lon, + hit.full_address, + ) + return result + return 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 +1961,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 +1977,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,78 +2004,8 @@ 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)) +# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум). +@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True) async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None: """Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon. @@ -1521,7 +2104,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 +2129,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..511386a1 100644 --- a/tradein-mvp/backend/app/services/house_dedup_merge.py +++ b/tradein-mvp/backend/app/services/house_dedup_merge.py @@ -13,8 +13,10 @@ WHAT this is: pipeline, run inside ONE transaction so a crash leaves the table untouched. Cluster key: CANONICAL address via tradein_canon_addr() over the CLEAN address - COALESCE(short_address, full_address, address) (cadastral_number is 100% NULL on prod — - confirmed in migration 040 — so address is the real building key). The clean source matters: + COALESCE(short_address, full_address, address) — the address is the only building key we + have (why: the KEY section below; the older claim here, «cadastral_number is 100% NULL on + prod», is no longer true — 2 648 of 9 179 rows carry one — and the conclusion no longer + rests on it). The clean source matters: `address` can carry район-noise the canon does not strip (e.g. «улица Вайнера, 66 · р-н Центр» → canon «вайнера66рнцентр»), while `short_address` holds the clean «улица Вайнера, 66» (→ «вайнера66») — preferring the clean field lets such a row cluster with its twin. The canon @@ -92,6 +94,67 @@ BACKFILL (reduces recurrence): (same as 108) so the matching pipeline's Tier-1/Tier-2 finds the keeper next scrape and does not immediately re-split it. +MERGE JOURNAL — the merge is REVERSIBLE (#2690, migration 230): + Every loser gets a row in `house_merge_log` written in the SAME transaction as the merge: + the full jsonb snapshot of the deleted row, the keeper's snapshot BEFORE the identity + carry-over, the ids of every child row whose FK moved, the full snapshots of every child row + a UNIQUE collision destroyed, plus the grounds — which pass, which cluster-key VALUE fired, + whether the geo guard was on, and the keeper↔loser distance in metres. + + This exists because the merge used to leave no restorable trace: losers were hard-deleted + with their children and the only record of «what went into what» was a log line, in a + container whose logs rotate faster than a day. A day after a run nobody could even NAME the + pairs, and the only rollback was restoring the whole database. + + Undo: `SELECT * FROM house_merge_undo(batch_id)` inside a transaction — restores the loser + rows, points the children back, re-inserts the destroyed children, and un-does the identity + carry-over on the keeper, reporting per record what it could and could not restore. + + NOTE the journal is deliberately NEUTRAL to the merge rule: it changes no cluster key, no + keeper rule and no guard. It only makes whatever the pass decides reversible — which is the + precondition for revisiting those decisions at all (#2690, #1772). + + distance_m is recorded on BOTH passes, including the fias pass whose geo guard is off. That + asymmetry — merge allowed without a proximity check — was invisible in data before; now + «how many merges happened beyond N metres, on which key» is one query. + +KEY — there is no second, address-independent observation. Measured on prod 2026-08-10 (#2690): + #2690 asked for a cluster key that does not come from the normalized address, so that two + rows merge on two independent statements of identity rather than one restated twice. Every + field `houses` carries was checked against the live table. None qualifies: + + cadastral_number 2 648 filled, ALL 2 648 values DISTINCT → collapses nothing. Provenance: + all 2 648 also carry dadata_enriched_at and house_fias_id, i.e. they are + DaData's answer to our address string, not a second observation of the + building. (The other cadastre we hold, listings.building_cadastral_number, + is the KNN geo-nearest hint — 20.1% of its values cover >1 ГАР building; + #2674 refused it as an identity key and that stands.) + house_fias_id 3 678 filled, ALL DISTINCT → the FIAS pass merges 0 rows today. Same + DaData provenance. + gar_house_guid the key #2690 rejected, re-measured: of 458 same-guid pairs, 441 share + the canon (the guid restates it), 17 do not — and 5 of those 17 are + >250 m apart, worst 5 064 km. Still circular, still noisy. + zhkh_house_guid looks independent (ГИС ЖКХ is an external registry) and is not: the + loader sets it WHERE gar_house_guid = , i.e. it IS the ГАР guid for + 4 268 of 4 663 rows. The 395 that differ come from the cadastre fallback + — keyed by that same KNN hint. Of its 194 pairs with a DIFFERENT canon, + 193 come through the fallback, and 30 of the 31 pairs >250 m apart do too. + source+ext_house_id, cian_internal_house_id, yandex_jk_id + distinct by construction / 39 / 0 rows → nothing to cluster. + coordinates a real independent observation, but not an IDENTITY: neighbours share a + yard. It is already used the only way it can be — as the guard. + year_built+total_floors + a FALSE witness, not a corroborator: of the 391 same-canon pairs the + guard cannot judge, only 18 agree on both fields (357 have a NULL), while + 306 pairs the guard rejected at >250 m DO agree — it would confirm merges + that are provably wrong. + + Conclusion: do NOT strengthen the key, and do not read the leftover as a backlog. What the + canon key + 250 m guard reach IS the ceiling; what is left is counted, not queued — see the + residual census (`_RESIDUAL_SQL`), whose buckets keep «the guard was silent» apart from «the + guard rejected on the merits». Prod 2026-08-10, 963 excess rows: 568 of them are >250 m apart + (median 1 084 m) — those are not duplicates at all, the canon key is wrong about them. + IDEMPOTENCY: Every UPDATE/DELETE keys off a temp mapping of (loser→keeper). On a clean table the mapping is empty → every statement touches 0 rows → no-op. Re-running is safe. @@ -105,8 +168,10 @@ psycopg v3: all SQL uses CAST(:x AS type), never the colon-colon bound-param cas from __future__ import annotations +import json import logging import time +import uuid from dataclasses import dataclass, field from typing import Any @@ -132,9 +197,24 @@ _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-перенос полей неполон (год постройки / тип дома / +# этажность / застройщик не переносятся) — данные богатого проигравшего удаляются безвозвратно. +# +# ПРОВЕРЕНО ЗАДНИМ ЧИСЛОМ (#2690 п.3, 2026-08-10): первый прогон на исправленном правиле — +# 08.08, 821 слияние — разобран по house_merge_log (у проигравшего число объявлений = длина +# children_repointed['listings.house_id_fk'], у победителя — что висело на нём до слияния). +# Слияний, где победитель беднее проигравшего по объявлениям: 0 из 821. Контрфактика старого +# правила на тех же кластерах: 6 из 762 забрали бы пустого победителя (8 объявлений). Мерить +# «победителя до слияния» по listings.scraped_at НЕЛЬЗЯ — #2206 двигает его при каждом +# ре-подтверждении, отчего появляются 207 несуществующих «худших победителей». _KEEPER_ORDER = f""" (h.geom IS NOT NULL) DESC, - listing_cnt DESC, + listing_cnt DESC NULLS LAST, ({_COMPLETENESS_EXPR}) DESC, h.id ASC """ @@ -166,37 +246,17 @@ _CANON_KEY_EXPR = """ """ -def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: - """Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression. +def _ranked_cte(cluster_key_case: str) -> str: + """Render the `WITH … ranked AS (…)` prelude: cluster → rank → expose the keeper per row. - Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers - are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered - row shares one fias by construction). - - apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True. - - CANON pass → True: the canon strips город/район, so same-street-number buildings in - different region-66 towns share a canon; the guard stops the cross-town over-merge. - - FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks - proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the - geom-first keeper rule simultaneously repairs the broken coordinate). + Shared verbatim by the merge mapping (`_mapping_sql`) and the residual census + (`_RESIDUAL_SQL`) so the census counts EXACTLY the rows the merge reasons about — a census + built from its own copy of the clustering would drift from the pass it describes and the + drift would be invisible (it is the same class of error as #2690's cluster key: two + expressions that look alike and are not). `cluster_key_case` is a STATIC module constant (never runtime data) — no value injection. """ - geo_guard = ( - """ - -- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two - -- different buildings sharing a street+number canon («Ленина 5» in different region-66 - -- towns) collapse to one cluster_key. A loser merges only when geographically next to the - -- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at - -- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL - -- geom on either side, → left as separate rows (conservative — never over-merges). - AND keeper_geom IS NOT NULL - AND loser_geom IS NOT NULL - AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250""" - if apply_geo_guard - else "" - ) return f""" - CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS WITH clustered AS ( SELECT id, @@ -248,12 +308,55 @@ def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: FROM dup_houses dh JOIN houses h ON h.id = dh.id LEFT JOIN listing_counts lc ON lc.house_id = dh.id + )""" + + +def _mapping_sql(cluster_key_case: str, *, apply_geo_guard: bool = True) -> str: + """Render the loser→keeper mapping SQL for one pass, given its cluster-key CASE expression. + + Only cluster keys shared by >1 house_id form a cluster; the keeper is rn=1 per cluster, losers + are rn>1. The CROSS-FIAS guard always applies (a no-op for the fias pass, where every clustered + row shares one fias by construction). + + apply_geo_guard (#2187): the 250 m ST_DistanceSphere guard is emitted ONLY when True. + - CANON pass → True: the canon strips город/район, so same-street-number buildings in + different region-66 towns share a canon; the guard stops the cross-town over-merge. + - FIAS pass → False: a shared ФИАС/ГАР UUID IS the building identity and strictly outranks + proximity, so same-fias rows merge even with NULL geom on a side or >250 m apart (the + geom-first keeper rule simultaneously repairs the broken coordinate). + `cluster_key_case` is a STATIC module constant (never runtime data) — no value injection. + """ + geo_guard = ( + """ + -- GEO GUARD (canon pass only — #2187). tradein_canon_addr strips город/район, so two + -- different buildings sharing a street+number canon («Ленина 5» in different region-66 + -- towns) collapse to one cluster_key. A loser merges only when geographically next to the + -- keeper (<=250 m — covers one building's geocode spread, prod: Мраморская 34к4 dupes at + -- 222 m; region-66 towns are km+ apart → 250 m is safe from cross-town). >250 m, or NULL + -- geom on either side, → left as separate rows (conservative — never over-merges). + AND keeper_geom IS NOT NULL + AND loser_geom IS NOT NULL + AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250""" + if apply_geo_guard + else "" ) + return f""" + CREATE TEMP TABLE _1772_dup_mapping ON COMMIT DROP AS + {_ranked_cte(cluster_key_case)} -- CROSS-FIAS guard (#1772 follow-up): never merge two rows that BOTH carry a non-null but -- DIFFERENT house_fias_id — provably different buildings the cluster key collapsed (canon -- slash-collapse «Сулимова, 32»/«Сулимова, 3/2»). No-op for the fias pass (one fias per -- cluster) and for canon clusters where at most one side carries a fias. - SELECT id AS loser_id, keeper_id, norm_address + -- + -- cluster_key / distance_m are carried out of the mapping for the MERGE JOURNAL (#2690): + -- cluster_key records WHICH key value fired, distance_m how far apart the two rows were. + -- distance_m is computed even when the geo guard is OFF for this pass — that is precisely + -- the case where nothing else records the distance, and #2690 had no way to ask + -- «how many merges happened at distances the guard would have blocked» from data. + SELECT id AS loser_id, keeper_id, norm_address, cluster_key, + CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL + THEN ST_DistanceSphere(loser_geom, keeper_geom) + END AS distance_m FROM ranked WHERE rn > 1 AND id <> keeper_id{geo_guard} @@ -272,6 +375,54 @@ _BUILD_MAPPING_SQL = text(_mapping_sql(_CANON_KEY_EXPR)) # merge even with NULL geom or >250 m apart (the geom-first keeper rule fixes broken coords). _BUILD_MAPPING_SQL_FIAS = text(_mapping_sql(_FIAS_KEY_EXPR, apply_geo_guard=False)) +# ── RESIDUAL CENSUS (#2690 п.2/п.4) ─────────────────────────────────────────── +# +# Read-only, run AFTER both passes: how many same-canon rows the merge LEFT BEHIND, and WHY. +# Same `ranked` prelude as the canon mapping, minus the guard — so every row the guard filtered +# out is counted here, bucketed by the reason it survived. +# +# WHY this exists. #2690 asked for a second, address-independent key; measured 2026-08-10, there +# is none (see the KEY section in the module docstring), so the remainder is a CEILING, not a +# backlog — and a ceiling has to be a live number, not a one-off. The one-off rots fast: the +# issue's own census (781 excess rows, 06.08) was 963 four days later, after a run deleted 821. +# +# The buckets are deliberately NOT summed into one «остаток». «Guard was silent» and «guard +# rejected» are opposite facts: +# residual_no_geom — one side has no coordinates: the guard could not speak. UNKNOWN. +# residual_far — both geocoded, >250 m apart: the guard spoke on the merits. These are +# NOT duplicates — the canon key is wrong about them (prod 2026-08-10: +# 568 rows, median 1084 m). Counting them as «дубли» inflates the debt. +# residual_cross_fias — provably different buildings (two different ФИАС UUIDs). +# residual_mergeable — passes every guard and STILL was not merged. Must be 0 after a real +# run; non-zero is a tripwire on the pass itself, not a census entry. +# residual_listings is the user-visible size of the remainder (listings hanging on those rows). +_RESIDUAL_SQL = text( + f""" + {_ranked_cte(_CANON_KEY_EXPR)} + SELECT + count(*) FILTER (WHERE rn > 1) AS residual_rows, + COALESCE(sum(lcnt) FILTER (WHERE rn > 1), 0) AS residual_listings, + count(*) FILTER (WHERE rn > 1 AND cross_fias) AS residual_cross_fias, + count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist IS NULL) + AS residual_no_geom, + count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist > 250) AS residual_far, + count(*) FILTER (WHERE rn > 1 AND NOT cross_fias AND dist <= 250) + AS residual_mergeable + FROM ( + SELECT rn, + COALESCE(lc.listing_cnt, 0) AS lcnt, + CASE WHEN keeper_geom IS NOT NULL AND loser_geom IS NOT NULL + THEN ST_DistanceSphere(loser_geom, keeper_geom) + END AS dist, + (NULLIF(loser_fias, '') IS NOT NULL + AND NULLIF(keeper_fias, '') IS NOT NULL + AND lower(loser_fias) <> lower(keeper_fias)) AS cross_fias + FROM ranked + LEFT JOIN listing_counts lc ON lc.house_id = ranked.id + ) r + """ +) + # Each step keys off _1772_dup_mapping → empty mapping ⇒ 0 rows touched ⇒ idempotent no-op. _STEPS: list[tuple[str, str]] = [ # ── Plain re-point (no UNIQUE on the FK column) ─────────────────────────── @@ -282,6 +433,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id_fk = m.keeper_id FROM _1772_dup_mapping m WHERE l.house_id_fk = m.loser_id + RETURNING m.loser_id, l.id AS child_id """, ), ( @@ -291,6 +443,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hph.house_id = m.loser_id + RETURNING m.loser_id, hph.id AS child_id """, ), ( @@ -300,6 +453,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hr.house_id = m.loser_id + RETURNING m.loser_id, hr.id AS child_id """, ), ( @@ -309,6 +463,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hrc.house_id = m.loser_id + RETURNING m.loser_id, hrc.id AS child_id """, ), ( @@ -318,6 +473,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE ev.house_id = m.loser_id + RETURNING m.loser_id, ev.id AS child_id """, ), # ── UNIQUE(ext_source, ext_id): delete colliding losers, re-point rest ───── @@ -333,6 +489,7 @@ _STEPS: list[tuple[str, str]] = [ AND hs2.ext_source = hs.ext_source AND hs2.ext_id = hs.ext_id ) + RETURNING hs.house_id AS loser_id, to_jsonb(hs.*) AS row_snapshot """, ), ( @@ -342,6 +499,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hs.house_id = m.loser_id + RETURNING m.loser_id, hs.id AS child_id """, ), # ── UNIQUE(normalized_address): delete colliding losers, re-point rest ───── @@ -356,6 +514,7 @@ _STEPS: list[tuple[str, str]] = [ WHERE haa2.house_id = m.keeper_id AND haa2.normalized_address = haa.normalized_address ) + RETURNING haa.house_id AS loser_id, to_jsonb(haa.*) AS row_snapshot """, ), ( @@ -365,6 +524,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE haa.house_id = m.loser_id + RETURNING m.loser_id, haa.id AS child_id """, ), # ── UNIQUE(house_id, source, room_count, prices_type, period, month_date) ── @@ -386,6 +546,7 @@ _STEPS: list[tuple[str, str]] = [ LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id ) d WHERE t.id = d.id AND d.rn > 1 + RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot """, ), ( @@ -395,6 +556,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hpd.house_id = m.loser_id + RETURNING m.loser_id, hpd.id AS child_id """, ), # ── UNIQUE(house_id): one evaluation per keeper ─────────────────────────── @@ -412,6 +574,7 @@ _STEPS: list[tuple[str, str]] = [ LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id ) d WHERE t.id = d.id AND d.rn > 1 + RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot """, ), ( @@ -421,6 +584,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hie.house_id = m.loser_id + RETURNING m.loser_id, hie.id AS child_id """, ), # ── UNIQUE(house_id, ext_item_id) ───────────────────────────────────────── @@ -438,6 +602,7 @@ _STEPS: list[tuple[str, str]] = [ LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id ) d WHERE t.id = d.id AND d.rn > 1 + RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot """, ), ( @@ -447,6 +612,7 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE hs.house_id = m.loser_id + RETURNING m.loser_id, hs.id AS child_id """, ), # ── UNIQUE(house_id, audit_batch) ───────────────────────────────────────── @@ -464,6 +630,7 @@ _STEPS: list[tuple[str, str]] = [ LEFT JOIN _1772_dup_mapping m ON m.loser_id = t2.house_id ) d WHERE t.id = d.id AND d.rn > 1 + RETURNING t.house_id AS loser_id, to_jsonb(t.*) AS row_snapshot """, ), ( @@ -473,10 +640,98 @@ _STEPS: list[tuple[str, str]] = [ SET house_id = m.keeper_id FROM _1772_dup_mapping m WHERE ama.house_id = m.loser_id + RETURNING m.loser_id, ama.id AS child_id """, ), ] +# ── MERGE JOURNAL (#2690) ───────────────────────────────────────────────────── +# +# Every child of houses(id) except `listings` references it through a column named house_id; +# listings uses house_id_fk. The undo function reads the column name back out of the journal +# key ("таблица.колонка"), so this mapping is what makes the reverse UPDATE possible. +_FK_COLUMN = {"listings": "house_id_fk"} + +# The (table, column) pairs the _STEPS pipeline actually handles, derived FROM the steps so the +# set cannot drift away from them. Compared against pg_catalog before every merge — see +# _assert_all_fk_children_handled. +_HANDLED_CHILDREN: frozenset[tuple[str, str]] = frozenset( + (tbl, _FK_COLUMN.get(tbl, "house_id")) for tbl in {label.split("(")[0] for label, _ in _STEPS} +) + +# Live FK children of houses(id), read from the catalog rather than trusted from a comment. +_FK_CHILDREN_SQL = text( + """ + SELECT CAST(CAST(c.conrelid AS regclass) AS text) AS child_table, + a.attname AS fk_column + FROM pg_constraint c + JOIN unnest(c.conkey) AS k(attnum) ON true + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum + WHERE c.confrelid = CAST('houses' AS regclass) + AND c.contype = 'f' + """ +) + +# One journal row per loser, written from the mapping BEFORE anything is mutated — so loser_row +# is the row as it stood, and keeper_before precedes the identity carry-over. +_JOURNAL_INSERT_SQL = text( + """ + INSERT INTO house_merge_log ( + batch_id, run_id, initiator, merge_pass, cluster_key, geo_guard, distance_m, + norm_address, loser_id, keeper_id, loser_row, keeper_before + ) + SELECT + CAST(:batch_id AS uuid), + CAST(:run_id AS bigint), + CAST(:initiator AS text), + CAST(:merge_pass AS text), + m.cluster_key, + CAST(:geo_guard AS boolean), + m.distance_m, + m.norm_address, + m.loser_id, + m.keeper_id, + to_jsonb(l.*), + to_jsonb(k.*) + FROM _1772_dup_mapping m + JOIN houses l ON l.id = m.loser_id + JOIN houses k ON k.id = m.keeper_id + """ +) + +# Child bookkeeping lands after the steps ran — only then is it known which rows moved and which +# were destroyed by a UNIQUE collision. +_JOURNAL_CHILDREN_SQL = text( + """ + UPDATE house_merge_log + SET children_repointed = CAST(:children_repointed AS jsonb), + children_deleted = CAST(:children_deleted AS jsonb) + WHERE batch_id = CAST(:batch_id AS uuid) + AND loser_id = CAST(:loser_id AS bigint) + """ +) + + +def _assert_all_fk_children_handled(db: Session) -> None: + """Fail the merge if houses(id) gained an FK child the _STEPS pipeline does not handle. + + This is what makes the journal's promise true rather than merely documented. An unhandled + child is not a cosmetic gap: 9 of the 11 FKs are ON DELETE CASCADE, so `DELETE FROM houses` + would destroy its rows silently — no re-point step touches them, no RETURNING records them, + and the journal would claim a complete snapshot it does not have. Migration 133 already + broke on prod for exactly this (a missed child); there the failure was loud. Here it would + be silent, which is worse. Aborting the transaction costs one skipped merge cycle. + """ + live = {(r.child_table, r.fk_column) for r in db.execute(_FK_CHILDREN_SQL).all()} + unhandled = live - _HANDLED_CHILDREN + if unhandled: + raise RuntimeError( + "merge_duplicate_houses: houses(id) has FK children the merge does not handle: " + f"{sorted(unhandled)}. Their rows would be CASCADE-deleted without a journal entry. " + "Add a re-point step to _STEPS (and its RETURNING) before merging again." + ) + + # Delete the loser houses — all FK children are re-pointed or CASCADE by now. _DELETE_LOSERS_SQL = text( """ @@ -580,6 +835,15 @@ class DedupMergeResult: listings_repointed: int = 0 # listings.house_id_fk moved loser→keeper children_deleted: int = 0 # collision/dedup deletions across all UNIQUE children children_repointed: int = 0 # survivor child rows moved loser→keeper + # Residual census (#2690): same-canon rows STILL in the table after this run, by reason. + # Not a backlog — measured 2026-08-10 there is no address-independent key to shrink it with, + # so this is the ceiling of what this pass can reach. See _RESIDUAL_SQL. + residual_rows: int = 0 # excess same-canon rows left behind (sum of the three buckets) + residual_listings: int = 0 # listings hanging on them (the user-visible size) + residual_no_geom: int = 0 # guard was SILENT — one side has no coordinates + residual_far: int = 0 # guard SPOKE — >250 m apart, i.e. not the same building + residual_cross_fias: int = 0 # two different ФИАС UUIDs — provably different buildings + residual_mergeable: int = 0 # passed every guard and still unmerged — TRIPWIRE, expect 0 dry_run: bool = False duration_sec: float = field(default=0.0) @@ -590,6 +854,12 @@ class DedupMergeResult: "listings_repointed": self.listings_repointed, "children_deleted": self.children_deleted, "children_repointed": self.children_repointed, + "residual_rows": self.residual_rows, + "residual_listings": self.residual_listings, + "residual_no_geom": self.residual_no_geom, + "residual_far": self.residual_far, + "residual_cross_fias": self.residual_cross_fias, + "residual_mergeable": self.residual_mergeable, "dry_run": int(self.dry_run), "duration_sec": int(self.duration_sec), } @@ -600,14 +870,20 @@ def _run_merge_pass( *, build_sql: Any, pass_label: str, + geo_guard: bool, + batch_id: str, + run_id: int | None, + initiator: str, result: DedupMergeResult, ) -> None: """Run ONE merge pass (fias- or canon-key) inside the caller's open transaction. - Builds a fresh loser→keeper mapping for this pass's cluster key, re-points every FK child - (UNIQUE-collision-safe), carries identity/enrichment onto the keeper, deletes the losers and - backfills sources/aliases. Accumulates counters onto `result`. NEVER commits/rolls back — the - caller owns the single transaction wrapping both passes. + Builds a fresh loser→keeper mapping for this pass's cluster key, writes the MERGE JOURNAL + (#2690), re-points every FK child (UNIQUE-collision-safe), carries identity/enrichment onto + the keeper, deletes the losers and backfills sources/aliases. Accumulates counters onto + `result`. NEVER commits/rolls back — the caller owns the single transaction wrapping both + passes, which is also what makes the journal atomic with the merge: there is no ordering in + which the rows vanish but the journal entry does not land (and dry_run rolls back both). """ # Fresh mapping for this pass. ON COMMIT DROP only fires at txn end, so drop the temp table # explicitly — the second pass must rebuild the same-named table within the one transaction. @@ -616,8 +892,8 @@ def _run_merge_pass( mapping = db.execute( text( - "SELECT loser_id, keeper_id, norm_address FROM _1772_dup_mapping " - "ORDER BY keeper_id, loser_id" + "SELECT loser_id, keeper_id, norm_address, cluster_key, distance_m " + "FROM _1772_dup_mapping ORDER BY keeper_id, loser_id" ) ).all() if not mapping: @@ -627,32 +903,73 @@ def _run_merge_pass( result.losers_deleted += len(mapping) result.clusters_merged += len({row.keeper_id for row in mapping}) - # Audit log: every loser→keeper move with its address, for traceability. + # JOURNAL, phase 1 — snapshot loser + keeper BEFORE any statement mutates them. + db.execute( + _JOURNAL_INSERT_SQL, + { + "batch_id": batch_id, + "run_id": run_id, + "initiator": initiator, + "merge_pass": pass_label, + "geo_guard": geo_guard, + }, + ) + + # Container logs rotate faster than a day (#2690), so this line is a convenience, not the + # record — house_merge_log is. Distance is logged too: it is the one number that says + # whether a merge would have survived the geo guard. for row in mapping: logger.info( - "merge_duplicate_houses: pass=%s merge loser_id=%d → keeper_id=%d address=%r", + "merge_duplicate_houses: pass=%s merge loser_id=%d → keeper_id=%d address=%r " + "distance_m=%s batch=%s", pass_label, row.loser_id, row.keeper_id, row.norm_address, + "n/a" if row.distance_m is None else f"{row.distance_m:.0f}", + batch_id, ) + # Per-loser child bookkeeping, collected from each step's RETURNING: survivors by id (the + # rows are intact, only their FK moved), destroyed rows by full snapshot (nothing else is + # left of them). + repointed: dict[int, dict[str, list[int]]] = {} + deleted: dict[int, dict[str, list[Any]]] = {} + for label, sql in _STEPS: - res = db.execute(text(sql)) - rowcount = res.rowcount or 0 - if label == "listings": - result.listings_repointed += rowcount - elif label.endswith("(collision-delete)") or label.endswith("(dedup)"): + rows = db.execute(text(sql)).all() + rowcount = len(rows) + table = label.split("(")[0] + if label.endswith("(collision-delete)") or label.endswith("(dedup)"): result.children_deleted += rowcount - elif label.endswith("(re-point)") or label in ( - "house_placement_history", - "house_reviews", - "house_reliability_checks", - "external_valuations", - ): - result.children_repointed += rowcount + for r in rows: + deleted.setdefault(r.loser_id, {}).setdefault(table, []).append(r.row_snapshot) + else: + key = f"{table}.{_FK_COLUMN.get(table, 'house_id')}" + for r in rows: + repointed.setdefault(r.loser_id, {}).setdefault(key, []).append(r.child_id) + if label == "listings": + result.listings_repointed += rowcount + else: + result.children_repointed += rowcount logger.debug("merge_duplicate_houses: pass=%s step=%s rows=%d", pass_label, label, rowcount) + # JOURNAL, phase 2 — attach the child bookkeeping to the rows written in phase 1. + touched = sorted(set(repointed) | set(deleted)) + if touched: + db.execute( + _JOURNAL_CHILDREN_SQL, + [ + { + "batch_id": batch_id, + "loser_id": loser_id, + "children_repointed": json.dumps(repointed.get(loser_id, {})), + "children_deleted": json.dumps(deleted.get(loser_id, {}), default=str), + } + for loser_id in touched + ], + ) + # Carry identity/enrichment onto the keeper BEFORE the losers vanish, then delete + backfill. db.execute(_CARRY_OVER_IDENTITY_SQL) db.execute(_DELETE_LOSERS_SQL) @@ -660,7 +977,56 @@ def _run_merge_pass( db.execute(_BACKFILL_ALIASES_SQL) -def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]: +def _measure_residual(db: Session, result: DedupMergeResult) -> None: + """Count the same-canon rows this run did NOT merge, bucketed by the reason (#2690). + + Read-only; runs after both passes, so it describes the table as the run leaves it (under + dry_run it sees the not-yet-rolled-back state, which is the correct preview). Kept out of + `_run_merge_pass` because the census is about the CANON key only and must be taken once per + call, not once per pass. + + Never fails the merge: the merge itself is the product, the census is instrumentation, and a + census that can abort a committed-by-now transaction would be worse than a missing number. + """ + try: + rows = db.execute(_RESIDUAL_SQL).all() + except Exception: + logger.exception("merge_duplicate_houses: residual census failed — counters left at 0") + return + if not rows: + return + r = rows[0] + result.residual_rows = int(r.residual_rows or 0) + result.residual_listings = int(r.residual_listings or 0) + result.residual_no_geom = int(r.residual_no_geom or 0) + result.residual_far = int(r.residual_far or 0) + result.residual_cross_fias = int(r.residual_cross_fias or 0) + result.residual_mergeable = int(r.residual_mergeable or 0) + logger.info( + "merge_duplicate_houses: residual rows=%d listings=%d " + "(страж молчит=%d · страж отверг >250м=%d · cross-fias=%d · сливаемых=%d)", + result.residual_rows, + result.residual_listings, + result.residual_no_geom, + result.residual_far, + result.residual_cross_fias, + result.residual_mergeable, + ) + if result.residual_mergeable: + logger.warning( + "merge_duplicate_houses: %d rows pass every guard yet were NOT merged — the pass " + "left work on the table (expected 0)", + result.residual_mergeable, + ) + + +def merge_duplicate_houses( + db: Session, + *, + dry_run: bool = False, + run_id: int | None = None, + initiator: str = "manual", +) -> dict[str, int]: """Cluster houses by fias UUID, then by canonical address, merging dups onto one keeper. Re-implements migration 108's proven collision-safe pipeline as a RECURRING TWO-PASS job: @@ -673,16 +1039,45 @@ def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, i dry_run=True computes counts then ROLLS BACK (no writes). Idempotent: a clean table yields an empty mapping in each pass → every statement is a 0-row no-op. + Every deleted row is journaled to house_merge_log in the SAME transaction (#2690), so a + merge is reversible via house_merge_undo(batch_id); the batch_id is returned in the log line + and stored on every journal row of this call. + Returns the counter dict (DedupMergeResult.to_counters()). """ start = time.monotonic() result = DedupMergeResult(dry_run=dry_run) + batch_id = str(uuid.uuid4()) try: + # Refuse to merge at all if some FK child would be CASCADE-destroyed unjournaled. + _assert_all_fk_children_handled(db) # Pass 1: cluster by the ФИАС building UUID (runs first — most precise building identity). - _run_merge_pass(db, build_sql=_BUILD_MAPPING_SQL_FIAS, pass_label="fias", result=result) + _run_merge_pass( + db, + build_sql=_BUILD_MAPPING_SQL_FIAS, + pass_label="fias", + geo_guard=False, + batch_id=batch_id, + run_id=run_id, + initiator=initiator, + result=result, + ) # Pass 2: cluster by canonical address, with the cross-fias anti-over-merge guard. - _run_merge_pass(db, build_sql=_BUILD_MAPPING_SQL, pass_label="canon", result=result) + _run_merge_pass( + db, + build_sql=_BUILD_MAPPING_SQL, + pass_label="canon", + geo_guard=True, + batch_id=batch_id, + run_id=run_id, + initiator=initiator, + result=result, + ) + + # Census of what is LEFT (read-only). Runs before the no-op early return on purpose: + # a run that merged nothing is exactly the run whose remainder needs a number. + _measure_residual(db, result) if result.losers_deleted == 0: # Clean table — both passes empty. Roll back (we only opened temp tables). @@ -709,12 +1104,15 @@ def merge_duplicate_houses(db: Session, *, dry_run: bool = False) -> dict[str, i db.commit() logger.info( "merge_duplicate_houses: COMMITTED clusters=%d losers=%d " - "listings_repointed=%d children_deleted=%d children_repointed=%d", + "listings_repointed=%d children_deleted=%d children_repointed=%d " + "batch_id=%s (undo: SELECT * FROM house_merge_undo('%s'))", result.clusters_merged, result.losers_deleted, result.listings_repointed, result.children_deleted, result.children_repointed, + batch_id, + batch_id, ) except Exception: logger.exception("merge_duplicate_houses: FAILED — rolling back") @@ -754,7 +1152,7 @@ def run_house_dedup_merge(db: Session, *, run_id: int, params: dict) -> dict[str } try: runs_mod.update_heartbeat(db, run_id, counters) - counters = merge_duplicate_houses(db, dry_run=dry_run) + counters = merge_duplicate_houses(db, dry_run=dry_run, run_id=run_id, initiator="schedule") runs_mod.mark_done(db, run_id, counters) logger.info( "run_house_dedup_merge: run_id=%d DONE clusters=%d losers=%d dry_run=%s", diff --git a/tradein-mvp/backend/app/services/house_imv_backfill.py b/tradein-mvp/backend/app/services/house_imv_backfill.py index 38bdd005..7dd6f1b9 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, @@ -334,7 +411,8 @@ def save_imv_result(db: Session, house_id: int, params: dict, result: IMVEvaluat UPDATE houses SET imv_status = 'ok', last_imv_attempt_at = NOW(), - imv_error_reason = NULL + imv_error_reason = NULL, + imv_transient_attempts = 0 WHERE id = :hid """), {"hid": house_id}, @@ -347,12 +425,19 @@ def _mark_status( status: str, reason: str | None = None, ) -> None: + # #2674: счётчик растёт ТОЛЬКО на transient_error — это «сколько раз подряд дом + # падал по временной причине», а не «сколько раз его трогали». no_params / + # no_address / not_found счётчик не двигают: они не занимают retry-слот. db.execute( text(""" UPDATE houses SET imv_status = :s, last_imv_attempt_at = NOW(), - imv_error_reason = :r + imv_error_reason = :r, + imv_transient_attempts = CASE + WHEN :s = 'transient_error' THEN imv_transient_attempts + 1 + ELSE imv_transient_attempts + END WHERE id = :hid """), {"hid": house_id, "s": status, "r": reason}, @@ -366,6 +451,104 @@ _IMVStatus = Literal[ "ok", "no_params", "no_address", "not_found", "auth_error", "transient", "error" ] +# #2674: сколько раз подряд дом может упасть в transient_error, прежде чем +# перестанет занимать retry-слот. Число из замера: после починки сайдкара (04.08) +# доля отказов на попытку — 2/27 и 3/25 (прогоны 3708/3467), т.е. ~10%. На 1039 +# застрявших это ~104 повторных отказа на первом проходе, ~10 на втором, ~1 на +# третьем. Порог 3 стоит максимум ~115 слотов ВСЕГО (≈2 прогона) и гарантирует, +# что дом со СВОЕЙ (не инфраструктурной) причиной не крутится в пакете вечно. +# Исчерпавшие лимит не исчезают из наблюдаемости: они остаются imv_status= +# 'transient_error' и считаются как +# WHERE imv_status='transient_error' AND imv_transient_attempts >= 3. +_MAX_TRANSIENT_ATTEMPTS = 3 + +# Доля пакета под повтор transient_error. Половина — потому что остальные слоты +# после #2674 достаются ТОЛЬКО домам, по которым реально будет запрос к площадке +# (см. _premark_unusable): раньше из 50 слотов до площадки доходили 17 (замер +# головы очереди на 12.08), так что pending на половине пакета всё равно идёт +# быстрее, чем на целом до правки. +_RETRY_SLOTS_SHARE = 0.5 + +# Дом без пригодных параметров backfill всё равно пометит no_params — но только +# заплатив слотом пакета и паузой request_delay_sec. Тот же вердикт берётся одним +# запросом: нет ни одного объявления с rooms+area (pick_lot_params вернёт {}) ИЛИ +# не из чего взять house_type (_map_house_type вернёт None → «unknown house_type»). +# Причины пишем ТЕМИ ЖЕ строками, что и поштучный путь, — старые разрезы по +# imv_error_reason продолжают работать. +# Условие сознательно УЖЕ питоновского: normalize_house_type схлопывает в None ещё +# и нераспознанный вокабуляр ('other', 'wireframe'), который тут остаётся текстом. +# Промахнуться можно только в безопасную сторону — пометить меньше, чем пометил бы +# поштучный путь. +_PREMARK_UNUSABLE_SQL = text(""" + WITH unusable AS ( + SELECT h.id, + CASE WHEN NOT EXISTS ( + SELECT 1 FROM listings l + WHERE l.house_id_fk = h.id + AND l.rooms IS NOT NULL + AND l.area_m2 IS NOT NULL) + THEN 'no listings with rooms+area' + ELSE 'unknown house_type' + END AS reason + FROM houses h + WHERE h.imv_status = ANY(CAST(:statuses AS text[])) + AND h.lat IS NOT NULL + AND h.lon IS NOT NULL + AND h.address IS NOT NULL + AND ( + NOT EXISTS ( + SELECT 1 FROM listings l + WHERE l.house_id_fk = h.id + AND l.rooms IS NOT NULL + AND l.area_m2 IS NOT NULL) + OR COALESCE( + NULLIF(TRIM(( + SELECT mode() WITHIN GROUP (ORDER BY l.house_type) + FROM listings l + WHERE l.house_id_fk = h.id + AND l.rooms IS NOT NULL + AND l.area_m2 IS NOT NULL)), ''), + NULLIF(TRIM(h.house_type), '') + ) IS NULL + ) + ) + UPDATE houses + SET imv_status = 'no_params', + last_imv_attempt_at = NOW(), + imv_error_reason = unusable.reason + FROM unusable + WHERE houses.id = unusable.id +""") + +# Основная очередь: один статус, как и было (only_status — публичный параметр +# admin-API, семантику не трогаем). +_QUEUE_SQL = text(""" + SELECT id, address, full_address, lat, lon + FROM houses + WHERE imv_status = :status + AND lat IS NOT NULL + AND lon IS NOT NULL + AND address IS NOT NULL + ORDER BY last_imv_attempt_at NULLS FIRST, id + LIMIT :batch +""") + +# Retry-очередь (#2674). Отдельный запрос, а не OR к основной: у pending +# last_imv_attempt_at всегда NULL, поэтому при общем ORDER BY ... NULLS FIRST +# transient_error не попал бы в пакет, пока не кончится pending (по замеру +# 12.08 — 5747 домов ≈ год). Отдельная квота = отдельный проход. +_RETRY_QUEUE_SQL = text(""" + SELECT id, address, full_address, lat, lon + FROM houses + WHERE imv_status = 'transient_error' + AND imv_transient_attempts < :max_attempts + AND lat IS NOT NULL + AND lon IS NOT NULL + AND address IS NOT NULL + ORDER BY last_imv_attempt_at NULLS FIRST, id + LIMIT :batch +""") + @dataclass class HouseIMVBackfillResult: @@ -375,6 +558,23 @@ class HouseIMVBackfillResult: errors: int = 0 duration_sec: float = field(default=0.0) status_counts: dict[str, int] = field(default_factory=dict) + # #2674: сколько домов пакета пришло из retry-очереди transient_error и + # сколько помечено no_params до пакета (без запроса к площадке). + retried: int = 0 + premarked: int = 0 + + +def _premark_unusable(db: Session, statuses: list[str]) -> int: + """Пометить no_params дома, по которым запрос к площадке невозможен. → сколько. + + Не новое поведение, а тот же вердикт _process_one_house одним запросом: на + 12.08 в очереди 1925 таких домов из 5143 (113 без объявлений с rooms+area, + 1812 без house_type) — каждый занимал слот пакета и паузу, чтобы получить + ответ, который виден в SQL. + """ + res = db.execute(_PREMARK_UNUSABLE_SQL, {"statuses": statuses}) + db.commit() + return int(res.rowcount or 0) def _beat(heartbeat: Callable[[], None] | None) -> None: @@ -403,7 +603,11 @@ async def backfill_house_imv( batch_size: max houses to process (ignored when house_id given). request_delay_sec: sleep between Avito API calls (default 5s — anti-bot). only_status: process houses with this imv_status (default 'pending'). - Use 'transient_error' to retry failures. + Use 'transient_error' to retry failures. При значении по умолчанию + часть пакета (_RETRY_SLOTS_SHARE) автоматически уходит на повтор + transient_error с непотраченным лимитом попыток (#2674) — явно + переданный only_status этот проход отключает, оператор получает + ровно то, что попросил, включая исчерпавшие лимит дома. house_id: process a single specific house (debug). heartbeat: optional callback дёргается каждые _HEARTBEAT_EVERY_N_HOUSES домов — caller обновляет scrape_runs.heartbeat_at, чтобы reap_zombies @@ -432,23 +636,45 @@ async def backfill_house_imv( .all() ) else: - rows = ( + # Повторный проход только на расписании (only_status по умолчанию): явный + # only_status от оператора — это ручной запрос ровно одного статуса. + retry_lane = only_status == "pending" + + statuses = [only_status] + (["transient_error"] if retry_lane else []) + result.premarked = _premark_unusable(db, statuses) + if result.premarked: + logger.info( + "house_imv_backfill: %d домов помечены no_params до пакета (нет rooms+area " + "или house_type) — слоты пакета не потрачены", + result.premarked, + ) + + retry_rows: list = [] + if retry_lane: + retry_rows = ( + db.execute( + _RETRY_QUEUE_SQL, + { + "max_attempts": _MAX_TRANSIENT_ATTEMPTS, + "batch": int(batch_size * _RETRY_SLOTS_SHARE), + }, + ) + .mappings() + .all() + ) + result.retried = len(retry_rows) + + # Недобор retry-очереди (она кончится раньше pending: 1039 против 3218 на + # 12.08) возвращается pending — пакет не простаивает. + fresh_rows = ( db.execute( - text(""" - SELECT id, address, full_address, lat, lon - FROM houses - WHERE imv_status = :status - AND lat IS NOT NULL - AND lon IS NOT NULL - AND address IS NOT NULL - ORDER BY last_imv_attempt_at NULLS FIRST, id - LIMIT :batch - """), - {"status": only_status, "batch": batch_size}, + _QUEUE_SQL, + {"status": only_status, "batch": max(batch_size - result.retried, 0)}, ) .mappings() .all() ) + rows = list(fresh_rows) + list(retry_rows) result.checked = len(rows) if not rows: @@ -457,9 +683,11 @@ async def backfill_house_imv( return result logger.info( - "house_imv_backfill: %d houses (status=%r delay=%.1fs)", + "house_imv_backfill: %d houses (status=%r retry=%d premarked=%d delay=%.1fs)", result.checked, only_status, + result.retried, + result.premarked, request_delay_sec, ) @@ -500,19 +728,47 @@ 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) result.duration_sec = time.time() - t0 logger.info( - "house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d %.1fs %s", + "house_imv_backfill done: checked=%d saved=%d skipped=%d errors=%d " + "retried=%d premarked=%d %.1fs %s", result.checked, result.saved, result.skipped, result.errors, + result.retried, + result.premarked, result.duration_sec, result.status_counts, ) @@ -652,6 +908,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/conflict_resolution.py b/tradein-mvp/backend/app/services/matching/conflict_resolution.py index 712ece35..2e56f1eb 100644 --- a/tradein-mvp/backend/app/services/matching/conflict_resolution.py +++ b/tradein-mvp/backend/app/services/matching/conflict_resolution.py @@ -71,11 +71,18 @@ HOUSE_FIELD_PRIORITY: dict[str, list[str] | str] = { "commission_year": ["cian_serp", "yandex_realty_nb"], "commission_month": ["yandex_realty_nb"], # raw RU month name "developer_name": ["cian", "yandex_realty_nb"], - "has_panorama": ["yandex_valuation"], # Yandex 3D panorama flag + # #2674 (хвост): запись про «панораму» удалена вместе с колонкой (мигр. 259). + # В отличие от ceiling_height ниже, правило было ИСПОЛНИМО — колонка существовала, + # источник её писал. Разрешать было нечего: yandex_valuation отдавал False всегда, + # потому что слова «панорам» на странице оценки нет (0 true из 1536 страниц на + # проде; живая проверка боевым трактом 13.08.2026 не нашла его и в сыром HTML). "yandex_total_listings": ["yandex_valuation"], # "N объектов" в истории # Yandex Valuation enrichment (existing house attrs) "has_lift": ["cian_bti", "cian_detail", "yandex_valuation"], - "ceiling_height": ["cian_detail", "yandex_valuation"], + # #2699: запись "ceiling_height" удалена — колонки с таким именем в `houses` + # никогда не было (проверено на проде: 0 колонок LIKE '%ceiling%'), правило не + # могло сработать ни разу. Высота потолков — атрибут ОБЪЯВЛЕНИЯ + # (listings.ceiling_height_m), см. LISTING_FIELD_PRIORITY ниже. } # --------------------------------------------------------------------------- @@ -88,7 +95,10 @@ LISTING_FIELD_PRIORITY: dict[str, list[str] | str] = { "area_m2": ["cian_serp", "avito_detail"], "living_area_m2": ["cian_serp"], "kitchen_area_m2": ["cian_serp", "avito_detail"], - "ceiling_height": ["cian_detail"], + # #2699: ключ — имя РЕАЛЬНОЙ колонки. Был "ceiling_height" (019), которую + # эстиматор не читает; канон — ceiling_height_m, и её пишут все три источника, + # а не только cian_detail. + "ceiling_height_m": ["cian_detail", "avito_detail", "yandex_detail"], "floor": ["cian_serp", "avito_detail"], "total_floors": ["cian_serp", "avito_detail"], "year_built": ["cian_serp"], diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py index 2b130d29..1effa723 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,9 +75,8 @@ 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, + city: str | None = None, ) -> tuple[int | None, float, str]: """Match existing house or create new canonical record. @@ -58,21 +86,30 @@ 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. + NB: параметра `house_fias_id` здесь НЕТ намеренно (#2674) — см. шапку модуля. + ФИАС-тир живёт только в `match_house_readonly`, у которого есть источник ФИАС. + 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. + city: город-цель развёртки, собравшей эту карточку (`save_listings(city=…)`, + он же `listings.city`) — НЕЗАВИСИМОЕ от строки адреса наблюдение города + (#2777). Нужен ровно там, где адресный токен города бессилен: областной + формат Avito SERP «ул. Кирова,4» города не называет, а бескоординатный + ключ Tier-2a вырождается в один нормализованный адрес и становится + глобально уникальным. Опционален: вызывающие без sweep-контекста + (estimate-путь, ad-hoc скрипты) передают None → поведение прежнее. + Про независимость: в #2690 доказано, что усиление ключа полем, выведенным + из ТОЙ ЖЕ строки адреса (gar_house_guid), защиту отменяет, а не усиливает — + здесь признак приходит другим каналом (какой город запрашивала развёртка). 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 +124,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 +182,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 = ( @@ -203,18 +225,40 @@ def match_or_create_house( # SAME oblast building) still needs city-keyed aliases — a separate follow-up, out of # scope, only relevant once the oblast sweep is enabled. # - # EKB happy-path is byte-identical: the guard fires ONLY when the address names a non-ЕКБ - # city AND no coords disambiguate. ЕКБ cards (resolved city = екатеринбург) and the - # dominant bare/city-less Avito coord-less cards (resolved city None) run Tier-2a/2b - # exactly as before. NB: a BARE oblast card (no city token in the address — today's Avito - # SERP format) carries no signal here and is deliberately left on the unchanged path; that - # residual needs sweep-context and is out of this fix's scope. - _resolved_city = resolve_city_token(norm_addr) if (lat is None and lon is None) else None + # EKB happy-path is byte-identical: the guard fires ONLY when the card's city is known to + # be non-ЕКБ AND no coords disambiguate. ЕКБ cards and cards with no city signal at all + # (resolved city None) run Tier-2a/2b exactly as before. + # + # #2777: the residual the comment above used to describe as out of scope — a BARE oblast + # card ('ул. Кирова,4', today's Avito SERP format) — is closed here by the `city` kwarg. + # The sweep already knows which city it was crawling and stamps it on the listing row + # (save_listings → listings.city); that observation just never reached this guard, so + # 26 of 26 measured cross-city stitches went through Tier 2a on a coord-less key. Prod + # 2026-08-10: 7303 of 21603 aliases are coord-less keys, 6047 of them carry no city token + # at all — i.e. a globally unique 'street + number' that ANY city's card can hit. + # The address token still wins when present (it describes THIS card; the sweep city + # describes the batch). + _resolved_city = None + if lat is None and lon is None: + _resolved_city = resolve_city_token(norm_addr) or (normalize_address(city) or None) _skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN + # Известный потолок правки, названный числом (прод 2026-08-10, 35 домов со + # «сшитыми» городами по метке listings.city): + # • 30 из 35 — приходящая карточка областная, алиас принадлежит дому другого + # города → страж срабатывает; + # • 5 из 35 — приходящая карточка ЕКБ, а алиас завёл областной дом. Тут страж + # молчит: города владельца алиаса мы не знаем (в house_address_aliases его + # нет). Апгрейд — city-ключ у алиаса, но это миграция + перекладка 7303 + # бескоординатных ключей, и до неё нужен журнал слияний (#2690 п.1). + # • посёлки внутри ЕКБ-развёртки (Кедровка, Б. Седельниково, Решёты — 12-17 км + # разброса) этим признаком НЕ ловятся вовсе: у них тот же город-цель + # «Екатеринбург». Гранулярность независимого наблюдения — город, не населённый + # пункт; это ограничение данных, а не недоделка стража. if _skip_oblast_alias: logger.info( - "house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s", + "house tier2a/2b skip: coord-less non-ЕКБ city %r (sweep_city=%r) na=%r src=%s", _resolved_city, + city, norm_addr, ext_source, ) diff --git a/tradein-mvp/backend/app/services/matching/listings.py b/tradein-mvp/backend/app/services/matching/listings.py index f2b94f72..ceaae20c 100644 --- a/tradein-mvp/backend/app/services/matching/listings.py +++ b/tradein-mvp/backend/app/services/matching/listings.py @@ -246,7 +246,23 @@ def _upsert_listing_source( source_url: str | None, source_data: dict | None, ) -> None: - """Insert or refresh listing_sources row for this source+ext_id.""" + """Insert or refresh listing_sources row for this source+ext_id. + + Отметки времени — statement_timestamp(), НЕ NOW() (#2731). Этот upsert вызывается + ПОСТРОЧНО из save_listings (hook `_link_listing_to_house`), а транзакция batch'а + коммитится один раз в конце, поэтому NOW() (== transaction_timestamp) давал одну + метку на весь вызов: прод-замер 2026-08-06 — 219 строк на 1 метку в 11:00, + 235/1 в 10:00, 297/1 в 09:00, и так каждый час. + + Чинится вместе с listings.scraped_at/last_seen_at, а не отдельно: сегодня + listings.last_seen_at = listing_sources.last_seen_at у 100% пар (2407 из 2407 за + сутки) именно потому, что обе колонки берут одну транзакционную метку. Почини + только одну — вторая осталась бы замороженной на старте batch'а, и расхождение + выросло бы с миллисекунд (честная разница двух записей) до длительности прогона. + + Все три колонки пишутся ОДНИМ statement'ом, поэтому statement_timestamp() даёт им + одинаковое значение; clock_timestamp() развёл бы их на микросекунды. + """ raw = json.dumps(source_data) if source_data is not None else None db.execute( text(""" @@ -257,15 +273,15 @@ def _upsert_listing_source( price_rub, area_m2, floor, rooms_count, raw_payload ) VALUES ( CAST(:lid AS bigint), :s, :e, - CAST(:c AS real), :m, NOW(), NOW(), - :url, NOW(), + CAST(:c AS real), :m, statement_timestamp(), statement_timestamp(), + :url, statement_timestamp(), CAST(:p AS bigint), CAST(:a AS numeric), :fl, :rc, CAST(:raw AS jsonb) ) ON CONFLICT (ext_source, ext_id) DO UPDATE SET confidence = GREATEST(EXCLUDED.confidence, listing_sources.confidence), - last_seen_at = NOW(), - last_scraped_at = NOW(), + last_seen_at = statement_timestamp(), + last_scraped_at = statement_timestamp(), price_rub = COALESCE(EXCLUDED.price_rub, listing_sources.price_rub), area_m2 = COALESCE(EXCLUDED.area_m2, listing_sources.area_m2), floor = COALESCE(EXCLUDED.floor, listing_sources.floor), 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..6a78136f --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/__init__.py @@ -0,0 +1,20 @@ +"""Т-Банк интернет-эквайринг — чистый интеграционный слой (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` запросов + проверка подписи нотификаций + (никогда не кидает исключение на враждебном входе). +- `notification.py` — строгий типизированный разбор тела нотификации ПОСЛЕ + проверки подписи (`parse_notification`) — сырой `dict` дальше не уходит. +- `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/notification.py b/tradein-mvp/backend/app/services/payments/notification.py new file mode 100644 index 00000000..48faacac --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/notification.py @@ -0,0 +1,116 @@ +"""Строгий типизированный разбор нотификации Т-Банк — ПОСЛЕ проверки подписи. + +Вызывать `parse_notification()` только когда `token.verify_notification_token(...)` +уже вернул `True`. Разбор здесь НЕ проверяет подпись повторно — он только +превращает уже доверенный (по подписи) `dict` в типизированный объект, чтобы +сырой `dict` не утекал дальше в бизнес-логику (статус-машину платежа, запись +в БД). + +ПОЧЕМУ строгий разбор — самостоятельный слой защиты, а не формальность: +алгоритм подписи Т-Банка конкатенирует значения полей БЕЗ разделителя между +ними (см. `token.py`, docstring модуля, шаг 4). Из-за этого символы могут +"перекладываться" между лексикографически соседними ключами так, что итоговая +строка для SHA-256 не меняется, хотя значения полей — меняются. Проверено +живым расчётом на официальном эталонном векторе: `Amount=1111, CardId="000000"` +даёт тот же Token, что и `Amount=11, CardId="11000000"` (доп. `1` "перетекла" +из `Amount` в начало `CardId`, потому что `Amount` < `CardId` лексикографически +и обе стоят подряд в конкатенации). Значит подпись сама по себе НЕ гарантирует, +что банк прислал именно ту сумму, которую записал у себя платёжный сервис — +это СВОЙСТВО алгоритма банка, менять его нельзя (мы не управляем форматом +Token, который реально пришлёт банк на проде). + +КОНТРАКТ ДЛЯ PR-D (публичная ручка нотификации) — единственная реальная защита +от описанного выше перекладывания: `amount_kopecks` из `parse_notification()` +ОБЯЗАН быть сверен с уже сохранённым `payments.amount_kopecks` в БД (запись, +созданная на `init_payment()`, найденная по `order_id`/`payment_id` из этой же +нотификации) ДО того, как нотификация будет принята как валидное событие. +Если сумма из нотификации не совпадает с суммой в БД — это либо подделанная +нотификация (перекладывание символов дало другой `OrderId`/`Amount`-ключ и +подпись всё равно сошлась), либо рассинхронизация, но НЕ штатный кейс — то и +другое должно быть отказом, а не «примерно похоже, примем». +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +class NotificationParseError(ValueError): + """Поле нотификации не соответствует ожидаемому типу — отказ, не догадка.""" + + +@dataclass(frozen=True, slots=True) +class TBankNotification: + """Типизированное тело нотификации Т-Банка ПОСЛЕ успешной проверки подписи. + + `amount_kopecks` здесь — то, что ПРИСЛАЛ банк в текущем HTTP-запросе, а + НЕ подтверждённый источник истины сам по себе. См. docstring модуля — + сверка с `payments.amount_kopecks` в БД обязательна на вызывающей стороне. + """ + + success: bool + status: str + order_id: str + payment_id: str + terminal_key: str + amount_kopecks: int + + +def parse_notification(payload: dict[str, Any]) -> TBankNotification: + """Строгий разбор `payload` в `TBankNotification`. + + Вызывать ТОЛЬКО после `token.verify_notification_token(payload, password) + is True` — эта функция подпись не проверяет. + + Правила (без исключений, без «примерно разберём»): + - `Success` — только настоящий `bool` (не строка `"true"`, не `1`); + - `Amount` — только `int`; `bool` — подкласс `int` в Python + (`isinstance(True, int) is True`), поэтому проверяется и отсекается + ДО проверки на `int`, иначе `Success`-подобное поле молча прошло бы + как сумма; + - `Status`, `OrderId`, `PaymentId`, `TerminalKey` — только непустой `str`. + + Любое несоответствие — `NotificationParseError` с указанием поля, + ожидаемого типа и того, что реально пришло. + """ + if not isinstance(payload, dict): + raise NotificationParseError(f"payload должен быть dict, получено {type(payload).__name__}") + + return TBankNotification( + success=_require_strict_bool(payload, "Success"), + status=_require_nonempty_str(payload, "Status"), + order_id=_require_nonempty_str(payload, "OrderId"), + payment_id=_require_nonempty_str(payload, "PaymentId"), + terminal_key=_require_nonempty_str(payload, "TerminalKey"), + amount_kopecks=_require_strict_int(payload, "Amount"), + ) + + +def _require_strict_bool(payload: dict[str, Any], key: str) -> bool: + value = payload.get(key) + if not isinstance(value, bool): + raise NotificationParseError( + f"{key} должен быть bool, получено {type(value).__name__}={value!r}" + ) + return value + + +def _require_strict_int(payload: dict[str, Any], key: str) -> int: + value = payload.get(key) + # bool — подкласс int в Python: проверяем и отсекаем ДО isinstance(value, int), + # иначе True/False молча прошли бы как Amount=1/Amount=0. + if isinstance(value, bool) or not isinstance(value, int): + raise NotificationParseError( + f"{key} должен быть int (не bool/str/float), получено {type(value).__name__}={value!r}" + ) + return value + + +def _require_nonempty_str(payload: dict[str, Any], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value: + raise NotificationParseError( + f"{key} должен быть непустой str, получено {type(value).__name__}={value!r}" + ) + return value 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..e9baa367 --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/tbank_client.py @@ -0,0 +1,279 @@ +"""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` в теле) — тоже НЕ + ретраится: это содержательный ответ банка, а не сбой транспорта. + +БЮДЖЕТ ВРЕМЕНИ (важно для PR-D): worst case одного вызова любого метода — +около 74 с (4 попытки × `_DEFAULT_TIMEOUT_S`=15 с = 60 с, плюс backoff между +попытками 2+4+8=14 с при `_DEFAULT_MAX_RETRIES`=3). Т-Банк даёт на ответ на +нотификацию окно порядка 10 с — этот бюджет в 74 с в него заведомо не +укладывается. Значит: исходящий HTTP-вызов к `TBankClient` (в т.ч. +`get_state`/`confirm`/`cancel` для сверки/реконсиляции по нотификации) +ВНУТРИ обработчика публичной ручки нотификации ЗАПРЕЩЁН — обработчик обязан +только валидировать/сохранить событие и ответить `"OK"`, а любая сверка с +банком (`GetState`/`CheckOrder`) — асинхронно, вне HTTP-цикла ответа банку. + +БЕЗОПАСНОСТЬ: `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`. + + КОНТРАКТ ДЛЯ PR-D (обработка сетевой ошибки вызывающей стороной): + после `TBankApiError` от `Init` (в т.ч. `error_code == "network_error"` — + таймаут/обрыв) НЕЛЬЗЯ слепо повторять `init_payment()` с тем же + `order_id` — неизвестно, дошёл ли исходный запрос до банка до обрыва + соединения. Слепой повтор может создать ВТОРОЙ холд на тот же + `OrderId`. Разбираться нужно через `check_order(order_id=...)` — + он возвращает уже существующие платежи по заказу — и только по его + результату решать, нужен ли новый `Init`. + """ + 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`). + + КОНТРАКТ ДЛЯ PR-D (обработка ошибки вызывающей стороной): после + `TBankApiError` от `Confirm` (в т.ч. сетевой таймаут) слепой вызов + `cancel()` для того же `payment_id` ЗАПРЕЩЁН. Таймаут/обрыв мог + прийти УЖЕ ПОСЛЕ того, как банк фактически подтвердил холд — + `Confirm` состоялся на стороне банка, а ответ до клиента не дошёл. + В этом случае `cancel()` вернёт клиенту уже захваченные деньги. + Правильная последовательность: сначала `get_state(payment_id=...)`, + и только по актуальному статусу решать, нужен ли `cancel()`. + """ + 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..7a74a075 --- /dev/null +++ b/tradein-mvp/backend/app/services/payments/token.py @@ -0,0 +1,141 @@ +"""Подпись `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` → строка через `str()`; + `str` — как есть. `float` НЕ поддерживается — падаем явной ошибкой (формат + дробных чисел не задокументирован Т-Банком, см. `_stringify_value`). +3. Добавляем пару `Password: <пароль_терминала>`. +4. Сортируем пары по имени ключа (лексикографически по строке ключа), + конкатенируем ТОЛЬКО значения (не ключи и не имена) в одну строку. +5. SHA-256 (UTF-8) от строки, hex-digest в нижнем регистре. + +Эталонные векторы (см. `tests/test_payments_token.py`) сняты дословно с +doc-портала — оба подтверждены живым запросом, не выдуманы. +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +_EXCLUDED_KEYS = frozenset({"Token"}) + + +class TokenSigningError(ValueError): + """Поле не может быть однозначно сериализовано в подписываемую строку.""" + + +def _stringify_value(value: bool | int | float | str) -> str: + """Приводит плоское значение к строке по правилам Т-Банка. + + `bool` проверяем ДО `int`: в Python `bool` — подкласс `int` + (`isinstance(True, int) is True`), поэтому порядок веток важен — + иначе `True` попал бы в ветку int и дал `"1"` вместо `"true"`. + + `float` НЕ поддерживается — падаем с `TokenSigningError`, а не + угадываем формат. Документация Т-Банка не описывает сериализацию + дробных чисел в подписи; прежняя реализация (`format(value, "f")` + + rstrip нулей) была неподтверждённой догадкой, и она расходится с тем, + что реально уходит в JSON-теле запроса: `0.1 + 0.2` подписывался бы + как `"0.3"`, а `json.dumps(0.1 + 0.2)` даёт `"0.30000000000000004"` — + Token не соответствовал бы фактическому телу. Денежные суммы (`Amount`) + в этом API всегда целые копейки (`int`); для денежного пути правильнее + явно упасть на нецелом значении, чем подписать не то, что уйдёт в сеть. + """ + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + raise TokenSigningError( + f"float в подписываемых полях не поддерживается (получено {value!r}) — " + "формат дробных чисел не описан в документации Т-Банка, см. docstring " + "_stringify_value" + ) + 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` + (нечего сравнивать) — вызывающая сторона обязана трактовать это как + отказ в обработке нотификации, а не как «пропустить проверку». + + НИКОГДА не поднимает исключение — на любом враждебном/мусорном входе + (не `dict`, не-ASCII `Token`, поля, которые ломают сериализацию внутри + `sign()`) возвращает `False`. Это обязательное свойство для публичной + ручки нотификации (PR-D): необработанное исключение здесь — это + неаутентифицированный HTTP 500 в ответ банку, а любой ответ, отличный + от `"OK"`, банк трактует как временный сбой и ретраит уведомление + почасово в течение суток. Конкретные причины двух проверок ниже: + - `payload` не `dict` (например список) → `.get()` кинул бы + `AttributeError` без явной проверки типа; + - `Token` с не-ASCII символами → `hmac.compare_digest` на строках + требует ASCII и иначе кидает `TypeError` (документированное + ограничение stdlib, не баг). + """ + if not isinstance(payload, dict): + return False + received_token = payload.get("Token") + if not isinstance(received_token, str) or not received_token or not received_token.isascii(): + return False + try: + expected_token = sign(payload, password) + except Exception: + # Мусорное поле где-то ещё в payload (например float — см. + # `_stringify_value`) не должно валить проверку подписи в исключение. + logger.warning( + "verify_notification_token: sign() упал на входящем payload — трактуем как отказ", + exc_info=True, + ) + return False + try: + return hmac.compare_digest(expected_token, received_token) + except TypeError: + return False diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index d6c70869..6c2513ad 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,37 @@ 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 ( + CAP_MULT, + DEFAULT_MIN_CONFIRMATIONS, + DEFAULT_REVISIT_FLOOR_QUANTILE, + 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) + # Пол TTL по измеренному циклу переобхода (#2659) — тоже включён по умолчанию: + # незасеянное расписание не должно снимать объявления по порогу ниже собственного + # хвоста обхода. Снять ручку вручную: revisit_floor_quantile = 0. + revisit_floor_quantile: float = params.get( + "revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE + ) + # Пустой (NULL) listing_segment -- легаси-строки до миграции 011 + жертвы + # отсутствующего COALESCE в ON CONFLICT (base.py upsert никогда не перезаписывает + # listing_segment на повторном скрейпе). Отдельный явный предикат IS NULL, а не + # элемент :segments (ANY(...) никогда не матчит NULL) -- см. deactivate_stale_avito.py. + null_segment_only: bool = params.get("null_segment_only", False) + # Потолок эффективного TTL (см. CAP_MULT в deactivate_stale_avito.py) — множитель, + # а не голая константа: источник с непропорционально длинным хвостом переобхода + # относительно своего ttl_days переопределяет его через default_params (ключ + # "cap_mult"), не трогая дефолт для остальных источников. + cap_mult: float = params.get("cap_mult", CAP_MULT) loop = asyncio.get_event_loop() await loop.run_in_executor( @@ -161,6 +258,10 @@ async def _job_deactivate_stale( ttl_days=ttl_days, segments=segments, staleness_column=staleness_column, + min_confirmations=min_confirmations, + revisit_floor_quantile=revisit_floor_quantile, + null_segment_only=null_segment_only, + cap_mult=cap_mult, ), ) @@ -270,6 +371,19 @@ async def _job_domclick_detail_backfill( await run_domclick_detail_backfill(db, run_id=run_id, params=params) +# ── house_coords_from_listings — sync set-based UPDATE в executor (#2771) ───── +async def _job_house_coords_from_listings( + db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext +) -> None: + from app.tasks.backfill_house_coords_from_listings import run_house_coords_from_listings + + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: run_house_coords_from_listings(db, run_id=run_id, params=params), + ) + + # ── geoportal_coords_backfill — sync local exact match в executor (#1967) ───── async def _job_geoportal_coords_backfill( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext @@ -329,17 +443,44 @@ 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: из скольких слотов пакета взяты дома на ПОВТОР (transient_error) + # и сколько домов ушло в no_params до пакета одним запросом. Без этих + # двух счётчиков в scrape_runs.counters проверить, что застрявшие + # действительно возвращаются в очередь, можно только по houses. + "retried": result.retried, + "premarked": result.premarked, + } + # Честный статус (#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 +489,75 @@ 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 +) -> None: + from app.tasks.purge_expired_trade_in_data import purge_expired_trade_in_data + + batch_size = params.get("batch_size") + max_batches = params.get("max_batches") + + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: purge_expired_trade_in_data( + db, run_id, batch_size=batch_size, max_batches=max_batches + ), + ) + + # ── house_dedup_merge — sync destructive merge в executor, owns lifecycle ───── async def _job_house_dedup_merge( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext @@ -382,8 +592,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`), поэтому здесь он не замыкается. @@ -399,6 +610,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"), @@ -412,6 +626,9 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: "geoportal_coords_backfill": Handler( _job_geoportal_coords_backfill, "geoportal_coords_backfill" ), + "house_coords_from_listings": Handler( + _job_house_coords_from_listings, "house_coords_from_listings" + ), "geocode_missing_listings": Handler( _job_geocode_missing_listings, "geocode_missing_listings" ), @@ -424,6 +641,10 @@ 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" + ), "proxy_healthcheck": Handler( _job_proxy_healthcheck, "proxy_healthcheck", diff --git a/tradein-mvp/backend/app/services/proxy_egress.py b/tradein-mvp/backend/app/services/proxy_egress.py new file mode 100644 index 00000000..8227a40a --- /dev/null +++ b/tradein-mvp/backend/app/services/proxy_egress.py @@ -0,0 +1,334 @@ +"""Резолвер egress-прокси по источнику для ad-hoc сессий вне scrape_run (#2825). + +ПРОБЛЕМА (доказана на проде 2026-08-10): `settings.scraper_proxy_url` (и его алиасы +`cian_proxy_url`/`yandex_proxy_url`, все три — прямая проекция ENV `SCRAPER_PROXY_URL`, +см. `app.core.config`) был ЕДИНСТВЕННЫМ egress для всех curl_cffi/httpx-сессий, которые +строятся напрямую в `app/services/*` и `app/tasks/*` МИМО `app.services.proxy_pool` / +`scraper_kit`-оркестрации. При этом `scrape_proxy_source_bans` (миграция 210, #2600 п.2) +аккуратно вела учёт банов по паре «узел × источник» — но эти прямые сессии её никогда +не читали и месяц ходили через узел, забаненный и Avito, и Cian. + +ЧТО ЭТОТ МОДУЛЬ НЕ ДЕЛАЕТ: не берёт lease. `app.services.proxy_pool.acquire()` уже +реализует pick-с-учётом-банов, но с полной lease-семантикой (leased_by/release/ +reap_stale_leases) — она рассчитана на долгоживущие `scrape_run`/`BrowserFetcher`-сессии +(см. `RealProxyProvider` в `app.services.scraper_adapters`). Вызывающие здесь — короткие +одноразовые fetch'и (проверка cookies, одна detail-страница) без run_id и без +гарантированного `release` на каждом пути выхода; занимать под них lease значило бы +дырявить пул фантомно занятыми узлами при малейшей утечке release. Резолвер ниже — +ЧИСТО READ, той же таблицы `scrape_proxies` + `scrape_proxy_source_bans`, без блокировок +и без мутаций. + +ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS +(тот же карантинный порог, что у acquire), нет АКТИВНОЙ строки (banned_until > now()) +в scrape_proxy_source_bans для ЭТОГО source — это по-прежнему жёсткий фильтр, не +влияющий на порядок. Порядок среди прошедших фильтр (замер 2026-08-10, #2825 доп.): +сначала узлы БЕЗ ИСТОРИИ банов по этому source, затем по возрастанию ban_count — +даже если сама строка бана истекла (banned_until <= now()), её ban_count всё равно +учитывается, ведь строка НЕ удаляется сразу (purge только через 7 суток чистой +работы, см. 210-я миграция) и остаётся памятью «этот узел здесь уже банился N раз». +Внутри равного ban_count — прежние критерии без изменений: меньший consecutive_fails, +при равенстве — более свежий last_ok_at (NULLS LAST). Так хронически банящийся узел +(здоров по health-check, но регулярно ловит 403 от конкретной площадки) не всплывает +первым сразу после истечения TTL — свежий healthcheck сам по себе больше не решает. +Не изобретаем ротацию/балансировку: это резолвер «дай рабочий прокси прямо сейчас», +не lease-менеджер. + +FAIL-CLOSED ПРОТИВ ТИХОГО ОБХОДА ПУЛА (#2616, deep-review этой правки): пул и статичный +`SCRAPER_PROXY_URL` — РАЗНЫЕ вещи, и путать их нельзя. Два разных исхода "кандидата нет": + + 1. Пул ПУСТ (в `scrape_proxies` вообще нет строк — dev/staging без БД-пула, легитимный + сценарий). Тогда fallback на `settings.scraper_proxy_url` ЛЕГИТИМЕН — пула для этого + окружения попросту не существует, идти больше некуда. `logger.warning`. + 2. Пул НЕ пуст, но НИ ОДИН узел не прошёл фильтр для source (все забанены ИМЕННО для + этого источника / нездоровы / выключены). Здесь fallback на `SCRAPER_PROXY_URL` + ЗАПРЕЩЁН: инцидент 2026-08-10 — это ровно случай (2), узел статичной переменной был + тем же самым забаненным узлом, что и в пуле, «резервный» путь тихо возвращал систему + к первопричине. `resolve_proxy_url` в этом случае бросает `ProxyPoolExhaustedError` — + вызывающий обязан явно отказаться от запроса (`logger.error`), а не соскользнуть на + env в обход учёта банов. + +НАБЛЮДАЕМОСТЬ: при выборе из пула логируем label/host:port (БЕЗ credentials — url +несёт логин/пароль, в лог никогда не идёт целиком), id узла и ban_count по этому +source (0, если истории нет) — чтобы по логу было видно, что узел с историей банов +выбран осознанно (пул исчерпан по чистым узлам), а не тихо; при legit-fallback — +warning с текстом «пуст» (сценарий 1); при exhaustion — error с разбивкой +banned_for_source/unhealthy_or_disabled (сценарий 2) — тексты НАМЕРЕННО разные, чтобы +их нельзя было спутать в логах/алертах. + +psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from urllib.parse import urlsplit + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings as _settings +from app.core.db import SessionLocal as _SessionLocal +from app.services.proxy_pool import MAX_CONSECUTIVE_FAILS + +logger = logging.getLogger(__name__) + +__all__ = ["ProxyPoolExhaustedError", "resolve_proxy_url", "resolve_proxy_url_sync"] + + +class ProxyPoolExhaustedError(RuntimeError): + """Пул `scrape_proxies` НЕ пуст, но ни один узел не прошёл фильтр для `source` + (все забанены именно для этого источника / нездоровы / выключены). + + Fail-closed (#2616): вызывающий обязан явно отказаться от запроса (пропустить run + с понятным логом), а НЕ уйти в обход пула через статичный + `settings.scraper_proxy_url` — тот самый узел мог быть источником текущего + инцидента (см. module docstring, сценарий 2). + """ + + def __init__( + self, + source: str, + *, + pool_total: int, + banned_for_source: int, + unhealthy_or_disabled: int, + ) -> None: + self.source = source + self.pool_total = pool_total + self.banned_for_source = banned_for_source + self.unhealthy_or_disabled = unhealthy_or_disabled + super().__init__( + f"proxy pool exhausted for source={source!r}: pool_total={pool_total} " + f"banned_for_source={banned_for_source} unhealthy_or_disabled={unhealthy_or_disabled}" + ) + + +@dataclass(frozen=True) +class _Candidate: + id: int + url: str + label: str | None + ban_count: int + """ban_count по scrape_proxy_source_bans ДЛЯ ЭТОГО source (0, если строки нет — + узел ни разу не банился этой площадкой). Учитывает и истёкшие строки бана + (banned_until <= now(), но ещё не спурженные) — см. докстринг модуля.""" + + +def _safe_label(proxy_id: int, label: str | None, url: str) -> str: + """host:port для логов — НИКОГДА не credentials из url (userinfo).""" + if label: + return label + try: + parts = urlsplit(url) + host = parts.hostname or "?" + return f"{host}:{parts.port}" if parts.port else host + except ValueError: + return f"proxy#{proxy_id}" + + +def _pick_candidate(db: Session, source: str) -> _Candidate | None: + """READ-ONLY выбор egress для source. Без FOR UPDATE — резолвер не арендует узел. + + LEFT JOIN (не EXISTS) на scrape_proxy_source_bans — нужен сам ban_count для + ранжирования, а не только факт активного бана. Активный бан (banned_until > now()) + по-прежнему полный фильтр в WHERE, это НЕ меняется; но истёкшая (и ещё не + спурженная) строка бана остаётся в ORDER BY как история — см. докстринг модуля. + COALESCE(b.ban_count, 0) — узел без единой строки истории по source ранжируется + как ban_count=0, естественно раньше любого узла с реальной историей банов. + """ + row = ( + db.execute( + text( + """ + SELECT sp.id, sp.url, sp.label, COALESCE(b.ban_count, 0) AS ban_count + FROM scrape_proxies AS sp + LEFT JOIN scrape_proxy_source_bans AS b + ON b.proxy_id = sp.id + AND b.source = CAST(:source AS text) + WHERE sp.enabled + AND sp.consecutive_fails < CAST(:max_fails AS integer) + AND (b.banned_until IS NULL OR b.banned_until <= now()) + ORDER BY COALESCE(b.ban_count, 0) ASC, + sp.consecutive_fails ASC, + sp.last_ok_at DESC NULLS LAST, + sp.id + LIMIT 1 + """ + ), + {"max_fails": MAX_CONSECUTIVE_FAILS, "source": source}, + ) + .mappings() + .fetchone() + ) + # Чистое чтение без блокировок — ничего не коммитим/не откатываем намеренно, + # оставляем управление транзакцией вызывающему коду (тот же db может быть в + # середине более широкой операции). + if row is None: + return None + return _Candidate( + id=int(row["id"]), + url=str(row["url"]), + label=row["label"], + # .get(..., 0) — не .__getitem__: production-SELECT ВСЕГДА проецирует + # ban_count (см. запрос выше), но нулевой default защищает от полного KeyError + # у сторонних fake-db в других test-модулях (напр. test_2830_pool_bypass_tails), + # которые мокают этот же db.execute() урезанным dict без нового столбца. + ban_count=int(row.get("ban_count", 0)), + ) + + +@dataclass(frozen=True) +class _ExhaustionDiag: + """Разбивка причин "кандидата нет" — ТОЛЬКО когда пул реально не пуст (сценарий 2 + в докстринге модуля). Используется исключительно для diagnostic-лога/исключения.""" + + pool_total: int + banned_for_source: int + unhealthy_or_disabled: int + + +def _diagnose_no_candidate(db: Session, source: str) -> _ExhaustionDiag: + """Отдельный запрос, вызывается ТОЛЬКО когда основной SELECT кандидата вернул + пусто — не платим за агрегаты в happy-path (кандидат найден с первого запроса).""" + row = ( + db.execute( + text( + """ + SELECT + count(*) AS pool_total, + count(*) FILTER ( + WHERE NOT enabled + OR consecutive_fails >= CAST(:max_fails AS integer) + ) AS unhealthy_or_disabled, + count(*) FILTER ( + WHERE enabled + AND consecutive_fails < CAST(:max_fails AS integer) + AND EXISTS ( + SELECT 1 + FROM scrape_proxy_source_bans b + WHERE b.proxy_id = scrape_proxies.id + AND b.source = CAST(:source AS text) + AND b.banned_until > now() + ) + ) AS banned_for_source + FROM scrape_proxies + """ + ), + {"max_fails": MAX_CONSECUTIVE_FAILS, "source": source}, + ) + .mappings() + .fetchone() + ) + if row is None: # pragma: no cover — count(*) всегда возвращает строку + return _ExhaustionDiag(pool_total=0, banned_for_source=0, unhealthy_or_disabled=0) + return _ExhaustionDiag( + pool_total=int(row["pool_total"]), + banned_for_source=int(row["banned_for_source"]), + unhealthy_or_disabled=int(row["unhealthy_or_disabled"]), + ) + + +def resolve_proxy_url(db: Session, source: str) -> str | None: + """Egress-URL для source (avito/cian/yandex/domclick) — пул с учётом банов пары + «узел × источник». См. докстринг модуля за разбором двух РАЗНЫХ исходов + "кандидата нет": + + - пул пуст (0 строк в `scrape_proxies`) → fallback на + `settings.scraper_proxy_url`, `logger.warning`, легитимный dev/staging-сценарий; + - пул не пуст, все отсеяны (баны/health/disabled) → `ProxyPoolExhaustedError` + (`logger.error`), fail-closed — БЕЗ прохода через статичный env. + + БД пула недоступна (connection error и т.п., напр. dev-окружение без поднятой БД) + — трактуем КАК пустой пул (не можем подтвердить exhaustion — небезопасно поднимать + error/исключение по неполным данным), `logger.warning` + explicit (не silent + failure). Отличается от сценария exhaustion: там мы ТОЧНО знаем, что узлы есть и + все отсеяны; здесь мы вообще ничего не знаем о пуле. + """ + try: + candidate = _pick_candidate(db, source) + except Exception: + logger.warning( + "proxy_egress: source=%s -- пул scrape_proxies недоступен (ошибка БД), " + "лечим как пустой пул (fallback на статичный SCRAPER_PROXY_URL)", + source, + exc_info=True, + ) + try: + # Ошибка на execute() оставляет сессию в aborted-транзакции (psycopg/PG: + # "current transaction is aborted") — если db переживёт этот вызов + # (долгоживущая caller-сессия, напр. avito_detail_backfill/ + # yandex_detail_backfill), последующие запросы на ней иначе все падали + # бы с той же ошибкой, маскируя реальную причину. + db.rollback() + except Exception: + logger.warning( + "proxy_egress: source=%s -- rollback после сбоя пула тоже не удался", + source, + exc_info=True, + ) + return _settings.scraper_proxy_url + + if candidate is not None: + logger.info( + "proxy_egress: source=%s -> pool proxy id=%d (%s) ban_count=%d", + source, + candidate.id, + _safe_label(candidate.id, candidate.label, candidate.url), + candidate.ban_count, + ) + return candidate.url + + diag = _diagnose_no_candidate(db, source) + + if diag.pool_total == 0: + # Сценарий 1: пул для этого окружения попросту не сконфигурирован + # (dev/staging без БД-пула) — легитимный fallback. + fallback = _settings.scraper_proxy_url + if fallback: + logger.warning( + "proxy_egress: source=%s -- пул scrape_proxies ПУСТ (0 записей), " + "окружение без БД-пула -- идём через статичный SCRAPER_PROXY_URL " + "(fallback)", + source, + ) + else: + logger.warning( + "proxy_egress: source=%s -- пул scrape_proxies пуст и SCRAPER_PROXY_URL " + "не задан, идём прямым подключением без прокси", + source, + ) + return fallback + + # Сценарий 2: пул РЕАЛЬНО не пуст, но для source не осталось ни одного + # здорового/небаненного узла -- fail-closed (#2616), НЕ fallback на env. + logger.error( + "proxy_egress: source=%s -- пул scrape_proxies НЕ пуст (%d узлов), но НИ ОДИН " + "не прошёл фильтр для этого источника (banned_for_source=%d, " + "unhealthy_or_disabled=%d) -- FAIL-CLOSED (#2616): отказ, БЕЗ обхода через " + "статичный SCRAPER_PROXY_URL (тот самый узел мог быть источником инцидента)", + source, + diag.pool_total, + diag.banned_for_source, + diag.unhealthy_or_disabled, + ) + raise ProxyPoolExhaustedError( + source, + pool_total=diag.pool_total, + banned_for_source=diag.banned_for_source, + unhealthy_or_disabled=diag.unhealthy_or_disabled, + ) + + +def resolve_proxy_url_sync(source: str) -> str | None: + """Как `resolve_proxy_url`, но сама открывает короткую `SessionLocal()` — для + вызывающих без готового `db` в сигнатуре (напр. `cian_session.verify_session`). + + `ProxyPoolExhaustedError` из `resolve_proxy_url` пробрасывается как есть (fail-closed) — + вызывающий обязан явно её поймать и решить, как деградировать (см. call site'ы). + """ + db = _SessionLocal() + try: + return resolve_proxy_url(db, source) + finally: + db.close() diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index 44b666ee..8ed2176e 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -15,11 +15,104 @@ 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 не отобрал прокси у многочасового + прогона. + +Два тракта — два диагноза (#2723): + - ipify-проба (`_probe_proxy`) отвечает на «узел жив вообще» и владеет + consecutive_fails / enabled / exit_ip. Такт — каждый прогон healthcheck (30 мин). + - браузерная проба (`_run_browser_probe` → сайдкар → camoufox с ЭТИМ прокси → + навигация) отвечает на «через узел работает браузерный тракт» и владеет + browser_fail_streak / browser_unfit_since / browser_check_at (миграция 228). + Такт свой, редкий (BROWSER_PROBE_MINUTES) — она стоит запуска camoufox. + Пересечения нет: успешная ipify-проба НЕ обнуляет browser_fail_streak (иначе + дешёвая проба каждые 30 минут стирает вердикт дорогого тракта — узел, мёртвый для + браузера, вечно возвращается в выдачу), провал браузерной пробы НЕ выключает узел + (он жив, просто не для этого тракта). Схлопнуть их в один флаг = повторить #2686. + «Непригоден для браузера» — это НЕ исключение из пула: acquire() лишь отдаёт такой + узел последним (ORDER BY), потому что при 4 узлах (#2638) голодание хуже. + +Проба на ПАРУ «узел × источник» (#2800, продолжение #2723): + - #2723 починил ТРАНСПОРТ пробы (ходить браузером, как работа). Ходила она при этом + для всех узлов на один зашитый адрес — robots.txt Авито. Прокси-узел не «жив/мёртв» + вообще: замер на проде 09.08.2026 — узел id=1 отдаёт 200 на Авито и Яндексе и 500 + NS_ERROR_PROXY_BAD_GATEWAY на рабочем хосте Домклика, имея browser_fail_streak=0 и + свежую пробу. Зелёная проба означала «годен для Авито», а читалась как «годен». + - Теперь каждый узел за такт опрашивается по КАЖДОМУ источнику, который ему может + достаться (browser_fetcher.PROBE_SOURCES ∩ affinity), по РАБОЧЕМУ хосту площадки + (apex-домен не годится: `domclick.ru` через узел id=1 отвечает 200, а + `bff-search-web.domclick.ru`, куда ходит сбор, — 500). + - Вердикт пары пишется В СУЩЕСТВУЮЩУЮ таблицу scrape_proxy_source_bans (новой + сущности не заводим — эта ровно про пару и её уже читает acquire): подтверждённый + отказ → строка бана с reason=_PROBE_BAN_REASON, успех → снятие СВОЕЙ строки. + Чужие строки (бан, распознанный боевым сбором) проба не трогает — robots.txt + площадка отдаёт и забаненному IP, так что дешёвый успех не имеет права стирать + дорогой вердикт живого сбора (тот же принцип, что «ipify не стирает браузерный»). + - Узловые поля (browser_fail_streak/browser_unfit_since) сохраняют своё значение + «браузерный тракт через узел не работает ВООБЩЕ» и обновляются по итогу ВСЕГО + креста: хоть одна зелёная площадка → ok; все красные транспортом → провал узла. + Отказ одной площадки узел глобально не пятнает — иначе мы бы своими руками + вернули то самое схлопывание диагнозов. + psycopg v3 / SQLAlchemy text(): все параметры через CAST(:x AS type), НЕ :x::type. """ @@ -33,19 +126,32 @@ import httpx from sqlalchemy import text from sqlalchemy.orm import Session +from app.core.config import settings as _settings + logger = logging.getLogger(__name__) __all__ = [ + "BROWSER_PROBE_MINUTES", + "BROWSER_UNFIT_THRESHOLD", + "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_browser_health", "mark_health", + "mark_source_probe", "reap_stale_leases", "release", "run_proxy_healthcheck", + "touch", ] # ── Пороги ─────────────────────────────────────────────────────────────────── @@ -62,14 +168,83 @@ 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 +# ── браузерная проба узла (#2723) ──────────────────────────────────────────── +# Такт браузерной пробы. Решено по замеру, не по ощущению (прод, 06.08.2026): +# - одна браузерная проба = 8.3с и один запуск camoufox; +# - боевая нагрузка сайдкара = ~42 /fetch и ~8 запусков camoufox в час +# (≈1000 и ≈190 в сутки); +# - такт ipify-пробы = 30 мин → 48 прогонов healthcheck в сутки. +# Гнать браузерную пробу каждым прогоном по 4 узлам = +192 запуска camoufox в сутки, +# то есть УДВОЕНИЕ самой дорогой операции сайдкара ради диагностики. 360 мин даёт +# 4 пробы на узел в сутки: +16 запусков (+8% к запускам, +1.6% к запросам) — цена, +# которую видно только в логе. Отказ, пойманный с задержкой до 6 часов, всё равно +# ловится в разы раньше, чем сейчас (не ловится вовсе). +BROWSER_PROBE_MINUTES = 360 + +# Столько подряд-провалов браузерной пробы (атрибутированных узлу) переводят узел в +# browser_unfit. Не 1: запуск camoufox бывает флаки сам по себе, а пометка — операция +# с последствиями при пуле из 4 узлов. Не 5 (как DISABLE_THRESHOLD): при редком такте +# это были бы сутки. Второе подтверждение приходит на СЛЕДУЮЩЕМ прогоне healthcheck +# (~30 мин), а не через полный такт — browser_check_at на неподтверждённом провале +# намеренно не обновляется (см. mark_browser_health). +BROWSER_UNFIT_THRESHOLD = 2 + +# ── проба на пару «узел × источник» (#2800) ────────────────────────────────── +# ЦЕНА, посчитанная до правки (замер 09.08.2026, тот же тракт): +# - было: 4 узла × 1 адрес / 360 мин = 16 навигаций в сутки, все на Авито; +# - стало: 4 узла × 4 источника / 360 мин = 64 навигации в сутки, то есть +# 16 robots.txt НА ПЛОЩАДКУ в сутки против ~1000 боевых /fetch; +# - одна проба 9–18 с (замерено) → такт с крестом ~3 мин против ~50 с; прогонов +# healthcheck с браузерной пробой по-прежнему 4 в сутки (гейт browser_check_at). +# Запусков camoufox НЕ прибавляется пропорционально: сайдкар релончит браузер при +# смене ЖЕЛАЕМОГО прокси, а крест идёт узел-за-узлом — 4 релонча за такт, как и было. +# Разрежённая схема (по одному источнику за такт, round-robin) рассматривалась и +# отвергнута: вердикт пары протухал бы до 24 ч при бане в 6 ч — окно, в котором +# acquire снова выдаёт узел, не спросив. +# +# Причина в scrape_proxy_source_bans, которой владеет ИМЕННО проба. Отличает её +# вердикт от бана, распознанного боевым сбором (mark_banned из report_ban): успешная +# проба снимает ТОЛЬКО свои строки. Без этого дешёвый robots.txt, который площадка +# отдаёт и забаненному IP, стирал бы дорогой вердикт живого сбора — ровно ошибка +# #2723 («дешёвая проба стирает вердикт дорогого тракта»), только на паре. +_PROBE_BAN_REASON = "probe:browser" + +# 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 +265,37 @@ 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: если + fallback заберёт его под чужой источник, «свой» останется без прокси вообще — хуже, + чем голодание исходного источника, которое фикс призван устранить. Кандидат + участвует в fallback, только если его affinity='any' ИЛИ у этой affinity есть ДРУГОЙ + enabled-узел (EXISTS-подзапрос) — т.е. выдача не обнулит доступность выделенной + affinity целиком. + + Исторический повод для этой защиты (173_scrape_proxies_add_domclick_affinity.sql — + единственный residential-узел id=1, закреплённый за domclick, потому что QRATOR + банил остальные) снят миграцией 253 (#2800): живая проба показала, что как раз до + рабочего хоста Домклика (bff-search-web.domclick.ru) этот узел НЕ доходит, а + Авито/Яндекс через него работают — резервация держала узел за источником, которому + он не годен, и прятала от тех, кому годен. Узлов с выделенной affinity на проде + сейчас нет, но САМА защита остаётся: значение 'domclick' допустимо констрейнтом, и + следующий выделенный узел должен получить её сразу, а не после повторного разбора. + + ОБА запроса отсекают узлы с АКТИВНЫМ баном по ЭТОМУ 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 @@ -101,13 +303,22 @@ def acquire(db: Session, provider: str, *, run_id: int | None = None) -> ProxyLe db.execute( text( """ - SELECT id, url, kind, rotate_url + SELECT id, url, kind, rotate_url, browser_unfit_since FROM scrape_proxies WHERE enabled AND consecutive_fails < CAST(:max_fails AS integer) AND provider_affinity IN (:provider, 'any') AND leased_by IS NULL - ORDER BY last_ok_at NULLS LAST, id + 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() + ) + -- browser_unfit последним (#2723): узел, живой для HTTP, но не для + -- браузера, из пула НЕ исключается — только уходит в конец очереди. + ORDER BY (browser_unfit_since IS NOT NULL), last_ok_at NULLS LAST, id FOR UPDATE SKIP LOCKED LIMIT 1 """ @@ -117,6 +328,66 @@ 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, sp.browser_unfit_since + 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 основного запроса (#2723) + ORDER BY (sp.browser_unfit_since IS NOT NULL), 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 +404,31 @@ 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 + ) + if row["browser_unfit_since"] is not None: + # Узел помечен непригодным для браузера (#2723), но всё равно выдан — значит + # пригодных свободных не осталось. Голодание хуже работы через плохой узел + # (та же политика, что у защиты последнего узла в mark_banned), но молчать об + # этом нельзя: для браузерного источника это заведомо обречённый прогон. + logger.warning( + "proxy_pool: leased proxy id=%d provider=%s — узел BROWSER-UNFIT с %s " + "(жив для HTTP, браузерный тракт через него не работает). Выдан потому, " + "что пригодных свободных узлов нет — пул надо пополнять (#2638).", + proxy_id, + provider, + row["browser_unfit_since"], + ) return ProxyLease( id=proxy_id, url=str(row["url"]), @@ -160,6 +453,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 +501,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 +577,547 @@ 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_browser_health( + db: Session, + proxy_id: int, + ok: bool, + *, + fail_kind: str | None = None, + detail: str = "", +) -> str: + """Записать результат БРАУЗЕРНОЙ пробы узла (#2723). Returns исход для счётчиков. + + ЧЕМ ОТЛИЧАЕТСЯ ОТ mark_health: тем же, чем «нас забанила площадка» отличается от + «у нас упал сайдкар» (#2686/#2711) — это ДРУГОЙ диагноз, а не другое значение того + же. mark_health отвечает на «узел жив вообще» и владеет + consecutive_fails/enabled/exit_ip. Эта функция отвечает на «через узел работает + браузерный тракт» и владеет browser_fail_streak/browser_unfit_since/ + browser_check_at. Пересечения нет НИ В ОДНУ сторону, и это главное: + + - успешная ipify-проба НЕ обнуляет browser_fail_streak. До #2723 обнуляла бы + (через consecutive_fails=0) — узел, мёртвый для браузера, выходил из карантина + каждые ≤30 минут и снова забирал прогон; + - провал браузерной пробы НЕ инкрементит consecutive_fails и НЕ выключает узел: + он жив, просто не для этого тракта. + + ЧТО СЧИТАЕТСЯ ПРОВАЛОМ УЗЛА: только fail_kind == "proxy" (см. + scraper_kit.browser_fetcher.classify_browser_probe). "sidecar" (сайдкар лежит) и + "page" (площадка отдала пустое) узлу не принадлежат — засчитывать их значило бы + пометить непригодными ВСЕ узлы разом при одной упавшей общей зависимости, то есть + повторить #2686 ещё раз и уже с последствиями для всего пула. + + ТАКТ ПРИ ПРОВАЛЕ: browser_check_at обновляется только когда провал ПОДТВЕРЖДЁН + (streak дошёл до BROWSER_UNFIT_THRESHOLD). На первом, ещё не подтверждённом + провале поле остаётся старым → следующий же прогон healthcheck (~30 мин) повторит + пробу и либо подтвердит отказ, либо снимет подозрение. Иначе подтверждения ждали бы + полный BROWSER_PROBE_MINUTES. + + Returns: "ok" | "refit" (узел был непригоден и починился) | "unfit" (только что + помечен непригодным) | "fail" (провал засчитан, порог не достигнут) | "ignored" + (провал не принадлежит узлу). + """ + if ok: + row = ( + db.execute( + text( + """ + UPDATE scrape_proxies AS sp + SET browser_fail_streak = 0, + browser_unfit_since = NULL, + browser_check_at = now(), + updated_at = now() + -- prev — pre-image строки: RETURNING отдаёт УЖЕ обновлённые + -- значения (browser_unfit_since там всегда NULL), а нам нужно + -- знать, была ли это реанимация непригодного узла. + FROM ( + SELECT id, browser_unfit_since + FROM scrape_proxies + WHERE id = CAST(:id AS bigint) + ) AS prev + WHERE sp.id = prev.id + RETURNING (prev.browser_unfit_since IS NOT NULL) AS was_unfit + """ + ), + {"id": proxy_id}, + ) + .mappings() + .fetchone() + ) + db.commit() + was_unfit = bool(row["was_unfit"]) if row is not None else False + logger.info( + "proxy_pool: browser probe OK id=%d (%s)%s", + proxy_id, + detail, + " — узел снова пригоден для браузера" if was_unfit else "", + ) + return "refit" if was_unfit else "ok" + + if fail_kind != "proxy": + logger.warning( + "proxy_pool: browser probe FAILED id=%d, но отказ НЕ принадлежит узлу " + "(fail_kind=%s): %s — browser_fail_streak не трогаем", + proxy_id, + fail_kind, + detail, + ) + return "ignored" + + row = ( + db.execute( + text( + """ + UPDATE scrape_proxies + SET browser_fail_streak = browser_fail_streak + 1, + browser_unfit_since = CASE + WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer) + AND browser_unfit_since IS NULL + THEN now() ELSE browser_unfit_since + END, + browser_check_at = CASE + WHEN browser_fail_streak + 1 >= CAST(:threshold AS integer) + THEN now() ELSE browser_check_at + END, + updated_at = now() + WHERE id = CAST(:id AS bigint) + RETURNING browser_fail_streak, browser_unfit_since + """ + ), + {"threshold": BROWSER_UNFIT_THRESHOLD, "id": proxy_id}, + ) + .mappings() + .fetchone() + ) + db.commit() + if row is None: + logger.warning("proxy_pool: mark_browser_health id=%d not found — no-op", proxy_id) + return "ignored" + + streak = int(row["browser_fail_streak"]) + if streak >= BROWSER_UNFIT_THRESHOLD: + logger.warning( + "proxy_pool: proxy id=%d BROWSER-UNFIT (browser_fail_streak=%d) — жив для " + "обычного HTTP, но браузерный тракт через него не работает: %s. Узел " + "ОСТАЁТСЯ в пуле (enabled не тронут, curl-путь работает), но acquire() " + "теперь отдаёт его последним (#2723).", + proxy_id, + streak, + detail, + ) + return "unfit" + logger.warning( + "proxy_pool: browser probe FAILED id=%d (browser_fail_streak=%d/%d, порог не " + "достигнут — перепроверим на следующем прогоне): %s", + proxy_id, + streak, + BROWSER_UNFIT_THRESHOLD, + detail, + ) + return "fail" + + +def mark_banned(db: Session, proxy_id: int, *, source: str, reason: str | None = None) -> str: + """Записать бан узла площадкой `source` — по ПАРЕ (proxy_id, source), #2600 п.2. + + Returns: "banned" (строка записана/продлена) | "deferred" (активная строка пары + принадлежит другому вердикту, владельца не меняем) | "protected" (защита последнего + узла) | "missing" (нет такого proxy_id). + + `reason` попадает в одноимённую колонку и служит МЕТКОЙ ВЛАДЕЛЬЦА строки: по + умолчанию 'banned:' (бан распознан боевым сбором), у браузерной пробы — + _PROBE_BAN_REASON (#2800). Снимать чужую строку никто не должен, поэтому + clear_source_bans умеет фильтровать по ней (`only_reason`). + + ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ (дефект #2803, реализовался на проде 09.08.2026: + пара (1, cian) была `banned:cian, ban_count=1, до 00:21`, упавшая проба через + ON CONFLICT переписала её в `probe:browser, ban_count=2, до 07:43`). Фильтр + «снимаю только своё» защищает лишь до тех пор, пока чужую строку нельзя ПРИСВОИТЬ: + присвоенная строка становится «своей», и следующая успешная проба снимает ею бан, + который поставил боевой сбор по настоящему отказу площадки. Плюс теряется + происхождение: 'banned:cian' («площадка нас отбила») и 'probe:browser' («наша проба + не смогла») — разные факты с разными последствиями (ровно ловушка #2764), а ban_count + начинает считать события РАЗНОГО рода одной эскалацией (на проде это удлинило отдых + пары с 6 ч до 12 ч). + + Правило в `WHERE` у DO UPDATE: строку берём, если она ИСТЕКЛА (живого владельца нет), + ИЛИ она уже наша (та же метка — обычная эскалация), ИЛИ мы боевой сбор (`live_reason`). + Иначе — ничего: ни reason, ни ban_count, ни срок. Продлевать чужой бан «безвредно» + только на словах: срок пересчитывается от now() по НАШЕЙ эскалации и способен + УКОРОТИТЬ уже эскалированный чужой бан. Бан и так стоит — делать нечего. + + АСИММЕТРИЯ НАМЕРЕННАЯ: боевой сбор строку пробы перехватывает. Его вердикт сильнее + (площадка реально отбила именно сейчас), пара остаётся забаненной, а метка становится + ТОЧНЕЕ. Запретить ему это значило бы оставить строку за пробой — и её же зелёный + robots.txt снёс бы настоящий бан площадки, то есть тот самый дефект, только зеркально + и хуже. Цена перехвата — ban_count наследуется (отдых чуть длиннее заслуженного); + обнулять его на смене владельца нельзя: тогда запись пробы стирала бы память об + эскалации боевых банов пары. + + Отличается от `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 решает, ловить или нет. + """ + # Метка боевого сбора: право перехватить АКТИВНУЮ строку пары есть только у неё + # (см. докстринг "ВЛАДЕЛЬЦА АКТИВНОЙ СТРОКИ НЕ МЕНЯЕМ"). + live_reason = f"banned:{source}" + effective_reason = reason or live_reason + # Сериализует 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() + -- Владельца АКТИВНОЙ строки не меняем: берём истёкшую (владельца нет), + -- свою же (обычная эскалация) или перебиваем боевым сбором — он сильнее + -- пробы. Иначе 0 rows и ветка "deferred" ниже (дефект #2803). + WHERE scrape_proxy_source_bans.banned_until <= now() + OR scrape_proxy_source_bans.reason = CAST(:reason AS text) + OR CAST(:reason AS text) = CAST(:live_reason AS text) + RETURNING ban_count, banned_until + """ + ), + { + "proxy_id": proxy_id, + "source": source, + "reason": effective_reason, + "live_reason": live_reason, + "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 "banned" + + # 0 rows — ТРИ разные причины, и путать их нельзя: чужой активный владелец, защита + # последнего узла, отсутствующий узел. Читаем состояние ТОЛЬКО ради точного лога + # (на решение уже не влияет), но диагноз должен называть то, что произошло. + holder = ( + db.execute( + text( + """ + SELECT reason, banned_until + FROM scrape_proxy_source_bans + WHERE proxy_id = CAST(:proxy_id AS bigint) + AND source = CAST(:source AS text) + AND banned_until > now() + """ + ), + {"proxy_id": proxy_id, "source": source}, + ) + .mappings() + .fetchone() + ) + if holder is not None and holder["reason"] != effective_reason: + logger.info( + "proxy_pool: proxy id=%d source=%s — бан пары уже стоит от %r до %s; вердикт " + "%r его НЕ перебивает (владельца активной строки меняет только боевой сбор, " + "иначе проба присвоила бы чужой бан и потом сняла бы его как свой)", + proxy_id, + source, + holder["reason"], + holder["banned_until"], + effective_reason, + ) + return "deferred" + + 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) + return "missing" + logger.warning( + "proxy_pool: proxy id=%d — бан не записан: это последний узел, достижимый для " + "source=%s; нужны новые прокси (см. #2638). Узел продолжит выдаваться этому " + "источнику (голодание хуже, чем работа через забаненный узел).", + proxy_id, + source, + ) + return "protected" + + +def clear_source_bans( + db: Session, + proxy_id: int, + *, + source: str | None = None, + reason: str, + only_reason: str | None = None, +) -> 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»). + + `only_reason` — ФИЛЬТР по колонке reason, т.е. «снимать только строки, которые + написал я» (#2800). Нужен браузерной пробе: её успешный robots.txt — слабое + свидетельство, площадка отдаёт его и забаненному IP, поэтому снимать им бан, + распознанный боевым сбором по капче/QRATOR-заглушке, нельзя. Оператор и ротация + IP этот фильтр НЕ ставят: там повод как раз объявить историю пары недействительной + целиком. None — снимать всё, как и раньше. + """ + 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)) + AND (CAST(:only_reason AS text) IS NULL OR reason = CAST(:only_reason AS text)) + RETURNING source + """ + ), + {"proxy_id": proxy_id, "source": source, "only_reason": only_reason}, + ).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 mark_source_probe( + db: Session, + proxy_id: int, + *, + source: str, + ok: bool, + fail_kind: str | None = None, + detail: str = "", +) -> str: + """Записать вердикт браузерной пробы по ПАРЕ «узел × источник» (#2800). + + Пара — то, чего до сих пор не хватало: узел не «жив/мёртв» вообще, он годен или + не годен КОНКРЕТНОЙ площадке. Хранилище для этого уже есть и его уже читает + `acquire(source)` — `scrape_proxy_source_bans`; новой сущности не заводим. + + КОМУ ПРИНАДЛЕЖИТ ОТКАЗ (шкала та же, что у `classify_browser_probe`, но граница + другая — здесь судится ПАРА, а не узел): + - "sidecar" — общая зависимость лежит, к паре отношения не имеет → "ignored". + Иначе одна упавшая зависимость забанила бы разом все пары (#2686 в третий раз); + - "proxy" — через этот узел до площадки не доходит транспорт + (NS_ERROR_PROXY_*, camoufox не поднялся) → бан пары; + - "page" — дошли, но площадка отдала ЭТОМУ exit-IP не ресурс, а заглушку + (200 + «Ошибка — Циан» вместо robots.txt) → тоже бан пары. + Для УЗЛА этот исход по-прежнему «не виноват» (см. mark_browser_health), для + ПАРЫ — виноват ровно он: собирать через такой узел эту площадку нельзя. + + Успех снимает ТОЛЬКО строку, написанную пробой (`only_reason`). Бан, распознанный + боевым сбором, остаётся: robots.txt площадка отдаёт и забаненному IP, и разрешить + дешёвой пробе гасить дорогой вердикт значило бы повторить #2723 на паре. Обратная + половина того же правила живёт в `mark_banned`: чужую АКТИВНУЮ строку проба не + присваивает (дефект #2803) — иначе фильтр `only_reason` перестаёт защищать, ведь + присвоенная строка уже «своя». + + Защита последнего узла и эскалация срока — целиком из `mark_banned`, здесь ничего + своего: если после бана у `acquire(source)` не осталось бы кандидатов, бан не + пишется (голодание хуже работы через плохой узел). + + Returns: "ok" | "cleared" (сняли свой бан) | "ignored" | исход `mark_banned` + ("banned" | "deferred" | "protected" | "missing") — счётчик пар считает баном + только реально записанный бан. + """ + if ok: + cleared = clear_source_bans( + db, + proxy_id, + source=source, + reason=f"browser probe OK for source={source} ({detail})", + only_reason=_PROBE_BAN_REASON, + ) + return "cleared" if cleared else "ok" + + if fail_kind not in ("proxy", "page"): + logger.warning( + "proxy_pool: pair probe FAILED id=%d source=%s, но отказ НЕ принадлежит паре " + "(fail_kind=%s): %s — вердикт не пишем", + proxy_id, + source, + fail_kind, + detail, + ) + return "ignored" + + logger.warning( + "proxy_pool: pair probe FAILED id=%d source=%s (fail_kind=%s): %s — пишем бан " + "пары, узел остаётся первосортным для остальных площадок (#2800)", + proxy_id, + source, + fail_kind, + detail, + ) + return mark_banned(db, proxy_id, source=source, reason=_PROBE_BAN_REASON) def reap_stale_leases(db: Session, older_than_minutes: int = STALE_LEASE_MINUTES) -> int: @@ -237,10 +1144,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 +1164,119 @@ 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 _probe_sources_for(affinity: str) -> list[str]: + """Источники, которым узел с такой affinity МОЖЕТ достаться (#2800). + + Ровно предикат основной выборки `acquire`: `provider_affinity IN (:source,'any')`. + Спрашивать площадки, которым узел всё равно не выдадут, — платить за диагностику, + которой никто не воспользуется. + + ponytail: fallback-заход acquire умеет отдать узел и чужому источнику (когда своих + свободных нет) — такая пара останется без вердикта и решится как раньше, по факту + прогона. Полный крест по ВСЕМ источникам для каждого узла стоил бы столько же + только на проде (там сейчас все узлы 'any'), а на пуле с выделенными affinity рос + бы зря. Если fallback станет частым — снять условие, цена известна: N_узлов × 4. + """ + from scraper_kit.browser_fetcher import PROBE_SOURCES + + return [s for s in PROBE_SOURCES if affinity in (s, "any")] + + +async def _run_pair_probes( + db: Session, proxy_id: int, url: str, kind: str, affinity: str +) -> tuple[str, dict[str, int]]: + """Крест «этот узел × каждая его площадка» + запись вердиктов (#2800). + + Возвращает (исход mark_browser_health для УЗЛА, счётчики по парам). + + Два уровня вердикта, и они не пересекаются: + - ПАРА (`mark_source_probe` → scrape_proxy_source_bans) — по каждой площадке + отдельно, это то, что читает `acquire(source)`; + - УЗЕЛ (`mark_browser_health` → browser_fail_streak/browser_unfit_since) — по + итогу ВСЕГО креста: хоть одна площадка ответила → браузерный тракт через узел + работает (ok); все отказали транспортом → отказ узла. Отказ ОДНОЙ площадки + узел глобально не пятнает — иначе на месте вылеченного схлопывания диагнозов + появилось бы новое. + + Best-effort: любой сбой самой пробы (импорт, неожиданное исключение) НЕ роняет + healthcheck — ipify-часть уже отработала и её результат записан. Диагностика не + имеет права ломать то, что диагностирует. + """ + from scraper_kit.browser_fetcher import probe_proxy_via_browser + + counters = {"pair_checked": 0, "pair_banned": 0, "pair_cleared": 0} + fail_kinds: list[str] = [] + any_ok = False + last_detail = "" + + for source in _probe_sources_for(affinity): + try: + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind, source=source + ) + if not ok and fail_kind == "proxy": + # Подтверждение НЕМЕДЛЕННО, а не через такт: запуск camoufox бывает + # флаки сам по себе, а бан пары стоит источнику 6 часов узла. Повтор + # идёт по уже поднятому браузеру с тем же прокси — секунды, и только + # на отказах. Порог «2 подряд» у УЗЛОВОГО вердикта живёт своей жизнью + # (BROWSER_UNFIT_THRESHOLD), здесь он был бы сутками ожидания. + ok, fail_kind, detail = await probe_proxy_via_browser( + _settings.browser_http_endpoint, url, proxy_kind=kind, source=source + ) + except Exception: + logger.warning( + "proxy_pool: pair probe crashed id=%d source=%s — вердикт не записан", + proxy_id, + source, + exc_info=True, + ) + continue + + counters["pair_checked"] += 1 + last_detail = detail + if ok: + any_ok = True + else: + fail_kinds.append(fail_kind or "other") + outcome = mark_source_probe( + db, proxy_id, source=source, ok=ok, fail_kind=fail_kind, detail=detail + ) + if outcome == "banned": + counters["pair_banned"] += 1 + elif outcome == "cleared": + counters["pair_cleared"] += 1 + + if counters["pair_checked"] == 0: + return "ignored", counters # крест не состоялся — узел не судим + + if any_ok: + return mark_browser_health(db, proxy_id, True, detail=last_detail), counters + # Все площадки отказали. Узлу это принадлежит, только если КАЖДЫЙ отказ — + # транспортный: смесь с "page"/"sidecar" значит «дело не (только) в узле». + node_kind = "proxy" if all(k == "proxy" for k in fail_kinds) else fail_kinds[0] + return ( + mark_browser_health(db, proxy_id, False, fail_kind=node_kind, detail=last_detail), + counters, + ) def _mask(url: str) -> str: @@ -269,16 +1292,40 @@ 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). + + БРАУЗЕРНАЯ ПРОБА (#2723, на пару — #2800): узлам, прошедшим ipify и не + проверявшимся браузером дольше BROWSER_PROBE_MINUTES, гоняется КРЕСТ проб ЧЕРЕЗ + САЙДКАР — по одной навигации на каждую площадку, которую этот узел может + обслуживать (тот же тракт, что у боевого сбора: camoufox стартует с этим прокси, + потом навигация на robots.txt РАБОЧЕГО хоста площадки). Вердикт пары идёт в + scrape_proxy_source_bans (его читает acquire(source)), вердикт узла — в отдельные + browser_*-поля; ни один из них не смешивается с consecutive_fails/enabled. Гейт — + settings.use_proxy_pool_browser: при выключенном флаге браузер ходит мимо пула и + проба измеряла бы то, чем никто не пользуется. Пробы идут последовательно — пул небольшой (десятки узлов), а параллельный залп на один и тот же upstream-endpoint (ipify) не нужен. Returns counters - {reaped, checked, ok, failed}. + {reaped, checked, ok, failed, revived, bans_purged, browser_checked, browser_ok, + browser_unfit, browser_refit, pair_checked, pair_banned, pair_cleared}. """ reaped = reap_stale_leases(db) @@ -286,12 +1333,24 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: db.execute( text( """ - SELECT id, url, kind + SELECT id, url, kind, enabled, disabled_reason, provider_affinity, + (browser_check_at IS NULL + OR browser_check_at < now() - make_interval( + mins => CAST(:browser_probe_minutes AS integer) + )) AS browser_probe_due 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, + "browser_probe_minutes": BROWSER_PROBE_MINUTES, + }, ) .mappings() .all() @@ -300,22 +1359,112 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]: checked = 0 ok_count = 0 failed = 0 + revived = 0 + browser_checked = 0 + browser_ok = 0 + browser_unfit = 0 + browser_refit = 0 + pair_checked = 0 + pair_banned = 0 + pair_cleared = 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 + # Браузерная проба (#2723) — только если ipify прошла: провалившая ipify нода + # мертва целиком, диагноз уже поставлен, а запуск camoufox через неё — чистая + # трата 8 секунд. Гейт по use_proxy_pool_browser: при выключенном флаге браузер + # ходит мимо пула (через env-прокси сайдкара), и вердикт об узлах пула был бы + # вердиктом о том, чем никто не пользуется — ровно то расхождение «проба меряет + # не тот узел», из-за которого #2723 и появилась. + if ok and row["browser_probe_due"] and _settings.use_proxy_pool_browser: + outcome, pair_counters = await _run_pair_probes( + db, proxy_id, url, str(row["kind"]), str(row["provider_affinity"]) + ) + browser_checked += 1 + pair_checked += pair_counters["pair_checked"] + pair_banned += pair_counters["pair_banned"] + pair_cleared += pair_counters["pair_cleared"] + if outcome in ("ok", "refit"): + browser_ok += 1 + if outcome == "refit": + browser_refit += 1 + elif outcome == "unfit": + browser_unfit += 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 browser_checked=%d browser_ok=%d browser_unfit=%d browser_refit=%d " + "pair_checked=%d pair_banned=%d pair_cleared=%d", reaped, checked, ok_count, failed, + revived, + purged, + browser_checked, + browser_ok, + browser_unfit, + browser_refit, + pair_checked, + pair_banned, + pair_cleared, ) - 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, + # Счётчики браузерной пробы (#2723) — намеренно ОТДЕЛЬНЫЕ от checked/ok/failed: + # схлопнув их в общие, мы бы своими руками сделали то, за что чиним этот модуль. + "browser_checked": browser_checked, + "browser_ok": browser_ok, + "browser_unfit": browser_unfit, + "browser_refit": browser_refit, + # Вердикты по ПАРАМ (#2800). Тоже отдельно от узловых: browser_ok=1 и + # pair_banned=2 одновременно — это не противоречие, а точный диагноз + # «браузер через узел работает, но две площадки его не пускают». + "pair_checked": pair_checked, + "pair_banned": pair_banned, + "pair_cleared": pair_cleared, + } 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..8331b2d2 100644 --- a/tradein-mvp/backend/app/services/sber_index.py +++ b/tradein-mvp/backend/app/services/sber_index.py @@ -303,6 +303,18 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st #1348: blocking psycopg work — must run via asyncio.to_thread, never directly on the event loop. Idempotent ON CONFLICT(city, period_month, dashboard). + + #2846: `fetched_at` НЕ переписывается при конфликте. Забор идёт ВСЕЙ серией + (limit=1000&offset=0, отсечки по периоду нет), поэтому `fetched_at = now()` в + DO UPDATE ставил одну и ту же метку всем строкам ряда — на проде все 639 строк + несли время последнего прогона, включая период 2017-01. Как признак свежести + колонка была пуста. Теперь она означает «когда мы ВПЕРВЫЕ увидели этот период», + то есть по ней измеряется ТАКТ ПУБЛИКАЦИИ источника (min(fetched_at) по новым + периодам). Ретроспективу это не возвращает: у 639 уже лежащих строк метка + 2026-08-06 и она останется — такт публикации до этого PR невосстановим. + Времени последней ЗАГРУЗКИ колонка больше не хранит; оно и не нужно — + scrape_runs(source='sber_index_pull') хранит его точнее (с errors/upserted), + и именно оттуда его берёт tasks/sber_freshness_monitor. """ for city_label, period_month, segment, dash, value in rows_to_upsert: db.execute( @@ -323,8 +335,8 @@ def _upsert_rows_sync(db: Session, rows_to_upsert: list[tuple[str, date, str, st ON CONFLICT (city, period_month, dashboard) DO UPDATE SET index_value_rub_m2 = EXCLUDED.index_value_rub_m2, - segment = EXCLUDED.segment, - fetched_at = now() + segment = EXCLUDED.segment + -- fetched_at НЕ трогаем (#2846): она = «впервые увидели период». """ ), { @@ -464,8 +476,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..6fca052d 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, Collection, Mapping +from functools import cache from typing import Any import sentry_sdk @@ -21,6 +48,323 @@ 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" # наш сайдкар/прокси не отдал страницу — внутреннее +# #2764: причина НЕ установлена. Дефолт mark_banned — именно он, а не 'platform': +# на проде оба прогона, помеченных после миграции 218, получили 'platform' по +# умолчанию (ни один их не передавал), то есть метка выглядела доказательством, не +# будучи им. 'unknown' делает пробел измеримым (SELECT ban_kind, count(*)), а +# 'platform'/'infra' начинают означать ровно то, что доказано типом исключения. +BAN_KIND_UNKNOWN = "unknown" + + +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 успешных прогонов +# реально дали ноль. +# succeeded — yandex_newbuilding_sweep (42 прогона/90д) и newbuilding_enrich +# (65 прогонов/90д, единственные два писателя ключа на проде, +# проверено 2026-08-15). НЕ 'rows_inserted': тот ключ пишет ЕЩЁ и +# rosreestr_dkp_import (67 прогонов/90д) — у него rows_inserted=0 в +# 66 из 67 это ЗДОРОВЫЙ ответ догнавшего инкрементального импорта +# (rows_fetched=rows_skipped=96974, last_id не двигается неделями), +# а не отказ; если бы 'rows_inserted' попал в этот список, сторож +# зачитывал бы этот здоровый ноль как измеренный провал и копил бы +# практически непрерываемый стрик (rosreestr_dkp_import не +# прерывается другим статусом — импорт либо 'done', либо не бежал). +# НЕ 'processed' по той же причине с другой стороны: это счётчик +# ПОПЫТОК (у newbuilding_enrich processed==attempted==limit даже +# когда succeeded меньше — прод-факт 09.08: processed=25 succeeded=14, +# 44% отказов замаскировались бы под measured-25) — сторож нулевого +# результата на нём молчал бы ровно там, где должен сработать, а на +# будущем опустении очереди домов (cian_houses_pending) создал бы +# свой вечный ложный zero-стрик. 'succeeded' у yandex_newbuilding_sweep +# численно совпадает с 'rows_inserted' на всех 42/42 прод-прогонах — +# замена не теряет исходную цель (десять прогонов подряд 26.07-10.08, +# все 'done', succeeded=0 rows_inserted=0 failed_resolve=4-5 — раньше +# ни total_seen/lots_fetched/unique_fetched не было, и +# _run_result_count всегда возвращал None (honest-run-status)). +# Сводить сюда счётчики ОСТАЛЬНЫХ задач бессмысленно: на проде 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", + "succeeded", +) + + +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 _sweep_run_did_nothing(counters: Mapping[str, Any]) -> str | None: + """Развёртка, у которой КАЖДЫЙ якорь кончился отказом и не принесла ничего (#2625). + + Возвращает текст причины (для error) либо None, если прогон таким не является. + + Третий исход, у которого не было терминального статуса. Развёртка различает: + 1. «площадка отбила» — попытки разбора были, структура не извлеклась ни разу → + `mark_banned` в самих sweep'ах (#2642, cian/yandex); + 2. «площадка честно отдала пустоту» — валидный ответ, ноль предложений → + `done` с нулём, это здоровый результат (в Серове реально 10 объявлений); + 3. «мы не дошли» — якорь упал по таймауту или исключению ДО того, как + что-либо стало разбирать. Ровно этот случай в счётчики бана не попадает + НАМЕРЕННО (#2600 п.1: transport_error не должен выглядеть баном площадки), + и статуса ему никто не выдал — прогон уходил в `done`. + + Признак — собственная бухгалтерия прогона, а не список известных антибот-маркеров: + `errors_count >= anchors_total` при нулевом ИЗМЕРЕННОМ результате означает, что + отказом кончился каждый якорь, который у прогона был, и собрано ноль. Это НЕ + доказывает, КТО виноват (капча площадки / наш прокси / наш баг), поэтому статус + 'failed' без диагноза, а не 'banned' с 'platform' (#2764: диагноз не назначается + по умолчанию). + + Что признак НЕ ловит: прогон, где часть якорей отдала данные, а часть отказала — + `errors_count < anchors_total`, статус остаётся 'done' (частичный сбор — сбор). + + Замер на проде 2026-08-10 за 90 суток: под правило попадают 28 прогонов + (yandex_city_sweep_nizhniy_tagil 16 подряд по 15-30.07 — каждый ровно 240 с, + таймаут якоря, 0 лотов, 'done'; yandex_city_sweep 6; avito_city_sweep 5; + yandex_city_sweep_pervouralsk 1 от 09.08 — 155 мс, исключение до первого запроса). + НЕ затронуты: 132 прогона с отказами, но ненулевым сбором, и 37 прогонов честной + пустоты (errors_count=0) — они остаются 'done'. + """ + anchors = _pick_int(counters, "anchors_total") + errors = _pick_int(counters, "errors_count") + if not anchors or anchors <= 0 or errors is None or errors < anchors: + return None + if _run_result_count(counters) != 0: # None (не измерено) сюда тоже НЕ попадает + return None + return ( + f"sweep-honest-status: отказом кончились все {anchors} якорей прогона " + f"(errors_count={errors}), собрано 0 — работа не сделана. Причина НЕ " + f"установлена: якорь мог упасть по таймауту, из-за нашего прокси или " + f"блокировкой площадки — статус 'failed' без диагноза (#2625)" + ) + + +# #2700: сколько попыток фазы должно быть, чтобы «отказали все» что-то значило. +# 3 — не круглое число, а порог, на котором сам сбор уже сдаётся: столько подряд +# неудачных detail'ов достаточно оркестратору, чтобы ротировать прокси и оборвать фазу +# (_cian_detail_abort в orchestration/pipeline.py). Замер на проде 2026-08-10 за 90 +# суток: порог отсекает 2 прогона с ЕДИНСТВЕННОЙ попыткой (одиночный отказ — шум, не +# диагноз) и оставляет 50 прогонов, где отказали 3-50 попыток подряд. +_PHASE_MIN_ATTEMPTS = 3 + + +def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None: + """Фаза прогона, у которой отказала КАЖДАЯ попытка (#2700). Текст причины или None. + + Прогон состоит из фаз, а статус у него один. `_sweep_run_did_nothing` (#2625) ловит + случай, когда не сделано НИЧЕГО; этот — когда целое направление работы отказало на + сто процентов, а соседнее сработало, и суммарный ненулевой сбор прячет отказ. + + Живой повод (#2700): `cian_city_sweep` 15 суток подряд писал `detail_attempted=50, + detail_failed=50, errors_count=0, status=done` — каждая detail-страница отдавала + HTTP 403. Ноль обогащённых при 1 680 собранных лотах внешне неотличим от здорового + прогона: результатный счётчик (lots_fetched) ненулевой, а до `errors_count` отказ + подзадачи не доходил вовсе (403 гасился внутри провайдера в `return None`). + + Признак — собственная бухгалтерия фазы: `_failed == _attempted` при + `attempted >= _PHASE_MIN_ATTEMPTS`. Пары ищутся В САМИХ counters (любой ключ + `X_attempted` со спутником `X_failed`), а не по зашитому списку фаз: список — это + ровно то место, куда забывают дописать новую фазу, и тогда сторож молчит, выглядя + настроенным. На проде за 90 суток таких пар четыре: detail/houses/address/imv. + + Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому + 'failed' без диагноза, как и в #2625/#2764, а не 'banned'/'platform'. + + Замер на проде 2026-08-10 за 90 суток, ПРОГНАННЫЙ УЖЕ ДЕПЛОЙНУТОЙ функцией по + боевым counters (3 574 прогона, из них 3 293 'done'): правило переводит в 'failed' + 42 прогона (1.3%) — 31 cian_city_sweep* и 11 avito_city_sweep*; про вторые никто не + знал. Остальные 3 251 остаются 'done'. Первая версия этого абзаца называла 52 — + это было число ПАР «прогон × фаза» из SQL-замера, а не прогонов: у 10 прогонов + отказали обе фазы (detail и houses) сразу, и они посчитались дважды. + """ + for key in sorted(counters): + if not key.endswith("_attempted"): + continue + phase = key[: -len("_attempted")] + attempted = _pick_int(counters, key) + failed = _pick_int(counters, f"{phase}_failed") + if attempted is None or failed is None: + continue + if attempted >= _PHASE_MIN_ATTEMPTS and failed == attempted: + return ( + f"phase-honest-status: фаза '{phase}' отказала полностью — " + f"{failed} из {attempted} попыток неудачны, обогащено 0. Остальные фазы " + f"прогона могли отработать, поэтому ненулевой сбор это НЕ опровергает. " + f"Причина НЕ установлена: блок площадки, наш прокси или разбор — статус " + f"'failed' без диагноза (#2700)" + ) + return None + + +# honest-run-status (2026-08-15): доля отказов, которая обесценивает формально ненулевой +# сбор. Прод-факт avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6, +# "blocked":1} — 89% попыток отказали, а mark_backfill_finished всё равно звал mark_done, +# потому что "produced != 0" (6 обогащено). Ни _sweep_run_did_nothing (нужны +# anchors_total/errors_count, у backfill'ов их нет), ни _phase_totally_failed (нужна пара +# "_attempted"/"_failed" — здесь голые "attempted"/"failed" без фазового +# префикса, `"attempted".endswith("_attempted")` не матчит) эту форму counters не ловят — +# обе проверки написаны под СВОИ формы, а не под backfill'овскую. +# +# Порог 'failed' — половина и больше отказов: сбор для практических целей провалился, +# даже если несколько записей всё же обогатились. Порог 'partial' НЕ заведён отдельным +# статусом scrape_runs.status — это потребовало бы миграции (DROP+ADD CHECK constraint, +# 051_scrape_runs_extend.sql) и обучило бы новому значению ещё 4 места (Literal-фильтр +# admin API, хардкод статусов фронта, оба IN-списка сторожей) — тот же класс "оборванной +# проводки", из-за которого заведён #2686/ban_kind. Вместо статуса — тот же диагноз, что и +# у ban_kind: causa в тексте `error`, терминальный статус один ('failed'). 0.15..0.5 — +# та же 'failed', но с другой формулировкой причины ("деградировал", не "провалился"), чтобы +# оператор видел разницу читая error, не только status. +FAILED_RATIO_FAILED_THRESHOLD = 0.5 +FAILED_RATIO_DEGRADED_THRESHOLD = 0.15 +# Минимум попыток, при котором доля вообще что-то значит — иначе 1 отказ из 2 (=0.5) +# палит статус на шуме единичного случая. То же рассуждение и то же число, что у +# _PHASE_MIN_ATTEMPTS (см. выше). +_FAILED_RATIO_MIN_ATTEMPTS = _PHASE_MIN_ATTEMPTS + + +def _failed_ratio_too_high(counters: Mapping[str, Any]) -> str | None: + """Прогон, у которого доля отказов слишком велика, даже если что-то собрано. + + Возвращает текст причины (для error) либо None. Читает ГОЛЫЕ ключи "attempted"/ + "failed" (без фазового префикса) — сейчас это словарь только у четырёх + detail-backfill'ов (avito/yandex/domclick/newbuilding_enrich), все идут через + mark_backfill_finished → mark_done. `attempted < _FAILED_RATIO_MIN_ATTEMPTS` или + отсутствие любого из ключей → None (нечем/не о чём судить — счётчики либо не + заполнены, либо принадлежат другому источнику со своим словарём). + + Что признак НЕ доказывает: КТО виноват (площадка, наш прокси, наш парсер) — поэтому + 'failed' без диагноза, как и у #2625/#2700/#2764. + """ + attempted = _pick_int(counters, "attempted") + failed = _pick_int(counters, "failed") + if attempted is None or failed is None or attempted < _FAILED_RATIO_MIN_ATTEMPTS: + return None + ratio = failed / max(attempted, 1) + if ratio >= FAILED_RATIO_FAILED_THRESHOLD: + verb = "провалился" + elif ratio >= FAILED_RATIO_DEGRADED_THRESHOLD: + verb = "деградировал" + else: + return None + return ( + f"failed-ratio-honest-status: сбор {verb} — {failed} из {attempted} попыток " + f"отказали (доля {ratio:.0%}); формально ненулевой результат этого не искупает. " + f"Причина НЕ установлена — статус 'failed' без диагноза" + ) + def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: """Извлечь значения для dedicated-колонок total_seen / new_count из jsonb-counters. @@ -32,41 +376,48 @@ 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 / + succeeded) + - new_count ← 'new_count' / 'lots_inserted' / 'saved_inserted' / 'rows_inserted' + (первый присутствующий). 'saved_inserted' — full-load'ы (cian/avito/yandex, + CianFullLoadCounters и аналоги в pipeline.py): на проде витрина показывала + new_count=0 у трёх подряд cian_full_load при реально сохранённых + saved_inserted=482/214/239 (honest-run-status) — ключ 'new_count'/'lots_inserted' + у full-load'ов в counters не пишется вовсе. 'rows_inserted' — тот же ключ, + которым yandex_newbuilding_sweep и rosreestr_dkp_import сообщают число upsert'ов; + здесь (для витринной колонки new_count) это безопасно — в отличие от + _RESULT_COUNTER_KEYS этот список не участвует в подсчёте zero-result-стрика. Возвращает (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", "saved_inserted", "rows_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 +428,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 +546,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 +580,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 +591,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 +608,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,17 +639,48 @@ 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). + + #2625: сюда же сведён отказ называть успехом прогон, у которого отказом кончился + каждый якорь и собрано ноль — см. _sweep_run_did_nothing. Проверка стоит здесь, а + не в каждом sweep'е, ровно потому, что вызывающих у mark_done четыре десятка: + страж, который надо не забыть позвать, — это тот же дефект оборванной проводки, + из-за которого задача и появилась. + + #2700: там же — отказ называть успехом прогон, у которого отказала КАЖДАЯ попытка + целой фазы (см. _phase_totally_failed). Отличие от #2625: тот случай про «не сделано + ничего», этот — про «одно направление работы мертво, а суммарный сбор это прячет». + + honest-run-status: там же — отказ называть успехом прогон с высокой долей отказов, + даже если собрано > 0 (см. _failed_ratio_too_high). Отличие от #2625/#2700: те два + смотрят на «всё или ничего» (все якоря / вся фаза), этот — на ДОЛЮ отказов у + detail-backfill'ов, где ни один из первых двух признаков не матчит форму counters. """ + did_nothing = _sweep_run_did_nothing(counters) + if did_nothing is not None: + logger.error("%s run_id=%d", did_nothing, run_id) + mark_failed(db, run_id, did_nothing, counters) + return + phase_dead = _phase_totally_failed(counters) + if phase_dead is not None: + logger.error("%s run_id=%d", phase_dead, run_id) + mark_failed(db, run_id, phase_dead, counters) + return + ratio_bad = _failed_ratio_too_high(counters) + if ratio_bad is not None: + logger.error("%s run_id=%d", ratio_bad, run_id) + mark_failed(db, run_id, ratio_bad, counters) + return total_seen, new_count = _column_counts(counters) row = db.execute( 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 +698,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 +719,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 +743,30 @@ 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_UNKNOWN, +) -> None: + """Финализация run: status='banned' + диагноз ban_kind (#2686, дефолт — #2764). 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 — упала НАША инфраструктура (браузерный сайдкар/прокси); + - BAN_KIND_UNKNOWN (дефолт) — причина не установлена. + Значение приходит от места ПОРОЖДЕНИЯ отказа (тип исключения), а не из разбора + текста ошибки. Дефолт 'unknown', а НЕ 'platform' (#2764): вызывающий, которому + разводить нечего, ничего и не знает — а не «знает, что виновата площадка». + + Оба исхода одинаково сохраняют чекпоинт — они отличаются только диагнозом. Defensive rollback: если до этого вызова в той же транзакции был ошибочный UPDATE, он мог оставить сессию в error state — rollback сбрасывает состояние. @@ -269,8 +780,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 +794,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 +806,117 @@ 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, + fail_hint: str | None = None, + ban_kinds: Collection[str] = (), +) -> 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`: прогон, + который подтвердил снятие объявлений, работу сделал. + + `fail_hint` — самая частая причина отказа этого прогона (задача считает её сама, + см. avito_detail_backfill._failure_signature). Дописывается в текст статуса, + потому что «blocked=5, обогащено 0» не отвечает на единственный вопрос, ради + которого статус и читают: отказала площадка или наш тракт (#2686, #2698). Логи + контейнера на этот вопрос отвечать не могут — они исчезают при пересоздании + контейнера, то есть на первом же деплое после ночного прогона. + + `ban_kinds` — диагнозы (ban_kind_of_exception) ВСЕХ блоков, которые задача + поймала за прогон; пустой (дефолт) = задача типы не различает. Схлопываем сами, + в одном месте на все три backfill'а: все блоки сошлись в одном диагнозе → он и + пишется; разошлись (или их типы ничего не доказывают) → 'unknown'. Смешанный + прогон честнее пометить неизвестным, чем выбрать из двух причин ту, что + попалась последней — какая из них оборвала прогон, мы не знаем (#2764). + """ + 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) + hint = f"; причина: {fail_hint}" if fail_hint else "" + + 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} попыток{hint} (#2674)" + ) + logger.error("%s run_id=%d", reason, run_id) + kinds = set(ban_kinds) + mark_banned( + db, + run_id, + reason, + counters, + ban_kind=kinds.pop() if len(kinds) == 1 else BAN_KIND_UNKNOWN, + ) + return + + if produced == 0: + reason = ( + f"backfill-honest-status: {source} без результата — 0 обогащено из " + f"{attempted} попыток (failed={counters.get('failed', 0)}, " + f"blocked={blocked}){hint} (#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 +984,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 +999,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..01bf055a 100644 --- a/tradein-mvp/backend/app/services/scraper_adapters.py +++ b/tradein-mvp/backend/app/services/scraper_adapters.py @@ -66,8 +66,8 @@ class RealMatcherAdapter: *, year_built: int | None = None, building_cadastral_number: str | None = None, - cadastral_number: str | None = None, source_url: str | None = None, + city: str | None = None, ) -> tuple[int | None, float, str]: # house_id is None when the matcher refuses a numberless address without a # cadastral number (method 'no_house_number', P1). Callers must tolerate None. @@ -80,8 +80,8 @@ class RealMatcherAdapter: lon, year_built=year_built, building_cadastral_number=building_cadastral_number, - cadastral_number=cadastral_number, source_url=source_url, + city=city, ) def upsert_listing_source( @@ -136,10 +136,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 +144,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 +181,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 +207,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 +260,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/services/yandex_address_backfill.py b/tradein-mvp/backend/app/services/yandex_address_backfill.py index 1b5984f3..b4a660c5 100644 --- a/tradein-mvp/backend/app/services/yandex_address_backfill.py +++ b/tradein-mvp/backend/app/services/yandex_address_backfill.py @@ -111,7 +111,7 @@ async def backfill_yandex_addresses( Returns: YandexAddressBackfillResult with checked/saved/skipped/errors counters. """ - from app.core.config import settings + from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url result = YandexAddressBackfillResult() t0 = time.time() @@ -130,7 +130,23 @@ async def backfill_yandex_addresses( request_delay_sec, ) - _proxy_url = settings.scraper_proxy_url + # Резолвер по источнику (#2825): пул scrape_proxies с учётом + # scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если пул + # пуст (легитимный dev/staging-сценарий). + try: + _proxy_url = resolve_proxy_url(db, "yandex") + except ProxyPoolExhaustedError as exc: + # Fail-closed (#2616, #2825): пул не пуст, но все узлы забанены для yandex/ + # нездоровы — НЕ уходим на settings.scraper_proxy_url (см. proxy_egress module + # docstring). Явный пропуск run'а вместо слепого прохода через egress, который + # мог быть источником текущего инцидента. + logger.error( + "yandex_address_backfill: пул прокси исчерпан для yandex (%s) — run " + "пропущен, ни один листинг не обработан", + exc, + ) + result.duration_sec = time.time() - t0 + return result _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None async with AsyncSession( 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..d572c1bd 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -10,8 +10,22 @@ 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. + +Отказы, не являющиеся блоками, до 2026-08-06 брейкера не имели вовсе: прогоны +3-5 августа делали ~1600 попыток, получали 1600 отказов, ноль обогащений и +выедали весь бюджет (9000 с) вместе с 1600 запросами через единственный прокси. +Теперь такая серия обрывается по max_consecutive_failures, а самая частая причина +отказа пишется в текст статуса прогона (_failure_signature) — иначе она живёт +только в логах контейнера, а те исчезают на первом же деплое. + +ДИАГНОЗ бана (#2764): каждый пойманный блок классифицируется по ТИПУ исключения +(ban_kind_of_exception) и уходит в scrape_runs.ban_kind. До этой правки прогон +3306 (blocked=5, 0 обогащено) получил 'platform' по УМОЛЧАНИЮ — финализатор +диагноз не передавал, а browser-режим fetch_detail всё равно превращал отказ +сайдкара в AvitoBlockedError, так что передавать было бы нечего. """ from __future__ import annotations @@ -19,7 +33,9 @@ from __future__ import annotations import asyncio import logging import random +import re import time +from collections import Counter from dataclasses import dataclass, field from urllib.parse import urlparse @@ -30,6 +46,7 @@ from scraper_kit.avito_exceptions import ( AvitoRateLimitedError, ) from scraper_kit.browser_fetcher import BrowserFetcher +from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, ban_kind_of_exception # #2397 slice B (эпик #2277 decommission scrape_pipeline.py, Part E): раньше # _CHROME_HEADERS/_avito_proxies() импортировались из app.services.scrape_pipeline. @@ -46,12 +63,14 @@ 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 from app.core.config import settings from app.core.shutdown import shutdown_requested from app.services import scrape_runs as runs_mod +from app.services.proxy_egress import resolve_proxy_url from app.services.scraper_adapters import RealScraperConfig # #2397 Part D1 (#2330 закрыт): _AVITO_WARM_SEARCH_URL/build_warmed_session больше @@ -70,6 +89,70 @@ __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' матчит оба варианта -- позитивный кейс не сломан. +# +# #262 wave 2: avito_slug — Optional в CityLocation (не у каждого oblast-города +# подтверждён). Города без avito_slug пропускаем целиком — у них НЕТ avito_city_ +# sweep schedule (262_), значит НЕТ и avito-листингов с их URL; паттерн для них +# был бы либо мёртвым, либо (что хуже) построен из city_slug вместо реального +# avito URL-сегмента и создал бы ложный LIKE-матч. +_OBLAST_AVITO_URL_PATTERNS = tuple( + "%/" + loc.avito_slug.replace("\\", "\\\\").replace("_", "\\_").replace("%", "\\%") + "/%" + for loc in CITY_LOCATIONS.values() + if loc.avito_slug is not None +) + + +# Причина отказа карточки без её URL: 1576 отказов одного прогона должны схлопнуться +# в ОДНУ строку, иначе перепись бесполезна. +_URL_IN_MESSAGE_RE = re.compile(r"https?://\S+") + + +def _failure_signature(exc: BaseException) -> str: + """Подпись причины отказа: тип исключения + текст без URL. + + Зачем (замер 2026-08-06): у прогонов 3 и 4 августа counters говорили + `attempted=1576, failed=1576, blocked=0` — и ничего больше. Кто отказал, + площадка или наш тракт, было видно ТОЛЬКО в логах контейнера, а тот + пересоздаётся на каждом деплое и уносит их с собой; в GlitchTip попадают + события уровня ERROR, а поштучные отказы — WARNING. Разница между этими + двумя диагнозами — разные владельцы задачи (#2686, #2698), поэтому она + обязана переживать перезапуск контейнера, то есть лежать в самом прогоне. + + Тип исключения — первый разряд диагноза (AvitoBlockedError = площадка + показала 403/firewall; сетевой класс curl_cffi = наш прокси-тракт; + ValueError = ответ пришёл, но не разобран), текст — второй. + """ + message = _URL_IN_MESSAGE_RE.sub("", str(exc)).strip() + return f"{type(exc).__name__}: {message}"[:160] if message else type(exc).__name__ + + +def _top_failure(census: Counter[str]) -> str | None: + """Самая частая причина отказа с её долей; None — отказов не было.""" + if not census: + return None + reason, hits = census.most_common(1)[0] + return f"{reason} ({hits} из {sum(census.values())})" + @dataclass class AvitoDetailBackfillResult: @@ -102,18 +185,34 @@ 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. + max_consecutive_failures: int -- порог обрыва по отказам-не-блокам, + default 25 (см. комментарий у чтения параметра ниже). 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)) + # Брейкер на отказы-НЕ-блоки. Блоки свой брейкер имели с самого начала, отказы — + # нет, и это стоило трёх ночей подряд: 3-5 августа прогон делал ~1600 попыток, + # получал 1600 отказов, ноль обогащений и выедал весь бюджет 9000 с (плюс 1600 + # запросов через единственный прокси, #2638). Порог заметно выше блочного: пачка + # мёртвых карточек (404 → ValueError в curl-режиме) не должна обрывать здоровый + # прогон, а 25 отказов подряд без единого успеха — уже не невезение. + max_consecutive_failures = int(params.get("max_consecutive_failures", 25)) warm_batch = int(params.get("warm_batch", 500)) research_every = int(params.get("research_every", 50)) block_cooldown_sec = float(params.get("block_cooldown_sec", 30.0)) @@ -141,7 +240,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() @@ -177,42 +276,78 @@ async def run_avito_detail_backfill( elif not use_curl: # curl_cffi legacy path (scraper_fetch_mode="curl_cffi", use_curl=False): # строим shared сессию через auv, как делает run_avito_city_sweep (kit). + # Резолвер по источнику (#2825): пул scrape_proxies с учётом + # scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только + # если пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все + # забанены/нездоровы для avito -- resolve_proxy_url бросает + # ProxyPoolExhaustedError (fail-closed, #2616): НАРОЧНО не ловим здесь -- + # штатный except Exception ниже (mark_failed + logger.exception + raise) + # уже даёт явную деградацию run'а с понятным логом, отдельный catch не нужен. own_session = True session = AsyncSession( impersonate="chrome120", timeout=25, headers=DOCUMENT_HEADERS, - proxies=http_proxies(settings.scraper_proxy_url), + proxies=http_proxies(resolve_proxy_url(db, "avito")), ) scraper._cffi = session 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 +362,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,8 +379,17 @@ async def run_avito_detail_backfill( ) consecutive_blocks = 0 + consecutive_failures = 0 + aborted_by_blocks = False do_sleep = False items_since_warm = 0 + # Перепись причин (блоки + отказы) — переживает пересоздание контейнера, + # в отличие от логов; см. _failure_signature. + failure_census: Counter[str] = Counter() + # #2764: диагнозы всех блоков прогона по ТИПУ исключения. Сойдутся в один — + # он и попадёт в scrape_runs.ban_kind, разойдутся — 'unknown' (схлопывает + # mark_backfill_finished, один узел на все три backfill'а). + block_ban_kinds: set[str] = set() for idx, row in enumerate(snapshot): # Budget guard @@ -359,8 +508,9 @@ async def run_avito_detail_backfill( if use_curl: items_since_warm += 1 consecutive_blocks = 0 + consecutive_failures = 0 - except AvitoListingGoneError: + except AvitoListingGoneError as gone_exc: # #2034: мёртвый листинг (404 / removed) — НЕ блок, НЕ failed. # Координатные дыры в lat-null очереди в основном dead-листинги; # browser-mode рендерит их «Ошибка 404» без item-view → раньше это @@ -370,12 +520,31 @@ async def run_avito_detail_backfill( # и не сбрасываем). Метим is_active=FALSE → листинг уходит из scope # (snapshot SELECT фильтрует is_active = TRUE) и не тратит фетчи впредь. counters.gone += 1 + # 404 — честный ответ площадки, значит тракт цел: серия отказов + # прерывается (блочный брейкер 404 не трогает — см. #2034). + consecutive_failures = 0 + failure_census[_failure_signature(gone_exc)] += 1 try: with db.begin_nested(): db.execute( 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 " @@ -396,6 +565,8 @@ async def run_avito_detail_backfill( except (AvitoBlockedError, AvitoRateLimitedError) as e: consecutive_blocks += 1 counters.blocked += 1 + failure_census[_failure_signature(e)] += 1 + block_ban_kinds.add(ban_kind_of_exception(e)) do_sleep = False logger.warning( "avito_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s", @@ -416,6 +587,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; ротация только вручную @@ -452,12 +624,14 @@ async def run_avito_detail_backfill( exc_info=True, ) - except TimeoutError: + except TimeoutError as e: # asyncio.wait_for → TimeoutError (py3.12: asyncio.TimeoutError — alias). # Ловим ДО общего Exception (TimeoutError ⊂ OSError ⊂ Exception). Зависший # fetch отменён → листинг failed, переходим к следующему (loop не зависает, # run не zombie #1950). Не считаем soft-блоком: rotate не дёргаем. counters.failed += 1 + consecutive_failures += 1 + failure_census[_failure_signature(e)] += 1 logger.warning( "avito_detail_backfill: run_id=%d listing %s TIMEOUT (>%.0fs) -- skip", run_id, @@ -471,6 +645,8 @@ async def run_avito_detail_backfill( except Exception as e: counters.failed += 1 + consecutive_failures += 1 + failure_census[_failure_signature(e)] += 1 logger.warning( "avito_detail_backfill: run_id=%d listing %s failed: %s", run_id, @@ -482,15 +658,35 @@ async def run_avito_detail_backfill( except Exception: pass + if consecutive_failures >= max_consecutive_failures: + logger.error( + "avito_detail_backfill: run_id=%d ABORT -- %d отказов подряд без " + "единого успеха, частая причина: %s. enriched=%d attempted=%d", + run_id, + consecutive_failures, + _top_failure(failure_census) or "неизвестна", + counters.enriched, + counters.attempted, + ) + break + if counters.attempted % 25 == 0: current_counters = counters.to_dict() runs_mod.update_heartbeat(db, run_id, current_counters) 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, + fail_hint=_top_failure(failure_census), + ban_kinds=block_ban_kinds, + ) 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_house_coords_from_listings.py b/tradein-mvp/backend/app/tasks/backfill_house_coords_from_listings.py new file mode 100644 index 00000000..6036f869 --- /dev/null +++ b/tradein-mvp/backend/app/tasks/backfill_house_coords_from_listings.py @@ -0,0 +1,323 @@ +"""Перенос координат из объявлений в дом, когда объявления между собой согласны (#2771). + +ЗАЧЕМ. Дом получает координаты РОВНО ОДИН РАЗ — в момент создания, из породившего его +объявления (`matching/houses.py`). Объявление без координат создаёт дом без координат; +позже объявление геокодируется (`geoportal_coords_backfill`, `geocode_missing_listings` — +обе живые ежедневные задачи), но пишут они в `listings`, а `ON CONFLICT` у вставки дома +обновляет только `address`. Обратно в дом координаты не возвращались никогда: из 52 +источников расписания `houses.lat`/`houses.lon` не писал ни один. Замер на проде 2026-08-07: +1945 домов из 9625 (20.2%) без координат, при этом у 1490 из них объявления с координатами +УЖЕ ЕСТЬ. Эта задача — недостающее звено проводки, и именно ПОВТОРЯЮЩЕЕСЯ: однократный +скрипт закрыл бы дыру на день, а приток новых домов без координат никуда не девается. + +ЧТО ДЕЛАЕТ. Для каждого дома с lat IS NULL берёт geom всех связанных объявлений, считает +центроид и РАЗБРОС — максимальное расстояние от центроида до объявления (ST_DistanceSphere, +метры на сфере). Разброс <= max_spread_m → центроид пишется в дом; иначе дом НЕ трогается +и попадает в счётчик skipped_scattered. + +ПОРОГ 125 м — почему столько (замер на проде 2026-08-07, ST_DistanceSphere, не приближение +«градусы × 111320»). Гистограмма разброса по 258 домам с >1 различной точкой не даёт обрыва: +плотное ядро 0-125 м (150 домов), затем ровное плато ~4-5 домов на каждые 25 м вплоть до +400 м, и масса из 67 домов дальше 400 м. Раз в данных обрыва нет, порог берётся не из них, +а из ПОТРЕБИТЕЛЯ: гео-ограждение `house_dedup_merge` (#2187) считает два дома одним зданием +при ST_DistanceSphere <= 250 м. Записанный сюда центроид — оценка со своей погрешностью +ровно в этот разброс; если разрешить каждому дому ошибаться на все 250 м, два дома с +максимальной ошибкой разъедутся на 500 м, и ограждение отвергнет верную пару (или сведёт +неверную). Отдаём своей погрешности ПОЛОВИНУ бюджета ограждения — 125 м. Цена решения +мала и потому оно не несущее: 125 м пропускает 1382 дома, 250 м — 1403, разница 21 дом, +и это не потеря, а отсрочка (дом остаётся NULL и попадёт в следующий прогон, если его +объявления сойдутся). + +ЧЕГО НЕ ДЕЛАЕТ. Не обещает роста числа слияний дублей. Сейчас гео-ограждение отбраковывает +проигравших с NULL geom, потому что проверить тождество нечем; после переноса у него +появляется ВОЗМОЖНОСТЬ оценить пару — часть сольётся, часть будет отвергнута правильно +(дома в разных городах). Ни то ни другое здесь не предрешается. + +ДОМА С БОЛЬШИМ РАЗБРОСОМ — не задача переноса, а дефект сопоставителя: в одну запись дома +сшиты объявления из разных населённых пунктов (худший на проде — «ул. Кирова,4», разброс +290 км). Они не переносятся, считаются отдельным счётчиком и логируются поимённо (топ-5), +чтобы пропуск был виден, а не читался как «покрыто всё». + +ИДЕМПОТЕНТНОСТЬ. UPDATE только по `h.lat IS NULL` (тот же приём, что в +`backfill_listings_coords_geoportal`). Повторный прогон не трогает уже проставленные +координаты и на чистой базе — no-op с нулевыми счётчиками. + +geom СТРОИТСЯ ЯВНО. Триггер `houses_set_geom_trg` на проде — BEFORE INSERT OR UPDATE OF +lat, lon (проверено 2026-08-07), то есть geom построился бы и сам; UPDATE всё равно пишет +geom явно — как это делает `_update_listing_coords` в geoportal-задаче — чтобы правка не +зависела от живости триггера и не оказалась беззвучной, если его когда-нибудь снимут. + +Запуск: + python -m app.tasks.backfill_house_coords_from_listings + python -m app.tasks.backfill_house_coords_from_listings --max-spread-m 125 +Ежедневно через in-app scheduler (source='house_coords_from_listings', migration 239). +""" + +from __future__ import annotations + +import argparse +import logging +import time +from dataclasses import dataclass, field + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import SessionLocal +from app.services import scrape_runs as runs_mod + +logger = logging.getLogger(__name__) + +# Половина 250-метрового гео-ограждения house_dedup_merge (#2187) — обоснование в docstring. +DEFAULT_MAX_SPREAD_M = 125.0 + +# «Разброс в километрах» — порог отдельного счётчика для дефектов сопоставителя (#2777). +SCATTER_DEFECT_M = 5000.0 + +# Сколько худших домов с разбросом назвать поимённо в логе. +_SCATTER_LOG_TOP = 5 + +# Кандидаты: КАЖДЫЙ дом без координат + агрегат по его объявлениям. LEFT JOIN, чтобы +# «объявлений нет вовсе» и «объявления есть, но без координат» различались в счётчиках. +# ST_Collect/count(geom) пропускают NULL — дом без геокодированных объявлений получает +# cen IS NULL → lat/lon NULL → в UPDATE не попадает (n_geocoded = 0). +_BUILD_CANDIDATES_SQL = """ +CREATE TEMP TABLE _2771_house_coords ON COMMIT DROP AS +WITH coordless AS ( + SELECT id FROM houses WHERE lat IS NULL OR lon IS NULL +), +linked AS ( + SELECT c.id AS house_id, l.id AS listing_id, l.geom + FROM coordless c + LEFT JOIN listings l ON l.house_id_fk = c.id +), +agg AS ( + SELECT house_id, + count(listing_id) AS n_listings, + count(geom) AS n_geocoded, + ST_Centroid(ST_Collect(geom)) AS cen + FROM linked + GROUP BY house_id +), +spread AS ( + SELECT l.house_id, + max(ST_DistanceSphere(l.geom, a.cen)) AS spread_m + FROM linked l + JOIN agg a ON a.house_id = l.house_id + WHERE l.geom IS NOT NULL + GROUP BY l.house_id +) +SELECT a.house_id, + a.n_listings, + a.n_geocoded, + ST_Y(a.cen) AS lat, + ST_X(a.cen) AS lon, + s.spread_m +FROM agg a +LEFT JOIN spread s ON s.house_id = a.house_id +""" + +_COUNTERS_SQL = """ +SELECT count(*) AS candidates, + count(*) FILTER (WHERE n_listings = 0) AS skipped_no_listings, + count(*) FILTER (WHERE n_listings > 0 AND n_geocoded = 0) AS skipped_no_listing_coords, + count(*) FILTER (WHERE n_geocoded > 0 + AND spread_m > CAST(:max_spread_m AS double precision)) + AS skipped_scattered, + count(*) FILTER (WHERE n_geocoded > 0 + AND spread_m > CAST(:defect_m AS double precision)) + AS scattered_over_5km, + COALESCE(max(spread_m) FILTER (WHERE n_geocoded > 0 + AND spread_m > CAST(:max_spread_m AS double precision)), 0) + AS worst_spread_m, + count(*) FILTER (WHERE n_geocoded > 0 + AND spread_m <= CAST(:max_spread_m AS double precision)) + AS eligible +FROM _2771_house_coords +""" + +# Идемпотентность: h.lat IS NULL повторяется в UPDATE (а не только в отборе кандидатов) — +# уже проставленные координаты не перезаписываются ни при каком стечении обстоятельств. +_APPLY_SQL = """ +UPDATE houses h +SET lat = c.lat, + lon = c.lon, + geom = ST_SetSRID(ST_MakePoint(c.lon, c.lat), 4326) +FROM _2771_house_coords c +WHERE h.id = c.house_id + AND h.lat IS NULL + AND c.n_geocoded > 0 + AND c.spread_m <= CAST(:max_spread_m AS double precision) +""" + +_SCATTER_TOP_SQL = """ +SELECT c.house_id, h.address, c.n_geocoded, c.spread_m +FROM _2771_house_coords c +JOIN houses h ON h.id = c.house_id +WHERE c.n_geocoded > 0 + AND c.spread_m > CAST(:max_spread_m AS double precision) +ORDER BY c.spread_m DESC +LIMIT CAST(:top AS integer) +""" + + +@dataclass +class HouseCoordsResult: + """Счётчики прогона. Пропуск виден отдельно по КАЖДОЙ причине — молчаливое усечение + читается как «покрыто всё».""" + + candidates: int = 0 # домов без координат всего + eligible: int = 0 # объявления согласны (разброс <= порога) + updated: int = 0 # реально обновлено (UPDATE rowcount) + skipped_no_listings: int = 0 # объявлений у дома нет вовсе + skipped_no_listing_coords: int = 0 # объявления есть, но сами без координат + skipped_scattered: int = 0 # разброс больше порога — дефект сопоставителя (#2777) + scattered_over_5km: int = 0 # из них разъехавшиеся на километры + worst_spread_m: int = 0 # худший пропущенный разброс, метры + max_spread_m: int = 0 # применённый порог, метры (чтобы счётчики читались без кода) + duration_sec: float = field(default=0.0) + + def to_counters(self) -> dict[str, int]: + return { + "candidates": self.candidates, + "eligible": self.eligible, + "updated": self.updated, + "skipped_no_listings": self.skipped_no_listings, + "skipped_no_listing_coords": self.skipped_no_listing_coords, + "skipped_scattered": self.skipped_scattered, + "scattered_over_5km": self.scattered_over_5km, + "worst_spread_m": self.worst_spread_m, + "max_spread_m": self.max_spread_m, + "duration_sec": int(self.duration_sec), + } + + +def backfill_house_coords( + db: Session, + *, + max_spread_m: float = DEFAULT_MAX_SPREAD_M, +) -> HouseCoordsResult: + """Перенести координаты объявлений в дома, где объявления согласны. + + Одна транзакция: TEMP-таблица кандидатов (ON COMMIT DROP) → счётчики → UPDATE. + Счётчики считаются по ТОЙ ЖЕ таблице, из которой идёт UPDATE, поэтому «сколько + пропущено» относится ровно к этому прогону, а не к соседнему запросу. + + Args: + db: сессия tradein-БД. + max_spread_m: предел разброса объявлений, метры (см. DEFAULT_MAX_SPREAD_M). + + Returns: + HouseCoordsResult со счётчиками по каждой причине пропуска. + """ + start = time.monotonic() + res = HouseCoordsResult(max_spread_m=int(max_spread_m)) + params = {"max_spread_m": max_spread_m, "defect_m": SCATTER_DEFECT_M} + + db.execute(text(_BUILD_CANDIDATES_SQL)) + + row = db.execute(text(_COUNTERS_SQL), params).mappings().one() + res.candidates = int(row["candidates"]) + res.eligible = int(row["eligible"]) + res.skipped_no_listings = int(row["skipped_no_listings"]) + res.skipped_no_listing_coords = int(row["skipped_no_listing_coords"]) + res.skipped_scattered = int(row["skipped_scattered"]) + res.scattered_over_5km = int(row["scattered_over_5km"]) + res.worst_spread_m = int(row["worst_spread_m"]) + + if res.skipped_scattered: + worst = ( + db.execute(text(_SCATTER_TOP_SQL), {**params, "top": _SCATTER_LOG_TOP}).mappings().all() + ) + logger.warning( + "house_coords: ПРОПУЩЕНО %d домов с разбросом объявлений > %.0f м " + "(из них %d дальше %.0f км) — это дефект сопоставителя (#2777), не переноса. " + "Худшие: %s", + res.skipped_scattered, + max_spread_m, + res.scattered_over_5km, + SCATTER_DEFECT_M / 1000, + "; ".join( + f"id={w['house_id']} {w['address']!r} n={w['n_geocoded']} " + f"разброс={w['spread_m'] / 1000:.1f} км" + for w in worst + ), + ) + + res.updated = int(db.execute(text(_APPLY_SQL), params).rowcount or 0) + db.commit() + + res.duration_sec = time.monotonic() - start + logger.info( + "house_coords: DONE — кандидатов=%d годных=%d обновлено=%d | пропущено: " + "без_объявлений=%d объявления_без_координат=%d разброс>%.0fм=%d (худший %d м) " + "время=%.1fs", + res.candidates, + res.eligible, + res.updated, + res.skipped_no_listings, + res.skipped_no_listing_coords, + max_spread_m, + res.skipped_scattered, + res.worst_spread_m, + res.duration_sec, + ) + return res + + +# ── Run lifecycle wrapper (scheduler entrypoint) ───────────────────────────── +def run_house_coords_from_listings( + db: Session, + *, + run_id: int, + params: dict, +) -> HouseCoordsResult: + """Run-lifecycle обёртка (kit scheduler, source='house_coords_from_listings'). + + Params (default_params jsonb): + max_spread_m: предел разброса в метрах (default 125). + """ + max_spread_m = float(params.get("max_spread_m", DEFAULT_MAX_SPREAD_M)) + counters: dict[str, int] = HouseCoordsResult(max_spread_m=int(max_spread_m)).to_counters() + + try: + runs_mod.update_heartbeat(db, run_id, counters) + res = backfill_house_coords(db, max_spread_m=max_spread_m) + counters = res.to_counters() + runs_mod.mark_done(db, run_id, counters) + return res + except Exception as exc: + logger.exception("run_house_coords_from_listings: run_id=%d FAILED", run_id) + try: + db.rollback() + except Exception: + logger.warning("run_house_coords_from_listings: rollback failed", exc_info=True) + runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters) + raise + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + parser = argparse.ArgumentParser( + description="Перенос координат объявлений в дома, где объявления согласны (#2771)" + ) + parser.add_argument( + "--max-spread-m", + type=float, + default=DEFAULT_MAX_SPREAD_M, + help=f"Предел разброса объявлений, метры (default: {DEFAULT_MAX_SPREAD_M:.0f})", + ) + args = parser.parse_args() + + db = SessionLocal() + try: + backfill_house_coords(db, max_spread_m=args.max_spread_m) + finally: + db.close() + + +if __name__ == "__main__": + main() 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..2cb69680 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 @@ -34,7 +43,11 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.core.config import settings -from app.services.scraper_adapters import RealMatcherAdapter, RealScraperConfig +from app.services.scraper_adapters import ( + RealMatcherAdapter, + RealProxyProvider, + RealScraperConfig, +) from app.services.scraper_settings import get_scraper_delay logger = logging.getLogger(__name__) @@ -68,6 +81,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 +96,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 +138,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,10 +229,18 @@ 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: - enrichment = await fetch_newbuilding(zhk_url, config=RealScraperConfig()) + # proxy_provider (#2767): тот же сожжённый env-узел бил и сюда — + # это второй вызывающий fetch_newbuilding, чинить надо оба. + enrichment = await fetch_newbuilding( + zhk_url, + config=RealScraperConfig(), + proxy_provider=RealProxyProvider(), + ) except Exception as exc: logger.warning( "cian_newbuilding fetch failed for house_id=%s url=%s: %s", @@ -291,6 +319,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..d36ba452 100644 --- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py +++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py @@ -6,9 +6,32 @@ Ключевые решения: - Cian/Yandex не поддерживают full-coverage sweep -> паушальный TTL сломает живой инвентарь. DECISION: для yandex/cian деактивировать ТОЛЬКО listing_segment='vtorichka', - TTL=30. novostroyki (9659 активных первичных строк) и NULL-сегмент не трогаем. + TTL=30. novostroyki (9659 активных первичных строк) не трогаем. + ИЗВЕСТНЫЙ ПРОБЕЛ (ревью TTL-CAP круг 2, 2026-08-15): этот скоуп уже, чем множество + реально протухших строк -- живой замер на проде даёт cian/novostroyki 9 483 активных + строки старше 60 суток, ни одна из них не деактивируется НИ ОДНОЙ джобой (внутри + скоупа cian/vtorichka и yandex/vtorichka таких строк 0). Потолок cap_mult (см. + CAP_MULT ниже) этот пробел не закрывает и закрыть не может -- он сжимает пул ВНУТРИ + скоупа джобы, а не расширяет сам скоуп. NULL-сегмент (тот же замер круга 2 давал + cian/NULL 211, yandex/NULL 523 строки старше 60 суток) закрыт отдельно ниже + (null_segment_only, миграция 266) -- novostroyki-часть пробела остаётся: расширение + скоупа туда отдельная задача (нужно сперва выяснить, поддерживает ли cian/yandex + full-coverage sweep для novostroyki СЕЙЧАС, иначе паушальный TTL повторит инцидент, + ради которого этот DECISION и принят) и намеренно НЕ входит в TTL-CAP. - avito: все сегменты (segments=None), TTL=10 дней -- поведение без изменений. +- NULL-сегмент (легаси-строки до миграции 011 + жертвы бага в ON CONFLICT -- upsert + никогда не пишет listing_segment повторно, поэтому раз рождённая NULL-строка сама + себя не чинит даже при живой ежедневной досдаче) деактивируется ОТДЕЛЬНОЙ джобой per + source (null_segment_only=True, миграция 266): явный `listing_segment IS NULL` + предикат, а не ANY(:segments) -- этот оператор NULL никогда не матчит. Гейт + здоровья/пол переобхода для этой джобы выключены (min_confirmations=0, + revisit_floor_quantile=0) -- население нерепрезентативно мало (единицы подтверждений + в сутки против сотен-тысяч у обычного vtorichka-среза), калиброванный под vtorichka + порог держал бы джобу вечно skipped_unhealthy. - Строки НЕ удаляются -- история нужна для бэктеста (#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_*), @@ -21,6 +44,7 @@ TTL для avito берётся из settings.avito_stale_ttl_days (env AVITO_ST from __future__ import annotations import logging +from math import ceil from typing import Any from sqlalchemy import text @@ -41,21 +65,316 @@ 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[]))" + +# NULL-сегмент: `= ANY(...)` НИКОГДА не матчит NULL (SQL, не баг), поэтому +# для null_segment_only-режима нужен отдельный явный предикат IS NULL, а не элемент +# в :segments. См. _build_null_segment_sql ниже -- тот же принцип для самого UPDATE. +_CONFIRMATIONS_NULL_SEGMENT_FILTER = "\n AND listing_segment IS NULL" + + +# ── Пол TTL по измеренному циклу переобхода (#2659) ─────────────────────────── +# Гейт выше отвечает на вопрос «источник вообще собирается?». Он НЕ отвечает на +# вопрос, из-за которого заведён #2659: «а достаточно ли ttl_days, чтобы молчание +# означало снятие?». Пока свип возвращается к строке реже, чем раз в ttl_days, +# TTL меряет НАШУ выборку, а не жизнь объявления, — и источник при этом полностью +# здоров, так что гейт молчит. +# +# ЗАМЕР НА ПРОДЕ 2026-08-09, из-за которого этот пол существует. +# С момента деплоя гейта (06.08) TTL снял 1 028 строк; 127 из них (12.4%) УЖЕ снова +# активны — свип нашёл их живыми через 1-3 суток и вернул сам (upsert в +# scraper_kit/base.py ставит is_active = true). В единственном городе с настоящим +# покрытием доля ложных снятий 100%: +# cian Екатеринбург 103 снято → 103 снова активны +# yandex Екатеринбург 24 снято → 24 снова активны +# cian/yandex без города 901 снято → 0 вернулись (их свип не обходит вовсе) +# Возраст на момент снятия у всех 127: 29.9..30.3 суток при TTL=30 — то есть TTL +# срабатывал ровно на границе, а свип возвращался к строке на 31-34-е сутки. +# +# ПОЧЕМУ ЭТО НЕ ЛЕЧИТСЯ НОВОЙ КОНСТАНТОЙ. Разрывы переобхода, суток +# (listing_source_snapshots, 40 суток, посчитано по срезу TTL-джобы): +# источник/сегмент p90 p99 TTL сейчас TTL/p99 +# domklik vtorichka 1.9 3.1 14 4.5 ← сплошное суточное покрытие +# cian vtorichka 10.9 26.6 30 1.1 +# yandex vtorichka 5.7 43.0 30 0.7 +# avito vtorichka 29.1 42.1 10 0.24 ← отсюда 9 033 строки +# Домклик — контрольная группа: при почти полном суточном обходе TTL=14 лежит в +# 4.5 раза выше хвоста, и снятие у него действительно означает снятие. У остальных +# трёх порог ниже собственного хвоста обхода — руками подобранное число и есть +# корень #2659, поэтому чинить его вторым руками подобранным числом бессмысленно. +# +# ЧТО МЕРЯЕМ ВМЕСТО КОНСТАНТЫ: факт, а не оценку. «Какой самый большой возраст, при +# котором свип за последнее окно ДОКАЗАЛ, что объявление живо» — то есть насколько +# старую строку он только что нашёл на площадке. Если свип буквально вчера вернул к +# жизни строку, молчавшую 40 суток, то 30 суток молчания не доказывают ничего. +# Пол = квантиль этого распределения, эффективный TTL = max(ttl_days, пол). +# +# Считается по ТОМУ ЖЕ срезу (source + segments) и по ТОЙ ЖЕ колонке свежести, что +# и UPDATE. Предыдущее наблюдение берётся из listing_source_snapshots — единственной +# истории свежести, что у нас есть; расхождение listings. и +# listing_sources.last_seen_at замерено на проде и не превышает 0.5 суток в среднем +# (максимум 0), что на шкале 30-70 суток шум. +# +# КВАНТИЛЬ — калибровочная ручка, не догма. 0.99 подобран по требованию «пол обязан +# накрыть 127 доказанных ложных снятий», у которых возраст был 29.9..30.3: замер +# того же запроса на проде даёт 34.0 для cian/vtorichka и 74.3 для yandex/vtorichka. +# Ниже 0.99 опускать нельзя без нового замера. Ручка живёт в default_params +# расписания (revisit_floor_quantile), 0 -> пол выключен. +# +# ПОБОЧНЫЙ ЭФФЕКТ, КОТОРЫЙ ЗДЕСЬ НАМЕРЕННЫЙ: после провала сбора хвост разрывов +# распухает (свип разгребает завал и находит очень старые строки), пол поднимается, +# и деактивация замирает сама — без отдельного детектора банов. Когда завал разобран, +# хвост схлопывается и пол опускается обратно. Это ровно то поведение, которого +# issue просил от «гейта по банам», но выраженное через результат, а не через причину. +# +# ПОТОЛОК: пол не может превысить глубину истории снимков. Если снимок за нужную +# дату не писался (дыры на проде есть — 30.07, 01.08), берётся ближайший более +# ранний; при полном отсутствии снимков пол не считается и TTL остаётся как задан. +DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99 + +_REVISIT_FLOOR_SEGMENT_FILTER = "\n AND l.listing_segment = ANY(CAST(:segments AS text[]))" +_REVISIT_FLOOR_NULL_SEGMENT_FILTER = "\n AND l.listing_segment IS NULL" + + +# ── Потолок эффективного TTL (положительная обратная связь пола, найдено 2026-08-15) ── +# У пола выше нет верхней границы: max(ttl_days, пол) может расти неограниченно. +# ЗАМЕР НА ПРОДЕ (уточнён 2026-08-15 после разбора): у yandex counters держали +# ttl_days_effective 75/75/75/39/52/54 шесть прогонов подряд при deactivated=0 — +# пол реально разгонялся без верхней границы, и потолок закрывает именно это. +# ЧЕГО ПОТОЛОК НЕ ДЕЛАЕТ: он НЕ сжимает пул «активных». Замер показал 0 +# деактивируемых строк на всех четырёх джобах и до, и после калибровки. Цифра +# «23 687 из 44 744 не подтверждались >7 суток» относится ко ВСЕМ источникам +# сразу, и две трети её — новостройки, которых оценщик не берёт. У avito +# просроченных ноль. Раздутый пул, влияющий на оценку, лежит в строках с ПУСТЫМ +# сегментом и чинится отдельной джобой, не этим потолком. +# +# МЕХАНИЗМ ПЕТЛИ: медленный обход поднимает пол (он же квантиль разрывов переобхода) +# -> высокий пол продлевает жизнь снятым лотам дольше, чем к ним успевает вернуться +# свежий обход -> пул «активных» раздувается «протухшими» строками -> следующий замер +# пола на том же раздутом пуле оказывается ещё выше. Без верхней границы это не +# самокорректирующийся пол, а положительная обратная связь. +# +# CAP_MULT = 2 -- эффективный TTL не может превысить удвоенный заданный оператором +# ttl_days. Пол по-прежнему может его поднять (ради #2659 -- см. комментарий выше: +# ложные снятия при неполном покрытии обхода), но не бесконечно. Почему именно 2, а +# не 3 или 1.5: вдвое — это ещё «мы искренне не уверены, что молчание значит +# снятие», не «источник вообще умер». Дальнейший рост пола сигнализирует не о +# медленном, но живом обходе, а о мёртвом источнике -- для ЭТОГО случая уже есть +# отдельный гейт по здоровью (min_confirmations) выше в этой же функции, который +# выключает деактивацию целиком, а не растягивает TTL до бесконечности. Калибровочная +# ручка, не догма -- при новом замере можно пересмотреть, как и revisit_floor_quantile. +# +# ПОЧЕМУ MULT, А НЕ ФИКСИРОВАННОЕ ЧИСЛО СУТОК -- И ГДЕ ЭТА ФОРМА ЛОМАЕТСЯ. Множитель +# от ttl_days даёт разный АБСОЛЮТНЫЙ потолок на разных источниках: cian/yandex +# (ttl=30) -> 60 суток, avito (ttl=10) -> 20 суток, domklik (ttl=14) -> 28 суток. Это +# ломается ровно там, где абсолютный хвост переобхода источника НЕ пропорционален его +# ttl_days. Замер (_REVISIT_TAIL, 40 суток): avito p99 = 42.1 сут -- ВЫШЕ его же +# потолка 20. То есть для avito дефолтный CAP_MULT=2 может резать ttl ниже +# собственного хвоста обхода -- ровно тот false-kill, ради которого пол вообще +# заведён (см. комментарий выше). domklik (потолок 28 при хвосте 3.1) разрыва не +# имеет -- множитель 2 для него калиброван верно. +# +# YANDEX -- ТА ЖЕ ДЫРА, НАЙДЕНА ПОЗЖЕ (ревью круга 3, 2026-08-15). Строка выше до +# этой правки утверждала, что cian/yandex с потолком 60 тоже в порядке -- это было +# верно для cian (live-пол сейчас 31.1), но НЕ для yandex: ЖИВЫЕ полы из +# scrape_runs.counters (deactivate_stale_yandex, 2026-08-10..08-15) -- 75/75/75/39/ +# 52/54, а прямой live-замер той же percentile_disc(0.99)-формулы сегодня даёт 79.2 +# (n=1961 подтверждений за 3 суток). И то, и другое ВЫШЕ потолка 60 -- тот же +# false-kill класс, что у avito, статический p99=43.0 (_REVISIT_TAIL) для yandex +# устарел и вводит в заблуждение. cap_mult для yandex откалиброван отдельной +# миграцией (265_deactivate_stale_yandex_cap_mult.sql, cap_mult=3 -> потолок 90) -- +# см. её комментарий про то, почему это НЕ меняет число деактивированных строк +# следующим прогоном (0 активных строк источника старше 39 суток на момент замера). +# +# ПОЭТОМУ cap_mult -- параметр функции (как revisit_floor_quantile, min_confirmations), +# не голая константа: default = CAP_MULT для источников, где 2x достаточно (cian, +# domklik), но расписание может переопределить через default_params (JSON-колонка +# scrape_schedules, ключ "cap_mult") для источника с непропорционально длинным +# хвостом -- см. миграции для avito (cap_mult=6, потолок 60, с запасом выше +# статического p99=42.1 и живого прод-пика 52, замеренного 2026-08-10..12) и yandex +# (cap_mult=3, потолок 90, с запасом выше живого пола 79.2, замеренного 2026-08-15). +CAP_MULT = 2 + + +def _build_revisit_floor_sql( + staleness_column: str, *, with_segments: bool, null_segment_only: bool = False +) -> Any: + """Квантиль возраста, при котором свип за окно ДОКАЗАЛ, что строка жива. + + Пара «предыдущее наблюдение (снимок) → текущее наблюдение (listings)» даёт + разрыв переобхода в сутках; берём его квантиль по срезу source+segments. + Только строки, у которых свежесть реально сдвинулась, — то есть выжившие, + а не «мы к ним не приходили». + + null_segment_only=True переопределяет with_segments -- IS NULL вместо ANY(:segments) + (ANY никогда не матчит NULL). На практике для null_segment_only-джобы этот запрос + не строится вовсе (revisit_floor_quantile=0 -- см. модульный докстринг), но вариант + нужен для корректности, если порог когда-нибудь включат. + + staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings. + Значения — param-binding, psycopg v3 safe (CAST(... AS ...), никаких :param::type). + """ + if null_segment_only: + segment_filter = _REVISIT_FLOOR_NULL_SEGMENT_FILTER + elif with_segments: + segment_filter = _REVISIT_FLOOR_SEGMENT_FILTER + else: + segment_filter = "" + return text( + f""" + SELECT percentile_disc(CAST(:revisit_quantile AS double precision)) + WITHIN GROUP ( + ORDER BY EXTRACT(epoch FROM (l.{staleness_column} - prev.last_seen_at)) + / 86400.0 + ) + FROM listings l + JOIN listing_sources ls + ON ls.listing_id = l.id + AND ls.ext_source = l.source + JOIN listing_source_snapshots prev + ON prev.listing_source_id = ls.id + AND prev.snapshot_date = ( + SELECT max(snapshot_date) + FROM listing_source_snapshots + WHERE snapshot_date + <= CURRENT_DATE - CAST(:health_window_days AS integer) + ) + WHERE l.source = :listing_source + AND l.{staleness_column} + > NOW() - CAST(:health_window_days || ' days' AS interval) + AND l.{staleness_column} > prev.last_seen_at{segment_filter} + """ + ) + + +def _build_confirmations_sql( + staleness_column: str, *, with_segments: bool, null_segment_only: bool = False +) -> Any: + """SELECT count(*) подтверждённых за окно строк — тот же срез, что и у UPDATE. + + null_segment_only=True переопределяет with_segments -- IS NULL вместо ANY(:segments). + Для null_segment_only-джобы min_confirmations=0 по умолчанию (см. модульный + докстринг), так что на практике этот путь не строится -- оставлен для корректности. + + staleness_column уже прошёл whitelist-проверку в deactivate_stale_listings. + Значения (:listing_source, :health_window_days, :segments) — param-binding, + psycopg v3 safe (CAST(... AS ...), никаких :param::type). + """ + if null_segment_only: + segment_filter = _CONFIRMATIONS_NULL_SEGMENT_FILTER + elif with_segments: + segment_filter = _CONFIRMATIONS_SEGMENT_FILTER + else: + segment_filter = "" + 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 +387,43 @@ 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} + """ + ) + + +def _build_null_segment_sql(staleness_column: str) -> Any: + """UPDATE строго по listing_segment IS NULL (null_segment_only=True). + + НЕ переиспользует _build_segments_sql: `= ANY(CAST(:segments AS text[]))` никогда + не матчит NULL (SQL-семантика, не баг -- та же ловушка задокументирована выше у + novostroyki-гарда), поэтому NULL-сегмент не выразить через список segments и нужен + отдельный явный предикат. Целенаправленно НЕ трогает 'vtorichka'/'novostroyki' -- + их деактивация идёт через _build_segments_sql в отдельных, уже существующих джобах. + + staleness_column уже прошёл whitelist-проверку. Без :segments-параметра вовсе. + """ + return text( + f""" + 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 IS NULL + RETURNING id, price_rub + ) + {_STALE_SNAPSHOT_TAIL} """ ) @@ -95,6 +445,11 @@ 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, + revisit_floor_quantile: float = 0.0, + null_segment_only: bool = False, + cap_mult: float = CAP_MULT, ) -> dict[str, int]: """Пометить is_active=false объявления, чья свежесть старше ttl_days дней. @@ -104,23 +459,99 @@ def deactivate_stale_listings( ttl_days: количество дней TTL; объявления старше этого порога деактивируются. segments: если задан -- деактивировать только объявления с указанными listing_segment значениями. None -> все сегменты (поведение avito по умолчанию). + Несовместимо с null_segment_only=True (см. ниже). staleness_column: колонка-таймстемп, по которой считается свежесть. Whitelist {"last_seen_at", "scraped_at"} — иначе ValueError ДО любого SQL. Дефолт 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. + revisit_floor_quantile: пол TTL по измеренному циклу переобхода (#2659). + Квантиль возраста, при котором свип за окно ДОКАЗАЛ строку живой; + эффективный TTL = min(max(ttl_days, этот пол), ttl_days * cap_mult) -- + пол поднимает TTL, но не выше потолка. 0 -> пол выключен (так + вызывают старые тесты и совместимая обёртка), рабочее значение — + DEFAULT_REVISIT_FLOOR_QUANTILE, см. комментарий выше. + null_segment_only: True -> WHERE фильтрует `listing_segment IS NULL` вместо + ANY(:segments). Требует segments=None (иначе ValueError -- смешивать + бессмысленно, это два непересекающихся среза). Для этого среза гейт/пол + обычно держат выключенными (min_confirmations=0, revisit_floor_quantile=0, + см. миграцию 266 и модульный докстринг) -- население слишком мало для + откалиброванных под полноценный vtorichka-свип порогов. + cap_mult: множитель потолка эффективного TTL (см. комментарий у модульной + константы CAP_MULT). Дефолт -- сама CAP_MULT=2, но параметр, а НЕ голая + константа: источник с непропорционально длинным хвостом переобхода + относительно своего ttl_days (avito: p99=42.1 при ttl=10 -> дефолтный + потолок 20 режет ниже хвоста) может переопределить его через + default_params расписания (ключ "cap_mult"), не трогая остальные + источники. Итоговый потолок = ttl_days * cap_mult. Применяется и к + null_segment_only-джобе, но там гейт/пол выключены (см. выше), так что + на практике не участвует. 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} и НИ ОДНА строка не тронута. Если пол переобхода поднял + TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}. Если пол + упёрся в потолок cap_mult: дополнительно {"ttl_floor_capped": 1, + "ttl_days_floor_raw": N} -- N это то, во что пол поднял бы TTL БЕЗ потолка. Raises: - ValueError: если staleness_column не входит в whitelist (проверка ДО SQL, - никакой интерполяции пользовательского ввода в запрос). + ValueError: если staleness_column не входит в whitelist, ИЛИ ttl_days <= 0 + (проверка ДО SQL, никакой интерполяции пользовательского ввода в запрос; + ttl_days<=0 в WHERE-условии last_seen_at < NOW() - INTERVAL 'N days' + матчит практически весь активный пул -- без явного guard'а потолок + (ttl_days * cap_mult <= 0) к тому же перебивал бы пол в формуле min(), + снимая защиту, которую max(ttl_days, floor) давал раньше), ИЛИ cap_mult < 1 + (тот же класс дыры, но со стороны потолка, а не пола: cap_mult приходит из + jsonb default_params расписания -- ЕДИНСТВЕННЫЙ запланированный способ его + задать, т.е. именно там опечатка 0 / 0.5 вместо 6 доходит до прода. cap_mult=0 + даёт capped=0 -> effective_ttl_days=0 -> UPDATE снимает практически весь + активный пул источника; cap_mult<1 (например 0.5) опускает потолок НИЖЕ + заданного оператором ttl_days -- прямое нарушение инварианта «потолок не + может понизить TTL ниже настроенного», который проверяет + test_cap_never_lowers_ttl_below_configured_value), ИЛИ ttl_days/cap_mult -- + bool (найдено ревью круга 3, 2026-08-15: `cap_mult < 1` пропускает `True` -- + `bool` наследует `int`, `True < 1` ложно, а `ttl_days * True` == `ttl_days`, + то есть потолок = сам ttl_days и пол молча отключается, никакого ValueError. + jsonb `true`/`false` вместо числа -- ровно та опечатка в расписании, ради + которой оба guard'а вообще написаны, поэтому bool отклоняется явной + type-проверкой ДО числового сравнения для обоих параметров), ЛИБО если + заданы одновременно null_segment_only=True и segments (взаимоисключающие + срезы -- IS NULL и ANY(:segments) не композируются). """ counters: dict[str, int] = {"deactivated": 0} try: + # bool -- подкласс int в Python, поэтому `True < 1` (False) и `False <= 0` + # (True) НЕ ловят опечатку `"ttl_days": true` / `"cap_mult": true` в jsonb: + # `ttl_days * True` == `ttl_days`, `cap_mult=True` даёт потолок == ttl_days и + # молча отключает пол (см. Raises выше). Проверка типа -- ДО числового + # сравнения, иначе bool проскакивает мимо него необнаруженным. + if isinstance(ttl_days, bool): + raise ValueError(f"ttl_days must be a number, not bool: {ttl_days!r}") + if ttl_days <= 0: + raise ValueError(f"ttl_days must be positive, got {ttl_days!r}") + + # Тот же класс дыры, что и ttl_days<=0 выше, только со стороны потолка: + # cap_mult < 1 может опустить потолок (ttl_days * cap_mult) НИЖЕ заданного + # ttl_days, а cap_mult <= 0 -- сделать капнутый потолок <= 0 и победить пол + # в min() молча (ровно та дыра, ради которой заведён guard выше). Единственный + # запланированный способ задать cap_mult -- вписать его руками в jsonb + # default_params расписания (см. миграцию для avito), т.е. именно там опечатка + # 0 / 0.5 вместо 6 -- реальный риск, а не гипотетика. + if isinstance(cap_mult, bool): + raise ValueError(f"cap_mult must be a number, not bool: {cap_mult!r}") + if cap_mult < 1: + raise ValueError(f"cap_mult must be >= 1, got {cap_mult!r}") + # Whitelist-проверка ДО построения/выполнения SQL: только после неё имя колонки # интерполируется f-string'ом. Значения по-прежнему идут через param-binding. # Внутри try -> невалидная колонка финализирует run как failed (mark_failed), @@ -130,20 +561,157 @@ def deactivate_stale_listings( f"invalid staleness_column={staleness_column!r}; " f"allowed: {sorted(_ALLOWED_STALENESS_COLUMNS)}" ) + # null_segment_only + segments одновременно -- неоднозначный запрос: + # IS NULL и ANY(:segments) -- разные, непересекающиеся предикаты, а не + # композиция. Явный ValueError лучше молчаливого выбора одного из двух. + if null_segment_only and segments is not None: + raise ValueError("null_segment_only=True несовместимо с заданным segments") - # segments is None -> все сегменты (поведение avito). segments=[...] -> только - # перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список [] - # означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0), - # а НЕ "все сегменты" — иначе случайный [] стёр бы весь источник. - if segments is not None: + # Гейт по здоровью сбора (#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, + null_segment_only=null_segment_only, + ), + 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, null_segment_only=%s, staleness_column=%s); " + "ни одна строка не тронута", + listing_source, + run_id, + health_window_days, + confirmations, + min_confirmations, + segments, + null_segment_only, + staleness_column, + ) + return counters + + # Пол TTL по измеренному циклу переобхода (#2659) — тоже ДО UPDATE и по тому же + # срезу. Поднимает порог (max), но не выше потолка cap_mult * ttl_days (min) — + # см. комментарий у CAP_MULT про петлю с положительной обратной связью и про + # то, почему cap_mult -- параметр, а не голая константа. + effective_ttl_days = ttl_days + if revisit_floor_quantile > 0: + floor_params: dict[str, Any] = { + "listing_source": listing_source, + "health_window_days": health_window_days, + "revisit_quantile": revisit_floor_quantile, + } + if segments is not None: + floor_params["segments"] = segments + floor_days = db.execute( + _build_revisit_floor_sql( + staleness_column, + with_segments=segments is not None, + null_segment_only=null_segment_only, + ), + floor_params, + ).scalar() + # NULL = истории снимков за окно нет вовсе (свежая БД, дыра в снимках). + # Тогда пола нет и TTL остаётся как задан: выдумывать пол не из чего. + if floor_days is not None: + counters["revisit_floor_days"] = ceil(float(floor_days)) + # Пол поднимает TTL (max), потолок cap_mult его не пускает выше + # ttl_days * cap_mult (min) — без этого пол растёт без ограничения + # (см. комментарий у CAP_MULT). capped_ttl_days может быть float, + # если cap_mult переопределён нецелым значением из default_params — + # effective_ttl_days приводим к int (UPDATE ждёт целые сутки). + raw_effective_ttl_days = max(ttl_days, counters["revisit_floor_days"]) + capped_ttl_days = ttl_days * cap_mult + effective_ttl_days = int(min(raw_effective_ttl_days, capped_ttl_days)) + counters["ttl_days_effective"] = effective_ttl_days + + if raw_effective_ttl_days > capped_ttl_days: + # Пол упёрся в потолок -- оба числа в counters (не только в логе), + # чтобы это было видно в витрине прогонов, а не только в логах. + # 1, а не True -- counters типизирован dict[str, int] (тот же + # идиом, что skipped_unhealthy выше). + counters["ttl_floor_capped"] = 1 + counters["ttl_days_floor_raw"] = raw_effective_ttl_days + logger.warning( + "deactivate_stale source=%s run_id=%d TTL пол упёрся в потолок " + "cap_mult=%s: пол поднял бы TTL до %d сут, потолок ограничивает " + "заданные %d сут значением %d (квантиль %.3f, segments=%r) — " + "растущий без ограничения пол это петля с положительной обратной " + "связью, см. комментарий у CAP_MULT", + listing_source, + run_id, + cap_mult, + raw_effective_ttl_days, + ttl_days, + effective_ttl_days, + revisit_floor_quantile, + segments, + ) + elif effective_ttl_days > ttl_days: + logger.warning( + "deactivate_stale source=%s run_id=%d TTL поднят с %d до %d сут: " + "свип за %d сут доказал живой строку, молчавшую %d сут " + "(квантиль %.3f, segments=%r) — при ttl_days=%d снятие означало бы " + "«мы не дошли», а не «объявление снято»", + listing_source, + run_id, + ttl_days, + effective_ttl_days, + health_window_days, + counters["revisit_floor_days"], + revisit_floor_quantile, + segments, + ttl_days, + ) + + # null_segment_only -> IS NULL, отдельный явный предикат (ANY(:segments) + # никогда не матчит NULL). segments is None -> все сегменты (поведение avito). + # segments=[...] -> только перечисленные сегменты. Используем `is not None` + # (НЕ truthy): пустой список [] означает "ни один сегмент" (= ANY(ARRAY[]) + # ничего не матчит, деактивирует 0), а НЕ "все сегменты" — иначе случайный [] + # стёр бы весь источник. + if null_segment_only: params: dict[str, Any] = { "listing_source": listing_source, - "ttl_days": ttl_days, + "ttl_days": effective_ttl_days, + "run_id": run_id, + } + result = db.execute(_build_null_segment_sql(staleness_column), params) + elif segments is not None: + params = { + "listing_source": listing_source, + "ttl_days": effective_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": effective_ttl_days, + "run_id": run_id, + } result = db.execute(_build_all_segments_sql(staleness_column), params) counters["deactivated"] = result.rowcount or 0 @@ -152,12 +720,15 @@ def deactivate_stale_listings( runs_mod.mark_done(db, run_id, counters) logger.info( "deactivate_stale source=%s run_id=%d done: deactivated=%d " - "(ttl_days=%d, segments=%r, staleness_column=%s)", + "(ttl_days=%d эффективный, задан %d, segments=%r, null_segment_only=%s, " + "staleness_column=%s)", listing_source, run_id, counters["deactivated"], + effective_ttl_days, ttl_days, segments, + null_segment_only, staleness_column, ) return counters 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..00d91cf9 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -17,8 +17,9 @@ Both are wired together in the debug endpoint `POST /scrape/domclick/debug/detai wiring into the production scheduled orchestrator (previously only reachable manually). Solution: single snapshot SELECT at start (guarantees termination) + one BrowserFetcher -per run (async context manager, source="domclick" -- dedicated residential proxy pool, -see 173_scrape_proxies_add_domclick_affinity.sql) + cookies loaded ONCE via +per run (async context manager, source="domclick" -- узел берётся из ОБЩЕГО пула; +выделенного узла у Домклика больше нет, резервацию сняла миграция 253 (#2800), +на 13.08 все четыре узла имеют provider_affinity='any') + cookies loaded ONCE via domclick_session.load_session(db) and threaded into every fetch_detail() call. NAMING TRAP (verified live against prod DB 2026-07-04, do NOT "fix" this anywhere): @@ -36,12 +37,25 @@ 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 - (DomClick uses one dedicated residential proxy, not a rotating pool) -- an - aborted run simply retries the remaining backlog next window. + 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 -- an aborted run simply + retries the remaining backlog next window. + УСТАРЕВШЕЕ ОБОСНОВАНИЕ, снято 13.08: здесь стояло «DomClick uses one dedicated + residential proxy, not a rotating pool». Это перестало быть правдой на миграции + 253 (#2800), снявшей резервацию узла; сегодня узлов четыре и все общие. То есть + отсутствие ротации больше НЕ следует из «ротировать нечего» — это просто + непринятое решение. Разбор цены и рисков: #2854 (блок бьёт внутри первой + комнатной корзины, buckets_completed=0 во ВСЕХ прогонах; свежий узел, судя по + длительности до блока 111-332 с, получает свой бюджет). + ОГРАНИЧЕНИЕ (#2764): диагноз scrape_runs.ban_kind этот прогон НЕ передаёт и + получает 'unknown'. Один и тот же DomClickBlockedError поднимается и на + распознанном QRATOR-маркере (площадка), и на любом сбое браузерного fetch + (наш тракт) -- см. providers/domclick/detail.py::fetch_detail. Пока эти два + случая не разведены отдельным подтипом (как AvitoSidecarUnavailableError у + avito), любой диагноз отсюда был бы назначенным, а не установленным. - DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a block) -- counted as failed++, logged, does NOT touch consecutive_blocks and does NOT abort the run (neutral to the block-breaker, mirrors how Avito's @@ -52,10 +66,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 +85,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 +106,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 +195,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 +211,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 +266,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 +349,7 @@ async def run_domclick_detail_backfill( counters.enriched, counters.attempted, ) + aborted_by_blocks = True break except Exception as e: @@ -296,9 +371,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..bf71333d 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 @@ -108,16 +109,44 @@ class NewbuildingEnrichBackfillResult: failed_fetch: int = 0 # fetch returned None / raised failed_save: int = 0 # save raised after a good fetch - # Row-level deltas (how much actually landed). - price_dynamics_rows: int = 0 - reliability_rows: int = 0 - review_rows: int = 0 + # Сколько РЕАЛЬНО записано, по словам самих писателей (#2807). Раньше здесь стоял + # прирост COUNT(*) по таблице до/после сохранения — то есть «выросла ли таблица», а + # не «сколько записали»: при ON CONFLICT DO UPDATE обновление даёт ноль, а у + # reliability ноль давал ещё и _dedup_reliability, схлопывающий дубль сразу после + # вставки. Ключи переименованы намеренно: у price_dynamics_rows/reliability_rows/ + # review_rows в истории прогонов старый смысл, и молча поменять его под тем же + # именем — ровно тот дефект, ради которого правка и делается. + price_dynamics_inserted: int = 0 # новых точек динамики цен + price_dynamics_updated: int = 0 # существующих точек переписано свежей ценой + reliability_inserted: int = 0 # строк house_reliability_checks вставлено + review_upserted: int = 0 # отзывов записано (вставка+обновление, ключ ext_review_id) duration_sec: float = field(default=0.0) def to_dict(self) -> dict[str, int]: return {f.name: int(getattr(self, f.name)) for f in fields(self)} + def to_backfill_counters(self) -> dict[str, int]: + """to_dict() + ключи, которые читает общий финализатор (#2695, #2767). + + runs.mark_backfill_finished смотрит на attempted / enriched / failed; у этой + задачи те же величины называются иначе, поэтому через общий финализатор она не + проходила и ЛЮБОЙ прогон закрывался mark_done — включая 25 попыток с нулём + обогащений восемь суток подряд. + + `attempted` — дома, до которых дошли руки: пропущенный уже-обогащённый домом + попыткой не был, иначе прогон, которому просто нечего делать, выглядел бы + отказом. `blocked` НЕ заводим намеренно: задача блоки источника не различает, + а нулевой прогон без доказанных блоков финализатор помечает 'failed' — наш + тракт, а не «виновата площадка» (#2764: диагноз ставится по доказательству). + """ + return { + **self.to_dict(), + "attempted": self.processed - self.skipped_already_enriched, + "enriched": self.succeeded, + "failed": self.failed_resolve + self.failed_fetch + self.failed_save, + } + # SQL: anchor on the canonical cian_newbuilding link, require a house we can FETCH, and # (unless force) skip houses already enriched. @@ -306,8 +335,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 +356,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 +376,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 @@ -372,9 +406,18 @@ async def backfill_newbuilding_enrichment( save_newbuilding_enrichment, ) - from app.services.scraper_adapters import RealScraperConfig + from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig scraper_config = RealScraperConfig() + # #2767: обогащение было ЕДИНСТВЕННЫМ cian-путём мимо пула прокси — весь сбор шёл + # через env-узел сайдкара, и когда Циан забанил его exit-IP, 8 суток по 25 попыток + # уходили в тот же адрес (страница блокировки вместо карточки). Провайдер здесь ≠ + # «включить пул»: реально пул задействуется, только если включён + # config.use_proxy_pool_browser (build_browser_fetcher внутри fetch_newbuilding). + # #2830: тот же провайдер уходит и в resolve-ногу (curl_cffi, флаг + # use_proxy_pool_curl) — #2767 починил только fetch, а резолв ЖК-url остался на + # статичном cian_proxy_url, то есть на второй ноге той же цепочки. + proxy_provider = RealProxyProvider() result = NewbuildingEnrichBackfillResult() t0 = time.time() @@ -415,6 +458,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- @@ -449,7 +494,9 @@ async def backfill_newbuilding_enrichment( continue try: - resolved = await resolve_cian_zhk_url_via_search(nb_id, config=scraper_config) + resolved = await resolve_cian_zhk_url_via_search( + nb_id, config=scraper_config, proxy_provider=proxy_provider + ) except Exception as exc: # defensive — resolver already catches internally logger.warning( "zhk-url resolve raised house_id=%s nb_id=%s: %s", house_id, nb_id, exc @@ -496,7 +543,9 @@ async def backfill_newbuilding_enrichment( # ── Fetch (network; anti-bot surface) ────────────────────────────── enrichment = None try: - enrichment = await fetch_newbuilding(zhk_url, config=scraper_config) + enrichment = await fetch_newbuilding( + zhk_url, config=scraper_config, proxy_provider=proxy_provider + ) except Exception as exc: logger.warning( "newbuilding fetch failed house_id=%s url=%s: %s", house_id, zhk_url, exc @@ -506,8 +555,12 @@ async def backfill_newbuilding_enrichment( continue if enrichment is None: + # Без «(captcha / parse miss?)» (#2767): догадка автора кода в тексте лога + # читается дальше как факт и один раз уже увела диагноз не туда. Причина + # печатается строкой ВЫШЕ, в самом месте отказа (html_len + antibot_markers). logger.warning( - "newbuilding fetch returned None house_id=%s url=%s (captcha / parse miss?)", + "newbuilding fetch returned None house_id=%s url=%s — причина в строке " + "'initialState extraction failed' выше", house_id, zhk_url, ) @@ -516,15 +569,15 @@ async def backfill_newbuilding_enrichment( continue # ── Save under a SAVEPOINT so one bad house can't poison the batch ── - # begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally, - # so we snapshot the row counts BEFORE and recompute the delta AFTER its commit - # rather than relying on the nested transaction staying open. - pd_before, rc_before, rv_before = _house_enrichment_counts(db, house_id) + # begin_nested() = SAVEPOINT; save_newbuilding_enrichment commits internally. + # COUNT(*) до сохранения нужен ТОЛЬКО для had_reliability (дедуп ниже): сколько + # записано, теперь сообщают сами писатели, а не разница COUNT'ов (#2807). + _, rc_before, _ = _house_enrichment_counts(db, house_id) try: had_reliability = rc_before > 0 # 1) price_dynamics + reliability + houses UPDATE (existing, commits inside). - save_newbuilding_enrichment(db, house_id, enrichment) + saved = save_newbuilding_enrichment(db, house_id, enrichment) # 2) reviews — added here (save_newbuilding_enrichment skips them). # SAVEPOINT around the review write so a malformed review can't lose the @@ -560,16 +613,18 @@ async def backfill_newbuilding_enrichment( sp.rollback() logger.warning("reliability dedup failed house_id=%s: %s", house_id, dexc) - pd_after, rc_after, rv_after = _house_enrichment_counts(db, house_id) - result.price_dynamics_rows += max(0, pd_after - pd_before) - result.reliability_rows += max(0, rc_after - rc_before) - result.review_rows += max(0, rv_after - rv_before) + result.price_dynamics_inserted += saved.price_inserted + result.price_dynamics_updated += saved.price_updated + result.reliability_inserted += saved.reliability_inserted + result.review_upserted += review_written result.succeeded += 1 logger.info( - "enriched house_id=%s: +pd=%d +reliability=%d +reviews=%d (parsed reviews=%d)", + "enriched house_id=%s: динамика цен +%d новых / %d обновлено, " + "reliability +%d, отзывов записано %d (распознано %d)", house_id, - max(0, pd_after - pd_before), - max(0, rc_after - rc_before), + saved.price_inserted, + saved.price_updated, + saved.reliability_inserted, review_written, len(enrichment.reviews), ) @@ -588,8 +643,8 @@ async def backfill_newbuilding_enrichment( result.duration_sec = time.time() - t0 logger.info( "newbuilding-enrich backfill done: processed=%d ok=%d skip=%d resolved=%d " - "resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d " - "| %.1fs", + "resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / " + "%d обновлено, reliability +%d, отзывов %d | %.1fs", result.processed, result.succeeded, result.skipped_already_enriched, @@ -597,9 +652,10 @@ async def backfill_newbuilding_enrichment( result.failed_resolve, result.failed_fetch, result.failed_save, - result.price_dynamics_rows, - result.reliability_rows, - result.review_rows, + result.price_dynamics_inserted, + result.price_dynamics_updated, + result.reliability_inserted, + result.review_upserted, result.duration_sec, ) return result @@ -665,7 +721,9 @@ async def run_newbuilding_enrich( Thin scheduler wrapper around backfill_newbuilding_enrichment() — mirrors tasks/yandex_address_backfill.run_yandex_address_backfill: emit a heartbeat before the - batch, delegate to the proven backfill, then mark the scrape_run done/failed. + batch, delegate to the proven backfill, then finalise the scrape_run through the + SHARED runs.mark_backfill_finished (#2695) — ноль обогащений при ненулевых попытках + закрывается 'failed', а не 'done' (#2767; счётчики маппит to_backfill_counters). Idempotency is inherited from backfill_newbuilding_enrichment(): with force=False its SELECT excludes houses that already have BOTH price_dynamics AND reliability rows, and @@ -696,6 +754,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,14 +774,36 @@ async def run_newbuilding_enrich( limit=limit, force=force, request_delay_sec=request_delay_sec, + on_progress=_heartbeat, ) - counters = result.to_dict() - runs_mod.mark_done(db, run_id, counters) + # Честный финал через ОБЩИЙ финализатор (#2695): ноль обогащений при ненулевых + # попытках больше не 'done'. fail_hint называет ЭТАП, на котором чаще всего + # отказывало (resolve / fetch / save) — «failed=25» на этот вопрос не отвечает. + counters = result.to_backfill_counters() + stage, stage_n = max( + ( + ("resolve", result.failed_resolve), + ("fetch", result.failed_fetch), + ("save", result.failed_save), + ), + key=lambda kv: kv[1], + ) + runs_mod.mark_backfill_finished( + db, + run_id, + counters, + source="newbuilding_enrich", + fail_hint=( + f"чаще всего отказ на этапе {stage} ({stage_n} из {counters['attempted']})" + if stage_n + else None + ), + ) logger.info( - "scheduler: newbuilding_enrich run_id=%d done — processed=%d ok=%d skip=%d " - "resolve_fail=%d fetch_fail=%d save_fail=%d | rows pd=%d reliability=%d reviews=%d " - "| pending=%d %.1fs", + "scheduler: newbuilding_enrich run_id=%d finished — processed=%d ok=%d skip=%d " + "resolve_fail=%d fetch_fail=%d save_fail=%d | записано: динамика +%d новых / " + "%d обновлено, reliability +%d, отзывов %d | pending=%d %.1fs", run_id, result.processed, result.succeeded, @@ -719,9 +811,10 @@ async def run_newbuilding_enrich( result.failed_resolve, result.failed_fetch, result.failed_save, - result.price_dynamics_rows, - result.reliability_rows, - result.review_rows, + result.price_dynamics_inserted, + result.price_dynamics_updated, + result.reliability_inserted, + result.review_upserted, result.cian_houses_pending, result.duration_sec, ) diff --git a/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py new file mode 100644 index 00000000..4dcda362 --- /dev/null +++ b/tradein-mvp/backend/app/tasks/purge_expired_trade_in_data.py @@ -0,0 +1,289 @@ +"""Physically delete expired personal data — ЭТАП 4 B2C retention enforcement (152-ФЗ). + +WHY: + trade_in_estimates.expires_at (и, начиная с migration 231, trade_in_leads.expires_at) + defined a retention window, but neither table had any background job that actually + DELETEd rows once expired -- expires_at was used ONLY as a read-time filter + (GET /estimate/{id}: "AND expires_at > NOW()"). Personal data (address / phone) + outlived its declared lifetime indefinitely, contradicting the retention policy + shown to the user. + +WHAT: + Batched physical DELETE for both tables, run nightly by the kit-scheduler (see + app.services.product_handlers._job_purge_expired_trade_in_data, scrape_schedules + row seeded by migration 231 -- seeded enabled=false, see that migration's docstring + for why). Same architecture as app/tasks/deactivate_stale_avito.py (sync, DB-only, + invoked via run_in_executor from the async kit handler). + + - trade_in_estimates: ON DELETE CASCADE already cleans up estimate_photos + (007_estimate_photos.sql) and avito_imv_evaluations (018_avito_imv_evaluations.sql) + for each deleted estimate. + - trade_in_leads: ON DELETE SET NULL on trade_in_leads.estimate_id (172_trade_in_leads.sql) + means a lead created from a now-purged estimate SURVIVES with estimate_id nulled -- + it has its OWN retention clock (trade_in_leads.expires_at) and its own PII (phone), + purged independently below. + +⚠️ trade_in_estimates DELETE is scoped to `created_by IS NULL` (deep-review finding, + 2026-08-06, HIGH): `expires_at` on this table is set UNCONDITIONALLY for every + estimate, B2B pilot or anonymous (`now + settings.trade_in_estimate_retention_hours`, + see app/services/estimator.py) -- it is a TTL on the ESTIMATE LINK/PDF staying + resolvable (GET /estimate/{id}: 404 past expiry, PDF export: 410 past expiry), NOT a + declared retention deadline for the ROW. B2B pilots' consent is closed by contract + (see migration 229's `consent` column asymmetry: NULL for `created_by IS NOT NULL`, + the exact same B2B-vs-B2C split used here) and their estimates are the live basis for + /trade-in/history, /team/employees/{id}/history and the cache-stats dashboards (see + app/api/v1/trade_in.py, app/api/v1/team.py) -- deleting them past a 24h *link* TTL + would be silent, irreversible data loss of pilots' own operational data, not a + privacy-driven cleanup. Audited against prod on 2026-08-06: of 1057 rows, 1040 had + already crossed `expires_at`, and 911 of THOSE belonged to named pilots + (`created_by` set: admin, kopylov, brusnika, praktika, pilottest, admintest, user1). + Without the `created_by IS NULL` guard, one unattended run at + `batch_size=500, max_batches=20` (the defaults) would have deleted essentially the + whole table. `created_by IS NULL` is the honest B2C population -- 129 rows in that + same audit. trade_in_leads has no `created_by` column (never had a B2B/B2C split -- + its own `expires_at` really is a 180-day retention deadline, not a link TTL, see + migration 231) so its DELETE below is intentionally NOT scoped the same way. + +BATCHING (не единый DELETE по всей таблице): + Each table is drained in batches of `batch_size` rows (default + settings.trade_in_purge_batch_size), each batch its OWN statement + its OWN commit + (bounds lock/transaction duration on a backlog). A run stops draining a table once a + batch returns fewer rows than batch_size (caught up) OR after `max_batches` iterations + (safety cap on total run duration -- any remaining backlog drains over subsequent + nightly runs, not one giant transaction). Idempotent: rows already deleted simply + don't match `expires_at < NOW()` on the next run; a mid-run failure leaves earlier + committed batches deleted (correct, not rolled back) and mark_failed records the + partial counters reached so far. + +Payments retention (PR #2754): the `created_by IS NULL` population above is EXACTLY + the future paying-customer population -- the owner sells this report to + individuals for money, and a paid row must outlive the 24h `expires_at` link TTL + (a separate column, `retain_until`, set by the -- separate, not-yet-existing -- + payment fulfillment code to now() + settings.trade_in_paid_retention_days, NOT + a change to `expires_at` itself). Two independent safeguards were added to + `_DELETE_EXPIRED_ESTIMATES_SQL` (retain_until IS NULL + NOT EXISTS payments) + plus a pre-flight count in `purge_expired_trade_in_data` that refuses to run at + all if it finds an ANOMALOUS paid candidate -- see the SQL constants and + `_preflight_paid_candidates` below for the mechanics (deep-review finding + 2026-08-06 MEDIUM on PR #2754: the pre-flight predicate itself must ALSO carry + `retain_until IS NULL`, otherwise a perfectly healthy paid row trips it and + wedges the job permanently -- see that function's docstring). No payment code + lives in this file. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.services import scrape_runs as runs_mod + +logger = logging.getLogger(__name__) + +# Safety cap on batches per table per run -- bounds a single scheduled run's total +# duration even if the backlog is much larger than batch_size * max_batches; the +# remainder simply drains on the next nightly run (idempotent, no data loss risk). +_DEFAULT_MAX_BATCHES = 20 + +# +# Payments retention (2026-08-06, PR #2754): два независимых предохранителя +# добавлены к тому же предикату ПЕРЕД тем, как платёжный код появился в +# проекте (мина уже была заряжена: без них джоба удаляла бы будущих платящих +# клиентов). Отдельная колонка retain_until (не подъём expires_at) — потому +# что expires_at глобальный TTL расчёта на ВСЕ строки (включая неоплаченные) +# и печатается в PDF/UI как «актуальность расчёта»; поднять его до года +# означало бы одновременно нарушить минимизацию ПДн по 152-ФЗ и соврать в +# документе клиента про срок актуальности цифры: +# 1. `retain_until IS NULL` — именно IS NULL, НЕ `< NOW()`. Оплаченная +# строка (retain_until IS NOT NULL, migration 240) не удаляется джобой +# В ПРИНЦИПЕ, пока не поднято ослабление отдельным PR не раньше чем +# через год после первой продажи. `retain_until` ставится сервисным +# кодом платёжного контура (ещё не существует в этом PR) на now() + +# settings.trade_in_paid_retention_days. +# 2. `NOT EXISTS (payments)` — независимая страховка на случай, если выдача +# забыла проставить retain_until (баг/гонка/ручной INSERT): строка, +# которой коснулись деньги, переживёт джобу даже без корректного (1). +# `payments` создана migration 233 (payments_estimate_idx — дешёвый терм). +# См. также _preflight_paid_candidates ниже — та же логика ДО первого батча. +_DELETE_EXPIRED_ESTIMATES_SQL = text( + """ + DELETE FROM trade_in_estimates + WHERE id IN ( + SELECT id FROM trade_in_estimates + WHERE expires_at < NOW() + AND created_by IS NULL + AND retain_until IS NULL + AND NOT EXISTS ( + SELECT 1 FROM payments p WHERE p.estimate_id = trade_in_estimates.id + ) + ORDER BY expires_at + LIMIT CAST(:batch_size AS int) + ) + """ +) + +# Pre-flight (см. _preflight_paid_candidates). deep-review finding 2026-08-06 +# MEDIUM (PR #2754): первая редакция считала по БАЗОВОМУ предикату БЕЗ +# retain_until вообще -- а это ловит и штатно-здоровые оплаченные строки +# (retain_until проставлен, есть payments) точно так же, как настоящую +# аномалию (retain_until НЕ проставлен, но payments есть) -- джоба вставала +# на первой же честной продаже и больше никогда не запускалась (вместе с ней +# вставало и удаление лидов, вызываемое из той же функции ПОСЛЕ этой +# проверки -- 180-дневный purge по 152-ФЗ тоже переставал бы работать). +# Правильная форма: базовый предикат AND "новый предохранитель НЕ сработал +# бы" (retain_until IS NULL) AND "признак аномалии" (payments всё же есть). +# Здоровая оплаченная строка (retain_until IS NOT NULL) исключается ЭТИМ +# термом -- она и так под DELETE не попадает (см. safeguard 1 выше), тревогу +# поднимать не должна. +_PREFLIGHT_PAID_CANDIDATES_SQL = text( + """ + SELECT count(*) FROM trade_in_estimates e + WHERE e.expires_at < NOW() + AND e.created_by IS NULL + AND e.retain_until IS NULL + AND EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id) + """ +) + +_DELETE_EXPIRED_LEADS_SQL = text( + """ + DELETE FROM trade_in_leads + WHERE id IN ( + SELECT id FROM trade_in_leads + WHERE expires_at < NOW() + ORDER BY expires_at + LIMIT CAST(:batch_size AS int) + ) + """ +) + + +def _drain_expired( + db: Session, + stmt: Any, + *, + batch_size: int, + max_batches: int, + label: str, + counters: dict[str, int], + counter_key: str, +) -> None: + """Run `stmt` (one bounded DELETE batch) repeatedly until caught up or capped. + + Commits after EVERY batch -- keeps each individual transaction/lock short even + when the backlog is large. Updates `counters[counter_key]` INCREMENTALLY (not + just once at the end) so that a mid-run exception on a LATER batch still leaves + an accurate count of what was actually deleted-and-committed by earlier batches + -- those rows are gone for real (commit already happened) whether or not this + function ever returns normally. + """ + for batch_num in range(1, max_batches + 1): + result = db.execute(stmt, {"batch_size": batch_size}) + deleted = result.rowcount or 0 + db.commit() + counters[counter_key] += deleted + logger.info( + "purge_expired_trade_in_data: %s batch=%d deleted=%d (running_total=%d)", + label, + batch_num, + deleted, + counters[counter_key], + ) + if deleted < batch_size: + break # caught up -- fewer expired rows left than one batch + + +def _preflight_paid_candidates(db: Session) -> int: + """Safety gate: count ANOMALOUS purge-candidates -- base predicate, retain_until + IS NULL (safeguard 1 did NOT protect the row), AND a payments row exists anyway. + + Runs BEFORE any DELETE batch. A non-zero result means fulfillment failed to set + `retain_until` on a row money actually touched (bug/race/manual INSERT) -- this + run must not delete anything; see `purge_expired_trade_in_data` below, which + aborts before the first batch when this returns non-zero. + + MUST include `retain_until IS NULL` (deep-review finding 2026-08-06 MEDIUM, PR + #2754): a healthy paid row (retain_until set, has a payments row) is the EXPECTED + steady state one day after every sale -- without this term it counts as a "paid + candidate" too, so the very first successful sale permanently wedges this job + (mark_failed, zero deletions, forever -- and since leads purge runs from the same + function AFTER this check, the unrelated 180-day lead retention would also stop). + """ + return db.execute(_PREFLIGHT_PAID_CANDIDATES_SQL).scalar_one() + + +def purge_expired_trade_in_data( + db: Session, + run_id: int, + *, + batch_size: int | None = None, + max_batches: int | None = None, +) -> dict[str, int]: + """Delete expired rows from trade_in_estimates + trade_in_leads, in bounded batches. + + Sync (invoked via run_in_executor from the kit-scheduler handler, same pattern as + deactivate_stale_listings). Finalises the scrape_runs row (mark_done / mark_failed). + + Returns {"estimates_deleted": N, "leads_deleted": M}. + + Payments retention pre-flight (see `_preflight_paid_candidates`): if any + purge-candidate estimate has `retain_until IS NULL` AND a `payments` row (the + ANOMALY -- fulfillment failed to set retain_until on a row money touched), the + run aborts BEFORE the first DELETE batch (estimates OR leads) -- zero rows + deleted, `mark_failed` records why. Healthy paid rows (retain_until set) do NOT + trip this -- they never matched the check to begin with. This is deliberately + checked outside the `try` below so it can never be caught and silently + re-reported as a generic mid-run failure -- it is a distinct, actionable + pre-condition failure. + """ + batch_size = batch_size or settings.trade_in_purge_batch_size + max_batches = max_batches or _DEFAULT_MAX_BATCHES + counters: dict[str, int] = {"estimates_deleted": 0, "leads_deleted": 0} + + paid_candidates = _preflight_paid_candidates(db) + if paid_candidates: + error = ( + f"pre-flight abort: {paid_candidates} purge-candidate trade_in_estimates " + "row(s) have retain_until IS NULL but a matching payments row -- " + "refusing to run, zero rows deleted" + ) + logger.error("purge_expired_trade_in_data run_id=%d %s", run_id, error) + runs_mod.mark_failed(db, run_id, error, counters) + raise RuntimeError(error) + + try: + _drain_expired( + db, + _DELETE_EXPIRED_ESTIMATES_SQL, + batch_size=batch_size, + max_batches=max_batches, + label="trade_in_estimates", + counters=counters, + counter_key="estimates_deleted", + ) + _drain_expired( + db, + _DELETE_EXPIRED_LEADS_SQL, + batch_size=batch_size, + max_batches=max_batches, + label="trade_in_leads", + counters=counters, + counter_key="leads_deleted", + ) + runs_mod.mark_done(db, run_id, counters) + logger.info( + "purge_expired_trade_in_data run_id=%d done: estimates_deleted=%d leads_deleted=%d", + run_id, + counters["estimates_deleted"], + counters["leads_deleted"], + ) + return counters + except Exception as exc: + logger.exception("purge_expired_trade_in_data 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/sber_freshness_monitor.py b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py index 00e49fdc..cde46a41 100644 --- a/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py +++ b/tradein-mvp/backend/app/tasks/sber_freshness_monitor.py @@ -1,35 +1,68 @@ -"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1. +"""Монитор ОТСТАВАНИЯ ЗАГРУЗКИ СберИндекса (не календарного возраста периода). -Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет -СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning, -когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба -`sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует -данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит -(на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness -видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок. +ЧТО БЫЛО НЕ ТАК (замер на проде 2026-08-12, #2846). -Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и -поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага -публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах. +Монитор мерил `now() - max(period_month)` и алертил при возрасте > 60 суток +(sber_index_max_age_days 35 + lag_allowance 25). Такой возраст НЕДОСТИЖИМО МАЛ по +построению: `period_month` — метка ПЕРВОГО числа месяца, поэтому на закрытии месяца +возрасту уже ≥30; плюс собственный лаг публикации источника. За 31 сутки прямых +наблюдений монитора (07-13 … 08-12, scrape_runs.counters) возраст лежал в 46..76 и +НИ РАЗУ не опускался ниже 46. Порог 35 у оценщика был истинным 100% времени — ноль бит. -Порог алерта (документирование выбора): - 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. +Порог 60 у монитора не лучше: он лежит ВНУТРИ рабочего диапазона, поэтому монитор +мерил не источник, а нашу же пилу. Миграция 212 (такт 28 → 7) обещала потолок +возраста ≈46+7=53 < 60. Прод это ОПРОВЕРГ: 2026-08-12 возраст 72 при ПОЛНОМ прогоне +загрузки шестидневной давности (08-06, errors=0, upserted=639) — источник просто не +опубликовал июль. Двенадцать суток подряд (08-01 … 08-12) монитор писал ERROR при +исправной загрузке. Потолок 53 держится, только если источник публикует строго +помесячно; он не публикует. -Задача синхронная (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 тестируется без БД). +ЧТО МЕРИМ ТЕПЕРЬ. Загрузчик тянет ВСЮ серию (limit=1000&offset=0, отсечки по периоду +нет), поэтому после прогона с errors=0 AND upserted>0 наш max(period_month) РАВЕН +максимуму источника ПО ПОСТРОЕНИЮ. Значит вопрос «отстали ли мы» — это вопрос +«давно ли был последний ЗАВЕДОМО ПОЛНЫЙ прогон», и он не зависит от возраста периода: -Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — WARNING -достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего -оценивать). + последний полный прогон свежий → наш max == max источника → источник не публиковал, + молчание ПРАВИЛЬНОЕ (возраст = лаг источника); + последний полный прогон старый → мы не забрали → тревога про ЗАГРУЗЧИК. + +ЛОВУШКА: `status='done'` НЕ означает успех — прогон id=37 (2026-05-31) имеет +{errors: 9, upserted: 0} и статус done. Успех = errors=0 AND upserted>0 (все 9 серий +3 табло × 3 региона прошли: errors — счётчик по всему прогону). + +ПОРОГ — не круглое число, а такт самой загрузки: `scrape_schedules.default_params +.interval_days` для sber_index_pull, ЧИТАЕТСЯ ИЗ ТОЙ ЖЕ СТРОКИ, по которой планировщик +запускает прогон (orchestration/scheduler.py::_defer_next_run_at). Разъехаться с +тактом порог не может: поменяли такт — порог поехал следом. Тревога после +MISSED_PULL_CYCLES=2 пропущенных тактов: один пропуск (сдвиг окна, разовый сбой сети) +поглощается, два подряд означают, что загрузка встала. При нынешнем такте 7 это 14 +суток; на прод-истории такое состояние ДОСТИЖИМО — разрывы между полными прогонами +были 14.8 и 20 суток (05-31→06-15 и 07-17→08-06). + +ПО ТАБЛО, А НЕ ПО max() ВСЕЙ ТАБЛИЦЫ. Оценщик берёт ПЕРВОЕ НЕПУСТОЕ табло из +estimator.SBER_COEFF_DASHBOARDS; у real_estate_deals latest=2026-06, у +dinamika-tsen-obyavlenii — 2026-05 (на 2026-08-12). max() по таблице маскирует +отставшее табло, поэтому монитор идёт тем же порядком, что и оценщик, и берёт ту же +серию — список импортируется из estimator, дублировать его тут нельзя. + +ЧЕГО ЭТОТ МОНИТОР НЕ ЛОВИТ (осознанно, #2846). Если источник ЗАМОЛЧИТ НАВСЕГДА, а +загрузка останется исправной — монитор промолчит: по нашим данным «источник не +публиковал 2 месяца» неотличимо от «источник публикует раз в 2 месяца». Такт +публикации источника ретроспективно невосстановим — его затёр апсерт +(sber_index.py ставил fetched_at=now() всем строкам серии). С этого PR fetched_at +не переписывается при конфликте и означает «когда мы ВПЕРВЫЕ увидели этот период», +т.е. такт публикации станет измеримым; вернуться к вопросу порога «источник встал» +имеет смысл после 3 наблюдённых публикаций (ориентир — ноябрь 2026). + +ERROR, а не WARNING (#2674): в контейнере скрапера GlitchTip поднят с +LoggingIntegration(event_level=ERROR), WARNING событием не становится вообще. + +Задача синхронная (DB-only) — запускается kit-scheduler'ом через +product_handlers._job_sber_freshness_monitor в run_in_executor. Вердикт считает +ЧИСТАЯ функция evaluate_sber_freshness() (frozen-now, тестируется без БД). + +Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы). mark_failed +только если у оценщика вообще нет серии (нечего оценивать). """ from __future__ import annotations @@ -41,148 +74,239 @@ from datetime import UTC, date, datetime from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import settings from app.services import scrape_runs as runs_mod +from app.services.estimator import SBER_COEFF_DASHBOARDS, SBER_TIME_ADJUST_REGION logger = logging.getLogger(__name__) __all__ = [ - "DEFAULT_LAG_ALLOWANCE_DAYS", + "DEFAULT_PULL_INTERVAL_DAYS", + "MISSED_PULL_CYCLES", + "SBER_FRESHNESS_PULL_SOURCE", "SberFreshnessVerdict", "check_sber_freshness", "evaluate_sber_freshness", ] -# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate -# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days. -DEFAULT_LAG_ALLOWANCE_DAYS = 25 +# Джоба-загрузчик, чей такт и успешность мы и мониторим. +SBER_FRESHNESS_PULL_SOURCE = "sber_index_pull" -# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями -# sber_price_index для областного уровня. -SBER_MONITOR_CITY = "Свердловская область" +# Сколько тактов загрузки подряд можно пропустить до тревоги. 1 = разовый сбой/сдвиг +# окна (поглощаем), 2 = загрузка встала (алерт). +MISSED_PULL_CYCLES = 2 -_LATEST_SBER_PERIOD_SQL = text(""" +# Фолбэк, если в scrape_schedules нет строки/ключа interval_days (миграция 212 ставит 7). +DEFAULT_PULL_INTERVAL_DAYS = 7 + +_LATEST_PERIOD_SQL = text(""" SELECT max(period_month) AS latest FROM sber_price_index WHERE city = CAST(:city AS text) + AND dashboard = CAST(:dash AS text) -- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка -- (новостройки) = направленно неверная коррекция. Зеркалит фильтр -- estimator._load_sber_index_series. AND (segment IS NULL OR segment ILIKE '%вторичн%') """) +# Последний ЗАВЕДОМО ПОЛНЫЙ прогон загрузчика. status='done' сюда не входит намеренно: +# прогон id=37 имеет done при {errors: 9, upserted: 0}. Сравнения — jsonb-ные, без +# CAST(... AS int): counters других источников планировщик может отфильтровать позже +# каста, а не раньше, и нечисловое значение уронило бы запрос. Для jsonb-чисел +# оператор > численный. +_LAST_COMPLETE_PULL_SQL = text(""" + SELECT max(finished_at) AS last_pull + FROM scrape_runs + WHERE source = CAST(:src AS text) + AND counters @> CAST('{"errors": 0}' AS jsonb) + AND counters -> 'upserted' > CAST('0' AS jsonb) +""") + +# Такт загрузки — из той же строки, по которой планировщик считает next_run_at. +_PULL_INTERVAL_SQL = text(""" + SELECT default_params ->> 'interval_days' AS interval_days + FROM scrape_schedules + WHERE source = CAST(:src AS text) +""") + @dataclass(frozen=True) class SberFreshnessVerdict: - """Вердикт свежести СберИндекса по max(period_month).""" + """Вердикт: отстала ли ЗАГРУЗКА СберИндекса от собственного такта.""" latest_period: date - age_days: int + age_days: int # наблюдение (лаг публикации источника), НЕ критерий тревоги + pull_lag_days: int # суток с последнего полного прогона; -1 = полных прогонов не было + max_pull_lag_days: int # порог = MISSED_PULL_CYCLES × такт загрузки stale: bool def evaluate_sber_freshness( latest_period: date, now: datetime, - max_age_days: int, + *, + last_complete_pull_at: datetime | None, + pull_interval_days: int, ) -> SberFreshnessVerdict: - """Чистая логика: устарел ли latest период СберИндекса. + """Чистая логика: отстала ли загрузка от собственного такта. - stale = age_days > max_age_days, где age_days = now.date() - latest_period. - `max_age_days` — ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance), - вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД. + stale = полных прогонов не было ВООБЩЕ, либо последний старше + MISSED_PULL_CYCLES × pull_interval_days. Возраст периода считается и кладётся в + вердикт как НАБЛЮДЕНИЕ, но на вердикт не влияет: после полного прогона наш + max(period_month) равен максимуму источника по построению, и его возраст — это + лаг ПУБЛИКАЦИИ, на который мы повлиять не можем. """ age_days = (now.date() - latest_period).days - stale = age_days > max_age_days + max_pull_lag_days = MISSED_PULL_CYCLES * pull_interval_days + if last_complete_pull_at is None: + return SberFreshnessVerdict(latest_period, age_days, -1, max_pull_lag_days, True) + pull_lag_days = (now - last_complete_pull_at).days return SberFreshnessVerdict( latest_period=latest_period, age_days=age_days, - stale=stale, + pull_lag_days=pull_lag_days, + max_pull_lag_days=max_pull_lag_days, + stale=pull_lag_days > max_pull_lag_days, ) +def _load_estimator_dashboard(db: Session) -> tuple[str, date] | None: + """Табло, которое возьмёт оценщик, и его latest период. + + Тот же порядок, что и estimator._load_sber_index_series: первое НЕПУСТОЕ табло + из SBER_COEFF_DASHBOARDS. max() по всей таблице маскировал бы отставшее табло. + """ + for dash in SBER_COEFF_DASHBOARDS: + row = db.execute( + _LATEST_PERIOD_SQL, {"city": SBER_TIME_ADJUST_REGION, "dash": dash} + ).first() + latest = row.latest if row is not None else None + if latest is not None: + return dash, latest + return None + + +def _pull_interval_days(db: Session) -> int: + """Такт загрузчика из scrape_schedules (фолбэк DEFAULT_PULL_INTERVAL_DAYS).""" + row = db.execute(_PULL_INTERVAL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE}).first() + raw = row.interval_days if row is not None else None + try: + return int(raw) if raw is not None else DEFAULT_PULL_INTERVAL_DAYS + except (TypeError, ValueError): + logger.warning( + "sber freshness: interval_days=%r в scrape_schedules нечисловой — беру %d", + raw, + DEFAULT_PULL_INTERVAL_DAYS, + ) + return DEFAULT_PULL_INTERVAL_DAYS + + def check_sber_freshness( db: Session, run_id: int, params: dict | None = None, # type: ignore[type-arg] now: datetime | None = None, ) -> dict[str, int]: - """Проверить свежесть СберИндекса по max(period_month) и алертить при staleness. + """Проверить, не отстала ли загрузка СберИндекса, и алертить при отставании. Sync (вызывается scheduler-триггером в executor, как check_deals_freshness). - Читает один SELECT max(period_month) вторичного сегмента по региону, считает - вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops) - и финализирует run. + Читает: latest период табло оценщика, время последнего ПОЛНОГО прогона + sber_index_pull, такт загрузки из scrape_schedules. Вердикт — чистой функцией. - Params (default_params jsonb): - lag_allowance_days: int — запас сверх sber_index_max_age_days (default 25). - `now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC). + `params` больше ничего не настраивает: порог берётся из такта самой загрузки + (унаследованный default_params.lag_allowance_days=25 монитора игнорируется — + он кодировал мёртвый календарный порог). `now` инъектируется в тестах. - Returns counters {latest_year, latest_month, age_days, alert}. - mark_failed только если sber_price_index пуста/недоступна (нечего оценивать); + Returns counters {latest_year, latest_month, age_days, pull_lag_days, + max_pull_lag_days, alert}. + mark_failed только если у оценщика нет серии вообще (нечего оценивать); при алерте прогон помечается done (это монитор, не сбой джобы). """ - params = params or {} now = now or datetime.now(UTC) counters: dict[str, int] = { "latest_year": 0, "latest_month": 0, "age_days": 0, + "pull_lag_days": -1, + "max_pull_lag_days": 0, "alert": 0, } try: runs_mod.update_heartbeat(db, run_id, counters) - 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( - "sber freshness: sber_price_index пуст/недоступен для region=%s " - "(вторичка) — оценить свежесть нельзя", - SBER_MONITOR_CITY, + found = _load_estimator_dashboard(db) + if found is None: + # ERROR (#2674): монитор не может выполнить свою работу вовсе — это сбой, + # а не наблюдение. mark_failed ниже виден только стрик-алерту (3 подряд), + # а монитор ходит раз в сутки — три дня молчания на пустом бенчмарке. + logger.error( + "sber freshness: у оценщика нет серии — ни одно табло %s не даёт строк " + "для region=%s (вторичка); оценить нечего", + list(SBER_COEFF_DASHBOARDS), + SBER_TIME_ADJUST_REGION, ) runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters) return counters - lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS)) - max_age_days = settings.sber_index_max_age_days + lag_days - verdict = evaluate_sber_freshness(latest, now, max_age_days) + dashboard, latest = found + last_pull_row = db.execute( + _LAST_COMPLETE_PULL_SQL, {"src": SBER_FRESHNESS_PULL_SOURCE} + ).first() + last_complete_pull_at = last_pull_row.last_pull if last_pull_row is not None else None + verdict = evaluate_sber_freshness( + latest, + now, + last_complete_pull_at=last_complete_pull_at, + pull_interval_days=_pull_interval_days(db), + ) counters = { "latest_year": latest.year, "latest_month": latest.month, "age_days": verdict.age_days, + "pull_lag_days": verdict.pull_lag_days, + "max_pull_lag_days": verdict.max_pull_lag_days, "alert": int(verdict.stale), } if verdict.stale: - logger.warning( - "sber freshness: max(period_month)=%s устарел на %d дней " - "(> порога %d = sber_index_max_age_days %d + lag %d); " - "СберИндекс time-adjustment ДКП-сделок мог отстать — " - "проверь sber_index_pull и доступность новых периодов источника", + # ERROR (#2674): WARNING не долетает до GlitchTip (event_level=ERROR). + logger.error( + "sber freshness: загрузка СберИндекса отстала — последний ПОЛНЫЙ прогон " + "%s (%s суток назад, порог %d = %d такта × %d суток; " + "status='done' с errors>0 за успех НЕ считается). " + "Наш max(period_month)=%s (табло %s) мог разойтись с источником — " + "проверь sber_index_pull: планировщик, сеть, /api/sowa 404", + last_complete_pull_at.isoformat() if last_complete_pull_at else "НИ РАЗУ", + verdict.pull_lag_days if verdict.pull_lag_days >= 0 else "∞", + verdict.max_pull_lag_days, + MISSED_PULL_CYCLES, + verdict.max_pull_lag_days // MISSED_PULL_CYCLES, latest, - verdict.age_days, - max_age_days, - settings.sber_index_max_age_days, - lag_days, + dashboard, ) else: logger.info( - "sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) " - "region=%s — алерта нет", + "sber freshness: загрузка в такте — последний полный прогон %d суток назад " + "(≤ порога %d). max(period_month)=%s (табло %s, возраст %d суток) равен " + "максимуму источника по построению: возраст = лаг ПУБЛИКАЦИИ источника, " + "не наше отставание — алерта нет", + verdict.pull_lag_days, + verdict.max_pull_lag_days, latest, + dashboard, verdict.age_days, - max_age_days, - SBER_MONITOR_CITY, ) runs_mod.mark_done(db, run_id, counters) logger.info( - "check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d", + "check_sber_freshness run_id=%d done: latest=%s dash=%s alert=%d " + "pull_lag_days=%d age_days=%d", run_id, latest, + dashboard, counters["alert"], + counters["pull_lag_days"], counters["age_days"], ) return counters diff --git a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py index b6b6f865..48771f0b 100644 --- a/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/yandex_detail_backfill.py @@ -12,8 +12,24 @@ 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. + +Почему брейкер срабатывал так часто (разобрано 2026-08-06, замеры в комментарии +у OFFER_URL_PATTERN): в очереди лежали карточки новостроек, у которых source_url +ведёт на сайт застройщика, а не на realty.yandex.ru/offer//. Парсер отвергает +такие URL регуляркой ДО сети — это не капча, а предрешённый parse→None. Идут они +пачками, поэтому «5 подряд» набиралось на первых же строках и обрывало прогон +целиком. Снапшот-SELECT берёт только то, что парсер в принципе может разобрать. + +Но «не по тому URL» ≠ «нечего обогащать» (разобрано 2026-08-12, см. комментарий +у OFFER_ID_PATTERN): у ВСЕХ таких строк в source_id лежит yandex offerId, и по +собранному из него каноническому URL страница отдаётся и парсится. Поэтому в +очередь они входят по адресу, ВЫЧИСЛЕННОМУ из source_id, а counters разделены: +url_from_offer_id — сколько ждёт починки адреса, unenrichable_pending — сколько +не адресуемо вообще (ни offer-URL, ни числового source_id). Why curl_cffi and not YandexDetailScraper.fetch_detail: fetch_detail uses BaseScraper._http_get (plain httpx, no proxy, no TLS @@ -35,16 +51,67 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper, save_detail from sqlalchemy import text from sqlalchemy.orm import Session -from app.core.config import settings from app.services import scrape_runs as runs_mod +from app.services.proxy_egress import resolve_proxy_url logger = logging.getLogger(__name__) __all__ = [ + "CANONICAL_URL_SQL", + "OFFER_ID_PATTERN", + "OFFER_URL_PATTERN", "YandexDetailBackfillResult", "run_yandex_detail_backfill", ] +# Условие, при котором обогащение этого объявления вообще возможно (#2723-класс). +# `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/` и без него +# возвращает None ЕЩЁ ДО обращения к HTML (providers/yandex/detail.py:150) — то есть +# отказ предрешён регуляркой, а не капчей. +# +# Замер прода 2026-08-06: из 15 511 необогащённых yandex-объявлений 3 535 имеют +# source_url на сайт застройщика (macroserver.ru, prospect-federation.ru, +# strana.com, …) — так карточки новостроек ведут с выдачи Яндекса. +# +# Вред не в бесполезности, а в том, что они идут ПАЧКАМИ (один свип — один +# застройщик) и упираются в брейкер «5 parse-None подряд», обрывающий ВЕСЬ прогон: +# 32 прогона из 53 закончились ровно так — attempted=5, enriched=0, 23 секунды. +# Плюс каждая такая попытка — запрос на чужой сайт, который мы всё равно выбросим. +OFFER_URL_PATTERN = "/offer/[0-9]+" + +# ── «Непригодных» не бывает без причины (разобрано 2026-08-12) ──────────────── +# Симптом: unenrichable_pending шесть прогонов подряд равнялся РОВНО 3535 — ни на +# единицу, при том что очередь обогащалась по ~500/прогон. Замер на проде: +# +# * счётчик считается живым SELECT'ом, кэша/матвьюхи нет — арифметика честная; +# * множество замкнуто: новых строк в него не приходит (0 из 6892 yandex-строк, +# вставленных после самой свежей его строки, id 2583989), и выйти из него +# нельзя (обогащение недостижимо, source_url не переписывается). Замкнутое +# множество и обязано быть константой — вопрос был не «почему не растёт», а +# «правда ли они непригодны». +# +# Непригодны они НЕ были. У всех 3535 в source_id лежит числовой yandex offerId +# (у 3523 он же продублирован в yandex_offer_id), а канонический адрес оффера из +# него собирается — это инвариант #2235 (`_canonical_source_url` в +# scraper_kit/providers/yandex/serp.py) и та же формула, которой миграция 164 +# чинила легаси-строки. Живая проба 2026-08-12 прод-трактом (тот же прокси, +# curl_cffi chrome120, тот же parse): 6 из 6 — HTTP 200 и parse OK, включая +# строки, чей сохранённый source_url — рекламный редирект na100.pro/go.php. +# +# Откуда взялся стухший адрес: source_url пишется ТОЛЬКО при вставке — его нет ни +# в `ON CONFLICT DO UPDATE`, ни в reconcile-UPDATE у `save_listings`. Значит #2235 +# вылечил только новые строки, миграция 164 — только те легаси, чей URL ДЕЛИЛИ +# несколько строк (она искала дубли URL, а не непарсимость). Строки с уникальной +# ссылкой на карточку застройщика не попали ни туда, ни туда и носят адрес, +# замороженный в момент вставки, хотя свип переобходит ~511 из них в сутки. +# +# Поэтому адресуем такие строки вычисленным URL, а не сохранённым. Починка самой +# колонки (одноразовый UPDATE, тот же 164 без условия на дубли) — за миграцией: +# от неё зависит и yandex_address_backfill, где 1618 из 5217 кандидатов ходят +# на сайты застройщиков вместо Яндекса. +OFFER_ID_PATTERN = "^[0-9]+$" +CANONICAL_URL_SQL = "'https://realty.yandex.ru/offer/' || source_id || '/'" + @dataclass class YandexDetailBackfillResult: @@ -53,6 +120,12 @@ class YandexDetailBackfillResult: attempted: int = 0 enriched: int = 0 failed: int = 0 + # Ждут обогащения, сохранённый source_url непарсим, но адрес восстановим из + # source_id — идут в очередь по вычисленному URL. Должен убывать от прогона к + # прогону; замер на месте = очередь снова читается не тем признаком. + url_from_offer_id: int = 0 + # Ждут обогащения и адресовать их НЕЧЕМ: ни offer-URL, ни числового source_id. + unenrichable_pending: int = 0 duration_sec: float = field(default=0.0) def to_dict(self) -> dict[str, int]: @@ -60,6 +133,8 @@ class YandexDetailBackfillResult: "attempted": self.attempted, "enriched": self.enriched, "failed": self.failed, + "url_from_offer_id": self.url_from_offer_id, + "unenrichable_pending": self.unenrichable_pending, "duration_sec": int(self.duration_sec), } @@ -85,7 +160,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)) @@ -102,32 +178,100 @@ async def run_yandex_detail_backfill( # SNAPSHOT: single SELECT at start -- NOT re-selected in loop. # Priority: is_active DESC (active first), scraped_at DESC (newest first). + # В очередь идёт то, для чего есть АДРЕС, который парсер примет: либо + # сохранённый source_url подходит под OFFER_URL_PATTERN, либо адрес + # собирается из source_id (см. комментарий у OFFER_ID_PATTERN). Что шире + # этого условия — гарантированный parse→None пачкой и обрыв по брейкеру. snapshot = ( db.execute( text( - """ - SELECT id, source_url + f""" + SELECT id, + CASE + WHEN source_url ~ CAST(:offer_url_pattern AS text) + THEN source_url + ELSE {CANONICAL_URL_SQL} + END AS source_url FROM listings WHERE source = 'yandex' AND detail_enriched_at IS NULL - AND source_url IS NOT NULL + AND ( + ( + source_url IS NOT NULL + AND source_url ~ CAST(:offer_url_pattern AS text) + ) + OR source_id ~ CAST(:offer_id_pattern AS text) + ) ORDER BY is_active DESC NULLS LAST, scraped_at DESC NULLS LAST LIMIT CAST(:batch_size AS int) """ + # f-string здесь безопасен: CANONICAL_URL_SQL — литерал модуля, + # не пользовательский ввод. Всё изменяемое — bind-параметры. ), - {"batch_size": batch_size}, + { + "batch_size": batch_size, + "offer_url_pattern": OFFER_URL_PATTERN, + "offer_id_pattern": OFFER_ID_PATTERN, + }, ) .mappings() .all() ) + # Отброшенное не должно исчезнуть из виду: без этого счётчика «обогащено + # 12 тыс. из 15,5 тыс.» снова стало бы необъяснимым нулём (#2674). И оно + # разделено по ПРИЧИНЕ: одно число на две разные судьбы читалось как + # «тут делать нечего» и держало 3535 квартир вне обогащения неделю. + pending = db.execute( + text( + """ + SELECT + count(*) FILTER ( + WHERE source_id ~ CAST(:offer_id_pattern AS text) + ) AS url_from_offer_id, + count(*) FILTER ( + WHERE source_id IS NULL + OR source_id !~ CAST(:offer_id_pattern AS text) + ) AS unenrichable_pending + FROM listings + WHERE source = 'yandex' + AND detail_enriched_at IS NULL + AND ( + source_url IS NULL + OR source_url !~ CAST(:offer_url_pattern AS text) + ) + """ + ), + {"offer_url_pattern": OFFER_URL_PATTERN, "offer_id_pattern": OFFER_ID_PATTERN}, + ).one() + counters.url_from_offer_id = int(pending.url_from_offer_id) + counters.unenrichable_pending = int(pending.unenrichable_pending) + if counters.url_from_offer_id: + logger.info( + "yandex_detail_backfill: run_id=%d — у %d объявлений сохранённый " + "source_url не ведёт на карточку Яндекса; адресуем их по offerId из " + "source_id (колонку чинит миграция, см. OFFER_ID_PATTERN)", + run_id, + counters.url_from_offer_id, + ) + if counters.unenrichable_pending: + logger.warning( + "yandex_detail_backfill: run_id=%d — %d объявлений вне очереди: нет ни " + "offer-URL (%s), ни числового source_id — адресовать их нечем", + run_id, + counters.unenrichable_pending, + OFFER_URL_PATTERN, + ) + if not snapshot: logger.info( "yandex_detail_backfill: run_id=%d -- no pending listings " "(detail_enriched_at IS NULL = 0), done", run_id, ) - runs_mod.mark_done(db, run_id, current_counters) + # to_dict(), а не current_counters: пустая очередь при непустом + # unenrichable_pending — самый важный случай этого счётчика. + runs_mod.mark_done(db, run_id, counters.to_dict()) return counters logger.info( @@ -140,8 +284,15 @@ async def run_yandex_detail_backfill( max_consecutive_blocks, ) - # Build proxies dict once — mirrors yandex_address_backfill.py - _proxy = settings.scraper_proxy_url + # Build proxies dict once — mirrors yandex_address_backfill.py. + # Резолвер по источнику (#2825): пул scrape_proxies с учётом + # scrape_proxy_source_bans, fallback на settings.scraper_proxy_url только если + # пул пуст (легитимный dev/staging-сценарий). Пул не пуст, но все забанены/ + # нездоровы для yandex -- resolve_proxy_url бросает ProxyPoolExhaustedError + # (fail-closed, #2616): НАРОЧНО не ловим здесь -- штатный except Exception ниже + # (mark_failed + logger.exception + raise) уже даёт явную деградацию run'а с + # понятным логом, отдельный catch не нужен. + _proxy = resolve_proxy_url(db, "yandex") _proxies = {"http": _proxy, "https": _proxy} if _proxy else None consecutive_none = 0 @@ -278,9 +429,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/app/tgbot_main.py b/tradein-mvp/backend/app/tgbot_main.py index 5f731a86..48eaf32e 100644 --- a/tradein-mvp/backend/app/tgbot_main.py +++ b/tradein-mvp/backend/app/tgbot_main.py @@ -58,12 +58,17 @@ if settings.glitchtip_dsn: from sentry_sdk.integrations.httpx import HttpxIntegration from sentry_sdk.integrations.logging import LoggingIntegration - from app.observability.sentry_scrub import redact_telegram_bot_token, scrub_pii_event + from app.observability.sentry_scrub import ( + redact_telegram_bot_token, + scrub_payment_request_body, + scrub_pii_event, + ) def _before_send(event: Any, hint: dict[str, Any]) -> Any: - """Композиция PII-scrub (form-данные) + Telegram bot-токен redaction - (#tgsupport review). Токен утекает ДВУМЯ независимыми векторами, которые - `include_local_variables=False` ниже и этот хук закрывают вместе: + """Композиция платёжный body-wipe (PR-D2) + PII-scrub (form-данные) + + Telegram bot-токен redaction (#tgsupport review). Токен утекает ДВУМЯ + независимыми векторами, которые `include_local_variables=False` ниже и + этот хук закрывают вместе: 1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame locals (`self._base`/`url` в `TelegramClient._request`) в traceback — закрыто через `include_local_variables=False` в `sentry_sdk.init`. @@ -72,8 +77,16 @@ if settings.glitchtip_dsn: перестанет спасать, если трейсинг когда-нибудь включат. Regex-редактор — belt-and-suspenders на случай #1 (если include_local_variables случайно вернут) И на span data. + + Платёжный body-wipe — belt-and-suspenders: этот процесс не держит ASGI- + приложения (нет `request` в event сегодня), но тот же обработчик передан + ОБОИМ каналам ниже (before_send/before_send_transaction) ради единообразия + со всеми точками инициализации sentry_sdk в проекте (см. app/main.py). """ - scrubbed = scrub_pii_event(event, hint) + scrubbed = scrub_payment_request_body(event, hint) + if scrubbed is None: + return None + scrubbed = scrub_pii_event(scrubbed, hint) if scrubbed is None: return None return redact_telegram_bot_token(scrubbed, hint) @@ -86,6 +99,7 @@ if settings.glitchtip_dsn: send_default_pii=False, include_local_variables=False, before_send=_before_send, + before_send_transaction=_before_send, integrations=[ HttpxIntegration(), LoggingIntegration(level=logging.INFO, event_level=logging.ERROR), 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/222_db_audit_cleanup.sql b/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql new file mode 100644 index 00000000..35bfb069 --- /dev/null +++ b/tradein-mvp/backend/data/sql/222_db_audit_cleanup.sql @@ -0,0 +1,140 @@ +-- 222_db_audit_cleanup.sql +-- Уборка по итогам ручного аудита схемы tradein (2026-08-06). Три независимых +-- части, порядок между ними не важен (разные объекты, нет пересекающихся +-- зависимостей). Идемпотентно целиком: IF EXISTS везде, CREATE OR REPLACE VIEW. +-- +-- ── A. Временные таблицы разовой чистки 02.07 ───────────────────────────────── +-- tmp_purged_junk_houses_0702 / tmp_purged_junk_links_0702 — снэпшоты записей, +-- вычищенных вручную 2026-07-02. Проверено на проде перед этой миграцией: +-- * обе существуют под этими именами, суммарно 2.9 МБ +-- (tmp_purged_junk_houses_0702 = 1608 kB, tmp_purged_junk_links_0702 = 1272 kB); +-- * pg_constraint: ни один FK НЕ ссылается на них (confrelid пусто); +-- * pg_depend: ни view, ни другой объект их не использует (только сами по себе). +-- Дальше не нужны — были just-in-case снэпшотом на случай отката чистки, месяц +-- прошёл без претензий. +-- +-- ── B. Пять строгих дублей индексов ─────────────────────────────────────────── +-- Строгий дубль = тот же access method + тот же УПОРЯДОЧЕННЫЙ список колонок +-- (включая ASC/DESC/NULLS) + тот же частичный предикат (или оба NULL) + тот же +-- opclass, независимо от UNIQUE-флага и имени. Проверено запросом по +-- pg_index/pg_stat_user_indexes/pg_opclass на проде — найдено РОВНО 5 пар (не 6, +-- см. примечание ниже), в каждой паре оставляем индекс, несущий UNIQUE-constraint +-- (дропнуть его нельзя без дропа constraint'а), дропаем чистый btree-дубль: +-- +-- agents: DROP agents_source_ext_idx +-- (дубль agents_ext_source_ext_agent_id_key, btree (ext_source, ext_agent_id); +-- 2 скана за всё время — планировщик и так предпочитал unique-версию) +-- ekb_geoportal_buildings: DROP ix_ekb_geoportal_buildings_street_house +-- (дубль ekb_geoportal_buildings_street_norm_house_norm_key, +-- btree (street_norm, house_norm); 13 945 сканов, но unique-версия того же +-- определения покрывает те же запросы — 70 702 скана на ней) +-- house_placement_history: DROP hph_source_item_idx +-- (дубль house_placement_history_source_ext_item_id_key, +-- btree (source, ext_item_id); 0 сканов — полностью мёртв) +-- house_reviews: DROP hr_source_ext_idx +-- (дубль house_reviews_source_ext_review_id_key, btree (source, ext_review_id); +-- 15 сканов) +-- sellers: DROP sellers_source_idx +-- (дубль sellers_source_ext_seller_id_key, btree (source, ext_seller_id); +-- 2 скана) +-- +-- ПРИМЕЧАНИЕ (расхождение с ожиданием «шесть»): при систематической проверке +-- (3 независимых метода: нормализованный DDL-текст, сравнение indkey/indoption, +-- сравнение opclass) строгих дублей найдено 5, не 6. Два похожих кандидата +-- ЦЕЛЕНАПРАВЛЕННО исключены — их «дубль» только по списку колонок, а порядок +-- сортировки различается (ASC,ASC у unique-версии против ASC,DESC у второй), +-- то есть это ТОТ ЖЕ класс исключения, что explicitly подтверждённые +-- idx_lss_source_date/listings_snapshots_listing_date_idx (#2607, см. миграцию 225): +-- * offer_price_history: oph_listing_time_idx (listing_id ASC, change_time DESC) +-- против offer_price_history_listing_change_uq (listing_id ASC, change_time ASC) +-- * houses_price_dynamics: hpd_house_dim_idx (..., month_date DESC) +-- против houses_price_dynamics_dim_key (..., month_date ASC) +-- Оба НЕ тронуты. Если «шесть» подразумевали один из них — нужно явное +-- подтверждение, что смешанный порядок сортировки в конкретном запросе не +-- используется (тем же способом, каким для idx_lss_source_date подтверждено +-- обратное). +-- +-- ── C. v_data_quality — явный список колонок вместо SELECT * ───────────────── +-- Сейчас: `WITH active_listings AS (SELECT * FROM listings WHERE is_active = true)`. +-- Postgres разворачивает `*` в CREATE VIEW time в полный список колонок listings +-- (89 на момент миграции) и фиксирует pg_depend на КАЖДУЮ из них — это то самое +-- уже задокументированное в 214/216 предупреждение («View-зависимость: v_data_quality +-- содержит SELECT * FROM listings, что фиксирует column-level зависимость на ВСЕ +-- колонки»), которое обязывало делать DROP VIEW → DROP COLUMN → CREATE VIEW при +-- каждой чистке listings. +-- Фактически используются только 6 колонок active_listings ниже по телу view: +-- id — IN (SELECT active_listings.id FROM active_listings) +-- lat — pct_geocoded +-- cadastral_number — pct_cadastr +-- description — pct_description +-- house_id_fk — JOIN houses h ON h.id = l.house_id_fk (pct_year_built) +-- is_active — WHERE-фильтр самого CTE (создаёт зависимость даже не будучи +-- в SELECT-списке, поэтому перечислен явно для наглядности) +-- Поведение view НЕ меняется — только явный список вместо *. Тело SELECT (16 +-- выходных колонок) скопировано без изменений из 216_dead_code_sweep.sql. +-- COMMENT ON VIEW сохраняется автоматически (CREATE OR REPLACE VIEW не сбрасывает +-- комментарий). +-- +-- Dependencies: 002_core_tables.sql (listings), 214_drop_dead_run_metrics.sql, +-- 216_dead_code_sweep.sql (последний DDL v_data_quality). +-- Идемпотентно: DROP TABLE IF EXISTS / DROP INDEX IF EXISTS / CREATE OR REPLACE VIEW. + +BEGIN; + +-- ── A ────────────────────────────────────────────────────────────────────── +DROP TABLE IF EXISTS tmp_purged_junk_houses_0702, tmp_purged_junk_links_0702; + +-- ── B ────────────────────────────────────────────────────────────────────── +DROP INDEX IF EXISTS agents_source_ext_idx; +DROP INDEX IF EXISTS ix_ekb_geoportal_buildings_street_house; +DROP INDEX IF EXISTS hph_source_item_idx; +DROP INDEX IF EXISTS hr_source_ext_idx; +DROP INDEX IF EXISTS sellers_source_idx; + +-- ── C ────────────────────────────────────────────────────────────────────── +CREATE OR REPLACE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT id, lat, cadastral_number, description, house_id_fk, is_active + 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; + +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/225_listing_source_snapshots_run_id_idx.sql b/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql new file mode 100644 index 00000000..e783502e --- /dev/null +++ b/tradein-mvp/backend/data/sql/225_listing_source_snapshots_run_id_idx.sql @@ -0,0 +1,42 @@ +-- 225_listing_source_snapshots_run_id_idx.sql +-- Индекс под внешний ключ listing_source_snapshots.run_id → scrape_runs(id) +-- ON DELETE SET NULL. Индекса на run_id нет (проверено \d listing_source_snapshots +-- на проде): есть только listing_source_snapshots_pkey (listing_source_id, +-- snapshot_date), idx_lss_source_date (listing_source_id, snapshot_date DESC), +-- idx_lss_snapshot_date (snapshot_date DESC) — ни один не начинается с run_id. +-- Таблица — 2 872 080 строк (reltuples), 696 MB (pg_total_relation_size). +-- Без индекса каждый DELETE из scrape_runs делает Seq Scan по 2.87М строк, чтобы +-- обнулить run_id у зависимых снэпшотов (ON DELETE SET NULL). +-- +-- ── Почему CONCURRENTLY и почему в файле нет BEGIN/COMMIT ──────────────────── +-- CREATE INDEX CONCURRENTLY не может выполняться внутри блока транзакции +-- (Postgres: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block"). +-- На 2.87М строк / 696 MB обычный CREATE INDEX держит ACCESS EXCLUSIVE lock на +-- время сборки (секунды-десятки секунд под нагрузкой) — на боевой таблице, +-- которую пишет активный скрейпинг, это неприемлемо; нужен CONCURRENTLY. +-- +-- Более ранние миграции с похожей потребностью (117, 120, 134, 137) сознательно +-- ОТКАЗАЛИСЬ от CONCURRENTLY с комментарием «deploy migration runner wraps each +-- file in an explicit transaction (BEGIN/COMMIT)». Перепроверено перед этой +-- миграцией: .forgejo/workflows/deploy-tradein.yml, шаг применения миграций +-- (`for sql_file in ...; psql -v ON_ERROR_STOP=on < "$sql_file"`) НЕ добавляет +-- собственный BEGIN/COMMIT и не передаёт `-1`/`--single-transaction` — транзакция +-- в тех файлах возникала ТОЛЬКО из-за их же собственных BEGIN;...COMMIT; внутри +-- файла, не из-за механизма деплоя. Эмпирическое подтверждение: в data/sql уже +-- есть применённые на проде миграции без BEGIN/COMMIT вовсе (003_seed_deals.sql, +-- 005_geocode_tracking.sql, 218_scrape_runs_ban_kind.sql, +-- 223_scrape_runs_time_columns_meaning.sql) — psql выполняет их операторы с +-- autocommit по одному, деплой не падает. Поэтому здесь BEGIN/COMMIT сознательно +-- ОПУЩЕН: файл — это один самостоятельный CREATE INDEX CONCURRENTLY, выполняемый +-- psql в autocommit-режиме. +-- +-- Идемпотентно: IF NOT EXISTS. (Единственный неидемпотентный случай — если +-- предыдущая попытка CONCURRENTLY была прервана и оставила INVALID индекс с тем +-- же именем; тогда IF NOT EXISTS молча НЕ пересоздаст его валидным, и потребуется +-- ручной `DROP INDEX CONCURRENTLY idx_lss_run_id;` перед повтором — это штатное +-- поведение CONCURRENTLY, не специфика этого файла.) +-- Dependencies: 079_listing_source_history.sql (создала таблицу и оба FK). +-- Deploy order: standalone, независим от 222_db_audit_cleanup.sql. + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_lss_run_id + ON listing_source_snapshots (run_id); 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/228_scrape_proxies_browser_health.sql b/tradein-mvp/backend/data/sql/228_scrape_proxies_browser_health.sql new file mode 100644 index 00000000..71b4cd62 --- /dev/null +++ b/tradein-mvp/backend/data/sql/228_scrape_proxies_browser_health.sql @@ -0,0 +1,66 @@ +-- 228_scrape_proxies_browser_health.sql +-- Здоровье узла ОТДЕЛЬНО для браузерного тракта (#2723). +-- +-- WHY: +-- `run_proxy_healthcheck` гоняет через узел обычный httpx-GET к ipify. Боевой сбор +-- Авито с 02.08 (#2637) ходит через сайдкар браузером: camoufox стартует С ЭТИМ +-- прокси (geoip-lookup на launch), потом навигация. Это разные свойства узла: +-- крошечный GET проходит там, где launch/навигация падает (`browser unavailable +-- (proxy may be down)` — все 90 записанных обрывов сбора именно такие). +-- +-- Хуже того, оба свойства писались в ОДИН счётчик: боевой /fetch репортит +-- mark_health(ok=False) → consecutive_fails++, но следующая (≤30 мин) успешная +-- ipify-проба делает consecutive_fails=0 + enabled=true. Дешёвая проба СТИРАЛА +-- вердикт дорогого тракта, и узел, мёртвый для браузера, вечно возвращался в +-- выдачу. Это ровно ошибка #2686 (схлопывание двух диагнозов в один флаг) в +-- другом месте; разводим её тем же приёмом, что #2711 (`scrape_runs.ban_kind`) — +-- поле РЯДОМ, а не новое значение существующего флага. +-- +-- WHAT (три колонки, ни одна не участвует в enabled/consecutive_fails): +-- browser_fail_streak — подряд-провалы ИМЕННО браузерной пробы, и только те, что +-- атрибутируются узлу (сайдкар лежит / страница пустая — +-- не считаются, см. proxy_pool._classify_browser_probe). +-- Успешная ipify-проба его НЕ обнуляет — в этом весь смысл. +-- browser_unfit_since — момент, когда streak дошёл до порога. NOT NULL = «жив для +-- HTTP, непригоден для браузера». acquire() такой узел НЕ +-- исключает (голодание хуже — #2600/#2638, пул 4 узла), а +-- отправляет в КОНЕЦ очереди выдачи: его возьмут, только +-- если свободных пригодных нет. +-- browser_check_at — когда браузерную пробу гоняли последний раз. Такт у неё +-- свой, редкий (BROWSER_PROBE_MINUTES): она стоит запуска +-- camoufox (~8с замерено на проде), ipify — миллисекунды. +-- +-- IDEMPOTENCY / SAFETY: +-- ADD COLUMN IF NOT EXISTS × 3, аддитивно, без backfill'а: NULL/0 = «браузерную +-- пробу ещё не гоняли», ровно то состояние, в котором пул и находится. Ни одна +-- существующая выборка не меняет результат (все три колонки новые). Повторный +-- прогон — no-op (auto-apply strict на деплое это требует). +-- +-- Dependencies: 157_scrape_proxies.sql + +BEGIN; + +ALTER TABLE scrape_proxies + ADD COLUMN IF NOT EXISTS browser_fail_streak integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS browser_unfit_since timestamptz, + ADD COLUMN IF NOT EXISTS browser_check_at timestamptz; + +COMMENT ON COLUMN scrape_proxies.browser_fail_streak IS + 'Подряд-провалы браузерной пробы (сайдкар + camoufox через ЭТОТ узел), ' + 'атрибутированные узлу. НЕ обнуляется успешной ipify-пробой — иначе дешёвая ' + 'проба стирает вердикт дорогого тракта (#2723). Обнуляется успешной браузерной ' + 'пробой. Порог → browser_unfit_since, см. proxy_pool.BROWSER_UNFIT_THRESHOLD.'; + +COMMENT ON COLUMN scrape_proxies.browser_unfit_since IS + 'NOT NULL = узел жив для обычного HTTP, но браузерный тракт через него не ' + 'работает (#2723). Это НЕ enabled=false: узел остаётся в пуле и обслуживает ' + 'curl-путь, а acquire() лишь отдаёт его последним. Полное выключение по-прежнему ' + 'значит «узел мёртв целиком» (серия транспортных сбоев) либо решение оператора.'; + +COMMENT ON COLUMN scrape_proxies.browser_check_at IS + 'Последняя браузерная проба. Такт свой, редкий (proxy_pool.BROWSER_PROBE_MINUTES): ' + 'одна такая проба = запуск camoufox (~8с на проде), против миллисекунд у ipify. ' + 'На неподтверждённом провале НЕ обновляется — чтобы следующий же цикл ' + 'healthcheck подтвердил/опроверг отказ, а не ждал полный такт.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/229_trade_in_estimates_consent_proof.sql b/tradein-mvp/backend/data/sql/229_trade_in_estimates_consent_proof.sql new file mode 100644 index 00000000..482f9405 --- /dev/null +++ b/tradein-mvp/backend/data/sql/229_trade_in_estimates_consent_proof.sql @@ -0,0 +1,93 @@ +-- 229_trade_in_estimates_consent_proof.sql +-- ЭТАП 4 B2C launch — правовая рамка для анонимных пользователей (152-ФЗ). +-- +-- WHY: +-- trade_in_estimates.address (NOT NULL) сохраняется на КАЖДОЙ оценке, но до +-- сих пор в кодовой базе не было ни единой проверки согласия ДО этого +-- сохранения — ни на уровне схемы, ни на уровне API. Для авторизованного +-- B2B-пилота это было терпимо (согласие закрыто договором), но сегодняшняя +-- схема совершенно не готова к анонимному B2C-пользователю "с улицы": для +-- него согласия не существует вообще, ни в БД, ни в договоре. +-- +-- Прецедент уже есть и работает: migration 182 добавила на trade_in_leads +-- durable-доказательство согласия (client_ip / consent_policy_version / +-- consent_text_snapshot) вместо голого boolean + audit-лога. Этот файл +-- переиспользует РОВНО ТУ ЖЕ форму для trade_in_estimates — согласованность +-- схемы для одного и того же понятия (152-ФЗ proof-of-consent) в двух +-- соседних таблицах важнее гипотетической экономии на отдельной events- +-- таблице (см. разбор формы хранения в PR/задаче ЭТАП 4). +-- +-- WHAT: +-- Четыре nullable-колонки на trade_in_estimates: +-- - consent (boolean) — True для анонимных запросов, что +-- прошли consent-gate в +-- estimate_quality() (app/services/ +-- estimator.py). NULL для B2B-пилотов +-- (created_by задан) — их согласие +-- закрыто договором, НЕ UI-чекбоксом, +-- и мы НЕ подделываем доказательство, +-- которое реально не собиралось. +-- - client_ip (inet) — клиентский IP анонимного запроса на +-- момент согласия. +-- - consent_policy_version (text) — снимок _ESTIMATE_CONSENT_POLICY_VERSION +-- (estimator.py) на момент согласия. +-- - consent_text_snapshot (text) — снимок точного текста согласия, +-- показанного пользователю +-- (_ESTIMATE_CONSENT_TEXT_SNAPSHOT). +-- +-- CHECK-констрейнт: consent IS NULL OR consent IS TRUE — на уровне схемы +-- защищает от того, чтобы False-согласие когда-либо попало в БД (сама +-- проверка в estimate_quality() уже не пускает False дальше 422, это +-- defense-in-depth на случай будущего кода, который забудет про gate). +-- +-- Индекс на expires_at — обслуживает будущую retention-задачу +-- purge_expired_trade_in_data (см. migration 231), которая физически +-- удаляет строки, чей expires_at истёк (сегодня expires_at используется +-- ТОЛЬКО как read-time фильтр, см. GET /estimate/{id}: "AND expires_at > +-- NOW()" — без индекса такой batched-DELETE делал бы full scan таблицы +-- на каждый ночной прогон). +-- +-- IDEMPOTENCY / SAFETY: +-- - ADD COLUMN IF NOT EXISTS x4 — безопасный re-run, все nullable, без +-- DEFAULT, без backfill (существующие строки остаются NULL — честное +-- отражение того, что доказательство согласия для них НЕ собиралось, +-- не искусственная порча схемы). +-- - CHECK-констрейнт добавлен через DO-блок с проверкой pg_constraint по +-- имени (Postgres не поддерживает `ADD CONSTRAINT IF NOT EXISTS` +-- напрямую) — паттерн 1:1 из 189_account_estimate_usage_nonnegative.sql. +-- - CREATE INDEX IF NOT EXISTS — безопасный re-run. +-- - Чисто additive: ничего существующего не читается/не переписывается. +-- +-- Dependencies: 001_trade_in_estimates.sql (таблица), +-- 083_trade_in_estimates_created_by.sql (created_by, использован в gate), +-- 182_trade_in_leads_consent_proof.sql (форма-прецедент для trade_in_leads). + +BEGIN; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS consent boolean, + ADD COLUMN IF NOT EXISTS client_ip inet, + ADD COLUMN IF NOT EXISTS consent_policy_version text, + ADD COLUMN IF NOT EXISTS consent_text_snapshot text; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'trade_in_estimates_consent_not_false' + ) THEN + ALTER TABLE trade_in_estimates + ADD CONSTRAINT trade_in_estimates_consent_not_false + CHECK (consent IS NULL OR consent IS TRUE); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS trade_in_estimates_expires_at_idx + ON trade_in_estimates (expires_at); + +COMMENT ON COLUMN trade_in_estimates.consent IS '152-ФЗ proof-of-consent (ЭТАП 4 B2C): TRUE для анонимных запросов, прошедших consent-gate в estimate_quality(). NULL для B2B-пилотов (created_by задан) — согласие закрыто договором, доказательство здесь не собирается.'; +COMMENT ON COLUMN trade_in_estimates.client_ip IS '152-ФЗ proof-of-consent: клиентский IP анонимного запроса на момент согласия (см. app/api/v1/trade_in.py::estimate, _client_ip). NULL для B2B-пилотов.'; +COMMENT ON COLUMN trade_in_estimates.consent_policy_version IS '152-ФЗ proof-of-consent: снимок _ESTIMATE_CONSENT_POLICY_VERSION (app/services/estimator.py) на момент согласия.'; +COMMENT ON COLUMN trade_in_estimates.consent_text_snapshot IS '152-ФЗ proof-of-consent: снимок текста согласия на ОЦЕНКУ, показанного пользователю (_ESTIMATE_CONSENT_TEXT_SNAPSHOT) — отдельный текст от trade_in_leads.consent_text_snapshot (тот про согласие на контакт-заявку, другой предмет обработки).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/230_house_merge_log.sql b/tradein-mvp/backend/data/sql/230_house_merge_log.sql new file mode 100644 index 00000000..bf55ec6b --- /dev/null +++ b/tradein-mvp/backend/data/sql/230_house_merge_log.sql @@ -0,0 +1,262 @@ +-- 230_house_merge_log.sql +-- Журнал слияний домов + обратная операция (#2690). +-- +-- WHY: +-- `house_dedup_merge` — НЕ спящая идея, а живой деструктивный проход: расписание +-- `house_dedup_merge` на проде enabled=true, dry_run=false, такт 7 дней. Шесть прогонов +-- с 2026-06-27 уже удалили 119 строк `houses` (счётчики losers_deleted в scrape_runs: +-- 2/39/31/9/6/32). Единственным следом «кто в кого» была строка `logger.info` в контейнере, +-- а логи ротируются быстрее суток. То есть **уже сегодня** нельзя назвать, какой дом в какой +-- свернули 1 августа, — не говоря о том, чтобы вернуть. +-- +-- Пока этого журнала нет, любой разговор о расширении ключа схлопывания (#2690, #1772) +-- ведётся без права на ошибку: единственный откат — restore всей БД на момент до прогона, +-- т.е. выброс недели сбора. Журнал снимает это условие: слияние становится обратимым, +-- и вопрос о ключе можно пересматривать, а не решать «навсегда». +-- +-- Правку НЕ следует читать как одобрение текущего ключа/победителя/гео-стража. Она к ним +-- НЕЙТРАЛЬНА: ни ключ, ни правило выбора победителя, ни гео-страж здесь не меняются. +-- Меняется только одно — теперь есть что откатить. +-- +-- WHAT (одна строка = один проигравший дом): +-- merge_pass / cluster_key / geo_guard / distance_m — ОСНОВАНИЕ слияния. Это не косметика: +-- ровно этих полей не хватило в #2690, чтобы ответить на вопрос «сколько слияний прошло +-- на расстояниях, которые гео-страж заблокировал бы» по ДАННЫМ, а не по ревью. distance_m +-- пишется всегда, даже когда страж для прохода выключен (fias-проход) — тогда он и есть +-- единственная запись о том, насколько далеко разъехались объединённые дома. +-- loser_row — ПОЛНЫЙ jsonb-снимок удаляемой строки (`to_jsonb(h.*)`, все 86 колонок). +-- Ссылка на удалённую строку бесполезна, поэтому хранится содержимое. Снимок целиком, +-- а не список полей: проверено, что `jsonb_populate_record(NULL::houses, loser_row)` +-- восстанавливает строку побайтово, включая PostGIS-geom (to_jsonb отдаёт её GeoJSON'ом, +-- populate_record разбирает обратно входной функцией типа). Побочная выгода: новая +-- колонка в `houses` попадает в снимок и в откат САМА, без правки этой миграции. +-- keeper_before — снимок ПОБЕДИТЕЛЯ до переноса метаданных. Нужен, потому что слияние не +-- только удаляет проигравшего: `_CARRY_OVER_IDENTITY_SQL` дозаполняет победителю NULL-поля +-- идентичности (fias/кадастр/ГАР/DaData) значениями проигравшего. Без этого снимка откат +-- вернул бы дом, но оставил бы его ФИАС на победителе — и следующий же fias-проход слил +-- бы их обратно. +-- children_repointed — {"таблица.колонка": [id, ...]}. Дочерние строки ПЕРЕЖИЛИ слияние, +-- у них сменилась только ссылка, поэтому хранятся id, а не содержимое (иначе одни +-- listings с их raw-payload'ом дали бы ~7 КБ на строку вместо ~8 байт на id). +-- children_deleted — {"таблица": [{строка целиком}, ...]}. Дочерние строки, которые проход +-- УДАЛИЛ из-за коллизии по UNIQUE. Их содержимое уничтожено, id недостаточно — только +-- полный снимок. Таких таблиц шесть (см. _STEPS), строки мелкие. +-- batch_id — один вызов merge_duplicate_houses() (оба прохода). Единица отката. +-- run_id / initiator — кто инициировал: scrape_runs.id для расписания, NULL для ручного. +-- +-- НАМЕРЕННО БЕЗ ВНЕШНИХ КЛЮЧЕЙ на houses(id) и scrape_runs(id): +-- журнал обязан ПЕРЕЖИВАТЬ строки, которые описывает. loser_id указывает на заведомо +-- удалённый дом. keeper_id — на дом, который сам может быть слит следующим прогоном; FK +-- с CASCADE стёр бы историю ровно тогда, когда она нужнее всего, а FK без CASCADE +-- заблокировал бы слияние. То же с run_id: чистка scrape_runs не должна трогать журнал. +-- +-- ОБЪЁМ (замерено на проде 2026-08-06): +-- 9 571 дом, средняя строка houses в jsonb 2 581 Б. Строка журнала ≈ loser_row 2.5 КБ + +-- keeper_before 2.5 КБ + списки id (в среднем 27.9 дочерних строк на дом × ~8 Б) ≈ 5.3 КБ. +-- Наблюдаемый темп — 20 слияний в неделю (119 за 6 прогонов) ≈ 106 КБ/нед ≈ 5.5 МБ/год. +-- Ближайший прогон (замер тем же выражением, что и код): 93 проигравших ≈ 0.5 МБ. +-- Абсолютный потолок, если схлопнуть вообще все дома: 9 571 × 5.3 КБ ≈ 50 МБ против 23 МБ +-- самой таблицы houses. +-- +-- RETENTION: НЕ НУЖЕН, сознательно. Потолок роста — двузначные мегабайты, то есть дешевле +-- любой процедуры чистки; а журнал слияний — это ровно то, что удалять не хочется: его +-- ценность в том, что он отвечает на вопрос «что было год назад», когда логов давно нет. +-- Если объём когда-нибудь станет проблемой, удалять надо не строки, а тяжёлые снимки +-- (loser_row/keeper_before → NULL) у записей старше N лет, сохранив соответствие +-- loser→keeper: оно весит байты и именно оно нужно дольше всего. +-- +-- Dependencies: 002_core_tables.sql (houses), 135_scrape_schedules_seed_house_dedup_merge.sql +-- Пишется в ТОЙ ЖЕ транзакции, что и слияние (см. house_dedup_merge._run_merge_pass) — +-- разрыв «слияние прошло, запись не легла» невозможен по построению; dry_run откатывает и то, +-- и другое вместе. + +BEGIN; + +CREATE TABLE IF NOT EXISTS house_merge_log ( + id bigserial PRIMARY KEY, + merged_at timestamptz NOT NULL DEFAULT now(), + batch_id uuid NOT NULL, + run_id bigint, + initiator text NOT NULL, + merge_pass text NOT NULL, + cluster_key text NOT NULL, + geo_guard boolean NOT NULL, + distance_m double precision, + norm_address text, + loser_id bigint NOT NULL, + keeper_id bigint NOT NULL, + loser_row jsonb NOT NULL, + keeper_before jsonb NOT NULL, + children_repointed jsonb NOT NULL DEFAULT '{}'::jsonb, + children_deleted jsonb NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_house_merge_log_loser ON house_merge_log (loser_id); +CREATE INDEX IF NOT EXISTS idx_house_merge_log_keeper ON house_merge_log (keeper_id); +CREATE INDEX IF NOT EXISTS idx_house_merge_log_batch ON house_merge_log (batch_id); + +COMMENT ON TABLE house_merge_log IS + 'Журнал слияний домов (#2690): одна строка = один проигравший дом, удалённый проходом ' + 'house_dedup_merge. Пишется в ТОЙ ЖЕ транзакции, что и слияние. Содержит полный снимок ' + 'удалённой строки и перечень перенесённых/удалённых дочерних строк — достаточно, чтобы ' + 'назвать поимённо, что во что свернули, и вернуть обратно (house_merge_undo). Намеренно ' + 'БЕЗ FK на houses/scrape_runs: журнал переживает строки, которые описывает. Retention нет.'; + +COMMENT ON COLUMN house_merge_log.cluster_key IS + 'ЗНАЧЕНИЕ ключа, по которому дома попали в один кластер («addr:вайнера66» / «fias:»), ' + 'а не имя ключа — по нему видно, какое именно совпадение сработало.'; +COMMENT ON COLUMN house_merge_log.geo_guard IS + 'Был ли для этого прохода включён гео-страж 250 м. false = слияние разрешено БЕЗ проверки ' + 'близости; вместе с distance_m это и есть аудит основания (#2690).'; +COMMENT ON COLUMN house_merge_log.distance_m IS + 'ST_DistanceSphere между победителем и проигравшим на момент слияния; NULL = у одной из ' + 'сторон не было geom. Пишется ВСЕГДА, в том числе когда гео-страж выключен.'; +COMMENT ON COLUMN house_merge_log.loser_row IS + 'to_jsonb() удалённой строки houses целиком. Восстановление: ' + 'INSERT INTO houses SELECT r.* FROM jsonb_populate_record(NULL::houses, loser_row) r.'; +COMMENT ON COLUMN house_merge_log.keeper_before IS + 'Снимок победителя ДО переноса метаданных с проигравшего (COALESCE-дозаполнение полей ' + 'идентичности). Без него откат вернул бы дом, но оставил его ФИАС/кадастр на победителе.'; +COMMENT ON COLUMN house_merge_log.children_repointed IS + '{"таблица.колонка": [id, ...]} — дочерние строки, у которых слияние сменило ссылку ' + 'loser→keeper. Строки целы, поэтому хранятся id: откат возвращает ссылку обратно.'; +COMMENT ON COLUMN house_merge_log.children_deleted IS + '{"таблица": [{строка целиком}, ...]} — дочерние строки, УДАЛЁННЫЕ проходом из-за коллизии ' + 'по UNIQUE с победителем. Содержимое уничтожено, поэтому хранится снимок, а не id.'; + +-- ── Обратная операция ──────────────────────────────────────────────────────── +-- +-- Откат одного батча (или его части) по журналу. Транзакционен: вызывающий сам решает +-- COMMIT/ROLLBACK, увидев отчёт. Возвращает СТРОКУ НА КАЖДУЮ запись журнала со статусом — +-- в том числе «не смог», потому что молчаливо-успешный откат хуже отсутствующего. +-- +-- Порядок внутри одной записи важен: сначала воскресить дом (на него ссылаются дети), потом +-- вернуть ссылки детей, потом вернуть удалённых детей, потом снять перенос метаданных с +-- победителя. Записи батча обходятся в обратном порядке (id DESC) — если дом A слили в B, +-- а B потом в C, разматывать надо с конца. +-- +-- ИЗВЕСТНЫЕ ГРАНИЦЫ (сознательные, отражены в статусе): +-- * дочерняя строка, удалённая по коллизии, может не вернуться: место в UNIQUE-ключе занято +-- строкой победителя. ON CONFLICT DO NOTHING + счётчик в статусе, а не тихая потеря; +-- * backfill-строки house_sources/house_address_aliases, которые проход дописал победителю, +-- НЕ удаляются: они собраны из собственных полей победителя и остались бы верны и без +-- слияния; +-- * если id проигравшего уже занят — запись пропускается со статусом, откат не гадает. +CREATE OR REPLACE FUNCTION house_merge_undo( + p_batch uuid, + p_only_losers bigint[] DEFAULT NULL +) +RETURNS TABLE ( + out_log_id bigint, + out_loser_id bigint, + out_keeper_id bigint, + out_status text +) +LANGUAGE plpgsql +AS $$ +DECLARE + rec record; + v_table text; + v_column text; + v_ids bigint[]; + v_rows jsonb; + v_field text; + v_repointed int; + v_restored int; + v_lost int; + v_n int; + -- Список полей ДОЛЖЕН совпадать с SET в house_dedup_merge._CARRY_OVER_IDENTITY_SQL; + -- за расхождением следит тест test_undo_carryover_fields_match_merge_carryover. + c_carry_fields constant text[] := ARRAY[ + 'house_fias_id', 'cadastral_number', 'gar_house_guid', 'gar_flat_count', + 'gar_matched_at', 'gar_match_method', 'dadata_qc_geo', 'dadata_qc_house', + 'dadata_enriched_at' + ]; +BEGIN + FOR rec IN + SELECT * + FROM house_merge_log l + WHERE l.batch_id = p_batch + AND (p_only_losers IS NULL OR l.loser_id = ANY (p_only_losers)) + ORDER BY l.id DESC + LOOP + out_log_id := rec.id; + out_loser_id := rec.loser_id; + out_keeper_id := rec.keeper_id; + + IF EXISTS (SELECT 1 FROM houses h WHERE h.id = rec.loser_id) THEN + out_status := 'skipped: houses.id ' || rec.loser_id || ' занят — уже откачено?'; + RETURN NEXT; + CONTINUE; + END IF; + + -- 1. Воскресить проигравшего целиком из снимка (все колонки, включая geom). + INSERT INTO houses + SELECT r.* FROM jsonb_populate_record(NULL::houses, rec.loser_row) r; + + -- 2. Вернуть ссылки уцелевших детей. Условие «сейчас указывает на победителя» + -- защищает от затирания строк, которые после слияния перепривязали чем-то ещё. + v_repointed := 0; + FOR v_table, v_column, v_ids IN + SELECT split_part(e.key, '.', 1), + split_part(e.key, '.', 2), + ARRAY(SELECT jsonb_array_elements_text(e.value)::bigint) + FROM jsonb_each(rec.children_repointed) AS e + LOOP + EXECUTE format( + 'UPDATE %I SET %I = $1 WHERE id = ANY ($2) AND %I = $3', + v_table, v_column, v_column + ) USING rec.loser_id, v_ids, rec.keeper_id; + GET DIAGNOSTICS v_n = ROW_COUNT; + v_repointed := v_repointed + v_n; + END LOOP; + + -- 3. Вернуть детей, удалённых по коллизии UNIQUE. Место могло остаться занятым + -- строкой победителя — тогда DO NOTHING, и это попадёт в отчёт как «не вернулось». + v_restored := 0; + v_lost := 0; + FOR v_table, v_rows IN + SELECT e.key, e.value FROM jsonb_each(rec.children_deleted) AS e + LOOP + EXECUTE format( + 'INSERT INTO %I SELECT r.* FROM jsonb_array_elements($1) AS el, ' + 'LATERAL jsonb_populate_record(NULL::%I, el) r ON CONFLICT DO NOTHING', + v_table, v_table + ) USING v_rows; + GET DIAGNOSTICS v_n = ROW_COUNT; + v_restored := v_restored + v_n; + v_lost := v_lost + (jsonb_array_length(v_rows) - v_n); + END LOOP; + + -- 4. Снять перенос метаданных с победителя. Только там, где до слияния было NULL И + -- текущее значение всё ещё РОВНО то, что принёс этот проигравший: если поле успел + -- заполнить загрузчик (или донором был другой проигравший кластера) — не трогаем. + -- Сравнение в jsonb-пространстве, чтобы один цикл покрыл text/int/timestamptz. + FOREACH v_field IN ARRAY c_carry_fields LOOP + IF rec.keeper_before ->> v_field IS NULL THEN + EXECUTE format( + 'UPDATE houses SET %I = NULL WHERE id = $1 AND to_jsonb(%I) = $2', + v_field, v_field + ) USING rec.keeper_id, rec.loser_row -> v_field; + END IF; + END LOOP; + + out_status := format( + 'restored: дом %s вернулся, ссылок возвращено %s, дочерних строк восстановлено %s' + || CASE WHEN v_lost > 0 THEN ', НЕ ВЕРНУЛОСЬ ' || v_lost || ' (место занято)' + ELSE '' END, + rec.loser_id, v_repointed, v_restored + ); + RETURN NEXT; + END LOOP; +END; +$$; + +COMMENT ON FUNCTION house_merge_undo(uuid, bigint[]) IS + 'Откат слияния домов по журналу house_merge_log (#2690). Аргументы: batch_id (единица ' + 'отката = один вызов merge_duplicate_houses) и опциональный список loser_id для частичного ' + 'отката. Возвращает строку-статус на КАЖДУЮ запись журнала, включая неудачные. ' + 'Транзакции не открывает и не закрывает — вызывающий смотрит отчёт и решает COMMIT/ROLLBACK: ' + ' BEGIN; SELECT * FROM house_merge_undo(''''); -- прочитать статусы -- COMMIT;'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql b/tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql new file mode 100644 index 00000000..b3a3995b --- /dev/null +++ b/tradein-mvp/backend/data/sql/231_trade_in_privacy_retention.sql @@ -0,0 +1,119 @@ +-- 231_trade_in_privacy_retention.sql +-- ЭТАП 4 B2C launch — retention enforcement (152-ФЗ): срок хранения ДОЛЖЕН +-- приводить к физическому удалению, а не быть декоративным полем. +-- +-- WHY: +-- trade_in_estimates.expires_at существовал (migration 004), но применялся +-- ТОЛЬКО как read-time фильтр (GET /estimate/{id}: "AND expires_at > NOW()") +-- — ни одна фоновая задача не удаляла строки после истечения TTL. Данные +-- физлица (адрес) жили в БД бессрочно вопреки декларированному сроку. +-- trade_in_leads было ещё хуже: там вообще НЕ было понятия TTL/expires_at — +-- телефон + согласие хранились бессрочно с момента создания таблицы +-- (172_trade_in_leads.sql). +-- +-- WHAT: +-- 1. trade_in_leads.expires_at (timestamptz NOT NULL) — backfill для +-- существующих строк = created_at + 180 дней (тот же default, что +-- settings.trade_in_lead_retention_days в app/core/config.py на момент +-- этой миграции; 180 дней = рабочий MVP-default для НЕконвертированных +-- маркетинговых лидов, см. обоснование в config.py — конкретный +-- юридически обоснованный срок хранения это решение DPO/юриста, не +-- инженера). Новые строки получают expires_at на insert-time +-- (app/api/v1/lead.py, тем же паттерном, что trade_in_estimates). +-- 2. Индекс на trade_in_leads.expires_at — для batched-DELETE ниже. +-- 3. scrape_schedules seed: purge_expired_trade_in_data — ночная задача +-- (app/tasks/purge_expired_trade_in_data.py, kit-handler в +-- app/services/product_handlers.py), физически удаляющая ИСТЁКШИЕ +-- строки в trade_in_estimates И trade_in_leads пачками (batch_size из +-- default_params, лимит max_batches за один прогон — см. таск-докстринг). +-- ON DELETE CASCADE (007_estimate_photos, 018_avito_imv_evaluations) и +-- ON DELETE SET NULL (172_trade_in_leads.estimate_id) уже подчищают +-- зависимые таблицы автоматически — этот файл их не трогает. +-- +-- Seeded с enabled=false (тот же осторожный паттерн, что +-- 175_scrape_schedules_seed_domclick_detail_backfill.sql): это ПЕРВАЯ +-- автоматическая задача физического DELETE персональных данных в trade-in — +-- заслуживает supervised первого прогона (смотри логи/counters вручную) +-- перед тем, как доверить её расписанию. +-- +-- ⚠️ ПЕРЕД ВКЛЮЧЕНИЕМ (deep-review 2026-08-06, HIGH — читай целиком, не +-- только команду в конце): задача удаляет строки trade_in_estimates +-- физически и НЕОБРАТИМО. DELETE ограничен `created_by IS NULL` — только +-- анонимные B2C-оценки (см. докстринг app/tasks/purge_expired_trade_in_ +-- data.py, раздел про асимметрию expires_at = TTL ссылки/PDF, а не срок +-- хранения строки) — строки B2B-пилотов (`created_by` задан) задача НЕ +-- трогает НИКОГДА, что бы ни стояло в expires_at. Аудит на проде на момент +-- этой правки: из 1057 строк trade_in_estimates 1040 уже просрочены +-- (expires_at < NOW()), но ТОЛЬКО 129 из них имеют created_by IS NULL +-- (реальная B2C-популяция под удаление) — остальные 911 принадлежат +-- пилотам (admin/kopylov/brusnika/praktika/pilottest/admintest/user1) и +-- гвардом защищены от удаления. Эти цифры УСТАРЕЮТ — не включай задачу по +-- их памяти. Перед `UPDATE scrape_schedules SET enabled=true WHERE +-- source='purge_expired_trade_in_data'`: +-- 1. Свежий `SELECT count(*) FROM trade_in_estimates WHERE expires_at +-- < NOW() AND created_by IS NULL` — если число на порядок больше +-- 129 (или created_by-гвард почему-то отсутствует в текущем коде +-- задачи) — СТОП, разберись, прежде чем включать. +-- 2. Прогони задачу вручную ОДИН раз (app/tasks/ +-- purge_expired_trade_in_data.py, синхронная функция) и сверь +-- counters с п.1 (задача логирует batch/running_total через +-- logger.info в _drain_expired) — supervised первый прогон, не +-- включение вслепую. +-- 3. Только после совпадения счётчиков — enable в scrape_schedules; +-- расписание подхватит на следующем тике планировщика (крутится в +-- контейнере tradein-scraper, не tradein-backend). +-- Включение — отдельный ручной шаг, эта миграция его НЕ делает. +-- +-- IDEMPOTENCY / SAFETY: +-- - ADD COLUMN IF NOT EXISTS + UPDATE ... WHERE expires_at IS NULL (no-op на +-- повторном прогоне, все строки уже проставлены) + +-- ALTER COLUMN ... SET NOT NULL (идемпотентно само по себе — Postgres не +-- ошибается на повторной установке уже действующего NOT NULL). +-- - CREATE INDEX IF NOT EXISTS — безопасный re-run. +-- - INSERT ... ON CONFLICT (source) DO NOTHING — безопасный re-run seed'а. +-- +-- Dependencies: 172_trade_in_leads.sql (таблица), 052_scrape_schedules.sql +-- (scrape_schedules), 229_trade_in_estimates_consent_proof.sql (соседняя +-- часть той же ЭТАП 4 инициативы — индекс на trade_in_estimates.expires_at +-- уже создан там). + +BEGIN; + +ALTER TABLE trade_in_leads + ADD COLUMN IF NOT EXISTS expires_at timestamptz; + +UPDATE trade_in_leads +SET expires_at = created_at + interval '180 days' +WHERE expires_at IS NULL; + +ALTER TABLE trade_in_leads + ALTER COLUMN expires_at SET NOT NULL; + +CREATE INDEX IF NOT EXISTS trade_in_leads_expires_at_idx + ON trade_in_leads (expires_at); + +COMMENT ON COLUMN trade_in_leads.expires_at IS 'ЭТАП 4 B2C (152-ФЗ): срок хранения лида. Backfill для legacy-строк = created_at + 180 дней; новые строки считаются на insert-time из settings.trade_in_lead_retention_days (app/api/v1/lead.py). Физическое удаление после истечения — app/tasks/purge_expired_trade_in_data.py.'; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'purge_expired_trade_in_data', + false, + 2, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"batch_size": 500, "max_batches": 20}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMENT ON TABLE scrape_schedules IS + 'In-app scheduler config (replaces 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), yandex_address_backfill (#855, EKB pilot), sber_index_pull (#887, monthly), rosreestr_quarter_poll (#888, monthly), cian_city_sweep (dormant, #973), yandex_newbuilding_sweep (dormant, #974), geocode_missing_listings (#1: listings geom backfill, all sources), avito_detail_backfill (#1551: nightly detail-enrichment backfill for legacy avito listings), domclick_detail_backfill (#2000: nightly Layer B detail-enrichment backfill for domklik listings, cookie-injection + QRATOR-aware, disabled by default until smoke-tested), purge_expired_trade_in_data (ЭТАП 4 B2C: nightly batched physical DELETE of expired trade_in_estimates/trade_in_leads rows, disabled by default until a supervised first run).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/232_listings_observation_time_meaning.sql b/tradein-mvp/backend/data/sql/232_listings_observation_time_meaning.sql new file mode 100644 index 00000000..adf9e522 --- /dev/null +++ b/tradein-mvp/backend/data/sql/232_listings_observation_time_meaning.sql @@ -0,0 +1,84 @@ +-- 232_listings_observation_time_meaning.sql +-- Purpose (#2731): зафиксировать в схеме, что содержательные метки наблюдения до +-- этой правки несли время НАЧАЛА транзакции сбора, а не момент наблюдения строки, +-- и назвать дату, с которой их смысл изменился. +-- +-- Dependencies: 002_core_tables.sql (listings.scraped_at/last_seen_at), +-- 016_listings_snapshots.sql (listings_snapshots.observed_at), +-- 161_backfill_scraped_at_active_recent.sql (ретро-выравнивание scraped_at по last_seen_at). +-- Apply after: 230_house_merge_log.sql +-- Идемпотентно: только COMMENT ON COLUMN (перезаписывает сам себя), данных не трогает. +-- +-- ── ЧТО БЫЛО ───────────────────────────────────────────────────────────────── +-- Писатель объявлений (packages/scraper-kit/.../base.py::save_listings + snapshot_writer) +-- ставил NOW(). В PostgreSQL NOW() == transaction_timestamp() — время старта транзакции. +-- save_listings коммитит ОДИН раз в конце всего batch'а, поэтому одну метку получали все +-- строки одного вызова, сколько бы он ни работал. +-- +-- Прод-замер 2026-08-06 (listings_snapshots, строк / различных меток на прогон): +-- run 3303 — 219 / 1 (прогон шёл 7 минут, все метки на нулевой секунде); +-- run 3299 — 235 / 1; run 3293 — 297 / 1; +-- run 3229 — 2643 / 55 (метка на вызов save_listings, а не на строку). +-- listings за те же сутки: 219/1, 235/1, 297/1 … — и scraped_at, и last_seen_at, причём +-- у 100% строк они РАВНЫ между собой (замер по часам: eq == rows во всех корзинах). +-- +-- ── ЧЕГО ЭТО НЕ ЛОМАЛО ─────────────────────────────────────────────────────── +-- Фильтры свежести и TTL — НЕ искажены. Смещение равно длительности прогона: типично +-- минуты, худший случай на проде 17.3 ч (cian_full_load). Против окна свежести в 14 суток +-- это 0.03-5%. Утверждение «долгие прогоны ломают фильтр свежести» проверено и снято. +-- +-- ── ЧТО ЭТО ЛОМАЛО ─────────────────────────────────────────────────────────── +-- Разрешение во времени. По данным нельзя восстановить ни темп сбора, ни порядок строк +-- внутри прогона: все они выглядят одномоментными. Отсюда же следовала невозможность +-- бэкфилла run_id (#2701). Ошибка тихая — значения правдоподобны. +-- +-- ── ПОЧЕМУ ИСТОРИЮ НЕ ЧИНИМ ────────────────────────────────────────────────── +-- Внутрипрогонное время НИГДЕ БОЛЬШЕ НЕ СОХРАНЯЛОСЬ: у прогона есть только started_at и +-- finished_at, а распределение строк между ними неизвестно. Строки ДО перехода +-- невосстановимы — их метки помечаются, а не переписываются. +-- +-- ── ПОЧЕМУ statement_timestamp(), А НЕ clock_timestamp() ───────────────────── +-- В #2702/#2718 (служебные колонки прогона) взяли clock_timestamp() — там колонка одна. +-- Здесь в одном statement'е пишутся ДВЕ колонки, и они обязаны совпадать: после #2206 +-- scraped_at и last_seen_at равны, и на этом равенстве стоит предикат миграции 161 +-- (`WHERE last_seen_at > scraped_at` как признак «видели живым, но не пере-скрейпили»). +-- Проверка на проде: `clock_timestamp() = clock_timestamp()` → false, +-- `statement_timestamp() = statement_timestamp()` → true. При этом statement_timestamp() +-- двигается ОТ STATEMENT'А К STATEMENT'У внутри одной транзакции (проверено: 1.2 с между +-- соседними запросами при неподвижном now()), а каждый upsert объявления — свой statement. +-- Итог: построчная метка без ложного расхождения колонок. +-- +-- Set-based писатели (listing_source_snapshots, deactivate_stale_avito) НАМЕРЕННО оставлены +-- на now(): там один statement пишет десятки тысяч строк, и одна метка — это правда о нём. +-- clock_timestamp() выдал бы там ложное разрешение: на проде +-- `count(DISTINCT clock_timestamp())` по 200 000 строк одного statement'а = 27 783 разных +-- значения, кодирующих порядок обработки строк планировщиком, а не порядок наблюдения. + +BEGIN; + +COMMENT ON COLUMN listings_snapshots.observed_at IS + 'Момент наблюдения снимка. С #2731 (2026-08-06) — statement_timestamp(), то есть ' + 'время записи КОНКРЕТНОЙ строки. У строк ДО этой даты значение общее на весь вызов ' + 'save_listings (писалось now() = старт транзакции batch''а): на проде 219 снимков ' + 'семиминутного прогона несли одну метку. Для строк до перехода observed_at читать как ' + '«прогон, в котором строку увидели», а НЕ как момент; темп сбора и порядок строк ' + 'внутри прогона по историческим данным невосстановимы — внутрипрогонное время нигде ' + 'не сохранялось.'; + +COMMENT ON COLUMN listings.scraped_at IS + 'Момент последнего скрейпа объявления. С #2206 двигается и при ре-подтверждении живым ' + '(не только при вставке), с #2731 (2026-08-06) пишется statement_timestamp() — ' + 'построчно. У строк ДО этой даты — время старта транзакции batch''а, общее на все ' + 'строки вызова save_listings. На фильтры свежести это не влияло: смещение равно ' + 'длительности прогона (минуты; худший случай 17.3 ч у cian_full_load) против окна в ' + '14 суток. Невосстановимо потеряно другое — разрешение внутри прогона.'; + +COMMENT ON COLUMN listings.last_seen_at IS + 'Момент последнего подтверждения, что объявление живо. Пишется тем же statement''ом, ' + 'что и scraped_at, и потому РАВЕН ему — это свойство сохранено намеренно: ' + 'с #2731 (2026-08-06) обе колонки берут statement_timestamp(), стабильный в statement''е ' + '(clock_timestamp() развёл бы их на микросекунды и сделал бы предикат ' + '`last_seen_at > scraped_at` из миграции 161 истинным почти для всех строк). ' + 'У строк ДО перехода — время старта транзакции batch''а, см. комментарий к scraped_at.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/233_payments.sql b/tradein-mvp/backend/data/sql/233_payments.sql new file mode 100644 index 00000000..99dfeca9 --- /dev/null +++ b/tradein-mvp/backend/data/sql/233_payments.sql @@ -0,0 +1,305 @@ +-- 233_payments.sql +-- Платёжный контур МЕРЫ (Т-Банк интернет-эквайринг) — схема БД, PR-B из серии +-- A..F (см. корень репо `mera-tbank-acquiring-recon.md`, §9 «Разбивка на PR»). +-- Ни разу не применялась на проде (см. `_manifest_applied.txt`) — правится на +-- месте по итогам review (статус HOLD), без ребейза номера. Дважды +-- переименована (git mv, история сохранена): 228 → 232 → 233. Номер 228 +-- заняли 228_scrape_proxies_browser_health.sql и 230_house_merge_log.sql +-- (влились в main); 229 и 231 занимает открытый PR #2547; 232 занял открытый +-- PR #2742 (`232_listings_observation_time_meaning.sql`). Урок: сверять номер +-- нужно не только по `forgejo/main`, но и по ВСЕМ открытым PR-веткам — ни один +-- из этих файлов сам себя в `_manifest_applied.txt` не пишет (мы пишем), из-за +-- чего коллизия обнаруживается только тестом `test_new_files_do_not_reuse_prefix` +-- уже после того, как чей-то PR смержен первым. +-- +-- ── WHY ────────────────────────────────────────────────────────────────────── +-- Этот PR — ТОЛЬКО схема + конфиг + kill-switch (`PAYMENTS_ENABLED=false` в +-- app/core/config.py, тот же PR). Роутера, статус-машины и обработчика +-- нотификаций здесь НЕТ (появятся в PR-D/E; PR-C — token/tbank_client/receipt — +-- уже смержен, схемы не касается). До PAYMENTS_ENABLED=true эти три таблицы +-- просто не пишутся никаким кодом; создание сейчас разблокирует параллельную +-- разработку PR-D без гонки миграций. +-- +-- ── WHAT ───────────────────────────────────────────────────────────────────── +-- payments — одна строка на попытку оплаты (Init → notify → +-- Confirm/Cancel). order_id — наш внутренний id, +-- уходит в T-Bank как OrderId (CHECK ≤50 симв. — +-- падать у себя, а не на /v2/Init); tbank_payment_id — +-- PaymentId из ответа Init, известен только ПОСЛЕ +-- вызова. pd_erased_at — см. отдельный блок ниже. +-- payment_notifications — append-only лог входящих вебхуков Т-Банка. +-- Идемпотентность нотификаций — это и есть +-- UNIQUE NULLS NOT DISTINCT(tbank_payment_id, status, +-- amount_kopecks, token): T-Bank шлёт AUTHORIZED и +-- CONFIRMED одновременно, дедуп через ON CONFLICT DO +-- NOTHING (сервисный код — PR-D). processed_at — +-- контракт fulfillment, см. блок ниже. Осознанно БЕЗ +-- CHECK на status: это сырой лог входящих данных, +-- узкий CHECK здесь означал бы, что недокументиро- +-- ванный/новый статус банка ломает запись самого +-- факта нотификации. +-- payment_entitlements — факт «что выдано за платёж» (доступ), НЕ кошелёк. +-- См. блок про amount/consumed ниже. +-- +-- ── ИДЕМПОТЕНТНОСТЬ UNIQUE-ключей: NULLS NOT DISTINCT (найдено на проде) ──── +-- Первая версия миграции использовала обычный UNIQUE на обоих ключах +-- дедупликации. В Postgres обычный UNIQUE считает NULL уникальным относительно +-- самого себя (NULL ≠ NULL) — при ref_id IS NULL / token IS NULL несколько +-- строк с одинаковым остальным набором колонок НЕ схлопываются. Это не +-- гипотетика: три одинаковых INSERT в payment_entitlements с ref_id IS NULL +-- дали три строки вместо одной при проверке на проде (до первого реального +-- применения этой миграции — воспроизведено отдельно). PG 16.4 (прод) умеет +-- `UNIQUE NULLS NOT DISTINCT` (с PG15) — NULL трактуется как равный NULL, +-- ровно то поведение, которое ожидает сервисный слой (ON CONFLICT DO NOTHING / +-- DO UPDATE). Применено к обоим дедуп-ключам ниже. +-- +-- ── СТАТУСЫ T-BANK (payments.status CHECK) ────────────────────────────────── +-- Источник истины — официальная OpenAPI-спека: +-- https://developer.tbank.ru/schemas/eacq/openapi.yaml (OpenAPI 3.0.2, v1.24), +-- схема `Confirm-2`, 24 значения. На странице /eacq/intro/developer/openapi +-- прямо сказано: при расхождении прозы и спеки приоритет у спеки — поэтому +-- ссылка на спеку, а не на человекочитаемые доки. +-- +-- ВАЖНО про GetState/CheckOrder (ручки, которыми реконсиляция PR-E читает +-- статус): в спеке их поле `Status` объявлено СВОБОДНОЙ строкой +-- (`maxLength: 20`, БЕЗ enum). То есть на ручках, которыми фактически питается +-- реконсиляция, банк словарь значений не фиксирует контрактно — наш CHECK +-- здесь строже, чем контракт поставщика. Это осознанный выбор (закрытый +-- список читается и валидируется проще, чем произвольная строка), а не +-- недосмотр; следующий читатель должен видеть, что этот CHECK может однажды +-- отвергнуть легитимный, но недокументированный `Confirm-2`-строкой статус — +-- см. контракт 'UNKNOWN' ниже. +-- +-- Сверка по `Confirm-2` относительно первой версии миграции: +-- - УБРАНЫ 'AUTHORIZED_AND_CHARGED' и 'RECEIPT_REGISTERED' — отсутствуют в +-- `Confirm-2`. Дополнительно у поля `Status` в спеке `maxLength: 20`, а +-- 'AUTHORIZED_AND_CHARGED' — 22 символа: физически не может быть значением +-- этого поля, не только "не найдено", а невозможно по контракту. +-- - ДОБАВЛЕНЫ '3DS_CHECKING' и '3DS_CHECKED' — есть в `Confirm-2`. +-- - НЕ добавлены 'ATTEMPTS_EXPIRED' и 'PAY_CHECKING' — отсутствуют в +-- `Confirm-2`, гипотеза не подтвердилась. +-- - 'PREAUTHORIZING' оставлен и подтверждён: есть в `Confirm-2`. (Ранее +-- редакция ссылалась на комментарий стороннего Go-клиента о том, что этот +-- статус будто бы убран из API — спекой это не подтверждается, комментарий +-- был неточным источником и снят.) +-- - ДОБАВЛЕНЫ пять пропущенных in-flight значений из `Confirm-2`: +-- 'CHECKING', 'CHECKED', 'PROCESSING', 'COMPLETING', 'COMPLETED'. Это +-- ровно те статусы, которые GetState/CheckOrder вернёт по зависшему +-- платежу — их читает реконсиляция (PR-E). Пропуск реального значения — +-- единственное опасное направление ошибки CHECK: не "лишний" статус +-- проскочит, а свой же CHECK отвергнет то, что банк реально прислал. +-- Порядок в списке ниже — по смысловой близости к соседним стадиям +-- (CHECKING/CHECKED рядом с 3DS_CHECKING/3DS_CHECKED, COMPLETING/COMPLETED +-- рядом с CONFIRMED), а не порядок из спеки — `Confirm-2` не гарантирует +-- порядок enum, для CHECK-констрейнта (проверка принадлежности множеству) +-- порядок значения не имеет. +-- - 'PARTIAL_REVERSED' и 'REFUND_FAILED' в `Confirm-2` ОТСУТСТВУЮТ. Оставлены +-- в CHECK как безвредный запас на случай появления в будущей версии API +-- (сам факт лишнего разрешённого значения в CHECK ничего не ломает — в +-- отличие от отсутствующего). Это отличается от предыдущей редакции +-- комментария, которая ошибочно утверждала, что сверка их "подтверждает": +-- не подтверждает, они не найдены в источнике истины. +-- +-- Контракт: если банк присылает статус вне списка ниже, ОБРАБОТЧИК +-- НОТИФИКАЦИЙ И ЗАДАЧА РЕКОНСИЛЯЦИИ (GetState/CheckOrder, PR-E) обязаны +-- писать в payments.status значение 'UNKNOWN' (не поднимать исключение, не +-- терять запись) — сырое тело нотификации в любом случае лежит целиком в +-- payment_notifications.body (у GetState/CheckOrder своего append-only лога +-- нет — если реконсиляция сама не сохранит сырой ответ, факт неизвестного +-- статуса останется только в payments.status='UNKNOWN' и её собственных логах). +-- INSERT/UPDATE payments с любым другим незнакомым значением упадёт на +-- CHECK — это специально: тихое искажение статуса хуже, чем громкий сбой +-- одной записи. +-- +-- ── payment_entitlements: без кредитно-кошельковой семантики ──────────────── +-- Первая версия несла amount/consumed (модель «кредиты/пакеты»). Явно +-- отвергнуто в `mera-b2c-paid-flow-decision.md` (§1): «Что отвергнуто явно: +-- кредиты/пакеты, роль customer, ... — цена ошибки в guard'е выше годовой +-- выручки этой воронки». Доставка купленного выбрана через capability-URL +-- (`/r/`, волна 3 §9 того же дока), а не через списание количества с +-- баланса. Таблица остаётся фактом «что выдано за платёж» (payment → kind +-- [+ ref_id]), без количественного состояния. Если модель когда-нибудь +-- реально понадобится — восстановить amount/consumed дешевле (ADD COLUMN), +-- чем сейчас снимать с них зависимости в PR-E, которого ещё нет. +-- +-- ── payments.pd_erased_at: покрытие purge-контура #2547 ───────────────────── +-- #2547 знает про PII в trade_in_leads/trade_in_estimates, но НЕ про payments +-- — эта таблица нового хранилища ПДн (customer_email/customer_phone) появится +-- вместе с PR-D. pd_erased_at NULL = ПДн не стирались; проставляется по +-- запросу субъекта на удаление — обнуляет customer_email/customer_phone, +-- фискально значимые поля (order_id, amount_kopecks, confirmed_at, +-- terminal_key и т.д.) остаются нетронутыми (обязательны для чека/сверки с +-- банком). Сам purge-job — вне scope этого PR (схема-only); колонку дешевле +-- завести сейчас, чем добавлять отдельной миграцией после того как PR-D +-- начнёт писать ПДн в эту таблицу. +-- +-- ── payment_notifications.processed_at: контракт fulfillment (для PR-D) ───── +-- Без этой колонки обработчик получается at-most-once по ОШИБКЕ: если процесс +-- упал ПОСЛЕ INSERT нотификации, но ДО выдачи товара (payment_entitlements / +-- инкремент квоты), ретрай банка увидит уже существующую строку через +-- ON CONFLICT DO NOTHING, ответит "OK" и товар не выдастся никогда — при этом +-- деньги у клиента уже списаны/захолдированы. Контракт для PR-D: обработка +-- нотификации считается завершённой (fulfillment состоялся) ТОЛЬКО когда +-- processed_at проставлен; сам факт наличия строки в payment_notifications +-- этого не гарантирует и не должен использоваться как признак «обработано». +-- +-- ── IDEMPOTENCY ────────────────────────────────────────────────────────────── +-- CREATE TABLE IF NOT EXISTS + DROP CONSTRAINT IF EXISTS перед ADD CONSTRAINT +-- (безопасный re-run на CHECK). Ничего не удаляет и не бэкфиллит. +-- +-- ── FK на trade_in_estimates / trade_in_leads ─────────────────────────────── +-- Обе таблицы проверены по факту (001_trade_in_estimates.sql, +-- 172_trade_in_leads.sql): id uuid PRIMARY KEY DEFAULT gen_random_uuid() в +-- обеих — FK безопасен, типы совпадают. ON DELETE SET NULL — по образцу +-- уже существующего trade_in_leads.estimate_id (172_trade_in_leads.sql:11): +-- обе колонки здесь опциональные бизнес-ссылки, а не владеющая связь, удаление +-- estimate/lead не должно ронять запись о платеже. +-- +-- Dependencies: 001_trade_in_estimates.sql, 172_trade_in_leads.sql. +-- Apply after: 230_house_merge_log.sql. + +BEGIN; + +-- ───────────────────────────────────────────────────────────────────────── +-- payments +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS payments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + order_id text NOT NULL UNIQUE CHECK (char_length(order_id) <= 50), + tbank_payment_id text UNIQUE, -- PaymentId из ответа Init (NULL до Init) + terminal_key text NOT NULL, + product_code text NOT NULL, -- что продали (product_code, не цена из тела запроса) + amount_kopecks bigint NOT NULL CHECK (amount_kopecks > 0), + currency text NOT NULL DEFAULT 'RUB', + status text NOT NULL DEFAULT 'NEW', + payment_url text, + + created_by text, -- username (X-Authenticated-User), NULL если анонимный checkout + estimate_id uuid REFERENCES trade_in_estimates(id) ON DELETE SET NULL, + lead_id uuid REFERENCES trade_in_leads(id) ON DELETE SET NULL, + customer_email text, + customer_phone text, + pd_erased_at timestamptz, -- см. блок про purge-контур #2547 в шапке файла + + error_code text, + error_message text, + init_response jsonb, -- сырой ответ T-Bank /v2/Init, для дебага + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + authorized_at timestamptz, + confirmed_at timestamptz, + refunded_at timestamptz +); + +ALTER TABLE payments DROP CONSTRAINT IF EXISTS payments_status_check; +ALTER TABLE payments + ADD CONSTRAINT payments_status_check + CHECK (status IN ( + 'NEW', + 'FORM_SHOWED', + 'DEADLINE_EXPIRED', + 'CANCELED', + 'PREAUTHORIZING', + 'AUTHORIZING', + 'AUTHORIZED', + 'AUTH_FAIL', + 'REJECTED', + '3DS_CHECKING', + '3DS_CHECKED', + 'CHECKING', + 'CHECKED', + 'PROCESSING', + 'CONFIRMING', + 'CONFIRMED', + 'COMPLETING', + 'COMPLETED', + 'REVERSING', + 'PARTIAL_REVERSED', + 'REVERSED', + 'REFUNDING', + 'PARTIAL_REFUNDED', + 'REFUNDED', + 'REFUND_FAILED', + 'UNKNOWN' + )); + +CREATE INDEX IF NOT EXISTS payments_status_created_idx ON payments (status, created_at); +CREATE INDEX IF NOT EXISTS payments_created_by_idx ON payments (created_by); +CREATE INDEX IF NOT EXISTS payments_estimate_idx ON payments (estimate_id); +-- lead_id имеет FK ON DELETE SET NULL — без индекса Postgres делает seq scan +-- по payments на каждый DELETE FROM trade_in_leads (проверка "нет ли ссылок" +-- перед SET NULL). #2547 вводит пакетное физическое удаление лидов — без +-- индекса это N seq scan'ов по payments на один batch-прогон purge-джобы. +CREATE INDEX IF NOT EXISTS payments_lead_idx ON payments (lead_id); + +COMMENT ON TABLE payments IS + 'Платёжный контур МЕРЫ (Т-Банк эквайринг). Одна строка на попытку оплаты. ' + 'Контур выключен по умолчанию — см. PAYMENTS_ENABLED в app/core/config.py.'; + + +-- ───────────────────────────────────────────────────────────────────────── +-- payment_notifications — append-only, идемпотентность входящих вебхуков +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS payment_notifications ( + id bigserial PRIMARY KEY, + + order_id text, + tbank_payment_id text, + status text, -- сырой статус из тела, без CHECK (см. WHY выше) + amount_kopecks bigint, + token text, + token_valid boolean NOT NULL, + body jsonb NOT NULL, -- полное тело нотификации как есть + + received_at timestamptz NOT NULL DEFAULT now(), + -- Контракт fulfillment для PR-D — см. подробный блок в шапке файла. + -- NULL = обработка (выдача товара) ещё не завершена или не начиналась; + -- проставляется сервисным кодом ПОСЛЕ успешной выдачи, не в момент INSERT. + processed_at timestamptz, + + -- Дедуп-ключ идемпотентности (recon §3 п.4): T-Bank шлёт AUTHORIZED и + -- CONFIRMED одновременно для одностадийной оплаты; ON CONFLICT DO NOTHING + -- в сервисном коде (PR-D) значит "уже обработано". NULLS NOT DISTINCT + -- (см. блок в шапке файла) — без него NULL в token/tbank_payment_id не + -- считался бы дублем самого себя, и дедуп молча переставал бы работать + -- ровно в вырожденном случае, для которого он и нужен. + UNIQUE NULLS NOT DISTINCT (tbank_payment_id, status, amount_kopecks, token) +); + +COMMENT ON TABLE payment_notifications IS + 'Append-only лог входящих вебхуков T-Bank. Идемпотентность через UNIQUE ' + 'NULLS NOT DISTINCT(tbank_payment_id, status, amount_kopecks, token) + ' + 'ON CONFLICT DO NOTHING. processed_at — контракт "выдача состоялась" для PR-D.'; + + +-- ───────────────────────────────────────────────────────────────────────── +-- payment_entitlements — что выдано за платёж (факт, не кошелёк) +-- ───────────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS payment_entitlements ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + payment_id uuid NOT NULL REFERENCES payments(id), + subject text NOT NULL, -- username или anon-token, кому выдано + kind text NOT NULL, -- 'pdf_report' | 'report_link' | ... + ref_id uuid, -- estimate_id для разового отчёта, NULL если не применимо + + expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + + -- Гарантия "выдали один раз" (recon §3). NULLS NOT DISTINCT (см. блок в + -- шапке файла) — без него при ref_id IS NULL несколько строк с одинаковым + -- (payment_id, kind) НЕ считались бы дублем этим UNIQUE, что и + -- воспроизвелось на проде до первого применения миграции. + UNIQUE NULLS NOT DISTINCT (payment_id, kind, ref_id) +); + +COMMENT ON TABLE payment_entitlements IS + 'Факт "что выдано за платёж" (доступ), НЕ кредитный кошелёк — amount/' + 'consumed сознательно отсутствуют, см. mera-b2c-paid-flow-decision.md §1 ' + '(модель кредитов/пакетов отвергнута явно). UNIQUE NULLS NOT DISTINCT ' + '(payment_id, kind, ref_id) страхует fulfillment (PR-E) от повторной ' + 'выдачи по одной нотификации.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql b/tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql new file mode 100644 index 00000000..ecaead7e --- /dev/null +++ b/tradein-mvp/backend/data/sql/234_scrape_runs_ban_kind_unknown.sql @@ -0,0 +1,55 @@ +-- 234_scrape_runs_ban_kind_unknown.sql +-- scrape_runs.ban_kind получает третье значение 'unknown' — «причина не установлена» (#2764). +-- +-- Dependencies: 218_scrape_runs_ban_kind.sql (создала колонку и CHECK по ban_kind). +-- Apply after: 233_payments.sql +-- Идемпотентно: DROP/ADD CONSTRAINT + UPDATE, который после первого прогона +-- никого не находит (условие ban_kind='platform' перестаёт выполняться). +-- +-- ── ЗАЧЕМ ──────────────────────────────────────────────────────────────────── +-- 218 дала статусу 'banned' диагноз, но дефолт в сигнатуре mark_banned остался +-- 'platform', а передавали его 3 вызывающих из 13. Замер на проде 2026-08-07: +-- SELECT ban_kind, count(*) FROM scrape_runs WHERE status='banned' GROUP BY 1; +-- infra | 92 ← ретро-классификация 218 по тексту (разовая, осмысленная) +-- platform | 41 ← в т.ч. 2 строки, написанные УЖЕ в рантайме, по умолчанию +-- Обе рантайм-строки пришли из общего финализатора backfill-задач +-- (scrape_runs.mark_backfill_finished, #2674), который диагноз не передаёт вовсе: +-- 3306 avito_detail_backfill 2026-08-06 12:42 blocked=5, 0 обогащено из 5 +-- 3313 domclick_detail_backfill 2026-08-06 15:20 blocked=3, 0 обогащено из 3 +-- Причина отказов ни у одной из них не устанавливалась. Метка 'platform' у них — +-- не вывод, а значение по умолчанию, и выглядит она ровно как доказанные 10 +-- строк «Avito SERP firewall». Оставить их — значит сохранить ложное +-- доказательство ровно того класса, ради которого 218 и делалась. +-- +-- ── ГРАНИЦА ПРАВКИ ─────────────────────────────────────────────────────────── +-- Ретро-классификацию 218 (по тексту ошибки, применена 2026-08-06 08:24:19 UTC) +-- НЕ трогаем: разбор текста был там законным — типов исключений у исторических +-- строк уже нет, а маркер сайдкара в тексте есть. Трогаем только строки, которые +-- та миграция УЖЕ НЕ ВИДЕЛА (finished_at позже её применения) и которые пришли из +-- финализатора backfill'ов — единственного вызывающего, который на тот момент +-- писал ban_kind по умолчанию. Условие по error здесь опознаёт ВЫЗЫВАЮЩЕГО +-- (префикс, который он сам ставит), а не причину отказа: диагноз из текста не +-- выводится ни здесь, ни тем более в рантайме. +-- +-- Верхней границы по времени нет намеренно: миграции применяются деплоем ДО +-- рестарта приложения, но если между этим и новым кодом финализатор успеет +-- пометить ещё строки — они тоже написаны дефолтом и тоже подлежат переводу. + +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', 'unknown')); + +COMMENT ON COLUMN scrape_runs.ban_kind IS + 'Диагноз status=''banned'' (#2686, #2764): platform — площадка заблокировала ' + '(firewall/403/captcha, доказано типом исключения); infra — не отдала НАША ' + 'инфраструктура (браузерный сайдкар/прокси); unknown — причина НЕ установлена ' + '(дефолт mark_banned). NULL для прогонов с другим статусом. Пишется из типа ' + 'исключения в момент отказа, не из текста ошибки.'; + +UPDATE scrape_runs +SET ban_kind = 'unknown' +WHERE status = 'banned' + AND ban_kind = 'platform' + AND finished_at > TIMESTAMPTZ '2026-08-06 08:24:19.102335+00' -- 218 applied_at + AND error LIKE 'backfill-honest-status:%'; diff --git a/tradein-mvp/backend/data/sql/238_listings_ceiling_height_unify.sql b/tradein-mvp/backend/data/sql/238_listings_ceiling_height_unify.sql new file mode 100644 index 00000000..32220153 --- /dev/null +++ b/tradein-mvp/backend/data/sql/238_listings_ceiling_height_unify.sql @@ -0,0 +1,90 @@ +-- 238_listings_ceiling_height_unify.sql +-- Purpose (#2699): свести высоту потолков к ОДНОЙ колонке — listings.ceiling_height_m. +-- +-- Dependencies: 019_listings_alter_cian.sql (ceiling_height numeric(3,2)), +-- 111_listings_avito_detail_fields.sql (ceiling_height_m numeric(5,2)). +-- Apply after: 234_scrape_runs_ban_kind_unknown.sql +-- Идемпотентно: UPDATE'ы по предикату «уже приведено» + COMMENT ON COLUMN. +-- +-- ── ЧТО БЫЛО ───────────────────────────────────────────────────────────────── +-- Две колонки одного смысла, источники разложены по ним НЕ ПЕРЕСЕКАЯСЬ, а +-- потребитель (эстиматор, estimator._apply_kitchen_ceiling_signal) читает одну. +-- +-- Прод-замер 2026-08-07, listings (всего 94 373): +-- source | всего | ceiling_height | ceiling_height_m | обе | расходятся +-- avito | 48 592 | 0 | 7 149 | 0 | 0 +-- cian | 21 951 | 855 | 0 | 0 | 0 +-- yandex | 16 854 | 7 699 | 7 675 | 7 675 | 0 +-- domklik | 6 594 | 0 | 0 | 0 | 0 +-- n1 | 382 | 0 | 0 | 0 | 0 +-- +-- РАСХОЖДЕНИЙ НЕТ: там, где заполнены обе (7 675 строк), значения СОВПАДАЮТ до +-- последнего знака. Значит это не «две разные правды», а одна правда в двух +-- ящиках — задача не про неверную цену, а про лишнюю колонку и потерянное +-- покрытие: 855 циановских + 24 яндексовых значения не видит ни один потребитель. +-- +-- ── ВЫБОР КАНОНА: ceiling_height_m ────────────────────────────────────────── +-- 1. Единицы в имени. Так этот же признак назван ВЕЗДЕ в проекте: +-- domrf_kn_flats.ceiling_height_m, domrf_kn_objects.ceiling_height_m, +-- фронтовый ObjectSpecsTable. listings.ceiling_height — единственное исключение. +-- 2. Её читает единственный потребитель, влияющий на цену (эстиматор). +-- 3. numeric(5,2) против numeric(3,2): у 019-колонки потолок 9.99, и запись +-- out-of-range роняет ВЕСЬ батч DataError'ом (per-lot SAVEPOINT ловит только +-- IntegrityError) — это уже приходилось обходить инлайн-гейтом в yandex SERP. +-- 4. После переноса она — надмножество: 15 591 против 8 554 (посчитано +-- симуляцией на проде: 14 824 − 110 мусорных + 877 перенесённых). +-- +-- ── ЧТО ДЕЛАЕТ ЭТА МИГРАЦИЯ ───────────────────────────────────────────────── +-- 1. Чистит невозможные значения в ОБЕИХ колонках (вне 2.0–6.0 м). +-- 2. Переносит уникальные значения ceiling_height → ceiling_height_m. +-- 3. Помечает ceiling_height как DEPRECATED (писателей у неё больше нет). +-- DROP COLUMN намеренно НЕ здесь: сначала прод должен подтвердить, что колонку +-- никто не пишет и не читает. Снос — отдельным шагом, за database-expert. + +BEGIN; + +-- ── 1. Невозможные значения → NULL ────────────────────────────────────────── +-- ceiling_height_m: 26 строк > 6 м (максимум 29.90) + 84 строки < 2 м (83 из них +-- ровно 0.00) — все из avito detail, где парсер брал первое число строки без +-- проверки. Корень починен в scraper_kit.ceiling_height.plausible_ceiling_m; +-- здесь — исторический хвост. NULL, а не clamp: 0.00 и 29.90 это не потолок, а +-- мис-парс, и «починить» их в правдоподобное число мы не можем — сырой текст +-- остаётся в raw_payload / на карточке. +UPDATE listings + SET ceiling_height_m = NULL + WHERE ceiling_height_m IS NOT NULL + AND ceiling_height_m NOT BETWEEN 2.0 AND 6.0; + +-- ceiling_height: 2 строки cian (0.00 и 1.70). Чистим до переноса, чтобы шаг 2 +-- не затащил мусор в канон. +UPDATE listings + SET ceiling_height = NULL + WHERE ceiling_height IS NOT NULL + AND ceiling_height NOT BETWEEN 2.0 AND 6.0; + +-- ── 2. Перенос уникальных значений в канон ────────────────────────────────── +-- Ожидаемо 877 строк: 855 cian (писал только в 019-колонку) − 2 мусорных, +-- отсеянных шагом 1, + 24 yandex (detail записал, а SERP по этой карточке +-- значения не дал). Пересечение переносить нечего — значения там тождественны +-- (см. замер выше), поэтому только IS NULL. +-- Итог после миграции (симуляция на проде 2026-08-07): ceiling_height_m = 15 591 +-- непустых, из них 0 вне диапазона 2.0–6.0. +UPDATE listings + SET ceiling_height_m = ceiling_height + WHERE ceiling_height_m IS NULL + AND ceiling_height IS NOT NULL; + +-- ── 3. Пометка устаревшей колонки ─────────────────────────────────────────── +COMMENT ON COLUMN listings.ceiling_height IS + 'DEPRECATED (#2699, 2026-08-07). Канон — listings.ceiling_height_m. ' + 'Писателей нет: base.save_listings / cian_detail / yandex_detail переведены ' + 'на ceiling_height_m, coverage-дашборд строку убрал. Значения перенесены этой ' + 'миграцией. Оставлена временно как страховка отката; снос — отдельным шагом ' + 'после прод-подтверждения, что никто не пишет и не читает.'; + +COMMENT ON COLUMN listings.ceiling_height_m IS + 'Высота потолков, метры. КАНОН (#2699) — единственная колонка этого признака. ' + 'Пишут все источники через scraper_kit.ceiling_height.plausible_ceiling_m ' + '(гейт правдоподобия 2.0–6.0 м). Читает estimator (comp-scoring #2012).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/239_scrape_schedules_seed_house_coords_from_listings.sql b/tradein-mvp/backend/data/sql/239_scrape_schedules_seed_house_coords_from_listings.sql new file mode 100644 index 00000000..55192b3e --- /dev/null +++ b/tradein-mvp/backend/data/sql/239_scrape_schedules_seed_house_coords_from_listings.sql @@ -0,0 +1,72 @@ +-- 239_scrape_schedules_seed_house_coords_from_listings.sql +-- Ежедневный перенос координат из объявлений в дом, когда объявления согласны (#2771). +-- +-- ЧТО (source='house_coords_from_listings'): +-- product_handlers._job_house_coords_from_listings → run_house_coords_from_listings +-- (app/tasks/backfill_house_coords_from_listings.py). Для каждого дома с lat IS NULL: +-- центроид geom его объявлений + разброс (max ST_DistanceSphere до центроида). +-- Разброс <= max_spread_m → центроид пишется в дом; больше → дом не трогается и +-- попадает в отдельный счётчик skipped_scattered. +-- +-- ЗАЧЕМ ПОВТОРЯЮЩАЯСЯ, а не однократный скрипт: +-- Дом берёт координаты один раз при создании, из породившего его объявления. Объявление +-- геокодируется ПОЗЖЕ (geoportal_coords_backfill, geocode_missing_listings) и пишет в +-- listings; ON CONFLICT у вставки дома обновляет только address. Обратно координаты не +-- возвращались — на проде 2026-08-07 накопилось 1945 домов из 9625 (20.2%) без координат, +-- 1490 из них уже имеют объявления С координатами. Приток новых таких домов не +-- прекращается, поэтому однократный прогон закрыл бы дыру на день. +-- +-- ПОРОГ 125 м: половина 250-метрового гео-ограждения house_dedup_merge (#2187). Записанный +-- центроид — оценка с погрешностью ровно в разброс; отдать ей весь бюджет ограждения +-- значит позволить двум домам разъехаться на 500 м и сломать сравнение. Гистограмма +-- разброса обрыва не даёт (ядро 0-125 м, затем ровное плато до 400 м), поэтому порог +-- взят от потребителя, а не подогнан к данным. Цена: 125 м пропускает 1382 дома, +-- 250 м — 1403; 21 дом разницы не теряется, а откладывается до следующего прогона. +-- +-- ОКНО 03:00-04:00 UTC, ежедневно (interval_days по умолчанию = 1): +-- ПЕРЕД house_dedup_merge (04:00-05:00, migration 135) — недельный дедуп застаёт +-- координаты, перенесённые этой ночью из объявлений, геокодированных накануне +-- (geoportal_coords_backfill 05:00-06:00, geocode_missing_listings 06:00-09:00). +-- Чисто внутренняя операция БД: ни HTTP, ни прокси, ни anti-bot — конкуренции нет. +-- +-- enabled=true: операция НЕ деструктивная — пишет только туда, где lat IS NULL, и не +-- перезаписывает ни одной существующей координаты (тот же приём, что у +-- geoportal_coords_backfill, migration 171, тоже засеянного enabled=true). +-- +-- default_params: +-- max_spread_m -- 125: предел разброса объявлений в метрах (см. выше). +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)). +-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно применять повторно. +-- Runner применяет миграции БЕЗ --single-transaction, поэтому явные BEGIN/COMMIT. + +BEGIN; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'house_coords_from_listings', + true, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"max_spread_m": 125}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMENT ON TABLE scrape_schedules IS + 'In-app scheduler config (заменяет cron-script setup). Источники перечислены в ' + 'tests/test_scraper_kit_scheduler_parity.py::_PRODUCT_SOURCES и в сид-миграциях ' + 'data/sql/*scrape_schedules*seed*.sql. Последний добавленный: ' + 'house_coords_from_listings (#2771: ежедневный перенос координат из объявлений в дом ' + 'при согласии объявлений, разброс <= 125 м; дома с большим разбросом пропускаются ' + 'и считаются отдельно — это дефект сопоставителя #2777, не переноса).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql new file mode 100644 index 00000000..e9b0cb67 --- /dev/null +++ b/tradein-mvp/backend/data/sql/240_trade_in_estimates_retain_until.sql @@ -0,0 +1,90 @@ +-- 240_trade_in_estimates_retain_until.sql +-- Платёжный контур МЕРЫ, ретеншен (PR #2754): «оплаченное живёт год, purge +-- его не трогает». Владелец продаёт отчёт физлицу за 150 ₽ — отчёт должен +-- жить год на нашей стороне, а не 24ч (см. WHY ниже). +-- Номер сверен по `forgejo/main` и всем открытым PR-веткам ДВАЖДЫ: сначала +-- как 234 (последняя занятая на момент ветвления была 233_payments.sql), но +-- main уехал вперёд и 234 занял `234_scrape_runs_ban_kind_unknown.sql` +-- (коммит 0de22f4b) — переименовано в 240 (main max на момент повторной +-- сверки — 239, с дырами 235-237; max+1 безопаснее дыр). Урок пятый за +-- сутки: сверять номер нужно не только перед первым коммитом, а прямо перед +-- пушем/мержем — main не стоит на месте. +-- +-- ── WHY ────────────────────────────────────────────────────────────────────── +-- purge_expired_trade_in_data (migration 231, seeded enabled=false) удаляет +-- строки `WHERE expires_at < NOW() AND created_by IS NULL` — это ровно +-- популяция будущих платящих физлиц (анонимные B2C-оценки). Владелец продаёт +-- отчёт физлицу за 150 ₽: скачанный файл у клиента бессрочно, но ссылка/строка +-- на нашей стороне обязана жить дольше 24-часового TTL расчёта — иначе первый +-- же прогон purge-джобы после запуска продаж физически и безвозвратно удалит +-- уже оплаченное (PDF нигде не хранится, рендерится на лету). +-- +-- `expires_at` НЕ трогаем ни на йоту: это единая глобальная настройка +-- (`trade_in_estimate_retention_hours`), она же — печатаемая в PDF/UI дата +-- «ДЕЙСТВИТЕЛЕН ДО» (актуальность РАСЧЁТА, а не срок жизни строки), и от неё +-- зависит вычисление даты расчёта во фронте (`mappers.ts` fmtDateShift(-24)). +-- Поднять её до года означало бы: (а) дать год хранения ВСЕМ строкам, включая +-- неоплаченные адреса физлиц — прямое нарушение минимизации по 152-ФЗ; +-- (б) напечатать в PDF клиента, что расчёт актуален год. +-- +-- ── WHAT ───────────────────────────────────────────────────────────────────── +-- Новая, независимая колонка retain_until — срок жизни ДОСТУПА/СТРОКИ: +-- NULL = неоплаченная строка, поведение (чтение/PDF/purge) бит-в-бит текущее. +-- Бэкфилла нет — все 1058 существующих строк остаются NULL, ничего не меняется +-- для уже созданных оценок (весь B2B pilot-трафик в их числе). +-- При оплате (платёжный код — отдельный PR, здесь его нет) сервисный слой +-- проставит retain_until = now() + trade_in_paid_retention_days (config.py). +-- +-- Частичный индекс покрывает predicate purge-джобы (migration 231, +-- `_DELETE_EXPIRED_ESTIMATES_SQL`) уже С УЧЁТОМ нового терма retain_until — +-- заведён вместе с колонкой, а не отдельной миграцией, чтобы purge не начал +-- жить без него хотя бы один деплой. +-- +-- ── IDEMPOTENCY ────────────────────────────────────────────────────────────── +-- ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS — безопасный re-run. +-- Ничего не удаляет, не бэкфиллит, DDL-only (доли секунды на 1058 строках). +-- +-- Dependencies: 001_trade_in_estimates.sql, 233_payments.sql (индекс исключает +-- строки со строкой в payments опосредованно через predicate purge-джобы, +-- сама таблица payments здесь не читается). +-- Apply after: 233_payments.sql. +-- +-- ── lock_timeout — выставлен, хотя гейт (scripts/check-migration-lock-timeout.py) +-- этот файл не проверяет ──────────────────────────────────────────────────── +-- Порог гейта для tradein (NN >= 250) — артефакт: назначен по номеру аварийной +-- миграции 250, которую затем сняли с деплоя (#2792, 29f10002). Фактический +-- максимум применённого на main — 239, то есть НИ ОДНА миграция в диапазоне +-- 240-249 (этот файл включительно) гейтом не проверяется вообще — "проверено +-- новых миграций: 0" в логе означает "не проверено ни одного файла", а не +-- "все чисты". Сама функция scan() внутри гейта, если прогнать её без +-- порогового отсечения, помечает ALTER TABLE ниже как блокирующий DDL без +-- lock_timeout. `trade_in_estimates` — самая горячая таблица стека (история, +-- история сотрудников, каждое чтение/PDF оценки); на этой БД уже наблюдались +-- открытые транзакции на 46 и 22 часа. Ждущая ACCESS EXCLUSIVE-блокировка +-- встаёт в очередь ПЕРЕД новыми запросами — за ней начинают ждать обычные +-- SELECT приложения (см. `sql.md` § lock_timeout). На 1058 строках сам DDL +-- мгновенный — риск не в исполнении, а в ожидании чужой блокировки. Красный +-- деплой по таймауту — осознанно принятый в проекте размен (честный отказ +-- лучше тихой очереди перед приложением). НЕ убирать как "гейт же не просит". + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS retain_until timestamptz; + +COMMENT ON COLUMN trade_in_estimates.retain_until IS + 'До какого момента строку НЕЛЬЗЯ удалять и ссылка обязана открываться ' + '(оплаченный доступ). Семантика expires_at не меняется: это дата ' + 'актуальности РАСЧЁТА (24ч), она печатается в PDF. NULL = неоплачено, ' + 'поведение бит-в-бит текущее. Задаётся сервисным кодом платёжного контура ' + '(отдельный PR) на now() + trade_in_paid_retention_days (config.py).'; + +-- Частичный индекс под predicate purge-джобы (app/tasks/purge_expired_trade_in_data.py): +-- WHERE created_by IS NULL AND retain_until IS NULL AND expires_at < NOW(). +CREATE INDEX IF NOT EXISTS trade_in_estimates_purge_idx + ON trade_in_estimates (expires_at) + WHERE created_by IS NULL AND retain_until IS NULL; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql new file mode 100644 index 00000000..d725963d --- /dev/null +++ b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql @@ -0,0 +1,140 @@ +-- 250_drop_duplicate_expires_at_index.sql +-- Issue #2752 — снос дубля индекса на trade_in_estimates(expires_at). +-- Возврат после #2792 (снятие с деплоя) — теперь с lock_timeout, см. #2793/#2791. +-- +-- WHY: +-- 229_trade_in_estimates_consent_proof.sql (применена 2026-08-06 17:09) +-- создала trade_in_estimates_expires_at_idx. Это ПОБАЙТОВЫЙ дубль +-- trade_in_estimates_expires_idx из 001_trade_in_estimates.sql. +-- +-- Дословное сравнение на проде 2026-08-07 (pg_index, а не по имени): +-- name indkey indclass indoption indcollation pred am +-- trade_in_estimates_expires_idx 22 3127 0 0 — btree +-- trade_in_estimates_expires_at_idx 22 3127 0 0 — btree +-- Совпадает всё: колонка, класс операторов, направление сортировки, +-- NULLS-порядок (indoption=0 → ASC/NULLS LAST у обоих), коллация, +-- отсутствие частичного предиката, метод доступа. Ни один не привязан к +-- ограничению (pg_constraint.conindid пуст для обоих), в pg_depend на них +-- никто не ссылается — снос ничего не роняет по цепочке и НЕ требует +-- CASCADE (важно: в этом продукте DROP ... CASCADE уже терял гранты +-- FDW-пользователю). Гранты живут на таблице, не на индексе. +-- +-- ── Почему у «нулевого» дубля появились сканы ──────────────────────────────── +-- В теле #2752 значилось «у нового 0 сканов». Через сутки у него 15, а у +-- старого счётчик ЗАМОРОЖЕН на 234 (два замера, 09:14 и 09:18 UTC: старый +-- +0, новый +4). Замер 2026-08-09 16:54 UTC подтверждает картину ещё через +-- двое суток: новый 21, старый ВСЁ ЕЩЁ 234. То есть планировщик перевёл на +-- новый весь живой трафик, и это устойчивое состояние, а не переходное. +-- +-- Причина не семантическая, а физическая: индексы идентичны, но новый +-- собран позже с нуля и плотнее упакован — relpages 5 против 6 у старого, +-- разъеденного месяцем UPDATE/DELETE. genericcostestimate() считает спуск +-- по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost, +-- и при прочих равных выигрывает. Никакого нового запроса не появилось: +-- отношение idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого, +-- 0.95 у нового) — это один и тот же класс точечных lookup'ов, просто +-- переехавший на более свежий индекс. Со временем новый забронзовеет так же +-- и они поменялись бы местами обратно. +-- +-- ── ОПРОВЕРГНУТО: обоснование индекса в самой 229 ──────────────────────────── +-- 229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу +-- purge_expired_trade_in_data (migration 231) — без индекса batched-DELETE +-- делал бы full scan». На проде это НЕ так. Фактический план боевого +-- запроса из app/tasks/purge_expired_trade_in_data.py (EXPLAIN, прод +-- 2026-08-07, перепроверено 2026-08-09 — план тот же): +-- Limit → Sort (Sort Key: expires_at) +-- → Bitmap Heap Scan Filter: (expires_at < now()) +-- → Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at +-- Index Cond: (created_by IS NULL) +-- Задача purge ограничена `AND created_by IS NULL` (134 строки из 1061), и +-- планировщик берёт именно этот, более селективный индекс, а expires_at +-- остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не +-- участвует. Так что аргумента «оставить именно индекс из 229, он заведён +-- под конкретный запрос» не существует — запрос его не использует. +-- Поэтому оставлен индекс из 001: он объявлен в миграции, создающей саму +-- таблицу, и на свежей БД (001..N по порядку) переживший индекс совпадёт с +-- прод-состоянием, без «001 создаёт — 250 сносит» на каждой новой БД. +-- +-- ── Планы ДО и ПОСЛЕ ───────────────────────────────────────────────────────── +-- Индексы побайтово идентичны, поэтому смена узла невозможна в принципе: +-- меняется только имя индекса в строке плана и cost на одну страницу спуска. +-- ДО (прод, 2026-08-09 16:54 UTC): +-- Limit (cost=0.28..58.98 rows=100 width=24) +-- → Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..623.07) +-- Index Cond: (expires_at < now()) +-- ПОСЛЕ ожидается тот же узел с именем trade_in_estimates_expires_idx и +-- cost, отличающимся на спуск по одной лишней странице. Проверено на чистом +-- PostgreSQL 16.4 (та же минорная версия, что на проде) с воспроизведённым +-- перекосом плотности: +-- ДО: Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..31.84) +-- ПОСЛЕ: Index Scan using trade_in_estimates_expires_idx (cost=0.28..38.30) +-- Форма плана, Index Cond и Filter идентичны; отличается только имя. +-- +-- ── Стоимость блокировки и почему здесь SET LOCAL lock_timeout ────────────── +-- Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. УДЕРЖАНИЕ здесь +-- дёшево: trade_in_estimates — 1061 строка, heap 1856 kB, сносимый индекс +-- 40 kB; DROP INDEX ничего не переписывает (удаление строк каталога плюс +-- unlink файла, единицы миллисекунд). +-- +-- Дорого — ОЖИДАНИЕ выдачи лока, и это уже случилось. 2026-08-07 первая +-- редакция этого файла (без строки ниже) ждала ACCESS EXCLUSIVE 29 минут за +-- чужой аналитической psql-сессией (`CREATE TEMP TABLE tmp_res AS ...`, +-- pid 83256), вторая попытка — ещё 16. Четыре прогона деплоя красные, +-- четыре смерженных PR не доехали до прода; ждущий ACCESS EXCLUSIVE встаёт +-- в очередь ПЕРЕД новыми запросами, поэтому за ним начали ждать и обычные +-- SELECT приложения. Файл сняли с деплоя (#2792), конвенцию закрепили +-- (#2791: гейт scripts/check-migration-lock-timeout.py + .claude/rules/sql.md). +-- +-- Значение 5 s: снизу ограничено deadlock_timeout (на проде 1 s — сверено +-- 2026-08-09) — автоотмена мешающего autovacuum срабатывает только после +-- того, как ждущий отстоял эту секунду, поэтому 1-2 s гонялись бы с рутинным +-- autovacuum. Сверху — потолок простоя очереди приложения; против +-- наблюдённых 1740 s это в 348 раз меньше. На работу ПОД локом значение не +-- влияет вообще. +-- +-- Срабатывание таймаута = красный деплой через 5 секунд с `canceling +-- statement due to lock timeout` вместо получасовой очереди. Это ожидаемое +-- поведение, а не авария: миграция не помечается применённой, повторить +-- позже. CONCURRENTLY здесь не нужен и был бы хуже: он не может выполняться +-- внутри блока транзакции, а значит файл пришлось бы оставить без +-- BEGIN/COMMIT (см. разбор механики раннера в +-- 225_listing_source_snapshots_run_id_idx.sql). +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP INDEX IF EXISTS — безопасный re-run; без CASCADE. +-- - Одна DDL-операция внутри BEGIN/COMMIT: либо применилась, либо нет. +-- - COMMENT ON INDEX переносит знание из 229 на переживший индекс, чтобы +-- дубль не завели заново (в т.ч. фиксирует, что purge его НЕ использует). +-- +-- Dependencies: 001_trade_in_estimates.sql (создаёт переживший индекс), +-- 229_trade_in_estimates_consent_proof.sql (создала сносимый дубль). +-- Deploy order: standalone. Ничего не ждёт и никого не блокирует. +-- +-- Критерий «таблица тиха» (записан ДО, выполнен 2026-08-09 16:54 UTC): +-- SELECT count(*) FROM pg_locks l JOIN pg_class c ON c.oid = l.relation +-- WHERE c.relname='trade_in_estimates' AND l.pid <> pg_backend_pid(); → 0 +-- +-- Критерий приёмки (записан ДО применения): +-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»). +-- 2. EXPLAIN того же запроса показывает Index Scan using +-- trade_in_estimates_expires_idx — детерминированная проверка, доступна +-- сразу. +-- 3. pg_stat_user_indexes.idx_scan у trade_in_estimates_expires_idx уходит с +-- 234. NB: наблюдаемый темп ~7 сканов/сутки (21 скан за трое суток у +-- дубля), поэтому «в течение часа» — недостаточное окно; честный срок +-- подтверждения ~сутки. Если через сутки счётчик всё ещё 234, значит +-- трафик ушёл в Seq Scan — это опровергло бы разбор выше и требовало бы +-- отката (вернуть индекс: CREATE INDEX CONCURRENTLY). + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке +-- и в .claude/rules/sql.md § lock_timeout. +SET LOCAL lock_timeout = '5s'; + +DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx; + +COMMENT ON INDEX trade_in_estimates_expires_idx IS + 'Единственный индекс на trade_in_estimates(expires_at) (001). НЕ заводить второй: 229 создала побайтовый дубль trade_in_estimates_expires_at_idx, снят миграцией 250 (#2752/#2793). Мотивировка 229 («под batched-DELETE в purge_expired_trade_in_data») на проде не подтвердилась: тот запрос сужен по created_by IS NULL и идёт через idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter''ом.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/251_listings_drop_ceiling_height.sql b/tradein-mvp/backend/data/sql/251_listings_drop_ceiling_height.sql new file mode 100644 index 00000000..df6fcdb1 --- /dev/null +++ b/tradein-mvp/backend/data/sql/251_listings_drop_ceiling_height.sql @@ -0,0 +1,109 @@ +-- 251_listings_drop_ceiling_height.sql +-- Issue #2699 (хвост) — снос DEPRECATED-колонки listings.ceiling_height. +-- +-- Dependencies: 019_listings_alter_cian.sql (завела колонку, numeric(3,2)), +-- 238_listings_ceiling_height_unify.sql (перенесла значения в +-- канон ceiling_height_m и пометила эту колонку DEPRECATED). +-- Apply after: 240_trade_in_estimates_retain_until.sql +-- Deploy order: код УЖЕ впереди схемы — писатели сняты PR #2779 (07.08) и с тех +-- пор на проде. Это тот случай, когда «код первый» правилен: DROP COLUMN +-- безопасен только после того, как ни один живой writer/reader колонки не +-- остался. Обратный порядок (снести колонку, потом деплоить код) уронил бы +-- скрейпинг. +-- +-- ── ПОЧЕМУ ЭТО ОТДЕЛЬНЫЙ ФАЙЛ, А НЕ ЧАСТЬ 238 ─────────────────────────────── +-- 238 намеренно оставила колонку: «сначала прод должен подтвердить, что колонку +-- никто не пишет и не читает. Снос — отдельным шагом». Подтверждение получено, +-- ниже — числа. +-- +-- ── КРИТЕРИЙ «ПИСАТЕЛЕЙ НЕТ» (записан ДО, а не после) ─────────────────────── +-- count(ceiling_height) обязан остаться 8552 (8554 из #2699 минус 2 мусорных +-- значения cian, обнулённых шагом 1 миграции 238). +-- 2026-08-07 (сразу после 238): 8552 +-- 2026-08-09 16:54 UTC: 8552 +-- 2026-08-09 16:57 UTC: 8552 +-- Двое суток без единой записи. Контроль того, что замер не «мёртвый» (БД жива, +-- скрейпинг идёт, просто пишет в канон): за те же 2.5 минуты между двумя +-- замерами count(ceiling_height_m) вырос 16133 → 16147, а last_seen_at строк с +-- непустым ceiling_height обновлялся в ту же минуту, что и замер. То есть UPDATE +-- по этим строкам идут прямо сейчас и НЕ трогают сносимую колонку — это сильнее, +-- чем «два дня тишины». +-- +-- ── ПОТРЕБИТЕЛИ: сверка на origin/main перед сносом ───────────────────────── +-- `git grep -n 'ceiling_height\b' origin/main -- '*.py' '*.ts' '*.tsx' '*.sql'` +-- минус вхождения ceiling_height_m: ни одного обращения к КОЛОНКЕ не осталось. +-- Что попало в выдачу и почему это не потребители: +-- - имена полей Python-датаклассов enrichment'ов (CianEnrichment.ceiling_height, +-- YandexEnrichment.ceiling_height, YandexValuation...) — атрибуты объектов, +-- не колонки; +-- - `CAST(:ceiling_height AS numeric)` в yandex/detail.py:600 — ИМЯ БИНД- +-- ПАРАМЕТРА, а присваивается он колонке ceiling_height_m (соседняя строка); +-- то же в cian/detail.py (`:ch`) и base.py (`:ceiling_height_m`); +-- - комментарии/докстринги с историей #2699 и тесты, которые как раз +-- УТВЕРЖДАЮТ отсутствие колонки в SQL писателей +-- (tests/test_ceiling_height_unify_2699.py, test_scraper_admin_apis.py); +-- - 019/238 — сами миграции, их переписывать нельзя и не нужно. +-- Фронтовых (.ts/.tsx) вхождений нет вообще. +-- +-- ── ЗАВИСИМОСТИ В СХЕМЕ: проверено на проде 2026-08-09, все нули ──────────── +-- pg_depend по атрибуту listings.ceiling_height ................ 0 объектов +-- вьюхи/матвьюхи с 'ceiling' в определении ..................... 0 +-- индексы listings с 'ceiling' в indexdef ...................... 0 +-- CHECK/constraint с 'ceiling' ................................. 0 +-- функции и процедуры с 'ceiling_height' в теле ................ 0 +-- тела обоих триггеров listings (price_change, set_geom) ....... не упоминают +-- pg_publication_rel по listings (column list ломает DROP) ..... 0 (публикаций в БД нет) +-- foreign tables НА listings в gendesign-БД (FDW-читатель) ..... 0 +-- То есть CASCADE не нужен — и не должен появиться: в этом продукте +-- `DROP ... CASCADE` уже терял гранты FDW-пользователю (инцидент C3). +-- +-- ── ПОТЕРИ ДАННЫХ НЕТ (проверено, а не предположено) ──────────────────────── +-- строк, где ceiling_height IS NOT NULL AND ceiling_height_m IS NULL ..... 0 +-- строк, где заполнены обе ............................................ 8552 +-- из них расходятся значения .............................................. 0 +-- Всё содержимое сносимой колонки присутствует в каноне до последнего знака. +-- ceiling_height_m на момент написания: 16 147 непустых (после 238 было 15 591 — +-- канон растёт, то есть живой). +-- +-- ── СТОИМОСТЬ БЛОКИРОВКИ И ПОЧЕМУ SET LOCAL lock_timeout ─────────────────── +-- ALTER TABLE ... DROP COLUMN берёт ACCESS EXCLUSIVE на listings. УДЕРЖАНИЕ +-- дёшево и не зависит от размера таблицы: PostgreSQL не переписывает heap, а +-- помечает атрибут attisdropped в каталоге (в listings уже 4 таких «пенька» от +-- прошлых сносов при 92 живых колонках) — единицы миллисекунд. +-- +-- Дорого ОЖИДАНИЕ выдачи лока, и цена здесь выше, чем у 250: listings — 19 GB, +-- 97 540 строк, по ней постоянно идёт скрейпинг (в т.ч. длинные проходы вроде +-- avito_full_load). Ждущий ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми +-- запросами, поэтому за ним начнут ждать обычные SELECT/UPDATE приложения — +-- ровно то, что 2026-08-07 положило деплой на 29 минут (#2791, #2792). +-- Поэтому `SET LOCAL lock_timeout = '5s'` (снизу ограничено deadlock_timeout = +-- 1 s на проде, сверху — потолок простоя очереди приложения; на работу ПОД +-- локом не влияет). Срабатывание = честный красный деплой через 5 секунд, +-- миграция не помечается применённой, повторить в окно потише. +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP COLUMN IF EXISTS — безопасный re-run. +-- - Без CASCADE: зависимых объектов нет (см. выше), а CASCADE молча снёс бы +-- то, что появится позже. +-- - Одна DDL-операция внутри BEGIN/COMMIT. +-- - Откат: колонку вернуть можно (ALTER TABLE ... ADD COLUMN), но данные в неё +-- не восстановятся — они и не нужны, дубликат канона (0 расхождений). +-- +-- Критерий приёмки (записан ДО применения): +-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»). +-- 2. information_schema.columns по listings: ceiling_height отсутствует, +-- ceiling_height_m на месте и count(ceiling_height_m) >= 16 147. +-- 3. Скрейпинг продолжает писать: count(ceiling_height_m) растёт после сноса. + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке +-- и в .claude/rules/sql.md § lock_timeout. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE listings DROP COLUMN IF EXISTS ceiling_height; + +COMMENT ON COLUMN listings.ceiling_height_m IS + 'Высота потолков, метры. ЕДИНСТВЕННАЯ колонка этого признака (#2699): дубль listings.ceiling_height (019) снесён миграцией 251 после того, как 238 перенесла в неё значения. Пишут все источники через scraper_kit.ceiling_height.plausible_ceiling_m (гейт правдоподобия 2.0-6.0 м). Читает estimator (comp-scoring #2012).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql b/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql new file mode 100644 index 00000000..b1f63939 --- /dev/null +++ b/tradein-mvp/backend/data/sql/253_scrape_proxy_domclick_affinity_release.sql @@ -0,0 +1,52 @@ +-- 253_scrape_proxy_domclick_affinity_release.sql +-- Снять с узла резервацию provider_affinity='domclick' (#2800). +-- +-- WHY (замер, не рассуждение — живая проба 09.08.2026, тракт сайдкар+camoufox, +-- POST /fetch на robots.txt рабочего хоста каждой площадки): +-- +-- узел | affinity | avito | ekb.cian.ru | realty.yandex.ru | bff-search-web.domclick.ru +-- -----+----------+-------+--------------------+------------------+--------------------------- +-- 1 | domclick | 200 | 200 «Ошибка — Циан»| 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY +-- 9 | any | 200 | 200 | 200 | 200 +-- 10 | any | 200 | 200 | 200 | 200 +-- 11 | any | 200 | 200 | 200 | 200 +-- +-- Узел, закреплённый 173-й миграцией СПЕЦИАЛЬНО за Домкликом, до рабочего хоста +-- Домклика не доходит вообще (NS_ERROR_PROXY_BAD_GATEWAY на bff-search-web — +-- именно туда ходит боевой сбор, см. providers/domclick/serp.py::_BFF_BASE), при +-- этом Авито и Яндекс через него отвечают штатно. Резервация даёт ровно обратный +-- эффект задуманному: единственный источник, которому узел ГОДЕН НЕ БЫЛ, держал его +-- за собой, а два источника, которым он годен, его не видели — +-- acquire('avito'|'yandex') отбирает по provider_affinity IN (source,'any'), а +-- fallback этот узел не берёт (защита последнего узла выделенной affinity). +-- +-- 'any', а НЕ enabled=false: узел жив для двух площадок из четырёх, выключать его +-- целиком — терять четверть и без того дефицитного пула (#2638). +-- +-- WHAT: +-- provider_affinity='domclick' → 'any' для узлов, у которых affinity именно такая. +-- CHECK-констрейнт (173) не трогаем: значение 'domclick' остаётся допустимым, если +-- в пуле появится узел, который до Домклика реально доходит. +-- +-- ЧТО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ (граница честная): +-- Она НЕ чинит Домклик. acquire('domclick') и до неё видел все четыре узла +-- (affinity IN ('domclick','any')), т.е. шанс вытянуть узел 1 и потратить первый +-- бакет впустую был и остаётся 1/4 — закрывает это проба по паре «узел × источник» +-- (#2800 часть B), а не смена affinity. Здесь снимается только резервация. +-- +-- IDEMPOTENCY / SAFETY: +-- Один UPDATE в транзакции; повторный прогон не находит строк (no-op) — auto-apply +-- strict на деплое это требует. Блокирующего DDL нет (см. +-- scripts/check-migration-lock-timeout.py: правило про ALTER/DROP/CREATE INDEX), +-- UPDATE берёт row-lock на единичные строки. +-- +-- Dependencies: 157_scrape_proxies.sql, 173_scrape_proxies_add_domclick_affinity.sql + +BEGIN; + +UPDATE scrape_proxies +SET provider_affinity = 'any', + updated_at = now() +WHERE provider_affinity = 'domclick'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql new file mode 100644 index 00000000..61120223 --- /dev/null +++ b/tradein-mvp/backend/data/sql/254_listings_backfill_avito_rating_glued_address.sql @@ -0,0 +1,155 @@ +-- 254_listings_backfill_avito_rating_glued_address.sql +-- Разовая чистка адресов Авито, в которые уехал рейтинг дома (#2814). +-- +-- WHY. С 27.07.2026 Авито рендерит рейтинг дома и число отзывов ВНУТРИ того же

+-- в data-marker="item-location", откуда serp.py берёт адрес: «ул. Ткачей,17·5,0 · 4 +-- отзыва». Парсер починен в #2815 (merged, прод-verified 2026-08-10 09:57 UTC), но +-- УЖЕ ЗАПИСАННЫЕ строки сами не вылечатся: апсерт пишет +-- `address = COALESCE(listings.address, EXCLUDED.address)` (base.py:614) — при +-- конфликте адрес осознанно НЕ перезаписывается (#2777: свежий сырой адрес от +-- площадки откатил бы чистку миграций 062/108/124). Эта миграция — единственный +-- путь, которым старые строки могут стать чистыми. +-- +-- ЗАМЕР НА ПРОДЕ 2026-08-10, после деплоя #2815 (не «по релиз-метке», а по данным): +-- +-- класс адреса (source='avito', is_active) строк с координатами +-- ------------------------------------------ ------ -------------- +-- чистый 7892 6111 (77.4%) +-- загрязнён рейтингом (address ~ '·\s*\d') 1123 0 (0.0%) +-- NULL 360 0 (0.0%) +-- +-- 1123 не изменились после деплоя парсера — ни одной из этих строк свип не касался +-- с 09:57 (max(last_seen_at) = 2026-08-09 16:53), и не коснётся с толком: COALESCE. +-- Все 1123 — source='avito', все is_active. Других источников с таким хвостом нет. +-- Цена простоя: строка без geom молча выпадает из радиусного отбора аналогов +-- (Tier W, ST_DWithin — NULL не проходит предикат и нигде не считается). +-- +-- ПРАВИЛО РЕЗКИ — ДОСЛОВНО ПАРСЕРНОЕ, не изобретённое здесь. +-- providers/avito/serp.py: _NOT_ADDRESS_TAIL_RE = re.compile( +-- r"\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d)", flags=re.I) +-- _clean_address: split(maxsplit=1)[0] → _deglue_house_marker → strip(" ,.\n\t") +-- → `return cleaned or None`. +-- Ниже — тот же альтернатив-набор, флаг 'i' = flags=re.I, `.*$` + regexp_replace = +-- взять текст ДО первого совпадения (обе реализации leftmost), тот же набор символов +-- в trim, NULLIF(...,'') = `or None`. +-- Ключевая тонкость (#1773): резать по «·» можно ТОЛЬКО когда за ней идёт ЦИФРА. +-- За буквой идёт район — «улица Бебеля, 138 · р-н Железнодорожный», и этот хвост +-- сохраняется намеренно. На проде таких строк 296, и они обязаны остаться целыми +-- (проверено в dry-run: 296 до = 296 после). +-- _deglue_house_marker в SQL НЕ повторяется — замерено, что он здесь no-op: после +-- резки хвоста ни одна из 1123 строк не содержит слипшегося «29р-н» (0 совпадений +-- паттерном _DEGLUE_RE). Повторять в SQL лукахеды ради нуля строк незачем. +-- +-- ПАРИТЕТ ПРОВЕРЕН ТЕМ ЖЕ КОДОМ, А НЕ ПО ГЛАЗАМ. Все 1123 сырых адреса выгружены с +-- прода и прогнаны через ЖИВОЙ парсер в боевом контейнере: +-- docker exec tradein-scraper python /tmp/m2814-parity.py +-- → rows=1123 mismatches=0 +-- т.е. SQL-выражение ниже даёт побайтово то же, что `_clean_address` в проде. +-- +-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-10): +-- UPDATE 1123 · осталось загрязнённых 0 · районных «·» сохранено 296/296 +-- ул. Ткачей,17·5,0 · 4 отзыва → ул. Ткачей,17 +-- ул. Свердлова,32Б·4,2 · 5 отзывов → ул. Свердлова,32Б +-- ул. Щорса,103·4,3 · 15 отзывов → ул. Щорса,103 +-- Уральская ул.,5·4,8 · 15 отзывов → Уральская ул.,5 +-- Селькоровская ул.,60·5,0 · 3 отзыва → Селькоровская ул.,60 +-- ул. Азина,22/2·4,6 · 17 отзывов → ул. Азина,22/2 +-- ул. 8 Марта,204Г/2·4,3 · 3 отзыва → ул. 8 Марта,204Г/2 +-- жилой район Сортировочный, мкр-н Старая Сортировка, Кунарская ул.,14к2·4,3 · 6 отзывов +-- → жилой район Сортировочный, мкр-н Старая +-- Сортировка, Кунарская ул.,14к2 +-- мкр-н Широкая Речка, ул. Анатолия Муранова,18·4,7 · 11 отзывов +-- → мкр-н Широкая Речка, ул. Анатолия Муранова,18 +-- ·3,1 · 11 отзывов → NULL (id 10377315, ровно 1 строка: адрес +-- состоял ИЗ рейтинга целиком. Парсер на такой строке возвращает None — здесь то +-- же самое через NULLIF. Оставлять «·3,1 · 11 отзывов» в колонке хуже пустоты: +-- NULL апсерт теперь ДОзаполняет (#2777), мусор — нет.) +-- +-- ОБРАТИМОСТЬ — без новой таблицы и без новой колонки: прежнее значение УЖЕ хранится. +-- `listings.raw_payload->>'address'` пишется скрейпером на INSERT и НЕ входит в +-- `ON CONFLICT DO UPDATE SET` (проверено по base.py: raw_payload отсутствует в SET) — +-- т.е. переживает любой свип. Замерено на проде: у 1123 из 1123 строк +-- raw_payload->>'address' = address ПОБАЙТОВО, NULL-ов нет ни одного. +-- Откат (idempotent, безопасен к повторному запуску): +-- +-- UPDATE listings +-- SET address = raw_payload->>'address' +-- WHERE source = 'avito' +-- AND raw_payload->>'address' ~ '·\s*\d' +-- AND address IS NOT DISTINCT FROM NULLIF(trim(both E' ,.\n\t' FROM +-- regexp_replace(raw_payload->>'address', +-- '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', '', 'i')), ''); +-- +-- Предикат самоидентифицирующий, список id хранить не нужно, и это ПРОВЕРЕНО, а не +-- предположено. В dry-run (BEGIN…ROLLBACK) после UPDATE он дал по всей таблице ровно +-- 1123 совпадения, все 1123 — наши; restored = before побайтово у 1123 из 1123. +-- Ложных срабатываний нет и на строках-соседях: есть 10 строк, где raw_payload грязный, +-- а address уже чистый (их адрес позже перезаписал avito_detail полным «Свердловская +-- обл., Первоуральск, …») — второе условие их не берёт (замерено: 0), и это ПРАВИЛЬНО: +-- возвращать рейтинг поверх нормализованного адреса не надо. Со временем предикат сам +-- перестаёт брать строки, у которых address улучшил detail-путь, — откат не деградирует +-- в порчу. +-- `geocode_tried_at` откатывать нечего: это метка backoff'а, не данные. +-- +-- ПОЧЕМУ geocode_tried_at = NULL. Очередь geocode_missing_listings отбирает по +-- `geocode_tried_at IS NULL OR < NOW() - 7 days`, и метка привязана к ТЕКСТУ +-- (address, city). У 711 из 1123 строк она стоит (у 370 — свежее 7 суток) — но стоит +-- она на СТАРОМ, заведомо негеокодируемом тексте. После смены текста она смысла не +-- имеет и лишь держала бы вычищенный адрес вне очереди до 7 суток. Сброс — это не +-- «попробовать ещё раз то же самое», а «текст другой». Побочный расход честно измерен: +-- 19 пар из 854 имеют соседа, которому геокодер отказал за последние 7 суток, т.е. до +-- 19 лишних запросов к Nominatim — цена ниже, чем неделя ожидания у 370 строк. +-- +-- ЧТО БУДЕТ ДАЛЬШЕ (и чего НЕ будет). Чистый адрес координат сам не даёт. После миграции +-- 1122 строки (854 уникальные пары address+city; 1123-я — та самая NULL) попадают в +-- выборку geocode_missing_listings: `lat IS NULL AND is_active AND address IS NOT NULL +-- AND length(trim(address)) >= 5 AND (geocode_tried_at IS NULL OR < 7 days)`. Очередь +-- станет 1938 строк / 1370 пар против 1569 / 1241 сейчас (+369 строк: 753 из 1123 уже +-- стояли в ней СО СВОИМ ГРЯЗНЫМ адресом и жгли бюджет Nominatim впустую — этот расход +-- миграция тоже снимает). Расписание: enabled, окно 0-23 UTC, batch_size=200, +-- budget_sec=1800, ближайший next_run_at = 2026-08-10 17:45 UTC. +-- Гарантированный низ (замер по живому geocode_cache тем же ключом, что строит +-- `_cache_key`): 138 из 854 пар уже лежат в кэше с координатами и не истекли → 245 +-- строк получат geom мгновенно, без единого внешнего запроса. Остальное — как повезёт +-- тирам (кадастровый FDW → Nominatim): последние 5 ночных прогонов давали 17-53% +-- успеха на адрес, гадать точнее не буду. +-- +-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО: +-- * не трогает COALESCE в апсерте — поведение осознанное (#2777); +-- * не трогает 360 строк с address IS NULL — их #2777 ДОзаполняет сам на ближайшем +-- свипе (замерено: пустых строк '' среди них 0, все именно NULL); +-- * не переносит координаты с соседних строк того же адреса. Такая возможность есть +-- (789 из 1123 строк имеют соседа с координатами по тому же cleaned address+city), +-- но у 88 из 548 донорских пар соседи расходятся между собой больше чем на 50 м, у +-- 32 — больше 250 м, худший разброс 15 км. Выбирать победителя между ними — это +-- новая политика, а не бэкфилл; отдельным решением, не тихо здесь. +-- +-- Dependencies: 002_core_tables.sql (listings), 089_listings_geo_precision.sql +-- (geocode_tried_at). Триггер listings_set_geom_trg тут не участвует: он BEFORE +-- INSERT OR UPDATE OF lat, lon — эта миграция координат не пишет. +-- Идемпотентность: по построению. Второй прогон видит 0 строк с '·<цифра>' и не делает +-- ничего (WHERE самоисчерпывающийся). Новые вставки чисты с #2815. +-- lock_timeout: блокирующего DDL здесь нет, но UPDATE по «горячей» listings берёт +-- ROW EXCLUSIVE, и ждать его выдачи за чужой ACCESS EXCLUSIVE сессией — ровно та +-- очередь перед приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт +-- громко (ON_ERROR_STOP=on), чем встанет тихо. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +UPDATE listings +SET address = NULLIF( + trim(both E' ,.\n\t' FROM + regexp_replace( + address, + '\s*(Площадь \d|от \d+\s?мин\.|css-[a-z0-9_-]+|·\s*\d).*$', + '', + 'i' + )), + ''), + geocode_tried_at = NULL +WHERE source = 'avito' + AND address ~ '·\s*\d'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql b/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql new file mode 100644 index 00000000..de19a1f4 --- /dev/null +++ b/tradein-mvp/backend/data/sql/255_trade_in_estimates_revival_relaxations.sql @@ -0,0 +1,77 @@ +-- 255_trade_in_estimates_revival_relaxations.sql +-- Номер сверен по `ls data/sql | sort` (max applied = 254) непосредственно +-- перед коммитом — см. sql.md § file naming + tradein.md § collision trap +-- (108_*/084_* уже дублировались в прошлом). +-- +-- ── Incident 2026-08-10: «мёртвые» сохранённые оценки ─────────────────────── +-- Заказчик открыл сохранённую ссылку /trade-in/v2?id=... и увидел «НЕДОСТАТОЧНО +-- ДАННЫХ»: запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR +-- #2823/#2825) и лежит в БД с median_price=0/NULL, хотя тот же адрес и +-- параметры сейчас честно считаются (4 031 157 ₽ / 39 аналогов). 117 из 1071 +-- строк trade_in_estimates находятся в этом состоянии (29 за последние 30 +-- дней). GET /api/v1/trade-in/estimate/{id} (app/api/v1/trade_in.py:: +-- _try_revive_dead_estimate) теперь пересчитывает такую строку на месте и +-- пишет результат В ТУ ЖЕ строку (id/ссылка не меняются) — этому нужны две +-- новые колонки: +-- +-- revival_attempted_at — throttle: не пересчитывать чаще одного раза в N +-- минут (settings.trade_in_revival_throttle_minutes, default 10) на одну +-- строку. Заявка на пересчёт — атомарный conditional +-- `UPDATE ... WHERE revival_attempted_at IS NULL OR ... < NOW() - N min +-- RETURNING id` (тот же паттерн, что account_quota.increment, #747) — +-- защищает и от шторма повторных попыток на мёртвый адрес, и от гонки +-- двух параллельных GET (второй просто теряет заявку и отдаёт то, что +-- есть, без 500). +-- +-- ── relaxations / reliability (открытый хвост PR #2823, найден post-deploy +-- 2026-08-10 — см. fixes/Fix_Mera_Studio_Not_Estimated_Never_Block_Aug10) ── +-- Обе колонки УЖЕ вычисляются в estimator.estimate_quality() и уходят в POST- +-- ответ (AggregatedEstimate.relaxations/reliability), но раньше НЕ +-- персистились — на GET-rehydrate (открытие сохранённой ссылки) красный +-- баннер «точность снижена» пропадал, хотя цена по-прежнему построена на +-- расширенной/тонкой выборке. Теперь пишутся при каждом (re)compute (основной +-- INSERT в estimate_quality() + этот revival-путь) и читаются на GET. +-- +-- ── IDEMPOTENCY ─────────────────────────────────────────────────────────── +-- ADD COLUMN IF NOT EXISTS — безопасный re-run. Бэкфилла нет: все существующие +-- строки получают DEFAULT (reliability='ok', relaxations='[]', revival_ +-- attempted_at=NULL) — честно отражает то, что для них каскад послаблений +-- никогда не считался (записи ДО #oblast-F) и revival ещё не запускался. +-- +-- Dependencies: 001_trade_in_estimates.sql. Apply after: 254_*. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS relaxations jsonb NOT NULL DEFAULT '[]'::jsonb; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS reliability text NOT NULL DEFAULT 'ok' + CHECK (reliability IN ('ok', 'low', 'very_low')); + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS revival_attempted_at timestamptz; + +COMMENT ON COLUMN trade_in_estimates.relaxations IS + 'RU-подписи применённых послаблений подбора (estimator.py #oblast-F cascade) ' + '— персистится, чтобы GET-rehydrate (?id=) мог восстановить дисклеймер ' + '«точность снижена». [] = базовой выборки хватило / запись создана до ' + '#oblast-F (без бэкфилла).'; + +COMMENT ON COLUMN trade_in_estimates.reliability IS + 'Надёжность итоговой выборки (ok|low|very_low), производная от n_analogs + ' + 'relaxations (estimator.py::estimate_quality) — персистится для GET-rehydrate ' + 'красного баннера. Default ok = запись создана до #oblast-F (без бэкфилла).'; + +COMMENT ON COLUMN trade_in_estimates.revival_attempted_at IS + 'Момент последней попытки пересчитать «мёртвую» (median_price<=0/NULL) ' + 'строку на GET /estimate/{id} (incident 2026-08-10, app/api/v1/trade_in.py::' + '_try_revive_dead_estimate). Throttle: не пересчитывать чаще одного раза в ' + 'settings.trade_in_revival_throttle_minutes на одну строку — атомарный ' + 'conditional UPDATE...RETURNING (см. модульный докстринг). NULL = либо ' + 'строка живая (median_price>0) и revival никогда не запускался, либо ' + 'запись создана до этой фичи.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql b/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql new file mode 100644 index 00000000..499f2f79 --- /dev/null +++ b/tradein-mvp/backend/data/sql/256_trade_in_estimates_revival_completed_at.sql @@ -0,0 +1,48 @@ +-- 256_trade_in_estimates_revival_completed_at.sql +-- Номер сверен по `ls data/sql | sort` (max applied = 255) непосредственно +-- перед коммитом — см. sql.md § file naming + tradein.md § collision trap +-- (108_*/084_* уже дублировались в прошлом). +-- +-- ── fix/tradein-created (2026-08-11): created_at перезаписывался revival'ом ── +-- `_try_revive_dead_estimate` (app/api/v1/trade_in.py, migration 255) писал +-- пересчитанные поля в ИСХОДНУЮ строку trade_in_estimates, и вместе с ними — +-- `created_at`, скопированный из временной строки (estimate_quality() ставит +-- туда NOW() на момент пересчёта). Факт с прода: оценка +-- ff421062-cc38-4c4c-ad2e-0cfac52d14ff создана 2026-08-10 12:54:47, после +-- revival'а на GET её created_at стал 2026-08-11 04:30:03 — «дата обращения» +-- клиента (печатается в /history и в схеме AggregatedEstimate.created_at, +-- см. app/schemas/trade_in.py:317-318 «для метки «отчёт от DD.MM» в UI») +-- подменилась датой служебного пересчёта. Заодно ломался ORDER BY created_at +-- DESC в GET /history — оживлённая старая заявка выпрыгивала в начало списка. +-- +-- Фикс (app/api/v1/trade_in.py): created_at исключён из UPDATE SET revival'а, +-- исходная дата больше не трогается. Момент, когда revival РЕАЛЬНО пересчитал +-- строку (не просто "попытался" — revival_attempted_at из 255 ставится на +-- claim'е ДО вызова estimate_quality(), в том числе при throttle-проигрыше и +-- при неудачном пересчёте), нужен для аудита отдельно — новая колонка. +-- +-- ── IDEMPOTENCY ─────────────────────────────────────────────────────────── +-- ADD COLUMN IF NOT EXISTS — безопасный re-run. Бэкфилла нет: NULL = либо +-- строка живая и revival никогда успешно не пересчитывал, либо запись +-- создана до этой колонки. +-- +-- Dependencies: 255_trade_in_estimates_revival_relaxations.sql. Apply after: 255_*. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS revival_completed_at timestamptz; + +COMMENT ON COLUMN trade_in_estimates.revival_completed_at IS + 'Момент УСПЕШНОГО пересчёта «мёртвой» (median_price<=0/NULL) строки ' + 'revival''ом (app/api/v1/trade_in.py::_try_revive_dead_estimate) — ' + 'выставляется, когда пересчёт реально записал новые значения в строку. ' + 'Отличается от revival_attempted_at (255): тот ставится на claim''е ДО ' + 'вызова estimate_quality() и фиксирует ЛЮБУЮ попытку (включая throttled-' + 'проигрыш гонки и неудачный пересчёт), этот — только успех. created_at ' + 'строки при этом НЕ меняется (исходная дата обращения клиента, печатается ' + 'в /history) — см. fix/tradein-created 2026-08-11.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql b/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql new file mode 100644 index 00000000..05f29a7c --- /dev/null +++ b/tradein-mvp/backend/data/sql/257_listings_backfill_yandex_source_url.sql @@ -0,0 +1,150 @@ +-- 257_listings_backfill_yandex_source_url.sql +-- Разовое лечение source_url у yandex-строк, чей адрес ведёт на сайт застройщика (#2838). +-- +-- WHY. `source_url` пишется ТОЛЬКО при вставке: его нет ни в `ON CONFLICT DO UPDATE`, +-- ни в reconcile-UPDATE (`scraper_kit/base.py`). Поэтому починка продюсера (#2235, +-- `_canonical_source_url` в providers/yandex/serp.py) вылечила только НОВЫЕ строки, +-- а миграция 164 — только те легаси, чей URL ДЕЛИЛИ несколько строк (её CTE `shared` +-- искал дубли URL, а не непарсимость адреса). Строки с УНИКАЛЬНОЙ ссылкой на карточку +-- застройщика не попали ни туда, ни туда и носят адрес, замороженный в момент вставки. +-- Цена простоя: `YandexDetailScraper.parse` первым делом ищет в URL `/offer/<цифры>/` +-- и без него возвращает None ещё ДО обращения к HTML — такие строки не обогащаются +-- никогда, а `yandex_address_backfill` вдобавок ходит по ним на чужие сайты. +-- PR #2838 научил ОЧЕРЕДЬ адресовать их по source_id; колонку чинит эта миграция. +-- +-- ЗАМЕР НА ПРОДЕ 2026-08-12 (SELECT-only, не «по описанию из issue»): +-- +-- source='yandex' AND source_url !~ '/offer/[0-9]+' строк +-- ------------------------------------------------------- ----- +-- всего 3535 +-- из них source_id ~ '^[0-9]+$' (адрес восстановим) 3535 +-- из них source_id NULL/нечисловой (нечем адресовать) 0 +-- из них source_url IS NULL 0 +-- из них is_active 3522 +-- +-- хосты: macroserver.ru 912, macro.sbercrm.com 440, akademicheskiy.org 356, +-- na100.pro 331, strana.com 318, ten-stroy.ru 189, ecologica.ru 167, +-- xn--b1agbiqxpe4gxa.xn--p1ai 145, sinara-development.ru 114, +-- ekaterinburg.razum.life 111, www.lsr.ru 81, samolet.ru 68, хвост. +-- +-- Множество ЗАМКНУТО (важно: значит список ниже не устареет между PR и деплоем): +-- самая свежая его строка — id 2583989, после неё вставлено 6892 yandex-строк, +-- и НИ ОДНА в множество не попала — продюсер после #2235 таких адресов не пишет. +-- Множество может только уменьшаться (удаление строк), не расти. +-- +-- ФОРМА АДРЕСА — ДОСЛОВНО ПРОДЮСЕРНАЯ, не изобретённая здесь. +-- scraper_kit/providers/yandex/serp.py::_canonical_source_url: +-- return f"https://realty.yandex.ru/offer/{offer_id}/" # ветка «url не ведёт на realty.yandex» +-- тот же литерал живёт в app/tasks/yandex_detail_backfill.py::CANONICAL_URL_SQL +-- "'https://realty.yandex.ru/offer/' || source_id || '/'" +-- и та же формула стоит в миграции 164. Ниже — она же, посимвольно; +-- tests/test_migration_257_yandex_source_url_backfill.py держит это сцепление +-- (сравнивает выражение из ЭТОГО файла с CANONICAL_URL_SQL, который, в свою +-- очередь, уже сверен с продюсером в test_yandex_detail_backfill.py). +-- Условия отбора — те же строковые константы OFFER_URL_PATTERN ('/offer/[0-9]+') +-- и OFFER_ID_PATTERN ('^[0-9]+$'), которыми очередь #2838 отбирает эти же строки. +-- +-- КОЛЛИЗИЙ НЕТ — ПРОВЕРЕНО, А НЕ ЗАЯВЛЕНО: +-- * новый URL, уже занятый ДРУГОЙ строкой listings (любой источник): 0; +-- * два кандидата с одинаковым новым URL внутри самого множества: 0 +-- (source_id уникален по constraint 133_listings_uq_source_source_id.sql); +-- * после UPDATE в dry-run дублей source_url среди ВСЕХ yandex-строк: 0. +-- +-- DRY-RUN НА ПРОДЕ (BEGIN … ROLLBACK, 2026-08-12, тем же телом, что ниже): +-- UPDATE 3535 · осталось непарсимых 0 · дублей source_url у yandex 0 +-- счётчики очереди #2838 после: url_from_offer_id 3535 → 0, unenrichable_pending 0 +-- yandex_address_backfill (кандидаты 5545): с непарсимым URL 1777 → 0 +-- +-- было → стало (10 строк, взяты по id DESC): +-- 2583989 https://ekaterinburg.razum.life/flats/7228451 → .../offer/7087563582288224501/ +-- 2583986 https://sinara-development.ru/#/macrocatalog/… → .../offer/6990986462977811151/ +-- 2583940 https://www.an-nks.ru/catalog/38/4241/ → .../offer/7567121745684380093/ +-- 2583938 https://ten-stroy.ru/parametric/osnovinskiye-… → .../offer/5227777077487552091/ +-- 2583931 https://ekaterinburg.razum.life/flats/7225097 → .../offer/7087563582288131414/ +-- 2583929 https://samolet.ru/ekaterinburg/project/payer/… → .../offer/1827858605736006765/ +-- 2583923 https://samolet.ru/ekaterinburg/project/auruum/… → .../offer/2812449412758148821/ +-- 2583917 http://na100.pro/go.php?link=uRy09YqcU9pqegrRc… → .../offer/895871493295352384/ +-- 2583891 https://macroserver.ru/id/8783797/ → .../offer/6378643964567459685/ +-- 2583878 https://strana.com/ekb/uralskij-sad/flats/14986370→ .../offer/6591508026346911121/ +-- (префикс «стало» везде один: https://realty.yandex.ru/offer//) +-- Живая проба прод-трактом 2026-08-12 (тот же прокси, curl_cffi chrome120, тот же +-- parse) по таким восстановленным адресам: 6 из 6 — HTTP 200 и parse OK. +-- +-- ОБРАТИМОСТЬ — ТАБЛИЦА, А НЕ ПРЕДИКАТ, И ВОТ ПОЧЕМУ (проверено, а не предположено). +-- Ход «прежнее значение уже где-то лежит» (как в 254, где им был +-- raw_payload->>'address') здесь НЕ работает: +-- * listings.raw_payload ключа 'url' НЕ содержит: 0 из 3535. Ключи там +-- ceiling_height, kitchen_area_m2, offer_id, page_param, raw_building_type, +-- site_name — адреса нет ни под одним именем; +-- * listings.house_url / newbuilding_url у всех 3535 = NULL; +-- * listing_sources.source_url (тоже insert-only: в его ON CONFLICT DO UPDATE +-- source_url отсутствует) хранит прежний адрес у 3529 из 3535 — но восстановить +-- ПО НЕМУ нельзя точно: самоидентифицирующий предикат «ls.source_url не +-- realty.yandex» берёт 4832 строки, из которых наши только 3529; сузив его +-- уникальностью URL, всё равно получаем 3529 наших + 9 чужих (это строки, +-- чей listings.source_url канонизировала ещё 164 — вернуть им URL застройщика +-- значило бы отменить чужую починку). Плюс 6 наших строк не покрыты вовсе +-- (у 3 нет строки в listing_sources, у 3 там уже канонический адрес). +-- Поэтому прежние значения сохраняются ЯВНО и поимённо — таблица ниже. Откат: +-- +-- UPDATE listings l +-- SET source_url = b.old_source_url +-- FROM yandex_source_url_backfill_257 b +-- WHERE l.id = b.listing_id +-- AND l.source_url = 'https://realty.yandex.ru/offer/' || l.source_id || '/'; +-- +-- (второе условие — чтобы откат не затирал адрес, который к тому моменту записал +-- кто-то другой; повторный прогон отката безвреден). Таблица маленькая +-- (3535 строк) и одноразовая: когда откат больше не нужен, её можно просто +-- удалить — на приложение она не влияет, читателей у неё нет. +-- +-- ЧЕГО ЭТА МИГРАЦИЯ НЕ ДЕЛАЕТ, СОЗНАТЕЛЬНО: +-- * не трогает `ON CONFLICT DO UPDATE` / reconcile в scraper_kit/base.py — +-- дописывание source_url в апсерт это отдельное решение (прецедент #2818: там +-- COALESCE в апсерте так же намеренно не трогали); +-- * не трогает listing_sources.source_url — читателей у колонки нет (grep по +-- app/: единственное обращение — тот самый INSERT), а в ней остаётся живая +-- история того, что отдал gate-API; +-- * не трогает строки с source_url IS NULL — их 0, а не «на всякий случай» +-- (`!~` на NULL даёт NULL, такие строки предикат и так не берёт); +-- * не гасит и не удаляет ни одной строки: меняется ровно одна колонка. +-- +-- Dependencies: 002_core_tables.sql (listings), 133_listings_uq_source_source_id.sql +-- (уникальность source_id, на ней держится «коллизий 0»), 164 (та же формула). +-- Идемпотентность: по построению. Второй прогон видит 0 строк с непарсимым URL → +-- UPDATE и INSERT берут пустое множество; CREATE TABLE IF NOT EXISTS + ON CONFLICT +-- DO NOTHING делают повтор безопасным и при частичном откате. +-- lock_timeout: блокирующего DDL здесь нет (гейт check-migration-lock-timeout.py +-- про CREATE TABLE молчит), но UPDATE по «горячей» listings берёт ROW EXCLUSIVE, и +-- ждать его выдачи за чужой ACCESS EXCLUSIVE-сессией — ровно та очередь перед +-- приложением, из-за которой заведён #2752. Пусть лучше деплой упадёт громко. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +CREATE TABLE IF NOT EXISTS yandex_source_url_backfill_257 ( + listing_id bigint PRIMARY KEY, + old_source_url text NOT NULL, + changed_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE yandex_source_url_backfill_257 IS + 'Прежние (застройщицкие) listings.source_url, переписанные миграцией 257 (#2838). ' + 'Только для отката; читателей в приложении нет, удаляется без последствий.'; + +INSERT INTO yandex_source_url_backfill_257 (listing_id, old_source_url) +SELECT id, source_url + FROM listings + WHERE source = 'yandex' + AND source_url !~ '/offer/[0-9]+' + AND source_id ~ '^[0-9]+$' +ON CONFLICT (listing_id) DO NOTHING; + +UPDATE listings + SET source_url = 'https://realty.yandex.ru/offer/' || source_id || '/' + WHERE source = 'yandex' + AND source_url !~ '/offer/[0-9]+' + AND source_id ~ '^[0-9]+$'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql b/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql new file mode 100644 index 00000000..12f72b4b --- /dev/null +++ b/tradein-mvp/backend/data/sql/258_houses_imv_transient_attempts.sql @@ -0,0 +1,41 @@ +-- 258_houses_imv_transient_attempts.sql +-- Счётчик подряд идущих временных отказов домовой оценки Авито (эпик #2674). +-- +-- ЗАЧЕM. imv_status='transient_error' был состоянием БЕЗ ВЫХОДА: очередь +-- backfill'а выбирает ровно один статус за прогон (only_status, по умолчанию +-- 'pending'), и за всю историю (41 прогон, 26.06–11.08) ни один не был запущен +-- с другим значением. На 12.08.2026 в этом статусе лежали 1390 домов, 1337 из +-- них — с причиной «503/500 от tradein-browser:3000/fetch-json» или «All +-- connection attempts failed», то есть с ИНФРАСТРУКТУРНОЙ причиной, которой +-- больше нет (сайдкар починен #2698; за 7 суток до 12.08 в его access-логе +-- 108 из 108 POST /fetch-json = 200). +-- +-- Сервис теперь отдаёт часть пакета на повтор transient_error автоматически +-- (house_imv_backfill._RETRY_QUEUE_SQL). Этот счётчик — условие ВЫХОДА из +-- повтора: дом, падающий по своей причине, а не по инфраструктурной, перестаёт +-- занимать слот пакета после _MAX_TRANSIENT_ATTEMPTS (3) подряд. +-- +-- Наблюдаемость НЕ переименовывается: статус остаётся 'transient_error', +-- прежние разрезы по imv_status/imv_error_reason работают как работали, а +-- «застряли окончательно» — это +-- SELECT count(*) FROM houses +-- WHERE imv_status='transient_error' AND imv_transient_attempts >= 3; +-- +-- Индекс не добавляем: houses_imv_status_idx (064) уже частичный по +-- imv_status IN ('pending','transient_error') с сортировкой по +-- last_imv_attempt_at — фильтр по счётчику остаётся остаточным условием на +-- выборке в тысячи строк. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE houses + ADD COLUMN IF NOT EXISTS imv_transient_attempts smallint NOT NULL DEFAULT 0; + +COMMENT ON COLUMN houses.imv_transient_attempts IS + 'Сколько раз подряд домовая IMV-оценка падала в transient_error. ' + 'Растёт только на transient_error, обнуляется успехом. ' + '>= 3 — дом больше не берётся в автоматический повтор (эпик #2674).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql b/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql new file mode 100644 index 00000000..16312e53 --- /dev/null +++ b/tradein-mvp/backend/data/sql/259_data_quality_drop_pct_cadastr.sql @@ -0,0 +1,145 @@ +-- 259_data_quality_drop_pct_cadastr.sql +-- Purpose (#2674, третий показатель того же класса): убрать v_data_quality.pct_cadastr. +-- +-- 214 убрала outliers_flagged, 216 — price_disagreements_count по одному доводу: ноль, +-- гарантированный устройством системы, читается как «проверили — чисто», хотя честно он +-- означает «мы это не считаем». pct_cadastr — третий такой же, поэтому и действие то же: +-- не переключать источник, а снять показатель. +-- +-- ── ЧИСЛА С ПРОДА (2026-08-13, точный count) ──────────────────────────────── +-- v_data_quality.pct_cadastr .................... 0.000000000000000000000000 +-- знаменатель витрины (listings_active) ......... 45 198 (в listings всего 99 304) +-- listings.cadastral_number IS NOT NULL ......... 0 из 99 304 (и 0 из 45 198 активных) +-- deals.cadastral_number ........................ 0 из 96 974 +-- houses.cadastral_number (DaData) .............. 2 648 из 9 468 ← ДРУГОЙ объект +-- listings.building_cadastral_number ............ 30 970 из 99 304 ← ДРУГОЙ объект +-- +-- ── ЭТО НЕ ДЕФЕКТ ИЗМЕРИТЕЛЯ (контроль на здоровом образце в тех же данных) ── +-- Тот же CTE active_listings и тот же шаблон `count(*) WHERE IS NOT NULL * 100.0 +-- / NULLIF(count(*), 0)` в соседних строках витрины даёт 95.61% (pct_geocoded), 39.82% +-- (pct_description), 65.10% (pct_year_built). Ровно 0% — про колонку, а не про арифметику. +-- +-- ── ПОЧЕМУ НОЛЬ СТРУКТУРНЫЙ ───────────────────────────────────────────────── +-- listings.cadastral_number — кадастр КВАРТИРЫ. Единственное место в коде, которое его +-- вообще читает, — providers/cian/serp.py:886 (`offer.get("cadastralNumber")`); в парсерах +-- avito/yandex/domclick/n1 слов cadastr/kadastr нет ни разу, то есть для ЧЕТЫРЁХ площадок +-- из пяти ноль гарантирован НАШИМ кодом и о предметной области не говорит ничего. Пусто +-- при этом везде, где мы этот номер храним (три таблицы выше) — то же уже записано в +-- app/services/matching/houses.py: «площадки кадастр не отдают». +-- +-- ── ПОЧЕМУ НЕЛЬЗЯ «ПОЧИНИТЬ ОДНОЙ СТРОКОЙ», ПЕРЕКЛЮЧИВ НА СОСЕДНЮЮ КОЛОНКУ ── +-- Напрашивается считать по listings.building_cadastral_number (31.19% всего, 29.47% у +-- активных). Под подписью «доля объявлений с кадастром» это НОВАЯ ложь вместо старой: +-- * это кадастр ЗДАНИЯ, и в listings у него РОВНО ОДИН писатель — наш ночной KNN ≤50 м +-- по локальному зеркалу ЕГРН (tasks/cadastral_geo_match.py:161; проверено `git grep` +-- по origin/main: других INSERT/UPDATE этой колонки нет). Он не «тот же кадастр из +-- другого места», а наша производная; +-- * #2674 замерил ключ как неинъективный (656 из 3 260 значений накрывают >1 здание ГАР, +-- 20.1%; 751 из 2 864 зданий получают >1 значение, 26.2%) и прямо запретил считать его +-- идентичностью здания; +-- * разброс по площадкам среди активных геокодированных (cian 33.8%, yandex 20.3%, +-- avito 42.0%, domclick 49.7%) — про точность НАШИХ координат и охват зеркала по ЕКБ, +-- а не про качество объявления. +-- Переименовать подпись мало: честное имя было бы «доля объявлений, которым ночной KNN +-- подобрал здание в 50 м» — это другой показатель, и заводить его надо отдельно и +-- осознанно, а не под видом починки этого. Авторитетный кадастр здания у нас есть — +-- houses.cadastral_number из DaData (2 648/9 468 домов), но он про ДОМА, а витрина считает +-- ОБЪЯВЛЕНИЯ; подставить его в эту строку — снова назвать одно другим. +-- +-- ── ЦЕНА ПРАВКИ ──────────────────────────────────────────────────────────── +-- Читателей у витрины в коде нет (grep по /app/app в живом backend-контейнере пуст; +-- /api/v1/admin/scraper/data-quality считает свои метрики сам и кадастр не показывает +-- вовсе) — это ручной psql-снимок. Зависимых объектов у view тоже нет (pg_depend по +-- 'v_data_quality'::regclass, прод 13.08: 0 строк), поэтому CASCADE не нужен и не должен +-- появиться: в этом продукте `DROP ... CASCADE` уже терял гранты FDW-пользователю (C3). +-- +-- ── ПОРЯДОК И БЛОКИРОВКА ─────────────────────────────────────────────────── +-- CREATE OR REPLACE VIEW колонку УДАЛИТЬ не может → DROP VIEW → CREATE VIEW (тот же +-- порядок, что 214/216). DROP VIEW берёт ACCESS EXCLUSIVE, поэтому `SET LOCAL +-- lock_timeout` (см. scripts/check-migration-lock-timeout.py). В отличие от 222, которая +-- обошлась CREATE OR REPLACE, здесь COMMENT ON VIEW надо выставить ЗАНОВО: DROP уносит +-- комментарий вместе с объектом. +-- +-- Тело SELECT скопировано из 222_db_audit_cleanup.sql (последний DDL; сверено с живым +-- pg_get_viewdef на проде 13.08 — совпадает) минус строка pct_cadastr. Из CTE убран +-- ставший ненужным cadastral_number: 222 завела явный список колонок ровно затем, чтобы +-- view не держал column-level зависимость на то, чего не показывает. +-- +-- Dependencies: 216_dead_code_sweep.sql (текст COMMENT ON VIEW), 222_db_audit_cleanup.sql +-- (последний DDL v_data_quality). +-- Apply after: 258_houses_imv_transient_attempts.sql +-- Идемпотентно: DROP VIEW IF EXISTS + CREATE VIEW + COMMENT — повторный прогон даёт тот +-- же результат. + +BEGIN; + +-- Ждём лок не дольше 5 s: сам DROP мгновенный, но ждущий ACCESS EXCLUSIVE встаёт в +-- очередь ПЕРЕД новыми запросами (#2791/#2792). +SET LOCAL lock_timeout = '5s'; + +DROP VIEW IF EXISTS v_data_quality; + +-- DDL идентичен 222, минус строка pct_cadastr и минус cadastral_number в CTE. +CREATE VIEW v_data_quality AS +WITH active_listings AS ( + SELECT id, lat, description, house_id_fk, is_active + 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 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; + +-- Текст 216 + абзац про pct_cadastr. Выставляем заново, потому что DROP VIEW выше унёс +-- прежний комментарий вместе с объектом. +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+ источниками»), ' + 'ноль в нём — честный ответ, а не мнимое благополучие. ' + '#2674 (мигр. 259): pct_cadastr убран по тому же доводу — считал ' + 'listings.cadastral_number (кадастр КВАРТИРЫ), а его не отдаёт ни одна площадка: ' + '0 из 99 304 объявлений, 0 из 96 974 deals, единственный читающий его парсер — ' + 'cian/serp.py. Показатель НЕ переведён на listings.building_cadastral_number: та ' + 'колонка — кадастр ЗДАНИЯ и на 100% производная нашего ночного KNN ≤50 м ' + '(tasks/cadastral_geo_match.py), неинъективного как ключ здания (#2674: 20.1% ' + 'значений накрывают >1 здание ГАР); под подписью «доля объявлений с кадастром» она ' + 'мерила бы покрытие нашего геокодера, а не качество объявлений.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql new file mode 100644 index 00000000..c53c6ce5 --- /dev/null +++ b/tradein-mvp/backend/data/sql/260_houses_drop_has_panorama.sql @@ -0,0 +1,179 @@ +-- 260_houses_drop_has_panorama.sql +-- Issue #2674 (хвост) — снос houses.has_panorama: признака НЕТ в предметной области. +-- +-- Dependencies: 031_houses_alter_yandex.sql (завела колонку), +-- 154_market_contract_views.sql (внесла её в публичный контракт +-- market.v_houses), 155_reader_grants_to_contract_views.sql (грант +-- gendesign_reader на этот view). +-- Apply after: 258_houses_imv_transient_attempts.sql +-- Deploy order: код УЖЕ впереди схемы — писатель (_save_yandex_house_panorama), +-- парсер (ValuationHouseMeta.has_panorama) и правило разрешения конфликтов +-- (HOUSE_FIELD_PRIORITY) сняты тем же PR, что несёт этот файл. Обратный порядок +-- (снести колонку, оставить писателя) давал бы падающий UPDATE на каждой оценке +-- yandex_valuation — молча проглоченный, но с WARNING в логах. +-- +-- ── ЧТО ЗА НОЛЬ И ПОЧЕМУ ЭТО НЕ ДЕФЕКТ ────────────────────────────────────── +-- Колонка заполнялась `"Панорама" in body_text` по тексту страницы оценки Яндекса. +-- external_valuations (source='yandex_valuation', raw_payload->'house'), 24.05–12.08.2026: +-- страниц ............................................................. 1536 +-- has_panorama = true .................................................... 0 +-- has_panorama = false ................................................ 1536 +-- ключ отсутствует ....................................................... 0 +-- houses: 9468 строк, has_panorama непустых 12, из них true 0. +-- +-- Это НЕ «метка переехала» и НЕ «путь записи оборван». Живая проверка боевым трактом +-- 13.08.2026 (curl_cffi impersonate=chrome120 + прод-прокси, RealScraperConfig — тот же +-- клиент, что у estimator.py; только чтение) взяла три адреса Екатеринбурга, все HTTP 200: +-- Советская 51 ...... HTML 1 191 929 б — мета разобралась: 1974 г., 9 эт., панель, +-- 2,50 м потолки, 46 объектов +-- Парина 46/5 ....... HTML 1 185 458 б — 2020 г., 18 эт. +-- Сурикова 47 ....... 1977 г., 5 эт., кирпич, 184 объекта +-- Вхождений «анорам» (без учёта регистра) в ПОЛНОМ HTML: 0, 0, 0. Равно как panorama / +-- 3D-тур / Виртуальн / Street — 0. Переехать в атрибут, data-*, JSON-стейт или иную +-- вёрстку метка не могла: её нет в документе целиком. Словарь удобств дома на странице: +-- «Дом 1974 года · 9 этажей · Панельное здание · 2,50 м потолки · Газ · Лифт · +-- Мусоропровод», причём с ЯВНЫМИ отрицаниями («Лифт отсутствует», «Мусоропровода нет») — +-- будь панорама признаком дома, она печаталась бы в этом ряду и в отрицательной форме. +-- +-- Ноль был механически гарантирован самим кодом и о предметной области не говорил +-- ничего, кроме одного: измерять нечего. Третий вид нуля — НЕПРИМЕНИМО, лечится +-- удалением, а не починкой разбора. +-- +-- ОГОВОРКА ЧЕСТНОСТИ: сырой HTML прошлых сборов не хранится (raw_payload держит только +-- body_len/items_count), поэтому «метка была и исчезла в мае» доказательно не +-- опровергается. Но и положительных за всё окно 1536 страниц ноль — в измеренной +-- истории её тоже не было. +-- +-- ── ГЛАВНАЯ ЦЕНА: ЛОМАЕМ ПУБЛИЧНЫЙ КОНТРАКТ ──────────────────────────────── +-- has_panorama входит в market.v_houses (154), где сказано прямым текстом: «adding a +-- column later is backward compatible, renaming/removing one is not». Это осознанное +-- ломающее изменение контракта, а не недосмотр. Основание — консьюмер колонку не +-- читает: `git grep has_panorama` вне tradein-mvp пуст (в т.ч. +-- backend/app/services/etl/newbuilding_crossload.py, единственный живой читатель +-- контракта, #976/#2130). Держать в публичном обещании поле, которое всегда false и +-- никогда не станет ничем другим, — обещать данные, которых не существует. +-- +-- CREATE OR REPLACE VIEW удалить колонку не умеет, поэтому view пересоздаётся: +-- DROP VIEW → DROP COLUMN → CREATE VIEW. Порядок обязателен ещё и потому, что +-- DROP COLUMN без CASCADE упрётся в зависимость view (проверено на проде: единственный +-- зависимый объект — market.v_houses). CASCADE НЕ используем — он снёс бы и то, что +-- появится позже, без единого слова в логе. +-- +-- ГРАНТЫ ТЕРЯЮТСЯ ПРИ DROP VIEW (это уже кусало: C3, FDW-гранты после DROP ... CASCADE). +-- На проде на market.v_houses висит GRANT SELECT для gendesign_reader (155) — он +-- восстанавливается ниже явно, тем же стейтментом, что и в 155. Без этой строки +-- внешний ETL получил бы permission denied на следующем же прогоне. +-- +-- ── СТОИМОСТЬ БЛОКИРОВКИ И SET LOCAL lock_timeout ────────────────────────── +-- ALTER TABLE ... DROP COLUMN берёт ACCESS EXCLUSIVE на houses. Удержание дёшево и не +-- зависит от размера: PostgreSQL не переписывает heap, а помечает attisdropped в +-- каталоге — единицы миллисекунд на 9468 строк. Дорого ОЖИДАНИЕ выдачи лока: ждущий +-- ACCESS EXCLUSIVE встаёт в очередь ПЕРЕД новыми запросами, и за ним начинают ждать +-- обычные SELECT приложения — ровно то, что 2026-08-07 положило деплой на 29 минут +-- (#2791, #2792). Поэтому `SET LOCAL lock_timeout = '5s'` (снизу ограничено +-- deadlock_timeout = 1 s на проде; на работу ПОД локом не влияет). Срабатывание = +-- честный красный деплой через 5 секунд, миграция не помечается применённой. +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP VIEW IF EXISTS + DROP COLUMN IF EXISTS + CREATE VIEW после DROP — +-- безопасный re-run. +-- - Без CASCADE. +-- - Откат: колонку вернуть можно (ALTER TABLE houses ADD COLUMN has_panorama boolean), +-- данные не восстановятся — восстанавливать нечего, все 12 непустых значений false. +-- +-- Критерий приёмки (записан ДО применения): +-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»). +-- 2. information_schema.columns по houses: has_panorama отсутствует. +-- 3. market.v_houses существует, has_panorama в нём нет, остальные 59 колонок на +-- месте и в том же порядке (прод до правки: 60), SELECT count(*) отдаёт 9468+ строк. +-- 4. information_schema.role_table_grants: gendesign_reader снова имеет SELECT на +-- market.v_houses. + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке +-- и в .claude/rules/sql.md § lock_timeout. +SET LOCAL lock_timeout = '5s'; + +DROP VIEW IF EXISTS market.v_houses; + +ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama; + +-- Пересоздание контракта БЕЗ has_panorama. Список колонок — копия 154 минус одна +-- строка; он и есть обещание стабильности, поэтому выписан явно, без SELECT *. +CREATE VIEW market.v_houses AS +SELECT + id, + source, + ext_house_id, + url, + slug, + address, + full_address, + short_address, + lat, + lon, + geom, + year_built, + house_type, + house_class, + material_walls, + material_floors, + series_name, + total_floors, + total_units, + entrances, + flat_count, + is_emergency, + passenger_elevators, + cargo_elevators, + has_concierge, + closed_yard, + has_playground, + hot_water, + heat_supply_type, + gas_supply_type, + overlap_type, + parking_type, + infrastructure_summary, + infrastructure_walk_distance, + developer_name, + developer_key, + management_company_id, + rating, + reviews_count, + rating_score, + rating_string, + transport_accessibility_rate, + advantages, + banks, + builders, + houses_by_turn, + corpus_count, + commission_year, + commission_month, + total_area_ha, + cadastral_number, + house_fias_id, + yandex_jk_id, + yandex_jk_slug, + cian_internal_house_id, + cian_zhk_url, + raw_payload, + first_seen_at, + last_scraped_at +FROM public.houses; + +COMMENT ON VIEW market.v_houses IS + 'Stable public contract over public.houses (#2130). Explicit column list is the ' + 'stability promise — do not SELECT * against the base table from external ' + 'consumers. raw_payload is included because it is read today by gendesign ETL ' + '#976 (newbuilding_crossload.py); scraper-internal QC/status/validated_at ' + 'bookkeeping columns are intentionally excluded. #2674 (хвост): has_panorama ' + 'убрана из контракта вместе с колонкой — ломающее изменение, принятое осознанно ' + '(0 true из 1536 страниц, признака нет на площадке, читателей вне tradein нет).'; + +-- DROP VIEW уничтожил гранты — восстанавливаем ровно то, что дала 155. +GRANT SELECT ON market.v_houses TO gendesign_reader; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql b/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql new file mode 100644 index 00000000..751f9e36 --- /dev/null +++ b/tradein-mvp/backend/data/sql/261_listings_search_mv_drop_placeholder_columns.sql @@ -0,0 +1,270 @@ +-- 261_listings_search_mv_drop_placeholder_columns.sql +-- Issue #2857 (эпик #2674) — снос трёх колонок-заглушек из listings_search_mv: +-- distance_to_metro_m, last_price_change, photos_count. +-- +-- Dependencies: 050_search_optimization.sql (завела витрину и 6 индексов), +-- 094_cadastral_unify.sql (последняя пересоздала витрину; её текст +-- и есть текущее прод-определение, сверено с pg_matviews 13.08.2026 — +-- расхождений нет), 088_scrape_schedules_seed_search_matview_refresh.sql +-- (суточный REFRESH ... CONCURRENTLY). +-- Apply after: 260_houses_drop_has_panorama.sql +-- Deploy order: схема и код независимы — у трёх колонок НЕТ читателей, поэтому +-- правки кода этот PR не несёт и порядок «миграция ↔ образ» безразличен. +-- +-- ── ЧТО ЗА НОЛЬ ──────────────────────────────────────────────────────────── +-- Не потеря данных и не оборванный писатель: NULL прописан в самом определении +-- витрины литералом. Четвёртый вид нуля — ОБЕЩАНИЕ В КОНТРАКТЕ БЕЗ РЕАЛИЗАЦИИ: +-- имена зарезервировали в 050, реализацию не подключили никогда. +-- +-- pg_stats по listings_search_mv, 13.08.2026 (45 310 строк): +-- null_frac = 1.0 у 5 колонок: cadastral_number, district, +-- distance_to_metro_m, last_price_change, photos_count. +-- Сносим три. После применения колонок с null_frac = 1.0 останется 2 +-- (cadastral_number — живая колонка с писателем, просто площадки её не отдают, +-- см. 216/search_query.py; district — вынесен решением владельца, ниже). +-- +-- ЧИТАТЕЛЕЙ НОЛЬ — перепроверено на origin/main, не по памяти: +-- `git grep -E "distance_to_metro_m|last_price_change|photos_count" origin/main` +-- даёт 8 строк, и все 8 — сами файлы 050 и 094 (объявление + комментарий над ним). +-- Ни бэкенда, ни фронта, ни тестов, ни скриптов. Отдельно проверено, что колонки +-- не уезжают в ответ через звёздочку: `SELECT *` из listings_search_mv в репозитории +-- НЕТ ни одного (единственный читатель — services/search_query.py, там явный +-- список из 27 имён), и SQLAlchemy-рефлексии витрины тоже нет. +-- +-- DISTRICT НЕ ТРОГАЕМ, хотя он такой же пустой. Он доехал дальше всех: его тянет +-- services/search_query.py:138 и объявляет schemas/search_response.py:44 +-- (`district: str | None`), то есть API его ОТДАЁТ — всегда null. Снос = ломающее +-- изменение контракта, решение владельца, вынесено отдельным пунктом в #2857. +-- Здесь он воспроизводится байт-в-байт (`NULL::text AS district`). +-- +-- ── ПОЧЕМУ DROP + CREATE, А НЕ ALTER ─────────────────────────────────────── +-- Материализованному представлению нельзя удалить колонку: ALTER MATERIALIZED VIEW +-- такой формы не имеет, а ALTER TABLE ... DROP COLUMN на relkind='m' отказывает. +-- Единственный путь — пересоздание, как в 094. +-- +-- БЕЗ CASCADE. Зависимых объектов на проде ноль (проверено через pg_depend/pg_rewrite +-- 13.08.2026: 0 строк). Если зависимость появится до применения — DROP упрётся и +-- деплой честно покраснеет; CASCADE снёс бы её молча. +-- +-- ── ГРАНТЫ: ЛОВУШКА, КОТОРАЯ ЗДЕСЬ НЕ СРАБАТЫВАЕТ, НО ПРИКРЫТА ───────────── +-- DROP уносит ACL вместе с объектом — это уже кусало (C3, FDW-гранты после +-- DROP ... CASCADE; 260 восстанавливала GRANT SELECT для gendesign_reader вручную). +-- На listings_search_mv восстанавливать сегодня НЕЧЕГО, и это измерено, а не +-- предположено: +-- pg_class.relacl = {tradein=arwdDxt/tradein} — только владелец, ни одного +-- стороннего grantee; column-level грантов нет; pg_default_acl пуст. +-- (information_schema.role_table_grants по витрине пуст ВСЕГДА и ничего не +-- доказывает: information_schema не показывает материализованные представления +-- в принципе — смотреть надо relacl. Это и есть тот источник, где ловушку легко +-- проглядеть.) +-- Для сравнения: gendesign_reader имеет SELECT на listings и offer_price_history — +-- на витрину ему не давали. +-- Тем не менее ACL снимается и переигрывается ниже автоматически: между написанием +-- файла и его применением на проде может пройти неделя, и ручной слепок к тому +-- моменту протухнет молча. Снимок берётся в той же транзакции, что и DROP, поэтому +-- врать не может. +-- +-- ── ИНДЕКСЫ ──────────────────────────────────────────────────────────────── +-- Пересоздаются все 6 (прод, 13.08.2026 — совпадают с 050/094 один в один). +-- UNIQUE listings_search_mv_id_idx (listing_id) обязателен: без него суточный +-- REFRESH MATERIALIZED VIEW CONCURRENTLY (app/tasks/refresh_search_matview.py, +-- расписание refresh_search_matview 03:00-04:00 UTC) упадёт с +-- «cannot refresh materialized view concurrently ... no unique index». +-- +-- ── ЦЕНА ПЕРЕСОЗДАНИЯ И БЛОКИРОВКА ───────────────────────────────────────── +-- Транзакция держит ACCESS EXCLUSIVE на витрине от DROP до COMMIT, т.е. читатели +-- ждут всё построение. Замер на проде (EXPLAIN ANALYZE тела витрины, 13.08.2026): +-- сам SELECT 6.6 s на прогретом кэше; плюс 6 индексов (GIN tsv 19 МБ, GIN trgm +-- 17 МБ, остальные мелочь) при maintenance_work_mem = 64 МБ — ориентир 30-60 s +-- на всю транзакцию. Для сравнения, суточный CONCURRENTLY-рефреш укладывается в +-- 9-17 s, но он делает вдвое больше работы (строит + сливает). +-- Простой READ-трафика приемлем: за всё время жизни БД (pg_stat_database.stats_reset +-- пуст, т.е. счётчики ни разу не сбрасывались) витрина видела 225 seq_scan и +-- 63 idx_scan — а суточный CONCURRENTLY-рефреш сам по себе даёт по seq_scan в день. +-- То есть /api/v1/search к ней практически не ходит, и трюк «собрать под временным +-- именем + переименовать» (12 лишних строк ради миллисекунд вместо минуты) не нужен. +-- +-- SET LOCAL lock_timeout = '5s' — ограничивает ОЖИДАНИЕ выдачи лока, не работу под +-- ним (см. .claude/rules/sql.md § lock_timeout). Ждущий ACCESS EXCLUSIVE встаёт в +-- очередь ПЕРЕД новыми запросами. Отдельный реальный конфликт здесь: если деплой +-- попадёт в окно 03:00-04:00 UTC, DROP столкнётся с REFRESH ... CONCURRENTLY → +-- честный красный деплой через 5 s, миграция не помечается применённой, повторный +-- деплой пройдёт. +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP MATERIALIZED VIEW IF EXISTS + CREATE — повторный прогон приводит к тому +-- же состоянию (ценой ещё одного построения). Индексы создаются на заведомо +-- новом объекте, поэтому без IF NOT EXISTS (как в 050/094). +-- - Данных не теряем: витрина целиком выводима из listings/houses/listing_sources. +-- - Откат: вернуть три строки `NULL::...` в определение и пересоздать тем же +-- способом. Восстанавливать нечего — значений не существовало. +-- +-- КРИТЕРИЙ ПРИЁМКИ (записан ДО применения): +-- 1. Строка `261_listings_search_mv_drop_placeholder_columns.sql` в +-- _schema_migrations (а не «деплой зелёный»). +-- 2. Колонок в витрине 31 (было 34); distance_to_metro_m / last_price_change / +-- photos_count отсутствуют; district на месте, тип text. +-- 3. pg_matviews.definition не содержит подстроки 'distance_to_metro_m'. +-- 4. Индексов 6, среди них UNIQUE listings_search_mv_id_idx. +-- 5. pg_class.relacl витрины эквивалентен доприменительному (сегодня — владелец +-- и никого больше). +-- 6. SELECT count(*) FROM listings_search_mv отдаёт 40k+ строк. +-- 7. Следующий ночной refresh_search_matview завершается status='done' +-- (доказательство, что CONCURRENTLY не потерял UNIQUE-индекс). +-- 8. Ответ /api/v1/search по-прежнему содержит ключ district (и не содержит +-- удалённых — их там и не было). + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование — в шапке. +SET LOCAL lock_timeout = '5s'; + +-- ── 1. Снимок ACL ДО сноса ───────────────────────────────────────────────── +-- aclexplode(NULL) даёт 0 строк — на витрине без явного ACL блок просто пуст. +-- Владельца исключаем: CREATE вернёт его права сам. +-- Колоночные гранты (pg_attribute.attacl) снимаются ОТДЕЛЬНОЙ веткой: они живут +-- не в relacl, и первая редакция этого файла их молча теряла — поймано прогоном +-- на одноразовой БД, а не рассуждением. +CREATE TEMP TABLE _mv2857_acl ON COMMIT DROP AS +SELECT + CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END AS grantee, + a.privilege_type, + a.is_grantable, + NULL::text AS column_name +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +CROSS JOIN LATERAL aclexplode(c.relacl) AS a +WHERE n.nspname = 'public' + AND c.relname = 'listings_search_mv' + AND c.relkind = 'm' + AND a.grantee <> c.relowner +UNION ALL +SELECT + CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE a.grantee::regrole::text END, + a.privilege_type, + a.is_grantable, + quote_ident(att.attname) +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute att ON att.attrelid = c.oid AND att.attnum > 0 AND NOT att.attisdropped +CROSS JOIN LATERAL aclexplode(att.attacl) AS a +WHERE n.nspname = 'public' + AND c.relname = 'listings_search_mv' + AND c.relkind = 'm' + AND a.grantee <> c.relowner; + +-- ── 2. Пересоздание витрины без трёх заглушек ────────────────────────────── +DROP MATERIALIZED VIEW IF EXISTS listings_search_mv; + +CREATE MATERIALIZED VIEW listings_search_mv AS +SELECT + l.id AS listing_id, + l.source, + l.source_url, + l.address, + l.geom, + l.lat, + l.lon AS lng, + l.rooms, + l.area_m2 AS total_area, + l.floor, + l.total_floors, + l.price_rub, + l.price_per_m2, + l.cadastral_number, + l.is_active, + l.scraped_at, + -- House denorm + h.id AS house_id, + h.year_built, + h.house_class, + h.developer_name, + h.rating AS house_rating, + h.reviews_count AS house_ratings_count, + -- Cross-source aggregates + (SELECT count(*) FROM listing_sources ls WHERE ls.listing_id = l.id) AS source_count, + (SELECT array_agg(DISTINCT ext_source) FROM listing_sources ls WHERE ls.listing_id = l.id) AS sources, + (SELECT bool_or(ext_source = 'avito') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_avito, + (SELECT bool_or(ext_source = 'cian') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_cian, + (SELECT bool_or(ext_source = 'yandex_realty') FROM listing_sources ls WHERE ls.listing_id = l.id) AS has_yandex, + -- Price percentile within house + (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY ll.price_per_m2) + FROM listings ll + WHERE ll.house_id_fk = l.house_id_fk AND ll.is_active = true) AS house_median_ppm2, + -- Заглушка, оставленная СОЗНАТЕЛЬНО: district доезжает до схемы ответа API + -- (schemas/search_response.py), снос — ломающее изменение контракта, решение + -- владельца (#2857). Соседние distance_to_metro_m / last_price_change / + -- photos_count сняты здесь: у них не было ни одного читателя. + NULL::text AS district, + -- Trigram-ready columns + l.address AS address_trgm, + -- Aggregated tsv (description + address + developer_name) + to_tsvector('russian', + coalesce(l.description, '') || ' ' || + coalesce(l.address, '') || ' ' || + coalesce(h.developer_name, '') + ) AS tsv +FROM listings l +LEFT JOIN houses h ON h.id = l.house_id_fk +WHERE l.is_active = true + AND COALESCE(l.canonical, true) = true; + +-- ── 3. Те же 6 индексов (050/094) ────────────────────────────────────────── +-- UNIQUE — обязателен для REFRESH ... CONCURRENTLY, см. шапку. +CREATE UNIQUE INDEX listings_search_mv_id_idx + ON listings_search_mv (listing_id); + +CREATE INDEX listings_search_mv_geom_idx + ON listings_search_mv USING GIST (geom); + +CREATE INDEX listings_search_mv_filters_idx + ON listings_search_mv (rooms, price_rub, total_area, scraped_at DESC); + +CREATE INDEX listings_search_mv_address_trgm_idx + ON listings_search_mv USING GIN (address_trgm gin_trgm_ops); + +CREATE INDEX listings_search_mv_tsv_idx + ON listings_search_mv USING GIN (tsv); + +CREATE INDEX listings_search_mv_sources_idx + ON listings_search_mv (has_avito, has_cian, has_yandex); + +-- ── 4. Возврат грантов, снятых в п.1 ─────────────────────────────────────── +-- Пусто, если сторонних grantee не было (сегодня — так). privilege_type приходит +-- из системного каталога, поэтому подставляется как есть. +-- Если у кого-то окажется колоночный грант ИМЕННО на снесённую колонку — GRANT +-- упадёт на несуществующем имени, и это правильно: такой грант означает читателя, +-- которого мы не нашли, и деплой обязан покраснеть, а не молча снести колонку. +DO $$ +DECLARE + r record; +BEGIN + FOR r IN SELECT grantee, privilege_type, is_grantable, column_name FROM _mv2857_acl LOOP + EXECUTE format( + 'GRANT %s%s ON TABLE public.listings_search_mv TO %s%s', + r.privilege_type, + CASE WHEN r.column_name IS NULL THEN '' ELSE ' (' || r.column_name || ')' END, + r.grantee, + CASE WHEN r.is_grantable THEN ' WITH GRANT OPTION' ELSE '' END + ); + RAISE NOTICE 'listings_search_mv: возвращён GRANT % % для %', + r.privilege_type, coalesce('(' || r.column_name || ')', 'на витрину'), r.grantee; + END LOOP; +END +$$; + +-- ── 5. Статистика сразу, а не «когда-нибудь придёт autoanalyze» ──────────── +-- Иначе планировщик до первого автоанализа работает по пустым оценкам, а критерий +-- приёмки по pg_stats нечем проверить. +ANALYZE listings_search_mv; + +COMMENT ON MATERIALIZED VIEW listings_search_mv IS + 'Витрина поиска (/api/v1/search, 050/094). #2857: сняты три колонки-заглушки ' + 'distance_to_metro_m / last_price_change / photos_count — литеральный NULL в ' + 'определении, ноль читателей во всём репозитории. district оставлен намеренно: ' + 'он объявлен в schemas/search_response.py, его снос — ломающее изменение ' + 'контракта API и решение владельца. Единственный читатель витрины — ' + 'services/search_query.py с ЯВНЫМ списком колонок; SELECT * по ней запрещён ' + 'по той же причине, что и по market.v_houses.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql b/tradein-mvp/backend/data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql new file mode 100644 index 00000000..e03493d7 --- /dev/null +++ b/tradein-mvp/backend/data/sql/262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql @@ -0,0 +1,951 @@ +-- 262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql +-- Seed rows для oblast-wide city-sweep (Свердловская область, region 66) — WAVE 2: +-- avito/cian/yandex city-sweep за пределами Екатеринбурга для оставшихся 40 городов +-- области (wave 1 — 179_scrape_schedules_seed_oblast_city_sweeps.sql, 5 городов: +-- nizhniy_tagil/kamensk_uralskiy/pervouralsk/verkhnyaya_pyshma/serov). Объявления +-- по области сейчас 3229 против 20111 по ЕКБ — wave 2 заводит оставшийся охват +-- Свердловской обл. Domclick (BFF, city_id-based) — отдельный rollout, сюда НЕ входит. +-- +-- Координаты городов (lat/lon/название) — проверены на проде (геокодер + независимая +-- сверка медианой координат сделок Росреестра по городу, exclusion в радиусе 12км от +-- ЕКБ). CITY_ANCHORS-записи для всех 40 slug'ов — тот же PR, +-- packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py. +-- +-- БИСЕРТЬ ИСКЛЮЧЕНА ЦЕЛИКОМ (не 41, а 40 городов): у Циана её нет вообще — поиск на +-- любой запрос ("Бисерть", "пгт Бисерть") отдаёт Сысерть id=176028. Это пгт, а не +-- город области — не заводится ни в CITY_ANCHORS, ни здесь. +-- +-- ═══ ГЛАВНОЕ ОТЛИЧИЕ ОТ ПЕРВОЙ ВЕРСИИ ЭТОГО ФАЙЛА ═══ +-- Первая версия (до ревью) заводила 41 город × 3 источника = 123 строки для ВСЕХ +-- источников сразу, планируя добыть provider-идентификаторы (avito_slug/cian_region_id/ +-- yandex_rgid) ПОСЛЕ. Это оказалось бы РОВНО тем самым багом, о котором предупреждала её +-- же шапка: без подтверждённого идентификатора run_avito_city_sweep/run_yandex_city_sweep +-- падают на ЕКБ-дефолт (region_id/rgid Екатеринбурга) — развёртка "включена", но реально +-- собирает ЕКБ под меткой чужого города, порча данных под видом покрытия. +-- +-- Идентификаторы теперь ДОБЫТЫ И ВАЛИДИРОВАНЫ (см. CITY_LOCATIONS-коммент в pipeline.py: +-- cian_id — api.cian.ru/geo-suggest/v1/suggest; yandex_rgid — realty.yandex.ru/gate/ +-- region_suggest/suggest; avito_slug — живой GET avito.ru//kvartiry; все три метода +-- валидированы 5/5 на wave-1 городах с уже известными значениями). Но НЕ у каждого города +-- подтверждены ВСЕ ТРИ идентификатора. Правило этой миграции: **строка заводится ТОЛЬКО +-- там, где идентификатор подтверждён**. Развёртка, которая молча соберёт Екатеринбург, +-- хуже отсутствующей — недостающие источники НЕ заводим вовсе (а не заводим с заглушкой/ +-- fallback). +-- +-- Дополнительный defensive guard в коде (тот же PR, pipeline.py): если КОГДА-ЛИБО +-- run_avito_city_sweep/run_yandex_city_sweep будет вызван с city_slug, у которого в +-- CITY_LOCATIONS известный город, но конкретный provider-идентификатор всё ещё None — +-- функция явно падает `ValueError` (НЕ молчаливый ЕКБ-дефолт). При штатной эксплуатации +-- этой миграции (schedule заводится только при подтверждённом идентификаторе) этот +-- ValueError сработать не должен — он ловит будущий рассинхрон данных, не текущий. +-- +-- ИТОГО 102 строки (не 123): +-- cian_city_sweep_* — 40 строк (cian_region_id подтверждён у ВСЕХ 40 городов). +-- yandex_city_sweep_* — 39 строк (ВСЕ, КРОМЕ mikhaylovsk — Михайловск Нижнесергинского +-- р-на ОТСУТСТВУЕТ в гео-базе Яндекс.Недвижимости вообще: единственный "Михайловск" +-- там — ставропольский, rgid 586221, подставлять чужой регион нельзя. Это +-- подтверждённое ОТСУТСТВИЕ данных у источника, не "не проверили" — довести +-- нечем, ждать нечего). +-- avito_city_sweep_* — 23 строки. avito_slug НЕ подтверждён для 17 городов: +-- revda, polevskoy, berezovskiy, zarechny, kachkanar, sredneuralsk, degtyarsk, +-- artemovskiy, kamyshlov, sukhoy_log, kushva, karpinsk, nizhnyaya_tura, +-- nizhnie_sergi, lesnoy, verkhoturye, mikhaylovsk. +-- Причина по каждому — либо чистый 404 на опробованных вариантах slug'а (omonym- +-- коллизия с городом в другом регионе — нужна avito-специфичная дизамбигуация, +-- которой в проверке не делали), либо 403/429 из-за исчерпания пула прокси во +-- время проверки (кандидат НЕ опровергнут, просто НЕ подтверждён — это единственная +-- категория из трёх, которую стоит ПЕРЕПРОВЕРИТЬ на свежем пуле и добрать отдельной +-- миграцией; остальные — city_rgid mikhaylovsk и omonym-404 avito — подтверждённое +-- отсутствие/коллизия, довести нечем). +-- +-- !!! DORMANT BY DESIGN !!! Все 102 строки ship enabled = false. Оператор включает +-- ВРУЧНУЮ по одному городу за раз (как в wave 1), волнами после деплоя: +-- UPDATE scrape_schedules SET enabled = true WHERE source = 'cian_city_sweep_revda'; +-- Capability уже полностью wired — тот же механизм, что и wave 1 (pipeline.CITY_ANCHORS/ +-- get_city_anchors, scheduler._job_{avito,cian,yandex}_city_sweep читают +-- default_params->>'city', wildcard-registry "*_city_sweep_*" в +-- scraper_kit.orchestration.scheduler._default_kit_handlers) — код скраперов/хендлеров +-- НЕ меняется (кроме defensive-guard в pipeline.py выше, не меняющего штатный путь). +-- +-- default_params — за основу взяты прод-дефолты enabled-городов wave 1 (см. 179_ + +-- 206_), с тремя отличиями: +-- 1. radius_m = 3000 у avito/cian (было 1500 в 179_) — один anchor на город должен +-- покрыть город целиком; сама 179_ предупреждала, что 1500м мало для городов +-- крупнее одного круга. yandex — 25000 как есть (gate-API город скоупит city_rgid, +-- lat/lon/radius_m игнорирует целиком, см. run_yandex_city_sweep docstring — +-- radius_m там мёртвый default). +-- 2. detail_top_n = 0 у avito (было 20 в 179_) — Avito detail-страницы сейчас отдают +-- HTTP 439 firewall независимо от IP (issue #2827). Обречённые detail-запросы на +-- 23 подтверждённых города только приблизят бан общего прокси-пула зря — не тратим +-- их, пока #2827 не починен. cian detail_top_n = 10 — оставлен как в 179_. +-- 3. interval_days = 3 у всех трёх источников — тот же такт, на который migration 206_ +-- перевела wave-1 15 job'ов после замера (daily избыточен, независимая проверка по +-- listings_snapshots показала ~0.02-0.15%/сутки волатильности цены). +-- +-- window_start_hour/window_end_hour (UTC, 1-часовые окна): 24 часа в сутках, 102 новые +-- строки — полная уникальность окна на строку математически невозможна для cian/yandex +-- (40 и 39 > 16-18 свободных часов), возможна для avito (23 <= 18). Тот же round-robin +-- scheme, что в первой версии файла (координаты НЕ пересчитывались — просто отфильтрован +-- набор строк по подтверждённым идентификаторам, часы у оставшихся ГОРОДОВ не менялись): +-- окна исключают ПОЛНОСТЬЮ (а) EKB-окна (avito 6-7, cian 2-5, yandex 16-17) и (б) окна +-- wave-1 179_ (avito {0,1,5,7,8}, cian {9,10,11,12,13}, yandex {14,15,17,18,19}); внутри +-- оставшихся свободных часов round-robin по городам в исходном 41-городском TSV-порядке +-- (novouralsk..bisert, bisert выброшен целиком), затем строка эмитится, только если +-- источник подтверждён для этого города. Итоговый максимум коллизий ОДНОГО источника в +-- одном часе: avito <= 2, cian <= 3, yandex <= 3 (ниже, чем было бы при полных 41 — +-- меньше строк на источник). Разные провайдеры МОГУТ делить час — не ограничивалось (см. +-- 179_/206_ — proxy-pool уже не единственный узел). +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 179_ (wave 1, +-- CITY_ANCHORS-механизм и wildcard resolve_handler — не переопределяются здесь). +-- Idempotent: ON CONFLICT (source) DO NOTHING — каждый source в этой миграции уникален +-- по построению (40 городов × подтверждённые источники, ни один не пересекается с +-- wave-1 5 городами). + +BEGIN; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +-- ── avito_city_sweep_ — ТОЛЬКО 23 города с подтверждённым avito_slug +-- (radius_m 3000, detail_top_n 0 — issue #2827, enrich_houses true, +-- pages_per_anchor 3, request_delay_sec 7, interval_days 3) ────────────── +( + 'avito_city_sweep_novouralsk', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "novouralsk"}'::jsonb +), +( + 'avito_city_sweep_asbest', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "asbest"}'::jsonb +), +( + 'avito_city_sweep_bogdanovich', + false, + 10, + 11, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "bogdanovich"}'::jsonb +), +( + 'avito_city_sweep_irbit', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "irbit"}'::jsonb +), +( + 'avito_city_sweep_krasnoufimsk', + false, + 12, + 13, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb +), +( + 'avito_city_sweep_krasnoturinsk', + false, + 16, + 17, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb +), +( + 'avito_city_sweep_severouralsk', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "severouralsk"}'::jsonb +), +( + 'avito_city_sweep_ivdel', + false, + 18, + 19, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "ivdel"}'::jsonb +), +( + 'avito_city_sweep_tavda', + false, + 19, + 20, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "tavda"}'::jsonb +), +( + 'avito_city_sweep_turinsk', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "turinsk"}'::jsonb +), +( + 'avito_city_sweep_sysert', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "sysert"}'::jsonb +), +( + 'avito_city_sweep_verkhnyaya_salda', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb +), +( + 'avito_city_sweep_nizhnyaya_salda', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb +), +( + 'avito_city_sweep_nevyansk', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "nevyansk"}'::jsonb +), +( + 'avito_city_sweep_alapaevsk', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "alapaevsk"}'::jsonb +), +( + 'avito_city_sweep_krasnouralsk', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "krasnouralsk"}'::jsonb +), +( + 'avito_city_sweep_verkhniy_tagil', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb +), +( + 'avito_city_sweep_rezh', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "rezh"}'::jsonb +), +( + 'avito_city_sweep_aramil', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "aramil"}'::jsonb +), +( + 'avito_city_sweep_volchansk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "volchansk"}'::jsonb +), +( + 'avito_city_sweep_verkhnyaya_tura', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb +), +( + 'avito_city_sweep_talitsa', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "talitsa"}'::jsonb +), +( + 'avito_city_sweep_novaya_lyalya', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 0, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 3000, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb +), +-- ── cian_city_sweep_ — ВСЕ 40 городов (cian_id подтверждён у всех) +-- (radius_m 3000, detail_top_n 10, enrich_houses true, pages_per_anchor 3, +-- request_delay_sec 5, interval_days 3) ───────────────────────────────── +( + 'cian_city_sweep_novouralsk', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "novouralsk"}'::jsonb +), +( + 'cian_city_sweep_revda', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "revda"}'::jsonb +), +( + 'cian_city_sweep_polevskoy', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "polevskoy"}'::jsonb +), +( + 'cian_city_sweep_asbest', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "asbest"}'::jsonb +), +( + 'cian_city_sweep_bogdanovich', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "bogdanovich"}'::jsonb +), +( + 'cian_city_sweep_irbit', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "irbit"}'::jsonb +), +( + 'cian_city_sweep_krasnoufimsk', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb +), +( + 'cian_city_sweep_berezovskiy', + false, + 15, + 16, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "berezovskiy"}'::jsonb +), +( + 'cian_city_sweep_zarechny', + false, + 16, + 17, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "zarechny"}'::jsonb +), +( + 'cian_city_sweep_kachkanar', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kachkanar"}'::jsonb +), +( + 'cian_city_sweep_krasnoturinsk', + false, + 18, + 19, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb +), +( + 'cian_city_sweep_severouralsk', + false, + 19, + 20, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "severouralsk"}'::jsonb +), +( + 'cian_city_sweep_ivdel', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "ivdel"}'::jsonb +), +( + 'cian_city_sweep_tavda', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "tavda"}'::jsonb +), +( + 'cian_city_sweep_turinsk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "turinsk"}'::jsonb +), +( + 'cian_city_sweep_sysert', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sysert"}'::jsonb +), +( + 'cian_city_sweep_sredneuralsk', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sredneuralsk"}'::jsonb +), +( + 'cian_city_sweep_degtyarsk', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "degtyarsk"}'::jsonb +), +( + 'cian_city_sweep_verkhnyaya_salda', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb +), +( + 'cian_city_sweep_nizhnyaya_salda', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb +), +( + 'cian_city_sweep_nevyansk', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nevyansk"}'::jsonb +), +( + 'cian_city_sweep_artemovskiy', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "artemovskiy"}'::jsonb +), +( + 'cian_city_sweep_kamyshlov', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kamyshlov"}'::jsonb +), +( + 'cian_city_sweep_alapaevsk', + false, + 15, + 16, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "alapaevsk"}'::jsonb +), +( + 'cian_city_sweep_sukhoy_log', + false, + 16, + 17, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 16)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "sukhoy_log"}'::jsonb +), +( + 'cian_city_sweep_kushva', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "kushva"}'::jsonb +), +( + 'cian_city_sweep_krasnouralsk', + false, + 18, + 19, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "krasnouralsk"}'::jsonb +), +( + 'cian_city_sweep_karpinsk', + false, + 19, + 20, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "karpinsk"}'::jsonb +), +( + 'cian_city_sweep_nizhnyaya_tura', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnyaya_tura"}'::jsonb +), +( + 'cian_city_sweep_verkhniy_tagil', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb +), +( + 'cian_city_sweep_nizhnie_sergi', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "nizhnie_sergi"}'::jsonb +), +( + 'cian_city_sweep_lesnoy', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "lesnoy"}'::jsonb +), +( + 'cian_city_sweep_rezh', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "rezh"}'::jsonb +), +( + 'cian_city_sweep_aramil', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "aramil"}'::jsonb +), +( + 'cian_city_sweep_volchansk', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "volchansk"}'::jsonb +), +( + 'cian_city_sweep_verkhnyaya_tura', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb +), +( + 'cian_city_sweep_mikhaylovsk', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "mikhaylovsk"}'::jsonb +), +( + 'cian_city_sweep_verkhoturye', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "verkhoturye"}'::jsonb +), +( + 'cian_city_sweep_talitsa', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "talitsa"}'::jsonb +), +( + 'cian_city_sweep_novaya_lyalya', + false, + 15, + 16, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 3000, "detail_top_n": 10, "enrich_houses": true, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb +), +-- ── yandex_city_sweep_ — 39 городов (ВСЕ, КРОМЕ mikhaylovsk — города +-- нет в гео-базе Яндекса вообще) (radius_m 25000, pages_per_anchor 3, +-- request_delay_sec 9, interval_days 3) ─────────────────────────────── +( + 'yandex_city_sweep_novouralsk', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "novouralsk"}'::jsonb +), +( + 'yandex_city_sweep_revda', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "revda"}'::jsonb +), +( + 'yandex_city_sweep_polevskoy', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "polevskoy"}'::jsonb +), +( + 'yandex_city_sweep_asbest', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "asbest"}'::jsonb +), +( + 'yandex_city_sweep_bogdanovich', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "bogdanovich"}'::jsonb +), +( + 'yandex_city_sweep_irbit', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "irbit"}'::jsonb +), +( + 'yandex_city_sweep_krasnoufimsk', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnoufimsk"}'::jsonb +), +( + 'yandex_city_sweep_berezovskiy', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "berezovskiy"}'::jsonb +), +( + 'yandex_city_sweep_zarechny', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "zarechny"}'::jsonb +), +( + 'yandex_city_sweep_kachkanar', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kachkanar"}'::jsonb +), +( + 'yandex_city_sweep_krasnoturinsk', + false, + 10, + 11, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnoturinsk"}'::jsonb +), +( + 'yandex_city_sweep_severouralsk', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "severouralsk"}'::jsonb +), +( + 'yandex_city_sweep_ivdel', + false, + 12, + 13, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "ivdel"}'::jsonb +), +( + 'yandex_city_sweep_tavda', + false, + 13, + 14, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "tavda"}'::jsonb +), +( + 'yandex_city_sweep_turinsk', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "turinsk"}'::jsonb +), +( + 'yandex_city_sweep_sysert', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sysert"}'::jsonb +), +( + 'yandex_city_sweep_sredneuralsk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sredneuralsk"}'::jsonb +), +( + 'yandex_city_sweep_degtyarsk', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "degtyarsk"}'::jsonb +), +( + 'yandex_city_sweep_verkhnyaya_salda', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhnyaya_salda"}'::jsonb +), +( + 'yandex_city_sweep_nizhnyaya_salda', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnyaya_salda"}'::jsonb +), +( + 'yandex_city_sweep_nevyansk', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nevyansk"}'::jsonb +), +( + 'yandex_city_sweep_artemovskiy', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "artemovskiy"}'::jsonb +), +( + 'yandex_city_sweep_kamyshlov', + false, + 4, + 5, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 4)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kamyshlov"}'::jsonb +), +( + 'yandex_city_sweep_alapaevsk', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "alapaevsk"}'::jsonb +), +( + 'yandex_city_sweep_sukhoy_log', + false, + 6, + 7, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 6)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "sukhoy_log"}'::jsonb +), +( + 'yandex_city_sweep_kushva', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "kushva"}'::jsonb +), +( + 'yandex_city_sweep_krasnouralsk', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "krasnouralsk"}'::jsonb +), +( + 'yandex_city_sweep_karpinsk', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "karpinsk"}'::jsonb +), +( + 'yandex_city_sweep_nizhnyaya_tura', + false, + 10, + 11, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnyaya_tura"}'::jsonb +), +( + 'yandex_city_sweep_verkhniy_tagil', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhniy_tagil"}'::jsonb +), +( + 'yandex_city_sweep_nizhnie_sergi', + false, + 12, + 13, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "nizhnie_sergi"}'::jsonb +), +( + 'yandex_city_sweep_lesnoy', + false, + 13, + 14, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "lesnoy"}'::jsonb +), +( + 'yandex_city_sweep_rezh', + false, + 20, + 21, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 20)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "rezh"}'::jsonb +), +( + 'yandex_city_sweep_aramil', + false, + 21, + 22, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 21)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "aramil"}'::jsonb +), +( + 'yandex_city_sweep_volchansk', + false, + 22, + 23, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 22)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "volchansk"}'::jsonb +), +( + 'yandex_city_sweep_verkhnyaya_tura', + false, + 23, + 0, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 23)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhnyaya_tura"}'::jsonb +), +( + 'yandex_city_sweep_verkhoturye', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "verkhoturye"}'::jsonb +), +( + 'yandex_city_sweep_talitsa', + false, + 2, + 3, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "talitsa"}'::jsonb +), +( + 'yandex_city_sweep_novaya_lyalya', + false, + 3, + 4, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 3)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "interval_days": 3, "city": "novaya_lyalya"}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMENT ON TABLE scrape_schedules IS + 'In-app scheduler config (заменяет cron-script setup). Источники перечислены в ' + 'tests/test_scraper_kit_scheduler_parity.py::_PRODUCT_SOURCES и в сид-миграциях ' + 'data/sql/*scrape_schedules*seed*.sql. Последний добавленный: 102 wave-2 oblast ' + 'city-sweep source''ы (40 городов, только подтверждённые provider-id: ' + 'cian x40 / yandex x39 (без mikhaylovsk) / avito x23, #262 — все enabled=false, ' + 'defensive ValueError guard в pipeline.py против молчаливого ЕКБ-fallback).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/263_scrape_schedules_wave2_cian_newbuilding_only_false.sql b/tradein-mvp/backend/data/sql/263_scrape_schedules_wave2_cian_newbuilding_only_false.sql new file mode 100644 index 00000000..5f1078ed --- /dev/null +++ b/tradein-mvp/backend/data/sql/263_scrape_schedules_wave2_cian_newbuilding_only_false.sql @@ -0,0 +1,52 @@ +-- 263_scrape_schedules_wave2_cian_newbuilding_only_false.sql +-- Дописывает "newbuilding_only": false в default_params 40 cian-строк wave 2 (262_). +-- +-- ПОЧЕМУ. Прогон первого включённого города области показал, что sweep отрабатывает +-- «успешно», но не сохраняет НИЧЕГО: +-- +-- cian-sweep run_id=3884 anchor Новоуральск центр: +-- SERP fetched=84 nb_kept=0 dropped_secondary=84 ins=0 upd=0 +-- cian-sweep run_id=3884 done: anchors=1/1 lots=84 (ins=0/upd=0) ... errors=0 +-- +-- 84 лота найдено и все 84 отброшено как вторичка, статус прогона при этом done. +-- +-- Причина: scraper_kit.orchestration.scheduler (_job_cian_city_sweep) читает +-- newbuilding_only=bool(params.get("newbuilding_only", True)) +-- то есть дефолт — True. Сид 179_ (wave 1) ключ проставляет явно (false), а 262_ +-- (wave 2) его потерял. Мера оценивает ВТОРИЧКУ — estimator отбирает аналоги с +-- (listing_segment IS NULL OR listing_segment = 'vtorichka'), — поэтому режим +-- «только новостройки» для этих строк бессмыслен: сбор идёт, данные выбрасываются. +-- +-- ЗАТРАГИВАЕТ ТОЛЬКО cian. У avito/yandex такого параметра нет ни в 179_, ни в 262_ +-- (проверено сравнением default_params wave-1 и wave-2 на проде) — их не трогаем. +-- +-- ПОБОЧНАЯ НАХОДКА: под гейт попадает 41 строка, а не 40. Лишняя — +-- `cian_city_sweep_verkhnyaya_pyshma` из wave 1, ВКЛЮЧЁННАЯ и работающая в проде: +-- 179_ проставил newbuilding_only не всем своим городам. Последствия на живых данных: +-- +-- Верхняя Пышма (ключа нет): cian 184 активных → вторички 3, новостроек 181 +-- Первоуральск (ключ есть): cian 336 активных → вторички 308 +-- +-- То есть по Верхней Пышме Циан давал оценщику 3 пригодных объявления вместо ~300 — +-- сбор шёл, статус зелёный, данные молча выбрасывались. Эта миграция чинит и её. + + +-- +-- Идемпотентность: WHERE-гейт `NOT (default_params ? 'newbuilding_only')` — миграция +-- дописывает ключ только там, где его нет. Повторный прогон — no-op, и она никогда +-- не перезатрёт значение, выставленное позже вручную оператором. +-- +-- ЗАВИСИМОСТИ: 262_ (сами строки), 052_scrape_schedules.sql (таблица). + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +UPDATE scrape_schedules +SET default_params = default_params || '{"newbuilding_only": false}'::jsonb, + updated_at = NOW() +WHERE source LIKE 'cian\_city\_sweep\_%' + AND default_params ? 'city' + AND NOT (default_params ? 'newbuilding_only'); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/264_deactivate_stale_avito_cap_mult.sql b/tradein-mvp/backend/data/sql/264_deactivate_stale_avito_cap_mult.sql new file mode 100644 index 00000000..fc0a1273 --- /dev/null +++ b/tradein-mvp/backend/data/sql/264_deactivate_stale_avito_cap_mult.sql @@ -0,0 +1,83 @@ +-- 264_deactivate_stale_avito_cap_mult.sql +-- Калибрует потолок эффективного TTL (cap_mult) для avito (#TTL-CAP, 2026-08-15). +-- +-- ЗАЧЕМ. Пол TTL по измеренному циклу переобхода (#2659, deactivate_stale_avito.py) +-- поднимает эффективный TTL через max(ttl_days, пол) без верхней границы -- на проде +-- это оказалось петлёй с положительной обратной связью: медленный обход поднимает +-- пол, высокий пол продлевает жизнь снятым лотам дольше, чем к ним успевает +-- вернуться свежий обход, пул «активных» раздувается протухшими строками. ВАЖНАЯ +-- ОГОВОРКА (перепроверено 2026-08-15): цифра «23 687 из 44 744» -- это ВСЕ источники +-- вместе, и две трети её -- новостройки, которые оценщик не берёт вообще. У самого +-- avito просроченных строк НОЛЬ (8 663 активных, максимальный возраст 10 суток) -- +-- его деактивация работает исправно. Этот потолок существует не ради сжатия пула +-- (он деактивирует 0 строк, замерено), а как защита от опечатки в расписании и от +-- будущего разгона пола. Потолок cap_mult ограничивает пол сверху: эффективный TTL не +-- может превысить ttl_days * cap_mult (код -- app/tasks/deactivate_stale_avito.py, +-- CAP_MULT). +-- +-- ПОЧЕМУ ИМЕННО AVITO. Дефолт CAP_MULT=2 даёт разный АБСОЛЮТНЫЙ потолок на разных +-- источниках (множитель от ttl_days), и ломается там, где хвост переобхода +-- источника НЕ пропорционален его ttl_days. Таблица ниже -- ЖИВЫЕ полы из +-- scrape_runs.counters (ttl_days_effective/revisit_floor_days по каждой job'е за +-- 2026-08-10..08-15, ПЕРЕСЧИТАНО ревью круга 3 2026-08-15 -- прежняя версия таблицы +-- брала статический p99 из _REVISIT_TAIL (40-суточный замер на более раннюю дату) +-- и по нему ошибочно утверждала «yandex 43.0 -> потолок 60, запас есть»; live-полы +-- показывают обратное, см. ниже), а не по статической константе: +-- источник/сегмент живой пол (6 прогонов) ttl_days потолок cap_mult=2 +-- domklik vtorichka 23/24/25/skip/skip/skip 14 28 (запас есть) +-- cian vtorichka 34/34/37/27/27/32 30 60 (запас есть) +-- yandex vtorichka 75/75/75/39/52/54 30 60 (ХВОСТ ВЫШЕ) +-- avito все сегменты 52/52/52/7/8/9 10 20 (ХВОСТ ВЫШЕ) +-- У avito p99=42.1 суток (_REVISIT_TAIL) и живой пик 52 -- ВЫШЕ его же дефолтного +-- потолка 20: дефолтный cap_mult=2 может резать пол ниже собственного хвоста +-- обхода, то есть ровно тот false-kill, ради которого пол вообще заведён. +-- +-- YANDEX -- ТА ЖЕ ДЫРА, что и у avito, но найдена ПОЗЖЕ (при первой версии этой +-- миграции статический p99=43.0 ошибочно считался достаточным запасом). Живой пол +-- yandex/vtorichka держится 39-75 суток шесть прогонов подряд, а прямой live-замер +-- 2026-08-15 (та же percentile_disc(0.99)-формула, что и в проде) даёт 79.2 суток +-- (n=1961 подтверждений за 3 суток) -- выше потолка 60 при дефолтном cap_mult=2. +-- Калибровка yandex вынесена в ОТДЕЛЬНУЮ миграцию +-- (265_deactivate_stale_yandex_cap_mult.sql, cap_mult=3 -> потолок 90), не сюда -- +-- эта миграция специфична для avito по имени и назначению, смешивать источники в +-- одном файле хуже для git-истории калибровок. cian и domklik разрыва не имеют, +-- дефолт cap_mult=2 для них по-прежнему калиброван верно, эта миграция их не трогает. +-- +-- ЧИСЛЕННЫЙ ЭФФЕКТ (обе миграции, 264+265, live-замер 2026-08-15): на пул активных +-- строк не влияет ни у одного из четырёх источников -- next-run deactivated=0 что до, +-- что после калибровки. У avito и cian живой пол (12/32 суток) уже ниже потолка -- +-- калибровка cap_mult просто не участвует в min(). У yandex 0 активных строк старше +-- 39 суток вообще (весь "просроченный" хвост младше того возраста, где потолок +-- 60 vs 90 может разойтись), поэтому даже БЕЗ калибровки (дефолт cap_mult=2, +-- потолок 60 < живой пол 79.2) next-run deactivated тоже 0 -- калибровка убирает +-- будущий риск (потолок бы капал ttl_days_effective 79->60 в counters и резал бы +-- ниже собственного хвоста обхода, как только появятся строки в возрастной полосе +-- 60-90 суток), а не текущее число. domklik заблокирован гейтом здоровья +-- (confirmations 94 < min_confirmations 200) -- до потолка/пола дело не доходит. +-- +-- ПОЧЕМУ 6. Потолок 60 = 10 * 6 -- тот же порядок, что у cian (60, дефолт cap_mult=2), +-- с запасом выше и статического p99=42.1 (_REVISIT_TAIL, tests/test_deactivate_stale_revisit_floor.py), +-- и живого прод-пика: floor=52 три прогона подряд 2026-08-10..08-12 +-- (scrape_runs.counters, status=done, confirmations 6934..7138, гейт здоровья +-- пропустил). Без этой калибровки в проде остаётся дефолт cap_mult=2 (потолок 20) +-- -- именно тот случай, для которого потолок и его собственная калибровочная ручка +-- заведены, но не применены к единственному источнику, ради которого ручка сделана. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 219 (тот же +-- приём -- UPDATE default_params через jsonb ?, min_confirmations). +-- ТОЛЬКО данные (UPDATE default_params), DDL нет. +-- Идемпотентность + уважение к ручной настройке: ключ проставляется лишь там, где +-- его ещё нет, поэтому повторный прогон файла не затирает подкрученное оператором +-- значение. Снять/поднять потолок вручную: cap_mult в default_params +-- (deactivate_stale_avito), 1 -> потолок = сам ttl_days (см. guard cap_mult < 1 +-- в deactivate_stale_listings -- ниже 1 отклоняется до любого SQL). + +BEGIN; + +UPDATE scrape_schedules +SET default_params = default_params || jsonb_build_object('cap_mult', 6), + updated_at = NOW() +WHERE source = 'deactivate_stale_avito' + AND NOT default_params ? 'cap_mult'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/265_deactivate_stale_yandex_cap_mult.sql b/tradein-mvp/backend/data/sql/265_deactivate_stale_yandex_cap_mult.sql new file mode 100644 index 00000000..7fa1acb6 --- /dev/null +++ b/tradein-mvp/backend/data/sql/265_deactivate_stale_yandex_cap_mult.sql @@ -0,0 +1,57 @@ +-- 265_deactivate_stale_yandex_cap_mult.sql +-- Калибрует потолок эффективного TTL (cap_mult) для yandex (#TTL-CAP круг 3, 2026-08-15). +-- +-- ЗАЧЕМ. Та же дыра, что закрыта для avito миграцией +-- 264_deactivate_stale_avito_cap_mult.sql (см. её комментарий про механизм петли), +-- но обнаружена на yandex позже: первая версия 264 утверждала, что дефолтный +-- CAP_MULT=2 (потолок 60 при ttl_days=30) для yandex "калиброван верно" на +-- основании статического p99=43.0 (_REVISIT_TAIL, замер на более раннюю дату). +-- +-- ЖИВОЙ ЗАМЕР, из-за которого миграция существует. scrape_runs.counters +-- (deactivate_stale_yandex, 2026-08-10..08-15) держал ttl_days_effective 75/75/75/ +-- 39/52/54 шесть прогонов подряд при deactivated=0 -- то есть пол ВСЕ ЭТИ ДНИ был +-- выше потолка 60. Прямой live-замер той же percentile_disc(0.99)-формулы, что и в +-- коде (app/tasks/deactivate_stale_avito.py, _build_revisit_floor_sql), 2026-08-15 +-- даёт 79.2 суток (n=1961 подтверждений за окно 3 суток). Оба замера выше потолка +-- 60 -- ровно тот false-kill, ради которого пол #2659 вообще заведён: без калибровки +-- потолок капал бы ttl_days_effective yandex до 60 в counters уже сегодня и резал бы +-- ниже собственного хвоста обхода, как только в пуле появятся строки возрастом +-- 60-90 суток (сейчас таких 0 -- см. ЧИСЛЕННЫЙ ЭФФЕКТ ниже). +-- +-- ПОЧЕМУ 3. Потолок 90 = 30 * 3 -- запас ~14% над живым пиком 79.2, той же +-- пропорции, что и у avito (потолок 60 против пика 52 -- запас ~15%, см. 264). +-- Меньший cap_mult=2 (потолок 60) уже сейчас ниже пика 79.2. Больший cap_mult +-- намеренно не берём -- дальнейший рост пола означает не "медленный, но живой +-- обход", а кандидата в mёртвый источник, для которого есть отдельный гейт +-- здоровья (min_confirmations), а не растягивание потолка до бесконечности (см. +-- комментарий у CAP_MULT в deactivate_stale_avito.py). +-- +-- ЧИСЛЕННЫЙ ЭФФЕКТ (live-замер 2026-08-15): 0 активных строк yandex/vtorichka +-- старше 39 суток вообще (запрос: count(*) FROM listings WHERE source='yandex' AND +-- listing_segment='vtorichka' AND is_active=true AND last_seen_at < NOW() - +-- INTERVAL 'N days', N=39/52/54/60/75/79 -- везде 0). Next-run deactivated=0 что +-- при дефолтном cap_mult=2 (потолок 60, капает пол), что при cap_mult=3 из этой +-- миграции (потолок 90, не капает) -- эта миграция убирает БУДУЩИЙ риск +-- false-kill при появлении строк в полосе 60-90 суток, а не текущее число +-- деактиваций. Ветка #TTL-CAP не сжимает пул ни у одного из четырёх источников -- +-- см. 264 для остальных трёх. +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)), 219 (тот же +-- приём -- UPDATE default_params через jsonb ?, min_confirmations), 264 (тот же +-- приём для avito, cap_mult -- параметр deactivate_stale_listings). +-- ТОЛЬКО данные (UPDATE default_params), DDL нет. +-- Идемпотентность + уважение к ручной настройке: ключ проставляется лишь там, где +-- его ещё нет, поэтому повторный прогон файла не затирает подкрученное оператором +-- значение. Снять/поднять потолок вручную: cap_mult в default_params +-- (deactivate_stale_yandex), 1 -> потолок = сам ttl_days (см. guard cap_mult < 1 +-- в deactivate_stale_listings -- ниже 1 отклоняется до любого SQL). + +BEGIN; + +UPDATE scrape_schedules +SET default_params = default_params || jsonb_build_object('cap_mult', 3), + updated_at = NOW() +WHERE source = 'deactivate_stale_yandex' + AND NOT default_params ? 'cap_mult'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/266_seed_deactivate_stale_null_segment_yandex_cian.sql b/tradein-mvp/backend/data/sql/266_seed_deactivate_stale_null_segment_yandex_cian.sql new file mode 100644 index 00000000..63851298 --- /dev/null +++ b/tradein-mvp/backend/data/sql/266_seed_deactivate_stale_null_segment_yandex_cian.sql @@ -0,0 +1,109 @@ +-- 266_seed_deactivate_stale_null_segment_yandex_cian.sql +-- Деактивация протухших yandex/cian объявлений с ПУСТЫМ listing_segment. +-- +-- Замер на проде 2026-08-15 (is_active=true, listing_segment IS NULL): +-- source | активных | старше 30 сут | макс возраст +-- yandex | 544 | 533 | 86.3 сут +-- cian | 224 | 211 | 86.3 сут +-- 97% / 94% этих строк протухли, вплоть до 86 суток. При этом estimator их +-- ИСПОЛЬЗУЕТ как comps без freshness-фильтра (Tier A "тот же дом" / Tier C +-- micro-radius в app/services/estimator.py фильтруют только is_active=true, +-- без scraped_at-фильтра свежести — в отличие от Tier S/H, у которых он есть). +-- +-- ПОЧЕМУ NULL, А НЕ ANY(:segments). deactivate_stale_yandex / deactivate_stale_cian +-- (миграция 115) уже деактивируют segments=['vtorichka'] — пустой сегмент они НЕ видят: +-- `listing_segment = ANY(CAST(:segments AS text[]))` в SQL никогда не матчит NULL +-- (задокументировано в 115 у novostroyki-гарда). Нужен отдельный явный предикат +-- IS NULL — app/tasks/deactivate_stale_avito.py получил kwarg null_segment_only=True, +-- строящий `... AND listing_segment IS NULL` вместо ANY(:segments). +-- +-- ПОЧЕМУ ОТДЕЛЬНАЯ ДЖОБА, А НЕ РАСШИРЕНИЕ deactivate_stale_yandex/_cian. Гейт +-- здоровья сбора (#2659, migration 219) и пол переобхода (#2659) откалиброваны под +-- полноценный vtorichka-свип (сотни-тысячи подтверждений в сутки, см. 219). У +-- NULL-сегмента подтверждений на 2-3 порядка меньше (замер того же дня: 9 cian + +-- 4 yandex строк с last_seen_at < 7 суток) — с общим min_confirmations джоба +-- вечно давала бы skipped_unhealthy и никогда не деактивировала бы ни строки. +-- Отдельная джоба с собственными (выключенными) порогами не трогает работающие +-- deactivate_stale_yandex/_cian и их пол/гейт. +-- +-- НЕ ЗАТРАГИВАЕТ novostroyki: null_segment_only-предикат — строго `IS NULL`, ни +-- 'novostroyki', ни 'vtorichka' в него не попадают ни при каких условиях (в отличие +-- от паушального TTL по всему source, который снёс бы все ~22,5к первичных строк). +-- +-- TTL=60 суток — консервативный, обоснование числом: +-- Строки этого среза по определению не переобходятся систематически (иначе у них +-- был бы сегмент — свежий обход cian/yandex SERP всегда вычисляет listing_segment +-- детерминированно, см. providers/cian/serp.py:955-958, providers/yandex/serp.py:177). +-- Значит «пол переобхода» (revisit_floor, #2659) здесь измерять нечем: он квантиль +-- разрывов НАБЛЮДАЕМОГО повторного обхода, а для строки вне скоупа обхода такого +-- ряда нет — вычислять его было бы фикцией. Поэтому revisit_floor_quantile=0 явно +-- (выключен), а весь запас закладываем в сам TTL: +-- deactivate_stale_avito.py документирует измеренные p99 разрывов переобхода +-- vtorichka (тот же тип строк, тот же source, разница только в сегменте): +-- cian/vtorichka p99 = 26.6 сут +-- yandex/vtorichka p99 = 43.0 сут +-- TTL=60 даёт запас 2.26x над cian p99 и 1.4x над yandex p99 — комфортный отступ +-- без специального замера под null-сегмент (население слишком мало для устойчивого +-- перцентиля). При этом бимодальность выборки (замер 2026-08-15: gt30d/gt45d/gt60d +-- почти не меняются — 211/211/211 cian, 533/525/523 yandex) означает, что более +-- консервативный TTL стоит ПОЧТИ НИЧЕГО в охвате: первый прогон снимет 734 из 768 +-- строк (95.6%) вместо 744 при TTL=30 — разница 10 строк, зато вдвое больший +-- защитный запас над измеренным хвостом обхода. +-- +-- min_confirmations=0, revisit_floor_quantile=0 — оба гейта ВЫКЛЮЧЕНЫ явно (не через +-- умолчание product_handlers.py, которое иначе подставило бы DEFAULT_MIN_CONFIRMATIONS +-- = 500 и DEFAULT_REVISIT_FLOOR_QUANTILE = 0.99 — оба откалиброваны под другую шкалу +-- популяции и держали бы эту джобу в вечном skipped_unhealthy, см. выше). +-- +-- Schedule window 07:00-08:00 UTC — тот же слот, что и deactivate_stale_yandex/_cian +-- (migration 115) и deactivate_stale_domklik/_n1 (migration 160): после ночных sweep'ов +-- (02:00-05:00 UTC), так что реально переобойдённые строки не деактивируются. +-- +-- next_run_at bootstrapped на завтра 07:00 UTC — тот же паттерн, что 090/115/160, +-- чтобы не сработать сразу на деплое. +-- +-- Идемпотентно: ON CONFLICT (source) DO NOTHING — безопасно при повторном применении. +-- +-- Dependencies: +-- 052_scrape_schedules.sql (таблица + UNIQUE(source)). +-- listings.listing_segment (011_listings_alter.sql). +-- 115_scrape_schedules_seed_deactivate_stale_yandex_cian.sql (соседние джобы, тот же слот). +-- app/tasks/deactivate_stale_avito.py — null_segment_only kwarg. +-- app/services/product_handlers.py — _job_deactivate_stale читает null_segment_only +-- из default_params и пробрасывает в deactivate_stale_listings. +-- +-- Deploy order: применять ПОСЛЕ деплоя backend-кода (null_segment_only kwarg), иначе +-- первый прогон свалится с TypeError на неизвестный параметр default_params. + +BEGIN; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +( + 'deactivate_stale_yandex_null_segment', + true, -- SAFE: pure internal DB UPDATE, no ext calls + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"listing_source":"yandex","ttl_days":60,"null_segment_only":true,' + '"min_confirmations":0,"revisit_floor_quantile":0}'::jsonb +), +( + 'deactivate_stale_cian_null_segment', + true, -- SAFE: pure internal DB UPDATE, no ext calls + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"listing_source":"cian","ttl_days":60,"null_segment_only":true,' + '"min_confirmations":0,"revisit_floor_quantile":0}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 7d8d5a0f..276864a0 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,67 @@ 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 — условие выполнено. +# +# 217-232 сюда намеренно не дописаны этой миграцией (222/225): в момент +# правки они уже слиты в main и применены на проде (см. _schema_migrations), +# но их авторы не дописали имена в тот же PR — это чужой пробел, не наш; +# self-maintenance-контракт (см. докстринг test_migrations_manifest.py) +# требует дописывать только СВОЙ файл в СВОЁМ PR, что и сделано ниже для +# 222/225 по прецеденту 233_payments.sql. +222_db_audit_cleanup.sql +225_listing_source_snapshots_run_id_idx.sql +233_payments.sql +234_scrape_runs_ban_kind_unknown.sql +240_trade_in_estimates_retain_until.sql +250_drop_duplicate_expires_at_index.sql +251_listings_drop_ceiling_height.sql +254_listings_backfill_avito_rating_glued_address.sql +257_listings_backfill_yandex_source_url.sql +258_houses_imv_transient_attempts.sql +259_data_quality_drop_pct_cadastr.sql +260_houses_drop_has_panorama.sql +261_listings_search_mv_drop_placeholder_columns.sql +262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql +263_scrape_schedules_wave2_cian_newbuilding_only_false.sql +264_deactivate_stale_avito_cap_mult.sql +265_deactivate_stale_yandex_cap_mult.sql +266_seed_deactivate_stale_null_segment_yandex_cian.sql 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 c9129029..02b64a9f 100644 --- a/tradein-mvp/backend/tests/conftest.py +++ b/tradein-mvp/backend/tests/conftest.py @@ -1,14 +1,18 @@ """Repo-wide test config for tradein-mvp/backend. -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). Also resets cross-test-file shared -rate-limiter state (see fixture docstring below). +Регистрирует кастомные pytest-маркеры (иначе PytestUnknownMarkWarning: +`--strict-markers` в pyproject.toml не включён, так что незарегистрированный +маркер только предупреждал бы) и сторожит глобальное состояние, которое +переживает отдельный тест: общий rate-limiter POST /estimate (см. +`_reset_estimate_rate_limiter`) и слоты проверки пароля (см. +`_no_leaked_password_verify_slots`). """ from __future__ import annotations +import sys +from pathlib import Path + import pytest @@ -42,3 +46,107 @@ def _reset_estimate_rate_limiter() -> None: trade_in_module._estimate_limiter = SlidingWindowLimiter( limit=settings.estimate_rate_limit, window_s=settings.estimate_rate_limit_window_s ) + + +@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 всем на входе" + ) + + +# ── Бюджет пропусков (#2745) ────────────────────────────────────────────────── +# +# Три инцидента за одни сутки (#2722, #2729, #2740) — один класс дефекта: +# проверка, которая тихо не исполняется, со временем перестаёт быть верной, и +# узнают об этом ровно тогда, когда на неё понадобилось опереться. `-rs` в CI +# делает пропуск ГРОМКИМ; этот хук делает его УЧТЁННЫМ — пропущено может быть +# только то, что записано в `tests/skip_allowlist.txt`. Любой новый пропуск (в +# том числе возникший сам: отвалилась зависимость, исчезла БД, кто-то дописал +# skipif «пока починю») роняет прогон, пока его не внесут в список осознанно. +# +# Список — НАДмножество: покрывает и CI (где есть Postgres и native-libs), и +# ноутбук разработчика (где их нет). Запись, чей пропуск не случился, — не +# ошибка; ошибка — пропуск без записи. +# +# Сюда же попадает xfail: pytest рапортует его как skipped, поэтому xfail без +# `strict=True` (зеленеет и когда тест внезапно ПРОШЁЛ) тоже придётся объявить. +# +# Чего хук НЕ видит: `--deselect` / `-k` / `-m` — они убирают тест до прогона, +# отчёта о пропуске не возникает вовсе. Против них работает правило «никаких +# deselect'ов в workflow» (см. .forgejo/workflows/ci-tradein.yml). + +_SKIP_ALLOWLIST_PATH = Path(__file__).parent / "skip_allowlist.txt" +_observed_skips: set[str] = set() + + +def _allowed_skips() -> set[str]: + if not _SKIP_ALLOWLIST_PATH.exists(): + return set() + lines = _SKIP_ALLOWLIST_PATH.read_text(encoding="utf-8").splitlines() + return {stripped for line in lines if (stripped := line.split("#", 1)[0].strip())} + + +def _record_skip(report) -> None: + if report.skipped: + # nodeid без «[параметров]»: список фиксирует, какая ПРОВЕРКА может + # отсутствовать, а не каждую её параметризацию. + _observed_skips.add(report.nodeid.split("[", 1)[0]) + + +def pytest_runtest_logreport(report) -> None: + _record_skip(report) + + +def pytest_collectreport(report) -> None: + # Модульный pytest.skip(allow_module_level=True) приходит сюда, а не в + # logreport, и nodeid у него — путь файла. + _record_skip(report) + + +def pytest_sessionfinish(session, exitstatus) -> None: + unlisted = sorted(_observed_skips - _allowed_skips()) + if not unlisted: + return + print( + f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не " + f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:" + ) + for nodeid in unlisted: + print(f" - {nodeid}") + print( + "Почини тест либо внеси его в skip_allowlist.txt с причиной — " + "пропуск без записи неотличим от пройденной проверки." + ) + if exitstatus == 0: + session.exitstatus = 1 diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index 55fe9bb3..b11e827e 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": 12.67, "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": 12.63, + "median_bias_pct": -3.74, + "n": 269, "n_no_analogs": 0, - "p25_pct": -15.49, - "p75_pct": 10.07 + "p25_pct": -15.17, + "p75_pct": 8.67 }, "per_rooms": { "0": { "label": "студия", - "mape_pct": 18.1, + "mape_pct": 16.96, "median_bias_pct": 16.96, - "n": 37, + "n": 35, "n_no_analogs": 0, - "p25_pct": 1.37, - "p75_pct": 33.53 + "p25_pct": 1.5, + "p75_pct": 38.82 }, "1": { "label": "1к", @@ -50,50 +50,50 @@ "n": 93, "n_no_analogs": 0, "p25_pct": -14.43, - "p75_pct": 6.98 + "p75_pct": 8.15 }, "2": { "label": "2к", - "mape_pct": 18.26, - "median_bias_pct": -12.2, + "mape_pct": 17.39, + "median_bias_pct": -11.71, "n": 74, "n_no_analogs": 0, - "p25_pct": -24.35, + "p25_pct": -23.07, "p75_pct": -0.36 }, "3": { "label": "3к", - "mape_pct": 9.34, - "median_bias_pct": -3.08, + "mape_pct": 7.79, + "median_bias_pct": -4.05, "n": 43, "n_no_analogs": 0, "p25_pct": -10.26, - "p75_pct": 4.68 + "p75_pct": 3.82 }, "4": { "label": "4+", - "mape_pct": 20.27, - "median_bias_pct": 8.54, - "n": 30, + "mape_pct": 16.38, + "median_bias_pct": -1.32, + "n": 24, "n_no_analogs": 0, - "p25_pct": -6.86, - "p75_pct": 23.54 + "p25_pct": -14.91, + "p75_pct": 15.41 } }, "per_segment": { "бизнес": { - "mape_pct": 14.65, + "mape_pct": 13.65, "median_bias_pct": -10.54, "n": 46, - "p25_pct": -22.93, + "p25_pct": -27.15, "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": 10.14, + "median_bias_pct": -5.01, + "n": 101, + "p25_pct": -15.49, + "p75_pct": 4.37 }, "премиум": { "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": 14.2, + "median_bias_pct": 3.33, + "n": 115, + "p25_pct": -8.69, + "p75_pct": 27.3 }, "элит": { "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/cian_waf_block_zhk_page.html b/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html new file mode 100644 index 00000000..3fd69b35 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/cian_waf_block_zhk_page.html @@ -0,0 +1 @@ +Ошибка - ЦианISIDPlhwqgqcowpM8uZOWWzawmWX3JbKNOWV5y8jbBk1QTMxXSbNTCYzv2eY56%2BzgvWctsm1nNNBInjiTIE4wTKyIXqNmtYlOzmhaTK60Tn8LgFw465ooSTZRlZbWNAVRqsSkSbwX3cLXr%2BXama%2BvH5PpteyqjVoMEnZZ9fy7U9Mq6lNhNqPwTg6YASdghUaijPGTLJGRj1xv2zyp3nGfDDlmBEufGFsQSmWpaquZAFvWbMSIVagrgmWV%2FpwZY%2B05W7zk%2B48X94xHfjeo42qibiytt3w1NTImtWX6RAUPZOEWRMJX%2FDAhDnp6PpcNPWJMlIzO54LIEgTLTbPMeT8Zcz8bbz8bbz8bbz8bcxqGIlysW6SCSEHnI3OmWtyI1qNpU%2BaVl4fL4yPXGUHMlzpFO8g7x3nL5nBgMCqbNNM489eVZ8CQV07DTtFVFNVZlZbprTPdHkXK8dpDGUHO2YoUriDzZ%2FPcifCVNlgCap0b6RzwJbvsziCR09wO2bk%2B2Fh0BtH1djPV2M9XYz1djPV2MtsmyQM5f7xfaMjZdAbCdhzBtLjB7V9hsvkNjwjKUT8C9Zw3rBwyJ6wsNWNoze1g2nkHtUNoereRt7INYvs9XYwnf%2FAM6pU5R2c0UDE8gAHwP%2FAIr%2FxAA4EQABAwEFBAgFAgcBAAAAAAABAAIRECExQVGRAxIgYQQiUHGBobHRMDKSwdJA8BMjM0JgguHx%2F9oACAEDAQk%2FAPgbQSMJtW01sTgRy7OMNF5z5BWoJxgYK%2FEdmXus1oN5y2e6cx%2F2aXO7MuF6vCvx76f22I%2FK5vjJ7MxCwVrcimhs440F8Hs0dceYqLMEI7OHeEOqELB2g2Ozf%2FV8hN2QlGQUBC3UIPDu7qIgRhwRu4LdjQ13Ykwt0jzrgmhrcAb%2FABujuW7GivoC52QtPeYuHNbuyH1fiulR3NP5KH8gI%2B5Tf4buZ9wKxHNbqvqBGMrdKEOx4jACsYLh9zQ9QrIff4vOuayqOoNZ9qZ0vRlxvKEqE2EIcLjzXzM%2BDnw3L%2BmDfmeAzFnxedc1lQwSChAGFM6P3T3LbDQo2Zoq0H1WC%2BY%2FBz4f9jlyq0gH4%2FOuayV6Mk%2FuPBCwIRLQVnQwBem7ozdf4DEeK6S7ws9Cukv%2Bo%2B66Q8xhvO96Z1dDsDetsNFtm6LbN0Tt5wvMRTPgM7S7uqIi6ULCjIFLyhB4mzPC2RbwDqAWnxQIGHMZoWBXDzRuaAhNqvRkNN2EhCSiGhbb96LbQB97BhmihZQyBI0KEpiYmIQUEIqf5jssOavNBZhKuoIeL7L62u9RwCQaGokGhoJFLWnDgzKNGx3Zmhmgkug2YQZBPIkRQ2UAA5c6PKedU86oyeC%2BLFa5xv8Aag6oVgHBdiOeYpejaQOHaBhzIn1BXSR9I%2FFdJH0j8V0mQCLA0W%2BQptA05kT6grpE8g0ew4L65mtxV62IIzk%2ByY1nMkzpulHecbzn7dws4OXHlUWLrNOcSPRCLOESCpJm0EjyuQ63C2R3hM9EzzCZ5hM8wmeiEFBYpkjvCZ5j3QgCo3gbcL1s9SPdC1XH9yjvDIkSPRMKYVbOATZaeYTIH21oJB7ls%2FT3Qg1E6LZye8e6EH9C4hPOpTzqU86lPOpTzqUZKyo4jxTifHgcQnnWpTynnWjiE4mjiE86ngMJ51P6hknkmGf8M%2F%2FZ" alt="logo">

Обнаружен подозрительный трафик

Просмотрите возможные причины ошибки по ссылке ниже. Если ошибка повторяется, напишите в службу поддержки или на почту support@cian.ru.

Укажите в письме эти данные:

  • IP-адрес: 46.8.110.92
  • ID запроса: 9f4b43ee-bd7e-4b91-acde-639dac97a366
  • Код страницы: cian_waf_block
" +) + + +@pytest.mark.asyncio +async def test_waf_block_is_reported_as_a_ban_of_the_node(monkeypatch, waf_html, caplog) -> None: + """Блок → `report_ban`, пока lease ещё жив. + + Без этого пул продолжает выдавать сожжённый узел, и следующий дом идёт с того же + IP — ровно то, что происходило 200 попыток подряд. + """ + fetcher = _spy_fetcher(waf_html) + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + MagicMock(return_value=fetcher), + ) + + with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"): + result = await fetch_newbuilding( + "https://zhk-pihtovyy-ekb-i.cian.ru", + config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), + ) + + assert result is None + fetcher.report_ban.assert_called_once() + assert "cian_waf_block" in fetcher.report_ban.call_args.args[0] + # Бан репортится ДО выхода из контекста — иначе lease уже отпущен и вызов no-op. + assert fetcher.__aexit__.await_count == 1 + assert "cian_waf_block" in "\n".join(r.getMessage() for r in caplog.records) + + +@pytest.mark.asyncio +async def test_parsed_page_never_bans_the_node_even_with_captcha_word(monkeypatch) -> None: + """Разобранная страница НЕ банит узел, даже если на ней написано «captcha». + + Живая регрессия 2026-08-09: первая версия этой правки звала `_blocked_by` на КАЖДОМ + ответе, и за первые 17 секунд прогона забанила два ЗДОРОВЫХ узла (9 и 10) на + страницах, которые успешно разобрались — обычная карточка ЖК грузит скрипт + SmartCaptcha, подстрока там есть всегда. Признак «есть подпись защиты» осмыслен + только вместе с «состояния нет»: список маркеров создавался объяснять УЖЕ + случившийся отказ разбора, а не служить самостоятельным детектором. + """ + fetcher = _spy_fetcher(_HEALTHY_CARD_WITH_CAPTCHA_SCRIPT) + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + MagicMock(return_value=fetcher), + ) + + result = await fetch_newbuilding( + "https://zhk-pihtovyy-ekb-i.cian.ru", + config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), + ) + + # Предпосылка теста: страница действительно и разбирается, и содержит слово-ловушку. + assert result is not None + assert result.cian_internal_house_id == 108855 + assert "captcha" in _HEALTHY_CARD_WITH_CAPTCHA_SCRIPT + fetcher.report_ban.assert_not_called() + + +@pytest.mark.asyncio +async def test_proxy_provider_reaches_the_fetcher_factory(monkeypatch, waf_html) -> None: + """Провод целиком: provider из задачи → build_browser_fetcher → пул. + + Красный на старом коде дважды: аргумента `proxy_provider` там не было, и фабрика + для этого провайдера не вызывалась вовсе (BrowserFetcher конструировался вручную, + без пула). + """ + factory = MagicMock(return_value=_spy_fetcher(waf_html)) + monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.build_browser_fetcher", factory) + provider = object() + config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000") + + await fetch_newbuilding( + "https://zhk-pihtovyy-ekb-i.cian.ru", config=config, proxy_provider=provider + ) + + factory.assert_called_once_with(config, "cian", proxy_provider=provider) + + +# ── новый фронт Циана: тот же ключ под другим именем MFE ────────────────────── + +# Форма живой страницы zhk-kosmos-ekb-i.cian.ru (2026-08-09): состояние лежит под +# 'newbuilding-card-desktop-fichering-frontend', СТАРОГО имени на странице нет вовсе. +_FICHERING_CARD = ( + "" +) + + +@pytest.mark.asyncio +async def test_state_is_found_under_the_renamed_fichering_mfe(monkeypatch) -> None: + """ЖК на новом фронте Циана разбирается — вместе с графиком цен. + + Именно из-за этого имени `houses_price_dynamics` стояла с 2026-07-26: страница + приходила целиком, состояние в ней было, но под другим MFE — и отказ выглядел как + «разметка изменилась», хотя форма состояния та же. + """ + fetcher = _spy_fetcher(_FICHERING_CARD) + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + MagicMock(return_value=fetcher), + ) + + result = await fetch_newbuilding( + "https://zhk-kosmos-ekb-i.cian.ru", + config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), + ) + + assert result is not None + assert result.cian_internal_house_id == 3235089 + assert result.name == "Космос" + # График — тот самый, ради которого задача и существует. + assert [p["month_date"] for p in result.realty_valuation_chart] == [ + "2026-06-01", + "2026-07-01", + ] + assert [p["price_per_sqm"] for p in result.realty_valuation_chart] == [4100000.0, 4250000.0] + fetcher.report_ban.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py new file mode 100644 index 00000000..fea61b82 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py @@ -0,0 +1,200 @@ +"""#2767 — сделать причину отказа newbuilding_enrich видимой. + +Восемь суток подряд прогон делал 25 попыток, обогащал ноль записей и закрывался +статусом 'done'. Различить «страница пришла целиком, изменилась разметка» и +«пришла защитная заглушка» было нельзя: в логе не было ни размера ответа, ни +признаков защиты, а статус утверждал успех. + +Две проверки, обе падают на коде до правки: + 1. строка отказа разбора несёт размер полученной страницы и найденные маркеры; + 2. прогон с ненулевыми попытками и нулём обогащений финализируется через общий + runs.mark_backfill_finished (#2695), а не через mark_done. + +Ни сети, ни БД: BrowserFetcher подменён спаем, финализаторы — заглушками. +""" + +from __future__ import annotations + +import logging +import os +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +# DATABASE_URL required by config before any app import. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint stub — not installed in CI without GTK. +sys.modules.setdefault("weasyprint", MagicMock()) + +import pytest # noqa: E402 +from scraper_kit.providers.cian.newbuilding import ( # noqa: E402 + _describe_parse_miss, + fetch_newbuilding, +) + +from app.tasks import newbuilding_enrich_backfill as task_mod # noqa: E402 +from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402 + NewbuildingEnrichBackfillResult, + run_newbuilding_enrich, +) + +# --------------------------------------------------------------------------- +# 1. Размер страницы + маркеры защиты в строке отказа разбора. +# --------------------------------------------------------------------------- + + +def test_describe_parse_miss_reports_size_and_no_markers() -> None: + """Целая страница без маркеров: размер есть, маркеров нет → «изменилась разметка».""" + html = "" + "x" * 1_170_000 + "" + described = _describe_parse_miss(html) + assert f"html_len={len(html)}" in described + assert "antibot_markers=none" in described + + +def test_describe_parse_miss_names_found_markers() -> None: + """Стена капчи: маркер назван, но диагноз не выносится — только перечисление найденного. + + Опознаётся по заголовку «Вы не робот?», а НЕ по подстроке "captcha": последняя есть + на любой здоровой карточке ЖК (скрипт SmartCaptcha) и была снята из списка после + того, как забанила здоровые узлы (#2767). + """ + described = _describe_parse_miss("
Вы не робот?
") + assert "вы не робот" in described + assert "html_len=" in described + + +def _spy_browser_fetcher(html: str) -> MagicMock: + """Подмена фабрики фетчера: async-CM, чей .fetch() отдаёт заданный html.""" + fetcher = MagicMock() + fetcher.fetch = AsyncMock(return_value=html) + fetcher.__aenter__ = AsyncMock(return_value=fetcher) + fetcher.__aexit__ = AsyncMock(return_value=None) + return MagicMock(return_value=fetcher) + + +@pytest.mark.asyncio +async def test_fetch_newbuilding_logs_page_size_on_parse_miss(monkeypatch, caplog) -> None: + """Отказ разбора обязан писать РАЗМЕР полученной страницы. + + Размер один гипотезы уже НЕ разделяет (страница блока Циана весит 374 КБ — см. + test_2767_cian_waf_block.py), но остаётся обязательным фактом в строке отказа. + """ + shell = "Доступ ограничен" # короткая заглушка + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", + _spy_browser_fetcher(shell), + ) + + with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"): + result = await fetch_newbuilding( + "https://zhk-tihiy-centr-ekb-i.cian.ru", + config=SimpleNamespace( + browser_http_endpoint="http://tradein-browser:3000", + use_proxy_pool_browser=False, + ), + ) + + assert result is None + message = "\n".join(r.getMessage() for r in caplog.records) + assert "initialState extraction failed" in message + assert f"html_len={len(shell)}" in message + assert "доступ ограничен" in message + + +# --------------------------------------------------------------------------- +# 2. Честный статус: ноль обогащений при ненулевых попытках ≠ 'done'. +# --------------------------------------------------------------------------- + + +def _stub_finalisers(monkeypatch: pytest.MonkeyPatch) -> dict: + """Заглушить lifecycle-функции; вернуть журнал вызовов финализаторов.""" + calls: dict = {} + monkeypatch.setattr(task_mod.runs_mod, "update_heartbeat", lambda *a, **k: None) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_done", + lambda _db, run_id, counters: calls.setdefault("mark_done", dict(counters)), + ) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_failed", + lambda _db, run_id, err, counters: calls.setdefault("mark_failed", err), + ) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_backfill_finished", + lambda _db, run_id, counters, **kw: calls.setdefault( + "finished", {"counters": dict(counters), **kw} + ), + ) + return calls + + +def _stub_backfill( + monkeypatch: pytest.MonkeyPatch, result: NewbuildingEnrichBackfillResult +) -> None: + async def _fake(_db, **_kwargs): + return result + + monkeypatch.setattr(task_mod, "backfill_newbuilding_enrichment", _fake) + + +@pytest.mark.asyncio +async def test_zero_enrichment_run_goes_through_shared_finaliser(monkeypatch) -> None: + """Прод-картина: 25 попыток, 0 успехов, 25 отказов fetch. Финал — общий + mark_backfill_finished (который такой прогон помечает 'failed'), НЕ mark_done.""" + _stub_backfill( + monkeypatch, + NewbuildingEnrichBackfillResult(processed=25, succeeded=0, failed_fetch=25), + ) + calls = _stub_finalisers(monkeypatch) + + await run_newbuilding_enrich(object(), run_id=42, params={}) # type: ignore[arg-type] + + assert "mark_done" not in calls # ← падает на старом коде + finished = calls["finished"] + assert finished["source"] == "newbuilding_enrich" + assert finished["counters"]["attempted"] == 25 + assert finished["counters"]["enriched"] == 0 + assert finished["counters"]["failed"] == 25 + # Диагноза «виновата площадка» здесь нет: блоки не считаются вовсе (#2764). + assert finished["counters"].get("blocked") is None + assert "fetch" in finished["fail_hint"] + + +@pytest.mark.asyncio +async def test_all_skipped_run_reports_zero_attempts(monkeypatch) -> None: + """Все выбранные дома уже обогащены → attempted=0: честная пустота, а не отказ + (общий финализатор такой прогон закрывает 'done').""" + _stub_backfill( + monkeypatch, + NewbuildingEnrichBackfillResult(processed=25, skipped_already_enriched=25), + ) + calls = _stub_finalisers(monkeypatch) + + await run_newbuilding_enrich(object(), run_id=43, params={}) # type: ignore[arg-type] + + assert calls["finished"]["counters"]["attempted"] == 0 + assert calls["finished"]["fail_hint"] is None + + +@pytest.mark.asyncio +async def test_partial_success_keeps_rich_counters(monkeypatch) -> None: + """Ключи финализатора ДОБАВЛЯЮТСЯ к своим счётчикам задачи, а не заменяют их.""" + _stub_backfill( + monkeypatch, + NewbuildingEnrichBackfillResult( + processed=10, succeeded=3, failed_fetch=6, failed_resolve=1, price_dynamics_inserted=7 + ), + ) + calls = _stub_finalisers(monkeypatch) + + await run_newbuilding_enrich(object(), run_id=44, params={}) # type: ignore[arg-type] + + counters = calls["finished"]["counters"] + assert counters["attempted"] == 10 + assert counters["enriched"] == 3 + assert counters["failed"] == 7 + assert counters["price_dynamics_inserted"] == 7 # исходные счётчики на месте + assert counters["succeeded"] == 3 diff --git a/tradein-mvp/backend/tests/test_2800_per_source_probe.py b/tradein-mvp/backend/tests/test_2800_per_source_probe.py new file mode 100644 index 00000000..7fa59d45 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2800_per_source_probe.py @@ -0,0 +1,541 @@ +"""#2800 — браузерная проба спрашивает КАЖДУЮ площадку, вердикт пишется на ПАРУ. + +Продолжение #2723 на другой оси. Там проба ходила не тем транспортом; здесь — +верным транспортом, но всегда на один адрес (robots.txt Авито), поэтому её зелёный +ответ означал «узел годен для Авито», а читался как «узел годен вообще». + +Замер на проде 09.08.2026 (тот же тракт: сайдкар → camoufox с этим прокси → навигация): + + узел | affinity | avito | ekb.cian.ru | realty.ya.ru | bff-search-web.domclick.ru + -----+----------+-------+---------------------+--------------+--------------------------- + 1 | domclick | 200 | 200 «Ошибка — Циан» | 200 | 500 NS_ERROR_PROXY_BAD_GATEWAY + 10 | any | 200 | 200 | 200 | 200 + +Что сторожится (каждый тест ниже падает на коде до фикса): + + 1. ГЛАВНОЕ: узел, зелёный по Авито и мёртвый по Домклику, ОТБРАКОВЫВАЕТСЯ для + Домклика и остаётся первосортным для Авито. До фикса `acquire('domclick')` + выдавал его как ни в чём не бывало. + 2. Адрес пробы — рабочий хост КАЖДОЙ площадки, а не один зашитый и не apex-домен + (`domclick.ru` через тот же узел отвечает 200 — проба по нему была бы зелёной). + 3. HTTP 200 с заглушкой вместо robots.txt — это отказ пары, а не успех. + 4. Успешная проба снимает ТОЛЬКО свою строку бана: robots.txt площадка отдаёт и + забаненному IP, и гасить им вердикт живого сбора нельзя (та же ошибка, что + «дешёвая ipify стирает браузерный вердикт» в #2723). + 5. Отказ ОДНОЙ площадки не пятнает узел глобально; отказ ВСЕХ — пятнает (узловой + вердикт #2723 сохранён). + 6. Лежащий сайдкар не пишет ни одного бана пары (#2686-класс). + 7. Цена такта названа числом и закреплена: узлов × обслуживаемых источников. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from datetime import UTC, datetime, timedelta +from typing import Any + +import pytest +import scraper_kit.browser_fetcher as bf + +from app.services import proxy_pool +from app.services.proxy_pool import BROWSER_UNFIT_THRESHOLD, acquire, release +from tests.services.test_proxy_pool import FakeSession, _proxy + +# Живой замер с прода 09.08.2026 — узел h1 мёртв для Домклика и годен остальным. +_LIVE_MATRIX: dict[tuple[str, str], tuple[bool, str | None, str]] = { + ("http://u:p@h1:8080", "domclick"): ( + False, + "proxy", + '{"error": "Error: Page.goto: NS_ERROR_PROXY_BAD_GATEWAY"}', + ), +} + + +def _patch_probes( + monkeypatch: pytest.MonkeyPatch, + matrix: dict[tuple[str, str], tuple[bool, str | None, str]], + calls: list[tuple[str, str]] | None = None, + *, + default: tuple[bool, str | None, str] = (True, None, "html_len=16477"), +) -> None: + """ipify всегда зелёная; браузерная проба отвечает по матрице (прокси, источник).""" + + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "1.2.3.4", 10, None + + async def _fake_browser( + endpoint: str, proxy_url: str, **kw: Any + ) -> tuple[bool, str | None, str]: + source = str(kw.get("source", "avito")) + if calls is not None: + calls.append((proxy_url, source)) + return matrix.get((proxy_url, source), default) + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(bf, "probe_proxy_via_browser", _fake_browser) + + +# ── 1. главное: вердикт разведён по источникам ─────────────────────────────── + + +async def test_node_dead_for_domclick_is_not_issued_to_domclick( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узел зелёный по Авито и мёртвый по Домклику: Домклику НЕ выдаём, Авито — выдаём. + + Именно этот сценарий четверо суток давал `domclick_city_sweep` ноль лотов при + `browser_fail_streak=0` и свежей пробе. + """ + _patch_probes(monkeypatch, _LIVE_MATRIX) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + # Домклику достаётся только исправный узел… + first = acquire(db, "domclick") # type: ignore[arg-type] + assert first is not None and first.id == 2 + + # …а когда он занят, Домклик остаётся БЕЗ прокси, вместо того чтобы получить + # заведомо мёртвый узел 1 и сжечь на нём прогон. ЭТО и есть поломка, ради которой + # заведена задача: до фикса здесь выдавался узел 1 с browser_fail_streak=0. + assert acquire(db, "domclick") is None, ( # type: ignore[arg-type] + "Домклику выдан узел, у которого рабочий хост Домклика отвечает NS_ERROR_PROXY_BAD_GATEWAY" + ) + + # Для Авито тот же узел 1 — полноценный кандидат: бан у пары, не у узла. + lease = acquire(db, "avito") # type: ignore[arg-type] + assert lease is not None and lease.id == 1 + + ban = db._ban(1, "domclick") + assert ban is not None + assert ban["reason"] == "probe:browser", "строку должна опознавать сама проба" + assert db._ban(1, "avito") is None and db._ban(2, "domclick") is None + assert counters["pair_banned"] == 1 + + +async def test_one_dead_platform_does_not_stain_the_node_globally( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Узловой вердикт остаётся про узел: одна мёртвая площадка его не помечает.""" + _patch_probes(monkeypatch, _LIVE_MATRIX) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + row = db._by_id(1) + assert row["browser_unfit_since"] is None + assert row["browser_fail_streak"] == 0 + assert row["enabled"] is True and row["consecutive_fails"] == 0 + + +async def test_all_platforms_dead_still_marks_the_node(monkeypatch: pytest.MonkeyPatch) -> None: + """Обратная сторона: транспорт не доходит НИКУДА → это уже диагноз узлу (#2723 цел).""" + dead_everywhere = { + ("http://u:p@h1:8080", src): (False, "proxy", "503 browser unavailable") + for src in ("avito", "cian", "yandex", "domclick") + } + _patch_probes(monkeypatch, dead_everywhere) + db = FakeSession([_proxy(1), _proxy(2)]) + + for _ in range(BROWSER_UNFIT_THRESHOLD): + db._by_id(1)["browser_check_at"] = None # снять гейт такта, ускорить подтверждение + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._by_id(1)["browser_unfit_since"] is not None + assert db._by_id(2)["browser_unfit_since"] is None + + +# ── 2-3. адрес пробы и «200 ≠ ответ площадки» ──────────────────────────────── + + +@pytest.mark.parametrize( + ("source", "must_contain", "path_suffix"), + [ + ("avito", "www.avito.ru", "/robots.txt"), + ("cian", "ekb.cian.ru", "/robots.txt"), + ("yandex", "realty.yandex.ru", "/robots.txt"), + # apex-домен НЕ годится: через узел id=1 `domclick.ru/robots.txt` отдаёт 200, + # а рабочий bff-хост — 500. Проба по apex была бы зелёной и бесполезной. + # + # #2855: хоста мало — нужен ЗАЩИЩЁННЫЙ ПУТЬ. QRATOR закрывает /api/offers/*, + # robots.txt того же bff-хоста отдаётся свободно (это знал уже #2800 — см. п.4 + # в шапке: «robots.txt площадка отдаёт и забаненному IP»), поэтому проба по + # нему зелёная ровно тогда, когда свип получает блок. + ("domclick", "bff-search-web.domclick.ru", "/api/offers/count/v1"), + ], +) +async def test_probe_asks_the_working_host_of_each_source( + monkeypatch: pytest.MonkeyPatch, source: str, must_contain: str, path_suffix: str +) -> None: + seen: dict[str, Any] = {} + # Тело ответа зависит от площадки: у robots.txt-источников признак «ресурс отдан» — + # 'User-agent', у Домклика — ключ 'snippetsCount' живого count-ответа. + _body = ( + '{"result":{"snippetsCount":678}}' if source == "domclick" else "
User-agent: *
" + ) + + class _Resp: + status_code = 200 + text = "{}" + + @staticmethod + def json() -> dict[str, str]: + return {"html": _body} + + class _Client: + def __init__(self, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def post(self, url: str, json: dict[str, Any]) -> _Resp: + seen["payload"] = json + return _Resp() + + monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) + ok, _fail_kind, _detail = await bf.probe_proxy_via_browser( + "http://tradein-browser:3000", "http://u:p@node:8080", source=source + ) + + assert ok is True + assert must_contain in seen["payload"]["url"] + # #2855: путь тоже сторожим — у robots.txt-источников он лёгкий, у Домклика это + # боевой count-эндпоинт (одно число в ответе, без пагинации и выдачи), то есть + # нагрузка на площадку остаётся минимальной, а защита — той же, что у работы. + assert path_suffix in seen["payload"]["url"] + # Инстанс сайдкара остаётся 'generic' — проба не отбирает лок у боевой сессии. + assert seen["payload"]["source"] == "generic" + + +async def test_stub_page_with_status_200_is_a_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """374 КБ «Ошибка — Циан» с кодом 200 — это отказ пары, а не успех пробы.""" + stub = "Ошибка - Циан…" + + class _Resp: + status_code = 200 + text = "{}" + + @staticmethod + def json() -> dict[str, str]: + return {"html": stub} + + class _Client: + def __init__(self, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def post(self, url: str, json: dict[str, Any]) -> _Resp: + return _Resp() + + monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) + ok, fail_kind, detail = await bf.probe_proxy_via_browser( + "http://tradein-browser:3000", "http://u:p@node:8080", source="cian" + ) + + assert ok is False + # Тракт узла исправен — виновата ПАРА: площадка не отдала ресурс этому exit-IP. + assert fail_kind == "page" + # #2855: сообщение называет КОНКРЕТНЫЙ маркер, потому что он теперь зависит от + # площадки ('User-agent' у robots.txt-источников, 'snippetsCount' у Домклика). + # Прежний текст «not robots.txt» стал бы враньём там, где robots.txt и не просили. + assert "no marker 'User-agent'" in detail + + +async def test_stub_page_bans_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + """«page» не принадлежит узлу (#2723), но принадлежит паре — собирать через неё нельзя.""" + _patch_probes( + monkeypatch, + {("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")}, + ) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert db._ban(1, "cian") is not None + assert db._by_id(1)["browser_unfit_since"] is None # узел не при чём + + +# ── 4. проба снимает только свою строку ────────────────────────────────────── + + +async def test_probe_clears_only_its_own_ban(monkeypatch: pytest.MonkeyPatch) -> None: + """Зелёный robots.txt снимает вердикт ПРОБЫ и не трогает бан, распознанный сбором. + + robots.txt площадка отдаёт и забаненному IP — разрешить дешёвому успеху гасить + дорогой вердикт значило бы повторить #2723 на паре. + """ + later = datetime.now(UTC) + timedelta(hours=6) + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "banned_until": later, + "ban_count": 1, + "reason": "banned:avito", # распознан боевым сбором (капча/QRATOR) + }, + { + "proxy_id": 1, + "source": "cian", + "banned_until": later, + "ban_count": 1, + "reason": "probe:browser", # прошлый вердикт самой пробы + }, + ], + ) + _patch_probes(monkeypatch, {}) # все площадки отвечают + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + avito = db._ban(1, "avito") + assert avito is not None, "чужой бан проба снимать не имеет права" + assert (avito["reason"], avito["ban_count"]) == ("banned:avito", 1), "и не переписывать" + assert db._ban(1, "cian") is None, "свой вердикт проба обязана снять" + assert counters["pair_cleared"] == 1 + + +# ── 4b. …и не присваивает чужую (дефект #2803, реализовался на проде) ───────── + + +async def test_probe_does_not_steal_a_live_ban(monkeypatch: pytest.MonkeyPatch) -> None: + """Упавшая проба НЕ переписывает активный бан, поставленный боевым сбором. + + Прод 09.08.2026, пара (1, cian): строка `banned:cian, ban_count=1, до 00:21` после + упавшей пробы стала `probe:browser, ban_count=2, до 07:43`. Фильтр «снимаю только + своё» при этом цел, но защищать перестаёт: присвоенная строка уже «своя», и + следующая успешная проба сняла бы ею бан, который площадка поставила по-настоящему. + Плюс сама метка перестаёт быть свидетельством («нас отбили» неотличимо от «мы не + смогли», #2764), а ban_count складывает события разного рода в одну эскалацию — + отдых пары вырос с 6 ч до 12 ч. + """ + until = datetime.now(UTC) + timedelta(hours=6) + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "cian", + "banned_until": until, + "ban_count": 1, + "reason": "banned:cian", # боевой сбор: Циан отдал заглушку + } + ], + ) + _patch_probes( + monkeypatch, + {("http://u:p@h1:8080", "cian"): (False, "page", "not robots.txt (html_len=374168)")}, + ) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + ban = db._ban(1, "cian") + assert ban is not None + assert ban["reason"] == "banned:cian", "проба присвоила себе бан боевого сбора" + assert ban["ban_count"] == 1, "два события разного рода посчитаны одной эскалацией" + assert ban["banned_until"] == until, "чужой срок проба не пересчитывает (может и укоротить)" + assert counters["pair_banned"] == 0, "счётчик не должен объявлять баном то, чего не записал" + + +async def test_live_ban_takes_over_the_probe_row(monkeypatch: pytest.MonkeyPatch) -> None: + """Зеркало намеренно НЕ симметрично: боевой сбор строку пробы перехватывает. + + Его вердикт сильнее — площадка отбила нас именно сейчас, — пара остаётся забаненной, + а метка становится точнее. Если запретить и ему, строка останется за пробой, и её же + зелёный robots.txt снесёт настоящий бан площадки: тот же дефект, только зеркально. + """ + db = FakeSession( + [_proxy(1), _proxy(2)], + bans=[ + { + "proxy_id": 1, + "source": "cian", + "banned_until": datetime.now(UTC) + timedelta(hours=6), + "ban_count": 1, + "reason": "probe:browser", + } + ], + ) + + proxy_pool.mark_banned(db, 1, source="cian") # type: ignore[arg-type] + + assert db._ban(1, "cian")["reason"] == "banned:cian" + + # …и с этой минуты зелёная проба его не снимет — ради чего перехват и нужен. + _patch_probes(monkeypatch, {}) + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + assert db._ban(1, "cian") is not None + + +# ── 5-6. чужие отказы ──────────────────────────────────────────────────────── + + +async def test_sidecar_outage_bans_nothing(monkeypatch: pytest.MonkeyPatch) -> None: + """Лежащий сайдкар не должен забанить разом все пары (#2686-класс).""" + down = { + (f"http://u:p@h{pid}:8080", src): (False, "sidecar", "ConnectError") + for pid in (1, 2) + for src in ("avito", "cian", "yandex", "domclick") + } + _patch_probes(monkeypatch, down) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["pair_banned"] == 0 + assert db.bans == [] + assert db._by_id(1)["browser_unfit_since"] is None + + +# ── 7. цена такта ──────────────────────────────────────────────────────────── + + +async def test_probe_cost_is_nodes_times_servable_sources( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Крест ограничен источниками, которым узел вообще может достаться. + + Диагностика не должна превращаться в нагрузку: узел с выделенной affinity + спрашивает ОДНУ площадку, 'any' — все четыре. На проде это 4 узла × 4 источника + = 16 навигаций за такт (раз в BROWSER_PROBE_MINUTES), то есть 16 robots.txt на + площадку в сутки против ~1000 боевых /fetch. + """ + calls: list[tuple[str, str]] = [] + _patch_probes(monkeypatch, {}, calls) + db = FakeSession([_proxy(1, affinity="any"), _proxy(2, affinity="domclick")]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert sorted(s for url, s in calls if url.endswith("h1:8080")) == [ + "avito", + "cian", + "domclick", + "yandex", + ] + assert [s for url, s in calls if url.endswith("h2:8080")] == ["domclick"] + assert len(calls) == 5 + + +async def test_confirmed_failure_needs_a_second_look(monkeypatch: pytest.MonkeyPatch) -> None: + """Транспортный отказ пары подтверждается повтором — запуск camoufox бывает флаки. + + Повтор идёт по уже поднятому браузеру и только на отказах, поэтому цена такта из + теста выше не меняется, пока всё зелено. + """ + calls: list[tuple[str, str]] = [] + _patch_probes(monkeypatch, _LIVE_MATRIX, calls) + db = FakeSession([_proxy(1), _proxy(2)]) + + await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert calls.count(("http://u:p@h1:8080", "domclick")) == 2 + assert calls.count(("http://u:p@h1:8080", "avito")) == 1 + + +async def test_flaky_failure_does_not_ban_the_pair(monkeypatch: pytest.MonkeyPatch) -> None: + """Один провал, второй заход зелёный → бан пары не пишется.""" + attempts: dict[str, int] = {} + + async def _fake_http(url: str) -> tuple[bool, str | None, int | None, str | None]: + return True, "1.2.3.4", 10, None + + async def _flaky(endpoint: str, proxy_url: str, **kw: Any) -> tuple[bool, str | None, str]: + source = str(kw.get("source", "avito")) + key = f"{proxy_url}|{source}" + attempts[key] = attempts.get(key, 0) + 1 + if source == "domclick" and proxy_url.endswith("h1:8080") and attempts[key] == 1: + return False, "proxy", "503 browser unavailable" + return True, None, "html_len=150" + + monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_http) + monkeypatch.setattr(proxy_pool._settings, "use_proxy_pool_browser", True) + monkeypatch.setattr(bf, "probe_proxy_via_browser", _flaky) + db = FakeSession([_proxy(1), _proxy(2)]) + + counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type] + + assert counters["pair_banned"] == 0 + assert db._ban(1, "domclick") is None + lease = acquire(db, "domclick") # type: ignore[arg-type] + assert lease is not None + release(db, lease.id) # type: ignore[arg-type] + + +# ── 8. #2855: проба обязана делить с работой ЗАЩИЩЁННЫЙ ПУТЬ, а не только хост ── + + +@pytest.mark.parametrize( + ("api_body", "expect_ok"), + [ + # Блок: площадка отдаёт robots.txt свободно и закрывает /api/offers/* QRATOR'ом. + # На старом коде проба спрашивала robots.txt → 'User-agent' на месте → ok=True, + # то есть «пара здорова» ровно там, где свип получает блок. Красный на main. + ("Ошибкаqrator captcha", False), + # Контроль: живой ответ count-эндпоинта → ok=True. Держит двусторонность + # ВНУТРИ ветки: реализация «маркер не найден никогда» прошла бы первый случай + # и провалила бы этот, то есть тест не может позеленеть от глухого отказа. + # На origin/main этот случай тоже красный, но по другой причине — там проба + # спрашивает robots.txt, `ok` выходит True, и падает проверка АДРЕСА. + ('{"result":{"snippetsCount":678}}', True), + ], +) +async def test_domclick_probe_sees_the_block_that_robots_txt_hides( + monkeypatch: pytest.MonkeyPatch, api_body: str, expect_ok: bool +) -> None: + """Площадка: robots.txt отдаёт всем, /api/offers/* закрывает. Проба обязана увидеть блок. + + Замер прода 13.08.2026: 04:30 healthcheck `pair_banned=0` → 05:02 свип + `QRATOR block during rooms='1'` и узел 11 в бан. За сутки 64 проверки пар и + 5 банов на все четыре площадки при ежедневном блоке Домклика. + """ + seen: dict[str, Any] = {} + + class _Resp: + status_code = 200 + text = "{}" + + def __init__(self, body: str) -> None: + self._body = body + + def json(self) -> dict[str, str]: + return {"html": self._body} + + class _Client: + def __init__(self, **_kw: Any) -> None: ... + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + async def post(self, url: str, json: dict[str, Any]) -> _Resp: + seen["payload"] = json + asked = str(json["url"]) + # Площадка ведёт себя как в проде: лёгкий путь открыт, боевой — закрыт. + if asked.endswith("/robots.txt"): + return _Resp("
User-agent: *
") + return _Resp(api_body) + + monkeypatch.setattr(bf.httpx, "AsyncClient", _Client) + ok, fail_kind, _detail = await bf.probe_proxy_via_browser( + "http://tradein-browser:3000", "http://u:p@node:8080", source="domclick" + ) + + assert ok is expect_ok + if not expect_ok: + # Тракт узла исправен (200 пришёл) — негодна ПАРА, значит "page", не "proxy". + assert fail_kind == "page" + # Спрашивали именно боевой путь, а не robots.txt того же хоста. + assert "/api/offers/" in seen["payload"]["url"] diff --git a/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py new file mode 100644 index 00000000..69b93713 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2807_write_counters_honesty.py @@ -0,0 +1,193 @@ +"""#2807: счётчик мерил прирост таблицы, а читался как «сколько записали». + +`newbuilding_enrich_backfill` считал свою работу разницей `COUNT(*)` до и после +сохранения. Вставка в houses_price_dynamics идёт `ON CONFLICT … DO UPDATE`, поэтому +обновление существующей точки давало ноль. Прод 10.08: прогон 3578 отчитался +`price_dynamics_rows: 0`, обновив за своё окно **64 строки по 10 домам** — те самые, +что вставил прогон 3563 накануне (у него в тех же counters стояло 64). Ноль читался +как «динамика цен снова не пишется». + +Соседние счётчики врали в том же месте по своим причинам: `reliability_rows` обнулял +`_dedup_reliability`, схлопывающий строку сразу после вставки, а `review_rows` +игнорировал число, которое `_save_cian_reviews` УЖЕ возвращал, в пользу разницы COUNT'ов. + +Фальсификация (см. прогон в PR): на коде до правки `test_second_pass_reports_updates` +даёт `price_dynamics_rows == 0` при 64 переписанных точках — ровно прод-симптом. + +Отдельно проверяется, что правка НЕ ослабила сторожа нулевого результата: он смотрит на +`attempted`/`enriched`/`gone`/`blocked` (#2695), а не на счётчики записи, и прогон, +который ничего не обогатил, обязан остаться 'failed' при любых числах в `*_written`. +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") +sys.modules.setdefault("weasyprint", MagicMock()) + +from scraper_kit.providers.cian.newbuilding import ( # noqa: E402 + NewbuildingSaveCounts, + save_newbuilding_enrichment, +) + +from app.services import scrape_runs as runs_mod # noqa: E402 +from app.tasks.newbuilding_enrich_backfill import ( # noqa: E402 + NewbuildingEnrichBackfillResult, +) + +# Прод-масштаб прогона 3578: 10 домов × 64/10 точек. Держим ровно 64, чтобы число в +# тесте совпадало с числом в задаче. +PROD_POINTS = 64 + + +class _UpsertDB: + """Сессия, у которой houses_price_dynamics уже населена (второй проход). + + `RETURNING (xmax = 0)` возвращает False на конфликте — это и есть «обновили». + """ + + def __init__(self, *, already_present: bool) -> None: + self.already_present = already_present + self.price_writes = 0 + self.reliability_writes = 0 + self.committed = False + + def execute(self, statement, params=None): + sql = str(statement) + res = MagicMock() + if "INSERT INTO houses_price_dynamics" in sql: + self.price_writes += 1 + assert "RETURNING (xmax = 0)" in sql, "писатель обязан различать вставку и update" + res.fetchone.return_value = (not self.already_present,) + return res + if "INSERT INTO house_reliability_checks" in sql: + self.reliability_writes += 1 + res.fetchone.return_value = None + return res + + def commit(self) -> None: + self.committed = True + + +def _enrichment(points: int): + from scraper_kit.providers.cian.newbuilding import NewbuildingEnrichment + + return NewbuildingEnrichment( + cian_internal_house_id=1, + cian_zhk_url="https://zhk-x.cian.ru/", + name="ЖК Тест", + realty_valuation_chart=[ + { + "month_date": f"2026-{(i % 12) + 1:02d}-01", + "room_count": "all", + "prices_type": "price", + "period": "halfYear", + "price_per_sqm": 150000.0 + i, + } + for i in range(points) + ], + reliability_checks=[{"check_name": "Надёжный застройщик", "check_status": "reliable"}], + reviews=[], + ) + + +# ── 1. Писатель различает вставку и обновление ─────────────────────────────── + + +def test_first_pass_reports_inserts() -> None: + db = _UpsertDB(already_present=False) + counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS)) + assert counts.price_inserted == PROD_POINTS + assert counts.price_updated == 0 + assert counts.reliability_inserted == 1 + assert db.price_writes == PROD_POINTS + + +def test_second_pass_reports_updates() -> None: + """Прод-симптом: те же 64 точки, ничего нового — но записаны все 64. + + До правки этот прогон отчитывался нулём по всем трём счётчикам. + """ + db = _UpsertDB(already_present=True) + counts = save_newbuilding_enrichment(db, 42, _enrichment(PROD_POINTS)) + assert counts.price_inserted == 0 + assert counts.price_updated == PROD_POINTS + assert counts.price_written == PROD_POINTS + assert db.price_writes == PROD_POINTS + + +def test_nothing_to_write_stays_zero() -> None: + """Встречная проверка: пустой график — ноль и во «вставлено», и в «обновлено».""" + db = _UpsertDB(already_present=True) + counts = save_newbuilding_enrichment(db, 42, _enrichment(0)) + assert (counts.price_inserted, counts.price_updated, counts.price_written) == (0, 0, 0) + + +def test_points_without_price_are_not_counted_as_written() -> None: + """Точка без price_per_sqm пропускается писателем — и не попадает в счёт.""" + enrichment = _enrichment(2) + enrichment.realty_valuation_chart[0]["price_per_sqm"] = None + db = _UpsertDB(already_present=False) + counts = save_newbuilding_enrichment(db, 42, enrichment) + assert counts.price_written == 1 + assert db.price_writes == 1 + + +# ── 2. Сторож нулевого результата не ослаблен ──────────────────────────────── + + +def _finalize(counters: dict[str, int]) -> str: + """Прогнать counters через боевой финализатор и вернуть выбранный статус.""" + chosen: dict[str, str] = {} + with ( + patch.object(runs_mod, "mark_done", lambda *a, **k: chosen.setdefault("s", "done")), + patch.object(runs_mod, "mark_failed", lambda *a, **k: chosen.setdefault("s", "failed")), + patch.object(runs_mod, "mark_banned", lambda *a, **k: chosen.setdefault("s", "banned")), + ): + runs_mod.mark_backfill_finished(MagicMock(), 1, counters, source="newbuilding_enrich") + return chosen["s"] + + +def test_watchdog_still_fails_a_run_that_enriched_nothing() -> None: + """Прогон без обогащений остаётся 'failed', сколько бы записей ни насчитали. + + Числа записи в решение сторожа не входят вовсе — он судит по attempted/enriched. + Если бы входили, честный счётчик «обновлено» превратил бы холостой прогон в успех. + """ + result = NewbuildingEnrichBackfillResult( + processed=25, + succeeded=0, + failed_fetch=25, + price_dynamics_updated=PROD_POINTS, # «что-то писали» — но никого не обогатили + ) + assert _finalize(result.to_backfill_counters()) == "failed" + + +def test_watchdog_verdict_ignores_the_new_keys() -> None: + """Явно: добавление/убирание новых ключей не двигает вердикт ни в одну сторону.""" + base = {"attempted": 25, "enriched": 3, "failed": 22} + assert _finalize(dict(base)) == "done" + assert _finalize({**base, "price_dynamics_inserted": 0, "price_dynamics_updated": 0}) == "done" + zero = {"attempted": 25, "enriched": 0, "failed": 25} + assert _finalize(dict(zero)) == "failed" + assert _finalize({**zero, "price_dynamics_updated": 999}) == "failed" + + +def test_counters_carry_both_numbers_into_the_run() -> None: + """В scrape_runs.counters уезжают ОБА числа — ноль одного больше не читается как ноль.""" + counters = NewbuildingEnrichBackfillResult( + processed=25, succeeded=25, price_dynamics_updated=PROD_POINTS + ).to_backfill_counters() + assert counters["price_dynamics_inserted"] == 0 + assert counters["price_dynamics_updated"] == PROD_POINTS + # Старые имена не должны остаться: у них в истории прогонов другой смысл. + assert "price_dynamics_rows" not in counters + assert "reliability_rows" not in counters + assert "review_rows" not in counters + + +def test_save_counts_written_is_the_sum() -> None: + assert NewbuildingSaveCounts(price_inserted=3, price_updated=4).price_written == 7 diff --git a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py new file mode 100644 index 00000000..a6fb3820 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py @@ -0,0 +1,340 @@ +"""#2830: хвосты инцидента 2026-08-10 — пути, которые ещё ходили мимо пула прокси. + +Корень (issue #2830, разбор владельца): `SCRAPER_PROXY_URL` — один статичный узел, +общий для всех источников и НЕ знающий про `scrape_proxy_source_bans`. Три места +продолжали брать egress именно оттуда: + + 1. `cian_price_history` (ручка POST /admin/scrape/cian-price-history); + 2. `_provider_proxy_url` / GET /admin/scraper/health — ops-панель показывала статичный + узел, пока трафик уже выбирался пулом (#2825/#2831); + 3. `resolve_cian_zhk_url_via_search` — вторая нога обогащения ЖК: #2767 перевёл на пул + только `fetch_newbuilding`, резолв ЖК-url остался на `config.cian_proxy_url`. + +Тесты проверяют ПОВЕДЕНИЕ пула (какой узел взят и какой вердикт вернулся), а не наличие +kwarg'а в вызове. Красные на старом коде: + + * (1) `fetch_detail` вызывался без `proxy_provider` → lease не брался, 403 никому не + сообщался: `mark_banned_calls == []`. Плюс ловушка «правка без эффекта»: + `USE_PROXY_POOL_CURL` задан только контейнеру `scraper`, а ручка живёт в `backend`, + где флага нет — один `proxy_provider=` пул бы не включил (см. `_PoolCurlConfig`). + * (2) `_provider_proxy_url(source)` возвращал `settings.scraper_proxy_url` и не имел + параметра `db` — вызов из теста падал бы на сигнатуре, а исход «пул исчерпан» + выражения не имел вообще. + * (3) `resolve_cian_zhk_url_via_search` на 403 возвращал None: узел получал + `mark_health(ok=True)` и оставался в выдаче Циану (механика #2700/#2821). +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +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.cian_exceptions import CianBlockedError +from scraper_kit.contracts import ProxyLease +from scraper_kit.providers.cian import detail as cian_detail +from scraper_kit.providers.cian import newbuilding as cian_newbuilding +from scraper_kit.proxy_errors import NoProxyAvailableError + +# Узел 9 (asocks-mobile-1) — тот, что 2026-08-10 отдавал Циану 200, пока статичный +# SCRAPER_PROXY_URL смотрел на забаненный узел 1 (asocks-residential-1). +_LEASE = ProxyLease(id=9, url="http://user:pass@pool-node-9:10313", kind="http", rotate_url=None) + + +class _SpyProvider: + """ProxyProvider-заглушка: запоминает вердикты, которые путь вернул пулу.""" + + def __init__(self, lease: ProxyLease | None = _LEASE) -> None: + self._lease = lease + self.acquire_calls: list[str] = [] + self.mark_health_calls: list[tuple[int, bool]] = [] + self.mark_banned_calls: list[tuple[int, str]] = [] + self.release_calls: list[int] = [] + + def acquire(self, provider: str) -> ProxyLease | None: + self.acquire_calls.append(provider) + return self._lease + + def release(self, lease: ProxyLease) -> None: + self.release_calls.append(lease.id) + + def mark_health( + self, lease: ProxyLease, ok: bool, *, exit_ip: Any = None, latency_ms: Any = None + ) -> 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)) + + +class _SpyPool: + """Заглушка МОДУЛЯ `app.services.proxy_pool` — подставляется ПОД `RealProxyProvider`. + + Так тест краснеет на поведении (пул не получил вердикт), а не на отсутствии нового + имени в модуле: старый код проходит ровно тот же путь, просто ничего пулу не говорит. + """ + + def __init__(self, lease: Any = _LEASE) -> None: + self._lease = lease + self.acquire_calls: list[str] = [] + self.mark_health_calls: list[tuple[int, bool]] = [] + self.mark_banned_calls: list[tuple[int, str]] = [] + self.release_calls: list[int] = [] + + def acquire(self, db: Any, provider: str, **kw: Any) -> Any: + self.acquire_calls.append(provider) + return self._lease + + def release(self, db: Any, lease_id: int) -> None: + self.release_calls.append(lease_id) + + def mark_health(self, db: Any, lease_id: int, ok: bool, **kw: Any) -> None: + self.mark_health_calls.append((lease_id, ok)) + + def mark_banned(self, db: Any, lease_id: int, *, source: str) -> None: + self.mark_banned_calls.append((lease_id, source)) + + +def _session_returning(status_code: int, text: str = "") -> MagicMock: + session = MagicMock() + session.get = AsyncMock(return_value=MagicMock(status_code=status_code, text=text)) + session.close = AsyncMock() + return session + + +# ── 1. cian_price_history: узел из пула + вердикт обратно ──────────────────── + + +def _price_history_db(n_listings: int) -> MagicMock: + """Фейковая сессия: SELECT листингов, дальше COUNT'ы (до fetch дело не дойдёт).""" + rows = [ + {"id": 100 + i, "source_url": f"https://ekb.cian.ru/sale/flat/{100 + i}/"} + for i in range(n_listings) + ] + listings_result = MagicMock() + listings_result.mappings.return_value.all.return_value = rows + db = MagicMock() + db.execute.return_value = listings_result + return db + + +async def _run_price_history(pool: _SpyPool, *, status_code: int, n: int = 1) -> Any: + from app.services import scraper_adapters + from app.services.cian_price_history import backfill_cian_price_history + + with ( + patch.object( + cian_detail, "build_curl_cffi_session", return_value=_session_returning(status_code) + ), + patch("app.services.cian_price_history.get_scraper_delay", return_value=0.0), + patch.object(scraper_adapters, "_proxy_pool", pool), + patch.object(scraper_adapters, "_SessionLocal", MagicMock()), + ): + return await backfill_cian_price_history(_price_history_db(n), batch_size=n) + + +@pytest.mark.asyncio +async def test_price_history_takes_pool_node_despite_flag_off() -> None: + """Узел берётся из пула даже при выключенном USE_PROXY_POOL_CURL (контейнер backend). + + Красный на старом коде дважды: не было ни `proxy_provider=`, ни принудительного + pool-режима — `curl_proxy_url` уходил на статичный env-узел и `acquire` не звал. + """ + from app.core.config import settings + + assert ( + settings.use_proxy_pool_curl is False + ), "тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL" + pool = _SpyPool() + await _run_price_history(pool, status_code=200) + assert pool.acquire_calls == ["cian"] + assert pool.release_calls == [9] # lease не течёт + + +@pytest.mark.asyncio +async def test_price_history_403_bans_the_node_for_cian() -> None: + """403 от Циана снимает узел с выдачи ИМЕННО Циану. Красный: было `mark_banned` = [].""" + pool = _SpyPool() + result = await _run_price_history(pool, status_code=403) + assert pool.mark_banned_calls == [(9, "cian")] + assert pool.mark_health_calls == [(9, False)] + assert result.errors == 1 # прогон честен: отказ посчитан + + +@pytest.mark.asyncio +async def test_price_history_404_does_not_ban_the_node() -> None: + """Снятое объявление — не бан площадки: чужой вердикт узлу не присваиваем (#2805).""" + pool = _SpyPool() + await _run_price_history(pool, status_code=404) + assert pool.mark_banned_calls == [] + assert pool.mark_health_calls == [(9, True)] + + +@pytest.mark.asyncio +async def test_price_history_aborts_batch_when_pool_empty() -> None: + """Пул пуст в проде → отказ ДО HTTP и разрыв батча, а не 3 попытки подряд.""" + from app.core.config import settings + + pool = _SpyPool(lease=None) + # Прод-признак читает `curl_proxy_url` через config.environment (#2616 шаг 1): + # только в проде пустой пул = отказ, в dev это легитимный env/direct-путь. + with patch.object(settings, "environment", "production"): + result = await _run_price_history(pool, status_code=200, n=3) + assert pool.acquire_calls == ["cian"], "после отказа пула батч продолжаться не должен" + assert result.errors == 1 + assert result.checked == 3 # выбрали 3, но оборвались на первом + + +def test_no_proxy_available_is_not_a_platform_ban() -> None: + """Наш отказ инфраструктуры не должен читаться как бан площадки (#2616).""" + from scraper_kit.proxy_errors import ProxyBanError + + assert not issubclass(NoProxyAvailableError, ProxyBanError) + + +# ── 2. /admin/scraper/health: показывает узел, который реально получит трафик ── + + +def _health_db(*, candidate: dict[str, Any] | None, diag: dict[str, int]) -> MagicMock: + """Сессия для `proxy_egress`: SELECT кандидата, при пустом — SELECT-разбивка причин.""" + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + row = diag if "count(*)" in str(stmt).lower() else candidate + res = MagicMock() + res.mappings.return_value.fetchone.return_value = row + return res + + db = MagicMock() + db.execute.side_effect = _execute + return db + + +def _get_health(db: MagicMock) -> dict[str, Any]: + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from app.api.v1 import admin as admin_module + from app.core.db import get_db + + def _fake_db() -> Any: + yield db + + app = FastAPI() + app.include_router(admin_module.router, prefix="/api/v1/admin") + app.dependency_overrides[get_db] = _fake_db + + async def _no_browser() -> Any: + return admin_module.BrowserHealth(reachable=False, browsers={}) + + async def _no_ip(proxy_url: str | None) -> str | None: + return None + + with ( + patch.object(admin_module, "_probe_browser_health", _no_browser), + patch.object(admin_module, "_probe_current_ip", _no_ip), + # Статичный env-узел ОТЛИЧАЕТСЯ от пулового — иначе тест не различит источники. + patch.object( + admin_module.settings.__class__, + "scraper_proxy_url", + property(lambda _self: "http://u:p@static-env-node:10423"), + ), + ): + r = TestClient(app).get("/api/v1/admin/scraper/health") + assert r.status_code == 200 + return {p["source"]: p for p in r.json()["providers"]} + + +def test_health_shows_pool_node_not_static_env() -> None: + """Панель показывает узел, который получит трафик, а не SCRAPER_PROXY_URL. + + Красный на старом коде поведенчески: возвращался `static-env-node` — ровно то + расхождение «панель показывает один узел, трафик идёт через другой», из-за + которого инцидент 2026-08-10 месяц оставался невидимым. + """ + db = _health_db( + candidate={"id": 9, "url": _LEASE.url, "label": "asocks-mobile-1"}, + diag={"pool_total": 4, "banned_for_source": 0, "unhealthy_or_disabled": 0}, + ) + by_source = _get_health(db) + assert {p["proxy_host"] for p in by_source.values()} == {"pool-node-9"} + assert by_source["cian"]["proxy_port"] == 10313 + + +def test_health_shows_nothing_when_pool_exhausted_for_source() -> None: + """Все узлы забанены для источника → «—», а не статичный env-узел. + + Зелёная строка на месте отказа хуже пустой: она читается как «прокси есть». + """ + db = _health_db( + candidate=None, + diag={"pool_total": 4, "banned_for_source": 4, "unhealthy_or_disabled": 0}, + ) + by_source = _get_health(db) + assert by_source["cian"]["proxy_host"] is None + assert by_source["cian"]["proxy_port"] is None + + +# ── 3. resolve ЖК-url: вторая нога обогащения (#2767 чинил только первую) ───── + + +@dataclass +class _FakeConfig: + use_proxy_pool_curl: bool = True + cian_proxy_url: str | None = "http://static-env-node:10423" + environment: str = "production" + + +async def _resolve(status_code: int, spy: _SpyProvider, html: str = "") -> Any: + with patch.object( + cian_newbuilding, + "build_curl_cffi_session", + return_value=_session_returning(status_code, html), + ): + return await cian_newbuilding.resolve_cian_zhk_url_via_search( + 48853, config=_FakeConfig(), proxy_provider=spy + ) + + +@pytest.mark.asyncio +async def test_zhk_resolve_uses_pool_node() -> None: + """Резолв ЖК-url берёт узел из пула, а не статичный cian_proxy_url.""" + spy = _SpyProvider() + html = '

ЖК

' + assert await _resolve(200, spy, html) == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru" + assert spy.acquire_calls == ["cian"] + assert spy.mark_health_calls == [(9, True)] + assert spy.release_calls == [9] + + +@pytest.mark.asyncio +async def test_zhk_resolve_403_reaches_the_pool() -> None: + """Красный на старом коде: 403 гасился в `return None`, узел оставался в выдаче.""" + spy = _SpyProvider() + with pytest.raises(CianBlockedError): + await _resolve(403, spy) + assert spy.mark_banned_calls == [(9, "cian")] + assert spy.mark_health_calls == [(9, False)] + assert spy.release_calls == [9] + + +@pytest.mark.asyncio +async def test_zhk_resolve_404_is_a_soft_miss_not_a_ban() -> None: + """404 cat.php — не бан: здоровый узел за отсутствующий ЖК не наказываем.""" + spy = _SpyProvider() + assert await _resolve(404, spy) is None + assert spy.mark_banned_calls == [] + assert spy.mark_health_calls == [(9, True)] + + +@pytest.mark.asyncio +async def test_zhk_resolve_empty_serp_is_a_soft_miss_not_a_ban() -> None: + """200 без слага (дрейф вёрстки / пустая выдача) — тоже не повод банить узел.""" + spy = _SpyProvider() + assert await _resolve(200, spy, "ничего") is None + assert spy.mark_banned_calls == [] + assert spy.mark_health_calls == [(9, True)] diff --git a/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py b/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py new file mode 100644 index 00000000..9df5b583 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py @@ -0,0 +1,145 @@ +"""Витрина поиска не обещает колонок, которых не заполняет (#2857, эпик #2674). + +`listings_search_mv` с 050 несла четыре колонки, заданные литералом `NULL` прямо +в определении: district, distance_to_metro_m, last_price_change, photos_count. +Это не потеря данных и не оборванный писатель — имена зарезервировали, реализацию +не подключили никогда. Три из них не читает НИКТО (ни бэкенд, ни фронт, ни тесты) +и они сняты миграцией 261; district оставлен намеренно — он объявлен в +schemas/search_response.py, то есть API его отдаёт, и его снос это ломающее +изменение контракта (решение владельца, вынесено отдельно в #2857). + +Проверяется ФАКТ, а не текст: тест собирает СПИСОК КОЛОНОК витрины разбором её +актуального определения (самый старший NN среди файлов, создающих витрину) и +смотрит на состав списка. Переформатирование SQL, перестановка строк или смена +`NULL::int` на `NULL::integer` тест не трогают; возврат колонки — краснит. + +Без БД и сети: миграции читаются как текст, разбираются в структуру. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" + +MV = "listings_search_mv" +# Сняты 261: ноль читателей во всём репозитории на момент сноса. +DROPPED = ("distance_to_metro_m", "last_price_change", "photos_count") + + +def _strip_sql_comments(sql: str) -> str: + sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL) + return re.sub(r"--[^\n]*", "", sql) + + +def _latest_definition() -> str: + """Текст файла с самым старшим NN, который создаёт витрину = её актуальный вид.""" + creators = [ + p + for p in SQL_DIR.glob("*.sql") + if re.search( + rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\b", + _strip_sql_comments(p.read_text("utf-8")), + re.I, + ) + ] + assert creators, f"ни одна миграция не создаёт {MV} — тест смотрит не туда" + return max(creators, key=lambda p: int(p.name.split("_", 1)[0])).read_text("utf-8") + + +def mv_columns() -> list[str]: + """Имена колонок витрины в порядке объявления. + + Разбор: от `AS SELECT` до `FROM` на нулевой глубине скобок, разрез по запятым + той же глубины, имя колонки — последний идентификатор элемента (алиас после + `AS` либо хвост `l.foo`). + """ + sql = _strip_sql_comments(_latest_definition()) + body = re.split(rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\s+AS\s+SELECT\b", sql, flags=re.I)[1] + + depth, items, cur = 0, [], [] + for token in re.finditer(r"\(|\)|,|\bFROM\b|[^(),]+", body, re.I): + t = token.group(0) + if t == "(": + depth += 1 + elif t == ")": + depth -= 1 + elif depth == 0 and t == ",": + items.append("".join(cur)) + cur = [] + continue + elif depth == 0 and t.upper() == "FROM": + break + cur.append(t) + items.append("".join(cur)) + + return [item.split()[-1].split(".")[-1] for item in items if item.split()] + + +def test_placeholder_columns_are_gone_from_the_matview() -> None: + """Red => витрина снова обещает поля, которых не заполняет (#2857). + + Три колонки были литеральным `NULL` без единого читателя. Если тест покраснел + после возврата колонки — сначала заведи писателя, потом колонку, а не наоборот. + """ + cols = mv_columns() + still_there = [c for c in DROPPED if c in cols] + assert not still_there, ( + f"{MV} снова отдаёт колонки-заглушки {still_there}. Колонка без писателя " + "читается снаружи как «данные есть, просто у этого объекта пусто» — это " + "хуже мёртвого кода, потому что видно в контракте." + ) + + +def test_district_is_deliberately_kept() -> None: + """Red => district снесли заодно, а он в схеме ответа API. + + schemas/search_response.py объявляет `district: str | None`, services/search_query.py + его тянет — снос ломает контракт /api/v1/search. Это решение владельца (#2857), + а не побочный эффект уборки соседних заглушек. Убирать — вместе со схемой ответа. + """ + assert "district" in mv_columns(), ( + f"district пропал из {MV}, а schemas/search_response.py его всё ещё объявляет: " + "ответ поиска начнёт падать/врать. Снимать поле — только вместе со схемой." + ) + + +def test_search_api_selects_only_columns_the_matview_has() -> None: + """Настоящий инвариант: то, что просит API, витрина обязана иметь. + + Именно эта проверка отличает «список колонок» от «поиска подстроки»: она + краснеет на ЛЮБОЙ колонке, снесённой без правки читателя, а не только на трёх + известных именах. + """ + from app.schemas.search import SearchParams + from app.services.search_query import build_search_query + + sql, _ = build_search_query(SearchParams()) + selected = [ + c.strip() for c in sql[len("SELECT ") : sql.index(f" FROM {MV}")].split(",") if c.strip() + ] + missing = [c for c in selected if c not in mv_columns()] + assert not missing, ( + f"services/search_query.py просит у {MV} колонки, которых в её определении нет: " + f"{missing}. Либо верни колонку в витрину, либо убери её из запроса И из " + "schemas/search_response.py." + ) + + +def test_unique_index_for_concurrent_refresh_survives_recreation() -> None: + """Red => ночной REFRESH ... CONCURRENTLY упадёт. + + app/tasks/refresh_search_matview.py рефрешит витрину CONCURRENTLY (расписание + refresh_search_matview, 03:00-04:00 UTC). Без UNIQUE-индекса PostgreSQL отвечает + «cannot refresh materialized view concurrently ... no unique index» — а витрина, + которую пересоздали и забыли проиндексировать, молчит до самой ночи. + """ + sql = _strip_sql_comments(_latest_definition()) + assert re.search(rf"CREATE\s+UNIQUE\s+INDEX[^;]+ON\s+{MV}\s*\(\s*listing_id\s*\)", sql, re.I), ( + f"в актуальном определении {MV} нет UNIQUE-индекса по listing_id — " + "REFRESH MATERIALIZED VIEW CONCURRENTLY без него невозможен." + ) 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_930_scheduler_resume_checkpoint.py b/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py new file mode 100644 index 00000000..cc0ba37a --- /dev/null +++ b/tradein-mvp/backend/tests/test_930_scheduler_resume_checkpoint.py @@ -0,0 +1,295 @@ +"""#930 добивка: планировщик не подхватывал чекпоинт оборванного прогона. + +#930 сделал обе половины механизма — запись точки (`counters.done_buckets`, per-bucket +heartbeat) и её чтение (`run_*_full_load(resume_run_id=...)`, skip-set в SERP-слое), — +но единственным входом оставил админку. У avito full-load админского эндпоинта нет +вовсе, а планировщик передавал `resume_run_id=None` ЛИТЕРАЛОМ (scheduler.py 708/728/799 +на origin/main). То есть боевой путь возобновления не существовал ни одного дня. + +Цена на проде (замер 2026-08-12, 90 суток, read-only): 433 корзины в 30 оборванных +прогонах с ЖИВОЙ незабранной точкой — avito_full_load 242, cian_full_load 134, +avito_full_load_exhaustive 57. Прогон 3547 (09.08, убит деплоем на третьем часу, 35 из +84 корзин дерева) лежит до сих пор и будет подхвачен расписанием 139 16.08. + +Красный прогон на origin/main: + 1. `test_scheduler_hands_checkpoint_to_pipeline` — планировщик отдаёт в пайплайн + resume_run_id=None вместо id прошлого прогона (AssertionError на 3 источниках); + 2. `test_partial_bucket_is_not_complete` — бакет с выпавшей страницей приезжает в + on_bucket неотличимым от целого (у колбэка нет аргумента полноты вообще); + 3. `test_pipeline_keeps_partial_bucket_out_of_checkpoint` — TypeError: `_on_bucket` + на main принимает два аргумента, признаку полноты некуда приехать. +Тесты ладдера (`_resume_decision`) на main падают с AttributeError — функции нет. +""" + +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 scheduler as sched +from scraper_kit.orchestration.pipeline import run_avito_full_load +from scraper_kit.providers.avito.serp import AvitoScraper + +PFX = "scraper_kit.orchestration.pipeline" + +# Прод-слепок расписания 139 (avito_full_load_exhaustive) на 2026-08-12: params прогона +# 3547 совпадают с default_params расписания байт-в-байт — это и есть «то же задание». +_PARAMS = { + "concurrency": 1, + "interval_days": 7, + "secondary_only": True, + "request_delay_sec": 7.0, + "price_cap_per_bucket": 1400, +} + + +def _candidate(**over: Any) -> SimpleNamespace: + """Строка-кандидат из _RESUME_CANDIDATE_SQL: прогон 3547 как он лежит на проде.""" + base = { + "prev_id": 3547, + "prev_status": "cancelled", + "prev_counters": { + "unique_fetched": 5496, + "done_buckets": [f"room_1_komn:{i}:0" for i in range(35)], + }, + "same_params": True, + "age_h": 164.6, # 6.86 суток — столько будет точке 3547 к подхвату 16.08 + "interval_days": "7", + } + base.update(over) + return SimpleNamespace(**base) + + +class _FakeDb: + """Двойник сессии: отдаёт ОДНУ строку-кандидата на любой SELECT, глотает UPDATE.""" + + def __init__(self, row: Any) -> None: + self.row = row + self.written: list[dict[str, Any]] = [] + + def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any: + if params and "counters" in params: # update_heartbeat пишет вердикт + self.written.append(params) + return MagicMock() + return MagicMock(fetchone=lambda: self.row) + + def commit(self) -> None: + pass + + +# ── 1. Главное: планировщик обязан отдать точку в пайплайн ─────────────────── + + +@pytest.mark.parametrize( + ("job", "pipeline_fn"), + [ + (sched._job_avito_full_load, "run_avito_full_load"), + (sched._job_avito_full_load_exhaustive, "run_avito_full_load"), + (sched._job_cian_full_load, "run_cian_full_load"), + ], +) +async def test_scheduler_hands_checkpoint_to_pipeline(job: Any, pipeline_fn: str) -> None: + """Оборванный прогон с валидной точкой → новый прогон продолжает его, а не с нуля. + + Падает на origin/main: планировщик передаёт литеральный None — 433 корзины за 90 + суток перебирались заново, включая 35 корзин прогона 3547. + """ + db = _FakeDb(_candidate()) + captured: dict[str, Any] = {} + + async def _spy(*_a: Any, **kw: Any) -> None: + captured.update(kw) + + with patch.object(sched, pipeline_fn, _spy): + await job(db, 4000, dict(_PARAMS), MagicMock()) + + assert captured["resume_run_id"] == 3547 + + +async def test_verdict_lands_in_counters_of_new_run() -> None: + """Подхватили или нет — видно В СЧЁТЧИКАХ прогона, а не только в docker-логах. + + Логи теряются при редеплое (контейнер tradein-scraper пересоздаётся), поэтому + молчаливый отказ подхватить неотличим от отсутствия правки. + """ + db = _FakeDb(_candidate(prev_status="zombie")) + with patch.object(sched, "run_avito_full_load", AsyncMock()): + await sched._job_avito_full_load(db, 4000, dict(_PARAMS), MagicMock()) + + assert db.written, "вердикт о подхвате не записан в counters нового прогона" + written = db.written[-1]["counters"] + assert '"resume_reason": "status_zombie"' in written + assert '"resume_candidate": 3547' in written + + +# ── 2. Ладдер отказов: у каждого нуля своя причина ─────────────────────────── + + +@pytest.mark.parametrize( + ("row", "reason"), + [ + (None, "no_prev_run"), + (_candidate(prev_status="done"), "status_done"), + (_candidate(prev_status="zombie"), "status_zombie"), + (_candidate(same_params=False), "params_changed"), + (_candidate(prev_counters={"unique_fetched": 2977}), "no_checkpoint"), + (_candidate(age_h=200.0), "checkpoint_stale"), + (_candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2}), "chain_limit"), + ], +) +def test_resume_refusals_are_named(row: Any, reason: str) -> None: + """«Не подхватили» — это семь РАЗНЫХ фактов, и в counters они различимы.""" + resume_id, verdict = sched._resume_decision(row) + assert resume_id is None + assert verdict["resume_reason"] == reason + assert verdict["resume_from"] is None + + +def test_resume_chain_is_bounded() -> None: + """Цепочка возобновлений считается и упирается в потолок, а не тянется вечно. + + Потолок выведен из STALE_DIGEST_INTERVAL_FACTOR (см. scheduler.py): полный обход + обязан начаться раньше, чем сводка объявит источник просроченным. + """ + assert sched._MAX_RESUME_CHAIN == sched.STALE_DIGEST_INTERVAL_FACTOR - 1 + _id, first = sched._resume_decision(_candidate()) + assert first["resume_chain"] == 1 + _id2, second = sched._resume_decision( + _candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 1}) + ) + assert second["resume_chain"] == sched._MAX_RESUME_CHAIN + third_id, third = sched._resume_decision( + _candidate(prev_counters={"done_buckets": ["a"], "resume_chain": 2}) + ) + assert third_id is None and third["resume_reason"] == "chain_limit" + + +def test_stale_threshold_follows_the_source_tick() -> None: + """Срок годности точки считается от такта ИСТОЧНИКА, а не общей константой. + + cian ходит раз в 3 суток, avito — раз в 7; одна и та же точка возрастом 100 ч для + первого просрочена, для второго свежая. Плюс сутки — сетка запуска (см. + _resume_decision): 164.6 ч прогона 3547 при такте 7 суток обязаны пройти, иначе + точку отвергал бы jitter расписания, а пропущенный цикл (13 суток) — нет. + """ + assert sched._resume_decision(_candidate(age_h=100.0, interval_days="3"))[0] is None + assert sched._resume_decision(_candidate(age_h=100.0, interval_days="7"))[0] == 3547 + assert sched._resume_decision(_candidate(age_h=164.6, interval_days="7"))[0] == 3547 + assert sched._resume_decision(_candidate(age_h=13 * 24.0, interval_days="7"))[0] is None + + +# ── 3. Недособранный бакет не имеет права попасть в чекпоинт ───────────────── + + +def _serp_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, + scraper_skip_seen_today=False, + ) + + +@pytest.mark.parametrize( + ("page2_html", "expected_complete"), + [(None, False), ("", True)], +) +async def test_partial_bucket_is_not_complete( + page2_html: str | None, expected_complete: bool +) -> None: + """Страница 2 из 3 выпала → бакет НЕ «сделан»; все три пришли → «сделан». + + Контрольная половина обязательна: реализация «всегда False» тоже прошла бы + одностороннюю проверку, но убила бы возобновление целиком. + + Падает на origin/main: `on_bucket` вызывается двумя аргументами, признака полноты + в протоколе нет — частичный бакет неотличим от целого и попадает в done_buckets. + """ + scraper = AvitoScraper(_serp_config()) + scraper.request_delay_sec = 0.0 + calls: list[tuple[str, bool]] = [] + + def _on_bucket(key: str, lots: list, complete: bool = True) -> None: # type: ignore[type-arg] + calls.append((key, complete)) + + async def _fetch_page(_self: Any, _slug: str, page: int, *_a: Any, **_k: Any) -> str | None: + return page2_html if page == 2 else f"" + + with ( + patch.object(AvitoScraper, "_fetch_rooms_page_html", _fetch_page), + patch.object( + AvitoScraper, + "_parse_html", + lambda _self, html, **_k: [MagicMock(source_id=html, listing_segment="secondary")], + ), + ): + await scraper._paginate_leaf_bucket( + room_slug="kvartiry_1_komnatnye", + room_label="room_1_komn", + lo=0, + hi=3999999, + html="", + max_pages=3, + seen={}, + price_cap_per_bucket=1400, + max_pages_per_bucket=100, + concurrency=2, + secondary_only=False, + on_bucket=_on_bucket, + skip_buckets=None, + expected_total=3 * 50, + ) + + assert [c[1] for c in calls] == [expected_complete] + + +async def test_pipeline_keeps_partial_bucket_out_of_checkpoint() -> None: + """Пайплайн: лоты частичного бакета СОХРАНЕНЫ, но в чекпоинт он не попал. + + Именно здесь «видимая потеря» (перескрап) не превращается в «невидимую»: пропустить + частичный бакет на следующем прогоне значит не перечитать его страницы уже никогда. + """ + finals: list[dict[str, Any]] = [] + + class _Recorder: + def is_cancelled(self, *_a: Any, **_k: Any) -> bool: + return False + + def update_heartbeat(self, *_a: Any, **_k: Any) -> None: + pass + + def mark_done(self, _db: Any, _rid: int, counters: dict[str, Any]) -> None: + finals.append(dict(counters)) + + async def _fetch(*_a: Any, on_bucket: Any = None, **_k: Any) -> None: + on_bucket("room_1_komn:0:3999999", [MagicMock(source_id="a1")], True) + on_bucket("room_1_komn:4000000:4999999", [MagicMock(source_id="a2")], False) + + scraper = MagicMock() + scraper.__aenter__ = AsyncMock(return_value=scraper) + scraper.__aexit__ = AsyncMock(return_value=None) + scraper.fetch_all_secondary = _fetch + + with ( + patch(f"{PFX}.AvitoScraper", return_value=scraper), + patch(f"{PFX}.save_listings", MagicMock(return_value=(1, 0))), + patch(f"{PFX}.runs", _Recorder()), + ): + counters = await run_avito_full_load( + MagicMock(), run_id=1, config=_serp_config(), matcher=MagicMock() + ) + + assert finals[0]["done_buckets"] == ["room_1_komn:0:3999999"] + assert finals[0]["partial_buckets"] == 1 + assert counters.unique_fetched == 2, "лоты частичного бакета обязаны быть сохранены" diff --git a/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py b/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py new file mode 100644 index 00000000..884a7c8a --- /dev/null +++ b/tradein-mvp/backend/tests/test_admin_cian_session_endpoints.py @@ -0,0 +1,290 @@ +"""Offline tests для Cian cookie-session admin-эндпоинтов (#инцидент 2026-08-10). + +cian_session_svc.verify_session() возвращает 5 разных исходов (state dict / None / +VERIFY_BAN_SENTINEL / VERIFY_SOURCE_UNAVAILABLE_SENTINEL / VERIFY_MARKUP_CHANGED_SENTINEL) +и раньше admin.py проверял только `if state is None`, из-за чего реальный бан по IP +(403) и недоступность источника (5xx) выглядели как "куки протухли" — человек в +момент инцидента перезаливал заведомо валидные куки вместо починки egress/прокси. + +Покрытие 3 эндпоинтов (db/verify_session/BrowserFetcher мокаются, NO live network/DB), +зеркалит паттерн test_domclick_admin_apis.py (dependency_overrides[get_db] + TestClient): + - POST /api/v1/admin/scrape/cian/upload-cookies + - POST /api/v1/admin/scrape/cian/auto-login + - GET /api/v1/admin/scrape/cian/test-auth +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.services.cian_session import ( + VERIFY_BAN_SENTINEL, + VERIFY_MARKUP_CHANGED_SENTINEL, + VERIFY_SOURCE_UNAVAILABLE_SENTINEL, +) + + +@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(): + yield MagicMock() + + app.dependency_overrides[get_db] = fake_db + return TestClient(app) + + +_UPLOAD_URL = "/api/v1/admin/scrape/cian/upload-cookies" +_AUTOLOGIN_URL = "/api/v1/admin/scrape/cian/auto-login" +_TEST_AUTH_URL = "/api/v1/admin/scrape/cian/test-auth" + +_RAW_COOKIES = {"DMIR_AUTH": "abc", "_CIAN_GK": "def"} +_AUTHENTICATED_STATE = {"user": {"isAuthenticated": True, "userId": 102963817}} +_AUTHENTICATED_STATE_NO_USERID = {"user": {"isAuthenticated": True}} + + +# ── POST /scrape/cian/upload-cookies — пять исходов verify_session ──────────── + + +def test_upload_cookies_ban_returns_503(client: TestClient) -> None: + """403 TLS/bot ban → 503, текст говорит чинить прокси, НЕ перезаливать куки.""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_BAN_SENTINEL), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 503 + detail = resp.json()["detail"] + assert "заблокировал" in detail + assert "прокси" in detail or "egress" in detail + assert "бесполезно" in detail + # Текст обязан называть ЖИВУЮ переменную: инцидент 2026-08-10 — правка мёртвой + # CIAN_PROXY_URL не давала эффекта, реальный egress задаётся SCRAPER_PROXY_URL. + assert "SCRAPER_PROXY_URL" in detail + + +def test_upload_cookies_source_unavailable_returns_503(client: TestClient) -> None: + """5xx/сеть → 503, текст говорит повторить позже, куки не трогать.""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 503 + detail = resp.json()["detail"] + assert "недоступен" in detail + assert "позже" in detail + + +def test_upload_cookies_markup_changed_returns_500(client: TestClient) -> None: + """200 но auth-state не распарсился → 500, текст указывает на фикс парсера.""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 500 + detail = resp.json()["detail"] + assert "вёрстк" in detail + assert "перезалив" in detail + + +def test_upload_cookies_expired_returns_401(client: TestClient) -> None: + """None → 401, текст просит перезалить куки (единственный случай re-upload).""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=None), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 401 + assert "перезалейте" in resp.json()["detail"] + + +def test_upload_cookies_success_missing_userid_returns_400(client: TestClient) -> None: + """Успешный state, но без userId → 400 с уточнением что куки тут ни при чём.""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=_AUTHENTICATED_STATE_NO_USERID), + ), + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 400 + detail = resp.json()["detail"] + assert "userId" in detail + assert "куки тут ни при" in detail + + +def test_upload_cookies_success_saves_and_returns_200(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=_AUTHENTICATED_STATE), + ), + patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save, + ): + resp = client.post(_UPLOAD_URL, json=_RAW_COOKIES) + assert resp.status_code == 200 + body = resp.json() + assert body["ok"] is True + assert body["userId"] == 102963817 + mock_save.assert_called_once() + _, kwargs = mock_save.call_args + assert kwargs["account_user_id"] == 102963817 + + +# ── POST /scrape/cian/auto-login — тот же маппинг после успешного browser-login ── + + +def _mock_browser_fetcher(raw_cookies: dict[str, str]) -> MagicMock: + fetcher = AsyncMock() + fetcher.__aenter__ = AsyncMock(return_value=fetcher) + fetcher.__aexit__ = AsyncMock(return_value=None) + fetcher.login = AsyncMock(return_value=raw_cookies) + return fetcher + + +def test_auto_login_ban_returns_503(client: TestClient) -> None: + """Browser login прошёл, но verify_session ловит 403 ban → 503, не "куки протухли".""" + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"), + patch("app.api.v1.admin.settings.cian_login_password", "secret"), + patch( + "app.api.v1.admin.BrowserFetcher", + return_value=_mock_browser_fetcher(_RAW_COOKIES), + ), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_BAN_SENTINEL), + ), + ): + resp = client.post(_AUTOLOGIN_URL) + assert resp.status_code == 503 + assert "заблокировал" in resp.json()["detail"] + + +def test_auto_login_success_saves_and_returns_200(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.settings.cian_login_email", "user@example.com"), + patch("app.api.v1.admin.settings.cian_login_password", "secret"), + patch( + "app.api.v1.admin.BrowserFetcher", + return_value=_mock_browser_fetcher(_RAW_COOKIES), + ), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=_AUTHENTICATED_STATE), + ), + patch("app.api.v1.admin.cian_session_svc.save_session") as mock_save, + ): + resp = client.post(_AUTOLOGIN_URL) + assert resp.status_code == 200 + assert resp.json()["userId"] == 102963817 + mock_save.assert_called_once() + + +# ── GET /scrape/cian/test-auth — reason различает все пять исходов ──────────── + + +def test_test_auth_ban_reason(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_BAN_SENTINEL), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + body = resp.json() + assert body["authenticated"] is False + assert body["reason"] == "banned_403" + + +def test_test_auth_source_unavailable_reason(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_SOURCE_UNAVAILABLE_SENTINEL), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + assert resp.json()["reason"] == "source_unavailable" + + +def test_test_auth_markup_changed_reason(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=VERIFY_MARKUP_CHANGED_SENTINEL), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + assert resp.json()["reason"] == "markup_changed" + + +def test_test_auth_expired_reason(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=None), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + assert resp.json()["reason"] == "session_expired_or_invalid" + + +def test_test_auth_success(client: TestClient) -> None: + with ( + patch("app.api.v1.admin.settings.cookie_encryption_key", "test-key"), + patch("app.api.v1.admin.cian_session_svc.load_session", return_value=_RAW_COOKIES), + patch( + "app.api.v1.admin.cian_session_svc.verify_session", + new=AsyncMock(return_value=_AUTHENTICATED_STATE), + ), + ): + resp = client.get(_TEST_AUTH_URL) + assert resp.status_code == 200 + body = resp.json() + assert body["authenticated"] is True + assert body["userId"] == 102963817 + assert body["reason"] is 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..22324746 --- /dev/null +++ b/tradein-mvp/backend/tests/test_alerts_become_events.py @@ -0,0 +1,405 @@ +"""Сигналы о сбоях действительно становятся событиями 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-мок мониторов свежести. + + Монитор сделок спрашивает только max(...). Монитор СберИндекса (#2846) спрашивает + ещё время последнего ПОЛНОГО прогона загрузки и её такт — именно они, а не + календарный возраст периода, решают, быть ли тревоге. + """ + + def __init__( + self, + latest: date | None, + last_pull: datetime | None = datetime(2026, 8, 6, 5, 0, tzinfo=UTC), + interval_days: str = "7", + ) -> None: + self._latest = latest + self._last_pull = last_pull + self._interval_days = interval_days + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + sql = str(stmt) + result = MagicMock() + if "scrape_runs" in sql: + result.first.return_value = MagicMock(last_pull=self._last_pull) + elif "scrape_schedules" in sql: + result.first.return_value = MagicMock(interval_days=self._interval_days) + else: + 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_pull_stall_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: + """Загрузка встала (полный прогон 20 суток назад при такте 7) → событие. + + 20 суток — реальный разрыв прод-истории между полными прогонами 07-17 и 08-06. + """ + _patch_runs(monkeypatch, sber_mon) + db = _FakeMonitorDB(date(2026, 6, 1), last_pull=datetime(2026, 7, 17, 5, 38, tzinfo=UTC)) + 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_old_period_with_healthy_pull_stays_silent(monkeypatch: pytest.MonkeyPatch) -> None: + """Прод 2026-08-12: период старый (72 суток), но загрузка в такте — событий нет. + + Это ровно то состояние, в котором main двенадцатые сутки подряд писал ERROR: + возраст там был лагом ПУБЛИКАЦИИ Сбера, а не нашим отставанием. Алерт-усталость + от таких событий и делает настоящий отказ незаметным. + """ + _patch_runs(monkeypatch, sber_mon) + db = _FakeMonitorDB(date(2026, 6, 1)) # last_pull = 2026-08-06 (полный прогон) + with glitchtip_events() as events: + out = sber_mon.check_sber_freshness( + db, # type: ignore[arg-type] + run_id=2, + params={}, + now=datetime(2026, 8, 12, 19, 6, 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("у оценщика нет серии" 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_audit_api.py b/tradein-mvp/backend/tests/test_audit_api.py index 78ec6684..a3226678 100644 --- a/tradein-mvp/backend/tests/test_audit_api.py +++ b/tradein-mvp/backend/tests/test_audit_api.py @@ -53,6 +53,39 @@ def test_days_param_uses_cast_as_int() -> None: assert "CAST(:days AS int)" in _AUDIT_SRC +def test_every_group_by_username_filters_out_the_nameless() -> None: + """Каждая выборка «по аккаунтам» отбрасывает строки с пустым именем (#2715). + + Пустое имя пишет `login_verify_saturated`: отказ по насыщению случается ДО + того, как мы посмотрели на присланное имя, и записать его нельзя — иначе + атакующий набивал бы аудит строками с любым именем на выбор. Но аккаунтом + такая строка от этого не становится: без фильтра она встаёт ПЕРВОЙ в списке + (её `last_seen_at` — момент атаки), даёт фантома в `count(DISTINCT + username)`, а раскрытие уходит в `/audit/accounts/{username}` с + `min_length=1` — то есть в ошибку. + + То же и со счётчиками уникальных: `count(DISTINCT username)` считал бы + безымянного за человека, и первая же атака НАВСЕГДА добавила бы +1 к числу + пользователей (строка остаётся в таблице). `NULLIF(username, '')` роняет её + в NULL, который `count(DISTINCT)` не считает. Сами события при этом из + `total_events` не исчезают — они события, просто не люди. + + Сравнение ЧИСЛОМ, а не поиском подстроки: так сторож ловит и НОВУЮ выборку, + добавленную без фильтра, а не только сегодняшние. На проде пустых имён + сейчас 0 из 3365 строк — то есть это ново. + """ + grouped = _AUDIT_SRC.count("GROUP BY username") + filtered = _AUDIT_SRC.count("WHERE username <> ''") + assert grouped == filtered, ( + f"{grouped} выборок GROUP BY username, из них с фильтром {filtered} — " + "безымянная строка попадёт в список аккаунтов" + ) + assert "count(DISTINCT username)" not in _AUDIT_SRC, ( + "count(DISTINCT username) считает безымянные события за людей — " + "нужен count(DISTINCT NULLIF(username, ''))" + ) + + # --------------------------------------------------------------------------- # Fakes — mirror the mocked-DB convention used across tests/test_user_events.py etc. # --------------------------------------------------------------------------- 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..9cd7b5cd --- /dev/null +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -0,0 +1,1616 @@ +"""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 logging +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() + # Агрегатор отказов по насыщению (#2715) — тоже глобал процесса: без сброса + # недосчитанные отказы одного теста всплывают в записи другого. + monkeypatch.setattr(auth_router, "_saturation_rejected", 0) + monkeypatch.setattr(auth_router, "_saturation_reported_at", None) + 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 на планировщик). + max_attempts_per_s = ceiling_per_s * 1.5 + offered_per_s = len(codes) / elapsed + + # ПРЕДУСЛОВИЕ, и оно отделено от вердикта намеренно: «нагрузку создать не + # удалось» и «потолок не работает» — разные новости, и красный обязан их + # различать. Порог здесь ТОТ ЖЕ, с которым сверяется вердикт ниже, и это не + # совпадение: пока флуд предлагает меньше, следующее утверждение зелено даже + # на системе вовсе без потолка, то есть измерения нет. + # + # Раньше условием было `len(codes) > connections` — «каждое соединение + # успело сходить хотя бы дважды за секунду». Это мерило скорости РАННЕРА, а + # не нагрузки: на занятом первый круг из ста запросов сам съедал всю секунду, + # и сторож краснел неотличимо от настоящей поломки потолка (замер 2026-08-07: + # свободная машина — 0 падений из 20, ~800 ответов/с; под load average ~185 — + # 10 падений из 15, и в каждом флуд всё равно предлагал 55-80 запросов/с + # против порога 30/с, то есть мерить было на чем). + assert offered_per_s > max_attempts_per_s, ( + f"нагрузку создать не удалось: флуд предложил {offered_per_s:.0f} запросов/с " + f"({len(codes)} за {elapsed:.2f}с) — не больше порога следующей проверки " + f"({max_attempts_per_s:.0f}/с), она прошла бы и без потолка. Это «раннер не " + f"потянул», НЕ «потолок сломан»" + ) + assert attempts_per_s <= max_attempts_per_s, ( + 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)" + ) + + +# --------------------------------------------------------------------------- +# #2715 — отказ по насыщению: до похода в БД и со следом, который не выселяет лог +# --------------------------------------------------------------------------- + + +def _saturate_verify_slots(monkeypatch: pytest.MonkeyPatch) -> None: + """Слоты сверки заняты — снаружи ровно то же, что живой флуд, но без гонок. + + Именно счётчик, а не мок `verify_password_bounded`: проверяем настоящий + предикат отказа (`verify_slots_saturated` читает этот же глобал), а не + собственную заглушку. + """ + monkeypatch.setattr(password_mod, "_verify_inflight", 999) + + +def test_saturated_login_answers_before_touching_the_registry( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Под насыщением отказ приходит ДО выборки пользователя (#2715). + + Гейт стоял после SELECT'а, и каждый заведомо отклоняемый запрос всё равно + брал соединение из пула — тогда, когда система уже перегружена. Хуже того, + эта выборка оставалась ЕДИНСТВЕННОЙ работой на пути отказа: bcrypt, ровняющий + время ответа для существующего и несуществующего имени, до отказанного + запроса не доходит вовсе, так что разницу маскировать было нечем. + + Мерим не тайминг (в CI флейкует), а сам факт похода в реестр — и заодно + побайтовую одинаковость ответа для живого и выдуманного имени. + """ + store.add_user("alice", hash_password("Secret123!"), role="employee") + _capture_events(monkeypatch) + + lookups: list[str] = [] + real_lookup = auth_router.get_user_by_username + + def _spy(db: Any, username: str) -> Any: + lookups.append(username) + return real_lookup(db, username) + + monkeypatch.setattr(auth_router, "get_user_by_username", _spy) + _saturate_verify_slots(monkeypatch) + + bodies = [] + for name in ("alice", "ghost"): + resp = client.post( + "/api/v1/auth/login", + json={"username": name, "password": "x"}, + headers={"x-forwarded-for": "203.0.113.5"}, + ) + assert resp.status_code == 429, resp.text + assert resp.headers["Retry-After"] == "1" + bodies.append(resp.text) + + assert lookups == [], f"под насыщением всё-таки сходили в реестр: {lookups}" + # Существующее и несуществующее имя — неразличимы (#2571 на этом пути тоже). + assert bodies[0] == bodies[1] + + +def test_key_share_alone_also_answers_before_the_registry( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch +) -> None: + """Долю на ключ предчек проверяет ТОЖЕ — и это главный случай, а не запасной. + + Соседний тест занимает ОБЩИЙ счётчик, а `or` в `verify_slots_saturated` + коротит на первой половине: выброси вторую — и тот тест останется зелёным. + Между тем при флуде с ОДНОГО адреса (#2714) общий потолок не выбирается + вовсе, первой упирается именно доля, и без неё в базу ходили бы почти все + отклонённые запросы. + """ + store.add_user("alice", hash_password("Secret123!"), role="employee") + _capture_events(monkeypatch) + + lookups: list[str] = [] + real_lookup = auth_router.get_user_by_username + + def _spy(db: Any, username: str) -> Any: + lookups.append(username) + return real_lookup(db, username) + + monkeypatch.setattr(auth_router, "get_user_by_username", _spy) + # Общий котёл (4) НЕ выбран: занято 2 из 4, и оба — одним адресом. Это ровно + # его доля (`_per_key_slot_cap` = 4 // 2), больше ему не дают. + monkeypatch.setattr(password_mod, "_verify_inflight", 2) + monkeypatch.setattr(password_mod, "_verify_inflight_by_key", {"203.0.113.5": 2}) + assert password_mod._per_key_slot_cap() == 2 # исходные условия теста + + flooder = client.post( + "/api/v1/auth/login", + json={"username": "alice", "password": "x"}, + headers={"x-forwarded-for": "203.0.113.5"}, + ) + assert flooder.status_code == 429, flooder.text + assert lookups == [], f"доля исчерпана, а в реестр всё-таки сходили: {lookups}" + + # И тут же — доказательство, что предчек не отказывает всем подряд: с + # ДРУГОГО адреса свободные слоты есть, запрос идёт дальше, в реестр. + other = client.post( + "/api/v1/auth/login", + json={"username": "alice", "password": "wrong"}, + headers={"x-forwarded-for": "198.51.100.10"}, + ) + assert other.status_code == 401, other.text + assert lookups == ["alice"] + + +def test_saturation_is_reported_once_per_window_and_lands_in_audit( + client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch, caplog: Any +) -> None: + """Двадцать отказов — одна запись в лог и одно событие в аудит, со счётчиком. + + Строка на каждый отказ делила с остальным бэкендом `json-file max-size 20m × + max-file 3`: при флуде в сотни запросов в секунду 60 МБ прокручиваются за + минуты и выселяют ВСЕ остальные логи ровно во время атаки. Поэтому окно. + + А событие в `user_events` — потому что до #2715 инцидент не оставлял в + аудите ни строчки: событие неудачного входа тут не пишется намеренно + (пароль не проверялся, и трата бюджета неудач дала бы блокировку чужой + учётки насыщением) — значит нужен отдельный тип события, и он обязан + появляться независимо от того, ротировался лог или нет. + """ + # ЛИТЕРАЛ, а не арифметика от настройки: окно — компромисс «видно вовремя» + # против «не выселяет лог», и подъём его до минут прячет атаку целиком. + assert auth_router._SATURATION_REPORT_WINDOW_S == 1.0 + + events = _capture_events(monkeypatch) + _saturate_verify_slots(monkeypatch) + # Окно на весь тест — иначе медленный CI разбил бы 20 запросов на два окна + # и число записей стало бы функцией скорости раннера. + monkeypatch.setattr(auth_router, "_SATURATION_REPORT_WINDOW_S", 60.0) + caplog.set_level(logging.WARNING, logger="app.api.v1.auth") + + for i in range(20): + resp = client.post( + "/api/v1/auth/login", + json={"username": f"ghost{i}", "password": "x"}, + headers={"x-forwarded-for": "203.0.113.5"}, + ) + assert resp.status_code == 429, resp.text + + lines = [r for r in caplog.records if "saturated" in r.getMessage()] + assert len(lines) == 1, f"20 отказов дали {len(lines)} строк в логе — агрегации нет" + # ERROR, а не WARNING: бэкенд поднят с LoggingIntegration(event_level=ERROR) + # (app/main.py), и только с ERROR запись становится событием GlitchTip. + # Понижение уровня выключило бы канал молча — прецедент #2674. + assert lines[0].levelno == logging.ERROR + + saturated = [e for e in events if e["event_type"] == "login_verify_saturated"] + assert len(saturated) == 1, saturated + # Первый отказ отчитывается сразу (одиночная аномалия обязана быть видна), + # поэтому в первой записи он один — накопленное придёт следующей. + assert saturated[0]["payload"] == {"rejected": 1, "since_prev_s": None} + assert saturated[0]["ip"] == "203.0.113.5" + # Имя не пишем: отказ случился ДО того, как мы на него посмотрели, а запись + # присланного дала бы атакующему аудит-строки с любым именем на выбор. + assert saturated[0]["username"] == "" + + # Бюджет неудач по имени не тронут — иначе насыщением блокируют чужой вход. + assert [e for e in events if e["event_type"] == "login_failed"] == [] + assert not auth_router._USERNAME_FAIL_LIMITER._hits + + # Окно прошло — следующий отказ приносит НАКОПЛЕННОЕ, а не единицу. + monkeypatch.setattr(auth_router, "_saturation_reported_at", time.monotonic() - 61.0) + resp = client.post( + "/api/v1/auth/login", + json={"username": "ghost-last", "password": "x"}, + headers={"x-forwarded-for": "203.0.113.5"}, + ) + assert resp.status_code == 429 + saturated = [e for e in events if e["event_type"] == "login_verify_saturated"] + assert len(saturated) == 2 + assert saturated[1]["payload"]["rejected"] == 20, "счётчик за окно потерян" + # Без этого числа 20 отказов читались бы как «20 за секунду», хотя копились + # они минуту: хвост уезжает в запись, датированную моментом СЛЕДУЮЩЕГО + # отказа и подписанную ЕГО адресом — возможно, случайного соседа по NAT. + assert saturated[1]["payload"]["since_prev_s"] == pytest.approx(61.0, abs=1.0) + + +# --------------------------------------------------------------------------- +# 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..a885b74e --- /dev/null +++ b/tradein-mvp/backend/tests/test_backfill_honest_status.py @@ -0,0 +1,100 @@ +"""#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 — БД замокана (mark_done/mark_failed/ +mark_banned здесь fake-заглушки, регистрирующие ТОЛЬКО факт вызова). Это значит: кейсы +ниже с высокой долей отказов (attempted=50, failed=38 или 36 — 76%/72%), ожидающие +'done', проверяют лишь то, КАКОЙ финализатор ВЫБРАЛ mark_backfill_finished (#2674: +"обогатили хоть что-то — успех"), а НЕ то, что реально запишет в БД mark_done. С +honest-run-status (2026-08-15) mark_done САМ переквалифицирует такой прогон в 'failed' +через _failed_ratio_too_high (доля отказов >= 0.5) — реальный терминальный статус +для этих двух кейсов на проде теперь 'failed', не 'done'. Это намеренно проверяется +отдельно, БЕЗ мока mark_done, в tests/test_honest_run_status_failed_ratio.py +(test_prod_fact_avito_15_08_no_longer_done и соседние) — не читай эти два кейса как +"76%/72% отказов = 'done' в проде". +""" + +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)) + ), + # **k — mark_banned принимает ещё и ban_kind (#2686/#2764); этот тест про + # ВЫБОР финализатора, диагноз проверяет test_2764_ban_kind_no_default.py. + patch.object( + runs_mod, "mark_banned", lambda db, rid, err, c, **k: 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"), + # Частичный прогон: обогатили хоть что-то → mark_backfill_finished ВЫБИРАЕТ + # mark_done как финализатор (#2674). 76% отказов (38 из 50) — здесь mark_done + # замокан, поэтому статус остаётся 'done'; в реальном mark_done с + # honest-run-status (2026-08-15) это переквалифицируется в 'failed' + # (_failed_ratio_too_high, доля >= 0.5) — см. докстринг модуля. + ({"attempted": 50, "enriched": 12, "blocked": 0, "failed": 38}, False, "done"), + # Блоки были, но прогон доработал и обогатил — mark_backfill_finished выбирает + # НЕ 'banned'. 72% отказов (36 из 50) — та же оговорка: реальный mark_done + # переквалифицирует в 'failed', см. докстринг модуля выше. + ({"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..8a386aa4 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: @@ -495,7 +500,11 @@ async def test_backfill_house_imv_ok_path(): patch("app.services.house_imv_backfill.save_imv_result") as mock_save, ): mock_mappings = MagicMock() - mock_mappings.all.return_value = houses + # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error, + # затем основную по only_status. Фейк отвечает одним и тем же списком на любой + # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом + # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются). + mock_mappings.all.side_effect = [[], houses] mock_db.execute.return_value.mappings.return_value = mock_mappings result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0) @@ -534,7 +543,11 @@ async def test_backfill_house_imv_no_params(): patch("app.services.house_imv_backfill._mark_status") as mock_mark, ): mock_mappings = MagicMock() - mock_mappings.all.return_value = houses + # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error, + # затем основную по only_status. Фейк отвечает одним и тем же списком на любой + # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом + # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются). + mock_mappings.all.side_effect = [[], houses] mock_db.execute.return_value.mappings.return_value = mock_mappings result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0) @@ -588,7 +601,11 @@ async def test_backfill_house_imv_not_found(): patch("app.services.house_imv_backfill._mark_status") as mock_mark, ): mock_mappings = MagicMock() - mock_mappings.all.return_value = houses + # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error, + # затем основную по only_status. Фейк отвечает одним и тем же списком на любой + # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом + # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются). + mock_mappings.all.side_effect = [[], houses] mock_db.execute.return_value.mappings.return_value = mock_mappings result = await backfill_house_imv(mock_db, batch_size=10, request_delay_sec=0.0) 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_ceiling_height_unify_2699.py b/tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py new file mode 100644 index 00000000..2ae59653 --- /dev/null +++ b/tradein-mvp/backend/tests/test_ceiling_height_unify_2699.py @@ -0,0 +1,193 @@ +"""#2699 — высота потолков сведена к одной колонке listings.ceiling_height_m. + +Что было (прод-замер 2026-08-07, 94 373 объявления): + +* ``ceiling_height`` (мигр. 019, numeric(3,2)) — 8 554 значения: cian 855, + yandex 7 699. ``ceiling_height_m`` (мигр. 111, numeric(5,2)) — 14 824: + avito 7 149, yandex 7 675. +* Пересечение 7 675 строк, РАСХОЖДЕНИЙ НОЛЬ — одно и то же значение в двух + ящиках, а не два конфликтующих источника. +* Эстиматор (``_apply_kitchen_ceiling_signal``) читает только ``_m`` → 855 + циановских + 24 яндексовых значения не видит никто. +* Гейт правдоподобия был только у yandex SERP → avito detail нагнал 26 значений + > 6 м (максимум 29.90) и 83 ровных 0.00. + +Каждый тест ниже КРАСНЫЙ на коде до #2699. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from unittest.mock import MagicMock + +import pytest +from scraper_kit.ceiling_height import plausible_ceiling_m +from scraper_kit.providers.avito.detail import _parse_height_m +from scraper_kit.providers.cian.detail import DetailEnrichment as CianEnrichment +from scraper_kit.providers.cian.detail import save_detail_enrichment as cian_save +from scraper_kit.providers.yandex.detail import DetailEnrichment as YandexEnrichment +from scraper_kit.providers.yandex.detail import save_detail_enrichment as yandex_save + +from app.services.matching.conflict_resolution import ( + HOUSE_FIELD_PRIORITY, + LISTING_FIELD_PRIORITY, +) + +# ── гейт правдоподобия ─────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (2.7, 2.7), # типовая панелька + (2.0, 2.0), # нижняя граница включительно + (6.0, 6.0), # верхняя граница включительно (двухуровневая карточка) + (5.97, 5.97), # реальный максимум cian на проде + ("2,7".replace(",", "."), 2.7), # строковый вход от парсера + (29.90, None), # прод-максимум мусора avito + (18.0, None), # мусор yandex SERP из #2007 + (0.0, None), # 83 строки avito на проде + (1.70, None), # ниже жилого потолка + (9.99, None), # влезает в numeric(3,2), но потолком не является + (None, None), + ("не число", None), + ], +) +def test_plausible_ceiling_m_band(raw: object, expected: float | None) -> None: + assert plausible_ceiling_m(raw) == expected # type: ignore[arg-type] + + +def test_avito_parse_height_rejects_impossible() -> None: + """КОРЕНЬ мусора: парсер avito брал первое число строки без проверки. + + Красный до #2699: возвращал 29.9 / 0.0 и они уезжали в ceiling_height_m, + который читает эстиматор. + """ + assert _parse_height_m("2,7 м") == pytest.approx(2.7) + assert _parse_height_m("3 м") == pytest.approx(3.0) + assert _parse_height_m("29,9 м") is None + assert _parse_height_m("0 м") is None + assert _parse_height_m("нет данных") is None + + +# ── писатели пишут В КАНОН ─────────────────────────────────────────────────── + + +def _mock_db() -> MagicMock: + """Session-заглушка, копящая (sql, params) каждого execute.""" + db = MagicMock() + calls: list[tuple[str, dict]] = [] + + @contextmanager + def _nested(): # type: ignore[no-untyped-def] + yield MagicMock() + + db.begin_nested.side_effect = _nested + + def _execute(sql, params=None): # type: ignore[no-untyped-def] + calls.append((str(sql), params or {})) + result = MagicMock() + result.fetchone.return_value = None + result.scalar_one_or_none.return_value = None + result.rowcount = 1 + return result + + db.execute.side_effect = _execute + db._calls = calls + return db + + +def _yandex_enrichment(ceiling_height: float) -> YandexEnrichment: + return YandexEnrichment( + offer_id="1234567890", + source_url="https://realty.yandex.ru/offer/1234567890/", + ceiling_height=ceiling_height, + ) + + +def _listings_update_sql(db: MagicMock) -> str: + for sql, _params in db._calls: + if "UPDATE listings" in sql: + return sql + raise AssertionError("не нашли UPDATE listings") + + +def test_cian_detail_writes_canonical_column() -> None: + """855 значений Циана уходили в колонку, которую эстиматор не читает. + + Красный до #2699: UPDATE присваивал `ceiling_height`, не `ceiling_height_m`. + """ + db = _mock_db() + cian_save(db, 55, CianEnrichment(ceiling_height=2.7)) + + sql = _listings_update_sql(db) + assert "ceiling_height_m = COALESCE" in sql + assert "ceiling_height = COALESCE" not in sql + + +def test_yandex_detail_writes_canonical_column() -> None: + """Красный до #2699: yandex detail тоже писал только в 019-колонку.""" + db = _mock_db() + yandex_save(db, 77, _yandex_enrichment(2.55)) + + sql = _listings_update_sql(db) + assert "ceiling_height_m = COALESCE" in sql + assert "ceiling_height = COALESCE" not in sql + + +def test_detail_writers_gate_impossible_values() -> None: + """Невозможная высота не доезжает до БД ни одним из detail-путей.""" + db_cian = _mock_db() + cian_save(db_cian, 55, CianEnrichment(ceiling_height=29.9)) + assert next(p for s, p in db_cian._calls if "UPDATE listings" in s)["ch"] is None + + db_yandex = _mock_db() + yandex_save(db_yandex, 77, _yandex_enrichment(0.0)) + assert next(p for s, p in db_yandex._calls if "UPDATE listings" in s)["ceiling_height"] is None + + +def test_base_upsert_no_longer_writes_deprecated_column() -> None: + """save_listings писал ОДИН param в ОБЕ колонки — источник дубля. + + Красный до #2699: SQL содержал `ceiling_height,` в списке колонок INSERT. + Проверяем по исходнику модуля: собрать SQL иначе можно только выполнив + upsert против живой БД. + """ + import re + from pathlib import Path + + import scraper_kit.base as base + + src = Path(base.__file__).read_text(encoding="utf-8") + # SQL-упоминания колонки: `ceiling_height` не как префикс `ceiling_height_m` + # и не внутри комментария (`--`). + offenders = [ + line + for line in src.splitlines() + # `ceiling_height` в позиции SQL-колонки: перед `,` (список INSERT) или + # перед `=` (SET). Импорт модуля-гейта и комментарии не считаются. + if re.search(r"\bceiling_height\b(?!_m)\s*[,=]", line) and "--" not in line + ] + assert offenders == [], f"base.py всё ещё пишет DEPRECATED-колонку: {offenders}" + + +# ── карта приоритетов больше не указывает в пустоту ────────────────────────── + + +def test_house_priority_has_no_phantom_ceiling_rule() -> None: + """Колонки `ceiling_height` в таблице houses нет и не было. + + Красный до #2699: правило существовало и не могло сработать ни разу. + """ + assert not [k for k in HOUSE_FIELD_PRIORITY if "ceiling" in k] + + +def test_listing_priority_names_the_real_column() -> None: + """Красный до #2699: ключ назывался `ceiling_height` — колонка есть, но её + не читает ни один потребитель, влияющий на цену.""" + assert "ceiling_height" not in LISTING_FIELD_PRIORITY + assert LISTING_FIELD_PRIORITY["ceiling_height_m"] == [ + "cian_detail", + "avito_detail", + "yandex_detail", + ] 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..1210e5ec 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) ──────────────────────────────────── @@ -53,8 +57,9 @@ def _mock_db_bti( address: str | None = "Екатеринбург, улица Малышева, 51", lat: float = 56.83, lon: float = 60.6, + city: str | None = "Екатеринбург", ) -> MagicMock: - """Mock db: SELECT address/lat/lon FROM listings → mappings().first() dict.""" + """Mock db: SELECT address/city/lat/lon FROM listings → mappings().first() dict.""" db = MagicMock() @contextmanager @@ -68,9 +73,10 @@ def _mock_db_bti( mock_result = MagicMock() mock_result.fetchone.return_value = None mock_result.scalar_one_or_none.return_value = None - if "SELECT address, lat, lon FROM listings" in sql_str: + if "SELECT address, city, lat, lon FROM listings" in sql_str: mock_result.mappings.return_value.first.return_value = { "address": address, + "city": city, "lat": lat, "lon": lon, } @@ -106,6 +112,9 @@ def test_bti_data_present_updates_house_columns_via_coalesce(): assert kwargs["ext_source"] == "cian_bti" assert kwargs["ext_id"] == "123" assert kwargs["address"] == "Екатеринбург, улица Малышева, 51" + # #2777: город-цель развёртки (listings.city) — независимое от адреса наблюдение; + # без него бескоординатная BTI-карточка матчится в одноимённый дом другого города. + assert kwargs["city"] == "Екатеринбург" call = _bti_update_call(db) assert call is not None, "UPDATE houses с BTI-полями не найден" @@ -190,6 +199,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_session.py b/tradein-mvp/backend/tests/test_cian_session.py index ee4e50e6..4468e8ed 100644 --- a/tradein-mvp/backend/tests/test_cian_session.py +++ b/tradein-mvp/backend/tests/test_cian_session.py @@ -32,6 +32,21 @@ def mock_db() -> MagicMock: return db +@pytest.fixture(autouse=True) +def _mock_resolve_proxy_url_sync(monkeypatch: pytest.MonkeyPatch) -> None: + """#2825: verify_session больше не читает settings.cian_proxy_url напрямую, а + зовёт resolve_proxy_url_sync("cian") (пул scrape_proxies + fallback внутри + app.services.proxy_egress, отдельно покрыт tests/services/test_proxy_egress.py). + Без мока это реальный SessionLocal() -> живая (падающая в test-окружении) БД, + из-за чего verify_session уходил в generic except ДО session.get и все + verify_session-тесты ниже ловили не то, что проверяют. Мокаем на уровне модуля, + чтобы не трогать каждый тест по отдельности.""" + monkeypatch.setattr( + "app.services.cian_session.resolve_proxy_url_sync", + lambda source: "http://test-proxy.local:8080", + ) + + # --------------------------------------------------------------------------- # CIAN_REQUIRED_COOKIES # --------------------------------------------------------------------------- @@ -424,6 +439,40 @@ async def test_verify_session_network_error_returns_source_unavailable_sentinel( assert result is not None +@pytest.mark.asyncio +async def test_verify_session_pool_exhausted_returns_source_unavailable_with_error_log( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """#2825 fail-closed (#2616): пул scrape_proxies исчерпан для cian (все узлы + забанены/нездоровы) — session.get НЕ вызывается (никуда не ходим без egress), + возвращается VERIFY_SOURCE_UNAVAILABLE_SENTINEL, но с ERROR-логом (не warning, + отдельным от обычного network-error пути) — явная деградация, а не проглатывание.""" + from app.services.proxy_egress import ProxyPoolExhaustedError + + def _raise(source: str) -> str | None: + raise ProxyPoolExhaustedError( + source, pool_total=2, banned_for_source=2, unhealthy_or_disabled=0 + ) + + monkeypatch.setattr("app.services.cian_session.resolve_proxy_url_sync", _raise) + + mock_session = AsyncMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session.get = AsyncMock(return_value=_make_cffi_resp(200)) + + with ( + patch("app.services.cian_session.AsyncSession", return_value=mock_session), + caplog.at_level("WARNING"), + ): + result = await verify_session({"DMIR_AUTH": "x"}) + + assert result is VERIFY_SOURCE_UNAVAILABLE_SENTINEL + mock_session.get.assert_not_called() + errors = [rec for rec in caplog.records if rec.levelname == "ERROR"] + assert any("пул прокси исчерпан" in rec.message.lower() for rec in errors) + + @pytest.mark.asyncio async def test_verify_session_uses_chrome120_impersonate() -> None: """curl_cffi AsyncSession must be constructed with impersonate='chrome120'.""" 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..44d0488d --- /dev/null +++ b/tradein-mvp/backend/tests/test_cian_views_parse.py @@ -0,0 +1,101 @@ +"""#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 logging +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 + ("нет данных", (None, None)), # ответ площадки «нечего показать» + ], +) +def test_parse_views_forms(phrase: str | None, expected: tuple[int | None, int | None]) -> None: + assert _parse_views(phrase) == expected + + +def test_unparsed_phrase_is_logged_with_itself(caplog: pytest.LogCaptureFixture) -> None: + """Провал разбора обязан быть отличим от «Циан не прислал» (эпик #2674). + + Прошлая пропажа держалась месяцами ровно потому, что в журнале не было ни + строки: пустая колонка одинаково выглядела и при сломанном разборе, и при + молчании площадки. Поэтому в warning уходит САМА фраза. + """ + with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"): + assert _parse_views("нет данных") == (None, None) + + assert "нет данных" in caplog.text, "неразобранная фраза не видна в журнале" + + +def test_absent_phrase_is_not_logged(caplog: pytest.LogCaptureFixture) -> None: + """Циан не прислал ключ вовсе — это не поломка разбора, шуметь не о чем.""" + with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"): + assert _parse_views(None) == (None, None) + + assert caplog.text == "" + + +def test_no_warning_when_today_tail_absent(caplog: pytest.LogCaptureFixture) -> None: + """«0 просмотров» без хвоста «за сегодня» — штатная форма, не повод для warning.""" + with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.detail"): + assert _parse_views("0 просмотров") == (0, None) + + assert caplog.text == "" + + +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..e138a150 100644 --- a/tradein-mvp/backend/tests/test_city_sweep.py +++ b/tradein-mvp/backend/tests/test_city_sweep.py @@ -27,6 +27,112 @@ 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 резолвится в человекочитаемое имя. + + #262 wave 2: CITY_LOCATIONS выросла с 5 (wave 1) до 45 (wave 1 + 40 wave-2 + городов) — вместо хардкода полного списка (дублировал бы CITY_DISPLAY_NAMES и + ломался при каждом новом городе) проверяем структурный инвариант: CITY_DISPLAY_ + NAMES обязан покрывать РОВНО те же slug'и, что CITY_LOCATIONS (иначе oblast-город + бы тихо получил ЕКБ-дефолт вместо своего имени) + spot-check wave-1 (не тронуты + этим PR) и по одному wave-2 city из каждой tier-группы (все три id / только + cian+yandex / только cian). + """ + from scraper_kit.orchestration.pipeline import ( + CITY_DISPLAY_NAMES, + CITY_LOCATIONS, + resolve_city_name, + ) + + wave1_expected = { + "nizhniy_tagil": "Нижний Тагил", + "kamensk_uralskiy": "Каменск-Уральский", + "pervouralsk": "Первоуральск", + "verkhnyaya_pyshma": "Верхняя Пышма", + "serov": "Серов", + } + assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS) + assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items() + for slug, name in wave1_expected.items(): + assert resolve_city_name(slug) == name + # wave-2 spot-check: novouralsk (avito+cian+yandex), revda (cian+yandex, avito + # НЕ подтверждён), mikhaylovsk (только cian, yandex отсутствует у источника). + for slug, name in { + "novouralsk": "Новоуральск", + "revda": "Ревда", + "mikhaylovsk": "Михайловск", + }.items(): + assert slug in CITY_LOCATIONS + 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") == "Екатеринбург" + + +# ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный +# ЕКБ-fallback) ─────────────────────────────────────────────────────────────────── + + +async def test_run_avito_city_sweep_raises_on_known_city_without_avito_slug() -> None: + """revda — известный CITY_LOCATIONS город, но avito_slug=None (не подтверждён). + + run_avito_city_sweep обязан упасть ДО любого сетевого/DB похода — иначе + `_city_seg()` молча взяла бы 'ekaterinburg' и sweep собрал бы ЕКБ под меткой + 'revda'. Raise происходит в самом начале функции (до await) — MagicMock() для + config/matcher/enrichment безопасен, до них не доходит.""" + from scraper_kit.orchestration.pipeline import run_avito_city_sweep + + with pytest.raises(ValueError, match="revda"): + await run_avito_city_sweep( + MagicMock(), + run_id=1, + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + city_slug="revda", + ) + + +async def test_run_yandex_city_sweep_raises_on_known_city_without_yandex_rgid() -> None: + """mikhaylovsk — известный CITY_LOCATIONS город, но yandex_rgid=None (город + отсутствует в гео-базе Яндекса вообще). run_yandex_city_sweep обязан упасть ДО + любого сетевого/DB похода — иначе YandexRealtyScraper(city_rgid=None) молча + взял бы ЕКБ rgid.""" + from scraper_kit.orchestration.pipeline import run_yandex_city_sweep + + with pytest.raises(ValueError, match="mikhaylovsk"): + await run_yandex_city_sweep( + MagicMock(), + run_id=1, + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + city_slug="mikhaylovsk", + ) + + +async def test_run_avito_city_sweep_does_not_raise_for_ekb_none_city_slug() -> None: + """city_slug=None (ЕКБ-путь, back-compat) НЕ должен затронуть новый guard — guard + условие `_loc is not None`, а get_city_location(None) возвращает None.""" + from scraper_kit.orchestration.pipeline import get_city_location + + assert get_city_location(None) is None + + # ── CitySweepCounters ─────────────────────────────────────────────────────── @@ -115,7 +221,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 +244,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 +298,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_consent_text_frontend_sync.py b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py new file mode 100644 index 00000000..b82bda69 --- /dev/null +++ b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py @@ -0,0 +1,194 @@ +"""ЭТАП 4 B2C launch — consent-text sync guard (part D). + +WHY: + _CONSENT_TEXT_SNAPSHOT in app/api/v1/lead.py is a durable 152-ФЗ + proof-of-consent: it must be the EXACT text a user actually saw and agreed + to. Before this test, the only thing keeping it in sync with the real + frontend checkbox label (LeadForm.tsx) was a code COMMENT ("Должен + ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx"). A comment cannot fail CI + -- a frontend copy edit could silently drift from the backend snapshot, + and every future lead's "proof" would then misrepresent what the user + actually saw. + +WHAT: + Extract the actual consent-checkbox label text straight out of + LeadForm.tsx (regex, no JSX parser needed -- there is exactly one + in the file today) and assert it matches _CONSENT_TEXT_SNAPSHOT byte-for- + byte after whitespace normalisation (JSX text nodes wrap across source + lines; the DOM-rendered text collapses that to single spaces). The label + now wraps a `` ("Политикой обработки персональных данных" is a + clickable link to the actual policy document, RKN/owner requirement -- + the extractor strips JSX tags AND `{" "}` expression-spacers, keeping only + the human-readable text, so the comparison stays a FLAT string on both + sides). If someone edits ONE side without the other, this test fails. + + A second test (`test_consent_policy_version_matches_privacy_approval_date`) + guards the OTHER half of the same drift class found during triage: nothing + was checking that _CONSENT_POLICY_VERSION actually points at the privacy + policy edition it claims to (PRIVACY_APPROVAL in mera-public/content.ts). + Bumping the policy text without bumping the version tag (or vice versa) + would silently mislabel every lead's proof-of-consent snapshot. + + NOTE: the NEW anonymous-estimate consent text (_ESTIMATE_CONSENT_TEXT_SNAPSHOT + in app/services/estimator.py, ЭТАП 4 part A) has NO frontend counterpart yet + -- the anonymous /estimate flow isn't live (rbac_guard still requires + X-Authenticated-User on every non-public path, see app/core/rbac.py). When + that flow ships its own consent checkbox, add a second sync test here + mirroring this one -- do NOT rely on a comment for that pairing either. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_REPO_ROOT = Path(__file__).resolve().parents[2] + +_FRONTEND_LEAD_FORM = ( + _REPO_ROOT / "frontend" / "src" / "components" / "trade-in" / "v2" / "LeadForm.tsx" +) + +_FRONTEND_LEGAL_CONTENT = _REPO_ROOT / "frontend" / "src" / "app" / "mera-public" / "content.ts" + +# Родительный падеж месяцев, как их пишет владелец в content.ts ("13 августа 2026 г."). +_RU_MONTHS_GENITIVE = { + "января": 1, + "февраля": 2, + "марта": 3, + "апреля": 4, + "мая": 5, + "июня": 6, + "июля": 7, + "августа": 8, + "сентября": 9, + "октября": 10, + "ноября": 11, + "декабря": 12, +} + + +def _extract_span_text(tsx_source: str) -> str: + """Pull the text content of the (single) ... in LeadForm.tsx, + whitespace-normalised the same way a browser collapses JSX text-node + whitespace when rendering (multiple lines/indentation -> single spaces). + + The span may contain nested JSX markup (e.g. a wrapping part of the + label, and a `{" "}` expression-spacer forcing a real space between a text + node and the link on the next source line -- plain JSX whitespace between + a text node and a tag on separate lines collapses to NOTHING, not a + space, so LeadForm.tsx needs that explicit spacer for correct rendering). + Both are stripped here so the comparison is against the flat, human- + readable text a user actually sees -- not the markup. + """ + match = re.search(r"(.*?)", tsx_source, re.DOTALL) + assert match is not None, "no found in LeadForm.tsx -- consent label markup changed" + inner = match.group(1) + inner = re.sub(r"\{\s*[\"']\s*[\"']\s*\}", " ", inner) # {" "} spacer -> real space + inner = re.sub(r"\{/\*.*?\*/\}", " ", inner, flags=re.DOTALL) # JSX comments + inner = re.sub(r"<[^>]+>", "", inner) # strip remaining JSX tags (e.g. , ) + return re.sub(r"\s+", " ", inner).strip() + + +def _extract_privacy_approval_iso_date(content_ts_source: str) -> str: + """Pull the "DD YYYY" date out of PRIVACY_APPROVAL in + mera-public/content.ts and return it as an ISO "YYYY-MM-DD" string. + + PRIVACY_APPROVAL ("приказом директора № 1 от 13 августа 2026 г.") is the + order that approves the actual privacy-policy EDITION the consent + checkbox links to (/mera-public/privacy) -- it is the correct source of + truth for _CONSENT_POLICY_VERSION, as opposed to LEGAL_DOCS_REVISION + (which dates the offer + refund-policy documents, a different pair). + """ + match = re.search(r'PRIVACY_APPROVAL\s*=\s*"([^"]+)"', content_ts_source) + assert match is not None, "PRIVACY_APPROVAL constant not found in mera-public/content.ts" + date_match = re.search(r"(\d{1,2})\s+([а-яё]+)\s+(\d{4})", match.group(1)) + assert date_match is not None, f"no RU date found in PRIVACY_APPROVAL: {match.group(1)!r}" + day, month_name, year = date_match.groups() + month = _RU_MONTHS_GENITIVE.get(month_name) + assert month is not None, f"unknown RU month name in PRIVACY_APPROVAL: {month_name!r}" + return f"{year}-{month:02d}-{int(day):02d}" + + +def test_frontend_lead_form_exists() -> None: + assert _FRONTEND_LEAD_FORM.is_file(), f"missing frontend file: {_FRONTEND_LEAD_FORM}" + + +def test_backend_consent_snapshot_matches_frontend_checkbox_label() -> None: + """The whole point: this FAILS if lead.py._CONSENT_TEXT_SNAPSHOT and + LeadForm.tsx's checkbox label ever diverge -- no longer just a comment.""" + from app.api.v1.lead import _CONSENT_TEXT_SNAPSHOT + + frontend_text = _extract_span_text(_FRONTEND_LEAD_FORM.read_text(encoding="utf-8")) + backend_text = re.sub(r"\s+", " ", _CONSENT_TEXT_SNAPSHOT).strip() + + assert frontend_text == backend_text, ( + "consent text drift detected between app/api/v1/lead.py._CONSENT_TEXT_SNAPSHOT " + "and frontend/src/components/trade-in/v2/LeadForm.tsx checkbox label -- the " + "152-ФЗ proof-of-consent snapshot no longer matches what users actually see. " + "Bump _CONSENT_POLICY_VERSION and update _CONSENT_TEXT_SNAPSHOT together with " + "any frontend copy change.\n" + f" frontend: {frontend_text!r}\n" + f" backend: {backend_text!r}" + ) + + +def test_consent_policy_version_matches_privacy_approval_date() -> None: + """Guards the other half of the same drift class as the test above: + _CONSENT_POLICY_VERSION must point at the privacy-policy EDITION it + claims to (PRIVACY_APPROVAL in mera-public/content.ts), not just be some + unrelated date bumped by hand. A silent mismatch here would mislabel + every lead's proof-of-consent snapshot with the wrong policy edition.""" + from app.api.v1.lead import _CONSENT_POLICY_VERSION + + assert _FRONTEND_LEGAL_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_LEGAL_CONTENT}" + expected_version = _extract_privacy_approval_iso_date( + _FRONTEND_LEGAL_CONTENT.read_text(encoding="utf-8") + ) + + assert _CONSENT_POLICY_VERSION == expected_version, ( + "app/api/v1/lead.py._CONSENT_POLICY_VERSION does not match the privacy-policy " + "edition date derived from PRIVACY_APPROVAL in frontend/src/app/mera-public/" + "content.ts. Bump _CONSENT_POLICY_VERSION to the new edition date whenever " + "PRIVACY_APPROVAL changes (or vice versa).\n" + f" _CONSENT_POLICY_VERSION: {_CONSENT_POLICY_VERSION!r}\n" + f" PRIVACY_APPROVAL date: {expected_version!r}" + ) + + +def test_extract_span_text_helper_is_whitespace_insensitive() -> None: + """Sanity check on the extraction helper itself, independent of the real file.""" + sample = """ + + Line one + Line two + + """ + assert _extract_span_text(sample) == "Line one Line two" + + +def test_extract_span_text_helper_strips_nested_link_and_spacer() -> None: + """Sanity check: a wrapping part of the label (plus the {" "} + spacer JSX needs to force a real space before it) must collapse to plain + text, exactly like a browser renders it -- this is the shape LeadForm.tsx + actually uses today for the policy-document link.""" + sample = """ + + Согласен(-на) на обработку персональных данных в соответствии с{" "} + + Политикой обработки персональных данных + + + """ + assert _extract_span_text(sample) == ( + "Согласен(-на) на обработку персональных данных в соответствии с " + "Политикой обработки персональных данных" + ) + + +def test_extract_privacy_approval_iso_date_helper() -> None: + """Sanity check on the RU-date extraction helper, independent of the real file.""" + sample = 'export const PRIVACY_APPROVAL = "приказом директора № 1 от 13 августа 2026 г.";' + assert _extract_privacy_approval_iso_date(sample) == "2026-08-13" diff --git a/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py new file mode 100644 index 00000000..65d85921 --- /dev/null +++ b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py @@ -0,0 +1,656 @@ +"""Tests for POST /api/v1/trade-in/coverage (issue #2894). + +Бесплатная проба покрытия для публичного лэндинга «МЕРА» — до оплаты человек +видит, сколько похожих квартир продаётся рядом и как быстро они уходят, без +единой рублёвой цифры в ответе. Covers: + - пороги ok/thin/not_covered для зелёных/жёлтых/неподдерживаемых городов + - пустая когорта (n=0) → not_covered даже в поддерживаемом городе; threshold + принудительно 0 в этом случае (nit-fix, повторная проверка #2894) + - в ответе НЕТ ни одного price-подобного поля (падающий тест на регресс схемы) + - MAJOR-1 (независимый ревью #2894): когорта пробы — sync с + estimator._COMMON_WHERE / Tier W (novostroyki guard, geo_precision != 'city', + price_rub > 0), не шире когорты платного эстиматора + - MAJOR-2: median_listing_age_days честно null при тонкой n_with_age выборке, + выбросы (> COVERAGE_MAX_AGE_DAYS) не тянут медиану — запинено ЖИВЫМ SQL + (см. test_max_age_outlier_excluded_from_median_live), не только подстрокой + - Повторная проверка #2894 (2026-08): город резолвится ИСКЛЮЧИТЕЛЬНО по + lat/lon (ближайший центроид), НЕ по моде `listings.city` (город + свип-контекста скрейпера, не адреса объявления) и НЕ по `city_hint` + (непроверенный клиентский вход) — см. app.api.v1.trade_in._resolve_coverage_city +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import MagicMock + +# psycopg v3 driver required; stub DATABASE_URL before any app import. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import +# (trade_in.py imports generate_trade_in_pdf at module load). +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +@pytest.fixture() +def trade_in_app() -> FastAPI: + """Minimal FastAPI app mounting only the trade-in router with DB overridden.""" + from app.api.v1 import trade_in as trade_in_module + from app.core.db import get_db + + application = FastAPI() + application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + + def _override_db(): + yield MagicMock() + + application.dependency_overrides[get_db] = _override_db + return application + + +def _row( + n_listings: int, + median_age_days: float | None, + n_with_age: int | None = None, +) -> dict: + """Строка, которую coverage_probe читает через db.execute(...).mappings().fetchone(). + + n_with_age по умолчанию = n_listings, если не задан явно (большинство старых + тестов не проверяют MAJOR-2 отдельно — сохраняем их поведение). + + Повторная проверка #2894: строка больше не несёт cohort_city — город + резолвится по lat/lon запроса, не по SQL-агрегату (см. модуль-докстринг). + """ + return { + "n_listings": n_listings, + "median_age_days": median_age_days, + "n_with_age": n_with_age if n_with_age is not None else n_listings, + } + + +def _db_mock_returning(row: dict | None) -> MagicMock: + """DB session mock — coverage_probe reads db.execute(...).mappings().fetchone().""" + db = MagicMock() + mapping_result = MagicMock() + mapping_result.fetchone.return_value = row + execute_result = MagicMock() + execute_result.mappings.return_value = mapping_result + db.execute.return_value = execute_result + return db + + +def _override(app: FastAPI, db: MagicMock) -> None: + from app.core.db import get_db + + app.dependency_overrides[get_db] = lambda: (yield db) + + +# Екатеринбург — совпадает (с точностью до сотен метров) с центроидом +# _CITY_CENTROIDS_DEG["Екатеринбург"], поэтому дефолтный payload детерминированно +# резолвится в зелёный город без доп. настройки координат в каждом тесте. +_BASE_PAYLOAD = {"lat": 56.8384, "lon": 60.6057, "rooms": 2, "area_m2": 50.0} + +# Координаты других городов из COVERAGE_GREEN/YELLOW_CITIES (те же значения, что +# _CITY_CENTROIDS_DEG в trade_in.py) — используются, когда тесту нужен НЕ ЕКБ. +_NIZHNY_TAGIL = {"lat": 57.9099, "lon": 59.9819} +_REVDA = {"lat": 56.7986, "lon": 59.9298} +_BEREZOVSKY = {"lat": 56.9096, "lon": 60.8034} + +# Реальные координаты Серова — ближайший поддерживаемый центроид (Нижний Тагил) +# в ~190 км, далеко за пределами COVERAGE_CITY_MATCH_RADIUS_KM=25 — гарантированно +# "город не определён", без совпадения ни с одним из 8 центроидов. +_FAR_AWAY_CITY = {"lat": 59.6047, "lon": 60.1970} + + +# ── Response schema: NO price anywhere (issue #2894 hard rule) ──────────────── + +_PRICE_LIKE_SUBSTRINGS = ("price", "cena", "цена", "rub", "₽", "cost") + + +def test_coverage_response_has_no_price_fields(trade_in_app: FastAPI) -> None: + """Regression guard: response schema must never grow a price-shaped field.""" + from app.schemas.trade_in import CoverageProbeResponse + + field_names = set(CoverageProbeResponse.model_fields.keys()) + offending = [f for f in field_names if any(sub in f.lower() for sub in _PRICE_LIKE_SUBSTRINGS)] + assert not offending, f"CoverageProbeResponse must not carry price fields: {offending}" + + +def test_coverage_actual_response_has_no_price_fields(trade_in_app: FastAPI) -> None: + """Same guard but on a live serialized response (belt-and-suspenders).""" + db = _db_mock_returning(_row(10, 21.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + assert resp.status_code == 200 + data = resp.json() + offending = [k for k in data if any(sub in k.lower() for sub in _PRICE_LIKE_SUBSTRINGS)] + assert not offending, f"response body must not carry price fields: {offending} in {data}" + + +# ── Thresholds: green city ───────────────────────────────────────────────────── + + +def test_green_city_ok_at_threshold(trade_in_app: FastAPI) -> None: + """Екатеринбург (зелёный, порог 8) — n=8 ровно на границе → ok.""" + db = _db_mock_returning(_row(8, 15.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["n_listings"] == 8 + assert data["threshold"] == 8 + assert data["city"] == "Екатеринбург" + assert data["radius_m"] == 1000 + assert data["median_listing_age_days"] == 15 + assert data["n_with_age"] == 8 + + +def test_green_city_thin_below_threshold(trade_in_app: FastAPI) -> None: + """Екатеринбург, n=7 (< порог 8) → thin, не ok и не not_covered.""" + db = _db_mock_returning(_row(7, 10.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + data = resp.json() + assert data["status"] == "thin" + assert data["n_listings"] == 7 + assert data["threshold"] == 8 + + +# ── Thresholds: yellow city ───────────────────────────────────────────────────── + + +def test_yellow_city_ok_at_threshold(trade_in_app: FastAPI) -> None: + """Нижний Тагил (жёлтый, порог 12) — n=12 → ok. Город резолвится из lat/lon.""" + db = _db_mock_returning(_row(12, 30.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_NIZHNY_TAGIL}) + data = resp.json() + assert data["status"] == "ok" + assert data["threshold"] == 12 + assert data["city"] == "Нижний Тагил" + + +def test_yellow_city_thin_below_threshold(trade_in_app: FastAPI) -> None: + """Ревда, n=11 (< порог 12) → thin.""" + db = _db_mock_returning(_row(11, 40.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_REVDA}) + data = resp.json() + assert data["status"] == "thin" + assert data["threshold"] == 12 + + +# ── City outside all centroids → always not_covered ───────────────────────────── + + +def test_unsupported_city_not_covered_even_with_high_n(trade_in_app: FastAPI) -> None: + """Точка вне 25-км радиуса всех центроидов → not_covered независимо от n_listings + (даже n=500).""" + db = _db_mock_returning(_row(500, 5.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY}) + data = resp.json() + assert data["status"] == "not_covered" + assert data["threshold"] == 0 + assert data["n_listings"] == 500 # честно отдаём счётчик, статус его игнорирует + assert data["city"] == "" # город не определён — не эхуется сырой строкой + + +# ── Empty cohort ────────────────────────────────────────────────────────────── + + +def test_empty_cohort_supported_city_not_covered(trade_in_app: FastAPI) -> None: + """n=0 в поддерживаемом (зелёном) городе → not_covered, не thin — честнее. + + Nit-fix (повторная проверка #2894): threshold обязан быть 0, а не реальным + порогом города (8) — при not_covered threshold "неприменим" по докстрингу + CoverageProbeResponse, независимо от ПРИЧИНЫ not_covered. + """ + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + data = resp.json() + assert data["status"] == "not_covered" + assert data["n_listings"] == 0 + assert data["median_listing_age_days"] is None + assert data["n_with_age"] == 0 + assert data["city"] == "Екатеринбург" # город резолвится по координатам всегда + assert data["threshold"] == 0 # nit: не 8, хотя город поддерживаемый + + +def test_empty_cohort_no_row_at_all(trade_in_app: FastAPI) -> None: + """DB возвращает None (defensive — count(*) агрегат всегда даёт строку, но + coverage_probe обязан не падать, даже если mock/driver вернул пусто).""" + db = _db_mock_returning(None) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "not_covered" + assert data["n_listings"] == 0 + assert data["median_listing_age_days"] is None + assert data["n_with_age"] == 0 + assert data["threshold"] == 0 + + +# ── Город резолвится ТОЛЬКО по координатам — не по listings.city, не по city_hint ── + + +def test_city_resolved_from_coordinates_not_cohort_mode(trade_in_app: FastAPI) -> None: + """Точка в Берёзовском → city='Берёзовский' (а не 'Екатеринбург'). + + Регресс на прод-замер (повторная проверка #2894): в радиусе 1000м вокруг + Берёзовского 90/90 строк listings имеют city='Екатеринбург' (город + свип-контекста скрейпера, миграция 196) — старая логика (мода когорты) + отдала бы 'Екатеринбург'. Ручка больше НЕ читает cohort city из SQL вовсе. + """ + db = _db_mock_returning(_row(8, 5.0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY}) + data = resp.json() + assert data["city"] == "Берёзовский" + assert data["status"] == "ok" + assert data["threshold"] == 8 + + +def test_far_from_all_centroids_not_covered(trade_in_app: FastAPI) -> None: + """Точка за пределами 25 км от всех 8 центроидов → not_covered, city="".""" + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_FAR_AWAY_CITY}) + data = resp.json() + assert data["status"] == "not_covered" + assert data["city"] == "" + assert data["threshold"] == 0 + + +def test_city_hint_does_not_change_threshold_or_status(trade_in_app: FastAPI) -> None: + """city_hint — чисто информационное поле (повторная проверка #2894): точка в + Берёзовском + city_hint='Екатеринбург' обязана резолвиться в Берёзовский + (threshold=8, зелёный порог — оба города зелёные, поэтому дополнительно + проверяем n=8 → ok именно для Берёзовского, а не подмену клиентом города). + """ + db_with_hint = _db_mock_returning(_row(8, 5.0)) + _override(trade_in_app, db_with_hint) + client = TestClient(trade_in_app) + resp_with_hint = client.post( + "/api/v1/trade-in/coverage", + json={**_BASE_PAYLOAD, **_BEREZOVSKY, "city_hint": "Екатеринбург"}, + ) + + db_without_hint = _db_mock_returning(_row(8, 5.0)) + _override(trade_in_app, db_without_hint) + resp_without_hint = client.post( + "/api/v1/trade-in/coverage", json={**_BASE_PAYLOAD, **_BEREZOVSKY} + ) + + data_with, data_without = resp_with_hint.json(), resp_without_hint.json() + assert data_with["city"] == data_without["city"] == "Берёзовский" + assert data_with["threshold"] == data_without["threshold"] == 8 + assert data_with["status"] == data_without["status"] == "ok" + + +# ── MAJOR-2: median age — n_with_age threshold + outlier clamp ───────────────── + + +def test_median_age_null_below_min_age_samples(trade_in_app: FastAPI) -> None: + """n_with_age=2 (< COVERAGE_MIN_AGE_SAMPLES=5) → median_listing_age_days null, + даже если SQL посчитал percentile — "медиана" по 1-2 объявлениям не медиана.""" + from app.api.v1.trade_in import COVERAGE_MIN_AGE_SAMPLES + + assert COVERAGE_MIN_AGE_SAMPLES == 5 + db = _db_mock_returning(_row(20, 40.0, n_with_age=2)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + data = resp.json() + assert data["n_listings"] == 20 # когорта покрытия не урезается возрастным фильтром + assert data["n_with_age"] == 2 + assert data["median_listing_age_days"] is None + + +def test_median_age_present_at_min_age_samples_threshold(trade_in_app: FastAPI) -> None: + """n_with_age=5 (== порог) → median_listing_age_days отдаётся.""" + db = _db_mock_returning(_row(20, 40.0, n_with_age=5)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + resp = client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + data = resp.json() + assert data["n_with_age"] == 5 + assert data["median_listing_age_days"] == 40 + + +def test_max_age_outlier_days_passed_to_sql(trade_in_app: FastAPI) -> None: + """COVERAGE_MAX_AGE_DAYS=365 передаётся в SQL как параметр — выбросы (мёртвые + объявления) отсекаются percentile_cont FILTER на стороне БД, не в Python. + + Слабая (текстовая) проверка — подстрока встречается в SQL ДВАЖДЫ (count и + percentile_cont), поэтому `assert "..." in sql_text` одна ловит только + "убрали оба FILTER", не "убрали один из двух". Реальный поведенческий пин — + test_max_age_outlier_excluded_from_median_live ниже (живой Postgres). + """ + from app.api.v1.trade_in import COVERAGE_MAX_AGE_DAYS + + assert COVERAGE_MAX_AGE_DAYS == 365 + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + + call_args = db.execute.call_args + params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {}) + assert params["max_age_days"] == 365 + + sql_text = str(call_args[0][0]) + # count==2: и в count(*) FILTER, и в percentile_cont(...) FILTER — обе нужны, + # чтобы n_with_age и median_listing_age_days считались по ОДНОМУ и тому же + # предикату (иначе честный n_with_age маскирует нечестный медианный расчёт). + assert sql_text.count("days_on_market <= :max_age_days") == 2 + + +# ── DB dedup / cap params passed through ──────────────────────────────────────── + + +def test_coverage_sql_uses_radius_1000_and_area_tolerance(trade_in_app: FastAPI) -> None: + """SQL params: radius=1000 (строго), area ±15%, rooms exact.""" + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + + assert db.execute.called + call_args = db.execute.call_args + params = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("parameters", {}) + assert params["radius"] == 1000 + assert params["rooms"] == 2 + assert params["area_min"] == pytest.approx(50.0 * 0.85) + assert params["area_max"] == pytest.approx(50.0 * 1.15) + assert params["fresh_days"] == 14 + + +# ── MAJOR-1: cohort predicates — sync с estimator._COMMON_WHERE / Tier W ──────── +# +# Прямая регрессия из независимого ревью #2894: без этих трёх предикатов проба +# отвечает "ok" в точках, где платный эстиматор (radius Tier W, тот же 1000м) +# реально видит 0 — потому что вся когорта состоит из новостроек / city-centroid +# листингов, которые estimator._COMMON_WHERE / Tier W уже отсекают. Тест ловит +# случайное удаление ЛЮБОГО из трёх предикатов на уровне сгенерированного SQL — +# без живой БД, как и остальные тесты этого файла (см. test_gar_flats_loader.py +# для опционального real-Postgres-варианта аналогичной проверки в этом репо). + + +def test_cohort_sql_excludes_novostroyki(trade_in_app: FastAPI) -> None: + """Guard новостроек — sync с estimator._COMMON_WHERE (5460) / Tier W (5932).""" + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + + sql_text = str(db.execute.call_args[0][0]) + assert "listing_segment IS NULL OR listing_segment = 'vtorichka'" in sql_text + + +def test_cohort_sql_excludes_city_precision_geocodes(trade_in_app: FastAPI) -> None: + """geo_precision != 'city' — sync с estimator Tier W (5910/5945-5948, #769 Part E).""" + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + + sql_text = str(db.execute.call_args[0][0]) + assert "geo_precision IS DISTINCT FROM 'city'" in sql_text + + +def test_cohort_sql_excludes_zero_price(trade_in_app: FastAPI) -> None: + """price_rub > 0 — sync с estimator._COMMON_WHERE (5441) / Tier W (5916).""" + db = _db_mock_returning(_row(0, None, n_with_age=0)) + _override(trade_in_app, db) + + client = TestClient(trade_in_app) + client.post("/api/v1/trade-in/coverage", json=_BASE_PAYLOAD) + + sql_text = str(db.execute.call_args[0][0]) + assert "price_rub > 0" in sql_text + + +# ── Live-DB tests (self-skip без реальной Postgres+PostGIS) ──────────────────── +# +# Опциональные тесты против настоящего Postgres (тот же паттерн self-skip, что +# test_gar_flats_loader.py::_live_session) — требуют TEST_DATABASE_URL/ +# DATABASE_URL, указывающий на реальную БД (не дефолтный localhost:5432/test- +# заглушку); иначе skip. В CI (ci-tradein.yml) этот DSN всегда живой Postgres+ +# PostGIS-контейнер. +# +# Fix (повторная проверка #2894): раньше `_live_session()` вызывался И в +# `pytest.mark.skipif(...)` (на этапе СБОРА тестов — соединение открывалось и +# никогда не закрывалось, при реальном DSN это утечка на КАЖДЫЙ импорт файла), +# И повторно внутри тела единственного live-теста. Теперь доступность БД +# проверяется отдельной дешёвой функцией с явным закрытием соединения +# (`_live_db_available`), а сама Session выдаётся pytest-фикстурой +# (`live_session`) с гарантированным close() в finally, а не ручным вызовом. + + +def _live_db_available() -> bool: + """Дешёвая проверка доступности live-Postgres — соединение открывается и + СРАЗУ закрывается (`with engine.connect()`), никакого висящего ORM Session. + + Используется только в `pytest.mark.skipif(...)`, который вычисляется на + этапе сбора тестов — до фикстур. + """ + try: + from sqlalchemy import create_engine + from sqlalchemy import text as sa_text + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return False + engine = create_engine(dsn, future=True) + try: + with engine.connect() as conn: + conn.execute(sa_text("SELECT 1")) + return True + finally: + engine.dispose() + except Exception: + return False + + +@pytest.fixture() +def live_session(): # type: ignore[no-untyped-def] + """Session для live-Postgres тестов — гарантированно закрывается после теста + (rollback + close + dispose в finally), в отличие от прежнего ручного вызова + `_live_session()` внутри тела каждого теста.""" + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + engine = create_engine(dsn, future=True) + session_factory = sessionmaker(bind=engine, future=True) + session = session_factory() + try: + yield session + finally: + session.rollback() + session.close() + engine.dispose() + + +# Координаты вне Свердловской обл. (реальные данные там ~56-60/58-64) — изолируют +# тестовую когорту от прод-данных без нужды в COMMIT/rollback гимнастики поверх +# чужой транзакции. +_LIVE_LAT, _LIVE_LON = 1.111, 2.222 + + +@pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД") +def test_major1_cohort_excludes_novostroyki_and_city_precision_live(live_session) -> None: # type: ignore[no-untyped-def] + from sqlalchemy import text as sa_text + + from app.api.v1.trade_in import coverage_probe + from app.schemas.trade_in import CoverageProbeInput + + db = live_session + rows = [ + # (source_url suffix, listing_segment, geo_precision, price_rub) — все + # остальные поля общие: rooms=2, area_m2=50, is_active, scraped_at=NOW(). + ("ok-vtorichka", None, None, 5_000_000), # counted + ("bad-novostroyka", "novostroyki", None, 5_000_000), # excluded + ("bad-city-precision", None, "city", 5_000_000), # excluded + ("bad-zero-price", None, None, 0), # excluded + ] + for suffix, segment, geo_precision, price in rows: + url = f"https://test.invalid/coverage-major1-{suffix}" + db.execute( + sa_text( + """ + INSERT INTO listings + (source, source_url, source_id, dedup_hash, address, lat, lon, + rooms, area_m2, price_rub, is_active, scraped_at, + listing_segment, geo_precision) + VALUES + ('test', :url, :url, :url, 'test addr', :lat, :lon, + 2, 50.0, :price, true, NOW(), :segment, :geo_precision) + """ + ), + { + "url": url, + "lat": _LIVE_LAT, + "lon": _LIVE_LON, + "price": price, + "segment": segment, + "geo_precision": geo_precision, + }, + ) + + result = coverage_probe( + CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db + ) + # Только первая (ok-vtorichka) строка должна попадать в когорту — + # каждая следующая вставка не должна сдвигать счётчик. + assert result.n_listings == 1, ( + f"predicate regression: n_listings={result.n_listings} after inserting " + f"{suffix!r} (segment={segment!r} geo_precision={geo_precision!r} " + f"price={price}) — expected still 1 (only ok-vtorichka counted)" + ) + + +@pytest.mark.skipif(not _live_db_available(), reason="нет доступной Postgres test-БД") +def test_max_age_outlier_excluded_from_median_live(live_session) -> None: # type: ignore[no-untyped-def] + """MAJOR-2 поведенческий пин (повторная проверка #2894). + + Текстовый тест (test_max_age_outlier_days_passed_to_sql) проверял, что + подстрока `days_on_market <= :max_age_days` встречается в SQL — но она там + ДВАЖДЫ (count и percentile_cont), и мутация «убрать FILTER у + percentile_cont, оставив у count» проходила зелёной: n_with_age (из count) + оставался честным, а percentile_cont без FILTER считал медиану по ВСЕМ + days_on_market, включая выбросы. + + Вставляет когорту из 5 "нормальных" объявлений (days_on_market + 4/6/8/10/12, честная медиана — 8) и один выброс (days_on_market=4000, + > COVERAGE_MAX_AGE_DAYS=365). Проверяет, что после вставки выброса + n_with_age и median_listing_age_days НЕ меняются (выброс попадает только + в n_listings) — с правильными двумя FILTER это так; без FILTER у + percentile_cont медиана сдвинулась бы 8 → 9 (percentile_cont(0.5) по + [4,6,8,10,12,4000] = среднее 3-го и 4-го отсортированных значений = 9). + """ + from sqlalchemy import text as sa_text + + from app.api.v1.trade_in import coverage_probe + from app.schemas.trade_in import CoverageProbeInput + + db = live_session + normal_ages = [4, 6, 8, 10, 12] + for i, age in enumerate(normal_ages): + url = f"https://test.invalid/coverage-major2-normal-{i}" + db.execute( + sa_text( + """ + INSERT INTO listings + (source, source_url, source_id, dedup_hash, address, lat, lon, + rooms, area_m2, price_rub, is_active, scraped_at, days_on_market) + VALUES + ('test', :url, :url, :url, :addr, :lat, :lon, + 2, 50.0, 5000000, true, NOW(), :age) + """ + ), + { + "url": url, + "addr": f"test addr coverage-major2-{i}", + "lat": _LIVE_LAT, + "lon": _LIVE_LON, + "age": age, + }, + ) + + result = coverage_probe( + CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db + ) + assert result.n_listings == 5 + assert result.n_with_age == 5 + assert result.median_listing_age_days == 8 + + outlier_url = "https://test.invalid/coverage-major2-outlier" + db.execute( + sa_text( + """ + INSERT INTO listings + (source, source_url, source_id, dedup_hash, address, lat, lon, + rooms, area_m2, price_rub, is_active, scraped_at, days_on_market) + VALUES + ('test', :url, :url, :url, 'test addr coverage-major2-outlier', :lat, :lon, + 2, 50.0, 5000000, true, NOW(), 4000) + """ + ), + {"url": outlier_url, "lat": _LIVE_LAT, "lon": _LIVE_LON}, + ) + + result_with_outlier = coverage_probe( + CoverageProbeInput(lat=_LIVE_LAT, lon=_LIVE_LON, rooms=2, area_m2=50.0), db + ) + assert result_with_outlier.n_listings == 6 # выброс всё же попадает в n_listings + assert result_with_outlier.n_with_age == 5, ( + f"MAJOR-2 regression: outlier (days_on_market=4000 > MAX=365) leaked into " + f"n_with_age={result_with_outlier.n_with_age} — count(*) FILTER пропал/сломан" + ) + assert result_with_outlier.median_listing_age_days == 8, ( + f"MAJOR-2 regression: median_listing_age_days=" + f"{result_with_outlier.median_listing_age_days} shifted by outlier — " + f"percentile_cont(...) FILTER пропал (мутация «убрать FILTER у " + f"percentile_cont, оставив у count»)" + ) diff --git a/tradein-mvp/backend/tests/test_data_erasure.py b/tradein-mvp/backend/tests/test_data_erasure.py new file mode 100644 index 00000000..9d0e039c --- /dev/null +++ b/tradein-mvp/backend/tests/test_data_erasure.py @@ -0,0 +1,378 @@ +"""ЭТАП 4 B2C launch — right-to-erasure mechanism (part C). + +Covers app/services/data_erasure.py: + - at least one identifier required (ValueError, no db.execute at all) + - username (B2B pilot): estimates + their leads + web_support_threads deleted + - estimate_ids only (anonymous, has the link/PDF): estimates + linked leads deleted, + web_support/tg_support untouched (no username = nothing to key them by) + - phone only: only leads deleted (no estimate/support action) + - tg_chat_id only: only tg_support deleted (anonymous Telegram-support path) + - ORDER: leads are captured/deleted BEFORE estimates (estimate_id FK is + ON DELETE SET NULL -- deleting estimates first would orphan the join) + - commits once at the end +""" + +from __future__ import annotations + +import os +import re +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import data_erasure + + +class _Result: + def __init__(self, rowcount: int = 0, scalar_ids: list[Any] | None = None) -> None: + self.rowcount = rowcount + self._scalar_ids = scalar_ids or [] + + def scalars(self) -> SimpleNamespace: + return SimpleNamespace(all=lambda: self._scalar_ids) + + +def _sql_of(call: Any) -> str: + stmt = call.args[0] + return str(getattr(stmt, "text", stmt)) + + +def test_requires_at_least_one_identifier() -> None: + db = MagicMock() + with pytest.raises(ValueError, match="at least one identifier"): + data_erasure.erase_person_data(db) + assert not db.execute.called + assert not db.commit.called + + +def test_erase_by_username_deletes_estimates_leads_and_web_support() -> None: + db = MagicMock() + owned_id = uuid4() + db.execute.side_effect = [ + _Result(scalar_ids=[owned_id]), # SELECT id FROM trade_in_estimates WHERE created_by + _Result(rowcount=2), # DELETE FROM trade_in_leads + _Result(rowcount=1), # DELETE FROM trade_in_estimates + _Result(rowcount=3), # DELETE FROM web_support_threads + ] + + out = data_erasure.erase_person_data(db, username="kopylov") + + assert out == { + "trade_in_estimates_deleted": 1, + "trade_in_leads_deleted": 2, + "web_support_deleted": 3, + "tg_support_deleted": 0, + } + assert db.commit.called + + calls = db.execute.call_args_list + assert "SELECT id FROM trade_in_estimates" in _sql_of(calls[0]) + assert "created_by" in _sql_of(calls[0]) + assert "DELETE FROM trade_in_leads" in _sql_of(calls[1]) + assert "DELETE FROM trade_in_estimates" in _sql_of(calls[2]) + assert "DELETE FROM web_support_threads" in _sql_of(calls[3]) + # estimate_ids captured from the SELECT reach the estimates DELETE. + estimates_delete_params = calls[2].args[1] + assert str(owned_id) in estimates_delete_params["ids"] + + +def test_leads_deleted_before_estimates_order() -> None: + """FK trade_in_leads.estimate_id is ON DELETE SET NULL -- capturing/deleting + leads must happen BEFORE the estimates DELETE, else the join key is gone.""" + db = MagicMock() + owned_id = uuid4() + db.execute.side_effect = [ + _Result(scalar_ids=[owned_id]), + _Result(rowcount=0), + _Result(rowcount=1), + _Result(rowcount=0), + ] + data_erasure.erase_person_data(db, username="kopylov") + calls = db.execute.call_args_list + leads_idx = next(i for i, c in enumerate(calls) if "DELETE FROM trade_in_leads" in _sql_of(c)) + estimates_idx = next( + i for i, c in enumerate(calls) if "DELETE FROM trade_in_estimates" in _sql_of(c) + ) + assert leads_idx < estimates_idx + + +def test_erase_by_estimate_ids_only_no_web_or_tg_support_touched() -> None: + db = MagicMock() + eid = uuid4() + db.execute.side_effect = [ + _Result(rowcount=1), # DELETE FROM trade_in_leads (matches estimate_id) + _Result(rowcount=1), # DELETE FROM trade_in_estimates + ] + + out = data_erasure.erase_person_data(db, estimate_ids=[eid]) + + assert out == { + "trade_in_estimates_deleted": 1, + "trade_in_leads_deleted": 1, + "web_support_deleted": 0, + "tg_support_deleted": 0, + } + assert db.execute.call_count == 2 # no username -> no SELECT, no web_support DELETE + + +def test_erase_by_phone_only_touches_only_leads() -> None: + db = MagicMock() + db.execute.side_effect = [_Result(rowcount=1)] # DELETE FROM trade_in_leads WHERE phone=... + + out = data_erasure.erase_person_data(db, phone="+79123456789") + + assert out == { + "trade_in_estimates_deleted": 0, + "trade_in_leads_deleted": 1, + "web_support_deleted": 0, + "tg_support_deleted": 0, + } + assert db.execute.call_count == 1 + params = db.execute.call_args_list[0].args[1] + assert params["phone"] == "+79123456789" + assert params["ids"] == [] + + +def test_phone_delete_normalizes_digits_on_both_sides() -> None: + """Regression guard for the deep-review MEDIUM finding (2026-08-06) + + follow-up (RU 8-vs-7 trunk prefix): lead.py stores phone exactly as typed + (no E.164 normalization, by design), so a differently-formatted-but- + same-number erasure request must still match, AND the RU '8...' vs + '+7...' trunk-prefix pair must collapse to the same canonical value. The + old exact `phone = :phone` comparison silently deleted 0 rows and still + returned HTTP 200 'erased' -- worse than an honest error under 152-ФЗ. + Both sides must go through the SAME normalization (_PHONE_COLUMN_NORM_SQL + / _PHONE_PARAM_NORM_SQL, see _ru_phone_norm_sql), and the literal-equality + path must be gone.""" + db = MagicMock() + db.execute.side_effect = [_Result(rowcount=1)] + + data_erasure.erase_person_data(db, phone="+7 999 123-45-67") + + sql = _sql_of(db.execute.call_args_list[0]) + # The comparison uses EXACTLY the two module-level normalized fragments + # (not a hand-rolled inline duplicate) -- pins that both sides go through + # the SAME normalization function, not two independently-drifting copies. + col_norm = data_erasure._PHONE_COLUMN_NORM_SQL + param_norm = data_erasure._PHONE_PARAM_NORM_SQL + assert f"{col_norm} = {param_norm}" in sql + # RU trunk-prefix collapse present on BOTH sides (11 digits, leading '8' -> '7'). + assert sql.count("length(regexp_replace") == 2 + assert sql.count("= '8'") == 2 + assert sql.count("'7' ||") == 2 + assert "phone = :phone" not in sql # old literal-equality path must be GONE + assert not re.search(r":\w+::", sql) # psycopg v3 CAST trap + + +def test_ru_phone_norm_sql_only_ever_takes_static_expressions() -> None: + """`_ru_phone_norm_sql` is a query-STRUCTURE builder, not a data path -- + the two module-level constants are the ONLY call sites, and both pass a + column name / CAST(:bind AS type), never an actual phone value. This + pins that contract so a future call site can't accidentally splice a + real phone string into the SQL text.""" + assert data_erasure._PHONE_COLUMN_NORM_SQL == data_erasure._ru_phone_norm_sql("phone") + assert data_erasure._PHONE_PARAM_NORM_SQL == data_erasure._ru_phone_norm_sql( + "CAST(:phone AS text)" + ) + # column side references the column, never the bind param; param side is the reverse. + assert ":phone" not in data_erasure._PHONE_COLUMN_NORM_SQL + assert "CAST(:phone AS text)" in data_erasure._PHONE_PARAM_NORM_SQL + + +def test_erase_by_tg_chat_id_only_touches_only_tg_support() -> None: + """Anonymous person with NO username, NO estimate link, NO lead phone -- but + they DID message @MERAsupport_bot -- can still be identified by their own + Telegram chat_id (see module docstring: not spoofable by a third party).""" + db = MagicMock() + db.execute.side_effect = [ + _Result(rowcount=0), # DELETE FROM trade_in_leads (no ids, no phone -> matches nothing) + _Result(rowcount=5), # DELETE FROM tg_support_users + ] + + out = data_erasure.erase_person_data(db, tg_chat_id=123456789) + + assert out == { + "trade_in_estimates_deleted": 0, + "trade_in_leads_deleted": 0, + "web_support_deleted": 0, + "tg_support_deleted": 5, + } + calls = db.execute.call_args_list + assert "DELETE FROM tg_support_users" in _sql_of(calls[-1]) + assert calls[-1].args[1]["chat_id"] == 123456789 + + +# ── Optional real-Postgres behavioural test (self-skips without a DB) ────────── +# Same pattern as tests/test_house_dedup_merge.py::_live_session -- CI runs the +# mock-only lane (DATABASE_URL is a placeholder), so this self-skips there; it +# only executes with a real reachable Postgres (e.g. local dev DB). + + +def _live_session() -> Any | None: + """Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None.""" + try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + from sqlalchemy import text as _t + + conn.execute(_t("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_erase_by_phone_finds_differently_formatted_number() -> None: + """End-to-end on a real DB: a lead stored with phone EXACTLY as typed + ('89991234567', no separators) must still be found and deleted when the + erasure requester supplies the SAME digits in a DIFFERENT format + ('8 (999) 123-45-67') -- proves the regexp_replace normalization fix + actually matches, not just that the SQL text contains the function name.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + lead_id: Any = None + try: + row = db.execute( + _t( + "INSERT INTO trade_in_leads (phone, consent, expires_at) " + "VALUES (:phone, TRUE, NOW() + interval '180 days') " + "RETURNING id" + ), + {"phone": "89991234567"}, + ).fetchone() + assert row is not None + lead_id = row[0] + db.commit() + + out = data_erasure.erase_person_data(db, phone="8 (999) 123-45-67") + + assert out["trade_in_leads_deleted"] == 1 + remaining = db.execute( + _t("SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ).scalar() + assert remaining == 0 + finally: + if lead_id is not None: + db.execute( + _t("DELETE FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ) + db.commit() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_erase_by_phone_finds_ru_trunk_prefix_variant() -> None: + """End-to-end on a real DB: the coordinator's exact follow-up gap + (2026-08-06) -- a lead stored as '89991234567' (leading '8') must be + found and deleted when the erasure requester supplies '+7 999 123-45-67' + (leading '+7'). Pure digit-stripping does NOT close this: stripped, the + two are '89991234567' vs '79991234567' -- different at digit 1. Only the + explicit 11-digit '8'->'7' collapse in _ru_phone_norm_sql makes them + equal. Counterfactual proven manually against this same DB (raw SQL, + see PR discussion): WITHOUT the collapse, `regexp_replace` alone finds 0 + rows for this exact pair.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + lead_id: Any = None + try: + row = db.execute( + _t( + "INSERT INTO trade_in_leads (phone, consent, expires_at) " + "VALUES (:phone, TRUE, NOW() + interval '180 days') " + "RETURNING id" + ), + {"phone": "89991234567"}, + ).fetchone() + assert row is not None + lead_id = row[0] + db.commit() + + # Counterfactual: plain digit-stripping (the PRE-follow-up fix) does NOT + # match this pair -- proves the 8-vs-7 gap was real, not a strawman. + digits_only_match = db.execute( + _t( + "SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid) " + "AND regexp_replace(phone, '\\D', '', 'g') " + "= regexp_replace(CAST(:phone AS text), '\\D', '', 'g')" + ), + {"id": str(lead_id), "phone": "+7 999 123-45-67"}, + ).scalar() + assert digits_only_match == 0, "digit-stripping alone must NOT match 8- vs 7-prefix" + + out = data_erasure.erase_person_data(db, phone="+7 999 123-45-67") + + assert out["trade_in_leads_deleted"] == 1 + remaining = db.execute( + _t("SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ).scalar() + assert remaining == 0 + finally: + if lead_id is not None: + db.execute( + _t("DELETE FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ) + db.commit() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_erase_by_phone_does_not_match_different_number() -> None: + """Negative control: a number differing in even ONE significant digit + must NOT be found -- proves the normalization is an exact-identity + check, not a fuzzy/truncated match that could delete a STRANGER's data. + Stored '89991234567' vs requested '+7 999 123-45-68' (last digit 7->8) + -- same length, same RU-looking shape, one digit off -- zero rows.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + lead_id: Any = None + try: + row = db.execute( + _t( + "INSERT INTO trade_in_leads (phone, consent, expires_at) " + "VALUES (:phone, TRUE, NOW() + interval '180 days') " + "RETURNING id" + ), + {"phone": "89991234567"}, + ).fetchone() + assert row is not None + lead_id = row[0] + db.commit() + + out = data_erasure.erase_person_data(db, phone="+7 999 123-45-68") + + assert out["trade_in_leads_deleted"] == 0, "one differing digit must NOT match" + remaining = db.execute( + _t("SELECT count(*) FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ).scalar() + assert remaining == 1, "row must survive an erasure request for a DIFFERENT number" + finally: + if lead_id is not None: + db.execute( + _t("DELETE FROM trade_in_leads WHERE id = CAST(:id AS uuid)"), + {"id": str(lead_id)}, + ) + db.commit() + db.close() 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_deactivate_stale_listings.py b/tradein-mvp/backend/tests/test_deactivate_stale_listings.py index f41dc200..a236a419 100644 --- a/tradein-mvp/backend/tests/test_deactivate_stale_listings.py +++ b/tradein-mvp/backend/tests/test_deactivate_stale_listings.py @@ -424,3 +424,184 @@ def test_migration_160_is_transactional() -> None: def test_migration_160_no_psycopg_trap() -> None: sql = _MIGRATION_160.read_text("utf-8") assert not re.search(r":\w+::", sql) + + +# ── null_segment_only (пустой listing_segment yandex/cian, никогда не переобходится) ── + + +def test_null_segment_sql_uses_is_null_not_any() -> None: + sql = str(task_mod._build_null_segment_sql("last_seen_at").text) + assert "listing_segment IS NULL" in sql + assert "ANY(CAST(:segments AS text[]))" not in sql + assert ":segments" not in sql + + +def test_null_segment_sql_filters_is_active_and_source() -> None: + sql = str(task_mod._build_null_segment_sql("last_seen_at").text) + assert "is_active = true" in sql + assert ":listing_source" in sql + assert "SET is_active = false" in sql + assert "DELETE" not in sql.upper() + + +def test_null_segment_sql_no_psycopg_trap() -> None: + sql = str(task_mod._build_null_segment_sql("last_seen_at").text) + assert not re.search(r":\w+::", sql) + assert "CAST(:ttl_days || ' days' AS interval)" in sql + + +def test_null_segment_only_and_segments_raises(monkeypatch: pytest.MonkeyPatch) -> None: + """null_segment_only=True + segments заданы -- неоднозначный запрос, ValueError.""" + failed: dict[str, Any] = {} + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_failed", + lambda _db, run_id, err, counters: failed.update(run_id=run_id, err=err), + ) + db = _FakeDB(rowcount=0) + with pytest.raises(ValueError, match="null_segment_only"): + task_mod.deactivate_stale_listings( + db, + run_id=20, + listing_source="cian", + ttl_days=60, + segments=["vtorichka"], + null_segment_only=True, + ) # type: ignore[arg-type] + assert db.executed == [] + assert failed["run_id"] == 20 + + +def test_null_segment_only_deactivates_via_is_null(monkeypatch: pytest.MonkeyPatch) -> None: + 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(rowcount=211) + out = task_mod.deactivate_stale_listings( + db, + run_id=21, + listing_source="cian", + ttl_days=60, + null_segment_only=True, + ) # type: ignore[arg-type] + assert out == {"deactivated": 211} + assert db.committed is True + stmt, params = db.executed[0] + sql = _sql_text(stmt) + assert "listing_segment IS NULL" in sql + assert params is not None + assert "segments" not in params + assert params["listing_source"] == "cian" + assert params["ttl_days"] == 60 + assert marked["counters"] == {"deactivated": 211} + + +def test_null_segment_only_confirmations_sql_uses_is_null() -> None: + sql = str( + task_mod._build_confirmations_sql( + "last_seen_at", with_segments=False, null_segment_only=True + ).text + ) + assert "listing_segment IS NULL" in sql + assert ":segments" not in sql + + +def test_null_segment_only_revisit_floor_sql_uses_is_null() -> None: + sql = str( + task_mod._build_revisit_floor_sql( + "last_seen_at", with_segments=False, null_segment_only=True + ).text + ) + assert "l.listing_segment IS NULL" in sql + assert ":segments" not in sql + + +def test_null_segment_only_default_is_false(monkeypatch: pytest.MonkeyPatch) -> None: + """Обратная совместимость: старые вызовы без null_segment_only ведут себя как раньше.""" + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None) + db = _FakeDB(rowcount=3) + task_mod.deactivate_stale_listings( + db, run_id=22, listing_source="avito", ttl_days=10, segments=None + ) # type: ignore[arg-type] + stmt, _params = db.executed[0] + sql = _sql_text(stmt) + assert "listing_segment IS NULL" not in sql + + +# ── Migration 266 (deactivate_stale_yandex_null_segment / _cian_null_segment) ─────── +# Renumbered 264 -> 266 (collision with forgejo/main's 264_deactivate_stale_avito_cap_mult.sql +# / 265_deactivate_stale_yandex_cap_mult.sql, merged после того как эта ветка забрала 264). + +_MIGRATION_266 = _SQL_DIR / "266_seed_deactivate_stale_null_segment_yandex_cian.sql" + + +def test_migration_266_exists() -> None: + assert _MIGRATION_266.is_file(), f"missing migration: {_MIGRATION_266}" + + +def test_migration_266_seeds_yandex_and_cian_null_segment() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert "'deactivate_stale_yandex_null_segment'" in sql + assert "'deactivate_stale_cian_null_segment'" in sql + + +def test_migration_266_null_segment_only_true() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert '"null_segment_only":true' in sql + + +def test_migration_266_ttl_60_days() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert '"ttl_days":60' in sql + + +def test_migration_266_gates_disabled() -> None: + """min_confirmations/revisit_floor_quantile выключены явно -- население слишком + мало для порогов, откалиброванных под полноценный vtorichka-свип (см. файл).""" + sql = _MIGRATION_266.read_text("utf-8") + assert '"min_confirmations":0' in sql + assert '"revisit_floor_quantile":0' in sql + + +def test_migration_266_is_idempotent() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert "ON CONFLICT (source) DO NOTHING" in sql + + +def test_migration_266_is_transactional() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_266_enabled_true() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert "true" in sql + + +def test_migration_266_window_7_to_8_utc() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert re.search(r"\b7\b", sql), "window_start_hour 7 missing" + assert re.search(r"\b8\b", sql), "window_end_hour 8 missing" + + +def test_migration_266_no_psycopg_trap() -> None: + sql = _MIGRATION_266.read_text("utf-8") + assert not re.search(r":\w+::", sql) + + +def test_handler_wires_null_segment_only_from_schedule_params() -> None: + """Читаем исходник файлом (как test_handler_wires_revisit_floor_from_schedule_params): + 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] + flat = " ".join(job.split()) + assert 'params.get("null_segment_only", False)' in flat + assert "null_segment_only=null_segment_only" in job diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py new file mode 100644 index 00000000..fbfc49f5 --- /dev/null +++ b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py @@ -0,0 +1,444 @@ +"""Пол TTL по измеренному циклу переобхода (#2659). + +Гейт здоровья (#2710) отвечает «источник собирается?». Этот пол отвечает на второй +вопрос issue — «а достаточно ли ttl_days, чтобы молчание означало снятие?». Пока свип +возвращается к строке реже, чем раз в ttl_days, TTL меряет нашу выборку, а не жизнь +объявления, и источник при этом ЗДОРОВ — гейт молчит. + +Ключевой тест — test_effective_ttl_covers_every_proven_false_kill: он проигрывает +РЕАЛЬНЫЙ прод-случай (127 строк, снятых на возрасте 29.9..30.3 суток при TTL=30 и +доказанно вернувшихся живыми) и требует, чтобы эффективный TTL накрыл каждую. На +старом коде — без пола — эффективный TTL остаётся 30, и тест падает на всех срезах. +""" + +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 + +# ── Прод-замер 2026-08-09 (read-only), из-за которого пол существует ─────────── +# С деплоя гейта 06.08 TTL снял 1 028 строк, 127 уже снова активны (12.4%). +# В Екатеринбурге — единственном городе с настоящим покрытием — доля ложных 100%. +_FALSE_KILLS_BY_CITY: dict[str, tuple[int, int]] = { # срез -> (снято, снова активны) + "cian/Екатеринбург": (103, 103), + "yandex/Екатеринбург": (24, 24), + "cian/без города": (560, 0), + "yandex/без города": (341, 0), +} +# Возраст строки на момент снятия у всех 127 доказанно ложных снятий, суток. +_FALSE_KILL_AGE_MIN = 29.9 +_FALSE_KILL_AGE_MAX = 30.3 + +# Пол, который отдаёт ТОТ ЖЕ запрос на проде (percentile_disc 0.99, окно 3 суток, +# срез = срез TTL-джобы). Ключ -> (listing_source, segments, ttl_days сейчас, пол). +_PROD_FLOORS: dict[str, tuple[str, list[str] | None, int, float]] = { + "cian/vtorichka": ("cian", ["vtorichka"], 30, 34.0), + "yandex/vtorichka": ("yandex", ["vtorichka"], 30, 74.3), + "avito/все сегменты": ("avito", None, 10, 69.7), +} + +# Разрывы переобхода по срезу TTL-джобы (listing_source_snapshots, 40 суток): +# источник -> (p90, p99, ttl_days сейчас). Домклик — контрольная группа: почти +# полное суточное покрытие, TTL лежит в 4.5 раза выше хвоста, и снятие у него +# действительно означает снятие. +_REVISIT_TAIL: dict[str, tuple[float, float, int]] = { + "domklik/vtorichka": (1.9, 3.1, 14), + "cian/vtorichka": (10.9, 26.6, 30), + "yandex/vtorichka": (5.7, 43.0, 30), + "avito/vtorichka": (29.1, 42.1, 10), +} + + +# ── Фейковая сессия ─────────────────────────────────────────────────────────── + + +class _FakeResult: + def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None: + self.rowcount = rowcount + self._scalar = scalar_value + + def scalar(self) -> Any: + return self._scalar + + +class _FakeDB: + """Session-заглушка: percentile_disc -> пол, count(*) -> подтверждения, UPDATE -> rowcount.""" + + def __init__( + self, + *, + floor_days: float | None, + confirmations: int = 10_000, + rowcount: int = 137, + ) -> None: + self._floor = floor_days + 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 "percentile_disc" in sql: + return _FakeResult(scalar_value=self._floor) + 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 floor_query(self) -> tuple[str, dict[str, Any] | None]: + return next((e for e in self.executed if "percentile_disc" in e[0]), ("", None)) + + @property + def update_query(self) -> tuple[str, dict[str, Any] | None]: + return next((e for e in self.executed if "UPDATE listings" in e[0]), ("", None)) + + +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", "cian"), + ttl_days=kwargs.pop("ttl_days", 30), + **kwargs, + ) + + +# ── Исторический случай: 127 доказанных ложных снятий ───────────────────────── + + +def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.MonkeyPatch) -> None: + """Ни одно из 127 доказанно ложных снятий (cian/yandex) не должно повториться. + + Все они произошли на возрасте 29.9..30.3 суток. Эффективный TTL обязан быть + строго выше этого возраста на cian/yandex-срезах — иначе следующий прогон + снимет ту же строку снова. avito из этого цикла исключён намеренно: 127 + доказанных ложных снятий (_FALSE_KILLS_BY_CITY) измерены только по cian/yandex, + у avito другой сценарий и своя проверка ниже + (test_avito_prod_floor_is_capped_by_calibrated_cap_mult) -- калибровка cap_mult=6 + для avito (миграция 264_deactivate_stale_avito_cap_mult.sql) пиннится ТАМ, а не + здесь, чтобы не смешивать два разных замера под одним порогом + _FALSE_KILL_AGE_MAX, который к avito не относится. + """ + for slice_name, (source, segments, ttl_days, floor) in _PROD_FLOORS.items(): + if source == "avito": + continue + db = _FakeDB(floor_days=floor) + out = _run( + db, + monkeypatch, + listing_source=source, + ttl_days=ttl_days, + segments=segments, + revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE, + ) + effective = out["ttl_days_effective"] + assert effective > _FALSE_KILL_AGE_MAX, ( + f"{slice_name}: эффективный TTL {effective} не накрывает возраст ложного " + f"снятия {_FALSE_KILL_AGE_MAX} — 127 строк снимутся снова" + ) + _, update_params = db.update_query + assert update_params is not None + assert ( + update_params["ttl_days"] == effective + ), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен" + + +def _read_cap_mult_from_migration(filename: str, *, source: str) -> int: + """Читает cap_mult из UPDATE default_params миграции -- НЕ хардкодит дубль в тесте. + + Найдено ревью круга 3 2026-08-15: раньше тест ниже принимал cap_mult=6 как + аргумент напрямую, захардкоженный прямо в теле теста. Мутация значения в + 264_deactivate_stale_avito_cap_mult.sql (6 -> 2) НЕ трогала вход теста вовсе -- + набор оставался зелёным при любом реальном значении в миграции, то есть + калибровка нигде не была пином, только упоминанием в комментарии. Здесь + значение читается ИЗ ФАЙЛА миграции regex'ом, а ожидаемый результат + (ttl_days_effective, ttl_floor_capped) остаётся зафиксированным числом в самом + тесте -- так дрейф калибровки в миграции ломает тест, как и задумано. + """ + migration = Path(__file__).resolve().parents[1] / "data" / "sql" / filename + src = migration.read_text("utf-8") + # Порядок в файле -- jsonb_build_object('cap_mult', N) в SET, ЗАТЕМ WHERE source + # = '' ниже (см. 264/265_*.sql). DOTALL матчит перевод строки между ними; + # source в regex -- страховка от чтения не того UPDATE, если файл когда-нибудь + # станет мульти-source (сейчас в каждом файле ровно один UPDATE). + match = re.search( + r"jsonb_build_object\('cap_mult',\s*(\d+)\).*?WHERE\s+source\s*=\s*'" + + re.escape(source) + + r"'", + src, + re.DOTALL, + ) + assert match is not None, ( + f"{filename} сменил формат UPDATE default_params для source={source!r} -- " + "обнови regex в _read_cap_mult_from_migration" + ) + return int(match.group(1)) + + +def test_avito_prod_floor_is_capped_by_calibrated_cap_mult(monkeypatch: pytest.MonkeyPatch) -> None: + """Пиннит калибровку cap_mult=6 для avito (миграция + 264_deactivate_stale_avito_cap_mult.sql) на измеренном прод-поле _PROD_FLOORS + ("avito/все сегменты" = 69.7, замер 2026-08-09). + + cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции (regex), не хардкодится + здесь: дрейф калибровки в 264_*.sql (например 6 -> 2) меняет вход, но НЕ + ожидаемый результат ниже (60/70) -- эти числа пинят калибровку саму по себе, + поэтому дрейф ломает тест, как и задумано (см. _read_cap_mult_from_migration). + + С дефолтным cap_mult=2 потолок avito (20 сут) РЕЖЕТ ниже собственного хвоста + переобхода p99=42.1 (_REVISIT_TAIL) -- ровно тот false-kill, ради которого пол + заведён. С калиброванным cap_mult=6 потолок 60 сут -- выше и p99=42.1, и живого + прод-пика 52 (замер 08-10..08-12), и этого гипотетического замера 69.7 (капается + ровно на 60, не пропускается как есть). + """ + calibrated_cap_mult = _read_cap_mult_from_migration( + "264_deactivate_stale_avito_cap_mult.sql", source="deactivate_stale_avito" + ) + source, segments, ttl_days, floor = _PROD_FLOORS["avito/все сегменты"] + db = _FakeDB(floor_days=floor) + out = _run( + db, + monkeypatch, + listing_source=source, + ttl_days=ttl_days, + segments=segments, + revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE, + cap_mult=calibrated_cap_mult, + ) + assert out["ttl_days_effective"] == 60, "cap_mult из миграции 264 обязан дать потолок 60" + assert out["ttl_floor_capped"] == 1 + assert out["ttl_days_floor_raw"] == 70, "ceil(69.7) == 70 -- пол считается по real-числу" + + +def test_yandex_prod_floor_is_not_capped_by_calibrated_cap_mult( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Пиннит калибровку cap_mult=3 для yandex (миграция + 265_deactivate_stale_yandex_cap_mult.sql, найдено ревью круга 3 2026-08-15) на + измеренном прод-поле _PROD_FLOORS ("yandex/vtorichka" = 74.3). + + cap_mult -- ВХОД теста, читается ИЗ ФАЙЛА миграции 265 (тот же приём, что и у + avito выше): дрейф калибровки в 265_*.sql ломает тест. + + С дефолтным cap_mult=2 потолок yandex (60 сут) РЕЖЕТ живой пол (75-79 сут, + scrape_runs.counters 08-10..08-15 и live-замер 08-15) -- та же дыра, что у + avito, найдена позже (первая версия 264 ошибочно считала yandex безопасным по + устаревшему статическому p99=43.0). С калиброванным cap_mult=3 потолок 90 сут + выше живого пика 79.2 -- пол 74.3 из этого теста НЕ капается, эффективный TTL + равен сырому полу (75, ceil(74.3)). + """ + calibrated_cap_mult = _read_cap_mult_from_migration( + "265_deactivate_stale_yandex_cap_mult.sql", source="deactivate_stale_yandex" + ) + source, segments, ttl_days, floor = _PROD_FLOORS["yandex/vtorichka"] + db = _FakeDB(floor_days=floor) + out = _run( + db, + monkeypatch, + listing_source=source, + ttl_days=ttl_days, + segments=segments, + revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE, + cap_mult=calibrated_cap_mult, + ) + assert out["ttl_days_effective"] == 75, "ceil(74.3) == 75, потолок 90 не должен резать" + assert "ttl_floor_capped" not in out, "потолок 90 выше живого пола 74.3 -- капать нечего" + + +def test_false_kill_ages_sit_inside_the_old_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """Замер согласован сам с собой: снимали ровно на границе TTL=30, не раньше.""" + assert _FALSE_KILL_AGE_MIN < 30.0 <= _FALSE_KILL_AGE_MAX + for source, _segments, ttl_days, _floor in _PROD_FLOORS.values(): + if source in ("cian", "yandex"): + assert ttl_days == 30, f"{source}: прод-TTL разошёлся с замером" + + +def test_false_kill_rate_is_total_where_coverage_is_real() -> None: + """В городе с настоящим покрытием ложны ВСЕ снятия — это и есть корень #2659.""" + for slice_name in ("cian/Екатеринбург", "yandex/Екатеринбург"): + killed, returned = _FALSE_KILLS_BY_CITY[slice_name] + assert returned == killed, f"{slice_name}: замер разошёлся" + total_killed = sum(k for k, _ in _FALSE_KILLS_BY_CITY.values()) + total_returned = sum(r for _, r in _FALSE_KILLS_BY_CITY.values()) + assert total_killed == 1028 + assert total_returned == 127 + + +def test_control_group_domklik_needs_no_floor() -> None: + """Домклик — контроль: при почти полном суточном обходе TTL с запасом выше хвоста. + + Если бы пол был нужен всем подряд, он был бы нужен и источнику со сплошным + покрытием. Ему не нужен — значит меряем именно покрытие, а не «TTL маловат». + + Разделяет источники ЗАПАС над хвостом, а не сам факт превышения: у домклика + TTL/p99 = 4.5, у остальных трёх 0.24..1.13, то есть порог сидит вплотную к + хвосту или внутри него. Именно у самого «благополучного» из трёх (cian, 1.13) + и случились 103 доказанно ложных снятия: p99 запаса не даёт, снимает 1% живых. + """ + _p90, p99, ttl = _REVISIT_TAIL["domklik/vtorichka"] + assert ttl / p99 > 4, "домклик перестал быть контрольной группой — перемерить" + for name, (_p90, p99, ttl) in _REVISIT_TAIL.items(): + if name.startswith("domklik"): + continue + assert ttl / p99 < 1.5, f"{name}: TTL отошёл от хвоста обхода, замер устарел" + + +# ── Контракт пола ───────────────────────────────────────────────────────────── + + +def test_floor_never_lowers_configured_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """Пол поднимает порог и только. Здоровый источник с коротким циклом не даёт + поводу снять больше, чем задано оператором.""" + db = _FakeDB(floor_days=3.1) + out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 30 + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 30 + + +def test_floor_is_rounded_up_not_down(monkeypatch: pytest.MonkeyPatch) -> None: + """34.0 суток разрыва -> TTL 34, а 34.2 -> 35: округление в сторону осторожности.""" + db = _FakeDB(floor_days=34.2) + out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + assert out["revisit_floor_days"] == 35 + assert out["ttl_days_effective"] == 35 + + +def test_floor_disabled_keeps_old_behaviour(monkeypatch: pytest.MonkeyPatch) -> None: + """quantile=0 -> ни одного лишнего запроса, поведение как до правки.""" + db = _FakeDB(floor_days=74.3) + out = _run(db, monkeypatch, ttl_days=30) + assert out == {"deactivated": 137} + assert len(db.executed) == 1 + assert "percentile_disc" not in db.executed[0][0] + + +def test_missing_snapshot_history_leaves_ttl_as_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """NULL (истории снимков за окно нет) -> пол не выдумывается, TTL как задан.""" + db = _FakeDB(floor_days=None) + out = _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + assert "revisit_floor_days" not in out + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 30 + + +def test_floor_runs_before_any_write(monkeypatch: pytest.MonkeyPatch) -> None: + """Пол считается ДО UPDATE: снятое объявление возвращает только повторный сбор.""" + db = _FakeDB(floor_days=74.3) + _run(db, monkeypatch, ttl_days=30, revisit_floor_quantile=0.99) + kinds = ["floor" if "percentile_disc" in sql else "update" for sql, _ in db.executed] + assert kinds.index("floor") < kinds.index("update") + + +def test_floor_measures_same_slice_as_update(monkeypatch: pytest.MonkeyPatch) -> None: + """Срез пола совпадает со срезом UPDATE: тот же source и те же сегменты.""" + db = _FakeDB(floor_days=74.3) + _run( + db, + monkeypatch, + listing_source="yandex", + segments=["vtorichka"], + revisit_floor_quantile=0.99, + ) + floor_sql, floor_params = db.floor_query + assert "ANY(CAST(:segments AS text[]))" in floor_sql + assert floor_params is not None + assert floor_params["segments"] == ["vtorichka"] + assert floor_params["listing_source"] == "yandex" + + +def test_floor_uses_same_staleness_column_as_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """domklik считает свежесть по scraped_at (#2204) — пол обязан мерить ту же колонку, + иначе bulk-touch по last_seen_at показал бы цикл обхода там, где сбора нет.""" + db = _FakeDB(floor_days=20.0) + _run( + db, + monkeypatch, + listing_source="domklik", + ttl_days=14, + staleness_column="scraped_at", + revisit_floor_quantile=0.99, + ) + floor_sql, _ = db.floor_query + assert "l.scraped_at" in floor_sql + assert "l.last_seen_at" not in floor_sql + + +def test_floor_rejects_invalid_staleness_column(monkeypatch: pytest.MonkeyPatch) -> None: + """Whitelist колонки работает и на пути пола — интерполяции чужого имени нет.""" + db = _FakeDB(floor_days=20.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, staleness_column="is_active", revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_health_gate_still_wins_over_floor(monkeypatch: pytest.MonkeyPatch) -> None: + """Нездоровый источник блокируется гейтом ДО того, как считается пол: + лишний тяжёлый запрос по мёртвому источнику не нужен.""" + db = _FakeDB(floor_days=74.3, confirmations=10) + out = _run( + db, + monkeypatch, + min_confirmations=500, + revisit_floor_quantile=0.99, + ) + assert out["skipped_unhealthy"] == 1 + assert db.floor_query[0] == "" + assert db.update_query[0] == "" + + +def test_floor_sql_is_psycopg_v3_safe() -> None: + sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=True).text) + assert "CAST(:revisit_quantile AS double precision)" in sql + assert "CAST(:health_window_days AS integer)" in sql + assert not re.search(r":\w+::", sql) + assert "UPDATE" not in sql.upper() + assert "DELETE" not in sql.upper() + + +def test_floor_only_counts_rows_the_sweep_actually_refound() -> None: + """Пол меряет выживших, а не «мы к ним не приходили»: свежесть обязана СДВИНУТЬСЯ + относительно предыдущего снимка, иначе разрыв нулевой и хвост схлопнется в ноль.""" + sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=False).text) + assert "l.last_seen_at > prev.last_seen_at" in sql + + +def test_default_quantile_is_high_enough_for_the_prod_case() -> None: + """Ниже 0.99 опускать нельзя без нового замера: именно на 0.99 прод-запрос даёт + 34.0 для cian/vtorichka, что накрывает возраст ложных снятий 30.3.""" + assert task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE >= 0.99 + assert _PROD_FLOORS["cian/vtorichka"][3] > _FALSE_KILL_AGE_MAX + + +def test_handler_wires_revisit_floor_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] + flat = " ".join(job.split()) + assert 'params.get( "revisit_floor_quantile", DEFAULT_REVISIT_FLOOR_QUANTILE )' in flat + assert "revisit_floor_quantile=revisit_floor_quantile" in job diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py b/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py new file mode 100644 index 00000000..ddbaa559 --- /dev/null +++ b/tradein-mvp/backend/tests/test_deactivate_stale_ttl_cap.py @@ -0,0 +1,443 @@ +"""Потолок эффективного TTL деактивации (найдено на проде 2026-08-15). + +Пол TTL по измеренному циклу переобхода (#2659, deactivate_stale_avito.py) поднимает +эффективный TTL через max(ttl_days, пол) без верхней границы. На проде это оказалось +петлёй с положительной обратной связью: медленный обход поднимает пол, высокий пол +продлевает жизнь снятым лотам дольше, чем к ним успевает вернуться свежий обход, пул +«активных» раздувается протухшими строками. У yandex ttl_days_effective держали +75/75/75/39/52/54 шесть прогонов подряд при deactivated=0 -- это и есть разгон пола, +ради которого потолок написан. Цифру «23 687 из 44 744» из исходного разбора сюда НЕ +переносим: она про все источники сразу, две трети её -- новостройки вне выборки +оценщика, а у самого avito просроченных строк ноль (уточнено 2026-08-15). + +Этот файл проверяет CAP_MULT -- потолок, не пускающий эффективный TTL выше +ttl_days * CAP_MULT, независимо от того, насколько высоко посчитанный пол. +""" + +from __future__ import annotations + +import os +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 + +# ── Фейковая сессия (тот же контракт, что в test_deactivate_stale_revisit_floor.py) ── + + +class _FakeResult: + def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None: + self.rowcount = rowcount + self._scalar = scalar_value + + def scalar(self) -> Any: + return self._scalar + + +class _FakeDB: + """Session-заглушка: percentile_disc -> пол, count(*) -> подтверждения, UPDATE -> rowcount.""" + + def __init__( + self, + *, + floor_days: float | None, + confirmations: int = 10_000, + rowcount: int = 137, + ) -> None: + self._floor = floor_days + 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 "percentile_disc" in sql: + return _FakeResult(scalar_value=self._floor) + 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_query(self) -> tuple[str, dict[str, Any] | None]: + return next((e for e in self.executed if "UPDATE listings" in e[0]), ("", None)) + + +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, + ) + + +# ── Контракт из задачи ───────────────────────────────────────────────────────── + + +def test_high_floor_is_capped_at_double_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """revisit_floor=75, ttl_days=10 -> итог 20 (потолок 2x), НЕ 75.""" + db = _FakeDB(floor_days=75.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 20 + assert out["ttl_floor_capped"] == 1 + assert out["ttl_days_floor_raw"] == 75 + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 20, "UPDATE обязан получить капнутый TTL, не сырой пол" + + +def test_low_floor_leaves_ttl_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: + """revisit_floor=5, ttl_days=10 -> итог 10 (пол ниже заданного TTL, max() его не поднимает).""" + db = _FakeDB(floor_days=5.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 10 + assert "ttl_floor_capped" not in out + assert "ttl_days_floor_raw" not in out + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 10 + + +# ── Контракт потолка ──────────────────────────────────────────────────────────── + + +def test_cap_mult_is_named_module_constant_equal_two() -> None: + assert task_mod.CAP_MULT == 2 + + +def test_floor_between_ttl_and_cap_is_not_flagged_capped(monkeypatch: pytest.MonkeyPatch) -> None: + """Пол поднял TTL, но не дотянулся до потолка -- capped-флаг НЕ выставляется.""" + db = _FakeDB(floor_days=15.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 15 + assert "ttl_floor_capped" not in out + + +def test_floor_exactly_at_cap_boundary_is_not_flagged_capped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Пол ровно на потолке (2x ttl) -- это ещё "поднят до потолка", не "срезан выше него". + + Формула -- min(raw, cap): при raw == cap срезания не происходит (raw > cap ложно), + капнутый флаг предназначен сигналить именно "потолок реально что-то отрезал". + """ + db = _FakeDB(floor_days=20.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 20 + assert "ttl_floor_capped" not in out + + +def test_cap_logs_warning_containing_both_numbers( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """WARNING при срезании содержит и сырой пол, и капнутый результат -- не только counters.""" + db = _FakeDB(floor_days=75.0) + with caplog.at_level("WARNING", logger=task_mod.logger.name): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + messages = " ".join(r.getMessage() for r in caplog.records) + assert "75" in messages, "лог обязан называть сырой пол" + assert "20" in messages, "лог обязан называть итоговый (капнутый) TTL" + + +def test_cap_never_lowers_ttl_below_configured_value(monkeypatch: pytest.MonkeyPatch) -> None: + """Потолок -- верхняя граница, не альтернативный источник истины: заданный TTL + (10) остаётся нижней границей независимо от того, насколько низко ушёл пол.""" + db = _FakeDB(floor_days=1.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 10 + + +# ── avito self-descend (52 -> ... -> 10) не должен ломаться потолком ──────────── + + +def test_avito_high_transient_floor_is_capped_not_left_unbounded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Наблюдённый на проде транзиентный пик avito (счётчики видели ttl_days_effective=52) + теперь капается на 2x ttl=20, а не пропускается в UPDATE как есть.""" + db = _FakeDB(floor_days=52.0) + out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 20 + assert out["ttl_floor_capped"] == 1 + assert out["ttl_days_floor_raw"] == 52 + + +def test_avito_recovered_low_floor_still_reaches_configured_ttl( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """После восстановления обхода (пол опустился ниже ttl_days=10, как на проде 52->10) + потолок не мешает нормальному пути -- эффективный TTL просто равен заданному.""" + db = _FakeDB(floor_days=9.0) + out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 10 + assert "ttl_floor_capped" not in out + + +def test_avito_floor_above_ttl_but_under_cap_passes_through_uncapped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Промежуточная точка того же самопонижения (пол между ttl и потолком, например 18) + поднимает TTL как раньше -- потолок не мешает нормальному постепенному пути.""" + db = _FakeDB(floor_days=18.0) + out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 18 + assert "ttl_floor_capped" not in out + + +# ── cap_mult конфигурируем per-source (найдено ревью 2026-08-15) ──────────────── +# Дефолтный CAP_MULT=2 даёт разный АБСОЛЮТНЫЙ потолок на разных источниках +# (cian/yandex 60 сут, avito 20 сут), а хвост переобхода не пропорционален +# ttl_days: avito p99=42.1 -- выше его же дефолтного потолка 20. cap_mult -- ручка +# для конкретно такого источника, без изменения дефолта для остальных. + + +def test_cap_mult_defaults_to_module_constant_when_not_overridden( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Без явного cap_mult поведение не меняется: потолок = ttl_days * CAP_MULT (2).""" + db = _FakeDB(floor_days=75.0) + out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99) + assert out["ttl_days_effective"] == 10 * task_mod.CAP_MULT + + +def test_cap_mult_override_raises_the_ceiling_for_a_long_tailed_source( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """avito p99=42.1: cap_mult=6 (потолок 60) больше не режет пол ниже хвоста обхода, + в отличие от дефолтного cap_mult=2 (потолок 20).""" + db = _FakeDB(floor_days=45.0) + out = _run( + db, + monkeypatch, + listing_source="avito", + ttl_days=10, + revisit_floor_quantile=0.99, + cap_mult=6, + ) + assert out["ttl_days_effective"] == 45 + assert "ttl_floor_capped" not in out + + +def test_cap_mult_override_still_caps_when_floor_exceeds_the_wider_ceiling( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """cap_mult поднимает потолок, но не убирает его -- пол выше 60 всё равно срезается.""" + db = _FakeDB(floor_days=90.0) + out = _run( + db, + monkeypatch, + listing_source="avito", + ttl_days=10, + revisit_floor_quantile=0.99, + cap_mult=6, + ) + assert out["ttl_days_effective"] == 60 + assert out["ttl_floor_capped"] == 1 + assert out["ttl_days_floor_raw"] == 90 + + +def test_cap_mult_is_threaded_into_update_params(monkeypatch: pytest.MonkeyPatch) -> None: + """Капнутый по override'нутому потолку TTL реально уходит в UPDATE, не только считается.""" + db = _FakeDB(floor_days=90.0) + _run( + db, + monkeypatch, + listing_source="avito", + ttl_days=10, + revisit_floor_quantile=0.99, + cap_mult=6, + ) + _, update_params = db.update_query + assert update_params is not None + assert update_params["ttl_days"] == 60 + + +# ── ttl_days <= 0 (LOW из ревью 2026-08-15) ────────────────────────────────────── +# До потолка max(ttl_days, floor) прикрывал ttl_days<=0, если пол посчитан и +# положителен. С потолком min(raw, ttl_days * cap_mult) при ttl_days<=0 капнутый +# потолок тоже <= 0 и побеждает в min() -- защита пола пропадает молча. Явный guard +# ловит это ДО любого SQL, тем же путём, что и невалидный staleness_column. + + +def test_ttl_days_zero_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(floor_days=75.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=0, revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_ttl_days_negative_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(floor_days=75.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=-5, revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_ttl_days_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None: + """Тот же контракт, что и невалидный staleness_column: run помечается failed, + а не остаётся 'running'.""" + marked_failed: list[Any] = [] + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_failed", + lambda db, run_id, err, counters: marked_failed.append((run_id, err, counters)), + ) + db = _FakeDB(floor_days=75.0) + with pytest.raises(ValueError): + task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 7, + listing_source="avito", + ttl_days=0, + ) + assert len(marked_failed) == 1 + assert marked_failed[0][0] == 7 + + +# ── cap_mult < 1 (HIGH из ревью круга 2, 2026-08-15) ───────────────────────────── +# Тот же класс дыры, что и ttl_days<=0 выше, но со стороны потолка: cap_mult -- ЕДИНСТВЕННЫЙ +# запланированный способ его задать -- руками вписать в jsonb default_params расписания +# (см. миграцию для avito), т.е. именно там опечатка 0 / 0.5 вместо 6 доходит до прода. +# cap_mult=0 -> capped=0 -> effective_ttl_days=0 -> UPDATE снимает весь активный пул +# источника молча. cap_mult<1 (например 0.5) опускает потолок НИЖЕ заданного оператором +# ttl_days -- прямое нарушение инварианта, который проверяет +# test_cap_never_lowers_ttl_below_configured_value для пола, но не было проверено для +# потолка при некорректном cap_mult. + + +def test_cap_mult_zero_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=0) + assert db.executed == [] + + +def test_cap_mult_negative_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=-2) + assert db.executed == [] + + +def test_cap_mult_below_one_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """cap_mult=0.5 опустил бы потолок НИЖЕ заданного ttl_days -- та самая инверсия, + которую тест test_cap_never_lowers_ttl_below_configured_value гарантирует для пола.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=0.5) + assert db.executed == [] + + +def test_cap_mult_non_numeric_fails_safe_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """Опечатка в jsonb default_params (строка вместо числа) не должна молча пройти + в SQL -- TypeError из сравнения `cap_mult < 1` ловится тем же except Exception, + что и ValueError-гварды, и маршрутизируется через mark_failed. Никакого SQL не + исполняется, ни один active-лот не тронут.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(TypeError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult="6") + assert db.executed == [] + + +# ── cap_mult / ttl_days -- bool (найдено ревью круга 3, 2026-08-15) ───────────── +# bool -- подкласс int в Python: `True < 1` ложно, `True <= 0` ложно. Числовые +# guard'ы выше (`cap_mult < 1`, `ttl_days <= 0`) поэтому НЕ ловят jsonb `true` в +# default_params расписания -- ровно тот класс опечатки, ради которого guard'ы +# вообще написаны. `cap_mult=True` даёт потолок == ttl_days (ttl_days * True == +# ttl_days) -- пол молча отключается без единого ValueError. `ttl_days=True` даёт +# ttl_days == 1 -- TTL молча меняется на 1 сутки. Явная type-проверка ловит оба +# ДО числового сравнения и ДО любого SQL. + + +def test_cap_mult_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """cap_mult=True: `True < 1` ложно -- без явной type-проверки потолок = ttl_days + (пол молча отключается) вместо ValueError. Воспроизведено на HEAD ветки.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=True) + assert db.executed == [] + + +def test_cap_mult_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """cap_mult=False уже ловится `cap_mult < 1` (False == 0), но type-guard идёт + первым -- проверяем, что путь всё равно ValueError, а не иной exception.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=False) + assert db.executed == [] + + +def test_ttl_days_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """ttl_days=True: `True <= 0` ложно -- без явной type-проверки TTL молча + становится 1 сутки (True ведёт себя как int 1) вместо ValueError.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=True, revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_ttl_days_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None: + """ttl_days=False уже ловится `ttl_days <= 0` (False == 0), но type-guard идёт + первым -- проверяем, что путь всё равно ValueError.""" + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + _run(db, monkeypatch, ttl_days=False, revisit_floor_quantile=0.99) + assert db.executed == [] + + +def test_cap_mult_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None: + """Тот же контракт, что и ttl_days<=0: run помечается failed, а не остаётся 'running', + и НИ ОДНА строка не деактивируется (в отличие от воспроизведённого на HEAD дефекта, где + cap_mult=0 давало effective_ttl_days=0 и снимало весь активный пул источника).""" + marked_failed: list[Any] = [] + monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_failed", + lambda db, run_id, err, counters: marked_failed.append((run_id, err, counters)), + ) + db = _FakeDB(floor_days=52.0) + with pytest.raises(ValueError): + task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 9, + listing_source="avito", + ttl_days=10, + revisit_floor_quantile=0.99, + cap_mult=0, + ) + assert len(marked_failed) == 1 + assert marked_failed[0][0] == 9 + assert db.executed == [] + + +# ── проводка cap_mult в product_handlers ───────────────────────────────────────── + + +def test_handler_wires_cap_mult_from_schedule_params() -> None: + """Тот же приём, что test_handler_wires_revisit_floor_from_schedule_params: + читаем исходник файлом (product_handlers тянет scraper_kit, которого в юнит- + окружении может не быть) и проверяем именно проводку default_params -> вызов.""" + 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] + flat = " ".join(job.split()) + assert 'params.get("cap_mult", CAP_MULT)' in flat + assert "cap_mult=cap_mult" in job 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..5e6e0129 --- /dev/null +++ b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py @@ -0,0 +1,274 @@ +"""Разбор мёртвого кода #2674: подключить / удалить / задокументировать. + +Каждая правка эпика — тест, который краснеет без неё: + + подключено: + - 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 — показатель, который не мог быть + ненулевым; + - houses.has_panorama (хвост, мигр. 259) — признак, которого нет на площадке. + Первая редакция #2674 приняла его за оборванную проводку и ПОДКЛЮЧИЛА писателя; + подключённый писатель за 7 дней записал 12 значений, все false. Разница между + «проводка оборвана» и «мерить нечего» видна не по коду, а по площадке. + + задокументировано: + - BROWSER_BLOCK_RESOURCES: код его не читает с #1812, но прод его задаёт — + сервис обязан сказать об этом вслух на старте. + +Без БД и сети: сессия замокана, SQL-миграции читаются как текст. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.providers.cian.valuation import _parse_valuation_state + +from app.services import estimator + +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/2: загрузчик ДОМ.РФ — оборванная проводка +# ───────────────────────────────────────────────────────────────────────────── + + +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} не вызывается — загрузчик подключён лишь наполовину" + + +# ───────────────────────────────────────────────────────────────────────────── +# Подключено 2/2: 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", + # Хвост #2674: has_panorama снят целиком — парсер, писатель, правило приоритета + # источников. Гейт краснеет на любом из трёх, а не только на одном (первая + # редакция как раз убрала не всё: парсер писал в поле, которого никто не пишет). + "has_panorama", + ] + 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_has_panorama_dropped_together_with_the_contract_column() -> None: + """Снос колонки обязан пересобрать market.v_houses и вернуть ему грант. + + has_panorama входила в публичный контракт (154), а CREATE OR REPLACE VIEW колонку + не удаляет — значит DROP VIEW, и вместе с ним теряются гранты. Тест держит все три + части в одном файле: колонки нет, контракт пересобран без неё, GRANT восстановлен. + """ + sql = (SQL_DIR / "260_houses_drop_has_panorama.sql").read_text(encoding="utf-8") + assert "DROP VIEW IF EXISTS market.v_houses" in sql + assert "ALTER TABLE houses DROP COLUMN IF EXISTS has_panorama" in sql + assert "SET LOCAL lock_timeout" in sql, "блокирующий DDL без ограничения ожидания лока" + + view_ddl = sql.split("CREATE VIEW market.v_houses AS", 1)[1].split(";", 1)[0] + assert "has_panorama" not in view_ddl, "контракт пересобран вместе со снесённой колонкой" + # Контракт — обещание стабильности: пересборка не должна заодно потерять соседей. + for kept in ("transport_accessibility_rate", "advantages", "raw_payload", "last_scraped_at"): + assert kept in view_ddl, f"{kept} пропала из контракта при пересборке" + assert "GRANT SELECT ON market.v_houses TO gendesign_reader" in sql, ( + "DROP VIEW уничтожает гранты — без явного GRANT внешний ETL получит " + "permission denied на следующем прогоне" + ) + + +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_dkp_corridor_as_of_2846.py b/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py new file mode 100644 index 00000000..85ed4502 --- /dev/null +++ b/tradein-mvp/backend/tests/test_dkp_corridor_as_of_2846.py @@ -0,0 +1,208 @@ +"""#2846 — возраст выборки ДКП-коридора обязан доезжать до ответа. + +Плитка «ДКП · РОСРЕЕСТР (ФАКТИЧЕСКИЕ СДЕЛКИ)» несла count/low/median/high и +period_months — ОКНО ПОИСКА. Возраста самих сделок в схеме не было вовсе, и +слово «ФАКТИЧЕСКИЕ» читалось как «недавние». + +Замер прода 2026-08-12 (docker exec tradein-postgres psql -U tradein -d tradein): + + deals source='rosreestr': 96 974 строки, max(deal_date) = 2026-01-01 + (223 дня назад), 9 различных deal_date, day-of-month = 1 у 100% строк, + месяцы ровно {01, 04, 07, 10} → deal_date это метка КВАРТАЛЬНОЙ пачки. + + Реплей выборок 881 реальной оценки за 90 суток (улица+город+rooms+area±15% + +окно 12 мес + ppm²-банды, включая city-wide widen): + свежайшая I кв. 2026 — 654 (74.2%) + коридора нет вовсе — 165 (18.7%) + свежайшая IV кв. 2025 — 62 ( 7.0%) + → у 8.7% выборок с коридором свежайшая сделка на КВАРТАЛ старше общего + максимума таблицы, т.е. max по всей таблице был бы враньём в их пользу. + +Красный прогон на origin/main: тесты падают на отсутствии latest_deal_date в +dict/схеме (AttributeError / KeyError), а не на ожидании, списанном с настройки. +""" + +from __future__ import annotations + +import os +from datetime import date +from typing import Any +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.schemas.trade_in import AnalogLot, DkpCorridor +from app.services.estimator import _fetch_dkp_corridor + +_ADDRESS = "Екатеринбург, ул. Гоголя, 18" + + +def _deal(ppm2: int | None, deal_date: date | None) -> dict[str, Any]: + return {"price_per_m2": ppm2, "deal_date": deal_date} + + +def _db(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None) -> Any: + """Session-двойник: 1-й execute — СберИндекс, 2-й — улица, 3-й — city-widen. + + Порядок жёстко следует _fetch_dkp_corridor: сначала street-выборка, затем + _load_sber_index_series, затем (опционально) city-wide widen. + """ + db = MagicMock() + calls: list[Any] = [] + + def _execute(query: Any, params: dict[str, Any] | None = None) -> MagicMock: + result = MagicMock() + calls.append(query) + sql = str(query) + if "sber" in sql.lower(): + result.mappings.return_value.all.return_value = [] + elif "deal_city_price_bands" in sql and "d.address ILIKE" in sql: + result.mappings.return_value.all.return_value = street_rows + elif "deal_city_price_bands" in sql: + result.mappings.return_value.all.return_value = city_rows or [] + else: + result.mappings.return_value.all.return_value = [] + return result + + db.execute.side_effect = _execute + return db + + +def _fetch(street_rows: list[dict[str, Any]], city_rows: list[dict[str, Any]] | None = None): # type: ignore[no-untyped-def] + return _fetch_dkp_corridor( + _db(street_rows, city_rows), + address=_ADDRESS, + rooms=2, + area=60.0, + city="екатеринбург" if city_rows is None else "нижний тагил", + ) + + +# ── КРАСНОЕ ЯДРО: возраст доезжает до схемы ───────────────────────────────── + + +def test_latest_deal_date_reaches_the_response_schema() -> None: + """Главный красный: DkpCorridor обязан НЕСТИ дату свежайшей сделки. + + На origin/main падает AttributeError — поля в схеме нет, а Pydantic v2 + молча игнорирует лишний kwarg, так что «оно как-нибудь пролезет» — нет. + """ + raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(210_000, date(2026, 1, 1))]) + assert raw is not None + corridor = DkpCorridor(**raw) + assert corridor.latest_deal_date == date(2026, 1, 1) + + +def test_age_is_taken_from_the_selected_sample_not_the_table_max() -> None: + """Выборка кончается IV кв. 2025 → подпись обязана назвать IV кв. 2025. + + Это те 7.0% реальных оценок из шапки модуля. Общий максимум таблицы + (2026-01-01) для них — чужая, более свежая дата. + """ + raw = _fetch([_deal(190_000, date(2025, 7, 1)), _deal(195_000, date(2025, 10, 1))]) + assert raw is not None + assert raw["latest_deal_date"] == date(2025, 10, 1) + + +def test_deal_without_price_does_not_donate_its_freshness() -> None: + """Строка без ppm² не входит в границы коридора — не входит и в его возраст. + + Иначе самая свежая, но выброшенная сделка омолаживала бы подпись, не + участвуя ни в одном из чисел под ней. + """ + raw = _fetch([_deal(200_000, date(2025, 10, 1)), _deal(None, date(2026, 1, 1))]) + assert raw is not None + assert raw["count"] == 1 + assert raw["latest_deal_date"] == date(2025, 10, 1) + + +def test_city_wide_widen_moves_the_age_together_with_the_numbers() -> None: + """#oblast-D widen: числа переехали на city-выборку — дата обязана тоже. + + Street-выборка тут СВЕЖЕЕ (I кв. 2026) и её дата осталась бы приятнее для + глаза; но на экране после widen'а стоят city-числа, которые кончаются + III кв. 2025. Подпись описывает то, что показано. + """ + street = [_deal(200_000, date(2026, 1, 1))] + city = [_deal(150_000, date(2025, 4, 1)), _deal(160_000, date(2025, 7, 1))] + raw = _fetch(street, city) + assert raw is not None + assert raw["count"] == 2, "widen должен был сработать (street n=1 < 3)" + assert raw["latest_deal_date"] == date(2025, 7, 1) + + +def test_no_deals_means_no_corridor_and_nothing_to_date() -> None: + """count=0 → коридора нет вовсе; новое поле не создаёт повода что-то рисовать.""" + assert _fetch([]) is None + + +def test_period_months_is_not_a_freshness_claim() -> None: + """Регресс-якорь: окно поиска и возраст данных — РАЗНЫЕ числа. + + Ровно та подмена, из-за которой v2 отказался печатать «за N месяцев» + (mappers.ts) — а v1 печатал. + """ + raw = _fetch([_deal(200_000, date(2025, 10, 1))]) + assert raw is not None + assert raw["period_months"] == 12 + assert raw["latest_deal_date"] == date(2025, 10, 1) + + +# ── PDF: то же обещание, та же подпись ────────────────────────────────────── + + +def _estimate_with_deals(dates: list[date]) -> Any: + est = MagicMock() + est.actual_deals = [ + AnalogLot( + address="Екатеринбург, ул. Гоголя", + area_m2=60.0, + rooms=2, + floor=None, + total_floors=None, + price_rub=12_000_000, + price_per_m2=200_000, + listing_date=d, + days_on_market=None, + date_precision="quarter", + source="rosreestr", + ) + for d in dates + ] + return est + + +def test_pdf_deals_page_dates_the_deals_it_shows() -> None: + """PDF §03 «ФАКТИЧЕСКИЕ СДЕЛКИ» — то же обещание, что и плитка. + + До правки страница печатала «Период сделок: 08.2025 – 08.2026», где правый + конец = сегодня: окно поиска, выданное за период данных. + """ + from app.services.exporters.trade_in_pdf import deals_as_of_label + + assert deals_as_of_label(_estimate_with_deals([date(2025, 10, 1), date(2026, 1, 1)])) == ( + "по I кв. 2026" + ) + assert deals_as_of_label(_estimate_with_deals([])) is None + + +def test_quarter_label_matches_every_deal_date_present_on_prod() -> None: + """Все 9 живых deal_date проекции 2026-08-12 → корректная римская метка. + + Список не выдуман: это ровно `SELECT DISTINCT deal_date FROM deals`. + """ + from app.services.exporters.trade_in_pdf import deals_as_of_label + + expected = { + date(2026, 1, 1): "по I кв. 2026", + date(2025, 10, 1): "по IV кв. 2025", + date(2025, 7, 1): "по III кв. 2025", + date(2025, 4, 1): "по II кв. 2025", + date(2025, 1, 1): "по I кв. 2025", + date(2024, 10, 1): "по IV кв. 2024", + date(2024, 7, 1): "по III кв. 2024", + date(2024, 4, 1): "по II кв. 2024", + date(2024, 1, 1): "по I кв. 2024", + } + for d, label in expected.items(): + assert deals_as_of_label(_estimate_with_deals([d])) == label diff --git a/tradein-mvp/backend/tests/test_domclick_sweep.py b/tradein-mvp/backend/tests/test_domclick_sweep.py index 0476100c..39f869f3 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, **_kw: object) -> _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_estimate_consent_gate.py b/tradein-mvp/backend/tests/test_estimate_consent_gate.py new file mode 100644 index 00000000..4dc9316c --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimate_consent_gate.py @@ -0,0 +1,319 @@ +"""ЭТАП 4 B2C launch — consent-before-save gate on trade_in_estimates (part A). + +Covers: + - require_consent=True + payload.consent missing/False -> HTTPException(422), + raised BEFORE geocode() is ever touched and BEFORE any db.execute call + (gate is literally the first statement in estimate_quality()). + - require_consent=True + payload.consent=True -> proceeds, and the eventual + INSERT carries consent=True / client_ip / policy version / text snapshot. + - require_consent=True + consent=True but geocode fails -> the + _empty_estimate() fallback INSERT *also* carries the same consent proof + (both places an address can reach trade_in_estimates are covered). + - require_consent defaults to False (regression guard): a bare + estimate_quality(payload, db) call -- exactly the shape used by ~90 other + estimator tests that don't care about auth/consent at all -- is completely + unaffected. This is the whole reason the gate keys off an explicit + keyword-only flag instead of `created_by is None`: the first version of + this gate DID key off created_by and broke 92 unrelated tests across the + estimator test suite (every one of them calls estimate_quality(payload, db) + with created_by defaulting to None, which used to mean nothing). + - the real (only) production caller, app/api/v1/trade_in.py::estimate(), + passes require_consent=(x_authenticated_user is None) -- verified via source + inspection, since exercising it live would require a full FastAPI app + fixture (rbac_guard is DB/env-heavy and out of scope for this offline test). + +Style mirrors tests/test_estimator_client_coords.py + test_estimator_event_loop_2207.py +(offline, db=MagicMock(), downstream helpers patched). +""" + +from __future__ import annotations + +import contextlib +import inspect +import os +import re +from pathlib import Path +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio +import pytest +from fastapi import HTTPException + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_229 = _SQL_DIR / "229_trade_in_estimates_consent_proof.sql" + + +def _make_payload(**overrides: Any) -> Any: + from app.schemas.trade_in import TradeInEstimateInput + + base: dict[str, Any] = dict(address="ЕКБ, ул. Тестовая, 1", area_m2=40.0, rooms=1) + base.update(overrides) + return TradeInEstimateInput(**base) + + +def _make_fake_geo() -> Any: + from app.services.geocoder import GeocodeResult + + return GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Тестовая, 1", + provider="nominatim", + ) + + +def _downstream_patches(geocode_mock: Any) -> tuple[Any, ...]: + """Offline mocks so estimate_quality runs to completion (mirrors 2207 test's set).""" + return ( + patch("app.services.estimator.geocode", new=geocode_mock), + 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._get_asking_sold_ratio", return_value=(None, None)), + ) + + +def _find_insert_call(db: MagicMock, table_marker: str) -> dict[str, Any]: + for call in db.execute.call_args_list: + stmt = call.args[0] + sql = str(getattr(stmt, "text", stmt)) + if f"INSERT INTO {table_marker}" in sql: + return call.args[1] + raise AssertionError( + f"no INSERT INTO {table_marker} call captured; calls={db.execute.call_args_list}" + ) + + +# ── require_consent=True, no consent -> 422 BEFORE any work ─────────────────── + + +def test_require_consent_without_payload_consent_raises_422_before_geocode() -> None: + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() # consent defaults to None + + async def _run() -> None: + with patch("app.services.estimator.geocode") as geocode_mock: + with pytest.raises(HTTPException) as exc_info: + await estimate_quality(payload, db, require_consent=True) + assert exc_info.value.status_code == 422 + geocode_mock.assert_not_called() + + anyio.run(_run) + assert not db.execute.called, "gate must precede ANY db write" + + +def test_require_consent_with_payload_consent_false_raises_422() -> None: + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload(consent=False) + + async def _run() -> None: + with pytest.raises(HTTPException) as exc_info: + await estimate_quality(payload, db, require_consent=True) + assert exc_info.value.status_code == 422 + + anyio.run(_run) + assert not db.execute.called + + +# ── require_consent=True, consent given -> proceeds, proof persisted ────────── + + +def test_require_consent_with_payload_consent_persists_proof() -> None: + from app.services.estimator import _ESTIMATE_CONSENT_POLICY_VERSION as POLICY_VERSION + from app.services.estimator import _ESTIMATE_CONSENT_TEXT_SNAPSHOT as TEXT_SNAPSHOT + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload(consent=True) + geocode_mock = AsyncMock(return_value=_make_fake_geo()) + + async def _run() -> Any: + with contextlib.ExitStack() as stack: + for cm in _downstream_patches(geocode_mock): + stack.enter_context(cm) + return await estimate_quality( + payload, db, client_ip="203.0.113.9", require_consent=True + ) + + result = anyio.run(_run) + assert result.estimate_id is not None + + params = _find_insert_call(db, "trade_in_estimates") + assert params["consent"] is True + assert params["client_ip"] == "203.0.113.9" + assert params["consent_policy_version"] == POLICY_VERSION + assert params["consent_text_snapshot"] == TEXT_SNAPSHOT + assert params["created_by"] is None + + +# ── require_consent=True, consent given, geocode fails -> _empty_estimate too ─ + + +def test_empty_estimate_fallback_persists_proof_when_consent_required() -> None: + from app.services.estimator import _ESTIMATE_CONSENT_POLICY_VERSION as POLICY_VERSION + from app.services.estimator import _ESTIMATE_CONSENT_TEXT_SNAPSHOT as TEXT_SNAPSHOT + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload(consent=True) + geocode_mock = AsyncMock(return_value=None) # geocode failure -> _empty_estimate path + + async def _run() -> Any: + with patch("app.services.estimator.geocode", new=geocode_mock): + return await estimate_quality( + payload, db, client_ip="198.51.100.4", require_consent=True + ) + + result = anyio.run(_run) + assert result.n_analogs == 0 + + params = _find_insert_call(db, "trade_in_estimates") + assert params["consent"] is True + assert params["client_ip"] == "198.51.100.4" + assert params["consent_policy_version"] == POLICY_VERSION + assert params["consent_text_snapshot"] == TEXT_SNAPSHOT + + +def test_empty_estimate_fallback_gate_still_blocks_without_consent() -> None: + """Gate precedes _empty_estimate too -- geocode is never even reached.""" + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() # no consent + + async def _run() -> None: + with patch("app.services.estimator.geocode") as geocode_mock: + with pytest.raises(HTTPException) as exc_info: + await estimate_quality(payload, db, require_consent=True) + assert exc_info.value.status_code == 422 + geocode_mock.assert_not_called() + + anyio.run(_run) + + +# ── require_consent defaults False -> zero blast radius on existing callers ─── + + +def test_require_consent_defaults_false() -> None: + from app.services.estimator import estimate_quality + + sig = inspect.signature(estimate_quality) + assert sig.parameters["require_consent"].default is False + assert sig.parameters["require_consent"].kind == inspect.Parameter.KEYWORD_ONLY + + +def test_bare_call_without_require_consent_is_unaffected() -> None: + """The exact call shape used by ~90 other estimator tests + (estimate_quality(payload, db), no created_by/require_consent at all) -- + must keep working with NO consent field on the payload whatsoever.""" + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() # no consent field + geocode_mock = AsyncMock(return_value=_make_fake_geo()) + + async def _run() -> Any: + with contextlib.ExitStack() as stack: + for cm in _downstream_patches(geocode_mock): + stack.enter_context(cm) + return await estimate_quality(payload, db) + + result = anyio.run(_run) + assert result.estimate_id is not None + + params = _find_insert_call(db, "trade_in_estimates") + assert params["consent"] is None + assert params["client_ip"] is None + assert params["consent_policy_version"] is None + assert params["consent_text_snapshot"] is None + + +# ── B2B pilot (created_by set, require_consent left False) -> unaffected ────── + + +def test_b2b_pilot_without_consent_field_unaffected() -> None: + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload() # no consent field at all -- mirrors real pilot frontend + geocode_mock = AsyncMock(return_value=_make_fake_geo()) + + async def _run() -> Any: + with contextlib.ExitStack() as stack: + for cm in _downstream_patches(geocode_mock): + stack.enter_context(cm) + return await estimate_quality(payload, db, created_by="kopylov") + + result = anyio.run(_run) + assert result.estimate_id is not None + + params = _find_insert_call(db, "trade_in_estimates") + assert params["created_by"] == "kopylov" + assert params["consent"] is None + assert params["client_ip"] is None + assert params["consent_policy_version"] is None + assert params["consent_text_snapshot"] is None + + +# ── production wiring: app/api/v1/trade_in.py passes require_consent correctly ─ + + +def test_api_handler_wires_require_consent_from_auth_header() -> None: + """Source-inspection guard: the ONLY production caller of estimate_quality + must derive require_consent from the ABSENCE of X-Authenticated-User, not + hardcode True/False. Cheaper and more robust than spinning up a full app + + rbac_guard fixture just to exercise one kwarg's wiring.""" + from app.api.v1 import trade_in as trade_in_module + + src = re.sub(r"\s+", " ", inspect.getsource(trade_in_module.estimate)) + assert "require_consent=x_authenticated_user is None" in src + + +# ── Migration 229 sanity ──────────────────────────────────────────────────────── + + +def test_migration_229_exists() -> None: + assert _MIGRATION_229.is_file(), f"missing migration: {_MIGRATION_229}" + + +def test_migration_229_is_transactional() -> None: + sql = _MIGRATION_229.read_text("utf-8") + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_229_is_idempotent() -> None: + sql = _MIGRATION_229.read_text("utf-8") + assert "ADD COLUMN IF NOT EXISTS consent" in sql + assert "ADD COLUMN IF NOT EXISTS client_ip" in sql + assert "CREATE INDEX IF NOT EXISTS" in sql + assert "pg_constraint" in sql # DO-block guard, not bare ADD CONSTRAINT + + +def test_migration_229_no_psycopg_trap() -> None: + sql = _MIGRATION_229.read_text("utf-8") + assert not re.search(r":\w+::", sql) + + +def test_migration_229_check_constraint_allows_null_or_true() -> None: + sql = _MIGRATION_229.read_text("utf-8") + assert "consent IS NULL OR consent IS TRUE" in sql diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 40234a3e..099d39d0 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -51,8 +51,19 @@ def trade_in_app() -> FastAPI: return application -def _make_estimate_row(created_by: str | None) -> SimpleNamespace: - """A trade_in_estimates row with the full column set the endpoints read.""" +def _make_estimate_row(created_by: str | None, retain_until: object = None) -> SimpleNamespace: + """A trade_in_estimates row with the full column set the endpoints read. + + retain_until defaults to None (PR-D1, migration 240) -- unpaid, matches every + row that existed before that migration; explicit param lets retention-gate + tests (see test_estimate_retention_gate.py) construct a paid row. + + relaxations/reliability (migration 255) default to the schema defaults + ('[]' / 'ok') -- matches every pre-migration row (no backfill). Revival + scenarios (a "dead" median_price<=0/NULL row) are covered separately in + test_estimate_revival.py with their own dedicated row builders, since this + fixture's downstream tests here all assume a "live" estimate. + """ from datetime import UTC, datetime, timedelta return SimpleNamespace( @@ -69,6 +80,7 @@ def _make_estimate_row(created_by: str | None) -> SimpleNamespace: sources_used=["avito"], data_freshness_minutes=10, expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=retain_until, address="ул. Тестовая, 1", lat=56.8, lon=60.6, @@ -93,6 +105,8 @@ def _make_estimate_row(created_by: str | None) -> SimpleNamespace: ratio_basis="per_rooms", created_by=created_by, created_at=datetime.now(tz=UTC), + relaxations=[], + reliability="ok", ) @@ -149,6 +163,10 @@ def _stub_precision_and_pdf(): # #2087 (M1): GET-rehydrate derives canonical sources_used via the shared # helper. Empty analogs + no valuation flags → [] (real behaviour). _canonical_sources=lambda *a, **k: [], + # #2632: GET-rehydrate реконструирует фактический радиус подбора. У этих + # фикстур нет ни подписи каскада, ни расстояний → None и есть настоящее + # поведение (см. tests/test_estimator_search_radius_2632.py). + rehydrate_search_radius_m=lambda *a, **k: None, ) real_estimator = sys.modules.get("app.services.estimator") sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment] @@ -597,3 +615,116 @@ def test_get_estimate_imv_benchmark_other_pilot_gets_404(trade_in_app: FastAPI) headers={"X-Authenticated-User": "attacker"}, ) assert resp.status_code == 404 + + +# ── Payments retention: retention gate unification (retain_until, PR #2754) ── + + +def test_estimate_readable_sql_uses_disjunction() -> None: + """Single definition — OR retain_until, not a hand-copied expression.""" + from app.api.v1.trade_in import ESTIMATE_READABLE_SQL + + assert "expires_at > NOW()" in ESTIMATE_READABLE_SQL + assert "retain_until > NOW()" in ESTIMATE_READABLE_SQL + assert " OR " in ESTIMATE_READABLE_SQL + + +def test_get_estimate_sql_built_from_shared_constant() -> None: + """GET /estimate/{id} SQL filter is built FROM ESTIMATE_READABLE_SQL, not a + hand-copied literal — regression guard against the two gates drifting apart + again (that's exactly what happened before this PR: 404 here, 410 in /pdf).""" + import inspect + + from app.api.v1.trade_in import get_estimate + + src = inspect.getsource(get_estimate) + assert "ESTIMATE_READABLE_SQL" in src + assert "expires_at > NOW()" not in src, "hand-copied predicate, not the shared constant" + assert "retain_until" in src, "SELECT must also fetch retain_until" + + +def test_estimate_pdf_select_includes_retain_until_column() -> None: + import inspect + + from app.api.v1.trade_in import estimate_pdf + + assert "retain_until" in inspect.getsource(estimate_pdf) + + +@pytest.mark.parametrize( + ("expires_delta_hours", "retain_delta_days", "expected"), + [ + (12, None, True), # not expired, unpaid — current B2B/B2C behaviour, unchanged + (-1, None, False), # expired, unpaid — current behaviour (404/410), unchanged + (-1, 365, True), # expired but PAID — new: readable + (12, 365, True), # not expired AND paid — readable + (-1, -1, False), # expired, and the (hypothetical) retain_until also in the past + ], +) +def test_estimate_readable_truth_table( + expires_delta_hours: int, retain_delta_days: int | None, expected: bool +) -> None: + from datetime import UTC, datetime, timedelta + + from app.api.v1.trade_in import estimate_readable + + expires_at = datetime.now(tz=UTC) + timedelta(hours=expires_delta_hours) + retain_until = ( + datetime.now(tz=UTC) + timedelta(days=retain_delta_days) + if retain_delta_days is not None + else None + ) + assert estimate_readable(expires_at, retain_until) is expected + + +def test_pdf_expired_but_paid_returns_200(trade_in_app: FastAPI) -> None: + """expires_at in the past, retain_until in the future → PDF still downloads + (200). Exactly the scenario PR-D1 exists for: a paid report must outlive + the 24h expires_at link TTL.""" + from datetime import UTC, datetime, timedelta + + row = _make_estimate_row(created_by="kopylov") + row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1) + row.retain_until = datetime.now(tz=UTC) + timedelta(days=300) + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/pdf" + + +def test_pdf_expired_unpaid_returns_410_without_ttl_text(trade_in_app: FastAPI) -> None: + """expires_at in the past, retain_until NULL (unpaid, unchanged behaviour) → + 410, and the detail text no longer claims a specific '24h TTL' (would be a + lie now that retain_until exists for paid rows).""" + from datetime import UTC, datetime, timedelta + + row = _make_estimate_row(created_by="kopylov") + row.expires_at = datetime.now(tz=UTC) - timedelta(hours=1) + row.retain_until = None + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}/pdf", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 410 + assert resp.json()["detail"] == "estimate expired" + assert "24h" not in resp.json()["detail"] + assert "TTL" not in resp.json()["detail"] + + +def test_get_estimate_response_includes_retain_until_field(trade_in_app: FastAPI) -> None: + """Response schema exposes retain_until (nullable) — schemas/trade_in.py.""" + row = _make_estimate_row(created_by="kopylov") # retain_until defaults to None + db_mock = _make_db_mock(row) + client = _client_with(trade_in_app, db_mock, role="pilot") + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["retain_until"] is None diff --git a/tradein-mvp/backend/tests/test_estimate_revival.py b/tradein-mvp/backend/tests/test_estimate_revival.py new file mode 100644 index 00000000..d41f2e4d --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimate_revival.py @@ -0,0 +1,602 @@ +"""Tests for GET /estimate/{id} revival of "dead" (median_price<=0/NULL) rows. + +Incident 2026-08-10: a customer opened a saved estimate link and saw +«НЕДОСТАТОЧНО ДАННЫХ» — the row was created BEFORE the estimator fix +(#oblast-E/#oblast-F, PR #2823/#2825) and is permanently stuck at +median_price=0, even though the same address/params now compute a real +price. app.api.v1.trade_in::_try_revive_dead_estimate recomputes such a row +in place (same id/link) via the same estimate_quality() path as POST +/estimate. Also covers migration 255 (relaxations/reliability persistence). + +Реальная БД не нужна: DB + get_role + estimate_quality мокируются, mirroring +test_estimate_idor.py's approach (self-contained, no cross-file fixture +imports — this repo has no precedent for importing fixtures across +tests/test_*.py modules, only from tests/support/). + +DB mock dispatches by SQL substring (not call-position): get_estimate's +existing rehydrate path calls the real (non-stubbed) _resolve_target_house_id +helper, which itself fires 1-2 incidental `SELECT id FROM houses` queries +whenever the revival attempt does NOT short-circuit with an early return — +hand-counting positional side_effect entries around that would be brittle. +""" + +from __future__ import annotations + +import inspect +import os +import sys +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock + +# psycopg v3 driver required; stub DATABASE_URL before any app import +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import. +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +_ESTIMATE_ID = "22222222-2222-2222-2222-222222222222" +_TEMP_ID = "33333333-3333-3333-3333-333333333333" + + +@pytest.fixture(autouse=True) +def _restore_get_role(): + """Restore app.core.auth.get_role after each test (mirror test_estimate_idor).""" + from app.core import auth as auth_mod + + original = auth_mod.get_role + yield + auth_mod.get_role = original + + +@pytest.fixture() +def trade_in_app() -> FastAPI: + """Minimal FastAPI app mounting only the trade-in router.""" + from app.api.v1 import trade_in as trade_in_module + + application = FastAPI() + application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + return application + + +def _make_dead_row( + *, + median_price: int | None = 0, + house_type: str | None = None, + repair_state: str | None = None, + relaxations: list[str] | None = None, + reliability: str = "ok", +) -> SimpleNamespace: + """A trade_in_estimates row in the "dead" state (incident 2026-08-10). + + house_type/repair_state default to None (valid TradeInEstimateInput input — + most revival tests need a row that round-trips through pydantic cleanly); + a dedicated test passes legacy Russian literal values to exercise the + "invalid persisted value" graceful-degrade path. + """ + return SimpleNamespace( + id=_ESTIMATE_ID, + median_price=median_price, + range_low=0, + range_high=0, + median_price_per_m2=0, + confidence="low", + confidence_explanation="Рядом найдено недостаточно объявлений (4 шт.)", + n_analogs=0, + analogs=[], + actual_deals=[], + sources_used=[], + data_freshness_minutes=None, + expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=None, + address="г Екатеринбург, ул Академика Парина, д 46, к 5", + lat=56.8519, + lon=60.6122, + area_m2=23.1, + rooms=1, + floor=5, + total_floors=16, + year_built=2018, + house_type=house_type, + repair_state=repair_state, + has_balcony=None, + canonical_address=None, + house_cadnum=None, + house_fias_id=None, + dadata_qc_geo=None, + dadata_metro=[], + expected_sold_price=None, + expected_sold_range_low=None, + expected_sold_range_high=None, + expected_sold_per_m2=None, + asking_to_sold_ratio=None, + ratio_basis=None, + created_by="kopylov", + created_at=datetime(2026, 5, 29, tzinfo=UTC), + relaxations=relaxations or [], + reliability=reliability, + ) + + +def _make_live_row( + *, relaxations: list[str] | None = None, reliability: str = "ok" +) -> SimpleNamespace: + """A "live" row (median_price>0) — revival must never touch it.""" + return SimpleNamespace( + id=_ESTIMATE_ID, + median_price=4_031_157, + range_low=3_700_000, + range_high=4_300_000, + median_price_per_m2=174_000, + confidence="medium", + confidence_explanation="Найдено 39 аналогов", + n_analogs=39, + analogs=[], + actual_deals=[], + sources_used=["avito", "rosreestr"], + data_freshness_minutes=15, + expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=None, + address="г Екатеринбург, ул Академика Парина, д 46, к 5", + lat=56.8519, + lon=60.6122, + area_m2=23.1, + rooms=1, + floor=5, + total_floors=16, + year_built=2018, + house_type=None, + repair_state=None, + has_balcony=None, + canonical_address="г Екатеринбург, ул Академика Парина, д 46, к 5", + house_cadnum=None, + house_fias_id=None, + dadata_qc_geo=0, + dadata_metro=[], + expected_sold_price=None, + expected_sold_range_low=None, + expected_sold_range_high=None, + expected_sold_per_m2=None, + asking_to_sold_ratio=None, + ratio_basis=None, + created_by="kopylov", + created_at=datetime.now(tz=UTC), + relaxations=relaxations or [], + reliability=reliability, + ) + + +def _fake_revived_result(**overrides): + """A canned AggregatedEstimate mimicking a successful estimate_quality() call.""" + from app.schemas.trade_in import AggregatedEstimate + + defaults = dict( + estimate_id=_TEMP_ID, + median_price_rub=4_031_157, + range_low_rub=3_700_000, + range_high_rub=4_300_000, + median_price_per_m2=174_000, + confidence="medium", + confidence_explanation="Найдено 39 аналогов", + n_analogs=39, + period_months=12, + analogs=[], + actual_deals=[], + expires_at=datetime.now(tz=UTC) + timedelta(hours=24), + target_address="г Екатеринбург, ул Академика Парина, д 46, к 5", + target_lat=56.8519, + target_lon=60.6122, + sources_used=["avito", "rosreestr"], + data_freshness_minutes=15, + canonical_address="г Екатеринбург, ул Академика Парина, д 46, к 5", + relaxations=["снят фильтр по году постройки", "учтены студии"], + reliability="low", + created_at=datetime.now(tz=UTC), + ) + defaults.update(overrides) + return AggregatedEstimate(**defaults) + + +def _dispatch_db(row: object, claim_result: object = None) -> MagicMock: + """DB session mock dispatching fetchone() results by SQL substring. + + - initial GET SELECT ("SELECT id, median_price ...") -> row + - revival claim UPDATE ("SET revival_attempted_at") -> claim_result + - everything else (houses lookup, persist UPDATE, DELETE, + avito_imv UPDATE — none of which .fetchone() in real code + except the two above, but MagicMock tolerates the unused + call either way) -> None + + call_args_list still records every call in order regardless of dispatch, + so tests can assert on it directly (grep by substring) without needing to + hand-count incidental queries fired by _resolve_target_house_id. + """ + db = MagicMock() + + def _execute(clause, params=None, *_a, **_k): + sql = getattr(clause, "text", str(clause)) + result = MagicMock() + if "SET revival_attempted_at" in sql: + result.fetchone.return_value = claim_result + elif "SELECT id, median_price" in sql: + result.fetchone.return_value = row + else: + result.fetchone.return_value = None + return result + + db.execute.side_effect = _execute + return db + + +def _calls_containing(db: MagicMock, needle: str) -> list: + return [c for c in db.execute.call_args_list if needle in getattr(c.args[0], "text", "")] + + +def _client_with(app: FastAPI, db_mock: MagicMock, role: str = "pilot") -> TestClient: + from app.core.db import get_db + + def _override_db(): + yield db_mock + + app.dependency_overrides[get_db] = _override_db + auth_mod = sys.modules["app.core.auth"] + auth_mod.get_role = lambda _u: role # type: ignore[assignment] + return TestClient(app) + + +@pytest.fixture() +def _estimator_stub(): + """Replaces app.services.estimator with a SimpleNamespace stub. + + Mirrors test_estimate_idor.py::_stub_precision_and_pdf, plus an + `estimate_quality` async attribute (revival's own lazy import target). + Individual tests overwrite `estimate_quality` per-scenario. + """ + real_estimator = sys.modules.get("app.services.estimator") + + async def _default_estimate_quality(*_a, **_k): # pragma: no cover — overridden per test + raise AssertionError("estimate_quality stub not configured for this test") + + stub = SimpleNamespace( + _qc_geo_to_precision=lambda _qc: None, + _fetch_price_trend=lambda *a, **k: None, + _fetch_dkp_corridor=lambda *a, **k: None, + _fetch_house_imv_anchor=lambda *a, **k: None, + _resolve_target_city=lambda *a, **k: None, + _cv_from_ppm2=lambda *a, **k: None, + _source_counts=lambda *a, **k: {}, + _canonical_sources=lambda *a, **k: [], + # #2632: GET-rehydrate реконструирует фактический радиус подбора. У строк + # этой фикстуры analogs пусты → None и есть настоящее поведение + # (см. tests/test_estimator_search_radius_2632.py). + rehydrate_search_radius_m=lambda *a, **k: None, + estimate_quality=_default_estimate_quality, + ) + sys.modules["app.services.estimator"] = stub # type: ignore[assignment] + yield stub + if real_estimator is not None: + sys.modules["app.services.estimator"] = real_estimator + else: + sys.modules.pop("app.services.estimator", None) + + +# ── Revival success ─────────────────────────────────────────────────────── + + +def test_dead_row_revives_and_updates_db( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Dead row (median_price=0) → recomputed, written to the SAME id, returned fresh. + + Success path returns early (before the pre-existing rehydrate block), so + the call count is exactly the 5 documented in _try_revive_dead_estimate's + docstring: claim, avito_imv relink, persist UPDATE, DELETE temp — plus the + initial SELECT. + """ + + async def _fake_estimate_quality(payload, db, **kwargs): + assert payload.address.startswith("г Екатеринбург") + assert payload.rooms == 1 + assert payload.radius_m is None # never persisted — default cascade + return _fake_revived_result() + + _estimator_stub.estimate_quality = _fake_estimate_quality + + row = _make_dead_row() + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + # id/link contract: response carries the ORIGINAL id, not the temp uuid + # estimate_quality() minted internally. + assert body["estimate_id"] == _ESTIMATE_ID + assert body["median_price_rub"] == 4_031_157 + assert body["n_analogs"] == 39 + assert body["insufficient_data"] is False + assert body["reliability"] == "low" + assert "снят фильтр по году постройки" in body["relaxations"] + + assert len(db.execute.call_args_list) == 5 + persist_calls = _calls_containing(db, "UPDATE trade_in_estimates SET") + assert len(persist_calls) == 1 + persist_params = persist_calls[0].args[1] + assert persist_params["id"] == _ESTIMATE_ID + assert persist_params["median_price"] == 4_031_157 + assert persist_params["reliability"] == "low" + delete_calls = _calls_containing(db, "DELETE FROM trade_in_estimates") + assert len(delete_calls) == 1 + assert delete_calls[0].args[1]["id"] == _TEMP_ID + + +def test_dead_row_revival_preserves_created_at( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """#incident-2026-08-11: created_at is the client's ORIGINAL request date + (printed on /history, see app/schemas/trade_in.py:317-318) — revival must + not clobber it with the throwaway temp row's NOW(). Regression for the + prod incident (estimate ff421062-...: created_at jumped from the original + 2026-08-10 12:54:47 to the revival moment 2026-08-11 04:30:03). + + Also asserts: (a) the persist UPDATE never sets created_at at all — the + fix removes the column from SET, it doesn't just overwrite it with the + right value; (b) migration 256's revival_completed_at IS stamped, as the + separate "when did revival last succeed" audit trail; (c) the JSON + response mirrors the original created_at, not the temp result's. + """ + _original_created_at = datetime(2026, 5, 29, tzinfo=UTC) + + async def _fake_estimate_quality(payload, db, **kwargs): + # The temp row estimate_quality() mints internally always carries + # NOW() as its created_at — deliberately far from the original, so a + # regression (copying result.created_at through) is unmissable. + return _fake_revived_result(created_at=datetime(2026, 8, 11, 4, 30, 3, tzinfo=UTC)) + + _estimator_stub.estimate_quality = _fake_estimate_quality + + row = _make_dead_row() + row.created_at = _original_created_at + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["created_at"] == "2026-05-29T00:00:00Z" + + persist_calls = _calls_containing(db, "UPDATE trade_in_estimates SET") + assert len(persist_calls) == 1 + persist_sql = persist_calls[0].args[0].text + persist_params = persist_calls[0].args[1] + assert "created_at" not in persist_sql + assert "created_at" not in persist_params + assert "revival_completed_at = NOW()" in persist_sql + # Input-snapshot / TTL columns (what the client originally asked for and + # for how long the row is retained) are likewise not recompute outputs — + # untouched by the revival persist UPDATE. + # NB: "address" is checked via persist_params only (not persist_sql) — + # canonical_address (a legitimate recompute output) ends in "address =", + # which would false-positive a substring check against the raw SQL text. + for protected in ("expires_at", "retain_until", "created_by"): + assert f"{protected} =" not in persist_sql + assert protected not in persist_params + assert "address" not in persist_params + + +# ── Live row is never touched ──────────────────────────────────────────── + + +def test_live_row_never_triggers_revival( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """median_price>0 → revival branch skipped entirely: estimate_quality is + never called and no claim UPDATE fires — the saved price is untouched.""" + + async def _must_not_be_called(*_a, **_k): + raise AssertionError("estimate_quality must not be called for a live row") + + _estimator_stub.estimate_quality = _must_not_be_called + + row = _make_live_row() + db = _dispatch_db(row) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["median_price_rub"] == 4_031_157 + assert _calls_containing(db, "SET revival_attempted_at") == [] + + +# ── Throttle / anti-storm ──────────────────────────────────────────────── + + +def test_dead_row_throttled_does_not_recompute( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Claim UPDATE returns no row (recent attempt / lost race) → no recompute, + honest still-dead response, no 500, exactly one claim attempt (no retry + loop within the same request).""" + + async def _must_not_be_called(*_a, **_k): + raise AssertionError("estimate_quality must not be called when throttled") + + _estimator_stub.estimate_quality = _must_not_be_called + + row = _make_dead_row() + db = _dispatch_db(row, claim_result=None) # throttled: WHERE matched nothing + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["insufficient_data"] is True + assert body["median_price_rub"] == 0 + assert len(_calls_containing(db, "SET revival_attempted_at")) == 1 + + +# ── Recompute error degrades gracefully (no 500) ───────────────────────── + + +def test_revival_recompute_exception_falls_back_without_500( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + async def _raises(*_a, **_k): + raise RuntimeError("geocode timeout") + + _estimator_stub.estimate_quality = _raises + + row = _make_dead_row() + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["insufficient_data"] is True + # exception happened AFTER the claim — throttle was still recorded once. + assert len(_calls_containing(db, "SET revival_attempted_at")) == 1 + # ...but nothing was written back to the row (no persist UPDATE fired). + assert _calls_containing(db, "UPDATE trade_in_estimates SET") == [] + + +def test_revival_still_zero_falls_back_without_500( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Recompute runs but still finds nothing (median_price_rub=0) — honest + insufficient_data, temp throwaway row cleaned up, no crash.""" + + async def _still_empty(*_a, **_k): + return _fake_revived_result( + median_price_rub=0, + range_low_rub=0, + range_high_rub=0, + median_price_per_m2=0, + n_analogs=0, + confidence="low", + ) + + _estimator_stub.estimate_quality = _still_empty + + row = _make_dead_row() + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["insufficient_data"] is True + delete_calls = _calls_containing(db, "DELETE FROM trade_in_estimates") + assert len(delete_calls) == 1 + assert delete_calls[0].args[1]["id"] == _TEMP_ID + # the still-dead ORIGINAL row was never overwritten with (fresh) zeros. + assert _calls_containing(db, "UPDATE trade_in_estimates SET") == [] + + +def test_revival_invalid_persisted_house_type_falls_back_gracefully( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Legacy row with a house_type outside the current Literal set — payload + reconstruction itself raises (pydantic ValidationError), caught the same + way as any other recompute failure. No 500.""" + + async def _must_not_be_called(*_a, **_k): + raise AssertionError("estimate_quality must not be reached — payload build fails first") + + _estimator_stub.estimate_quality = _must_not_be_called + + row = _make_dead_row(house_type="монолит", repair_state="хороший") + db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["insufficient_data"] is True + + +# ── relaxations/reliability round-trip (migration 255) ─────────────────── + + +def test_relaxations_reliability_roundtrip_on_get( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """A live row with persisted relaxations/reliability surfaces them + byte-for-byte on GET — the red "точность снижена" banner survives + reopening a saved link (previously always reset to ok/[]).""" + row = _make_live_row( + relaxations=["радиус расширен до 2000 м", "площадь ±25%"], reliability="low" + ) + db = _dispatch_db(row) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["reliability"] == "low" + assert body["relaxations"] == ["радиус расширен до 2000 м", "площадь ±25%"] + + +def test_get_estimate_defaults_relaxations_reliability_when_row_lacks_columns( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """Defensive getattr fallback: a row/mock without relaxations/reliability + attrs (e.g. a stale test double) degrades to schema defaults, not a crash.""" + row = _make_live_row() + del row.relaxations + del row.reliability + db = _dispatch_db(row) + client = _client_with(trade_in_app, db) + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["reliability"] == "ok" + assert body["relaxations"] == [] + + +# ── estimator.py persists relaxations/reliability on (re)compute ───────── + + +def test_estimate_quality_insert_persists_relaxations_reliability() -> None: + """Source guard: the main POST-path INSERT must write relaxations/ + reliability, not just return them in the response — regression guard + against the exact gap this migration closes (PR #2823 open follow-up).""" + from app.services import estimator + + src = inspect.getsource(estimator.estimate_quality) + assert "relaxations_json" in src + assert '"reliability": reliability' in src + + +def test_empty_estimate_persists_relaxations_reliability() -> None: + """_empty_estimate's INSERT must also set reliability='very_low' (mirrors + the Python object it returns) rather than silently defaulting to 'ok'.""" + from app.services import estimator + + src = inspect.getsource(estimator._empty_estimate) + assert "relaxations, reliability" in src + assert "'very_low'" in src diff --git a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py index 6935c5fb..f6cb2430 100644 --- a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py +++ b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py @@ -331,18 +331,24 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None: # --------------------------------------------------------------------------- -# Fix 5a — sber staleness warning +# Fix 5a — per-estimate sber staleness warning УДАЛЁН (#2846) +# +# Guard сравнивал возраст latest периода с sber_index_max_age_days=35. Такой +# возраст недостижим по построению (period_month — метка первого числа + лаг +# публикации ⇒ пол 46 суток), поэтому на проде warning писался у КАЖДОЙ оценки +# и не нёс ни бита. Пара прежних тестов зеленела только на фикстуре с «свежим» +# месяцем, которого в реальной серии не бывает. Свежесть теперь мерит одно место — +# tasks/sber_freshness_monitor, по отставанию ЗАГРУЗКИ. # --------------------------------------------------------------------------- -def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None: - """_load_sber_index_series логирует warning при stale серии.""" +def test_fix5a_no_per_estimate_staleness_warning(caplog: pytest.LogCaptureFixture) -> None: + """Серия отдаётся как есть; календарного warning'а в горячем пути больше нет.""" import logging from app.services.estimator import _load_sber_index_series - # Серия с единственным месяцем 2 года назад - stale_month = date(2024, 1, 1) + stale_month = date(2024, 1, 1) # два года назад — прежний guard тут кричал mock_db = MagicMock() mock_db.execute.return_value.mappings.return_value.all.return_value = [ {"period_month": stale_month, "index_value_rub_m2": 100_000.0} @@ -351,33 +357,9 @@ def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None with caplog.at_level(logging.WARNING, logger="app.services.estimator"): series = _load_sber_index_series(mock_db, region="Свердловская область") - assert len(series) == 1 - assert stale_month in series - # Warning о staleness должен быть залогирован + assert series == {stale_month: 100_000.0} stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()] - assert stale_msgs, f"Ожидали warning о stale sber, caplog: {caplog.text}" - - -def test_fix5a_fresh_sber_no_warning(caplog: pytest.LogCaptureFixture) -> None: - """_load_sber_index_series НЕ логирует warning при свежей серии.""" - import logging - - from app.services.estimator import _load_sber_index_series - - # Текущий месяц (age=0..30 дней — точно свежее 35-дневного порога). - today = datetime.now(tz=UTC).date() - fresh_month = today.replace(day=1) # 1-е число ТЕКУЩЕГО месяца - mock_db = MagicMock() - mock_db.execute.return_value.mappings.return_value.all.return_value = [ - {"period_month": fresh_month, "index_value_rub_m2": 128_000.0} - ] - - with caplog.at_level(logging.WARNING, logger="app.services.estimator"): - series = _load_sber_index_series(mock_db, region="Свердловская область") - - assert len(series) == 1 - stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()] - assert not stale_msgs, f"Не ожидали stale warning для свежей серии, caplog: {caplog.text}" + assert not stale_msgs, f"per-estimate guard вернулся, caplog: {caplog.text}" # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py new file mode 100644 index 00000000..16815488 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py @@ -0,0 +1,263 @@ +"""#2827 — confidence↔reliability consistency (live-prod smoke, 2026-08-11). + +`confidence` (`_compute_confidence` — unique-address/IQR metric) and +`reliability` (`estimate_quality` — n_analogs-bucket + #oblast-F relaxation- +cascade metric, see #2823) were computed fully independently. The cascade's +room-adjacency/freshness/novostroyki steps never set `fallback_used`, so +`_compute_confidence` stayed blind to them and could keep scoring a wide, +tight-IQR (post-relaxation) sample as "high", while `reliability` — which DOES +see the relaxations — honestly read "low". + +Live prod repro (customer address, rooms=1, 23.1 m², radius=2000): + n_analogs: 39 + confidence: high + reliability: low + relaxations: ['снят фильтр по году постройки', 'учтены студии', 'площадь ±25%'] + +A client could see a "высокая уверенность" badge next to a "точность снижена" +banner on the SAME estimate — exactly the class of contradiction cleaned up +from the PDF/counters earlier in this cycle (#2824-adjacent work), now leaking +through the two headline confidence signals themselves. + +Fix: `_cap_confidence_by_reliability` (estimator.py), applied ONCE in +`estimate_quality` right after both `confidence` and `reliability` are final +— NOT spread across the #oblast-F cascade steps. Rule: + reliability == 'very_low' → confidence forced to 'low' + reliability == 'low' → confidence capped at 'medium' + reliability == 'ok' → confidence untouched (common case, unaffected) + +Two layers: + 1. `_cap_confidence_by_reliability` direct unit tests — the 3 rules in + isolation, no DB/estimate_quality overhead. + 2. `estimate_quality` integration tests — the prod repro shape (confidence + downgraded + confidence_explanation restructured to LEAD with the + accuracy-reduced summary) and the byte-identical-when-unrelaxed control. +""" + +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.estimator import _cap_confidence_by_reliability +from app.services.geocoder import GeocodeResult + +# ───────────────────────────────────────────────────────────────────────────── +# Layer 1 — `_cap_confidence_by_reliability` direct unit tests +# ───────────────────────────────────────────────────────────────────────────── + + +def test_very_low_reliability_forces_confidence_low() -> None: + """reliability == 'very_low' → confidence forced to 'low', regardless of + what _compute_confidence originally scored.""" + for original in ("high", "medium", "low"): + assert ( + _cap_confidence_by_reliability(original, "very_low") == "low" + ), f"original={original!r} must be forced to 'low' under very_low reliability" + + +def test_low_reliability_caps_confidence_at_medium() -> None: + """reliability == 'low' → confidence capped at 'medium': 'high' is pulled + down to 'medium'; 'medium'/'low' pass through unchanged (already <= cap).""" + assert _cap_confidence_by_reliability("high", "low") == "medium" + assert _cap_confidence_by_reliability("medium", "low") == "medium" + assert _cap_confidence_by_reliability("low", "low") == "low" + + +def test_ok_reliability_leaves_confidence_untouched() -> None: + """reliability == 'ok' — the common, unrelaxed case — must NOT change + confidence at all (byte-identical to pre-#2827 behaviour).""" + for original in ("high", "medium", "low"): + assert _cap_confidence_by_reliability(original, "ok") == original + + +def test_cap_never_raises_confidence() -> None: + """Sanity: the cap only lowers/holds — never raises 'low' to something + higher under any reliability value.""" + for reliability in ("ok", "low", "very_low"): + assert _cap_confidence_by_reliability("low", reliability) == "low" + + +# ───────────────────────────────────────────────────────────────────────────── +# Layer 2 — `estimate_quality` integration tests (full stub-patched I/O path) +# ───────────────────────────────────────────────────────────────────────────── + + +def _geo() -> GeocodeResult: + return GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Академика Парина, 46/5", + provider="nominatim", + ) + + +def _make_listing(*, price_per_m2: float, address: str, area_m2: float = 23.1) -> dict[str, Any]: + return { + "source": "avito", + "source_url": f"https://avito.ru/offer/{address}", + "address": address, + "lat": 56.838, + "lon": 60.595, + "rooms": 1, + "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 _tight_price_pool(n: int, base_ppm2: float = 195_000.0) -> list[dict[str, Any]]: + """n listings, distinct addresses, tightly clustered price/m² (~±2%) — + engineered to clear _compute_confidence's 'high' bar (unique_addr_count>=7 + AND iqr_pct<0.15) once assembled, exactly like the live prod repro (n=39, + confidence='high' pre-fix).""" + return [ + _make_listing( + price_per_m2=base_ppm2 + (i - n / 2) * 200, + address=f"ул. Академика Парина, {i + 1}", + ) + for i in range(n) + ] + + +def _payload_with_year() -> Any: + from app.schemas.trade_in import TradeInEstimateInput + + return TradeInEstimateInput( + address="ЕКБ, ул. Академика Парина, 46/5", + area_m2=23.1, + rooms=1, + floor=5, + total_floors=9, + year_built=2010, + city_hint="Екатеринбург", + radius_m=2000, + ) + + +def _run_estimate( + *, + payload: Any, + fetch_analogs_side_effect: Any, + dkp_raw: dict[str, Any] | None = None, +) -> Any: + from app.services.estimator import estimate_quality + + db = MagicMock() + geo = _geo() + + 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_side_effect, + ), + 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_prod_repro_high_confidence_low_reliability_gets_capped() -> None: + """Live prod repro shape: cohort (year_built) filter gets dropped by the + Tier-0 cascade (empty cohort-call → fallback), landing on a 39-listing, + tight-price, 39-unique-address pool — a healthy sample that + `_compute_confidence` would score 'high' on its own. Because a relaxation + WAS applied to get there, `reliability` reads 'low' — the cap must pull + `confidence` down to 'medium' (not leave it at the contradictory 'high'), + and `confidence_explanation` must LEAD with the accuracy-reduced summary.""" + pool = _tight_price_pool(39) + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + # Tier 0 (with cohort filter) → empty, forces the "drop cohort" fallback. + if kwargs.get("cohort_year_min") is not None: + return [], False, "W" + # Every subsequent (no-cohort) call → the full healthy pool. + return list(pool), False, "W" + + est = _run_estimate(payload=_payload_with_year(), fetch_analogs_side_effect=_fetch_analogs_stub) + + assert est.n_analogs == 39 + assert est.reliability == "low" + assert "снят фильтр по году постройки" in est.relaxations + assert est.confidence == "medium", ( + f"confidence={est.confidence!r} must be capped to 'medium' under reliability='low' " + "(was 'high' pre-#2827 — the exact prod contradiction)" + ) + explanation = est.confidence_explanation + assert explanation is not None + assert explanation.startswith( + "Оценка построена с расширенными параметрами подбора — точность снижена." + ), f"explanation must LEAD with the accuracy-reduced summary, got: {explanation!r}" + assert "Найдено 39 аналогов" in explanation, "original detail must be preserved" + assert "Применены послабления подбора: снят фильтр по году постройки." in explanation + + +def test_e2e_unrelaxed_estimate_is_byte_identical_control() -> None: + """#3 (task spec): no relaxations, reliability=='ok' → confidence and + confidence_explanation must be UNCHANGED by #2827 — this is the common + path most estimates take, and it must not regress.""" + pool = _tight_price_pool(39) + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + # No cohort filter this time (payload has no year_built) — Tier 0 is + # skipped outright (cohort_range is None), so no relaxation fires. + return list(pool), False, "W" + + from app.schemas.trade_in import TradeInEstimateInput + + payload = TradeInEstimateInput( + address="ЕКБ, ул. Академика Парина, 46/5", + area_m2=23.1, + rooms=1, + floor=5, + total_floors=9, + city_hint="Екатеринбург", + ) + est = _run_estimate(payload=payload, fetch_analogs_side_effect=_fetch_analogs_stub) + + assert est.n_analogs == 39 + assert est.relaxations == [] + assert est.reliability == "ok" + assert est.confidence == "high", "unrelaxed healthy sample must keep its real confidence" + assert est.confidence_explanation is not None + assert not est.confidence_explanation.startswith("Оценка построена с расширенными параметрами") + assert "Применены послабления подбора" not in est.confidence_explanation + assert est.confidence_explanation.startswith("Найдено 39 аналогов"), ( + "unrelaxed explanation must keep its original (pre-#2827) leading sentence, got: " + f"{est.confidence_explanation!r}" + ) 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..a4033a0f --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py @@ -0,0 +1,549 @@ +"""#oblast-E — headline sufficiency gate (money-path audit, 2026-08-02, priority +RESTORED 2026-08-10) + #oblast-F — never-block relaxation cascade (product +decision, 2026-08-10). + +History: + 1. #oblast-E (2026-08-02) SUPPRESSED a thin (1..HEADLINE_LISTINGS_MIN_N-1) + listings sample to a literal zero, forcing the anchor/#oblast-D-deals- + fallback/insufficient_data chain to take over — motivated by a live + Серов repro (n=3 → 42 391 ₽/м², −36% vs the town's ДКП corridor of + 54 126 ₽/м²). + 2. #oblast-F (2026-08-10, first pass) reversed that suppression WHOLESALE — + a thin sample always kept its own median, even when a much more reliable + deals corridor was available. That accidentally REOPENED the exact Серов + bug #oblast-E existed to close. + 3. #oblast-E priority RESTORED (2026-08-10, same day, product correction): + "никогда не блокировать вывод" ≠ "предпочитать шумную медиану по 3 + объявлениям надёжному коридору по 54 сделкам". Final 3-way rule, in + `_price_from_inputs`'s gate: + - n_analogs >= HEADLINE_LISTINGS_MIN_N → listings median (unaffected). + - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND a usable ДКП corridor + exists (count >= DEALS_HEADLINE_FALLBACK_MIN_N, median_ppm2 > 0) → + listings aggregate suppressed to zero, headline ceded to the + #oblast-D deals-headline-fallback chain (original #oblast-E + behaviour, restored). `PricingResult.deals_headline_due_to_thin_ + listings=True` — estimate_quality() adds relaxation label "оценка по + сделкам — мало объявлений рядом" and caps reliability at 'low'. + Listings display cards are NOT hidden (unlike original #oblast-E) — + `listings_clean` stays intact and estimate_quality() still surfaces + them as context even though they no longer drive n_analogs/median. + - 0 < n_analogs < HEADLINE_LISTINGS_MIN_N AND no usable ДКП corridor → + #oblast-F: keep the real thin median (never refuse outright). + Real refusal ("недостаточно данных") now happens ONLY at genuine n=0 + (no listings AND no usable anchor/deals) — the never-block requirement + with an honest, priority-ordered source selection. + +`estimate_quality()` tries to grow a thin sample FIRST via the #oblast-F +relaxation cascade (room-adjacency / freshness / novostroyki / radius, see +estimator.py module docstring) BEFORE `_price_from_inputs` (tested here in +Layer 1) ever runs the 3-way gate above — `listings` here is whatever that +cascade could find. + +Two layers: + 1. `_price_from_inputs` unit tests (no DB, no estimate_quality overhead) — + boundary behaviour of the gate itself: the 3-way rule, low-reliability + wording, listings_clean/listings_headline_thin_n/deals_headline_due_to_ + thin_listings bookkeeping. + 2. `estimate_quality` integration tests — proves the money-path invariants + that matter to a caller: thin+usable-deals routes to the deals corridor + (Серов repro), thin+no-deals keeps its own median, display `analogs` + cards are shown either way, and the #oblast-F room-adjacency relaxation + (studio↔1-комн) actually grows a thin sample and is reported via + `AggregatedEstimate.relaxations` / `reliability`. +""" + +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_kept_not_suppressed() -> None: + """#oblast-F: n=4 (< 5) → the REAL 4-listing median is kept (product decision + 2026-08-10 — never zero out a thin-but-real sample), just flagged low.""" + pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0, 230_000.0])) + assert pr.median_ppm2 == 215_000.0 + assert pr.n_analogs == 4 + assert pr.median_price == round(215_000.0 * 45.0) + assert pr.confidence == "low" + + +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_kept_not_suppressed() -> None: + """#oblast-F: n=1 — the sharpest thin case — still keeps its own (single-lot) + median rather than being zeroed; confidence stays 'low'.""" + pr = _call(listings=_lots([200_000.0])) + assert pr.median_ppm2 == 200_000.0 + assert pr.n_analogs == 1 + assert pr.confidence == "low" + + +def test_thin_sample_with_sufficient_deals_uses_deals_headline() -> None: + """#oblast-E priority RESTORED (2026-08-10 product correction): a thin + (n=3) listings sample must NOT outrank a usable ДКП deals corridor — this + is the exact live Серов repro #oblast-E exists for (3 noisy listings gave + 42 391 ₽/м², the honest 54-deal corridor gives 65 957 ₽/м²). Headline + comes from the deal corridor median, NOT the 3-listing median.""" + 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" + assert pr.deals_headline_due_to_thin_listings is True + assert pr.listings_clean, "listings_clean must stay intact — display cards still show them" + # #4: explanation must not falsely claim "рядом нет объявлений" (some WERE + # found, just ceded priority to the more reliable deals corridor) and must + # NOT also carry the separate "Оценка построена по N аналогам" thin-kept + # wording (that phrasing is reserved for the no-usable-corridor branch). + assert pr.explanation is not None + assert "рядом нет актуальных объявлений" not in pr.explanation.lower() + assert "сделкам росреестра" in pr.explanation.lower() + assert "оценка построена по 3" not in pr.explanation.lower() + + +def test_thin_sample_with_thin_deals_also_uses_real_listings_median() -> None: + """n=3 listings (thin) + a ДКП corridor that is ITSELF too thin + (< DEALS_HEADLINE_FALLBACK_MIN_N) → the corridor is NOT usable, so + #oblast-F's never-block rule applies: the real listings median is kept + rather than refusing (neither source alone would justify a hard zero).""" + 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 == 42_391.0 + assert pr.n_analogs == 3 + assert pr.deals_headline_due_to_thin_listings is False + + +def test_thin_sample_explanation_is_honest_about_low_accuracy() -> None: + """#4 (task spec): the explanation for a thin-but-real sample must read as + "small sample, lower accuracy" — NOT the old refusal-flavoured "минимум для + оценки по рынку" copy, and NOT the generic zero-analogs text.""" + 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() + assert "точность снижена" in pr.explanation.lower() + assert "минимум для оценки по рынку" not in pr.explanation.lower() + assert "не найдено аналогов" not in pr.explanation.lower() + + +def test_zero_listings_with_sufficient_deals_still_uses_deals_headline() -> None: + """Control: the #oblast-D deals-headline-fallback path is UNCHANGED for + GENUINELY zero listings (n=0) — #oblast-F only affects the 1..N-1 thin + case, not the true-zero case, which still needs a fallback source.""" + dkp_raw = { + "count": 54, + "low_ppm2": 44_000, + "median_ppm2": 65_957, + "high_ppm2": 89_000, + "period_months": 12, + } + pr = _call(listings=[], dkp_raw=dkp_raw) + assert pr.median_ppm2 == 65_957.0 + assert pr.n_analogs == 0 + assert pr.confidence == "low" + assert pr.explanation is not None + assert "рядом нет актуальных объявлений" in pr.explanation.lower() + assert "сделкам росреестра" in pr.explanation.lower() + + +def test_thin_sample_listings_clean_preserved_and_thin_n_still_tracked() -> None: + """listings_clean stays intact (unchanged invariant — same-building anchor's + ghost-anchor guard #1871 depends on it) AND, post-#oblast-F, n_analogs is + the REAL count (not zeroed) while listings_headline_thin_n still marks the + sample as thin for the low-reliability note upstream.""" + pr = _call(listings=_lots([200_000.0, 210_000.0, 220_000.0])) + assert pr.n_analogs == 3 + 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 + + +def test_repair_coefficient_now_applies_to_thin_sample() -> None: + """#oblast-F: pre-#oblast-F, the repair-state coefficient was skipped for a + thin sample because the headline was already zeroed (applying it would be a + no-op). Now that the real median is kept, the coefficient must apply.""" + pr_no_repair = _call(listings=_lots([200_000.0, 210_000.0])) # n=2, thin + pr = _price_from_inputs( + listings=_lots([200_000.0, 210_000.0]), + area_m2=45.0, + rooms=2, + repair_state="excellent", + floor=5, + total_floors=9, + target_year=None, + 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=lambda _appm2: (None, None), + quarter_index_lookup=lambda q: None, + quarter_indexes_lookup=lambda qs: {}, + target_house_cadnum=None, + dadata_coarse=False, + geo=_geo(), + dadata_qc_geo=None, + ) + assert ( + pr.median_price != pr_no_repair.median_price + ), "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample" + + +# ───────────────────────────────────────────────────────────────────────────── +# 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]] | None = None, + dkp_raw: dict[str, Any] | None, + fetch_analogs_side_effect: Any = None, + payload: Any = None, + geo: GeocodeResult | None = None, +) -> Any: + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = payload or _serov_payload() + geo = geo or _geo() + + fetch_analogs_kwargs: dict[str, Any] = ( + {"side_effect": fetch_analogs_side_effect} + if fetch_analogs_side_effect is not None + else {"return_value": (list(analogs or []), False, "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", **fetch_analogs_kwargs), + 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_sample_no_relaxation_help_keeps_real_median() -> None: + """#oblast-F: 2 thin listings, no ДКП, and the mocked `_fetch_analogs` always + returns the SAME 2 listings regardless of relaxation params (none of them + help) — median_price_rub must be the REAL non-zero 2-listing median, + insufficient_data False, n_analogs=2, confidence='low', reliability + 'very_low' (n<3), relaxations empty (nothing actually helped).""" + 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 == round(205_000.0 * 45.0) + assert est.insufficient_data is False + assert est.n_analogs == 2 + assert est.confidence == "low" + assert est.relaxations == [] + assert est.reliability == "very_low" + + +def test_e2e_thin_sample_display_cards_match_n_analogs() -> None: + """#oblast-F: display `analogs` cards are NO LONGER suppressed for a thin + sample — they must match n_analogs exactly (both = 2), never hidden.""" + 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 == 2 + assert len(est.analogs) == 2 + + +def test_e2e_serov_repro_thin_sample_routes_to_deals_headline() -> None: + """Live Серов repro (n=3 scraped listings, wide ДКП corridor available) — + #oblast-E priority RESTORED: headline must come from the deal corridor, + not the noisy 3-listing median. Also proves the #4 task-spec requirements + layered on top of the restored priority: the estimate is honestly non- + 'insufficient' (a real number, low confidence), reliability is capped at + 'low' (not 'very_low' — a 54-deal corridor is real signal), the + relaxation label names the source switch, AND the 3 thin listings are + still shown as display cards (not discarded) even though they no longer + drive n_analogs/median.""" + 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() + assert est.reliability == "low", "a 54-deal corridor is real signal, not 'very_low'" + assert "оценка по сделкам — мало объявлений рядом" in est.relaxations + assert len(est.analogs) == 3, "thin listings must still surface as display cards" + + +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, no relaxations needed.""" + 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 + assert est.relaxations == [] + assert est.reliability == "low" # n=5 falls in the 3..7 bucket + + +def test_e2e_rooms_relaxation_includes_studios_when_thin() -> None: + """#oblast-F step (a) — the exact scenario from the task spec: rooms=1 thin + sample (studio-adjacent building, live prod repro Академика Парина 46/5) → + cascade retries with rooms IN (0,1) and finds a trustworthy sample there. + Asserts: studios pulled in, `relaxations` names it, real non-zero median, + reliability downgraded to 'low' (thin base sample).""" + from app.schemas.trade_in import TradeInEstimateInput + + exact_rooms1 = [ + _make_listing(price_per_m2=150_000.0, address="ул. Парина, 1", area_m2=23.0), + _make_listing(price_per_m2=155_000.0, address="ул. Парина, 2", area_m2=23.0), + ] + studio_pool = [ + *exact_rooms1, + _make_listing(price_per_m2=140_000.0, address="ул. Парина, 3", area_m2=20.0), + _make_listing(price_per_m2=145_000.0, address="ул. Парина, 4", area_m2=21.0), + _make_listing(price_per_m2=148_000.0, address="ул. Парина, 5", area_m2=22.0), + ] + + def _fetch_analogs_stub(*_args: Any, **kwargs: Any) -> tuple[list[dict[str, Any]], bool, str]: + if kwargs.get("rooms_min") == 0 and kwargs.get("rooms_max") == 1: + return list(studio_pool), False, "W" + return list(exact_rooms1), False, "W" + + geo = GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Парина, 46/5", + provider="nominatim", + ) + payload = TradeInEstimateInput( + address="ЕКБ, ул. Парина, 46/5", + area_m2=23.1, + rooms=1, + ) + + est = _run_estimate( + dkp_raw=None, + fetch_analogs_side_effect=_fetch_analogs_stub, + payload=payload, + geo=geo, + ) + + assert "учтены студии" in est.relaxations + assert est.median_price_rub > 0 + assert est.reliability == "low" + assert est.n_analogs == 5 + + +def test_e2e_radius_relaxation_respects_explicit_user_radius() -> None: + """#oblast-F step (d) contract: when the user explicitly picked radius_m + (#2044), the cascade must NOT auto-expand past it — mirrors the existing + radius-fallback contract above (no auto-expansion beyond user's choice).""" + from app.schemas.trade_in import TradeInEstimateInput + + thin = [ + _make_listing(price_per_m2=200_000.0, address="ул. Ленина, 5"), + _make_listing(price_per_m2=210_000.0, address="ул. Ленина, 7"), + ] + payload = TradeInEstimateInput( + address="Серов, ул. Ленина, 5", + area_m2=45.0, + rooms=2, + floor=5, + total_floors=9, + city_hint="Серов", + radius_m=1500, + ) + est = _run_estimate(analogs=thin, dkp_raw=None, payload=payload) + assert not any("радиус расширен" in r for r in est.relaxations) + assert est.search_radius_m == 1500 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_pure_units.py b/tradein-mvp/backend/tests/test_estimator_pure_units.py index 1170e4c2..b2f64d2c 100644 --- a/tradein-mvp/backend/tests/test_estimator_pure_units.py +++ b/tradein-mvp/backend/tests/test_estimator_pure_units.py @@ -378,7 +378,7 @@ def test_corridor_clamp_above_corridor_tier_c_clamps() -> None: def test_corridor_clamp_inside_corridor_is_noop() -> None: # Эконом/комфорт: headline в коридоре (с учётом slack) → ничего не меняется. - new_ppm2, new_price, new_low, new_high, clamped = _clamp( + new_ppm2, _, _, _, clamped = _clamp( median_ppm2=140_000, corridor_high=130_000, count=20, tier="C" ) assert clamped is False diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py new file mode 100644 index 00000000..d92a9ab9 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_quarter_from_own_coords.py @@ -0,0 +1,216 @@ +"""Квартал ЦЕЛИ для индекса #764 берётся у цели, а не у аналога. + +Дефект (прод, замер 2026-08-12): `dadata.house_cadnum` пуст в 15 из 15 применений +квартального индекса, и `target_quarter` молча брался из кадастра ПЕРВОГО аналога с +кадастром. Аналог-донор стоял в 201-4087 м от цели; индексы кварталов на такой +дистанции расходятся в 1.15-1.23 раза по медиане и в 1.6-1.8 по p90 — то есть +«индекс квартала цели» на витрине был индексом квартала соседа, и он двигал цену +(применённые на проде множители: 0.60 ×3, 0.73 ×2, 0.76 ×2, 0.79 ×2, 0.80, 1.03 ×3, +1.08, 1.09). + +Паттерн: os.environ.setdefault перед импортом (как test_estimator_quarter_index.py). +Фикстуры лотов/payload переиспользуются оттуда же; harness здесь свой, потому что +тестам нужны РАЗНЫЕ индексы у разных кварталов (общий harness отдаёт один индекс +всем кварталам → factor всегда 1.0, подмена квартала невидима) и контроль над +координатным резолвом квартала цели. +""" + +from __future__ import annotations + +import os +from contextlib import ExitStack +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 + +from tests.test_estimator_quarter_index import ( + _AREA, + _BASE_PPM2, + _make_fake_dadata, + _make_fake_geo_qi, + _make_listing_qi, + _make_payload_qi, +) + +_TARGET_Q = "66:41:0204016" +_NEIGH_CHEAP_Q = "66:41:0601022" +_NEIGH_RICH_Q = "66:41:0601033" +_INDEXES = {_TARGET_Q: 1.2, _NEIGH_CHEAP_Q: 0.8, _NEIGH_RICH_Q: 1.6} + +_UNPATCHED = object() # sentinel: координатный резолвер не подменяем + + +def _run_estimate( + *, + analogs: list[dict[str, Any]], + dadata_cadnum: str | None, + coords_quarter: Any = _UNPATCHED, + coords_enabled: bool = True, +): + """estimate_quality с полным stub-пачем I/O; индексы кварталов — из _INDEXES. + + coords_quarter: + * `_UNPATCHED` (default) — координатный резолвер НЕ подменяется, db-мок + отдаёт «зданий рядом нет» (`.first() → None`), т.е. свой квартал цели + неизвестен. Этот режим исполним и на старом коде (там резолвера ещё нет) — + старый код подставит квартал аналога, на чём тест и падает. + * str / None — явная подмена резолвера. + + coords_enabled — значение `estimate_quarter_from_coords_enabled` на время + прогона (в проде флаг ВЫКЛЮЧЕН, см. config); тесты координатной половины + включают его явно. + """ + from app.services.estimator import estimate_quality, settings + + db = MagicMock() + # «Ни одного здания в радиусе порога»: единственный db.execute(...).first() на + # пути оценки (всё остальное I/O застаблено) — координатный резолв квартала. + db.execute.return_value.first.return_value = None + + dadata_obj = _make_fake_dadata(dadata_cadnum) if dadata_cadnum is not None else None + + def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals): + idx = _INDEXES.get(quarter_cad_number) + return (idx, 30) if idx is not None else None + + def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals): + return {q: _INDEXES[q] for q in quarter_cad_numbers if q in _INDEXES} + + async def _run(): + stubs = [ + patch( + "app.services.estimator.geocode", + new=AsyncMock(return_value=_make_fake_geo_qi()), + ), + patch( + "app.services.estimator.dadata_clean_address", + new=AsyncMock(return_value=dadata_obj), + ), + 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_deals", return_value=[]), + patch("app.services.estimator._fetch_dkp_corridor", 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), + ), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + patch("app.services.estimator._fetch_house_imv_anchor", return_value=None), + patch("app.services.estimator._lookup_quarter_index", side_effect=_fake_lookup_index), + patch( + "app.services.estimator._lookup_quarter_indexes", + side_effect=_fake_lookup_indexes, + ), + ] + if coords_quarter is not _UNPATCHED: + stubs.append( + patch( + "app.services.estimator._lookup_target_quarter_by_coords", + return_value=coords_quarter, + ) + ) + stubs.append(patch.object(settings, "estimate_quarter_from_coords_enabled", coords_enabled)) + with ExitStack() as stack: + for stub in stubs: + stack.enter_context(stub) + return await estimate_quality(_make_payload_qi(), db) + + return anyio.run(_run) + + +def _analogs_two_quarters() -> list[dict[str, Any]]: + """5 аналогов в дешёвом квартале + 5 в дорогом, одинаковая цена ₽/м². + + Первым в listings_clean идёт дешёвый — именно его кадастр старый код брал за + кадастр ЦЕЛИ. avg_analog_index = (0.8 + 1.6) / 2 = 1.2 при равных весах. + Разные этажи обязательны: физический ключ _dedup_cross_source иначе схлопнет + все лоты в один. + """ + cheap = [ + _make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i) + for i in range(5) + ] + rich = [ + _make_listing_qi(building_cadastral_number=f"{_NEIGH_RICH_Q}:{200 + i}", floor=8 + i) + for i in range(5) + ] + return cheap + rich + + +def test_neighbour_cadastre_no_longer_sets_target_quarter() -> None: + """Свой квартал цели неизвестен → поправки нет (а не «квартал первого аналога»). + + КРАСНЫЙ на старом коде: там target_quarter = 66:41:0601022 (дешёвый сосед, + index 0.8), avg = 1.2 → factor 0.667 → медиана падает на треть, и пользователю + пишется «Учтена локация квартала (индекс цен квартала ×0.67)». + """ + est = _run_estimate(analogs=_analogs_two_quarters(), dadata_cadnum=None) + + assert est.median_price_rub == round(_BASE_PPM2 * _AREA) + assert "индекс цен квартала" not in (est.confidence_explanation or "") + + +def test_target_quarter_from_own_coords_drives_correction() -> None: + """Квартал цели снят по ЕЁ координатам → поправка считается от него. + + index цели 1.2 / avg аналогов 1.2 → factor 1.00: цена не двигается, потому что + уровень аналогов и уровень цели совпали. Старый код взял бы 0.8 у соседа и + ×0.67 без всяких данных о цели. + """ + est = _run_estimate( + analogs=_analogs_two_quarters(), + dadata_cadnum=None, + coords_quarter=_TARGET_Q, + ) + + assert est.median_price_rub == round(_BASE_PPM2 * _AREA) + assert "индекс цен квартала ×1.00" in (est.confidence_explanation or "") + + +def test_own_cadastre_wins_over_coords() -> None: + """Свой кадастр DaData приоритетнее координатного резолва — резолв не зовётся.""" + analogs_cheap = [ + _make_listing_qi(building_cadastral_number=f"{_NEIGH_CHEAP_Q}:{100 + i}", floor=2 + i) + for i in range(5) + ] + with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy: + est = _run_estimate(analogs=analogs_cheap, dadata_cadnum=f"{_TARGET_Q}:350") + + assert spy.call_count == 0 + # target 1.2 / avg 0.8 = 1.5 + assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5) + + +def test_coords_resolver_dormant_by_default() -> None: + """Флаг выключен (прод-дефолт) → координатный резолв не зовётся вообще. + + Поставляемое поведение = снятие фолбэка на кадастр аналога, и только оно: + точность самого резолва измерена (92% на 25 м), а эффект поправки на ЦЕНУ — + нет, поэтому включение — отдельное решение (см. config, критерий и дата). + """ + with patch("app.services.estimator._lookup_target_quarter_by_coords") as spy: + est = _run_estimate( + analogs=_analogs_two_quarters(), + dadata_cadnum=None, + coords_quarter=_UNPATCHED, + coords_enabled=False, + ) + + assert spy.call_count == 0 + assert est.median_price_rub == round(_BASE_PPM2 * _AREA) + assert "индекс цен квартала" not in (est.confidence_explanation or "") 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_search_radius_2632.py b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py new file mode 100644 index 00000000..5b824132 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_search_radius_2632.py @@ -0,0 +1,250 @@ +"""#2632 — ответ несёт ФАКТИЧЕСКИЙ радиус подбора, а не выбор пользователя. + +Прод, 2026-08-11 (оценка 4c20518f, ул. Советская 51): сохранённая строка несёт +`relaxations = ["радиус расширен до 5000 м"]`, все 10 сохранённых аналогов лежат +дальше километра (самый дальний — 4381 м), а `GET /estimate/{id}` отдавал +`search_radius_m: null` → фронт падал на превью-радиус 1 км и рисовал круг, за +которым остаются его же пины. Там же вторая ложь: `confidence_explanation` +утверждал «расширили радиус до 2 км» — константа старого 1км→2км fallback'а, +хотя каскад #oblast-F ушёл до 5 км. + +Тесты падают на коде до этой правки: helper'ов не существует (ImportError), +а объяснение прибито к «2 км». +""" + +from __future__ import annotations + +import os +import sys +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) +sys.modules.setdefault("weasyprint.CSS", _wp_mock) +sys.modules.setdefault("weasyprint.HTML", _wp_mock) + +import pytest # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from app.services.estimator import ( # noqa: E402 + DEFAULT_RADIUS_M, + _compute_confidence, + radius_from_relaxations, + radius_relaxation_label, + rehydrate_search_radius_m, +) + +_ESTIMATE_ID = "4c20518f-7d5d-4f9a-9dde-8f65d5ea2202" + + +# ── подпись каскада ↔ разбор: единственный носитель числа в persisted-состоянии ── + + +def test_radius_label_roundtrips() -> None: + assert radius_from_relaxations([radius_relaxation_label(5000)]) == 5000 + assert radius_from_relaxations(["учтены студии", radius_relaxation_label(2000)]) == 2000 + # прод-строка дословно + assert radius_from_relaxations(["радиус расширен до 5000 м"]) == 5000 + + +def test_radius_label_absent_or_malformed_gives_none() -> None: + assert radius_from_relaxations(None) is None + assert radius_from_relaxations([]) is None + assert radius_from_relaxations(["учтены новостройки", "площадь ±25%"]) is None + assert radius_from_relaxations(["радиус расширен до пяти км"]) is None + + +# ── реконструкция радиуса для сохранённой оценки ────────────────────────────── + + +def test_rehydrate_prefers_persisted_label_over_analog_extent() -> None: + """Подпись — точное значение (то же, что вернул POST); размах — лишь оценка снизу.""" + assert rehydrate_search_radius_m(["радиус расширен до 5000 м"], [4381, 1200, None]) == 5000 + + +def test_rehydrate_falls_back_to_analog_extent() -> None: + """Строки до миграции 255: подписи нет, но круг обязан вмещать свои же пины.""" + assert rehydrate_search_radius_m([], [800, 2428, None]) == 2428 + + +def test_rehydrate_never_below_default_radius() -> None: + """Все аналоги рядом → поиск всё равно шёл по базовому радиусу, не по 300 м.""" + assert rehydrate_search_radius_m([], [120, 300]) == DEFAULT_RADIUS_M + + +def test_rehydrate_without_distances_stays_none() -> None: + """Нечего утверждать — фронт честно падает на выбор пользователя (как раньше).""" + assert rehydrate_search_radius_m([], []) is None + assert rehydrate_search_radius_m(None, [None, None]) is None + + +# ── объяснение называет тот радиус, по которому реально искали ──────────────── + + +def _confidence(radius_used_m: int | None) -> str: + _, explanation = _compute_confidence( + 12, + 100_000.0, + 95_000.0, + 105_000.0, + True, # fallback_radius_used + False, + listings=[{"address": f"дом {i}"} for i in range(12)], + radius_used_m=radius_used_m, + ) + return explanation + + +def test_explanation_names_actual_radius_not_hardcoded_2km() -> None: + text = _confidence(5000) + assert "5 км" in text + assert "2 км" not in text + + +def test_explanation_keeps_2km_wording_when_that_is_the_truth() -> None: + assert "2 км" in _confidence(2000) + # не-круглый радиус не притворяется километрами + assert "1500 м" in _confidence(1500) + + +# ── GET /estimate/{id}: сохранённая оценка отдаёт фактический радиус ────────── + + +def _prod_shaped_row() -> SimpleNamespace: + """Форма прод-строки 4c20518f (расширение до 5 км, аналоги дальше километра).""" + analog = { + "address": "Екатеринбург, ул. Советская, 51", + "area_m2": 44.0, + "rooms": 2, + "floor": 3, + "total_floors": 9, + "price_rub": 6_000_000, + "price_per_m2": 136_000, + "listing_date": None, + "days_on_market": None, + "source": "cian", + "distance_m": 4381, + } + return SimpleNamespace( + id=_ESTIMATE_ID, + median_price=6_000_000, + range_low=5_500_000, + range_high=6_500_000, + median_price_per_m2=136_000, + confidence="low", + confidence_explanation="Найдено 42 аналогов", + n_analogs=42, + analogs=[analog, {**analog, "distance_m": 1200}], + actual_deals=[], + sources_used=["cian"], + data_freshness_minutes=15, + expires_at=datetime.now(tz=UTC) + timedelta(hours=12), + retain_until=None, + address="Свердловская область, г. Екатеринбург, ул. Советская, д. 51", + lat=56.8519, + lon=60.6122, + area_m2=44.0, + rooms=2, + floor=3, + total_floors=9, + year_built=1975, + house_type=None, + repair_state=None, + has_balcony=None, + canonical_address=None, + house_cadnum=None, + house_fias_id=None, + dadata_qc_geo=None, + dadata_metro=[], + expected_sold_price=None, + expected_sold_range_low=None, + expected_sold_range_high=None, + expected_sold_per_m2=None, + asking_to_sold_ratio=None, + ratio_basis=None, + created_by="praktika", + created_at=datetime.now(tz=UTC), + relaxations=["радиус расширен до 5000 м"], + reliability="low", + ) + + +@pytest.fixture() +def _light_estimator_stub(): + """Подменяет тяжёлые POST-only helper'ы estimator'а, оставляя РЕАЛЬНУЮ реконструкцию. + + Тот же приём, что в test_estimate_idor / test_estimate_revival (не тащить + геокодер/скрейперы в API-тест), но `rehydrate_search_radius_m` берётся + настоящий — его поведение и проверяем. + """ + real_estimator = sys.modules.get("app.services.estimator") + stub = SimpleNamespace( + _qc_geo_to_precision=lambda _qc: None, + _fetch_price_trend=lambda *a, **k: None, + _fetch_dkp_corridor=lambda *a, **k: None, + _fetch_house_imv_anchor=lambda *a, **k: None, + _resolve_target_city=lambda *a, **k: None, + _cv_from_ppm2=lambda *a, **k: None, + _source_counts=lambda *a, **k: {}, + _canonical_sources=lambda *a, **k: ["cian"], + rehydrate_search_radius_m=rehydrate_search_radius_m, + ) + sys.modules["app.services.estimator"] = stub # type: ignore[assignment] + yield stub + if real_estimator is not None: + sys.modules["app.services.estimator"] = real_estimator + else: + sys.modules.pop("app.services.estimator", None) + + +@pytest.fixture(autouse=True) +def _restore_get_role(): + from app.core import auth as auth_mod + + original = auth_mod.get_role + yield + auth_mod.get_role = original + + +def test_get_estimate_returns_actual_search_radius(_light_estimator_stub: SimpleNamespace) -> None: + """Прод-репро: круг на реоткрытой оценке рисовался по 1 км при поиске в 5 км.""" + from app.api.v1 import trade_in as trade_in_module + from app.core import auth as auth_mod + from app.core.db import get_db + + app = FastAPI() + app.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + + row = _prod_shaped_row() + db = MagicMock() + + def _execute(clause, params=None, *_a, **_k): + sql = getattr(clause, "text", str(clause)) + result = MagicMock() + result.fetchone.return_value = row if "SELECT id, median_price" in sql else None + return result + + db.execute.side_effect = _execute + + def _override_db(): + yield db + + app.dependency_overrides[get_db] = _override_db + auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment] + + resp = TestClient(app).get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "praktika"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["search_radius_m"] == 5000 + # круг обязан вмещать пины, которые сам же показывает + assert body["search_radius_m"] >= max(a["distance_m"] for a in body["analogs"]) + # вход не выдаём за результат: radius_m не персистится — «просили» неизвестно + assert body["requested_radius_m"] is None 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..5962af60 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,23 @@ 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). Фильтруем по SQL, а не по позиции вызова: + # #2674 однажды уже сдвинул позицию, добавив второй execute перед вставкой. + rows = _history_rows(db) assert isinstance(rows, list) and len(rows) == 2 - db.commit.assert_called_once() + # Один коммит — батч истории. Второй (UPDATE houses.has_panorama) ушёл вместе + # с колонкой, хвост #2674, мигр. 259. + assert db.commit.call_count == 1 def test_save_history_items_empty_no_commit(): + """Пустая история + НЕподтверждённая страница → дом резолвится, но не пишется ничего. + + #2674 (ревью): ранний возврат по пустой истории раньше стоял ПЕРВЫМ и заодно + отрезал резолв дома для отрисованных страниц без объявлений (~10%). Теперь + match_or_create_house вызывается до возврата — а записей по-прежнему ноль: + истории нет, вставлять нечего. + """ db = MagicMock() result = YandexValuationResult( address="x", @@ -189,11 +205,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 +245,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, а не по позиции вызова. Урок остаётся в силе + и после сноса has_panorama (мигр. 259): позиционный side_effect молча проверял бы + не тот путь, стоит появиться любому новому execute перед вставкой истории. + """ 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 +267,4 @@ 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 == 0 diff --git a/tradein-mvp/backend/tests/test_gar_flats_loader.py b/tradein-mvp/backend/tests/test_gar_flats_loader.py index e98fc4c5..62b2e9a3 100644 --- a/tradein-mvp/backend/tests/test_gar_flats_loader.py +++ b/tradein-mvp/backend/tests/test_gar_flats_loader.py @@ -449,8 +449,14 @@ def test_upsert_and_canon_match_populates_gar_flat_count(gar_dir: str) -> None: db = _live_session() assert db is not None try: - db.execute(text(_NORMALIZER_FN)) - db.execute(text(_CANON_FN)) + # exec_driver_sql, НЕ text(): в обеих функциях есть regexp-группы `(?:...)`, + # а `text()` читает `:Россия` / `:кв` / `:асть` как bind-параметры и падает + # с InvalidRequestError ещё до первого утверждения. Драйверный путь ничего + # не парсит. (Тест жил с 2026-06-28 и до #2741 не исполнялся ни разу — под + # skipif «нет БД» падение было не видно.) + raw = db.connection() + raw.exec_driver_sql(_NORMALIZER_FN) + raw.exec_driver_sql(_CANON_FN) db.execute( text( "CREATE TEMP TABLE gar_house_flats (" 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_city_substitution.py b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py new file mode 100644 index 00000000..b6626d94 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocoder_city_substitution.py @@ -0,0 +1,277 @@ +"""Тесты #2590 — постфактум-инвариант «назван чужой топоним + результат внутри ЕКБ». + +Гейты #2582/#2589 стоят НА ВХОДЕ и решают, пускать ли ЕКБ-only локальные реестры. +Внешние провайдеры ими не покрыты: «реж, ленина» уходит в Nominatim/Yandex, тот не +находит Режа и отдаёт улицу Ленина в Екатеринбурге — с координатами ЕКБ, пометкой +«точно» и записью в кэш на 90 дней. Замер на проде (`geocode_cache`, 9514 строк, +2026-08-12): **1251 запись** подпадает под инвариант — 1245 `provider=yandex`, +6 `nominatim`, 0 у локальных тиров; 365 разных населённых пунктов. + +Инвариант проверяется ПОСЛЕ ответа провайдера, потому что на входе «Реж» (город) +и «Малышева» (улица) структурно неразличимы — оба «слово до запятой». После +ответа различимы: провайдер сам пишет, какой НП он использовал. + +Адреса и full_address в тестах — живые строки из прод-кэша (не выдуманные). +""" + +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, + _city_substituted, + _claimed_locality, + geocode, +) + +# ── _claimed_locality: какой НП назван в тексте ────────────────────────────── + + +@pytest.mark.parametrize( + "address,expected", + [ + # НП назван явно + ("реж, ленина", "реж"), + ("арамиль, красноармейская ул, 118дк1", "арамиль"), + ("пос. кедровка, советская ул.,5", "кедровка"), # тип «пос.» отброшен, имя осталось + # Уровни выше НП пропускаются, НП находится дальше + ("свердловская обл., г.о. рефтинский", "рефтинский"), + ("россия, свердловская область, реж, ленина 5", "реж"), + # НП НЕ назван — основной трафик формы оценки, инвариант не применяется + ("малышева 30", None), + ("ул. титова/монтерская/смоленская, стр. 2", None), + ("улица декабристов, 16-18литж · р-н октябрьский", None), + ("мкр-н широкая речка, ул. анатолия муранова,18", None), + # Перекрёсток: второй уличный сегмент НЕ должен читаться как топоним + ("малышева, мопра", "малышева"), + ], +) +def test_claimed_locality(address: str, expected: str | None) -> None: + assert _claimed_locality(address) == expected + + +# ── _city_substituted: подмена vs честный ответ ────────────────────────────── + + +def _res(full_address: str, lat: float, lon: float) -> GeocodeResult: + return GeocodeResult( + lat=lat, lon=lon, full_address=full_address, provider="nominatim", confidence="exact" + ) + + +@pytest.mark.parametrize( + "address,full_address,lat,lon", + [ + # Живые записи прод-кэша из тела #2590 (все provider=yandex) + ( + "реж, ленина", + "Россия, Свердловская область, Екатеринбург, улица Ленина", + 56.83953, + 60.61244, + ), + ( + "реж, краснофлотцев", + "Россия, Свердловская область, Екатеринбург, улица Краснофлотцев", + 56.88996, + 60.63184, + ), + ( + "арамиль, заводская", + "Россия, Свердловская область, Екатеринбург, Заводская улица", + 56.83043, + 60.55170, + ), + ( + "свердловская обл., г.о. рефтинский", + "Россия, Свердловская область, Екатеринбург", + 56.83744, + 60.59764, + ), + # Города вне списка 37 и без структурной приставки — дыра, оставленная #2589 + ( + "талица, кузнецова", + "Россия, Свердловская область, Екатеринбург, улица Кузнецова", + 56.84226, + 60.64557, + ), + ( + "верхотурье, герцена", + "Россия, Свердловская область, Екатеринбург, улица Герцена", + 56.744397, + 60.677299, + ), + # Посёлок В ЧЕРТЕ ЕКБ, но улица подменена: настоящая Кедровка в 20 км от + # улицы Советской, куда провайдер поставил точку (живая запись прода). + ( + "пос. кедровка, советская ул.,5", + "Россия, Свердловская область, Екатеринбург, Советская улица, 5", + 56.8506, + 60.6282, + ), + ], +) +def test_substitution_detected(address: str, full_address: str, lat: float, lon: float) -> None: + assert _city_substituted(address, _res(full_address, lat, lon)) is True + + +@pytest.mark.parametrize( + "address,full_address,lat,lon", + [ + # Посёлок в черте ЕКБ, ответ ЧЕСТНЫЙ — имя дожило до full_address. + # Тот же вход, что и в подмене выше: разделяет их не география, а + # сохранность топонима (граница из #2777 — посёлки не ловятся + # «городским» признаком, и здесь ловить их и не нужно). + ( + "пос. кедровка, советская ул.,5", + "5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург", + 56.9902, + 60.7166, + ), + ( + "шувакиш, ленина 4", + "4, улица Ленина, Шувакиш, Железнодорожный район, Екатеринбург", + 56.9295, + 60.4938, + ), + # НП не назван вообще — основной трафик формы, ЕКБ как рабочая гипотеза + ( + "малышева 30", + "Россия, Свердловская область, Екатеринбург, улица Малышева, 30", + 56.8389, + 60.6057, + ), + # Явный Екатеринбург в адресе — не подмена по определению + ("екатеринбург, ул. репина, 99", "Репина, 99", 56.8207, 60.5686), + ("г екатеринбург, ул сурикова, д 47", "Сурикова, 47", 56.8156, 60.5943), + # Честный ответ по другому городу области: провайдер назвал НП, координаты + # в его собственном bbox — под инвариант не подпадает + ("нижний тагил, ленина 1", "1, проспект Ленина, Нижний Тагил", 57.9101, 59.9813), + # Ответ внутри ЕКБ-bbox, но провайдер назвал ДРУГОЙ НП (Берёзовский лежит + # внутри щедрого EKB_BBOX_TIGHT) — «внутри Екатеринбурга» не подтверждено + ( + "режевской, ленина", + "Россия, Свердловская область, Берёзовский, улица Ленина", + 56.9088, + 60.8107, + ), + # Перекрёсток двух улиц: провайдер взял первую — потеря второй улицы не + # является подменой города + ( + "малышева, мопра", + "Россия, Свердловская область, Екатеринбург, улица Малышева", + 56.8389, + 60.6057, + ), + # Тот же НП, но за пределами ЕКБ — инвариант молчит + ("реж, ленина", "Россия, Свердловская область, Реж, улица Ленина", 57.3722, 61.3897), + ], +) +def test_no_substitution(address: str, full_address: str, lat: float, lon: float) -> None: + assert _city_substituted(address, _res(full_address, lat, lon)) is False + + +# ── geocode(): сквозной эффект ─────────────────────────────────────────────── + + +@contextlib.contextmanager +def _tiers(*, cached=None, nominatim=None, geoportal=None): + with contextlib.ExitStack() as stack: + stack.enter_context(patch("app.services.geocoder._cache_get", return_value=cached)) + stack.enter_context(patch("app.services.geocoder._cache_put")) + stack.enter_context( + patch("app.services.geocoder._geoportal_house_match", return_value=geoportal) + ) + 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(return_value=nominatim)) + ) + stack.enter_context(patch("app.services.geocoder.asyncio.sleep", new=AsyncMock())) + yield + + +async def test_geocode_downgrades_substituted_result() -> None: + """«Реж, Ленина 5» → провайдер отдал улицу Ленина в ЕКБ с confidence=exact. + + Координаты остаются (честная деградация, не отказ), но confidence падает до + `locality` — а это уже рабочий контракт: `estimator._geocode_is_coarse` + включает #693 coarse-downgrade, `tasks.geocode_missing` ставит листингу + `geo_precision='city'`, что исключает его из пула аналогов. + """ + db = MagicMock() + substituted = GeocodeResult( + lat=56.83953, + lon=60.61244, + full_address="Россия, Свердловская область, Екатеринбург, улица Ленина, 5", + provider="nominatim", + confidence="exact", + ) + with _tiers(nominatim=substituted): + result = await geocode("Реж, Ленина 5", db) + + assert result is not None + assert result.confidence == "locality" + assert result.city_ambiguous is True + assert result.lat == pytest.approx(56.83953) # координаты НЕ выброшены + + +async def test_geocode_poisoned_cache_hit_downgraded() -> None: + """Отравленная запись кэша (их 1251 на проде) продолжает отдаваться, но уже + НЕ как точная — без единого DELETE в `geocode_cache`.""" + db = MagicMock() + poisoned = GeocodeResult( + lat=56.83744, + lon=60.59764, + full_address="Россия, Свердловская область, Екатеринбург", + provider="cache", + confidence="exact", + ) + with _tiers(cached=poisoned): + result = await geocode("Арамиль, Красноармейская ул, 118дк1", db) + + assert result is not None + assert result.confidence == "locality" + assert result.city_ambiguous is True + + +async def test_geocode_ekb_address_unaffected() -> None: + """Контроль: реальный ЕКБ-адрес через локальный тир (full_address без города) + остаётся `exact` — инвариант не трогает основной трафик.""" + db = MagicMock() + hit = GeocodeSuggestion( + label="Малышева, 30", full_address="Малышева, 30", lat=56.8389, lon=60.6057, kind="house" + ) + with _tiers(geoportal=hit): + result = await geocode("Екатеринбург, Малышева 30", db) + + assert result is not None + assert result.confidence == "exact" + assert result.city_ambiguous is False + + +async def test_geocode_settlement_inside_ekb_kept_exact() -> None: + """Посёлок в городской черте (Кедровка) с ЧЕСТНЫМ ответом провайдера не + отбраковывается — граница, о которой предупреждает #2777.""" + db = MagicMock() + honest = GeocodeResult( + lat=56.9902, + lon=60.7166, + full_address="5, Советская улица, Кедровка, Орджоникидзевский район, Екатеринбург", + provider="nominatim", + confidence="exact", + ) + with _tiers(nominatim=honest): + result = await geocode("пос. Кедровка, Советская ул., 5", db) + + assert result is not None + assert result.confidence == "exact" diff --git a/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py b/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py new file mode 100644 index 00000000..d28941c2 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocoder_local_houses_fallback.py @@ -0,0 +1,510 @@ +"""Unit tests for the `houses` fallback tier of `geocode()` (#2626). + +Covers: +- `_norm_local_house`: normalization of corpus/slash house-number forms + («49 к 1» / «49-к1» / «49 корпус 1» → «49к1»; «88 / 2» → «88/2»). +- `_extract_local_house_token`: pulling the house-number token out of a raw + user address, WITH the corpus/slash suffix that `_parse_street_house`'s + `_HOUSE_NUM` drops. +- `_clean_local_house_street` / `_row_local_house`: extracting a comparable + (street, house) pair out of the free-text `houses.address` column (multiple + scraper source formats — avito/cian/derived/yandex_valuation). +- `_street_tail_matches`: «Онуфриева» finds «Начдива Онуфриева» (ГАР canonical + name), regardless of leading district/city noise. +- `_local_houses_match`: full tier with a mocked DB session — + exact number match, corpus-1 fallback guess («49» → «49к1»), and the + defensive "ambiguous → None" invariant (no guessing on >1 distinct match). +- `geocode()` wiring: local-houses tier is the LAST step, only reached when + cache/geoportal/cadastral/Nominatim all miss, and marks + `GeocodeResult.address_refined=True`. + +Real prod addresses (#2626, lat IS NULL in trade_in_estimates) are used as +regression fixtures: «ул Онуфриева, д 24» → «Начдива Онуфриева, 24к1», +«ул. Хрустальногорская, д. 88/2» → exact match, «ул Крестинского, д 49» → +genuinely ambiguous in prod data (two DIFFERENT buildings both stored as +«Крестинского, 49к1» — must NOT resolve, per the defensive "no guessing" rule). +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_wp_mock = MagicMock() +sys.modules.setdefault("weasyprint", _wp_mock) + +from app.services.geocoder import ( # noqa: E402 + GeocodeSuggestion, + _clean_local_house_street, + _extract_local_house_token, + _local_houses_match, + _norm_local_house, + _row_local_house, + _street_tail_matches, + geocode, +) + +# ── _norm_local_house ──────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("49 к 1", "49к1"), + ("49-к1", "49к1"), + ("49 корпус 1", "49к1"), + ("49 корп. 1", "49к1"), + ("88 / 2", "88/2"), + ("88/2", "88/2"), + ("35А", "35а"), + ("13Б", "13б"), + ("13-б", "13б"), + ("44", "44"), + ], +) +def test_norm_local_house(raw: str, expected: str) -> None: + assert _norm_local_house(raw) == expected + + +# ── _extract_local_house_token ─────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("address", "expected"), + [ + ("ул Крестинского, д 49", "49"), + ("ул. Хрустальногорская, д. 88/2", "88/2"), + ("ул Онуфриева, д 24", "24"), + ("Крестинского 49к1", "49к1"), + ("8 Марта 204", "204"), # digit-leading street name doesn't confuse it + ("Малышева 30", "30"), + # #2626 review R2 #1 — прод-баг: квартира подменяла дом («д 15, кв 11» + # → дом «11», чужое здание). Реальные строки из trade_in_estimates: + ( + "620078, Свердловская обл, г Екатеринбург, Кировский р-н, " + "ул Педагогическая, д 15, кв 11", + "15", + ), + ( + "620078, Свердловская обл, г Екатеринбург, Кировский р-н, " + "ул Педагогическая, д 15, кв 48", + "15", + ), + # корпус ПЕРЕД квартирой — «26 к 1» обязан остаться частью номера дома, + # «кв 41» — уйти: + ( + "620149, Свердловская обл, г Екатеринбург, Ленинский р-н, " + "ул Начдива Онуфриева, д 26 к 1, кв 41", + "26к1", + ), + # подъезд/этаж — тот же класс бага, что и квартира (последнее число в + # строке — не дом): + ( + "Россия, Свердловская область, Екатеринбург, Трамвайный переулок, " + "2к2, подъезд 1, этаж 25, кв. 205", + "2к2", + ), + ], +) +def test_extract_local_house_token(address: str, expected: str) -> None: + assert _extract_local_house_token(address) == expected + + +def test_extract_local_house_token_none_for_garbage() -> None: + assert _extract_local_house_token("") is None + assert _extract_local_house_token("Екатеринбург") is None + + +# ── _clean_local_house_street / _street_tail_matches ──────────────────────── + + +def test_clean_local_house_street_strips_type_regardless_of_position() -> None: + """Тип улицы ДО имени («улица X») и ПОСЛЕ («X ул.») — оба зачищаются.""" + assert _clean_local_house_street("улица Начдива Онуфриева") == "начдива онуфриева" + assert _clean_local_house_street("Хрустальногорская ул.") == "хрустальногорская" + + +def test_street_tail_matches_onufrieva_finds_nachdiva_onufrieva() -> None: + """Ядро #2626: «Онуфриева» (как пишет пользователь) находит «Начдива + Онуфриева» (каноничное имя ГАР, как в houses.address).""" + assert _street_tail_matches("начдива онуфриева", "онуфриева") is True + + +def test_street_tail_matches_exact_equality() -> None: + assert _street_tail_matches("хрустальногорская", "хрустальногорская") is True + + +def test_street_tail_matches_rejects_non_suffix_substring() -> None: + """«Онуфриева» НЕ находит несвязанную улицу, где она — не хвостовое слово.""" + assert _street_tail_matches("онуфриева южная", "онуфриева") is False + + +# ── _row_local_house: разбор houses.address разных форматов источников ────── + + +@pytest.mark.parametrize( + ("row_address", "expected"), + [ + ( + "р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1", + ("р-н чкаловский мкр. ботанический крестинского", "49к1"), + ), + ("Хрустальногорская ул.,88/2", ("хрустальногорская", "88/2")), + ("ул. Начдива Онуфриева,24к2", ("начдива онуфриева", "24к2")), + ( + "р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + ("р-н ленинский мкр. юго-западный начдива онуфриева", "24к1"), + ), + ("Крестинского, 44", ("крестинского", "44")), + # house-then-district order («·» separator, no comma before house) — + # match-from-start of the LAST comma-segment still finds the leading token. + ("улица Хрустальногорская, 35к1 · р-н Академический", ("хрустальногорская", "35к1")), + ], +) +def test_row_local_house(row_address: str, expected: tuple[str, str]) -> None: + assert _row_local_house(row_address) == expected + + +def test_row_local_house_none_without_house_segment() -> None: + """Нет запятой (номер дома не отделён сегментом) → None, не гадаем.""" + assert _row_local_house("Крестинского") is None + assert _row_local_house("") is None + + +# ── _local_houses_match: full tier, mocked db ──────────────────────────────── + + +def _make_row(address: str, lat: float, lon: float) -> MagicMock: + row = MagicMock() + row.address = address + row.lat = lat + row.lon = lon + return row + + +def _db_with_rows(rows: list[MagicMock]) -> MagicMock: + db = MagicMock() + db.execute.return_value.fetchall.return_value = rows + return db + + +def test_local_houses_match_exact_house_number() -> None: + """«88/2» точно совпадает с единственной строкой houses — возвращает её координаты.""" + db = _db_with_rows( + [ + _make_row("Хрустальногорская ул.,88", 56.79412, 60.498687), + _make_row("Хрустальногорская ул.,88/2", 56.793218, 60.497106), + ] + ) + + hit = _local_houses_match(db, "хрустальногорская", "88/2") + + assert hit is not None + assert isinstance(hit, GeocodeSuggestion) + assert hit.lat == pytest.approx(56.793218) + assert hit.lon == pytest.approx(60.497106) + assert hit.kind == "house" + + +def test_local_houses_match_street_tail_and_corpus1_guess() -> None: + """«Онуфриева, 24» (без «Начдива», без корпуса), реестр — ЕДИНСТВЕННЫЙ + корпус «24к1» → уверенная догадка (нет sibling-корпусов — не угадайка).""" + db = _db_with_rows( + [ + _make_row( + "р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + 56.802928, + 60.551696, + ), + ] + ) + + hit = _local_houses_match(db, "онуфриева", "24") + + assert hit is not None + assert hit.lat == pytest.approx(56.802928) + assert hit.lon == pytest.approx(60.551696) + + +def test_local_houses_match_corpus1_guess_skipped_when_sibling_corpus_exists() -> None: + """#2626 review R2 #3, прод-данные: «Начдива Онуфриева, 24» реально ТРИ + разных здания (24к1/24к2/24к3, 250-400м друг от друга). Догадка «→24к1» + не угадывает конкретное здание среди known-siblings — честный None, не + «уверенный» результат с confidence='exact' на случайно выбранном доме.""" + db = _db_with_rows( + [ + _make_row( + "р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + 56.802928, + 60.551696, + ), + _make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391), + _make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283), + ] + ) + + assert _local_houses_match(db, "онуфриева", "24") is None + + +def test_local_houses_match_corpus1_guess_skipped_when_slash_sibling_exists() -> None: + """Sibling-guard ловит не только «кN», но и «/N» вариант того же номера.""" + db = _db_with_rows( + [ + _make_row("улица X, 24к1", 56.80, 60.60), + _make_row("улица X, 24/2", 56.81, 60.61), + ] + ) + + assert _local_houses_match(db, "x", "24") is None + + +def test_local_houses_match_no_corpus1_candidate_returns_none() -> None: + """Только «24к2»/«24к3» в реестре (нет «24к1») → фолбэк НЕ гадает, None.""" + db = _db_with_rows( + [ + _make_row("ул. Начдива Онуфриева,24к2", 56.802701, 60.554391), + _make_row("Екатеринбург, улица Начдива Онуфриева, 24к3", 56.802041, 60.548283), + ] + ) + + assert _local_houses_match(db, "онуфриева", "24") is None + + +def test_local_houses_match_ambiguous_exact_number_returns_none() -> None: + """Прод-кейс: «Крестинского, 49к1» встречается ДВАЖДЫ с РАЗНЫМИ координатами + (две разные строки houses) — неоднозначность, фолбэк не угадывает, None.""" + db = _db_with_rows( + [ + _make_row( + "р-н Чкаловский, мкр. Ботанический, улица Крестинского, 49к1", + 56.789895, + 60.632464, + ), + _make_row("Екатеринбург, улица Крестинского, 49к1", 56.7952695, 60.610079), + ] + ) + + assert _local_houses_match(db, "крестинского", "49к1") is None + + +def test_local_houses_match_ambiguous_corpus1_guess_returns_none() -> None: + """«49» → «49к1»-кандидатов больше одного (разные координаты) → None.""" + db = _db_with_rows( + [ + _make_row("улица X, 49к1", 56.80, 60.60), + _make_row("улица X, 49к1", 56.81, 60.61), + ] + ) + + assert _local_houses_match(db, "x", "49") is None + + +def test_local_houses_match_deduplicates_same_building_different_sources() -> None: + """Один и тот же дом, две source-строки (avito+cian) с ПОЧТИ идентичными + координатами — НЕ считается неоднозначностью (дедуп по округлённым coords).""" + db = _db_with_rows( + [ + _make_row("улица X, 49к1", 56.800001, 60.600001), + _make_row("улица X, 49к1", 56.800002, 60.600002), # тот же дом, другой source + ] + ) + + hit = _local_houses_match(db, "x", "49к1") + + assert hit is not None + assert hit.lat == pytest.approx(56.800001) + + +def test_local_houses_match_no_guess_for_non_digit_house() -> None: + """Запрос уже с литерой/корпусом («35к3»), точного совпадения нет — корпус-1 + ДОГАДКА не пробуется (не «35к3к1»), результат None.""" + db = _db_with_rows([_make_row("улица X, 35к4", 56.80, 60.60)]) + + assert _local_houses_match(db, "x", "35к3") is None + + +def test_local_houses_match_returns_none_on_db_error() -> None: + db = MagicMock() + db.execute.side_effect = RuntimeError("connection lost") + + assert _local_houses_match(db, "онуфриева", "24") is None + + +# ── bbox guard: `houses` is NOT EKB-only (#2626 review R2 #2) ─────────────── + + +def test_local_houses_match_rejects_row_outside_ekb_bbox() -> None: + """Прод-кейс: «улица Маяковского, 7» в `houses` — это Серов (56.6/60.66 — + ~310км от ЕКБ), не Екатеринбург. `use_local_ekb` в `geocode()` гейтит только + ЗАПРОС пользователя, не координаты строки-источника — bbox-фильтр внутри + `_local_houses_match` обязан отбросить такую строку, а не вернуть её как + confidence='exact' совпадение чужого города.""" + db = _db_with_rows( + [_make_row("улица Маяковского, 7", 59.652903, 60.659674)], # Серов, не ЕКБ + ) + + assert _local_houses_match(db, "маяковского", "7") is None + + +def test_local_houses_match_accepts_row_inside_ekb_bbox_wide() -> None: + """Контроль: легитимная ЕКБ-строка (в т.ч. приграничье, в WIDE, не в TIGHT) + по-прежнему проходит — bbox-фильтр не режет реальные ЕКБ-дома.""" + db = _db_with_rows( + [_make_row("Екатеринбург, улица Маяковского, 8", 56.862701, 60.620274)], + ) + + hit = _local_houses_match(db, "маяковского", "8") + + assert hit is not None + assert hit.lat == pytest.approx(56.862701) + + +# ── deterministic ORDER BY (#2626 review R2 #5) ────────────────────────────── + + +def test_local_houses_match_query_has_deterministic_order_by() -> None: + """Без ORDER BY дедуп по округлённым координатам оставлял бы ПЕРВУЮ строку + в порядке сканирования — недетерминированно между вызовами. SQL обязан + сортировать явно.""" + db = _db_with_rows([]) + + _local_houses_match(db, "x", "1") + + sql_text = str(db.execute.call_args[0][0]) + assert "ORDER BY" in sql_text.upper() + + +# ── geocode() wiring — last-resort tier, sets address_refined ─────────────── + + +async def test_geocode_falls_back_to_local_houses_after_nominatim_miss() -> None: + """Cache/geoportal/cadastral/Nominatim все промахнулись → local-houses тир + вызывается ПОСЛЕДНИМ и помечает результат `address_refined=True`.""" + db = MagicMock() + hit = GeocodeSuggestion( + label="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + full_address="р-н Ленинский, мкр. Юго-Западный, улица Начдива Онуфриева, 24к1", + lat=56.802928, + lon=60.551696, + kind="house", + ) + + 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), + patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), + patch("app.services.geocoder._cache_put") as mock_cache_put, + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "app.services.geocoder._local_houses_match", + return_value=hit, + ) as mock_local, + ): + result = await geocode("ул Онуфриева, д 24", db) + + assert result is not None + assert result.lat == pytest.approx(56.802928) + assert result.confidence == "exact" + assert result.address_refined is True + mock_local.assert_called_once() + # #2626 review R2 #4 — houses-фолбэк дешёвый и менее надёжный источник + # координат, чем geoportal/cadastral/Nominatim — свой результат не кэширует. + mock_cache_put.assert_not_called() + + +async def test_geocode_address_refined_false_when_earlier_tier_hits() -> None: + """geoportal-хит (обычный, точный ввод) НЕ помечается `address_refined` — + флаг честно относится ТОЛЬКО к houses-фолбэку.""" + 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), + patch("app.services.geocoder._cache_put"), + patch( + "app.services.geocoder._local_houses_match", + ) as mock_local, + ): + result = await geocode("Серова 27", db) + + assert result is not None + assert result.address_refined is False + mock_local.assert_not_called() + + +async def test_geocode_returns_none_when_local_houses_also_misses() -> None: + """Все тиры включая houses-фолбэк промахнулись → honest None (не выдумываем).""" + db = MagicMock() + + 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), + 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=None, + ), + patch("app.services.geocoder._local_houses_match", return_value=None) as mock_local, + ): + result = await geocode("ул Онуфриева, д 24", db) + + assert result is None + mock_local.assert_called_once() + + +async def test_geocode_local_houses_apartment_number_does_not_leak_into_house() -> None: + """End-to-end regression, #2626 review R2 #1: реальный прод-адрес с хвостом + «кв 11» должен резолвиться в дом 15 (`Педагогическая ул.,15`), а НЕ в дом 11 + (`Педагогическая ул.,11` — чужое здание) — `_local_houses_match` не + замокан, проверяем полную цепочку `geocode()` → `_extract_local_house_token` + → SQL-lookup.""" + db = _db_with_rows( + [ + _make_row("Педагогическая ул.,11", 56.835387, 60.654104), + _make_row("Педагогическая ул.,15", 56.835284, 60.655829), + ] + ) + + 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), + patch("app.services.geocoder._cadastral_forward_sync", return_value=[]), + patch("app.services.geocoder._cache_put") as mock_cache_put, + patch( + "app.services.geocoder._nominatim_lookup", + new_callable=AsyncMock, + return_value=None, + ), + ): + result = await geocode( + "620078, Свердловская обл, г Екатеринбург, Кировский р-н, " + "ул Педагогическая, д 15, кв 11", + db, + ) + + assert result is not None + assert result.lat == pytest.approx(56.835284) + assert result.lon == pytest.approx(60.655829) + assert result.address_refined is True + mock_cache_put.assert_not_called() 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_health_endpoint.py b/tradein-mvp/backend/tests/test_health_endpoint.py new file mode 100644 index 00000000..be2d7fab --- /dev/null +++ b/tradein-mvp/backend/tests/test_health_endpoint.py @@ -0,0 +1,35 @@ +"""GET/HEAD /health — uptime-monitor honesty (#uptime-honest-green). + +GlitchTip PING-мониторы шлют HEAD (или GET без чтения тела). Голый +`@app.get("/health")` без явного HEAD-хендлера отдаёт 405 на HEAD — Starlette +НЕ добавляет HEAD автоматически к FastAPI `@app.get()` роуту (в отличие от +низкоуровневого `Route(methods=["GET"])`). Прод-симптом: `HEAD /health` → 405, +монитор либо красный по конструкции, либо (при PING без сверки статуса) +зелёный вне зависимости от факта. Тест фиксирует оба метода. +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import app + + +def test_health_get_ok() -> None: + client = TestClient(app) + resp = client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + + +def test_health_head_ok_no_body() -> None: + """HEAD /health — то, что реально шлёт uptime-monitor. Должен быть 200, без тела.""" + client = TestClient(app) + resp = client.head("/health") + assert resp.status_code == 200 + assert resp.content == b"" + # RFC 9110 §9.3.2 — HEAD должен вернуть те же заголовки представления + # (Content-Type), что и GET; Content-Length допустимо не совпадать (payload + # header field, MAY быть опущен для HEAD). + assert resp.headers["content-type"] == "application/json" diff --git a/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py b/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py new file mode 100644 index 00000000..c32e4609 --- /dev/null +++ b/tradein-mvp/backend/tests/test_honest_run_status_failed_ratio.py @@ -0,0 +1,336 @@ +"""honest-run-status (2026-08-15): статус прогона не должен рапортовать 'done' поверх +провала или нуля. Три прод-факта закрыты этой правкой: + + (a) avito_detail_backfill 15.08: {"attempted":64,"failed":57,"enriched":6,"blocked":1} + -> status='done' — 89% отказов, статус зелёный. mark_backfill_finished звал + mark_done, потому что produced=6 (>0); ни _sweep_run_did_nothing (нет + anchors_total/errors_count у backfill'ов), ни _phase_totally_failed (ключи + "attempted"/"failed" без фазового префикса) эту форму counters не ловили. + Фикс: _failed_ratio_too_high внутри mark_done. + + (b) yandex_newbuilding_sweep 26.07-10.08: десять прогонов подряд 'done' при + processed=5, succeeded=0, rows_inserted=0, failed_resolve=4-5 — сторож нулевого + результата (_alert_if_consecutive_zero_results) слеп, т.к. _RESULT_COUNTER_KEYS + не знал ни одного ключа этого sweep'а (total_seen/lots_fetched/unique_fetched). + Фикс: _RESULT_COUNTER_KEYS дополнен 'succeeded'. Первая версия правки добавляла + голые 'rows_inserted'/'processed' — ревью нашло, что 'rows_inserted' пишет ЕЩЁ + rosreestr_dkp_import (66/67 прод-прогонов, здоровый ноль догнавшего импорта, а не + отказ) и завёл бы непрерываемый ложный zero-стрик, а 'processed' — счётчик + попыток (==limit даже при частичном провале у newbuilding_enrich) и маскирует + реальные отказы. 'succeeded' пишут только yandex_newbuilding_sweep и + newbuilding_enrich, численно совпадает с прежним 'rows_inserted' на всех + прод-прогонах sweep'а — см. test_rosreestr_dkp_import_healthy_zero_stays_unmeasured + и test_newbuilding_enrich_partial_failure_not_masked_by_processed ниже. + + (c) admin-витрина показывала new_count=0 у трёх подряд cian_full_load, хотя реально + сохранено saved_inserted=482/214/239 — full-load'ы не пишут ни 'new_count', ни + 'lots_inserted'. Фикс: _column_counts дополнен saved_inserted/rows_inserted. + +Проверяем на обоих модулях (kit-копия и app-копия — байт-эквивалентны по докстрингу +runs.py), тем же паттерном, что test_2625_run_that_did_nothing.py. +""" + +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 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 _capture_status(mod: Any, counters: dict[str, Any]) -> list[str]: + """Прогнать mark_done на фейковой сессии, вернуть статусы всех UPDATE'ов. + + Тот же helper, что в test_2625_run_that_did_nothing.py — читаем СТАТУС В SQL, а не + имя вызванной функции. + """ + statuses: list[str] = [] + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + sql = str(stmt) + for status in ("done", "failed", "banned"): + if f"status = '{status}'" in sql: + statuses.append(status) + return MagicMock() + + db = MagicMock() + db.execute.side_effect = _execute + with patch.object(mod, "sentry_sdk", MagicMock()): + mod.mark_done(db, 1, dict(counters)) + return statuses + + +def _capture_backfill_status( + counters: dict[str, Any], *, source: str = "avito_detail_backfill", aborted: bool = False +) -> list[str]: + """Прогнать app_runs.mark_backfill_finished на фейковой сессии (mark_done НЕ мокан — + в отличие от test_backfill_honest_status.py, здесь важно именно его РЕАЛЬНОЕ + поведение: mark_backfill_finished решает вызвать mark_done, а решает ли mark_done + остаться 'done' или сам себя переквалифицировать в 'failed' — предмет этого теста). + + mark_backfill_finished есть только в app_runs (kit-копия его не держит — см. + docstring модуля runs.py, "mark_skipped есть только здесь" — тот же принцип + относится к продуктовым финализаторам detail-backfill'ов). + """ + statuses: list[str] = [] + + def _execute(stmt: Any, *args: Any, **kwargs: Any) -> MagicMock: + sql = str(stmt) + for status in ("done", "failed", "banned"): + if f"status = '{status}'" in sql: + statuses.append(status) + return MagicMock() + + db = MagicMock() + db.execute.side_effect = _execute + with patch.object(app_runs, "sentry_sdk", MagicMock()): + app_runs.mark_backfill_finished( + db, 1, dict(counters), source=source, aborted_by_blocks=aborted + ) + return statuses + + +# ── (a) failed_ratio: прод-факт avito_detail_backfill 15.08 ───────────────────────── + + +def test_prod_fact_avito_15_08_no_longer_done() -> None: + """{"attempted":64,"failed":57,"enriched":6,"blocked":1} — 89% отказов — 'failed', + НЕ 'done'. Красный на старом коде (produced=6 != 0 -> mark_done -> 'done').""" + counters = {"attempted": 64, "failed": 57, "enriched": 6, "blocked": 1} + assert _capture_backfill_status(counters) == ["failed"] + + +def test_prod_fact_avito_reason_names_the_ratio() -> None: + reason = app_runs._failed_ratio_too_high( + {"attempted": 64, "failed": 57, "enriched": 6, "blocked": 1} + ) + assert reason is not None + assert "failed-ratio-honest-status" in reason + assert "57 из 64" in reason + assert "89%" in reason + + +@pytest.mark.parametrize("name", list(_MODULES)) +@pytest.mark.parametrize( + ("counters", "flagged", "why"), + [ + ({"attempted": 64, "failed": 57}, True, "прод-факт: 89% отказов"), + ({"attempted": 10, "failed": 5}, True, "ровно порог failed (0.5)"), + ({"attempted": 20, "failed": 3}, True, "ровно порог degraded (0.15)"), + ({"attempted": 20, "failed": 2}, False, "ниже порога degraded (0.10)"), + ({"attempted": 2, "failed": 2}, False, "ratio=1.0, но < _FAILED_RATIO_MIN_ATTEMPTS"), + ({"attempted": 0, "failed": 0}, False, "нет попыток вовсе"), + ({"failed": 5}, False, "нет attempted — чужой словарь"), + ({"attempted": 50}, False, "нет failed — чужой словарь"), + ({}, False, "пустые counters"), + ( + {"anchors_total": 5, "errors_count": 5, "lots_fetched": 0}, + False, + "sweep-словарь (anchors_total), не detail-backfill", + ), + ], +) +def test_failed_ratio_classifier_boundaries( + name: str, counters: dict[str, Any], flagged: bool, why: str +) -> None: + reason = _MODULES[name]._failed_ratio_too_high(counters) + assert (reason is not None) is flagged, why + + +# ── (5) не должен палить прогоны с малой/умеренной долей отказов ──────────────────── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_low_failure_ratio_stays_done(name: str) -> None: + """Штатный шум (10% отказов) не становится 'failed' — не каждый отказ диагноз.""" + counters = {"attempted": 50, "enriched": 45, "failed": 5} + assert _capture_status(_MODULES[name], counters) == ["done"] + + +def test_tiny_batch_zero_produced_fails_via_old_rule_not_ratio() -> None: + """2 попытки, обе отказали, produced=0 — доля тут не при чём (attempted < floor + _FAILED_RATIO_MIN_ATTEMPTS, _failed_ratio_too_high вернул бы None); статус всё + равно 'failed', но по СТАРОМУ правилу #2674 (produced==0), внутри + mark_backfill_finished — mark_done/_failed_ratio_too_high тут не вызываются вовсе. + Показывает, что новая проверка не дублирует и не подменяет старую.""" + counters = {"attempted": 2, "enriched": 0, "failed": 2} + assert _capture_backfill_status(counters) == ["failed"] + + +def test_tiny_batch_with_partial_success_stays_done() -> None: + """2 попытки, 1 успех, 1 отказ (ratio=0.5, но attempted < floor=3) — стрик слишком + короткий, чтобы доля что-то значила -> остаётся 'done'.""" + counters = {"attempted": 2, "enriched": 1, "failed": 1} + assert _capture_backfill_status(counters) == ["done"] + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_honest_empty_sweep_unaffected_by_failed_ratio(name: str) -> None: + """Сознательно спящее расписание (город без новостроек): sweep-словарь без + attempted/failed вовсе -> failed_ratio не о чем судить, честная пустота остаётся + 'done' (см. также test_2625_run_that_did_nothing.py::test_honest_empty_stays_done).""" + counters = {"anchors_total": 1, "errors_count": 0, "lots_fetched": 0} + assert _capture_status(_MODULES[name], counters) == ["done"] + + +# ── (b) _RESULT_COUNTER_KEYS: прод-факт yandex_newbuilding_sweep 26.07-10.08 ───────── + + +def test_prod_fact_yandex_newbuilding_sweep_measured_as_zero() -> None: + """processed=5, succeeded=0, rows_inserted=0, failed_resolve=4 — раньше + _run_result_count возвращал None ("не измерено"); теперь — измеренный 0 (через + 'succeeded', не 'rows_inserted' — см. ниже, почему ключ переигран ревью).""" + counters = { + "total": 309, + "fetchable": 200, + "pending": 50, + "processed": 5, + "skipped_already_enriched": 0, + "succeeded": 0, + "resolved_slug": 1, + "failed_resolve": 4, + "failed_fetch": 0, + "rows_inserted": 0, + "duration_sec": 42.0, + } + assert app_runs._run_result_count(counters) == 0 + assert kit_runs._run_result_count(counters) == 0 + + +def test_succeeded_is_the_measured_key_not_rows_inserted_or_processed() -> None: + """'succeeded' читается как результат; голые 'rows_inserted'/'processed' в + _RESULT_COUNTER_KEYS больше не участвуют (были в первой версии правки, снято + ревью — см. test_rosreestr_dkp_import_healthy_zero_stays_unmeasured и + test_newbuilding_enrich_partial_failure_not_masked_by_processed ниже).""" + counters = {"processed": 5, "rows_inserted": 0} + assert app_runs._run_result_count(counters) is None + assert kit_runs._run_result_count(counters) is None + + +def test_rosreestr_dkp_import_healthy_zero_stays_unmeasured() -> None: + """Прод-факт rosreestr_dkp_import (2026-08-15, 66 из 67 прогонов за 90д): инкрементальный + импорт догнал источник — rows_fetched==rows_skipped, rows_inserted=0. Это ЗДОРОВЫЙ + ответ (нечего вставлять), а не отказ; словарь не содержит 'succeeded' вовсе. + + Первая версия правки добавляла голый 'rows_inserted' в _RESULT_COUNTER_KEYS — тогда + этот прод-факт читался бы как "измеренный провал" и копил бы практически + непрерываемый zero-стрик (rosreestr_dkp_import не прерывается другим статусом: + он либо 'done' с этим же нулём, либо не бежал). Ревью поймало это до деплоя — + правильный ответ: "не измерено" (None), стрик не копится.""" + counters = { + "last_id": 6829903, + "batches_done": 49, + "rows_errored": 0, + "rows_fetched": 96974, + "rows_skipped": 96974, + "rows_updated": 0, + "rows_inserted": 0, + } + assert app_runs._run_result_count(counters) is None + assert kit_runs._run_result_count(counters) is None + + +def test_newbuilding_enrich_partial_failure_not_masked_by_processed() -> None: + """Прод-факт newbuilding_enrich (09.08): processed=25 (счётчик ПОПЫТОК, ==limit), + succeeded=14 — 44% отказов. Если бы сторож читал 'processed' как результат, партиальный + провал замаскировался бы под measured-25 (сторож нулевого результата промолчал бы + ровно там, где должен был сработать при полном провале). 'succeeded' даёт честные 14.""" + counters = { + "failed": 11, + "enriched": 14, + "attempted": 25, + "processed": 25, + "succeeded": 14, + "failed_fetch": 11, + } + assert app_runs._run_result_count(counters) == 14 + assert kit_runs._run_result_count(counters) == 14 + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_zero_result_watchdog_now_fires_for_newbuilding_sweep_streak(name: str) -> None: + """(b) integration: 3 подряд yandex_newbuilding_sweep-подобных 'done' с succeeded=0 + -> алерт срабатывает. До фикса _RESULT_COUNTER_KEYS сторож считал результат "не + измеренным" и молчал бы вечно (см. #2703 в docstring модуля).""" + mod = _MODULES[name] + row = MagicMock() + row.status = "done" + row.counters = {"processed": 5, "succeeded": 0, "rows_inserted": 0, "failed_resolve": 4} + db = MagicMock() + result = MagicMock() + result.fetchall.return_value = [row, row, row] + db.execute.return_value = result + with patch.object(mod, "sentry_sdk") as mock_sentry: + mod._alert_if_consecutive_zero_results(db, "yandex_newbuilding_sweep") + mock_sentry.capture_message.assert_called_once() + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_zero_result_watchdog_silent_on_rosreestr_dkp_import_streak(name: str) -> None: + """Негативный аналог теста выше: та же лестница из 3 подряд 'done', но словарь + rosreestr_dkp_import (нет 'succeeded') -> сторож не считает результат измеренным + и НЕ шлёт алерт — регрессионный тест на замечание ревью (HIGH #1).""" + mod = _MODULES[name] + row = MagicMock() + row.status = "done" + row.counters = { + "last_id": 6829903, + "rows_fetched": 96974, + "rows_skipped": 96974, + "rows_inserted": 0, + } + db = MagicMock() + result = MagicMock() + result.fetchall.return_value = [row, row, row] + db.execute.return_value = result + with patch.object(mod, "sentry_sdk") as mock_sentry: + mod._alert_if_consecutive_zero_results(db, "rosreestr_dkp_import") + mock_sentry.capture_message.assert_not_called() + + +# ── (c) _column_counts: прод-факт cian_full_load new_count=0 при saved_inserted>0 ─── + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_prod_fact_cian_full_load_saved_inserted_surfaces_as_new_count(name: str) -> None: + """saved_inserted=482 (прод-факт: три подряд прогона 482/214/239) — new_count + больше не 0, хотя ключей 'new_count'/'lots_inserted' в counters нет вовсе.""" + counters = {"unique_fetched": 1200, "saved_inserted": 482, "saved_updated": 30} + total_seen, new_count = _MODULES[name]._column_counts(counters) + assert total_seen == 1200 + assert new_count == 482 + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_yandex_newbuilding_rows_inserted_surfaces_as_new_count(name: str) -> None: + counters = {"rows_inserted": 7} + _, new_count = _MODULES[name]._column_counts(counters) + assert new_count == 7 + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_new_count_priority_unchanged_by_new_keys(name: str) -> None: + """'new_count' явный ключ всё ещё побеждает 'lots_inserted'/'saved_inserted' — + расширение списка не меняет приоритет уже существующих ключей.""" + counters = {"new_count": 5, "lots_inserted": 99, "saved_inserted": 1} + _, new_count = _MODULES[name]._column_counts(counters) + assert new_count == 5 + + +@pytest.mark.parametrize("name", list(_MODULES)) +def test_lots_inserted_still_beats_saved_inserted(name: str) -> None: + """Порядок пикулярно НЕ переставлен для уже существующей пары — 'lots_inserted' + (city/newbuilding-sweep'ы) проверяется раньше 'saved_inserted' (full-load'ы), + т.к. это разные, непересекающиеся семейства источников.""" + counters = {"lots_inserted": 12, "saved_inserted": 999} + _, new_count = _MODULES[name]._column_counts(counters) + assert new_count == 12 diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py index c6bacef4..4355e323 100644 --- a/tradein-mvp/backend/tests/test_house_dedup_merge.py +++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py @@ -18,6 +18,7 @@ import inspect import os import re from pathlib import Path +from types import SimpleNamespace from typing import Any import pytest @@ -106,6 +107,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) @@ -266,9 +285,18 @@ def test_fias_pass_drops_geo_guard_canon_pass_keeps_it() -> None: assert "keeper_geom IS NOT NULL" in canon assert "loser_geom IS NOT NULL" in canon # fias pass drops the distance guard AND the NULL-geom exclusions entirely. - assert "ST_DistanceSphere" not in fias - assert "loser_geom IS NOT NULL" not in fias - assert "keeper_geom IS NOT NULL" not in fias + # + # Asserted on the guard PREDICATE, not on the bare function name: since #2690 the mapping also + # MEASURES the keeper↔loser distance into `distance_m` for the merge journal, on both passes. + # Measuring is the opposite of guarding — the fias pass is precisely where nothing else records + # how far apart the merged rows were — so the name alone can no longer stand in for the guard. + assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in fias + guard = ( + "AND keeper_geom IS NOT NULL AND loser_geom IS NOT NULL " + "AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250" + ) + assert guard in canon + assert guard not in fias # the cross-fias anti-over-merge guard is untouched in the canon pass. assert "lower(loser_fias) <> lower(keeper_fias)" in canon @@ -276,12 +304,15 @@ def test_fias_pass_drops_geo_guard_canon_pass_keeps_it() -> None: def test_mapping_sql_geo_guard_param_toggles_only_distance_filter() -> None: """_mapping_sql(apply_geo_guard=...) toggles ONLY the 250 m distance filter; the cross-fias guard is emitted regardless, and the default is True (canon-safe).""" + guard = "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" with_guard = _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR, apply_geo_guard=True)) without_guard = _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR, apply_geo_guard=False)) - assert "ST_DistanceSphere" in with_guard - assert "ST_DistanceSphere" not in without_guard + assert guard in with_guard + assert guard not in without_guard # default = True (the canon pass must never lose its guard by omission). - assert "ST_DistanceSphere" in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR)) + assert guard in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR)) + # ...while the journal's distance MEASUREMENT is emitted either way (#2690). + assert "AS distance_m" in with_guard and "AS distance_m" in without_guard # cross-fias guard present in BOTH renderings (independent of the geo guard). assert "lower(loser_fias) <> lower(keeper_fias)" in with_guard assert "lower(loser_fias) <> lower(keeper_fias)" in without_guard @@ -309,7 +340,9 @@ def test_both_passes_share_one_pipeline_no_copy_paste() -> None: assert token in canon and token in fias # the 250 m distance guard is CANON-ONLY (#2187) — fias identity outranks proximity. assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" in canon - assert "ST_DistanceSphere" not in fias + assert "ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in fias + # ...but the journal's distance MEASUREMENT is on both — measuring is not guarding. + assert "AS distance_m" in canon and "AS distance_m" in fias def test_cross_fias_guard_blocks_slash_collapse_over_merge() -> None: @@ -415,28 +448,72 @@ class _FakeResult: class _Row: - def __init__(self, loser_id: int, keeper_id: int, norm_address: str): + def __init__( + self, + loser_id: int, + keeper_id: int, + norm_address: str, + cluster_key: str = "addr:тест", + distance_m: float | None = 12.0, + ): self.loser_id = loser_id self.keeper_id = keeper_id self.norm_address = norm_address + # journal grounds (#2690): which key value fired, and how far apart the rows were. + self.cluster_key = cluster_key + self.distance_m = distance_m + + +class _ChildRow: + """What a step's RETURNING yields: an id for a survivor, a snapshot for a destroyed row.""" + + def __init__(self, loser_id: int, child_id: int = 1): + self.loser_id = loser_id + self.child_id = child_id + self.row_snapshot = {"id": child_id, "house_id": loser_id} + + +class _FKChild: + def __init__(self, child_table: str, fk_column: str): + self.child_table = child_table + self.fk_column = fk_column class _FakeDB: """Session stand-in: build-mapping + a scripted SELECT result, then per-step rowcounts.""" - def __init__(self, mapping_rows: list[_Row], step_rowcount: int = 1): + def __init__( + self, + mapping_rows: list[_Row], + step_rowcount: int = 1, + fk_children: dict[str, str] | None = None, + residual: dict[str, int] | None = None, + residual_raises: bool = False, + ): self._mapping_rows = mapping_rows self._step_rowcount = step_rowcount + self._residual = residual + self._residual_raises = residual_raises self._mapping_served = False + # The catalog the FK-child guard reads; defaults to the real live set. + self._fk_children = _FK_CHILDREN if fk_children is None else fk_children self.commits = 0 self.rollbacks = 0 self.executed: list[str] = [] - def execute(self, clause: Any, params: dict | None = None) -> _FakeResult: + def execute(self, clause: Any, params: Any = None) -> _FakeResult: sql = str(getattr(clause, "text", clause)) self.executed.append(sql) if "CREATE TEMP TABLE" in sql: return _FakeResult() + if "FROM pg_constraint" in sql: + return _FakeResult(rows=[_FKChild(t, c) for t, c in self._fk_children.items()]) + if "AS residual_rows" in sql: # residual census (#2690) — read-only, after both passes + if self._residual_raises: + raise RuntimeError("census exploded") + if self._residual is None: + return _FakeResult(rows=[]) + return _FakeResult(rows=[SimpleNamespace(**self._residual)]) if "SELECT loser_id, keeper_id, norm_address" in sql: # The service now runs TWO passes (fias, then canon). Model «fias pass found the # duplicates, canon pass is clean»: serve the scripted mapping once, empty afterwards. @@ -444,7 +521,13 @@ class _FakeDB: return _FakeResult(rows=[]) self._mapping_served = True return _FakeResult(rows=list(self._mapping_rows)) - # any UPDATE/DELETE/INSERT step (incl. DROP TABLE, carry-over, delete, backfill) + # Steps now RETURN the rows they touched (journal, #2690) — one per scripted rowcount, + # attributed to the first loser so the per-loser bookkeeping has something to bucket. + if "RETURNING" in sql: + loser = self._mapping_rows[0].loser_id if self._mapping_rows else 0 + rows = [_ChildRow(loser, child_id=i + 1) for i in range(self._step_rowcount)] + return _FakeResult(rowcount=self._step_rowcount, rows=rows) + # any other UPDATE/DELETE/INSERT (DROP TABLE, journal, carry-over, delete, backfill) return _FakeResult(rowcount=self._step_rowcount) def commit(self) -> None: @@ -478,6 +561,85 @@ def test_dry_run_computes_counts_but_rolls_back() -> None: assert db.rollbacks == 1 +# ── Residual census (#2690 п.2/п.4): остаток фиксируется числом, а не оценкой ── + + +def test_residual_census_reuses_the_canon_mapping_prelude() -> None: + """Перепись остатка обязана считать РОВНО те строки, о которых рассуждает канон-проход. + + Общий `_ranked_cte` — не косметика: собственная копия кластеризации разъехалась бы с + проходом, который она описывает, и разъезд был бы невидим (это тот же класс ошибки, что и + ключ в #2690 — два выражения, похожие друг на друга и не равные). RED до фикса: переписи + нет вовсе. + """ + residual = _flat(str(hdm._RESIDUAL_SQL.text)) + prelude = _flat(hdm._ranked_cte(hdm._CANON_KEY_EXPR)) + assert prelude in residual + assert prelude in _flat(hdm._mapping_sql(hdm._CANON_KEY_EXPR)) + + +def test_residual_census_keeps_silent_guard_apart_from_rejecting_guard() -> None: + """«Координат нет» и «дальше 250 м» — противоположные факты, в одну сумму их нельзя. + + Первое означает, что страж не смог высказаться (остаток неизвестен), второе — что он + высказался по существу и дома РАЗНЫЕ (это вообще не дубли). Сумма из этих двух читается как + «долг», которого нет. Плюс: перепись НЕ применяет гео-фильтр — иначе она считала бы только + то, что и так слилось. + """ + residual = _flat(str(hdm._RESIDUAL_SQL.text)) + for bucket in ( + "AS residual_rows", + "AS residual_listings", + "AS residual_no_geom", + "AS residual_far", + "AS residual_cross_fias", + "AS residual_mergeable", + ): + assert bucket in residual, bucket + # Гео-страж 250 м здесь — РАЗДЕЛИТЕЛЬ корзин, а не фильтр строк. + assert "AND ST_DistanceSphere(loser_geom, keeper_geom) <= 250" not in residual + assert set(hdm.DedupMergeResult().to_counters()) >= { + "residual_rows", + "residual_listings", + "residual_no_geom", + "residual_far", + "residual_cross_fias", + "residual_mergeable", + } + + +def test_residual_counters_reported_even_when_nothing_merged() -> None: + """Прогон, который не слил ничего, — ровно тот, чьему остатку нужно число.""" + db = _FakeDB( + mapping_rows=[], + residual={ + "residual_rows": 963, + "residual_listings": 1765, + "residual_no_geom": 326, + "residual_far": 568, + "residual_cross_fias": 8, + "residual_mergeable": 61, + }, + ) + out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type] + assert out["losers_deleted"] == 0 + assert out["residual_rows"] == 963 + assert out["residual_listings"] == 1765 + assert out["residual_no_geom"] == 326 + assert out["residual_far"] == 568 + assert out["residual_cross_fias"] == 8 + assert out["residual_mergeable"] == 61 + + +def test_residual_census_failure_never_breaks_the_merge() -> None: + """Перепись — приборы, слияние — продукт: упавший счётчик не отменяет коммит.""" + db = _FakeDB(mapping_rows=[_Row(2, 1, "ул. мира, 10")], residual_raises=True) + out = hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type] + assert out["losers_deleted"] == 1 + assert out["residual_rows"] == 0 # неизвестно — и это видно, а не выдумано + assert db.commits == 1 + + def test_real_merge_commits() -> None: """dry_run=False with dups → exactly one commit, no rollback.""" rows = [_Row(2, 1, "ул. мира, 10")] @@ -506,7 +668,11 @@ def test_run_wrapper_marks_done_with_counters(monkeypatch: pytest.MonkeyPatch) - monkeypatch.setattr( hdm, "merge_duplicate_houses", - lambda _db, dry_run=False: {"clusters_merged": 3, "losers_deleted": 5, "dry_run": 0}, + lambda _db, dry_run=False, run_id=None, initiator="manual": { + "clusters_merged": 3, + "losers_deleted": 5, + "dry_run": 0, + }, ) out = hdm.run_house_dedup_merge(object(), run_id=42, params={"dry_run": False}) # type: ignore[arg-type] @@ -523,13 +689,20 @@ def test_run_wrapper_passes_dry_run_param(monkeypatch: pytest.MonkeyPatch) -> No monkeypatch.setattr(runs_mod, "mark_done", lambda *a, **k: None) monkeypatch.setattr(runs_mod, "mark_failed", lambda *a, **k: None) - def _fake_merge(_db: Any, dry_run: bool = False) -> dict[str, int]: + def _fake_merge( + _db: Any, dry_run: bool = False, run_id: int | None = None, initiator: str = "manual" + ) -> dict[str, int]: captured["dry_run"] = dry_run + captured["run_id"] = run_id + captured["initiator"] = initiator return {"dry_run": int(dry_run)} monkeypatch.setattr(hdm, "merge_duplicate_houses", _fake_merge) hdm.run_house_dedup_merge(object(), run_id=1, params={"dry_run": True}) # type: ignore[arg-type] assert captured["dry_run"] is True + # the journal must be able to say WHICH run did it, and that it was not a human (#2690) + assert captured["run_id"] == 1 + assert captured["initiator"] == "schedule" def test_run_wrapper_marks_failed_on_error(monkeypatch: pytest.MonkeyPatch) -> None: @@ -544,7 +717,9 @@ def test_run_wrapper_marks_failed_on_error(monkeypatch: pytest.MonkeyPatch) -> N lambda _db, run_id, err, counters: failed.update(run_id=run_id, err=err), ) - def _boom(_db: Any, dry_run: bool = False) -> dict[str, int]: + def _boom( + _db: Any, dry_run: bool = False, run_id: int | None = None, initiator: str = "manual" + ) -> dict[str, int]: raise RuntimeError("merge exploded") monkeypatch.setattr(hdm, "merge_duplicate_houses", _boom) @@ -624,12 +799,16 @@ def test_real_merge_repoints_dedups_deletes_and_is_idempotent() -> None: db = _live_session() assert db is not None try: - # Two houses at the SAME address. Keeper (geom present) should win. + # Two houses at the SAME address, ~10 m apart (the #2187 canon geo guard needs geom + # on BOTH sides). Keeper = min(id) once geom and listing counts tie. db.execute( _t( - "INSERT INTO houses (id, source, ext_house_id, address, lat, lon) VALUES " - "(900001, 'avito', 'EXT-KEEP', 'тестдом 1772, 1', 56.84, 60.60)," - "(900002, 'cian', 'EXT-LOSE', 'тестдом 1772, 1', NULL, NULL)" + # url is NOT NULL in houses (002_core_tables); nothing here asserts on it, + # so 'u' is a placeholder. These live-DB fixtures self-skip in CI, which is + # how they silently drifted out of sync with the schema in the first place. + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES " + "(900001, 'avito', 'EXT-KEEP','u', 'тестдом 1772, 1', 56.84, 60.60)," + "(900002, 'cian', 'EXT-LOSE','u', 'тестдом 1772, 1', 56.84009, 60.60)" ) ) # listings pointing at BOTH (the loser's must be re-pointed). source_url, dedup_hash, @@ -737,6 +916,9 @@ def test_real_merge_repoints_dedups_deletes_and_is_idempotent() -> None: db.execute( _t("DELETE FROM house_address_aliases WHERE normalized_address = 'тестдом 1772, 1'") ) + # journal rows have no FK and are never cascaded away — sweep them explicitly, + # or a re-run accumulates them (all live fixtures live in the 9000xx id range). + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299")) db.execute(_t("DELETE FROM houses WHERE id IN (900001,900002)")) db.commit() db.close() @@ -763,16 +945,16 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None: try: db.execute( _t( - "INSERT INTO houses (id, source, ext_house_id, address, lat, lon) VALUES " + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon) VALUES " # A — ул/улица spelling variants of the SAME building, ~10 m apart → MERGE - "(900010, 'avito', 'EXT-T-VK', 'улица Тестовая1772, 66', 56.84000, 60.60000)," - "(900011, 'cian', 'EXT-T-VL', 'ул. Тестовая1772, 66', 56.84009, 60.60000)," + "(900010, 'avito', 'EXT-T-VK','u', 'улица Тестовая1772, 66', 56.84000, 60.60000)," + "(900011, 'cian', 'EXT-T-VL','u', 'ул. Тестовая1772, 66', 56.84009, 60.60000)," # B — same canon (ленина-like) but ~5 km apart → geo guard BLOCKS the merge - "(900012, 'avito', 'EXT-T-L1', 'улица Тестовая1772, 5', 56.84000, 60.60000)," - "(900013, 'cian', 'EXT-T-L2', 'улица Тестовая1772, 5', 56.88500, 60.60000)," + "(900012, 'avito', 'EXT-T-L1','u', 'улица Тестовая1772, 5', 56.84000, 60.60000)," + "(900013, 'cian', 'EXT-T-L2','u', 'улица Тестовая1772, 5', 56.88500, 60.60000)," # C — different корпус → different canon, ~10 m apart → NOT merged - "(900014, 'avito', 'EXT-T-M2', 'Тестовая1772, 34к2', 56.84000, 60.60000)," - "(900015, 'cian', 'EXT-T-M4', 'Тестовая1772, 34к4', 56.84009, 60.60000)" + "(900014, 'avito', 'EXT-T-M2','u', 'Тестовая1772, 34к2', 56.84000, 60.60000)," + "(900015, 'cian', 'EXT-T-M4','u', 'Тестовая1772, 34к4', 56.84009, 60.60000)" ) ) db.execute( @@ -822,7 +1004,7 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None: db.execute( _t( "DELETE FROM house_sources WHERE ext_id IN " - "('EXT-T-VK','EXT-T-VL','EXT-T-L1','EXT-T-L2','EXT-T-M2','EXT-T-M4')" + "('EXT-T-VK','u','EXT-T-VL','u','EXT-T-L1','u','EXT-T-L2','u','EXT-T-M2','u','EXT-T-M4')" ) ) db.execute( @@ -832,6 +1014,9 @@ def test_real_canon_clusterkey_and_geo_guard_merge_semantics() -> None: "'тестовая1772, 34к2','тестовая1772, 34к4')" ) ) + # journal rows have no FK and are never cascaded away — sweep them explicitly, + # or a re-run accumulates them (all live fixtures live in the 9000xx id range). + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299")) db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900010 AND 900015")) db.commit() db.close() @@ -859,21 +1044,21 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None: db.execute( _t( "INSERT INTO houses " - "(id, source, ext_house_id, address, lat, lon, house_fias_id, gar_house_guid, " + "(id, source, ext_house_id, url, address, lat, lon, house_fias_id, gar_house_guid, " " dadata_enriched_at) VALUES " # A — same fias, different canon (different streets), ~10 m apart → FIAS-pass merge - "(900020,'avito','EXT-F-K','ФиасОдин1772, 10', 56.84000,60.60000," + "(900020,'avito','EXT-F-K','u','ФиасОдин1772, 10', 56.84000,60.60000," " 'F-SAME-1772',NULL,NULL)," - "(900021,'cian', 'EXT-F-L','СовсемДругая1772, 77',56.84009,60.60000," + "(900021,'cian', 'EXT-F-L','u','СовсемДругая1772, 77',56.84009,60.60000," " 'F-SAME-1772',NULL,NULL)," # B — same canon (slash-collapse), DIFFERENT fias → cross-fias guard BLOCKS - "(900022,'avito','EXT-B-1','Клара1772, 32',56.84000,60.60000," + "(900022,'avito','EXT-B-1','u','Клара1772, 32',56.84000,60.60000," " 'F-B1-1772',NULL,NULL)," - "(900023,'cian', 'EXT-B-2','Клара1772, 3/2',56.84009,60.60000," + "(900023,'cian', 'EXT-B-2','u','Клара1772, 3/2',56.84009,60.60000," " 'F-B2-1772',NULL,NULL)," # C — same canon, fias only on the loser → canon-pass merge + carry-over - "(900024,'avito','EXT-C-K','Донбасс1772, 8',56.84000,60.60000,NULL,NULL,NULL)," - "(900025,'cian', 'EXT-C-L','Донбасс1772, 8',56.84009,60.60000," + "(900024,'avito','EXT-C-K','u','Донбасс1772, 8',56.84000,60.60000,NULL,NULL,NULL)," + "(900025,'cian', 'EXT-C-L','u','Донбасс1772, 8',56.84009,60.60000," " 'F-CARRY-1772','G-CARRY-1772',NOW())" ) ) @@ -926,7 +1111,7 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None: db.execute( _t( "DELETE FROM house_sources WHERE ext_id IN " - "('EXT-F-K','EXT-F-L','EXT-B-1','EXT-B-2','EXT-C-K','EXT-C-L')" + "('EXT-F-K','u','EXT-F-L','u','EXT-B-1','u','EXT-B-2','u','EXT-C-K','u','EXT-C-L')" ) ) db.execute( @@ -936,6 +1121,9 @@ def test_real_fias_pass_cross_guard_and_identity_carryover() -> None: "'донбасс1772, 8')" ) ) + # journal rows have no FK and are never cascaded away — sweep them explicitly, + # or a re-run accumulates them (all live fixtures live in the 9000xx id range). + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299")) db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900020 AND 900025")) db.commit() db.close() @@ -964,16 +1152,16 @@ def test_real_fias_pass_ignores_geo_guard() -> None: db.execute( _t( "INSERT INTO houses " - "(id, source, ext_house_id, address, lat, lon, house_fias_id) VALUES " + "(id, source, ext_house_id, url, address, lat, lon, house_fias_id) VALUES " # A — same fias, loser NULL geom → fias pass merges despite the missing coordinate - "(900030,'avito','EXT-2187-A-K','ФиасГеоA2187, 1', 56.84000,60.60000,'F-A-2187')," - "(900031,'cian', 'EXT-2187-A-L','ФиасГеоAL2187, 2',NULL, NULL, 'F-A-2187')," + "(900030,'avito','EXT-2187-A-K','u','ФиасГеоA2187, 1',56.84,60.6,'F-A-2187')," + "(900031,'cian', 'EXT-2187-A-L','u','ФиасГеоAL2187, 2',NULL,NULL,'F-A-2187')," # B — same fias, ~5 km apart (>250 m) → fias pass merges despite the distance - "(900032,'avito','EXT-2187-B-K','ФиасГеоB2187, 3', 56.84000,60.60000,'F-B-2187')," - "(900033,'cian', 'EXT-2187-B-L','ФиасГеоBL2187, 4',56.88500,60.60000,'F-B-2187')," + "(900032,'avito','EXT-2187-B-K','u','ФиасГеоB2187, 3',56.84,60.6,'F-B-2187')," + "(900033,'cian', 'EXT-2187-B-L','u','ФиасГеоBL2187, 4',56.885,60.6,'F-B-2187')," # C — same canon, NO fias, ~5 km apart → canon pass STILL blocks (guard unchanged) - "(900034,'avito','EXT-2187-C-1','КанонГео2187, 5', 56.84000,60.60000,NULL)," - "(900035,'cian', 'EXT-2187-C-2','КанонГео2187, 5', 56.88500,60.60000,NULL)" + "(900034,'avito','EXT-2187-C-1','u','КанонГео2187, 5', 56.84000,60.60000,NULL)," + "(900035,'cian', 'EXT-2187-C-2','u','КанонГео2187, 5', 56.88500,60.60000,NULL)" ) ) # A loser gets a listing so we prove the re-point still fires with a NULL-geom loser. @@ -1010,6 +1198,199 @@ def test_real_fias_pass_ignores_geo_guard() -> None: db.execute(_t("DELETE FROM listings WHERE id = 910031")) db.execute(_t("DELETE FROM house_sources WHERE house_id BETWEEN 900030 AND 900035")) db.execute(_t("DELETE FROM house_address_aliases WHERE house_id BETWEEN 900030 AND 900035")) + # journal rows have no FK and are never cascaded away — sweep them explicitly, + # or a re-run accumulates them (all live fixtures live in the 9000xx id range). + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id BETWEEN 900000 AND 900299")) db.execute(_t("DELETE FROM houses WHERE id BETWEEN 900030 AND 900035")) db.commit() db.close() + + +# ── Merge journal: reversibility (#2690) ────────────────────────────────────── + + +def test_undo_carryover_fields_match_merge_carryover() -> None: + """Static drift guard: migration 230's undo must un-set EXACTLY the fields the merge carries. + + The undo NULLs the keeper's identity fields that the merge COALESCE-filled from a loser. + If _CARRY_OVER_IDENTITY_SQL ever gains a field and the migration's array does not, the undo + silently leaves that field on the keeper — the restored loser and the keeper would then both + claim the same ФИАС, and the next fias pass would merge them straight back. + """ + migration = (_SQL_DIR / "230_house_merge_log.sql").read_text(encoding="utf-8") + carried = set(re.findall(r"^\s+(\w+)\s*=\s*COALESCE\(k\.", _CARRY_SQL, re.M)) + # slice the ARRAY[...] literal itself — the declaration's own `text[]` also holds a «]» + block = migration[migration.index("c_carry_fields") :] + undone = set(re.findall(r"'(\w+)'", block[block.index("ARRAY[") : block.index("];")])) + assert carried, "could not parse carried fields out of _CARRY_OVER_IDENTITY_SQL" + assert carried == undone, f"carry-over/undo field drift: merge={carried} undo={undone}" + + +def test_journal_written_in_the_same_transaction_as_the_merge() -> None: + """The journal INSERT must sit between mapping and delete, with no commit in between. + + Requirement from #2690: a merge that commits without its journal row is exactly the failure + the journal exists to prevent, so the two must share one transaction. + """ + src = inspect.getsource(hdm._run_merge_pass) + assert "_JOURNAL_INSERT_SQL" in src + assert "db.commit()" not in src, "the pass must not commit — the caller owns the txn" + # phase 1 (snapshots) strictly before the steps mutate anything, delete strictly after. + assert src.index("_JOURNAL_INSERT_SQL") < src.index("for label, sql in _STEPS") + assert src.index("for label, sql in _STEPS") < src.index("_DELETE_LOSERS_SQL") + + +def test_every_step_returns_what_it_touched() -> None: + """Each step must RETURN its rows: ids for survivors, full snapshots for destroyed rows.""" + for label, sql in hdm._STEPS: + assert "RETURNING" in sql, f"{label}: no RETURNING — its rows would go unjournaled" + if label.endswith("(collision-delete)") or label.endswith("(dedup)"): + assert "to_jsonb(" in sql, f"{label}: destroys rows, must snapshot them, not ids" + else: + assert "AS child_id" in sql, f"{label}: re-points rows, must return their ids" + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_merge_is_reversible_via_journal() -> None: + """End-to-end on a real DB: merge → journal is sufficient → undo restores the ORIGINAL state. + + The comparison is over `to_jsonb(row.*)` for every row that existed before the merge — all + columns, not a chosen pair — for houses and for every FK child touched. + """ + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + ids = "(900201, 900202)" + try: + # Keeper 900201 and loser 900202: same canon address, ~12 m apart (inside the 250 m + # guard), keeper has the listings so the keeper rule picks it. + db.execute( + _t( + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon, geom, " + "year_built, house_fias_id, gar_flat_count, raw_payload) VALUES " + "(900201,'avito','K-2690','http://t/2690/k','улица Журнальная, 7', " + " 56.8400, 60.6000, ST_SetSRID(ST_MakePoint(60.6000,56.8400),4326), " + " 1979, NULL, NULL, '{\"k\":[1,2]}'), " + "(900202,'cian','L-2690','http://t/2690/l','ул. Журнальная,7', " + " 56.8401, 60.6000, ST_SetSRID(ST_MakePoint(60.6000,56.8401),4326), " + # NB: no «:word» inside the literal — SQLAlchemy text() would read it as a bind. + " NULL, 'fias-2690-uuid', 144, '{\"l\":{\"deep\":[3,4]}}')" + ) + ) + db.execute( + _t( + "INSERT INTO listings (id, source, source_url, source_id, dedup_hash, price_rub, " + "house_id_fk) VALUES " + "(910201,'avito','http://t/2690/1','L1','dh-2690-1',5000000,900201)," + "(910202,'avito','http://t/2690/2','L2','dh-2690-2',5100000,900201)," + "(910203,'cian','http://t/2690/3','L3','dh-2690-3',6000000,900202)" + ) + ) + db.execute( + _t( + "INSERT INTO house_sources (house_id, ext_source, ext_id, confidence, " + "matched_method) VALUES (900201,'avito','S-2690-K',1.0,'t')," + "(900202,'cian','S-2690-L',1.0,'t')" + ) + ) + # Colliding child: identical 6-col UNIQUE key on both → the loser's row is DESTROYED by + # the dedup step. Only a full snapshot can bring it back. + db.execute( + _t( + "INSERT INTO houses_price_dynamics (house_id, month_date, source, room_count, " + "prices_type, period, price_per_sqm) VALUES " + "(900201, DATE '2026-02-01','cian','all','priceSqm','allTime',100000)," + "(900202, DATE '2026-02-01','cian','all','priceSqm','allTime',999999)" + ) + ) + db.commit() + + def snapshot() -> dict[tuple[str, int], Any]: + """to_jsonb of every seeded row, keyed by (table, id) — the full-fidelity state.""" + out: dict[tuple[str, int], Any] = {} + for tbl, col in ( + ("houses", "id"), + ("listings", "house_id_fk"), + ("house_sources", "house_id"), + ("houses_price_dynamics", "house_id"), + ): + where = f"id IN {ids}" if tbl == "houses" else f"{col} IN {ids}" + for r in db.execute( + _t(f"SELECT id, to_jsonb(t.*) AS j FROM {tbl} t WHERE {where}") + ): + out[(tbl, r.id)] = r.j + return out + + before = snapshot() + assert len(before) == 9, f"fixture should seed 9 rows, got {sorted(before)}" + + # ── merge ── + out = hdm.merge_duplicate_houses(db, dry_run=False, initiator="test") + assert out["losers_deleted"] == 1 + assert db.execute(_t(f"SELECT count(*) FROM houses WHERE id IN {ids}")).scalar() == 1 + + # ── the journal alone must be able to NAME what went into what ── + row = db.execute( + _t("SELECT * FROM house_merge_log WHERE loser_id = 900202 ORDER BY id DESC LIMIT 1") + ).one() + assert (row.loser_id, row.keeper_id) == (900202, 900201) + assert row.merge_pass == "canon" and row.geo_guard is True + assert row.cluster_key.startswith("addr:") + assert 0 < row.distance_m < 250, "distance to the keeper must be recorded, in metres" + assert row.initiator == "test" + # full snapshot of the deleted row, not a reference to it + assert row.loser_row == before[("houses", 900202)] + # keeper as it stood BEFORE the identity carry-over (fias still empty there, filled now) + assert row.keeper_before["house_fias_id"] is None + assert ( + db.execute(_t("SELECT house_fias_id FROM houses WHERE id = 900201")).scalar() + == "fias-2690-uuid" + ), "carry-over should have moved the loser's fias up" + # children: the loser's listing moved by id, the destroyed price row by content + assert row.children_repointed["listings.house_id_fk"] == [910203] + assert [r["price_per_sqm"] for r in row.children_deleted["houses_price_dynamics"]] == [ + 999999 + ] + + # ── undo ── + report = db.execute( + _t("SELECT * FROM house_merge_undo(CAST(:b AS uuid))"), {"b": str(row.batch_id)} + ).all() + assert len(report) == 1 and report[0].out_status.startswith("restored:"), report + db.commit() + + after = snapshot() + # every row that existed before is back, byte-identical, on every column + assert {k: v for k, v in after.items() if k in before} == before + # the ONLY residue is the house_sources row the merge backfilled for the keeper. + # migration 230 documents this: it is built from the keeper's OWN ext_house_id, so + # it would have been true without the merge too. Asserted, not assumed. + residue = [v for k, v in after.items() if k not in before] + assert all(v["matched_method"] == "backfill_dedup_merge" for v in residue), residue + finally: + db.rollback() + db.execute(_t(f"DELETE FROM listings WHERE house_id_fk IN {ids}")) + db.execute(_t("DELETE FROM listings WHERE id IN (910201,910202,910203)")) + db.execute(_t("DELETE FROM house_merge_log WHERE loser_id = 900202")) + db.execute(_t("DELETE FROM house_address_aliases WHERE house_id IN (900201,900202)")) + db.execute(_t(f"DELETE FROM houses WHERE id IN {ids}")) + db.commit() + db.close() + + +def test_merge_refuses_when_an_fk_child_is_unhandled() -> None: + """A new FK child on houses(id) must ABORT the merge, not be CASCADE-deleted unjournaled. + + 9 of the 11 FKs are ON DELETE CASCADE. A child the _STEPS pipeline does not know about is + therefore destroyed by `DELETE FROM houses` — no re-point step touches it, no RETURNING + records it, and the journal would claim a complete snapshot it does not have. Migration 133 + already broke on prod over a missed child; there it failed loudly, here it would be silent. + """ + db = _FakeDB( + mapping_rows=[_Row(2, 1, "ул. ленина, 5")], + fk_children={**_FK_CHILDREN, "house_brand_new_child": "house_id"}, + ) + with pytest.raises(RuntimeError, match="house_brand_new_child"): + hdm.merge_duplicate_houses(db, dry_run=False) # type: ignore[arg-type] + assert db.commits == 0, "an unhandled child must abort before anything is committed" 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..98f057aa --- /dev/null +++ b/tradein-mvp/backend/tests/test_house_imv_params_honesty.py @@ -0,0 +1,224 @@ +"""#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, + ): + # #2674: backfill делает ДВЕ выборки — сначала retry-очередь transient_error, + # затем основную по only_status. Фейк отвечает одним и тем же списком на любой + # SELECT, поэтому очередь повтора отдаём пустой явно — иначе один и тот же дом + # придёт в пакет дважды (артефакт фейка, в БД статусы не пересекаются). + db.execute.return_value.mappings.return_value.all.side_effect = [[], 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_house_imv_retry_stuck.py b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py new file mode 100644 index 00000000..b414aefc --- /dev/null +++ b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py @@ -0,0 +1,267 @@ +"""#2674: «временная» ошибка домовой IMV-оценки снова означает временную. + +Что было. Очередь backfill'а выбирает РОВНО ОДИН imv_status за прогон +(`only_status`, дефолт 'pending'). Возможность повтора существовала и была +задокументирована в admin-API, но за всю историю (41 прогон, 26.06–11.08.2026) +её не передал никто: все прогоны ушли с 'pending'. Дома, упавшие по причине, +которой больше нет (503/500 от браузерного сайдкара, #2698), лежали в +'transient_error' без единого шанса вернуться — 1390 штук на 12.08.2026. + +Что проверяем — по существу, а не по настройке: + * дом в 'transient_error' ПОПАДАЕТ в пакет прогона с дефолтными параметрами + (тот же вызов, что делает расписание) — на старом коде он не попадает; + * pending при этом из пакета НЕ вытесняется; + * дом, исчерпавший лимит попыток, в автоматический повтор не берётся, а + ЯВНЫЙ only_status='transient_error' (ручной запрос оператора) берёт и его; + * дом без пригодных параметров помечается no_params БЕЗ запроса к площадке. + +Живой Postgres обязателен: проверяется поведение SQL-выборки, а не то, какие +строки вернул мок. Без БД тест самоскипается (в CI БД есть — ci-tradein.yml +поднимает Postgres и накатывает схему). +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import house_imv_backfill as hib + +# Фикстуры живут в собственном диапазоне id (ср. tests/test_house_dedup_merge.py). +_H_STUCK = 900601 # transient_error, попыток 0 → обязан вернуться в очередь +_H_EXHAUSTED = 900602 # transient_error, попытки исчерпаны → в автоповтор не берём +_H_PENDING = 900603 # pending → не должен быть вытеснен retry-очередью +_H_NO_PARAMS = 900604 # pending без объявлений с rooms+area → no_params без запроса +_IDS = (_H_STUCK, _H_EXHAUSTED, _H_PENDING, _H_NO_PARAMS) + + +def _live_session() -> Any | None: + """Session к достижимому не-заглушечному Postgres, иначе None.""" + try: + from sqlalchemy import create_engine, text + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + conn.execute(text("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +def _seed(db: Any) -> None: + from sqlalchemy import text as _t + + # url/source/ext_house_id — NOT NULL в houses (002_core_tables). + db.execute( + _t( + "INSERT INTO houses (id, source, ext_house_id, url, address, lat, lon, " + " imv_status, imv_transient_attempts, last_imv_attempt_at) VALUES " + "(:s, 'avito', 'EXT-2674-S', 'u', 'тестдом 2674, 1', 56.84, 60.60, " + " 'transient_error', 0, NOW() - INTERVAL '10 days')," + "(:x, 'avito', 'EXT-2674-X', 'u', 'тестдом 2674, 2', 56.84, 60.60, " + " 'transient_error', :cap, NOW() - INTERVAL '10 days')," + "(:p, 'avito', 'EXT-2674-P', 'u', 'тестдом 2674, 3', 56.84, 60.60, " + " 'pending', 0, NULL)," + "(:n, 'avito', 'EXT-2674-N', 'u', 'тестдом 2674, 4', 56.84, 60.60, " + " 'pending', 0, NULL)" + ), + { + "s": _H_STUCK, + "x": _H_EXHAUSTED, + "p": _H_PENDING, + "n": _H_NO_PARAMS, + # Литерал, а не hib._MAX_TRANSIENT_ATTEMPTS: подготовка теста обязана + # работать и на СТАРОМ коде, иначе красный прогон получится про + # отсутствующий атрибут, а не про застрявший дом. Синхронность + # литерала и константы стережёт test_transient_attempts_counter_*. + "cap": 3, + }, + ) + # Объявления с rooms+area+house_type — у всех, КРОМЕ _H_NO_PARAMS. + # source_url / dedup_hash / price_rub — NOT NULL, dedup_hash UNIQUE. + db.execute( + _t( + "INSERT INTO listings (id, source, source_url, source_id, dedup_hash, price_rub, " + " house_id_fk, rooms, area_m2, floor, total_floors, house_type) " + "VALUES " + "(:l1, 'avito', 'http://t/2674/1', 'L-2674-1', 'dh-2674-1', 5000000, :s, " + " 2, 52.0, 3, 9, 'brick')," + "(:l2, 'avito', 'http://t/2674/2', 'L-2674-2', 'dh-2674-2', 5000000, :x, " + " 2, 52.0, 3, 9, 'brick')," + "(:l3, 'avito', 'http://t/2674/3', 'L-2674-3', 'dh-2674-3', 5000000, :p, " + " 2, 52.0, 3, 9, 'brick')" + ), + { + "l1": 910601, + "l2": 910602, + "l3": 910603, + "s": _H_STUCK, + "x": _H_EXHAUSTED, + "p": _H_PENDING, + }, + ) + db.commit() + + +def _cleanup(db: Any) -> None: + from sqlalchemy import text as _t + + db.rollback() + db.execute(_t("DELETE FROM listings WHERE id IN (910601, 910602, 910603)")) + db.execute(_t("DELETE FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), {"ids": list(_IDS)}) + db.commit() + db.close() + + +async def _run(db: Any, **kwargs: Any) -> Any: + """Прогнать backfill с замоканным вызовом площадки. + + Мокаем ровно сетевую границу: evaluate_via_imv поднимает «адрес не найден», + поэтому дом уходит в not_found и путь сохранения (три таблицы) не нужен — + проверяем именно ВЫБОРКУ. Флаг браузерного фетчера гасим, чтобы не поднимать + сайдкар. + """ + err = hib.IMVAddressNotFoundError("address not found (test)") + with ( + patch.object(hib.settings, "avito_imv_use_browser_fetcher", False), + patch.object(hib, "evaluate_via_imv", AsyncMock(side_effect=err)), + ): + return await hib.backfill_house_imv(db, request_delay_sec=0.0, **kwargs) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +async def test_stuck_transient_house_returns_to_the_queue_by_itself() -> None: + """Прогон с дефолтными параметрами (как у расписания) забирает застрявший дом. + + На старом коде выборка — `WHERE imv_status = :status` с единственным + значением 'pending', поэтому дом остаётся в 'transient_error' навсегда. + """ + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + if db.execute(_t("SELECT count(*) FROM houses")).scalar(): + pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди") + try: + _seed(db) + + # Ровно то, что вызывает расписание: batch_size из default_params, статус по умолчанию. + result = await _run(db, batch_size=50) + + picked = { + r.id: r.imv_status + for r in db.execute( + _t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), + {"ids": list(_IDS)}, + ) + } + assert picked[_H_STUCK] != "transient_error", ( + "дом с временной ошибкой и непотраченным лимитом обязан попасть в пакет " + f"обычного прогона; фактически статус {picked[_H_STUCK]!r} " + f"(в пакет прогона попало домов: {result.checked})" + ) + # pending не вытеснен повтором. + assert picked[_H_PENDING] != "pending", "pending-дом обязан остаться в том же пакете" + # Исчерпавший лимит в автоповтор не берётся — иначе вечный цикл. + assert picked[_H_EXHAUSTED] == "transient_error" + assert result.retried == 1 + # Дом без объявлений с rooms+area помечен ДО пакета, слот не потрачен. + assert picked[_H_NO_PARAMS] == "no_params" + assert result.premarked == 1 + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +async def test_explicit_only_status_still_takes_exhausted_houses() -> None: + """Явный only_status — ручной запрос оператора: лимит попыток его не режет.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + if db.execute(_t("SELECT count(*) FROM houses")).scalar(): + pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди") + try: + _seed(db) + + result = await _run(db, batch_size=50, only_status="transient_error") + + statuses = { + r.id: r.imv_status + for r in db.execute( + _t("SELECT id, imv_status FROM houses WHERE id = ANY(CAST(:ids AS bigint[]))"), + {"ids": list(_IDS)}, + ) + } + assert ( + statuses[_H_EXHAUSTED] != "transient_error" + ), "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит" + # Автоповтора поверх явного запроса нет: pending не тронут. + assert statuses[_H_PENDING] == "pending" + assert result.retried == 0 + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +async def test_transient_attempts_counter_only_counts_transient() -> None: + """Счётчик — это «сколько раз подряд упал по временной причине», и он обнуляем. + + Без верхней границы у повтора не было бы выхода; без обнуления успехом дом, + однажды поймавший 3 сетевых сбоя, навсегда терял бы право на повтор. + """ + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + if db.execute(_t("SELECT count(*) FROM houses")).scalar(): + pytest.skip("тестовая БД не пуста — фикстуры не попадут в голову очереди") + try: + _seed(db) + assert hib._MAX_TRANSIENT_ATTEMPTS == 3, "литерал 'cap' в _seed рассинхронизирован" + + def _attempts() -> int: + return int( + db.execute( + _t("SELECT imv_transient_attempts FROM houses WHERE id = :i"), + {"i": _H_STUCK}, + ).scalar() + ) + + hib._mark_status(db, _H_STUCK, "transient_error", "boom") + assert _attempts() == 1 + hib._mark_status(db, _H_STUCK, "not_found", "адрес не найден") + assert _attempts() == 1, "нетранзиентные исходы счётчик не двигают" + db.execute( + _t("UPDATE houses SET imv_status='ok', imv_transient_attempts=0 WHERE id = :i"), + {"i": _H_STUCK}, + ) + db.commit() + assert _attempts() == 0 + finally: + _cleanup(db) + + +def test_retry_queue_is_not_bound_to_only_status() -> None: + """Статика (бежит и без БД): retry-выборка не зависит от параметра only_status. + + Ровно этот дефект и застрял на проде: единственный путь к 'transient_error' + шёл через значение, которое никто не передавал. + """ + retry_sql = str(hib._RETRY_QUEUE_SQL.text) + assert "imv_status = 'transient_error'" in retry_sql + assert ":status" not in retry_sql, "retry-очередь обязана быть независимой от only_status" + assert "imv_transient_attempts < :max_attempts" in retry_sql, "у повтора должен быть выход" + # psycopg v3: только CAST(:x AS type), никогда :x::type. + for sql in (retry_sql, str(hib._QUEUE_SQL.text), str(hib._PREMARK_UNUSABLE_SQL.text)): + assert "::" not in sql 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_segment_upsert_selfheal.py b/tradein-mvp/backend/tests/test_listing_segment_upsert_selfheal.py new file mode 100644 index 00000000..fcea6062 --- /dev/null +++ b/tradein-mvp/backend/tests/test_listing_segment_upsert_selfheal.py @@ -0,0 +1,215 @@ +"""listing_segment upsert self-heal: строка не должна вечно застревать с NULL-сегментом. + +Баг: `scraper_kit.base.save_listings` писал `listing_segment` ТОЛЬКО в INSERT-ветке +upsert'а — колонки не было ни в `ON CONFLICT (dedup_hash) DO UPDATE SET`, ни в +reconcile-UPDATE (dedup_hash-drift fallback, срабатывает при UniqueViolation по +(source, source_id)). Итог: если первый скрейп объявления не смог определить сегмент +(классификатор вернул None), строка рождалась с `listing_segment IS NULL` и +НИКОГДА не самочинялась на последующих пересборах, даже когда сегмент становился +определим. Симптом лечили отдельной джобой деактивации +(`deactivate_stale_{cian,yandex}_null_segment`, миграция 266, PR #2908) — чистит +мусор в `is_active`, но не лечит саму запись сегмента. + +Fix: `listing_segment = COALESCE(EXCLUDED.listing_segment, listings.listing_segment)` +в ON CONFLICT DO UPDATE + `listing_segment = COALESCE(:listing_segment, listing_segment)` +в reconcile UPDATE — тот же идиом, что уже применён для `city` (#2594, +test_listings_city_from_sweep.py) и `kitchen_area_m2`/`ceiling_height_m` (#2007): +новое значение обновляет строку, но НЕ затирает уже известное пустым. + +Тесты здесь, как и соседний test_listings_city_from_sweep.py, мокают db.execute и +проверяют SQL-текст + bind-параметры (unit-уровень, без реальной Postgres) — +COALESCE-семантику "новое непустое побеждает / пустое не затирает старое" исполняет +сама база при выполнении запроса. +""" + +from __future__ import annotations + +import os +from contextlib import contextmanager +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", + listing_segment: str | None = None, +) -> KitLot: + return KitLot( + source=source, + source_url=f"https://www.{source}.ru/item/{source_id}", + source_id=source_id, + address="ул. Победы, 30", + listing_segment=listing_segment, + price_rub=3_000_000, + ) + + +# ── save_listings(...) — INSERT path ──────────────────────────────────────── + + +def test_save_listings_writes_listing_segment_into_insert_sql() -> None: + """listing_segment передаётся в SQL params И колонка есть в INSERT-списке.""" + db = _mock_db_insert_path() + lot = _lot(listing_segment="vtorichka") + + 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 "listing_segment" in sql, "listing_segment column must be in INSERT column list" + assert params["listing_segment"] == "vtorichka" + + +def test_save_listings_listing_segment_none_backward_compat() -> None: + """Классификатор не определил сегмент (None) — INSERT всё равно проходит, NULL.""" + db = _mock_db_insert_path() + lot = _lot(listing_segment=None) + + 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["listing_segment"] is None + + +# ── ON CONFLICT DO UPDATE — COALESCE self-heal (главный фикс) ────────────── + + +def test_save_listings_on_conflict_coalesces_listing_segment() -> None: + """ON CONFLICT DO UPDATE — listing_segment = COALESCE(EXCLUDED.listing_segment, + listings.listing_segment), не blind overwrite и не "никогда не обновляется".""" + db = _mock_db_insert_path() + lot = _lot(listing_segment="vtorichka") + + 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 "listing_segment = COALESCE(" in sql + assert "EXCLUDED.listing_segment, listings.listing_segment" in sql + # Повторный скрейп с ОПРЕДЕЛЁННЫМ сегментом — новое значение уходит в EXCLUDED, + # COALESCE на стороне Postgres применит его к прежде-NULL строке (self-heal). + assert params["listing_segment"] == "vtorichka" + + +def test_save_listings_on_conflict_listing_segment_none_does_not_blind_overwrite() -> None: + """Повторный скрейп БЕЗ сегмента (classifier снова None) — SQL всё равно + несёт COALESCE (не голый EXCLUDED), значит уже известный сегмент строки + в БД НЕ будет затёрт пустым при выполнении запроса.""" + db = _mock_db_insert_path() + lot = _lot(listing_segment=None) + + 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 "listing_segment = COALESCE(" in sql + assert "EXCLUDED.listing_segment, listings.listing_segment" in sql + assert params["listing_segment"] is None + + +# ── Reconcile UPDATE (dedup_hash drift) — тот же self-heal ───────────────── + + +def test_save_listings_reconcile_update_coalesces_listing_segment() -> None: + """dedup_hash-drift reconcile UPDATE path — тоже COALESCE(:listing_segment, + listing_segment), не blind overwrite. Без этого фикса строки, дошедшие до + reconcile (content дрейфит, старый dedup_hash не находится, INSERT ловит + UniqueViolation по (source, source_id)), остались бы незалеченными.""" + 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", listing_segment="novostroyki") + + 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, "SET dedup_hash") + assert "listing_segment = COALESCE(:listing_segment, listing_segment)" in sql + assert params["listing_segment"] == "novostroyki" 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..e0066101 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( @@ -885,6 +844,111 @@ def test_tier2a_coord_less_bare_street_still_runs_tier2a(): assert any("fingerprint = :fp" in s for s in _executed_sqls(db)) +# --------------------------------------------------------------------------- +# match_or_create_house — sweep-city guard for BARE oblast cards (#2777) +# --------------------------------------------------------------------------- + + +def test_tier2a_bare_card_from_oblast_sweep_skips_alias_lookups(): + """RED до #2777. Бескоординатная карточка областного формата («ул. Кирова,4» — города + в адресе НЕТ) собрана развёрткой Серова. Ключ Tier-2a без координат вырождается в один + нормализованный адрес, глобально уникальный в house_address_aliases, поэтому карточка + села бы в одноимённый дом другого города (прод: 362.8 км, дом 380628). Признак города у + развёртки ЕСТЬ — он же пишется в listings.city — и теперь доезжает до стража.""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss (Tier 1) + # Tier 2a/2b SKIPPED стражем, Tier 3 SKIPPED (нет координат) + {"id": 2777}, # INSERT RETURNING id (New house) + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, conf, method = match_or_create_house( + db, "avito", "ext-2777-serov", address="ул. Кирова,4", city="Серов" + ) + assert (house_id, conf, method) == (2777, 1.0, "new") + sqls = _executed_sqls(db) + assert not any( + "fingerprint = :fp" in s for s in sqls + ), "карточка чужого города прошла Tier-2a по бескоординатному ключу «улица + номер»" + assert not any("normalized_address = :na" in s for s in sqls) + + +def test_sweep_city_ekb_keeps_tier2a_dedup(): + """Контроль к предыдущему: тот же бескоординатный ключ, но развёртка ЕКБ — страж молчит, + Tier-2a дедуп работает как раньше. Иначе правка ломала бы 31 663 ЕКБ-карточки ради 35.""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss + {"house_id": 55}, # Tier 2a fingerprint HIT + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, conf, method = match_or_create_house( + db, "avito", "ext-2777-ekb", address="ул. Кирова,4", city="Екатеринбург" + ) + assert (house_id, conf, method) == (55, 0.9, "fingerprint") + assert any("fingerprint = :fp" in s for s in _executed_sqls(db)) + + +def test_sweep_city_ignored_when_card_has_coords(): + """Карточка С координатами стражем города не трогается: у Tier-2a координаты уже в ключе, + у Tier-2b свой гео-страж 3 км. Расширять на неё городской признак — значит ломать + смежные ЕКБ/В.Пышма пары, которые в проде расходятся на 2-8 м (то же здание).""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss + {"house_id": 66}, # Tier 2a fingerprint HIT (координаты в ключе) + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, conf, method = match_or_create_house( + db, + "avito", + "ext-2777-coords", + address="ул. Кирова,4", + lat=59.60, + lon=60.58, + city="Верхняя Пышма", + ) + assert (house_id, conf, method) == (66, 0.9, "fingerprint") + assert any("fingerprint = :fp" in s for s in _executed_sqls(db)) + + +def test_sweep_city_guard_covers_cities_outside_the_token_list(): + """Страж не должен зависеть от списка _CITY_TOKENS: развёртка Ревды (её в списке нет) + всё равно не имеет права садиться на чужой алиас. Признак — имя города от развёртки, + а не токен, вычитанный из адреса.""" + from app.services.matching.houses import match_or_create_house + + db = _make_db( + [ + None, # pg_advisory_xact_lock + None, # house_sources miss + {"id": 2778}, # INSERT RETURNING id (New house) — алиасы пропущены + None, # _upsert_house_source + None, # _insert_alias + ] + ) + house_id, _conf, method = match_or_create_house( + db, "avito", "ext-2777-revda", address="ул. Кирова,4", city="Ревда" + ) + assert (house_id, method) == (2778, "new") + assert not any("fingerprint = :fp" in s for s in _executed_sqls(db)) + + # --------------------------------------------------------------------------- # match_or_create_listing — mock DB tier routing # --------------------------------------------------------------------------- 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..b16be85b --- /dev/null +++ b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py @@ -0,0 +1,133 @@ +"""Достижимость тиров матчинга домов (#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 + ), "в ключ дома вернулся фолбэк на кадастр квартиры" + + +def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None: + """Страж города бесполезен, пока признак не доезжает до него с настоящего вызова (#2777). + + Тот же класс ошибки, что у `house_fias_id`: параметр в сигнатуре есть, передать его + некому. Здесь граница — `scraper_kit.base._link_listing_to_house`, единственный боевой + вызывающий пути создания домов; город он получает из `save_listings`, который его же + пишет в `listings.city`. + """ + from scraper_kit.base import _link_listing_to_house, save_listings + + assert "city" in _params(match_or_create_house) + + hook_src = inspect.getsource(_link_listing_to_house) + assert "city=city" in hook_src, ( + "_link_listing_to_house перестал передавать город в матчер — страж #2777 снова " + "недостижим, а межгородская склейка молча вернётся" + ) + # save_listings считает lot_city (город batch'а после гео-guard'а) и обязан отдать + # именно его, а не сырой city-аргумент: лот вне city_radius_km города НЕ помечен. + assert "city=lot_city" in inspect.getsource( + save_listings + ), "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён" 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_migration_257_yandex_source_url_backfill.py b/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py new file mode 100644 index 00000000..6a8ce9b3 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_257_yandex_source_url_backfill.py @@ -0,0 +1,79 @@ +"""Static guards for migration 257 (#2838): лечение yandex source_url. + +Живой БД у юнит-теста нет, поэтому проверяем то, что ломается тихо и навсегда: + + 1. Форма адреса не разъехалась с продюсером. В файле стоит ДОСЛОВНО + `CANONICAL_URL_SQL` из app/tasks/yandex_detail_backfill.py, а тот уже сверен с + `_canonical_source_url` продюсера в test_yandex_detail_backfill.py. Сцепление + транзитивное: поменяют форму у продюсера — красным станет тот тест, поменяют в + миграции — этот. + 2. Отбор — теми же константами, что у очереди (#2838), а не «похожими» regexp'ами. + 3. Бэкап прежних значений пишется ДО UPDATE. Переставь местами — таблица отката + наполнится уже канонизированными адресами, и откат станет no-op'ом, который + выглядит рабочим. + 4. Недеструктивность и транзакционность: одна колонка, BEGIN/COMMIT, lock_timeout. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from app.tasks.yandex_detail_backfill import ( + CANONICAL_URL_SQL, + OFFER_ID_PATTERN, + OFFER_URL_PATTERN, +) + +_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "data" + / "sql" + / "257_listings_backfill_yandex_source_url.sql" +) + + +def _executable_sql() -> str: + """SQL без `--`-комментариев: шапка объясняет прозой и упоминает всё подряд.""" + lines = [raw.split("--", 1)[0] for raw in _MIGRATION.read_text(encoding="utf-8").splitlines()] + return "\n".join(line for line in lines if line.strip()) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def test_migration_exists() -> None: + assert _MIGRATION.exists(), f"missing migration: {_MIGRATION}" + + +def test_canonical_url_matches_producer_formula() -> None: + """Выражение UPDATE — тот же литерал, что CANONICAL_URL_SQL (и, значит, продюсер).""" + assert _flat(CANONICAL_URL_SQL) in _flat(_executable_sql()) + + +def test_selects_rows_by_the_same_constants_as_the_queue() -> None: + """Отбор — константами очереди #2838, иначе миграция чинит не то множество.""" + sql = _executable_sql() + assert f"'{OFFER_URL_PATTERN}'" in sql + assert f"'{OFFER_ID_PATTERN}'" in sql + assert "source = 'yandex'" in sql + + +def test_backup_is_written_before_the_update() -> None: + sql = _executable_sql() + assert sql.index("INSERT INTO yandex_source_url_backfill_257") < sql.index("UPDATE listings") + + +def test_transactional_and_lock_timeout_after_begin() -> None: + sql = _executable_sql() + assert "BEGIN;" in sql and "COMMIT;" in sql + assert sql.index("BEGIN;") < sql.index("SET LOCAL lock_timeout") + assert sql.index("SET LOCAL lock_timeout") < sql.index("UPDATE listings") + + +def test_touches_only_source_url_and_deletes_nothing() -> None: + sql = _executable_sql().upper() + for forbidden in ("DELETE", "DROP", "TRUNCATE", "IS_ACTIVE"): + assert forbidden not in sql, forbidden + assert sql.count("SET SOURCE_URL") == 1 diff --git a/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py new file mode 100644 index 00000000..75da91a6 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py @@ -0,0 +1,452 @@ +"""Static guards for migration 262 (wave 2 — оставшиеся 40 городов Свердловской обл.). + +Прод применяет data/sql построчно строго (ON_ERROR_STOP). Полный DB-прогон требует живой +БД; здесь фиксируем структурные инварианты миграции (транзакционность, отсутствие DDL, +отсутствие psycopg CAST-ловушки, все enabled=false) и, ГЛАВНОЕ, parity-guard между +data/sql (что реально сеется в scrape_schedules) и `scraper_kit.orchestration.pipeline` +(`CITY_ANCHORS`/`CITY_LOCATIONS`, откуда sweep берёт координаты и provider-id по +`default_params->>'city'`): + + - каждый slug из миграций 179_/262_ ОБЯЗАН существовать в CITY_ANCHORS — иначе + `get_city_anchors(slug)` вернёт None и `_job_*_city_sweep` молча упадёт на + EKB_ANCHORS (город "включат", а сборка физически уйдёт в Екатеринбург); + - и наоборот: КАЖДЫЙ ключ CITY_ANCHORS (кроме None-пути ЕКБ) обязан иметь schedule- + строки где-то (179_ ИЛИ 262_); + - #262 ревью (после первой версии файла): строка для (provider, city) существует + ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий provider-идентификатор в CITY_LOCATIONS + подтверждён (не None) — НЕ "каждый город получает все 3 источника". Первая версия + заводила все 123 (41×3) строки, планируя добыть идентификаторы ПОСЛЕ — это был бы + ровно тот силентный ЕКБ-fallback баг, о котором она сама предупреждала. Этот файл + проверяет обратное соответствие в обе стороны для avito_slug/yandex_rgid/ + cian_region_id. + +Это ловит ИМЕННО тот класс регрессии, которого просил избежать заказчик: опечатка в +slug/рассинхрон идентификатора при заведении миграции, из-за которой sweep молча +резолвится на EKB_ANCHORS или ЕКБ region_id/rgid. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from scraper_kit.orchestration.pipeline import CITY_ANCHORS, CITY_LOCATIONS + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_179 = _SQL_DIR / "179_scrape_schedules_seed_oblast_city_sweeps.sql" +_MIGRATION_262 = _SQL_DIR / "262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql" + +_PROVIDERS = ("avito", "cian", "yandex") + +# source-строка внутри VALUES (), напр. " 'avito_city_sweep_nizhniy_tagil',". +# Ограничено 4-пробельным отступом + запятой в конце — не матчит примеры в header- +# комментариях (см. "UPDATE scrape_schedules SET enabled = true WHERE source = '...';"). +_ROW_SOURCE_RE = re.compile(r"^ '(avito|cian|yandex)_city_sweep_([a-z_]+)',$", re.MULTILINE) + +_WAVE1_SLUGS = { + "nizhniy_tagil", + "kamensk_uralskiy", + "pervouralsk", + "verkhnyaya_pyshma", + "serov", +} +_WAVE2_SLUGS = { + "novouralsk", + "revda", + "polevskoy", + "asbest", + "bogdanovich", + "irbit", + "krasnoufimsk", + "berezovskiy", + "zarechny", + "kachkanar", + "krasnoturinsk", + "severouralsk", + "ivdel", + "tavda", + "turinsk", + "sysert", + "sredneuralsk", + "degtyarsk", + "verkhnyaya_salda", + "nizhnyaya_salda", + "nevyansk", + "artemovskiy", + "kamyshlov", + "alapaevsk", + "sukhoy_log", + "kushva", + "krasnouralsk", + "karpinsk", + "nizhnyaya_tura", + "verkhniy_tagil", + "nizhnie_sergi", + "lesnoy", + "rezh", + "aramil", + "volchansk", + "verkhnyaya_tura", + "mikhaylovsk", + "verkhoturye", + "talitsa", + "novaya_lyalya", +} +assert len(_WAVE2_SLUGS) == 40 +assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана) + + +def _sql(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _executable_sql(path: Path) -> 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 _row_sources(path: Path) -> list[tuple[str, str]]: + """(provider, slug) пар из исполняемых INSERT-строк VALUES (не из header-комментов).""" + return _ROW_SOURCE_RE.findall(_executable_sql(path)) + + +def _flat(text: str) -> str: + return re.sub(r"\s+", " ", text).strip().lower() + + +# ── existence / basic shape ─────────────────────────────────────────────────── + + +def test_migration_262_exists() -> None: + assert _MIGRATION_262.exists(), f"missing migration: {_MIGRATION_262}" + + +def test_migration_262_is_transactional() -> None: + sql = _sql(_MIGRATION_262) + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_262_no_ddl() -> None: + flat = _flat(_executable_sql(_MIGRATION_262)) + 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_262_no_psycopg_cast_trap() -> None: + assert not re.search(r":\w+::", _sql(_MIGRATION_262)) + + +def test_migration_262_idempotent_on_conflict_do_nothing() -> None: + flat = _flat(_executable_sql(_MIGRATION_262)) + assert "on conflict (source) do nothing" in flat + # Одна INSERT-инструкция на весь файл (не 102 отдельных INSERT) — ON CONFLICT + # покрывает весь батч разом; повторный прогон всего файла — no-op. + assert flat.count("insert into scrape_schedules") == 1 + + +def test_migration_262_excludes_bisert_entirely() -> None: + """Бисерть — пгт, не город; у Циана её нет вообще (любой запрос отдаёт Сысерть). + + Regression-guard: ни одной строки source LIKE '%bisert%' в исполняемом SQL.""" + flat = _flat(_executable_sql(_MIGRATION_262)) + assert "bisert" not in flat + + +# ── row counts (102 = 23 avito + 40 cian + 39 yandex) ─────────────────────── + + +def test_migration_262_has_exactly_102_rows() -> None: + rows = _row_sources(_MIGRATION_262) + assert len(rows) == 102, f"expected 23 avito + 40 cian + 39 yandex = 102 rows, got {len(rows)}" + + +def test_migration_262_row_counts_per_provider() -> None: + rows = _row_sources(_MIGRATION_262) + by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS} + for provider, slug in rows: + by_provider[provider].add(slug) + assert len(by_provider["avito"]) == 23 + assert len(by_provider["cian"]) == 40 + assert len(by_provider["yandex"]) == 39 + # cian — ровно все 40 wave-2 slug'ов (cian_region_id подтверждён у всех). + assert by_provider["cian"] == _WAVE2_SLUGS + # yandex — все, КРОМЕ mikhaylovsk. + assert by_provider["yandex"] == _WAVE2_SLUGS - {"mikhaylovsk"} + # avito — подмножество (только подтверждённые avito_slug), не пересекается с + # городами, у которых avito_slug=None в CITY_LOCATIONS. + assert by_provider["avito"] <= _WAVE2_SLUGS + + +def test_migration_262_no_duplicate_sources() -> None: + rows = _row_sources(_MIGRATION_262) + sources = [f"{p}_city_sweep_{s}" for p, s in rows] + assert len(sources) == len(set(sources)), "duplicate source в 262_" + + +def test_migration_262_does_not_touch_wave1_cities() -> None: + """Regression-guard: 262_ не должен переопределять/дублировать wave-1 5 городов — + они уже сидированы 179_ и живут (enabled управляется отдельно от них).""" + rows = _row_sources(_MIGRATION_262) + slugs = {slug for _provider, slug in rows} + overlap = slugs & _WAVE1_SLUGS + assert not overlap, f"262_ пересекается с wave-1 городами: {overlap}" + + +def test_migration_262_all_rows_enabled_false() -> None: + """Все 102 строки должны быть enabled=false (dormant by design — оператор включает + волнами вручную). Считаем по числу `false,` сразу после source-литерала.""" + executable = _executable_sql(_MIGRATION_262) + tuples = re.findall( + r"'(?:avito|cian|yandex)_city_sweep_[a-z_]+',\s*\n\s*(true|false),", + executable, + ) + assert len(tuples) == 102 + assert set(tuples) == {"false"}, "найдена строка с enabled=true — нарушение dormant-инварианта" + + +# ── CITY_LOCATIONS parity (ГЛАВНЫЙ regression-guard после ревью) ─────────── + + +def test_migration_262_row_exists_iff_identifier_confirmed() -> None: + """Строка (provider, city) есть в 262_ ТОГДА И ТОЛЬКО ТОГДА, когда соответствующий + provider-идентификатор в CITY_LOCATIONS подтверждён (не None). + + Это единственный источник правды после ревью: НЕ "каждый wave-2 город получает 3 + строки" (так было в отклонённой первой версии — риск silent EKB-fallback), а + "строка существует ровно там, где есть подтверждённый id". + """ + rows = _row_sources(_MIGRATION_262) + by_provider: dict[str, set[str]] = {p: set() for p in _PROVIDERS} + for provider, slug in rows: + by_provider[provider].add(slug) + + for slug in _WAVE2_SLUGS: + loc = CITY_LOCATIONS[slug] + + has_avito_row = slug in by_provider["avito"] + has_avito_id = loc.avito_slug is not None + assert has_avito_row == has_avito_id, ( + f"{slug}: avito_slug={loc.avito_slug!r} (confirmed={has_avito_id}) но " + f"avito-row-exists={has_avito_row} — рассинхрон CITY_LOCATIONS vs 262_" + ) + + has_cian_row = slug in by_provider["cian"] + has_cian_id = loc.cian_region_id is not None + assert has_cian_row == has_cian_id, ( + f"{slug}: cian_region_id={loc.cian_region_id!r} (confirmed={has_cian_id}) но " + f"cian-row-exists={has_cian_row} — рассинхрон CITY_LOCATIONS vs 262_" + ) + + has_yandex_row = slug in by_provider["yandex"] + has_yandex_id = loc.yandex_rgid is not None + assert has_yandex_row == has_yandex_id, ( + f"{slug}: yandex_rgid={loc.yandex_rgid!r} (confirmed={has_yandex_id}) но " + f"yandex-row-exists={has_yandex_row} — рассинхрон CITY_LOCATIONS vs 262_" + ) + + +def test_every_confirmed_avito_slug_has_a_schedule_row_and_vice_versa() -> None: + """Каждый slug из CITY_LOCATIONS с непустым avito_slug имеет avito_city_sweep_ + строку в 262_ (wave-2) или 179_ (wave-1) — и наоборот, каждая avito-строка + принадлежит городу с непустым avito_slug.""" + rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "avito"} + rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "avito"} + scheduled_avito = rows_262 | rows_179 + + confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None} + assert scheduled_avito == confirmed_avito, ( + f"missing schedule rows: {confirmed_avito - scheduled_avito}; " + f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_avito}" + ) + + +def test_every_confirmed_yandex_rgid_has_a_schedule_row_and_vice_versa() -> None: + rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "yandex"} + rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "yandex"} + scheduled_yandex = rows_262 | rows_179 + + confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None} + assert scheduled_yandex == confirmed_yandex, ( + f"missing schedule rows: {confirmed_yandex - scheduled_yandex}; " + f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_yandex}" + ) + + +def test_every_confirmed_cian_region_id_has_a_schedule_row_and_vice_versa() -> None: + rows_262 = {slug for provider, slug in _row_sources(_MIGRATION_262) if provider == "cian"} + rows_179 = {slug for provider, slug in _row_sources(_MIGRATION_179) if provider == "cian"} + scheduled_cian = rows_262 | rows_179 + + confirmed_cian = { + slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None + } + assert scheduled_cian == confirmed_cian, ( + f"missing schedule rows: {confirmed_cian - scheduled_cian}; " + f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_cian}" + ) + + +def test_mikhaylovsk_has_only_cian_row() -> None: + """Regression-guard для самого узкого случая: mikhaylovsk — единственный город без + yandex_rgid (город отсутствует в гео-базе Яндекса) и без avito_slug — должен иметь + РОВНО одну строку (cian), НЕ три.""" + rows = _row_sources(_MIGRATION_262) + mikhaylovsk_providers = {p for p, s in rows if s == "mikhaylovsk"} + assert mikhaylovsk_providers == { + "cian" + }, f"mikhaylovsk должен иметь только cian-строку, получено: {mikhaylovsk_providers}" + + +# ── CITY_ANCHORS parity ───────────────────────────────────────────────────── + + +def test_migration_262_slugs_all_exist_in_city_anchors() -> None: + """Каждый slug из 262_ ОБЯЗАН быть в CITY_ANCHORS — иначе get_city_anchors(slug) + вернёт None и sweep молча резолвится на EKB_ANCHORS (собирает ЕКБ вместо города).""" + rows = _row_sources(_MIGRATION_262) + slugs = {slug for _provider, slug in rows} + missing = slugs - set(CITY_ANCHORS) + assert not missing, f"slugs из 262_ отсутствуют в CITY_ANCHORS: {sorted(missing)}" + + +def test_migration_179_slugs_all_exist_in_city_anchors() -> None: + """Тот же guard для wave-1 (179_) — sanity, что baseline не сломан этим PR.""" + rows = _row_sources(_MIGRATION_179) + slugs = {slug for _provider, slug in rows} + missing = slugs - set(CITY_ANCHORS) + assert not missing, f"slugs из 179_ отсутствуют в CITY_ANCHORS: {sorted(missing)}" + + +def test_city_anchors_has_no_slug_without_schedule_rows() -> None: + """Обратное направление: каждый ключ CITY_ANCHORS обязан иметь schedule-строки в + 179_ ИЛИ 262_ — мёртвая запись без сидов сигнализирует рассинхрон/опечатку.""" + seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | { + slug for _p, slug in _row_sources(_MIGRATION_262) + } + orphaned = set(CITY_ANCHORS) - seeded_slugs + assert ( + not orphaned + ), f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}" + + +def test_city_anchors_wave2_count_and_content() -> None: + """CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 — + ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи).""" + assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS + assert len(CITY_ANCHORS) == 45 + + +def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None: + """Каждая wave-2 запись — ровно 1 anchor (город компактнее ЕКБ, см. коммент в + pipeline.py) с непустой человекочитаемой подписью, заканчивающейся на 'центр'.""" + for slug in _WAVE2_SLUGS: + anchors = CITY_ANCHORS[slug] + assert len(anchors) == 1, f"{slug}: ожидался 1 anchor, получено {len(anchors)}" + lat, lon, label = anchors[0] + assert 55.0 < lat < 62.0, f"{slug}: lat={lat} вне разумного диапазона Свердловской обл." + assert 56.0 < lon < 67.0, f"{slug}: lon={lon} вне разумного диапазона Свердловской обл." + assert label.strip().lower().endswith("центр"), f"{slug}: подпись '{label}' без 'центр'" + + +def test_city_locations_wave2_count_and_content() -> None: + """CITY_LOCATIONS покрывает ровно те же 45 slug'ов, что CITY_ANCHORS (иначе + get_city_location(slug) вернёт None для известного anchor'ами города и сборка + молча уйдёт на ЕКБ region_id/rgid).""" + assert set(CITY_LOCATIONS) == set(CITY_ANCHORS) + + +def test_city_locations_cian_region_id_never_none() -> None: + """cian_region_id подтверждён у ВСЕХ 45 городов (wave 1 + wave 2) — единственное + поле CityLocation, у которого нет Optional-пути.""" + for slug, loc in CITY_LOCATIONS.items(): + assert loc.cian_region_id is not None, f"{slug}: cian_region_id не должен быть None" + + +# ── window scheduling ─────────────────────────────────────────────────────── + +_EKB_OCCUPIED = {"avito": {6}, "cian": {2, 3, 4}, "yandex": {16}} +_WAVE1_OCCUPIED = { + "avito": {0, 1, 5, 7, 8}, + "cian": {9, 10, 11, 12, 13}, + "yandex": {14, 15, 17, 18, 19}, +} +# Математический минимум коллизий одного источника в одном часе (round-robin по +# 23/40/39 городам на 18/16/18 свободных часов соответственно). +_MAX_COLLISIONS = {"avito": 2, "cian": 3, "yandex": 3} + + +def _row_windows(path: Path) -> list[tuple[str, str, int, int]]: + """(provider, slug, window_start_hour, window_end_hour) для каждой VALUES-строки.""" + executable = _executable_sql(path) + pattern = re.compile( + r"'(avito|cian|yandex)_city_sweep_([a-z_]+)',\s*\n\s*(?:true|false),\s*\n\s*(\d+),\s*\n\s*(\d+),", + ) + return [(p, s, int(a), int(b)) for p, s, a, b in pattern.findall(executable)] + + +def test_migration_262_windows_are_one_hour() -> None: + """Окно ровно на час. Час 23 заворачивается в 0 (cross-midnight) — scheduler + это поддерживает явно (`window_end_hour <= window_start_hour` → окно через + полночь, см. scraper_kit.orchestration.scheduler).""" + for provider, slug, start, end in _row_windows(_MIGRATION_262): + expected = (start + 1) % 24 + assert ( + end == expected + ), f"{provider}_{slug}: window [{start},{end}) не 1-часовое (ожидали end={expected})" + + +def test_migration_262_window_hours_satisfy_db_check_constraint() -> None: + """Оба часа обязаны попадать в 0..23 — иначе миграция не применится вовсе. + + Регресс: первая версия давала start=23, end=24 у пяти строк. Проверка + «end == start + 1» это пропускала, а прод-констрейнт `window_end_range` + (052_scrape_schedules.sql: CHECK (window_end_hour BETWEEN 0 AND 23)) — + нет, и весь INSERT падал в CI на реальном postgres. + """ + for provider, slug, start, end in _row_windows(_MIGRATION_262): + assert 0 <= start <= 23, f"{provider}_{slug}: window_start_hour={start} вне 0..23" + assert 0 <= end <= 23, f"{provider}_{slug}: window_end_hour={end} вне 0..23" + + +def test_migration_262_windows_avoid_ekb_and_wave1_occupied_hours() -> None: + """Ни одна wave-2 строка не наезжает на занятые ЕКБ-окна (avito 6-7, cian 2-5, + yandex 16-17) или на окна wave-1 (179_) — тот же provider.""" + for provider, slug, start, _end in _row_windows(_MIGRATION_262): + occupied = _EKB_OCCUPIED[provider] | _WAVE1_OCCUPIED[provider] + assert start not in occupied, ( + f"{provider}_city_sweep_{slug}: окно start={start} пересекает занятый " + f"ЕКБ/wave-1 час ({sorted(occupied)})" + ) + + +def test_migration_262_same_provider_collisions_within_math_minimum() -> None: + """Коллизии ОДНОГО источника в одном окне не превышают математический минимум + (round-robin по подтверждённым городам на свободные часы), и распределены + равномерно (не сконцентрированы в 1-2 часах).""" + from collections import Counter + + rows = _row_windows(_MIGRATION_262) + for provider in _PROVIDERS: + hours = [start for p, _slug, start, _end in rows if p == provider] + counts = Counter(hours) + assert max(counts.values()) <= _MAX_COLLISIONS[provider], ( + f"{provider}: час {counts.most_common(1)} превышает математический минимум " + f"коллизий ({_MAX_COLLISIONS[provider]})" + ) + + +def test_migration_262_cross_provider_overlap_allowed() -> None: + """Разные провайдеры МОГУТ делить окно в рамках этой миграции (не ограничивается) — + документируем это явно, чтобы будущий рефактор не поломал предположение по ошибке.""" + rows = _row_windows(_MIGRATION_262) + avito_hours = {start for p, _s, start, _e in rows if p == "avito"} + cian_hours = {start for p, _s, start, _e in rows if p == "cian"} + assert avito_hours and cian_hours diff --git a/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py b/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py new file mode 100644 index 00000000..434d419f --- /dev/null +++ b/tradein-mvp/backend/tests/test_paid_retention_text_consistency.py @@ -0,0 +1,103 @@ +"""Payments retention (PR #2754) — "12 месяцев" text sync guard. + +WHY: + mera-public/content.ts declares itself as the ONE place product promises + live (docstring at the top of that file: "ни одного утверждения, которого + не делает код"). The public retention promise ("оплаченный отчёт хранится + N месяцев") has THREE places it could quietly drift: the backend setting + (`settings.trade_in_paid_retention_days`), the frontend constant + (`PAID_REPORT_RETENTION_MONTHS` in content.ts), and any page that renders + it (today: privacy/page.tsx). Deep-review finding 2026-08-06 MEDIUM on + PR #2754 caught exactly this: a comment claimed the number "reads from the + same setting" while the page actually hardcoded a `12 месяцев` literal -- + a comment cannot fail CI, same lesson as + test_consent_text_frontend_sync.py's _CONSENT_TEXT_SNAPSHOT guard (which + this file mirrors). + +WHAT: + 1. privacy/page.tsx imports PAID_REPORT_RETENTION_MONTHS from content.ts + and does NOT hardcode a "N месяцев" literal of its own. + 2. The frontend months constant and the backend days setting stay within + a sane calendar tolerance of each other (28-31 days per month) -- this + does NOT enforce byte-identity (days and months are different units by + design, see content.ts docstring), only that nobody silently changes + one without the other drifting out of "still honestly ~12 months". +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_FRONTEND_CONTENT = ( + Path(__file__).resolve().parents[2] / "frontend" / "src" / "app" / "mera-public" / "content.ts" +) +_FRONTEND_PRIVACY_PAGE = ( + Path(__file__).resolve().parents[2] + / "frontend" + / "src" + / "app" + / "mera-public" + / "privacy" + / "page.tsx" +) + +_MONTHS_CONST_RE = re.compile(r"PAID_REPORT_RETENTION_MONTHS\s*=\s*(\d+)\s*;") +_LITERAL_MONTHS_RE = re.compile(r'"?\d+ месяцев"?') + + +def _extract_months_constant(content_ts_source: str) -> int: + match = _MONTHS_CONST_RE.search(content_ts_source) + assert match is not None, ( + "PAID_REPORT_RETENTION_MONTHS not found in content.ts -- " + "constant renamed/removed without updating this test" + ) + return int(match.group(1)) + + +def test_frontend_files_exist() -> None: + assert _FRONTEND_CONTENT.is_file(), f"missing frontend file: {_FRONTEND_CONTENT}" + assert _FRONTEND_PRIVACY_PAGE.is_file(), f"missing frontend file: {_FRONTEND_PRIVACY_PAGE}" + + +def test_privacy_page_imports_retention_constant_not_hardcoded() -> None: + """The whole point: FAILS if privacy/page.tsx stops importing the shared + constant and goes back to a hardcoded '12 месяцев' literal (exactly the + drift the deep-review finding caught -- comment said 'reads from content.ts', + code said otherwise).""" + src = _FRONTEND_PRIVACY_PAGE.read_text(encoding="utf-8") + assert "PAID_REPORT_RETENTION_MONTHS" in src, ( + "privacy/page.tsx no longer references PAID_REPORT_RETENTION_MONTHS -- " + "the retention promise must be rendered from the shared content.ts " + "constant, not written out again by hand" + ) + assert re.search(r'from\s+"\.\./content"', src), ( + "privacy/page.tsx must import from '../content' (content.ts), where " + "PAID_REPORT_RETENTION_MONTHS is declared" + ) + literal_hits = _LITERAL_MONTHS_RE.findall(src) + assert not literal_hits, ( + "privacy/page.tsx contains a hardcoded 'N месяцев' literal -- render the " + "PAID_REPORT_RETENTION_MONTHS constant instead: " + f"{literal_hits!r}" + ) + + +def test_backend_days_setting_matches_frontend_months_within_calendar_tolerance() -> None: + """Not byte-identity (days vs months are different units, deliberately -- + see content.ts docstring on PAID_REPORT_RETENTION_MONTHS): just a sanity + bound that `trade_in_paid_retention_days` still honestly rounds to the + number of months the public page promises (28-31 days/month, generous).""" + from app.core.config import settings + + days = settings.trade_in_paid_retention_days + months = _extract_months_constant(_FRONTEND_CONTENT.read_text(encoding="utf-8")) + + assert 28 * months <= days <= 31 * months, ( + f"settings.trade_in_paid_retention_days={days} no longer honestly rounds to " + f"content.ts PAID_REPORT_RETENTION_MONTHS={months} -- update both together " + "(and the offer text, when it exists) so the public promise stays true" + ) 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_password_call_sites.py b/tradein-mvp/backend/tests/test_password_call_sites.py new file mode 100644 index 00000000..48e37a9d --- /dev/null +++ b/tradein-mvp/backend/tests/test_password_call_sites.py @@ -0,0 +1,110 @@ +"""Правило «из `async def` зови ТОЛЬКО ограниченную сверку» — проверяемое (#2715). + +Правило живёт в docstring `app/core/password.py`: синхронный `verify_password` +блокирует поток на ~282 мс (bcrypt cost 12), поэтому из кода приложения его +зовёт РОВНО ОДНА функция — `verify_password_bounded`, и она же единственная, +кто считает слоты (потолок темпа #2665 + доля на ключ #2714). + +Комментарий это правило не удерживает. Синхронная функция остаётся публичной и +импортируемой, и достаточно одной строчки `asyncio.to_thread(verify_password, +…)` в будущем коде, чтобы получить вынос в поток ВООБЩЕ БЕЗ учёта слотов: +внешне всё работает, вход отвечает быстро, а потолок перебора тихо исчезает. +Ревью такое ловит ровно до тех пор, пока помнит, что правило есть. + +Прецедент такого сторожа в репозитории: backend/tests/sql/test_auth_sql_migrations.py. + +ПОЧЕМУ AST, А НЕ GREP. `verify_password` упоминается в комментариях и docstring'ах +(app/api/v1/auth.py, app/core/config.py) — текстовый поиск краснел бы на них, и +сторож пришлось бы ослаблять исключениями до бессмысленности. AST видит только +ССЫЛКИ НА СИМВОЛ и ловит форму без скобок (`to_thread(verify_password, …)`), +которую `grep 'verify_password('` не поймал бы вовсе — то есть ровно ту, ради +которой сторож и написан. + +ЧЕГО СТОРОЖ НЕ ВИДИТ, и это записано тут, а не подразумевается: строкового +доступа (`getattr(mod, "verify_password")`) и обхода модуля целиком (прямой +`bcrypt.checkpw`). От НАМЕРЕННОГО обхода он не защищает и не может — только от +нечаянного, а нечаянный и есть частый случай. Обе непойманные формы закреплены +исполняемо (`test_detector_blind_spots_are_known`), чтобы «не ловим» было +проверенным фактом, а не обещанием в тексте. + +Без БД и без сети — только чтение файлов. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +_APP_DIR = _BACKEND_ROOT / "app" +# Единственное место, которому синхронная сверка разрешена: там она и определена, +# и оттуда её забирает пул внутри `verify_password_bounded`. +_OWNER = _APP_DIR / "core" / "password.py" + + +def _references_verify_password(source: str) -> bool: + """Ссылается ли модуль на символ `verify_password` (в любой форме).""" + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.Name) and node.id == "verify_password": + return True + if isinstance(node, ast.Attribute) and node.attr == "verify_password": + return True + if isinstance(node, ast.ImportFrom) and any( + alias.name == "verify_password" for alias in node.names + ): + return True + return False + + +def test_detector_actually_detects() -> None: + """Сторож обязан уметь краснеть — иначе он зелен вхолостую. + + Проверка на самого себя: пустой детектор (`return False`) прошёл бы все + файлы приложения и выглядел бы работающим сторожем ровно до первого + настоящего нарушения. + """ + # Формы, которые обязан ловить. + assert _references_verify_password("from app.core.password import verify_password") + assert _references_verify_password("asyncio.to_thread(verify_password, plain, hashed)") + assert _references_verify_password("password.verify_password(plain, hashed)") + assert _references_verify_password("ok = verify_password(plain, hashed)") + + # Формы, на которые краснеть НЕЛЬЗЯ (иначе сторож потребуют выключить). + assert not _references_verify_password("await verify_password_bounded(p, h, key=ip)") + assert not _references_verify_password('"""Зови verify_password только из пула."""') + assert not _references_verify_password("# verify_password тут только в комментарии") + + +def test_detector_blind_spots_are_known() -> None: + """Слепые зоны — зафиксированы, а не забыты. + + Обе формы обходят сторож НАМЕРЕННЫМ усилием: строковый доступ к атрибуту и + обход модуля целиком. Ловить их AST'ом можно было бы только ценой ложняков + (любой `getattr` с любой строкой, любой вызов bcrypt), а цена ложняка — + требование выключить сторож. Тест держит это знание исполняемым: захочет + однажды детектор их ловить — покраснеет здесь и заставит осознанно + переписать и этот тест, и текст модуля. + """ + assert not _references_verify_password('fn = getattr(password_mod, "verify_password")') + assert not _references_verify_password("bcrypt.checkpw(plain.encode(), hashed.encode())") + + +def test_sync_verify_password_is_called_from_one_place_only() -> None: + """В `app/` синхронную сверку не поминает никто, кроме её собственного модуля.""" + # Область сканирования жива. `rglob` по несуществующему каталогу не падает — + # отдаёт пусто, нарушителей ноль, сторож зелен НАВСЕГДА. Достаточно + # переложить этот файл в подкаталог tests/ (их уже восемь, и прецедент + # такого сторожа лежит именно в подкаталоге), чтобы `parents[1]` уехал. + assert _OWNER.exists(), f"область сканирования съехала: {_APP_DIR}" + + offenders = [ + str(path.relative_to(_BACKEND_ROOT)) + for path in sorted(_APP_DIR.rglob("*.py")) + if path != _OWNER and _references_verify_password(path.read_text(encoding="utf-8")) + ] + + assert offenders == [], ( + f"{offenders}: синхронный verify_password блокирует поток на ~282 мс и НЕ считает " + "слоты. Из кода приложения зови verify_password_bounded (app/core/password.py) — " + "она единственная точка выноса в пул и единственная точка учёта потолка" + ) diff --git a/tradein-mvp/backend/tests/test_payments_notification.py b/tradein-mvp/backend/tests/test_payments_notification.py new file mode 100644 index 00000000..110c8234 --- /dev/null +++ b/tradein-mvp/backend/tests/test_payments_notification.py @@ -0,0 +1,136 @@ +"""Тесты `app.services.payments.notification` — строгий разбор нотификации. + +`parse_notification()` вызывается ПОСЛЕ `verify_notification_token(...) is True` +(эта функция подпись не проверяет) — тесты здесь работают с payload напрямую, +без пересчёта подписи. +""" + +from __future__ import annotations + +import pytest + +from app.services.payments.notification import ( + NotificationParseError, + TBankNotification, + parse_notification, +) + +_VALID_PAYLOAD = { + "TerminalKey": "1234567890DEMO", + "OrderId": "order-1", + "Success": True, + "Status": "CONFIRMED", + "PaymentId": "0000000", + "Amount": 111100, + "Token": "irrelevant-here", +} + + +# ── happy path ──────────────────────────────────────────────────────────────── + + +def test_parse_notification_happy_path_returns_typed_object() -> None: + result = parse_notification(_VALID_PAYLOAD) + + assert isinstance(result, TBankNotification) + assert result.success is True + assert result.status == "CONFIRMED" + assert result.order_id == "order-1" + assert result.payment_id == "0000000" + assert result.terminal_key == "1234567890DEMO" + assert result.amount_kopecks == 111100 + + +def test_parse_notification_ignores_extra_fields() -> None: + """Лишние поля (ErrorCode, CardId, Pan, ...) в payload не мешают разбору.""" + payload = {**_VALID_PAYLOAD, "ErrorCode": "0", "CardId": "000000", "Pan": "200000******0000"} + result = parse_notification(payload) + assert result.order_id == "order-1" + + +# ── payload не dict ───────────────────────────────────────────────────────── + + +def test_parse_notification_rejects_non_dict_payload() -> None: + with pytest.raises(NotificationParseError): + parse_notification([_VALID_PAYLOAD]) # type: ignore[arg-type] + + +# ── Success: только настоящий bool ────────────────────────────────────────── + + +def test_parse_notification_rejects_success_as_string_true() -> None: + payload = {**_VALID_PAYLOAD, "Success": "true"} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_success_as_int_one() -> None: + payload = {**_VALID_PAYLOAD, "Success": 1} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_missing_success() -> None: + payload = {k: v for k, v in _VALID_PAYLOAD.items() if k != "Success"} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +# ── Amount: только int, bool отдельно отсекается ──────────────────────────── + + +def test_parse_notification_rejects_amount_as_bool_true() -> None: + """`bool` — подкласс `int` в Python, поэтому отсекается ДО общей int-проверки.""" + payload = {**_VALID_PAYLOAD, "Amount": True} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_amount_as_bool_false() -> None: + payload = {**_VALID_PAYLOAD, "Amount": False} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_amount_as_float() -> None: + payload = {**_VALID_PAYLOAD, "Amount": 111100.0} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_rejects_amount_as_string() -> None: + payload = {**_VALID_PAYLOAD, "Amount": "111100"} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +# ── строковые поля: только непустой str ───────────────────────────────────── + + +@pytest.mark.parametrize("field", ["Status", "OrderId", "PaymentId", "TerminalKey"]) +def test_parse_notification_rejects_empty_string_field(field: str) -> None: + payload = {**_VALID_PAYLOAD, field: ""} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +@pytest.mark.parametrize("field", ["Status", "OrderId", "PaymentId", "TerminalKey"]) +def test_parse_notification_rejects_non_string_field(field: str) -> None: + payload = {**_VALID_PAYLOAD, field: 12345} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +@pytest.mark.parametrize("field", ["Status", "OrderId", "PaymentId", "TerminalKey"]) +def test_parse_notification_rejects_missing_field(field: str) -> None: + payload = {k: v for k, v in _VALID_PAYLOAD.items() if k != field} + with pytest.raises(NotificationParseError): + parse_notification(payload) + + +def test_parse_notification_result_is_frozen() -> None: + """`TBankNotification` — frozen dataclass, случайная мутация после разбора невозможна.""" + result = parse_notification(_VALID_PAYLOAD) + with pytest.raises(AttributeError): + result.amount_kopecks = 1 # type: ignore[misc] 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..23101559 --- /dev/null +++ b/tradein-mvp/backend/tests/test_payments_token.py @@ -0,0 +1,256 @@ +"""Тесты `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 + +import pytest + +from app.services.payments.token import TokenSigningError, 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_field_raises_instead_of_guessing_format() -> None: + """`float` больше не сериализуется по угадываемому формату — явный отказ. + + Раньше `format(value, "f")` + rstrip нулей выдавал для `0.1 + 0.2` строку + "0.3", а `json.dumps(0.1 + 0.2)` реально даёт "0.30000000000000004" — + подписывалось не то, что уходит в JSON-теле запроса. Для денежного пути + (Amount — всегда int, копейки) правильнее упасть, чем угадать формат. + """ + with pytest.raises(TokenSigningError): + sign({"A": 1234.5}, "pw") + + +def test_large_float_field_also_raises() -> None: + """Тот же явный отказ и для значений, которые раньше ушли бы без экспоненты.""" + with pytest.raises(TokenSigningError): + sign({"A": 1e21}, "pw") + + +def test_amount_plus_float_sum_would_have_diverged_from_json_raises() -> None: + """Закрепляет мотивацию отказа: 0.1+0.2 != json.dumps(0.1+0.2) как строка.""" + computed = 0.1 + 0.2 + assert format(computed, "f").rstrip("0").rstrip(".") == "0.3" + with pytest.raises(TokenSigningError): + sign({"Amount": computed}, "pw") + + +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 + + +# ── verify_notification_token: НИКОГДА не кидает исключение на мусоре ───────── +# +# После появления публичной ручки нотификации (PR-D) необработанное +# исключение здесь = неаутентифицированный HTTP 500 в ответ банку, а любой +# ответ, отличный от "OK", банк трактует как временный сбой и ретраит +# нотификацию почасово в течение суток — см. docstring +# `verify_notification_token`. + + +def test_verify_notification_token_rejects_non_dict_payload_list() -> None: + """payload — список, не dict → `.get()` кинул бы AttributeError без guard'а.""" + assert verify_notification_token([{"Token": "x"}], "pw") is False # type: ignore[arg-type] + + +def test_verify_notification_token_rejects_non_dict_payload_string() -> None: + assert verify_notification_token("not-a-dict", "pw") is False # type: ignore[arg-type] + + +def test_verify_notification_token_rejects_non_dict_payload_none() -> None: + assert verify_notification_token(None, "pw") is False # type: ignore[arg-type] + + +def test_verify_notification_token_rejects_non_ascii_token() -> None: + """Не-ASCII Token → `hmac.compare_digest` кинул бы TypeError без guard'а.""" + payload = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": "кириллица-не-hex-токен"} + assert verify_notification_token(payload, _NOTIFICATION_VECTOR_PASSWORD) is False + + +def test_verify_notification_token_rejects_non_string_token() -> None: + payload = {**_NOTIFICATION_VECTOR_PAYLOAD, "Token": 12345} + assert verify_notification_token(payload, _NOTIFICATION_VECTOR_PASSWORD) is False + + +def test_verify_notification_token_does_not_raise_on_float_field() -> None: + """Поле-float где-то в payload (после отказа sign() от float) → False, не исключение.""" + payload = { + "TerminalKey": "demo", + "OrderId": "1", + "Amount": 11.5, # float — sign() теперь явно падает на нём + "Token": "0" * 64, + } + assert verify_notification_token(payload, "pw") is False + + +def test_verify_notification_token_rejects_empty_dict() -> None: + assert verify_notification_token({}, "pw") is False diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py index a97b017f..7d6d21ed 100644 --- a/tradein-mvp/backend/tests/test_pdf_security.py +++ b/tradein-mvp/backend/tests/test_pdf_security.py @@ -423,6 +423,86 @@ def test_build_listings_page_none_year_built_no_crash() -> None: assert "РЫНОК КВАРТИР" in html +# ── #pdf-honesty (#oblast-E deals-priority regression fix, 2026-08-10) ──────── +# n_analogs==0 (headline ceded to the ДКП deals corridor, estimator.py +# `deals_headline_due_to_thin_listings`) with a non-empty `analogs` display list +# (thin listings kept as reference cards) used to print "0 шт." above a +# non-empty examples table — a client-visible contradiction that leaked into +# the PDF handed to clients. See _build_listings_page / _deals_sourced_thin_ +# listings_note_html / _reliability_note_html. + + +def test_listings_page_zero_analogs_shown_cards_no_false_zero_count() -> None: + """The exact bug: n_analogs=0 + 3 shown analogs must NOT print '0 шт.' — + falls back to the actually-shown population (3) and adds an honest + deals-sourced footnote.""" + analogs = [ + _analog(address="ул. Льва Толстого, 8А"), + _analog(address="ул. Кирова, 4"), + _analog(address="ул. Льва Толстого, 34"), + ] + est = _estimate(n_analogs=0, analogs=analogs, sources_used=["avito"]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "0 шт." not in html + assert "3 шт." in html + assert "Оценка построена по зарегистрированным сделкам Росреестра" in html + assert "почти нет" in html + + +def test_listings_page_zero_analogs_empty_cards_stays_honest_zero() -> None: + """Control: genuinely zero listings (no cards to show either) — '0 шт.' is + honest here, and the deals-sourced footnote (which explains a MISMATCH) + must NOT appear since there is nothing to reconcile.""" + est = _estimate(n_analogs=0, analogs=[]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "0 шт." in html + assert "Оценка построена по зарегистрированным сделкам Росреестра" not in html + + +def test_listings_page_healthy_sample_keeps_full_n_analogs_not_capped_len() -> None: + """Control/regression guard for the max() choice: a healthy sample where + n_analogs (15) EXCEEDS the capped display list (10, AggregatedEstimate's + own top-10 cap) must keep printing the full honest count (15 шт.), NOT + silently understate it to len(analogs) (10 шт.).""" + analogs = [_analog(address=f"ул. Тест, {i}") for i in range(10)] + est = _estimate(n_analogs=15, analogs=analogs, sources_used=["avito"]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "15 шт." in html + assert "10 шт." not in html + + +def test_listings_page_relaxations_warning_shown_with_labels() -> None: + """relaxations non-empty → warning block present, names the labels, and + reliability != 'ok' — mirrors what the web LowConfidenceBanner already + shows (see AggregatedEstimate docstring).""" + est = _estimate(relaxations=["учтены студии", "радиус расширен до 3000 м"], reliability="low") + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "Точность оценки снижена." in html + assert "учтены студии" in html + assert "радиус расширен до 3000 м" in html + + +def test_listings_page_reliability_downgraded_no_relaxations_fallback_text() -> None: + """reliability != 'ok' but relaxations is empty (cascade couldn't grow a + thin sample, estimator.py #oblast-F) → warning block still shown, with a + fallback sentence (not an empty label list).""" + est = _estimate(n_analogs=2, reliability="very_low", relaxations=[]) + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "Точность оценки снижена." in html + assert "небольшой выборке" in html + + +def test_listings_page_no_warning_block_when_ok_and_no_relaxations() -> None: + """Control: the common/unrelaxed case (reliability='ok' default, no + relaxations) — no warning block at all, byte-identical to the report + before these fields existed.""" + est = _estimate() + assert est.reliability == "ok" + assert est.relaxations == [] + html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC) + assert "Точность оценки снижена." not in html + + def test_build_deals_page_none_year_built_no_crash() -> None: snap = dict(_SNAPSHOT) snap["year_built"] = None @@ -445,3 +525,36 @@ def test_brand_not_taken_from_query_param_docstring() -> None: assert ( "brand" not in param_names ), "estimate_pdf should NOT have a 'brand' query param after #7 fix" + + +# ── PR-D1: retain_until (paid retention) — cover row + valid_until unaffected ── + + +def test_cover_no_retain_until_row_when_unpaid() -> None: + """retain_until IS NULL (default, all current traffic) → no 'Ссылка доступна + до' row at all — B2B regression guard, cover renders bit-for-bit as before.""" + est = _estimate() + assert est.retain_until is None + html = mod._build_cover(est, _SNAPSHOT, _GENERIC) + assert "Ссылка доступна до" not in html + + +def test_cover_renders_retain_until_row_when_paid() -> None: + """retain_until IS NOT NULL → 'Ссылка доступна до ' row present, with + its OWN date (not conflated with 'Срок действия данных' / expires_at).""" + retain = datetime(2027, 8, 6, tzinfo=UTC) + est = _estimate(retain_until=retain) + html = mod._build_cover(est, _SNAPSHOT, _GENERIC) + assert "Ссылка доступна до" in html + assert "06.08.2027" in html + + +def test_expires_date_unaffected_by_retain_until() -> None: + """«ДЕЙСТВИТЕЛЕН ДО» (running footer, _expires_date) stays wired to + expires_at regardless of retain_until — it is data-actuality, not the + paid-access retention window, and must not move when a report is paid.""" + expires = datetime.now(UTC) + timedelta(hours=24) + est_unpaid = _estimate(expires_at=expires) + est_paid = _estimate(expires_at=expires, retain_until=expires + timedelta(days=365)) + assert mod._expires_date(est_unpaid) == expires.date() + assert mod._expires_date(est_paid) == expires.date() 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_purge_expired_trade_in_data.py b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py new file mode 100644 index 00000000..a49f1a96 --- /dev/null +++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py @@ -0,0 +1,609 @@ +"""ЭТАП 4 B2C launch — retention -> physical deletion task (part B). + +Covers app/tasks/purge_expired_trade_in_data.py: + - batched DELETE (not a single unbounded DELETE), each batch its own commit + - stops draining a table once a batch returns fewer rows than batch_size + - safety cap (max_batches) bounds a single run even on a huge backlog + - both tables (trade_in_estimates, trade_in_leads) get drained + - failure path: rollback + mark_failed with partial counters, exception re-raised + - SQL shape: DELETE (not UPDATE/deactivate), no psycopg `::` cast trap + - Payments retention (PR #2754): retain_until IS NULL + NOT EXISTS(payments) + safeguards on the estimates DELETE, plus a pre-flight that refuses to run + at all if it finds an ANOMALOUS paid purge-candidate (retain_until unset + despite a payments row) -- a healthy paid row (retain_until set) must NOT + trip it, see test_preflight_ignores_healthy_paid_row below. + +Style mirrors tests/test_deactivate_stale_listings.py (_FakeDB, monkeypatched +runs_mod.mark_done/mark_failed). + +Payments retention note on _FakeDB: purge_expired_trade_in_data now issues ONE +extra db.execute() call BEFORE any DELETE batch — the pre-flight paid- +candidates count (_PREFLIGHT_PAID_CANDIDATES_SQL). _FakeDB special-cases that +statement by identity and answers it from `preflight_count` (default 0 == +"no anomalous candidates, proceed exactly as before this PR"). Every +pre-existing test's `db.executed` index shifted by +1 to account for this; +`db.commits` is unaffected (the pre-flight is a read, never committed). +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.tasks import purge_expired_trade_in_data as task_mod + +_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" +_MIGRATION_231 = _SQL_DIR / "231_trade_in_privacy_retention.sql" + + +class _FakeResult: + def __init__(self, rowcount: int) -> None: + self.rowcount = rowcount + + def scalar_one(self) -> int: + """Supports the PR-D1 pre-flight `SELECT count(*) ... .scalar_one()` call.""" + return self.rowcount + + +class _FakeDB: + """Pops rowcounts in call order -- caller supplies the exact sequence expected. + + PR-D1: the pre-flight paid-candidates count is answered separately, from + `preflight_count` (default 0), keyed by statement IDENTITY -- it never + consumes an entry off `rowcounts` (that list is DELETE-batch rowcounts only). + """ + + def __init__(self, rowcounts: list[int], *, preflight_count: int = 0) -> None: + self._rowcounts = list(rowcounts) + self.preflight_count = preflight_count + self.executed: list[tuple[Any, Any]] = [] + self.commits = 0 + self.rolled_back = False + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + self.executed.append((stmt, params)) + if stmt is task_mod._PREFLIGHT_PAID_CANDIDATES_SQL: + return _FakeResult(self.preflight_count) + return _FakeResult(self._rowcounts.pop(0)) + + def commit(self) -> None: + self.commits += 1 + + def rollback(self) -> None: + self.rolled_back = True + + +def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + marked: dict[str, Any] = {} + monkeypatch.setattr( + task_mod.runs_mod, + "mark_done", + lambda _db, run_id, counters: marked.update( + kind="done", run_id=run_id, counters=dict(counters) + ), + ) + monkeypatch.setattr( + task_mod.runs_mod, + "mark_failed", + lambda _db, run_id, err, counters: marked.update( + kind="failed", run_id=run_id, err=err, counters=dict(counters) + ), + ) + return marked + + +# ── batching behaviour ────────────────────────────────────────────────────── + + +def test_stops_when_batch_below_size(monkeypatch: pytest.MonkeyPatch) -> None: + marked = _patch_runs(monkeypatch) + # estimates: one batch of 3 (< batch_size=10) -> stop. leads: one batch of 0 -> stop. + db = _FakeDB([3, 0]) + out = task_mod.purge_expired_trade_in_data(db, run_id=1, batch_size=10, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 3, "leads_deleted": 0} + # +1 vs pre-PR-D1: db.executed now also holds the pre-flight paid-candidates + # count (call #1), issued before either DELETE batch. + assert len(db.executed) == 3 + assert db.commits == 2 + assert marked["counters"] == out + + +def test_loops_until_below_batch_size(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_runs(monkeypatch) + # estimates: 5,5,2 (batch_size=5) -> 12 total, 3 batches. leads: 5,1 -> 6 total, 2 batches. + db = _FakeDB([5, 5, 2, 5, 1]) + out = task_mod.purge_expired_trade_in_data(db, run_id=2, batch_size=5, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 12, "leads_deleted": 6} + assert len(db.executed) == 6 # +1: pre-flight call before the 5 DELETE batches + assert db.commits == 5, "each batch must commit independently, not one final commit" + + +def test_respects_max_batches_cap(monkeypatch: pytest.MonkeyPatch) -> None: + """Every batch returns a FULL batch_size (never 'caught up') -- only the + max_batches safety cap can stop the loop. Proves the cap is enforced, not + just coincidentally matching a 'caught up' condition.""" + _patch_runs(monkeypatch) + db = _FakeDB([5, 5, 5, 5, 5, 5]) # exactly max_batches=3 per table, no more + out = task_mod.purge_expired_trade_in_data(db, run_id=3, batch_size=5, max_batches=3) # type: ignore[arg-type] + assert out == {"estimates_deleted": 15, "leads_deleted": 15} + assert len(db.executed) == 7 # pre-flight + 3 (estimates) + 3 (leads), NOT unbounded + + +def test_default_batch_size_and_max_batches_from_settings(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) # first batch already empty on both tables -> stop immediately + task_mod.purge_expired_trade_in_data(db, run_id=4) # type: ignore[arg-type] + # db.executed[0] is now the pre-flight call (no batch_size param) -- the + # first DELETE-batch call (with batch_size) shifted to index 1. + _stmt, params = db.executed[1] + assert params is not None + assert params["batch_size"] == task_mod.settings.trade_in_purge_batch_size + + +# ── table coverage / SQL shape ────────────────────────────────────────────── + + +def test_drains_both_tables_in_order(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) + task_mod.purge_expired_trade_in_data(db, run_id=5, batch_size=100, max_batches=1) # type: ignore[arg-type] + # index 0 is now the pre-flight call; DELETE batches shifted to 1/2. + first_sql = str(getattr(db.executed[1][0], "text", db.executed[1][0])) + second_sql = str(getattr(db.executed[2][0], "text", db.executed[2][0])) + assert "trade_in_estimates" in first_sql + assert "trade_in_leads" in second_sql + + +def test_estimates_sql_is_delete_not_update() -> None: + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "DELETE FROM trade_in_estimates" in sql + assert "UPDATE" not in sql.upper() + assert "expires_at < NOW()" in sql + assert "ORDER BY expires_at" in sql + assert "LIMIT CAST(:batch_size AS int)" in sql + assert not re.search(r":\w+::", sql) + + +# ── Payments retention (PR #2754): two independent purge safeguards ───────── + + +def test_estimates_sql_excludes_retain_until_not_null() -> None: + """Phase 1: exactly `retain_until IS NULL`, never `< NOW()` -- a paid row + (retain_until IS NOT NULL) must never match the DELETE predicate, full stop, + regardless of how far in the past that date eventually sits.""" + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "retain_until IS NULL" in sql + assert "retain_until <" not in sql, "phase 1 must not weaken to retain_until < NOW()" + + +def test_estimates_sql_has_not_exists_payments_safeguard() -> None: + """Independent safeguard: a row with ANY payments row survives even if + retain_until failed to be set (fulfillment bug/race/manual INSERT).""" + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "NOT EXISTS" in sql + assert "FROM payments p" in sql + assert "p.estimate_id = trade_in_estimates.id" in sql + + +def test_preflight_sql_requires_retain_until_is_null() -> None: + """Deep-review finding 2026-08-06 MEDIUM (PR #2754): the pre-flight predicate + MUST carry `retain_until IS NULL` -- without it, a perfectly healthy paid row + (retain_until set, has a payments row -- the ORDINARY steady state one day + after every sale) trips the alarm exactly as hard as the real anomaly + (retain_until unset despite a payments row existing), permanently wedging + the job on the very first successful sale (and, since leads purge runs from + the same function AFTER this check, silently stopping 180-day 152-ФЗ lead + retention too). See test_real_preflight_ignores_healthy_paid_row below for + the behavioural proof against a real DB.""" + sql = task_mod._PREFLIGHT_PAID_CANDIDATES_SQL.text + assert "expires_at < NOW()" in sql + assert "created_by IS NULL" in sql + assert "retain_until IS NULL" in sql + assert "EXISTS (SELECT 1 FROM payments p WHERE p.estimate_id = e.id)" in sql + assert not re.search(r":\w+::", sql) + + +def test_preflight_aborts_before_any_delete_batch(monkeypatch: pytest.MonkeyPatch) -> None: + """Non-zero pre-flight count -> RuntimeError, mark_failed, ZERO DELETE batches + ever issued (only the pre-flight SELECT itself is in db.executed).""" + marked = _patch_runs(monkeypatch) + db = _FakeDB([], preflight_count=2) # rowcounts empty on purpose: must never be popped + with pytest.raises(RuntimeError, match="pre-flight abort"): + task_mod.purge_expired_trade_in_data(db, run_id=42, batch_size=10, max_batches=20) # type: ignore[arg-type] + + assert len(db.executed) == 1, "only the pre-flight SELECT -- no DELETE batch was issued" + assert db.commits == 0 + assert marked["kind"] == "failed" + assert marked["run_id"] == 42 + assert marked["counters"] == {"estimates_deleted": 0, "leads_deleted": 0} + assert "2" in marked["err"] + + +def test_preflight_zero_candidates_proceeds_as_before(monkeypatch: pytest.MonkeyPatch) -> None: + """preflight_count=0 (default) -- the exact pre-PR-D1 behaviour for every + row that exists today (all retain_until IS NULL) -- run proceeds normally.""" + marked = _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) # preflight_count defaults to 0 + out = task_mod.purge_expired_trade_in_data(db, run_id=43, batch_size=10, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 0, "leads_deleted": 0} + assert marked["kind"] == "done" + + +def test_leads_sql_unchanged_by_pr_d1() -> None: + """Snapshot: _DELETE_EXPIRED_LEADS_SQL byte-for-byte unchanged by payments + retention (PR #2754) — leads have their own retention deadline (migration + 231, no created_by/B2B split, no payments concept) and are explicitly out + of scope for the payments-retention safeguards.""" + expected = ( + "\n DELETE FROM trade_in_leads\n WHERE id IN (\n" + " SELECT id FROM trade_in_leads\n" + " WHERE expires_at < NOW()\n" + " ORDER BY expires_at\n" + " LIMIT CAST(:batch_size AS int)\n )\n " + ) + assert task_mod._DELETE_EXPIRED_LEADS_SQL.text == expected + + +def test_leads_sql_is_delete_not_update() -> None: + sql = task_mod._DELETE_EXPIRED_LEADS_SQL.text + assert "DELETE FROM trade_in_leads" in sql + assert "UPDATE" not in sql.upper() + assert "expires_at < NOW()" in sql + assert "ORDER BY expires_at" in sql + assert not re.search(r":\w+::", sql) + + +def test_estimates_sql_scopes_delete_to_anonymous_created_by_null() -> None: + """Regression guard for the deep-review HIGH finding (2026-08-06): + trade_in_estimates.expires_at is set UNCONDITIONALLY on every estimate + (B2B pilot or anonymous) as a TTL on the estimate LINK/PDF staying + resolvable, NOT a declared row-retention deadline -- see the task's + module docstring. Without this guard, prod audit showed 1040/1057 rows + past expires_at, 911 of them belonging to named pilots (admin, kopylov, + brusnika, praktika, pilottest, admintest, user1); one unattended run at + the seeded defaults (batch_size=500, max_batches=20) would have deleted + essentially the whole table, including pilots' own operational history + (/trade-in/history, /team/employees/{id}/history, cache-stats all read + trade_in_estimates without an expires_at filter). The DELETE must stay + scoped to created_by IS NULL -- the honest B2C population (129 rows in + that same audit).""" + sql = task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text + assert "created_by IS NULL" in sql + assert not re.search(r":\w+::", sql) + + +def test_leads_sql_has_no_created_by_guard() -> None: + """trade_in_leads has NO created_by column at all (never had a B2B/B2C + split) -- its expires_at IS a genuine 180-day retention deadline (see + migration 231), not a link/PDF-access TTL like trade_in_estimates. This + documents the asymmetry explicitly so a future 'fix' doesn't bolt a + created_by filter onto a table that doesn't have the column.""" + sql = task_mod._DELETE_EXPIRED_LEADS_SQL.text + assert "created_by" not in sql + + +def test_sql_does_not_delete_whole_table_unbounded() -> None: + """Neither statement is a bare `DELETE FROM table` -- both scope via a + subselect + LIMIT batch.""" + statements = ( + task_mod._DELETE_EXPIRED_ESTIMATES_SQL.text, + task_mod._DELETE_EXPIRED_LEADS_SQL.text, + ) + for sql in statements: + assert "WHERE id IN (" in sql + assert "LIMIT" in sql + + +# ── failure path ───────────────────────────────────────────────────────────── + + +def test_failure_path_rollback_and_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None: + marked = _patch_runs(monkeypatch) + + class _BoomDB(_FakeDB): + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: + # Call #1 (pre-flight, preflight_count=0) and call #2 (first estimates + # batch) succeed and commit; call #3 (still draining estimates, or + # first leads call) explodes. +1 vs pre-PR-D1 to admit the pre-flight. + if len(self.executed) >= 2: + raise RuntimeError("db exploded") + return super().execute(stmt, params) + + db = _BoomDB([5]) # only ONE successful DELETE batch before the boom + with pytest.raises(RuntimeError, match="db exploded"): + task_mod.purge_expired_trade_in_data(db, run_id=6, batch_size=5, max_batches=20) # type: ignore[arg-type] + + assert db.rolled_back is True + assert marked["kind"] == "failed" + assert marked["run_id"] == 6 + # Partial progress preserved in the counters passed to mark_failed (first + # estimates batch of 5 already committed before the boom on the 2nd call). + assert marked["counters"]["estimates_deleted"] == 5 + assert marked["counters"]["leads_deleted"] == 0 + + +def test_idempotent_zero_rowcount_is_not_an_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Re-running against an already-drained backlog just deletes 0 rows, cleanly.""" + marked = _patch_runs(monkeypatch) + db = _FakeDB([0, 0]) + out = task_mod.purge_expired_trade_in_data(db, run_id=7, batch_size=500, max_batches=20) # type: ignore[arg-type] + assert out == {"estimates_deleted": 0, "leads_deleted": 0} + assert marked["kind"] == "done" + + +# ── migration 231 ──────────────────────────────────────────────────────────── + + +def test_migration_231_exists() -> None: + assert _MIGRATION_231.is_file(), f"missing migration: {_MIGRATION_231}" + + +def test_migration_231_is_transactional() -> None: + sql = _MIGRATION_231.read_text("utf-8") + assert "BEGIN;" in sql + assert "COMMIT;" in sql + + +def test_migration_231_backfills_and_sets_not_null() -> None: + sql = _MIGRATION_231.read_text("utf-8") + assert "ADD COLUMN IF NOT EXISTS expires_at" in sql + assert "WHERE expires_at IS NULL" in sql + assert "SET NOT NULL" in sql + assert "180 days" in sql + + +def test_migration_231_seeds_purge_schedule_disabled_by_default() -> None: + sql = _MIGRATION_231.read_text("utf-8") + assert "'purge_expired_trade_in_data'" in sql + assert "ON CONFLICT (source) DO NOTHING" in sql + # Seeded disabled -- first automated PII-DELETE job in trade-in deserves a + # supervised first run before the scheduler can trigger it unattended. + assert re.search(r"'purge_expired_trade_in_data',\s*\n\s*false,", sql) + + +def test_migration_231_no_psycopg_trap() -> None: + sql = _MIGRATION_231.read_text("utf-8") + assert not re.search(r":\w+::", sql) + + +# ── Optional real-Postgres behavioural test (self-skips without a DB) ────────── +# Same pattern as tests/test_house_dedup_merge.py::_live_session -- CI runs the +# mock-only lane (DATABASE_URL is a placeholder), so this self-skips there; it +# only executes with a real reachable Postgres (e.g. local dev DB). + + +def _live_session() -> Any | None: + """Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None.""" + try: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "") + if not dsn or "localhost:5432/test" in dsn: + return None + engine = create_engine(dsn, future=True) + conn = engine.connect() + from sqlalchemy import text as _t + + conn.execute(_t("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_purge_deletes_only_anonymous_expired_estimates() -> None: + """End-to-end on a real DB: one expired B2B row (created_by set) and one + expired anonymous row (created_by IS NULL) both exist -- a real run of + purge_expired_trade_in_data must delete ONLY the anonymous one. This is + the exact scenario the deep-review HIGH finding (2026-08-06) flagged: + without the created_by IS NULL guard, the pilot row would also be gone.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + anon_id = uuid4() + pilot_id = uuid4() + try: + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by) VALUES " + "(CAST(:anon_id AS uuid), 'purge-test аноним', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL), " + "(CAST(:pilot_id AS uuid), 'purge-test пилот', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', 'pytest_purge_guard')" + ), + {"anon_id": str(anon_id), "pilot_id": str(pilot_id)}, + ) + db.commit() + + task_mod.purge_expired_trade_in_data(db, run_id=999999999, batch_size=100, max_batches=5) + + remaining_ids = { + str(r) + for r in db.execute( + _t("SELECT id FROM trade_in_estimates " "WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": [str(anon_id), str(pilot_id)]}, + ) + .scalars() + .all() + } + assert str(anon_id) not in remaining_ids, "anonymous expired row must be purged" + assert str(pilot_id) in remaining_ids, "B2B pilot row must survive despite expires_at" + finally: + db.execute( + _t("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": [str(anon_id), str(pilot_id)]}, + ) + db.commit() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_preflight_ignores_healthy_paid_row_flags_only_anomaly() -> None: + """Deep-review finding 2026-08-06 MEDIUM on PR #2754, reproduced exactly + against a real DB: a HEALTHY paid row (retain_until set, a payments row + exists) is the ordinary steady state one day after every sale and must NOT + raise the pre-flight count; a row where fulfillment failed to set + retain_until despite a payments row existing is the real ANOMALY and must. + Baseline-delta assertions (not absolute counts) so this is safe to run + against a dev DB that may already contain unrelated rows.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + healthy_id = uuid4() + anomaly_id = uuid4() + healthy_order = f"pytest-healthy-{uuid4().hex[:12]}" + anomaly_order = f"pytest-anomaly-{uuid4().hex[:12]}" + try: + baseline = task_mod._preflight_paid_candidates(db) + + # Healthy: retain_until set (paid, safeguard 1 already protects it) + + # a payments row -- exactly what every successful sale looks like a day + # later. Must NOT move the pre-flight count. + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test здоровая оплаченная', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NOW() + interval '363 days')" + ), + {"id": str(healthy_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": healthy_order, "id": str(healthy_id)}, + ) + db.commit() + assert ( + task_mod._preflight_paid_candidates(db) == baseline + ), "healthy paid row (retain_until set) must NOT raise the pre-flight count" + + # Anomaly: retain_until NULL despite a payments row existing -- exactly + # the case the two DELETE safeguards exist for. Must raise by exactly one. + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test настоящая аномалия', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NULL)" + ), + {"id": str(anomaly_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": anomaly_order, "id": str(anomaly_id)}, + ) + db.commit() + assert task_mod._preflight_paid_candidates(db) == baseline + 1, ( + "anomaly row (retain_until unset, payments row exists) must raise " + "the pre-flight count by exactly one" + ) + finally: + db.execute( + _t("DELETE FROM payments WHERE order_id = ANY(CAST(:orders AS text[]))"), + {"orders": [healthy_order, anomaly_order]}, + ) + db.execute( + _t("DELETE FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), + {"ids": [str(healthy_id), str(anomaly_id)]}, + ) + db.commit() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_purge_not_wedged_by_healthy_paid_row() -> None: + """Deep-review finding 2026-08-06 MEDIUM on PR #2754: before the fix, a + healthy paid row anywhere in the table (retain_until set, has a payments + row) permanently wedged the job -- the very first successful sale would + have made every subsequent scheduled run abort in mark_failed with zero + deletions FOREVER, silently taking 180-day leads purge (152-ФЗ) down with + it (leads purge runs from the same function AFTER the pre-flight check). + This proves a real end-to-end run completes normally (mark_done) in the + presence of such a row.""" + from sqlalchemy import text as _t + + db = _live_session() + assert db is not None + healthy_id = uuid4() + healthy_order = f"pytest-wedge-{uuid4().hex[:12]}" + try: + db.execute( + _t( + "INSERT INTO trade_in_estimates " + "(id, address, area_m2, rooms, floor, total_floors, " + " median_price, range_low, range_high, median_price_per_m2, confidence, " + " expires_at, created_by, retain_until) VALUES " + "(CAST(:id AS uuid), 'purge-test не блокирует джобу', 40, 1, 2, 5, " + " 5000000, 4500000, 5500000, 125000, 'low', " + " NOW() - interval '1 hour', NULL, NOW() + interval '363 days')" + ), + {"id": str(healthy_id)}, + ) + db.execute( + _t( + "INSERT INTO payments " + "(order_id, terminal_key, product_code, amount_kopecks, status, estimate_id) " + "VALUES (:order_id, 'pytest_terminal', 'trade_in_report', 15000, 'CONFIRMED', " + " CAST(:id AS uuid))" + ), + {"order_id": healthy_order, "id": str(healthy_id)}, + ) + db.commit() + + # Must complete normally -- no RuntimeError, no mark_failed short-circuit + # (would raise before reaching this line if the bug were still present). + result = task_mod.purge_expired_trade_in_data( + db, run_id=999999998, batch_size=100, max_batches=1 + ) + assert set(result) == {"estimates_deleted", "leads_deleted"}, ( + "leads purge must also have run -- it is NOT reachable when the " + "pre-flight wrongly aborts first" + ) + + still_there = db.execute( + _t("SELECT id FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(healthy_id)}, + ).fetchone() + assert still_there is not None, "healthy paid row must survive the run untouched" + finally: + db.execute( + _t("DELETE FROM payments WHERE order_id = :order_id"), {"order_id": healthy_order} + ) + db.execute( + _t("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"), + {"id": str(healthy_id)}, + ) + db.commit() + db.close() diff --git a/tradein-mvp/backend/tests/test_ratelimit.py b/tradein-mvp/backend/tests/test_ratelimit.py index 4d1cd8dc..b9b71c1a 100644 --- a/tradein-mvp/backend/tests/test_ratelimit.py +++ b/tradein-mvp/backend/tests/test_ratelimit.py @@ -149,6 +149,98 @@ def test_sliding_window_limiter_per_key_isolation(): assert limiter.retry_after("bob") is None # свой ключ — не задет alice +# ── Платёжная нотификация — мимо ОБЩЕГО лимитера (PR-D2, критерий приёмки #3) ── + + +@pytest.fixture +def notify_client(monkeypatch): + """То же минимальное приложение, что `client`, но лимит анонима искусственно + крошечный (1/60с) — если бы notify-путь шёл через общий лимитер, 2-й запрос + уже получил бы 429. Плюс контрольный `/api/v1/ping` — доказывает, что + лимитер в принципе активен (не выключен целиком), просто notify мимо него.""" + monkeypatch.setattr(config.settings, "rate_limit", 1) + monkeypatch.setattr(config.settings, "rate_limit_window_s", 60.0) + monkeypatch.setattr(config.settings, "rate_limit_authenticated_multiplier", 2) + + app = FastAPI() + app.add_middleware(RateLimitMiddleware) + + @app.get("/api/v1/ping") + def ping() -> dict[str, bool]: + return {"ok": True} + + @app.post("/api/v1/trade-in/payments/notify") + def notify() -> dict[str, bool]: + return {"ok": True} + + return TestClient(app) + + +def test_notify_path_bypasses_general_limiter_400_requests_zero_429(notify_client): + """PR-D2 acceptance criteria: 400 запросов к notify с одного адреса за минуту + не дают ни одного отказа по частоте — даже с общим лимитом искусственно + зажатым до 1/60с (см. фикстуру).""" + statuses = [ + notify_client.post("/api/v1/trade-in/payments/notify").status_code for _ in range(400) + ] + assert all( + code == 200 for code in statuses + ), f"notify получил 429 хотя бы раз: {[c for c in statuses if c != 200]}" + + +def test_general_limiter_still_active_for_other_paths(notify_client): + """Контроль: общий лимитер НЕ выключен целиком — обычный /api/v1/ping с тем + же крошечным лимитом (1/60с) отбивается на 2-м запросе как обычно. Доказывает, + что notify-bypass узкий (точный путь), а не побочный эффект общей поломки.""" + assert notify_client.get("/api/v1/ping").status_code == 200 + assert notify_client.get("/api/v1/ping").status_code == 429 + + +def test_notify_bypass_has_own_dedicated_limiter_not_unlimited(): + """notify НЕ отключён от лимитера вовсе — своя щедрая, но конечная корзина + (`_notify_limiter`, идиома `SlidingWindowLimiter` из `support.py`). Проверяем + напрямую: исчерпать маленький искусственный лимит и убедиться, что backstop + таки срабатывает (защита от полного disable вместо узкого бюджета).""" + from app.core import ratelimit as ratelimit_module + + limiter = ratelimit_module.SlidingWindowLimiter(limit=2, window_s=60.0) + assert limiter.check("1.2.3.4") is None + assert limiter.check("1.2.3.4") is None + # 3-й запрос того же ключа — уже за лимитом (backstop жив). + assert limiter.check("1.2.3.4") is not None + + +def test_notify_limiter_key_is_per_ip_not_global(monkeypatch): + """Бюджет notify — per-IP (не общий на все входящие сразу), тот же принцип + ключа, что общий лимитер использует для анонимного трафика.""" + monkeypatch.setattr(config.settings, "rate_limit", 300) + monkeypatch.setattr(config.settings, "rate_limit_window_s", 60.0) + + from app.core import ratelimit as ratelimit_module + + monkeypatch.setattr( + ratelimit_module, + "_notify_limiter", + ratelimit_module.SlidingWindowLimiter(limit=1, window_s=60.0), + ) + + app = FastAPI() + app.add_middleware(RateLimitMiddleware) + + @app.post("/api/v1/trade-in/payments/notify") + def notify() -> dict[str, bool]: + return {"ok": True} + + client = TestClient(app) + # Первый запрос с IP #1 — проходит, второй с тем же IP — 429 (лимит=1). + headers_ip1 = {"X-Forwarded-For": "1.1.1.1"} + headers_ip2 = {"X-Forwarded-For": "2.2.2.2"} + assert client.post("/api/v1/trade-in/payments/notify", headers=headers_ip1).status_code == 200 + assert client.post("/api/v1/trade-in/payments/notify", headers=headers_ip1).status_code == 429 + # Другой IP — своя, независимая корзина. + assert client.post("/api/v1/trade-in/payments/notify", headers=headers_ip2).status_code == 200 + + def test_sliding_window_limiter_prunes_empty_buckets_past_threshold(): """review L2: пустые корзины чистятся при накоплении >10000 ключей (тот же паттерн, что `RateLimitMiddleware.dispatch`) — не бесконечная утечка памяти. 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_request_audit.py b/tradein-mvp/backend/tests/test_request_audit.py index 06bbd2d5..1e3f04c8 100644 --- a/tradein-mvp/backend/tests/test_request_audit.py +++ b/tradein-mvp/backend/tests/test_request_audit.py @@ -262,6 +262,37 @@ def test_login_event_type_when_request_succeeds(client: TestClient) -> None: assert login_calls[0].kwargs["payload"] == {"status_code": 200} +# ── Платёжная нотификация — вне аудита (PR-D2, критерий приёмки #4) ───────── + + +def test_payments_notify_path_skips_audit_even_with_spoofed_admin_header() -> None: + """PR-D2 acceptance criteria: запрос к нотификации не создаёт записей в + журнале аудита — даже с заголовком `X-Authenticated-User: admin`. Middleware + — ВНЕШНИЙ относительно rbac_guard и читает сырой заголовок напрямую (см. + docstring `request_audit.py`), так что анонимный POST со спуфнутым + заголовком иначе писал бы фальшивое `login`/`api_request` событие с + атрибуцией admin, хотя rbac этот путь пока (до PR-D3) закрывает 401'ом + отдельно и независимо от этого middleware.""" + app = FastAPI() + app.add_middleware(RequestAuditMiddleware) + + @app.post("/api/v1/trade-in/payments/notify") + def notify() -> dict[str, bool]: + return {"ok": True} + + with ( + patch("app.core.request_audit.schedule_event") as mock_schedule, + patch("app.core.request_audit.should_log_login", return_value=True), + ): + resp = TestClient(app).post( + "/api/v1/trade-in/payments/notify", + headers={"X-Authenticated-User": "admin"}, + ) + + assert resp.status_code == 200 + mock_schedule.assert_not_called() + + def test_login_failed_event_type_when_rbac_rejects_request() -> None: """Ответ >= 400 (напр. RBAC-отказ downstream: неизвестная роль / протухший внутренний секрет) -> event_type='login_failed', а НЕ 'login' — раньше эти diff --git a/tradein-mvp/backend/tests/test_sales_vs_listings.py b/tradein-mvp/backend/tests/test_sales_vs_listings.py index 39472da2..e0af347b 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,14 +220,22 @@ 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: сводная медиана по одной паре гасится гейтом правдоподобия. + 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 assert pair["listing_id"] == 2001 assert pair["listing_source"] == "avito" assert pair["days_listing_to_deal"] == 46 - assert pair["discount_pct"] == -5.77 + # #2672 п.3 (было: «пара — наблюдаемый факт, отдаём как есть»): эта пара стоит + # на ОДНОМ объявлении, поэтому её процент тоже гаснет. Обе цены — сделки и + # объявления — остаются: убирается подпись «торг», а не данные. + assert pair["discount_pct"] is None + assert pair["deal_price_rub"] == 4_900_000 + assert pair["listing_price_rub"] == 5_200_000 # ── Test: LEFT JOIN — deal без listing match ───────────────────────────────── @@ -251,8 +281,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 +296,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 +328,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 +369,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 +451,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 +541,271 @@ 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_row_discounts_gated_when_pairs_stand_on_one_listing(trade_in_app: FastAPI) -> None: + """#2672 п.3: погашена медиана — гаснут и проценты в строках таблицы. + + 50 пар на ОДНОМ объявлении: столбец «разница» здесь — столбец цены сделки, + поделённый на одну и ту же константу, т.е. ни одного наблюдения сверх уже + показанных цен. Старый код отдавал 50 процентов под погашенным сводным + числом. Цены сделки и объявления остаются — убирается подпись «торг». + """ + data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.36] * 50, n_listings=1)) + assert data["median_discount_pct"] is None + assert all(p["discount_pct"] is None for p in data["pairs"]) + assert all(p["listing_price_rub"] is not None for p in data["pairs"]) + # Микрокопия обязана назвать причину строк, а не только медианы. + assert "по каждой сделке тоже не показываем" in data["median_discount_explanation"] + assert "одной и той же цены" in data["median_discount_explanation"] + + +def test_row_discounts_gated_when_median_implausible(trade_in_app: FastAPI) -> None: + """#2672 п.3, живой кейс: под погашенной медианой Космонавтова видны +76%. + + 7 пар по −37.57% и 4 по +76% на двух объявлениях: порог по объявлениям не + срабатывает (2 ≥ 2), медиана −37.6% вне диапазона. По определению медианы + это утверждение о СТРОКАХ — половина из них ещё дальше от рынка. + """ + rows = _rows_on_shared_listings([-37.57] * 7 + [76.0] * 4, n_listings=2) + data = _get_sales(trade_in_app, rows) + assert data["median_discount_pct"] is None + assert all(p["discount_pct"] is None for p in data["pairs"]) + assert "половина из них" in data["median_discount_explanation"] + # Значение медианы в объяснении строк НЕ повторяется: в этой ветке оно уже + # названо предложением выше, а в ветке «мало пар» его намеренно не + # показывают — там оно стало бы отказом на словах. + assert data["median_discount_explanation"].count("−37.6%") == 1 + + +def test_row_discounts_kept_when_gate_fired_only_on_sample_size(trade_in_app: FastAPI) -> None: + """«Мало пар» гасит медиану, но НЕ строки — это свойство выборки, не пары. + + 4 пары на 4 объявлениях с рыночной медианой: про отдельную пару «пар всего + 4» не говорит ничего, и микрокопия «поэтому процент в строке не показываем» + была бы ложной причиной. На проде это 3 группы / 20 строк. + """ + data = _get_sales(trade_in_app, _rows_with_discounts([-8.0, -9.0, -10.0, -11.0])) + assert data["median_discount_pct"] is None + assert "пар «сделка ↔ объявление» всего 4" in data["median_discount_explanation"] + assert "по каждой сделке тоже не показываем" not in data["median_discount_explanation"] + assert [p["discount_pct"] for p in data["pairs"]] == [-8.0, -9.0, -10.0, -11.0] + + +def test_row_discounts_kept_when_median_shown(trade_in_app: FastAPI) -> None: + """Обратное направление: медиана прошла гейт — строки не трогаем.""" + data = _get_sales(trade_in_app, _rows_on_shared_listings([-12.98] * 117, n_listings=2)) + assert data["median_discount_pct"] == -12.98 + assert all(p["discount_pct"] == -12.98 for p in data["pairs"]) + + +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 остаются. + + #2672 п.3 сузил это правило: 11 пар с медианой +36.4% вне диапазона теряют и + поштучный процент (утверждение о медиане здесь же и утверждение о строках — + половина из них ещё дальше от рынка). Строка при этом остаётся полной: цена + сделки, цена объявления, срок и ссылка на месте. + """ + 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"] is None for p in data["pairs"]) + assert all(p["listing_price_rub"] is not None for p in data["pairs"]) + assert all(p["listing_source_url"] 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..fafe6f41 100644 --- a/tradein-mvp/backend/tests/test_sber_freshness_monitor.py +++ b/tradein-mvp/backend/tests/test_sber_freshness_monitor.py @@ -1,13 +1,20 @@ -"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1. +"""Монитор СберИндекса: тревога про ОТСТАВАНИЕ ЗАГРУЗКИ, а не про календарь (#2846). Покрывает: - 1. Чистую логику evaluate_sber_freshness (frozen now, без БД): - - fresh: age <= max_age_days (алерта нет); - - stale: age > max_age_days (алерт); - - граница порога (== max_age_days → нет алерта; +1 день → алерт). - 2. check_sber_freshness с FakeDB (fresh / stale / empty→mark_failed / кастомный lag). - 3. Свойства миграции 180 (по образцу test_deals_freshness_monitor). - 4. Регистрацию в kit product_handlers (registry остаётся зелёным). + 1. Чистую логику evaluate_sber_freshness (frozen now, без БД): загрузка в такте / + загрузка встала / полных прогонов не было вовсе / граница порога. + 2. check_sber_freshness с FakeDB — прод-реплей 2026-08-12 и двусторонность: + при ОДНОМ И ТОМ ЖЕ возрасте периода вердикт меняется вслед за загрузкой. + 3. Выбор табло тем же порядком, что у оценщика (max() по таблице маскировал бы + отставшее табло). + 4. Свойства миграций 180/212 + регистрацию в kit product_handlers. + +Прод-числа (read-only, 2026-08-12, scrape_runs/scrape_schedules/sber_price_index): + latest период табло оценщика real_estate_deals = 2026-06-01 (возраст 72 суток), + dinamika-tsen-obyavlenii = 2026-05-01 (103); + последний ПОЛНЫЙ прогон загрузки = 2026-08-06 (errors=0, upserted=639); + такт загрузки interval_days = 7; + прогон id=37 (05-31) — status='done' при {errors: 9, upserted: 0}, за успех НЕ считается. """ from __future__ import annotations @@ -22,76 +29,159 @@ import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") -from app.core.config import settings from app.services.product_handlers import build_product_handlers 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) - - -# ── evaluate_sber_freshness (чистая логика, frozen now) ─────────────────────── +# Прод-состояние 2026-08-12. +_JUN_2026 = date(2026, 6, 1) # latest табло real_estate_deals — возраст 72 суток +_MAY_2026 = date(2026, 5, 1) # latest табло dinamika-tsen-obyavlenii — возраст 103 +_LAST_FULL_PULL = datetime(2026, 8, 6, 5, 0, tzinfo=UTC) # errors=0, upserted=639 +_PULL_INTERVAL = 7 # scrape_schedules.default_params.interval_days +_PROD_NOW = datetime(2026, 8, 12, 19, 6, tzinfo=UTC) # момент прод-замера def _now(y: int, m: int, d: int) -> datetime: return datetime(y, m, d, tzinfo=UTC) -def test_fresh_within_max_age() -> None: - """age=30 ≤ max_age_days=60 — свежий, алерта нет.""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60) - assert v.stale is False - assert v.age_days == 30 - assert v.latest_period == _MAY_2026 +# ── evaluate_sber_freshness (чистая логика, frozen now) ─────────────────────── -def test_stale_beyond_max_age() -> None: - """Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт.""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60) - assert v.stale is True +def test_loader_in_cadence_no_alert_even_at_age_72() -> None: + """Прод 2026-08-12: возраст 72, но полный прогон 6 суток назад → молчим. + + После полного прогона наш max(period_month) равен максимуму источника ПО + ПОСТРОЕНИЮ (загрузчик тянет всю серию), значит 72 суток — лаг ПУБЛИКАЦИИ Сбера, + а не наше отставание. + """ + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=_LAST_FULL_PULL, + pull_interval_days=_PULL_INTERVAL, + ) assert v.age_days == 72 + assert v.pull_lag_days == 6 + assert v.stale is False + + +def test_loader_stalled_alerts_at_the_same_age() -> None: + """Тот же возраст периода, но полный прогон 20 суток назад → тревога. + + 20 суток — реальный разрыв прод-истории (07-17 → 08-06) при пороге 2×7=14. + """ + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=_now(2026, 7, 23), + pull_interval_days=_PULL_INTERVAL, + ) + assert v.age_days == 72 # возраст ТОТ ЖЕ, что в тесте выше + assert v.pull_lag_days == 20 + assert v.max_pull_lag_days == 14 + assert v.stale is True + + +def test_no_complete_pull_ever_alerts() -> None: + """Загрузчик умер совсем / не отработал ни разу успешно → тревога, не тишина.""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=None, + pull_interval_days=_PULL_INTERVAL, + ) + assert v.stale is True + assert v.pull_lag_days == -1 def test_threshold_boundary_exact_no_alert() -> None: - """Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >).""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60) - assert v.age_days == 60 + """Ровно на пороге (2 такта) алерта ещё нет — строгое >.""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL), + last_complete_pull_at=_LAST_FULL_PULL, + pull_interval_days=_PULL_INTERVAL, + ) + assert v.pull_lag_days == 14 assert v.stale is False -def test_threshold_boundary_next_day_alert() -> None: - """Порог + 1 день (age=61) — первый алерт.""" - v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60) - assert v.age_days == 61 +def test_threshold_boundary_next_day_alerts() -> None: + """Порог + 1 сутки — первый алерт (два такта подряд пропущены).""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _LAST_FULL_PULL + timedelta(days=mon.MISSED_PULL_CYCLES * _PULL_INTERVAL + 1), + last_complete_pull_at=_LAST_FULL_PULL, + pull_interval_days=_PULL_INTERVAL, + ) + assert v.pull_lag_days == 15 assert v.stale is True +def test_threshold_follows_pull_cadence() -> None: + """Порог — производная такта загрузки, а не константа: такт 28 → порог 56.""" + v = mon.evaluate_sber_freshness( + _JUN_2026, + _PROD_NOW, + last_complete_pull_at=_now(2026, 7, 23), # 20 суток + pull_interval_days=28, + ) + assert v.max_pull_lag_days == 56 + assert v.stale is False # при месячном такте 20 суток — норма + + # ── check_sber_freshness (FakeDB) ───────────────────────────────────────────── class _Row: - def __init__(self, latest: date | None) -> None: - self.latest = latest + def __init__(self, **kw: Any) -> None: + self.__dict__.update(kw) class _FakeResult: - def __init__(self, latest: date | None) -> None: - self._latest = latest + def __init__(self, row: _Row | None) -> None: + self._row = row - def first(self) -> _Row: - return _Row(self._latest) + def first(self) -> _Row | None: + return self._row class _FakeDB: - def __init__(self, latest: date | None) -> None: - self._latest = latest + """Отвечает на три запроса монитора; пригоден и для старой версии кода. + + Старый монитор спрашивал max(period_month) БЕЗ dashboard-фильтра — на такой + запрос отдаём максимум по всем табло, ровно как это делал бы Postgres. + """ + + def __init__( + self, + latest_by_dash: dict[str, date], + last_pull: datetime | None = _LAST_FULL_PULL, + interval_days: str | None = str(_PULL_INTERVAL), + ) -> None: + self._latest_by_dash = latest_by_dash + self._last_pull = last_pull + self._interval_days = interval_days self.rolled_back = False + self.asked_dashboards: list[str] = [] def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: - return _FakeResult(self._latest) + sql = str(stmt) + params = params or {} + if "scrape_runs" in sql: + return _FakeResult(_Row(last_pull=self._last_pull)) + if "scrape_schedules" in sql: + return _FakeResult(_Row(interval_days=self._interval_days)) + if "dash" in params: + self.asked_dashboards.append(params["dash"]) + return _FakeResult(_Row(latest=self._latest_by_dash.get(params["dash"]))) + # Старый монитор: max(period_month) по всей таблице. + latest = max(self._latest_by_dash.values()) if self._latest_by_dash else None + return _FakeResult(_Row(latest=latest)) def rollback(self) -> None: self.rolled_back = True @@ -117,60 +207,138 @@ def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: return calls -def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None: - calls = _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - # @2026-05-31: age=30 ≤ 35+25=60 → нет алерта. - out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type] - assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0} - assert calls["done"] == out - assert calls["failed"] is None +def _prod_db(last_pull: datetime | None = _LAST_FULL_PULL) -> _FakeDB: + """Прод-состояние 2026-08-12 (оба табло вторички).""" + return _FakeDB( + {"real_estate_deals": _JUN_2026, "dinamika-tsen-obyavlenii": _MAY_2026}, + last_pull=last_pull, + ) -def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None: +def test_prod_replay_healthy_loader_silent_source_no_alert( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """КРАСНЫЙ НА main. Прод 2026-08-12: загрузка исправна, источник молчит → тишина. + + На main монитор мерил календарь (72 > 60) и писал ERROR — двенадцатые сутки + подряд, при полном прогоне загрузки 08-06. Тревога описывала лаг публикации + Сбера, а не наш дефект, и на настоящий отказ загрузчика выглядела бы так же. + """ calls = _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - # @2026-07-12 (прод): age=72 > 60 → алерт. - out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type] - assert out["alert"] == 1 + db = _prod_db() + out = mon.check_sber_freshness(db, run_id=1, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert out["age_days"] == 72 - assert out["latest_month"] == 5 - # Монитор НЕ падает при алерте — прогон done, а не failed. + assert out["alert"] == 0 assert calls["done"] == out assert calls["failed"] is None -def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: +def test_alert_tracks_loader_not_calendar(monkeypatch: pytest.MonkeyPatch) -> None: + """Двусторонность: возраст периода одинаков, вердикт идёт за загрузкой. + + На main оба состояния дают alert=1 (вердикт зависит только от календаря) — + сторож не умеет зеленеть, что и было исходным дефектом. + """ + _patch_runs(monkeypatch) + healthy = mon.check_sber_freshness( + _prod_db(last_pull=_LAST_FULL_PULL), # type: ignore[arg-type] + run_id=2, + params={}, + now=_PROD_NOW, + ) + stalled = mon.check_sber_freshness( + _prod_db(last_pull=_now(2026, 7, 23)), # 20 суток назад > 14 # type: ignore[arg-type] + run_id=3, + params={}, + now=_PROD_NOW, + ) + assert healthy["age_days"] == stalled["age_days"] == 72 + assert (healthy["alert"], stalled["alert"]) == (0, 1) + + +def test_dead_loader_never_pulled_marks_alert(monkeypatch: pytest.MonkeyPatch) -> None: + """Загрузчик умер совсем (ни одного полного прогона) → монитор НЕ молчит. + + Прогон id=37 со status='done' при {errors: 9, upserted: 0} за успех не идёт — + SQL требует errors=0 AND upserted>0, поэтому «полных прогонов не было» здесь + ровно то состояние, что дал бы прод с одним лишь id=37. + """ + _patch_runs(monkeypatch) + out = mon.check_sber_freshness( + _prod_db(last_pull=None), # type: ignore[arg-type] + run_id=4, + params={}, + now=_PROD_NOW, + ) + assert out["alert"] == 1 + assert out["pull_lag_days"] == -1 + + +def test_asks_estimator_dashboard_first(monkeypatch: pytest.MonkeyPatch) -> None: + """Табло — то же и в том же порядке, что берёт оценщик (не max() по таблице).""" + _patch_runs(monkeypatch) + db = _prod_db() + out = mon.check_sber_freshness(db, run_id=5, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert db.asked_dashboards[0] == "real_estate_deals" + assert (out["latest_year"], out["latest_month"]) == (2026, 6) + + +def test_falls_back_to_next_dashboard_when_first_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Первое табло пусто → берём следующее, как и оценщик.""" + _patch_runs(monkeypatch) + db = _FakeDB({"dinamika-tsen-obyavlenii": _MAY_2026}) + out = mon.check_sber_freshness(db, run_id=6, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert out["latest_month"] == 5 + assert out["age_days"] == 103 + + +def test_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None: + """Ни одного табло с данными — оценивать нечего, это сбой монитора.""" calls = _patch_runs(monkeypatch) - db = _FakeDB(None) - out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type] + out = mon.check_sber_freshness(_FakeDB({}), run_id=7, params={}, now=_PROD_NOW) # type: ignore[arg-type] assert out["alert"] == 0 assert calls["done"] is None assert calls["failed"] is not None -def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None: +def test_legacy_lag_allowance_param_is_ignored(monkeypatch: pytest.MonkeyPatch) -> None: + """Мёртвая ручка default_params.lag_allowance_days не может вернуть календарь. + + Строка монитора в проде всё ещё несёт {"lag_allowance_days": 25} (миграция 180). + С любым её значением вердикт один и тот же — порог берётся из такта загрузки. + """ _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - # lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено. - out = mon.check_sber_freshness( - db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12) - ) # type: ignore[arg-type] - assert out["alert"] == 1 + base = mon.check_sber_freshness(_prod_db(), run_id=8, params={}, now=_PROD_NOW) # type: ignore[arg-type] + tweaked = mon.check_sber_freshness( + _prod_db(), # type: ignore[arg-type] + run_id=9, + params={"lag_allowance_days": 0}, + now=_PROD_NOW, + ) + assert base["alert"] == tweaked["alert"] == 0 -def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None: - """Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS.""" +def test_threshold_read_from_pull_schedule(monkeypatch: pytest.MonkeyPatch) -> None: + """Порог читается из строки загрузчика: такт 28 → порог 56, тревоги нет.""" _patch_runs(monkeypatch) - db = _FakeDB(_MAY_2026) - threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS - # Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт. - exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold) - out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type] - assert out_exact["age_days"] == threshold - assert out_exact["alert"] == 0 - out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type] - assert out_over["alert"] == 1 + db = _FakeDB( + {"real_estate_deals": _JUN_2026}, + last_pull=_now(2026, 7, 23), # 20 суток + interval_days="28", + ) + out = mon.check_sber_freshness(db, run_id=10, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert out["max_pull_lag_days"] == 56 + assert out["alert"] == 0 + + +def test_missing_schedule_row_falls_back_to_default(monkeypatch: pytest.MonkeyPatch) -> None: + """Строки/ключа нет — берём DEFAULT_PULL_INTERVAL_DAYS, а не падаем.""" + _patch_runs(monkeypatch) + db = _FakeDB({"real_estate_deals": _JUN_2026}, interval_days=None) + out = mon.check_sber_freshness(db, run_id=11, params={}, now=_PROD_NOW) # type: ignore[arg-type] + assert out["max_pull_lag_days"] == mon.MISSED_PULL_CYCLES * mon.DEFAULT_PULL_INTERVAL_DAYS # ── Миграция 180 ────────────────────────────────────────────────────────────── @@ -207,17 +375,46 @@ def test_migration_180_window_9_to_10_utc() -> None: assert re.search(r"\b10\b", sql), "window_end_hour 10 missing" -def test_migration_180_lag_allowance_25() -> None: - sql = _MIGRATION_180.read_text("utf-8") - assert "lag_allowance_days" in sql - assert "25" in sql - - def test_migration_180_no_psycopg_trap() -> None: sql = _MIGRATION_180.read_text("utf-8") assert not re.search(r":\w+::", sql) +# ── Миграция 212: такт загрузки = источник порога ───────────────────────────── +# +# Прежний инвариант («пол возраста + такт < календарного порога монитора») снят +# вместе с календарным порогом: прод его ОПРОВЕРГ — 2026-08-12 возраст 72 при +# полном прогоне шестидневной давности, потолок 53 держался бы только если бы +# источник публиковал строго помесячно. Остаётся то, что проверяемо: фолбэк кода +# не должен расходиться с тактом, который сеет миграция. + + +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_default_pull_interval_matches_migration_212() -> None: + """Фолбэк монитора == такт из миграции, иначе порог тихо разъедется с загрузкой.""" + interval_days = int( + re.search(r'"interval_days":\s*(\d+)', _MIGRATION_212.read_text("utf-8")).group(1) # type: ignore[union-attr] + ) + assert mon.DEFAULT_PULL_INTERVAL_DAYS == interval_days + + +# ── Один порог, а не два ────────────────────────────────────────────────────── + + +def test_no_second_calendar_threshold_in_settings() -> None: + """#2846: sber_index_max_age_days удалён — второму порогу неоткуда взяться.""" + from app.core.config import settings + + assert not hasattr(settings, "sber_index_max_age_days") + + # ── Регистрация в 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_scraped_at_bump_2206.py b/tradein-mvp/backend/tests/test_scraped_at_bump_2206.py index f5e3b247..784df63d 100644 --- a/tradein-mvp/backend/tests/test_scraped_at_bump_2206.py +++ b/tradein-mvp/backend/tests/test_scraped_at_bump_2206.py @@ -7,7 +7,8 @@ эстиматору было видно лишь ~45.6% активного инвентаря (CIAN — 5.9%). Фикс: и ON CONFLICT DO UPDATE, и dedup-drift reconcile UPDATE теперь выставляют -scraped_at = NOW() рядом с last_seen_at = NOW(). Проверяем оба пути в +scraped_at рядом с last_seen_at (с #2731 — statement_timestamp(), см. +test_2731_observation_timestamps.py; до него NOW()). Проверяем оба пути в `scraper_kit.base` (единственный живой модуль — `app.services.scrapers.base` удалён #2397 финальный шаг E, mirror-тесты через legacy убраны) и свойства ретро-бэкфилл-миграции 161. @@ -120,7 +121,12 @@ def _kit_matcher() -> MagicMock: def test_kit_on_conflict_bumps_scraped_at() -> None: - """kit ON CONFLICT DO UPDATE двигает scraped_at = NOW().""" + """kit ON CONFLICT DO UPDATE двигает scraped_at рядом с last_seen_at. + + #2731: обе метки пишутся statement_timestamp() (построчно, но одинаково внутри + statement'а) вместо NOW() — инвариант #2206 «scraped_at двигается вместе с + last_seen_at» от этого не меняется, меняется только источник времени. + """ db = _mock_db_update_path(inserted=False) lot = KitLot( source="cian", @@ -134,12 +140,12 @@ def test_kit_on_conflict_bumps_scraped_at() -> None: assert (inserted, updated) == (0, 1) sql = _find_sql(db, "INSERT INTO listings (") - assert "scraped_at = NOW()" in sql - assert "last_seen_at = NOW()" in sql + assert "scraped_at = statement_timestamp()" in sql + assert "last_seen_at = statement_timestamp()" in sql def test_kit_reconcile_bumps_scraped_at() -> None: - """kit dedup-drift reconcile UPDATE двигает scraped_at = NOW().""" + """kit dedup-drift reconcile UPDATE двигает scraped_at рядом с last_seen_at (#2731).""" db = _mock_db_reconcile(reconcile_id=88) lot = KitLot( source="avito", @@ -152,8 +158,8 @@ def test_kit_reconcile_bumps_scraped_at() -> None: kit_save_listings(db, [lot], matcher=_kit_matcher(), region_code=66) sql = _find_sql(db, "SET dedup_hash") - assert "scraped_at = NOW()" in sql - assert "last_seen_at = NOW()" in sql + assert "scraped_at = statement_timestamp()" in sql + assert "last_seen_at = statement_timestamp()" in sql # ── Migration 161: retro-backfill scraped_at ────────────────────────────────── 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..813d78a2 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,12 @@ 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]), + # #2830: узел резолвится из пула по (db, source), а не из статичного env + patch.object(admin_module, "_provider_proxy_url", lambda _db, s: proxy_urls[s]), patch.object(admin_module.settings, "scraper_fetch_mode", "browser"), ): r = client.get("/api/v1/admin/scraper/health") @@ -169,13 +173,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 @@ -203,8 +208,7 @@ def test_health_browser_unreachable(client: TestClient) -> None: with ( 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), + patch.object(admin_module, "_provider_proxy_url", lambda _db, _s: None), ): r = client.get("/api/v1/admin/scraper/health") @@ -214,137 +218,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 +361,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, @@ -499,13 +374,13 @@ def _make_dq_db_mock() -> MagicMock: "f_lon": 950, "f_kitchen_area_m2": 600, "f_living_area_m2": 100, - "f_ceiling_height": 50, "f_ceiling_height_m": 300, "f_metro_stations": 700, } cian_row = { "source": "cian", "active_count": 500, + "stale_count": 300, "f_description": 450, "f_photo_urls": 490, "f_address": 500, @@ -513,8 +388,10 @@ def _make_dq_db_mock() -> MagicMock: "f_lon": 480, "f_kitchen_area_m2": 400, "f_living_area_m2": 400, - "f_ceiling_height": 350, - "f_ceiling_height_m": 0, + # #2699: cian тоже пишет в канон ceiling_height_m — раньше в фикстуре + # стояло f_ceiling_height=350 / f_ceiling_height_m=0, ровно тот прод-раскол, + # из-за которого 855 циановских значений не видел ни один потребитель. + "f_ceiling_height_m": 350, "f_metro_stations": 100, } @@ -566,6 +443,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,8 +452,9 @@ def test_data_quality_shape(client: TestClient) -> None: cian = sources["cian"] assert cian["active_count"] == 500 - 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) + assert cian["stale_count"] == 300 # #2660: 60% «активных» на деле не виделись + assert cian["fields"]["ceiling_height_m"] == pytest.approx(70.0, abs=0.1) + assert "ceiling_height" not in cian["fields"] # #2699: DEPRECATED, убрана houses = body["houses"] assert houses["total"] == 2000 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..43cbe845 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: @@ -224,9 +236,14 @@ async def test_cian_history_backfill_houses_fetch_newbuilding_gets_config_endpoi captured: dict[str, Any] = {} - async def _fake_fetch_newbuilding(zhk_url: str, *, config: Any = None) -> None: + async def _fake_fetch_newbuilding( + zhk_url: str, *, config: Any = None, proxy_provider: Any = None + ) -> None: captured["zhk_url"] = zhk_url captured["endpoint"] = config.browser_http_endpoint if config is not None else None + # #2767: этот путь тоже обязан идти через пул прокси — до правки он, как и + # обогащение, выходил через единственный env-узел сайдкара. + captured["proxy_provider"] = proxy_provider db = MagicMock() db.execute.return_value.mappings.return_value.all.return_value = [ @@ -246,6 +263,7 @@ async def test_cian_history_backfill_houses_fetch_newbuilding_gets_config_endpoi assert captured["zhk_url"] == "https://zhk-test-ekb-i.cian.ru/" assert captured["endpoint"] == settings.browser_http_endpoint + assert captured["proxy_provider"] is not None async def test_cian_history_backfill_valuations_estimate_gets_config() -> None: diff --git a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py index b372a63e..d2890511 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py @@ -59,11 +59,16 @@ def _spy_browser_fetcher(html: str) -> MagicMock: @pytest.mark.asyncio async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch): - """config.browser_http_endpoint должен попасть в BrowserFetcher(endpoint=...).""" - spy = _spy_browser_fetcher("") - monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.BrowserFetcher", spy) + """config.browser_http_endpoint должен попасть в BrowserFetcher(endpoint=...). - config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT) + С #2767 путь идёт через `build_browser_fetcher` (подключение к пулу прокси), поэтому + подменяется BrowserFetcher В ФАБРИКЕ — сама фабрика при этом настоящая, и тест + проверяет всю цепочку config → фабрика → фетчер, а не контракт мока. + """ + spy = _spy_browser_fetcher("") + monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy) + + config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False) await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", config=config) spy.assert_called_once() 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..f3091685 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 = "unknown", # #2764: дефолт двойника = дефолт модуля + ) -> 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..ba629b56 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 = "unknown", # #2764: дефолт двойника = дефолт модуля + ) -> 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..b973e19a 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", @@ -68,6 +71,7 @@ _PRODUCT_SOURCES: set[str] = { "newbuilding_enrich", "yandex_newbuilding_sweep", "geoportal_coords_backfill", + "house_coords_from_listings", "geocode_missing_listings", "avito_detail_backfill", "yandex_detail_backfill", @@ -235,6 +239,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 +252,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 +296,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 +306,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 +317,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 +532,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_sentry_init_wiring.py b/tradein-mvp/backend/tests/test_sentry_init_wiring.py new file mode 100644 index 00000000..46b86499 --- /dev/null +++ b/tradein-mvp/backend/tests/test_sentry_init_wiring.py @@ -0,0 +1,111 @@ +"""PR-D2 (платёжный периметр): каждая точка инициализации `sentry_sdk.init(...)` +в проекте обязана проводить ОБА канала мониторинга — `before_send` (error-события) +и `before_send_transaction` (performance-трейсы). Мотивирующий инцидент (соседний +продукт, Птица, вчера): закрыли только error-канал через `before_send`, а +`before_send_transaction` остался вообще без обработчика — очистка body/PII там +не применялась. + +Инициализация происходит на module-level внутри `if settings.glitchtip_dsn:` — +поведенческий тест потребовал бы реального импорта модуля с DSN, выставленным +ДО импорта (модуль кэшируется, monkeypatch settings после импорта на init уже не +влияет), плюс `sentry_sdk.init` — процесс-глобальный singleton (повторные вызовы +из разных тестов друг друга затирают). Вместо этого — статический разбор AST: +детерминирован, не трогает process-global state, не зависит от порядка тестов. + +НЕ grep/substring по тексту файла: `before_send_transaction` уже упоминается в +docstring-комментариях этих же файлов (объясняющих МОТИВ) — substring-поиск дал +бы ложный PASS без единой реальной проводки в `sentry_sdk.init(...)`. Разбор +именно keyword-аргументов AST Call-узла `sentry_sdk.init(...)` не подвержен +этому false positive. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +_APP_DIR = Path(__file__).resolve().parent.parent / "app" + +# Все известные точки инициализации sentry_sdk в проекте (backend API, scraper +# scheduler, telegram support-bridge). Список сверяется отдельным тестом ниже +# против грепа по всему `app/`, чтобы новая точка инициализации не прошла мимо +# этого файла молча. +_SENTRY_INIT_FILES = ["main.py", "scheduler_main.py", "tgbot_main.py"] + + +def _sentry_init_calls(source: str, filename: str) -> list[ast.Call]: + """Все AST Call-узлы вида `sentry_sdk.init(...)` в модуле.""" + tree = ast.parse(source, filename=filename) + calls = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if ( + isinstance(func, ast.Attribute) + and func.attr == "init" + and isinstance(func.value, ast.Name) + and func.value.id == "sentry_sdk" + ): + calls.append(node) + return calls + + +@pytest.mark.parametrize("filename", _SENTRY_INIT_FILES) +def test_sentry_init_wires_both_channels(filename: str) -> None: + source = (_APP_DIR / filename).read_text(encoding="utf-8") + calls = _sentry_init_calls(source, filename) + assert calls, f"{filename}: sentry_sdk.init(...) call not found (файл переехал?)" + for call in calls: + kwarg_names = {kw.arg for kw in call.keywords if kw.arg is not None} + assert "before_send" in kwarg_names, ( + f"{filename}: sentry_sdk.init(...) не передаёт before_send — " + "error-канал уходит в GlitchTip без scrub" + ) + assert "before_send_transaction" in kwarg_names, ( + f"{filename}: sentry_sdk.init(...) не передаёт before_send_transaction — " + "transaction-канал уходит в GlitchTip без scrub (ровно вчерашний баг Птицы)" + ) + + +def test_sentry_init_before_send_and_transaction_use_same_handler() -> None: + """`before_send` и `before_send_transaction` обязаны указывать на ОДИН и тот + же обработчик (одинаковое имя переменной/функции в keyword-значении) — иначе + возможен регресс, при котором кто-то поправит один канал и забудет второй, + хотя формально оба параметра присутствуют.""" + for filename in _SENTRY_INIT_FILES: + source = (_APP_DIR / filename).read_text(encoding="utf-8") + calls = _sentry_init_calls(source, filename) + for call in calls: + kwargs = {kw.arg: kw.value for kw in call.keywords if kw.arg is not None} + before_send = kwargs.get("before_send") + before_send_txn = kwargs.get("before_send_transaction") + assert before_send is not None and before_send_txn is not None + # Оба значения — ссылки на имя (ast.Name), сравниваем идентификатор. + assert isinstance(before_send, ast.Name) + assert isinstance(before_send_txn, ast.Name) + assert before_send.id == before_send_txn.id, ( + f"{filename}: before_send={before_send.id!r} != " + f"before_send_transaction={before_send_txn.id!r} — разные обработчики " + "на двух каналах, ровно тот класс бага, что и голый пропуск канала" + ) + + +def test_all_sentry_init_call_sites_are_enumerated() -> None: + """Если кто-то добавит НОВУЮ точку инициализации sentry_sdk.init(...) где-то + ещё в app/ — этот тест должен упасть, а не молча пропустить её мимо теста + выше (список `_SENTRY_INIT_FILES` — руками поддерживаемый allowlist).""" + found_files = set() + for py_file in _APP_DIR.rglob("*.py"): + source = py_file.read_text(encoding="utf-8") + if _sentry_init_calls(source, str(py_file)): + found_files.add(py_file.relative_to(_APP_DIR).as_posix()) + + expected = set(_SENTRY_INIT_FILES) + assert found_files == expected, ( + f"Точки инициализации sentry_sdk.init(...) разошлись со списком в тесте: " + f"найдено {sorted(found_files)}, ожидалось {sorted(expected)}. Новую точку " + "нужно добавить в _SENTRY_INIT_FILES ЭТОГО файла и проверить оба канала." + ) diff --git a/tradein-mvp/backend/tests/test_sentry_scrub.py b/tradein-mvp/backend/tests/test_sentry_scrub.py index 1e8d8ba5..4925c48d 100644 --- a/tradein-mvp/backend/tests/test_sentry_scrub.py +++ b/tradein-mvp/backend/tests/test_sentry_scrub.py @@ -14,7 +14,9 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: from app.observability.sentry_scrub import ( redact_telegram_bot_token, + scrub_payment_request_body, scrub_pii_event, + stabilize_retry_error_fingerprint, ) @@ -231,6 +233,128 @@ def test_bare_token_redaction_leaves_benign_colon_strings_untouched(benign: str) assert out["logentry"]["message"] == benign +# ── Платёжный body-wipe (PR-D2, критерий приёмки #1) ───────────────────────── + + +def test_scrub_payment_request_body_removes_data_for_payments_path() -> None: + """Событие мониторинга с адресом платёжного пути и телом, содержащим `Token` + и `Pan`, уходит БЕЗ ключа с телом (PR-D2 acceptance criteria).""" + event = { + "request": { + "url": "https://gendsgn.ru/api/v1/trade-in/payments/notify", + "data": { + "Token": "deadbeefdeadbeefdeadbeef", + "Pan": "220000******0000", + "ExpDate": "1230", + "CardId": "123456", + "RebillId": "987654", + "DATA": {"Email": "someone@example.com"}, + }, + "method": "POST", + } + } + out = scrub_payment_request_body(event, {}) + assert out is not None + assert "data" not in out["request"] + # Остальные поля request не тронуты. + assert out["request"]["method"] == "POST" + assert out["request"]["url"] == "https://gendsgn.ru/api/v1/trade-in/payments/notify" + + +def test_scrub_payment_request_body_covers_checkout_too() -> None: + """Матч по сегменту пути, не по конкретному эндпоинту — checkout тоже режется.""" + event = { + "request": { + "url": "https://gendsgn.ru/api/v1/trade-in/payments/checkout", + "data": {"consent": True, "product_code": "report_pdf"}, + } + } + out = scrub_payment_request_body(event, {}) + assert out is not None + assert "data" not in out["request"] + + +def test_scrub_payment_request_body_case_insensitive_url_match() -> None: + """Регистр URL не должен позволять данным проскочить — Caddy/rbac регистр + трактуют по-разному, страховка на случай, если событие всё же породилось.""" + event = { + "request": { + "url": "https://gendsgn.ru/API/V1/Trade-In/Payments/Notify", + "data": {"Token": "secret"}, + } + } + out = scrub_payment_request_body(event, {}) + assert out is not None + assert "data" not in out["request"] + + +def test_scrub_payment_request_body_leaves_other_paths_untouched() -> None: + """Не платёжный путь — тело остаётся (это не общий kill-switch на request.data).""" + event = { + "request": { + "url": "https://gendsgn.ru/api/v1/trade-in/estimate", + "data": {"area_sqm": 50, "region": "66"}, + } + } + out = scrub_payment_request_body(event, {}) + assert out is not None + assert out["request"]["data"] == {"area_sqm": 50, "region": "66"} + + +def test_scrub_payment_request_body_handles_missing_request() -> None: + out = scrub_payment_request_body({"level": "error"}, {}) + assert out == {"level": "error"} + + +def test_scrub_payment_request_body_handles_non_dict_event() -> None: + assert scrub_payment_request_body(None, {}) is None # type: ignore[arg-type] + + +def test_scrub_payment_request_body_handles_missing_url() -> None: + """`request` без `url` (нестандартный event) — не бросает, тело не трогает.""" + event = {"request": {"data": {"Token": "x"}}} + out = scrub_payment_request_body(event, {}) + assert out is not None + assert out["request"]["data"] == {"Token": "x"} + + +# ── Расширенный набор платёжных PII-ключей (PR-D2, критерий приёмки #2) ────── + + +def test_pii_keys_scrub_payment_fields_at_arbitrary_depth() -> None: + """Скрабер вычищает `customer_email`/`customer_phone`/платёжные поля на + произвольной глубине вложенности (PR-D2 acceptance criteria).""" + event = { + "extra": { + "checkout_context": { + "buyer": { + "customer_email": "buyer@example.com", + "customer_phone": "+79991234567", + "nested_list": [ + {"pan": "220000******1111", "expdate": "0129"}, + {"cardid": "abc123", "rebillid": "xyz789"}, + ], + }, + "token": "sensitive-token-value", + "terminalkey": "TinkoffBankTest", + "order_id": "ord_123", + } + } + } + out = scrub_pii_event(event, {}) + ctx = out["extra"]["checkout_context"] + assert ctx["buyer"]["customer_email"] == "[REDACTED]" + assert ctx["buyer"]["customer_phone"] == "[REDACTED]" + assert ctx["buyer"]["nested_list"][0]["pan"] == "[REDACTED]" + assert ctx["buyer"]["nested_list"][0]["expdate"] == "[REDACTED]" + assert ctx["buyer"]["nested_list"][1]["cardid"] == "[REDACTED]" + assert ctx["buyer"]["nested_list"][1]["rebillid"] == "[REDACTED]" + assert ctx["token"] == "[REDACTED]" + assert ctx["terminalkey"] == "[REDACTED]" + # non-PII поле остаётся. + assert ctx["order_id"] == "ord_123" + + def test_composed_before_send_scrubs_pii_and_token_together() -> None: """Композиция, реально используемая в `app.tgbot_main._before_send`: PII-scrub (ключ-based) И token-redaction (regex full-text) применяются оба, не заменяя @@ -259,3 +383,247 @@ def test_composed_before_send_scrubs_pii_and_token_together() -> None: assert out["request"]["data"]["client_phone"] == "[REDACTED]" frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"] assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url + + +def test_composed_before_send_payment_wipe_pii_and_token_together() -> None: + """Полная композиция `app.main._before_send` (PR-D2): body-wipe для платёжного + пути → PII-scrub → token-redaction, в этом порядке, все три применяются.""" + event = { + "request": { + "url": "https://gendsgn.ru/api/v1/trade-in/payments/notify", + "data": {"Token": "deadbeef", "Pan": "220000******0000"}, + }, + "extra": {"client_phone": "+79991234567"}, + "exception": { + "values": [{"stacktrace": {"frames": [{"vars": {"url": _LEAKED_TOKEN_URL}}]}}] + }, + } + + def composed_before_send(evt, hint): + scrubbed = scrub_payment_request_body(evt, hint) + if scrubbed is None: + return None + scrubbed = scrub_pii_event(scrubbed, hint) + if scrubbed is None: + return None + return redact_telegram_bot_token(scrubbed, hint) + + out = composed_before_send(event, {}) + assert out is not None + assert "data" not in out["request"] + assert out["extra"]["client_phone"] == "[REDACTED]" + frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"] + assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url + + +# ── RetryError fingerprint stabilization (glitchtip-noise, #) ─ +# +# tenacity.RetryError.__str__() тащит repr() последнего Future — memory address +# объекта, случайный на каждый вызов процесса. Раньше (без `reraise=True` в +# app/services/geocoder.py) каждое исчерпание ретраев Nominatim улетало в +# GlitchTip как RetryError с этим нестабильным текстом → одна и та же причина +# плодила отдельный issue на КАЖДОЕ исчерпание (2 462 issue из 7 461 в трекере). +# Тесты ниже бьют по `stabilize_retry_error_fingerprint` напрямую — belt-and- +# suspenders слой для retry-кода БЕЗ reraise=True (напр. scraper_kit — +# geocoder.py `reraise=True` устраняет RetryError на своём пути, но остаётся +# фолбэком общего назначения), и по контракту before_send: 401-класс (RetryError) +# схлопывается ПО ИСТОЧНИКУ (не глобально — review round 2 claim #2: разные +# подсистемы с совпавшим типом причины НЕ сливаются), содержательные категории +# (500-подобный generic Exception, OperationalError) проходят НЕТРОНУТЫМИ. + +from tenacity import RetryError # noqa: E402 + + +def _hint_for(exc: BaseException) -> dict: + """Строит hint в форме, которую sentry_sdk реально передаёт в before_send — + `exc_info = (type, value, traceback)` (contract stabilize_retry_error_fingerprint + полагается именно на эту форму, не на уже сериализованный event dict).""" + return {"exc_info": (type(exc), exc, exc.__traceback__)} + + +def _raise_retry_error_from(cause: BaseException) -> RetryError: + try: + raise cause + except type(cause) as caught: + try: + raise RetryError(None) from caught + except RetryError as retry_exc: + return retry_exc + + +def test_stabilize_retry_error_sets_stable_fingerprint() -> None: + """RetryError коллапсится в persistent issue по (culprit, имени типа причины) — + НЕ по нестабильному str(RetryError) (repr() Future с memory address). Без + `event["logger"]` (напр. capture_exception без LoggingIntegration) culprit + падает на явный "unknown", а не пропадает из fingerprint молча.""" + exc = _raise_retry_error_from(TimeoutError("Nominatim timed out")) + out = stabilize_retry_error_fingerprint({"level": "error"}, _hint_for(exc)) + assert out is not None + assert out["fingerprint"] == ["retry-exhausted", "unknown", "TimeoutError"] + + +def test_stabilize_retry_error_fingerprint_has_no_variable_data() -> None: + """Fingerprint не должен содержать IP/id объявления/адрес и т.п. — только + culprit (logger-имя модуля) + фиксированное имя типа исключения-причины + (маленький словарь: HTTPStatusError/ConnectTimeout/TimeoutError/...).""" + exc = _raise_retry_error_from( + ValueError("addr='ул. Ленина 1', ip=95.165.147.218, listing_id=12345") + ) + out = stabilize_retry_error_fingerprint({}, _hint_for(exc)) + assert out is not None + fingerprint_text = " ".join(out["fingerprint"]) + assert "95.165.147.218" not in fingerprint_text + assert "12345" not in fingerprint_text + assert out["fingerprint"] == ["retry-exhausted", "unknown", "ValueError"] + + +def test_stabilize_retry_error_fingerprint_uses_logger_as_culprit() -> None: + """`event["logger"]` (sentry_sdk LoggingIntegration ставит его = имя модуля, + вызвавшего logger.exception/.error) идёт в fingerprint как culprit — стабильно + per-модуль, не переменные данные запроса.""" + exc = _raise_retry_error_from(TimeoutError("timed out")) + out = stabilize_retry_error_fingerprint({"logger": "app.services.geocoder"}, _hint_for(exc)) + assert out is not None + assert out["fingerprint"] == ["retry-exhausted", "app.services.geocoder", "TimeoutError"] + + +def test_stabilize_retry_error_fingerprint_does_not_collapse_unrelated_subsystems() -> None: + """Review round 2 claim #2: RetryError с ОДИНАКОВЫМ типом причины из + НЕСВЯЗАННЫХ подсистем (geocoder vs scraper_kit) НЕ должны схлопнуться в один + issue — разные проблемы не сливаются, даже если типы причины совпали.""" + exc_geocoder = _raise_retry_error_from(TimeoutError("nominatim timed out")) + exc_scraper = _raise_retry_error_from(TimeoutError("yandex detail timed out")) + out_geocoder = stabilize_retry_error_fingerprint( + {"logger": "app.services.geocoder"}, _hint_for(exc_geocoder) + ) + out_scraper = stabilize_retry_error_fingerprint( + {"logger": "scraper_kit.providers.yandex.detail"}, _hint_for(exc_scraper) + ) + assert out_geocoder is not None + assert out_scraper is not None + assert out_geocoder["fingerprint"] != out_scraper["fingerprint"] + + +class _DecoyRetryError(Exception): + """Посторонний класс, СЛУЧАЙНО названный так же, как tenacity.RetryError — + но НЕ его подкласс. Строковое сравнение имён (старый баг, review round 2 + claim #4) ложно матчило бы такое; isinstance — нет.""" + + +_DecoyRetryError.__name__ = "RetryError" # type: ignore[misc] + + +def test_stabilize_retry_error_ignores_lookalike_class_by_name() -> None: + """type(exc).__name__ == "RetryError" НЕ должно быть достаточно — только + реальный tenacity.RetryError (или его подкласс) триггерит fingerprint-хук.""" + exc = _DecoyRetryError("unrelated exception, same class __name__ by accident") + event = {"level": "error"} + out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc)) + assert out == event + assert "fingerprint" not in out + + +def test_stabilize_retry_error_leaves_operational_error_untouched() -> None: + """401-аналог задачи: OperationalError — содержательная категория (реальный + сбой БД), фильтр её НЕ трогает (см. задачу #4 — не выключить сигнал вместе с + шумом).""" + from sqlalchemy.exc import OperationalError + + exc = OperationalError("SELECT 1", {}, Exception("connection refused")) + event = {"level": "error", "message": "db connection failed"} + out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc)) + assert out == event + assert "fingerprint" not in out + + +def test_stabilize_retry_error_leaves_generic_exception_untouched() -> None: + """500-аналог задачи: обычное необработанное исключение (не RetryError) + проходит без изменений.""" + exc = RuntimeError("scraper city-sweep failed") + event = {"level": "error"} + out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc)) + assert out == event + assert "fingerprint" not in out + + +def test_stabilize_retry_error_no_exc_info_untouched() -> None: + """capture_message-based события (нет exc_info) — фильтр не трогает, напр. + scrape_runs.py consecutive-failure алерты (content-ful, должны доходить).""" + event = {"level": "error", "message": "Scraper source 'avito' has 5 consecutive failed runs"} + out = stabilize_retry_error_fingerprint(dict(event), {}) + assert out == event + + +def test_stabilize_retry_error_handles_non_dict_event() -> None: + assert stabilize_retry_error_fingerprint(None, {}) is None # type: ignore[arg-type] + + +# ── httpx error-message URL query stabilization (glitchtip-noise review round 2, +# claim #1) ──────────────────────────────────────────────────────────────── +# +# `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`) bakes +# the FULL request URL — including query string — into the exception message. +# Воспроизведено эмпирически (httpx.Response(403, request=...).raise_for_status()): +# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/ +# search?q=<адрес>&format=json&limit=3'". После `reraise=True` в geocoder.py +# (устраняет RetryError, но НЕ этот текст) именно ЭТА строка становится GlitchTip +# title/value — переменный `q=<адрес>` на каждый вызов воспроизводит тот же +# per-address issue-explosion, который reraise=True должен был устранить, просто +# сменивший класс исключения (RetryError → HTTPStatusError). Тесты бьют по +# `scrub_pii_event` напрямую (композиция, реально применяемая в before_send). + +_NOMINATIM_403_TEMPLATE = ( + "Client error '403 Forbidden' for url " + "'https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=3'\n" + "For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403" +) + + +def _httpx_error_event(message: str) -> dict: + return {"exception": {"values": [{"type": "HTTPStatusError", "value": message}]}} + + +def test_scrub_pii_event_stabilizes_httpx_error_url_query() -> None: + """Query string режется целиком из httpx-style 'for url' сообщения — host+path + остаются стабильными для группировки.""" + encoded_ekb = "%D0%95%D0%BA%D0%B0%D1%82%D0%B5%D1%80%D0%B8%D0%BD%D0%B1%D1%83%D1%80%D0%B3" + event = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query=encoded_ekb)) + out = scrub_pii_event(event, {}) + assert out is not None + value = out["exception"]["values"][0]["value"] + assert "search?[REDACTED]'" in value + assert "%D0%95" not in value + assert "nominatim.openstreetmap.org/search" in value # host+path сохранены + + +def test_scrub_pii_event_httpx_url_query_stabilization_collapses_different_addresses() -> None: + """Два РАЗНЫХ адреса (переменная часть query) после редактора дают + ИДЕНТИЧНЫЙ текст сообщения — GlitchTip group-title больше не плодит issue + на каждый адрес (review round 2 claim #1).""" + event_a = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Ленина+1")) + event_b = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Мира+42%2C+кв.+5")) + out_a = scrub_pii_event(event_a, {}) + out_b = scrub_pii_event(event_b, {}) + assert out_a is not None + assert out_b is not None + assert out_a["exception"]["values"][0]["value"] == out_b["exception"]["values"][0]["value"] + + +def test_scrub_pii_event_httpx_url_without_query_untouched() -> None: + """URL без query string (напр. scraper detail page — переменная часть в + ПУТИ, не в query) остаётся нетронутым — regex матчит только `?...`.""" + message = "Client error '404 Not Found' for url 'https://realty.yandex.ru/offer/12345/'" + event = _httpx_error_event(message) + out = scrub_pii_event(event, {}) + assert out is not None + assert out["exception"]["values"][0]["value"] == message + + +def test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched() -> None: + """Regex бьёт только по 'for url \\'...?...\\'' — произвольный текст с `?` и + кавычками не должен ложно матчиться.""" + benign = "Вопрос: 'что такое ЖК \"Солнечный\"?' — уточните адрес" + event = {"extra": {"note": benign}} + out = scrub_pii_event(event, {}) + assert out is not None + assert out["extra"]["note"] == benign 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_street_deals_endpoint.py b/tradein-mvp/backend/tests/test_street_deals_endpoint.py index c1ea1249..003883e1 100644 --- a/tradein-mvp/backend/tests/test_street_deals_endpoint.py +++ b/tradein-mvp/backend/tests/test_street_deals_endpoint.py @@ -107,6 +107,64 @@ def test_extract_street_name_parametrized(address: str | None, expected: str | N assert extract_street_name(address) == expected +@pytest.mark.parametrize( + "address,expected", + [ + # Live-prod repro (2026-08-10): DaData format — abbreviations WITHOUT a + # trailing dot ("ул Академика Парина", not "ул. ..."), plus a leading + # postal index + admin parts ("620105, Свердловская обл, г + # Екатеринбург, Академический р-н, ..."). Old `_STREET_KW_RE` required + # the dot → keyword never matched on ANY DaData address → street-deals + # / sales-vs-listings endpoints silently returned empty for every + # DaData-geocoded request, not just this one. + ( + "620105, Свердловская обл, г Екатеринбург, Академический р-н, " + "ул Академика Парина, д 46/5", + "Академика Парина", + ), + # Same address, WITH the dot — must give the identical result (dot + # optional, not dot-forbidden). + ( + "620105, Свердловская обл, г Екатеринбург, Академический р-н, " + "ул. Академика Парина, д 46/5", + "Академика Парина", + ), + # Same address, full word "улица" — the alternation-order/backtracking + # concern: "ул\\.?" must NOT eat the "ул" prefix of "улица" and leave + # "ица ..." behind. + ( + "620105, Свердловская обл, г Екатеринбург, Академический р-н, " + "улица Академика Парина, д 46/5", + "Академика Парина", + ), + # Without the leading postal index — same admin prefix otherwise. + ( + "Свердловская обл, г Екатеринбург, Академический р-н, ул Академика Парина, д 46/5", + "Академика Парина", + ), + # Bare street+house, no admin prefix at all. + ("ул Академика Парина, д 46/5", "Академика Парина"), + # Other dot-optional abbreviations from _STREET_KW_RE (пр/пер/ш/наб/пл/мкр). + ("г Екатеринбург, пр Ленина, 5", "Ленина"), + ("г Екатеринбург, пер Красный, 4", "Красный"), + ("г Екатеринбург, наб Реки Исеть, 1", "Реки Исеть"), + # "ул. X" / "ул X" / "улица X" must all agree (no dot-optional regression). + ("Екатеринбург, ул. Малышева, 1", "Малышева"), + ("Екатеринбург, ул Малышева, 1", "Малышева"), + ("Екатеринбург, улица Малышева, 1", "Малышева"), + ], +) +def test_extract_street_name_dadata_no_dot_abbreviations( + address: str | None, expected: str | None +) -> None: + """#pdf-honesty/street-deals live-prod fix (2026-08-10): DaData addresses + use dot-less abbreviations ("ул", "пр", "пер", "ш", "наб", "пл", "мкр") + — _STREET_KW_RE must match them exactly like the dotted forms.""" + from app.services.estimator import extract_street_name + + assert extract_street_name(address) == expected + + # ── Helpers ─────────────────────────────────────────────────────────────────── 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_version_api.py b/tradein-mvp/backend/tests/test_version_api.py new file mode 100644 index 00000000..767e6b87 --- /dev/null +++ b/tradein-mvp/backend/tests/test_version_api.py @@ -0,0 +1,99 @@ +"""Tests for GET /api/v1/trade-in/version (build metadata) — app/core/version.py + +app/api/v1/version.py. + +Isolated FastAPI app (no full app.main import, no DB) — same pattern as +tests/test_geocode_reverse_api.py: mount only the router under test. +""" + +from __future__ import annotations + +import importlib +import os + +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 version as version_module +from app.core import version as version_core + + +@pytest.fixture +def app() -> FastAPI: + application = FastAPI() + application.include_router(version_module.router, prefix="/api/v1/trade-in") + return application + + +# ── GET /api/v1/trade-in/version ───────────────────────────────────────────── + + +def test_version_endpoint_shape(app: FastAPI) -> None: + client = TestClient(app) + r = client.get("/api/v1/trade-in/version") + assert r.status_code == 200 + body = r.json() + assert set(body.keys()) == {"version", "sha", "built_at"} + assert isinstance(body["version"], str) and body["version"] + assert isinstance(body["sha"], str) and body["sha"] + assert isinstance(body["built_at"], str) and body["built_at"] + + +def test_version_endpoint_matches_core_constants(app: FastAPI) -> None: + client = TestClient(app) + body = client.get("/api/v1/trade-in/version").json() + assert body["version"] == version_core.APP_VERSION + assert body["sha"] == version_core.BUILD_SHA + assert body["built_at"] == version_core.BUILD_DATE + + +def test_version_path_is_public_no_auth_required() -> None: + """rbac_guard must let this path through without X-Authenticated-User / + session — see app/core/rbac.py::_PUBLIC_PATHS. Not a secret, no DB call.""" + from app.core.rbac import _PUBLIC_PATHS + + assert "/api/v1/trade-in/version" in _PUBLIC_PATHS + + +# ── app/core/version.py — product_version_line / format_build_date_human ──── + + +def test_product_version_line_format() -> None: + line = version_core.product_version_line("Мера") + assert line.startswith("Мера v") + parts = line.split(" · ") + assert len(parts) == 3, f"expected 'name vX.Y.Z · sha · date', got {line!r}" + + +def test_format_build_date_human_parses_iso_utc() -> None: + assert version_core.format_build_date_human("2026-08-10T12:00:00Z") == "10.08.2026" + + +def test_format_build_date_human_falls_back_on_garbage_without_raising() -> None: + assert version_core.format_build_date_human("not-a-date") == "not-a-date" + + +# ── Fallback when APP_VERSION/BUILD_SHA/BUILD_DATE env vars are absent ────── +# (local `uvicorn` run without a Docker build — see module docstring in +# app/core/version.py). Reloading the module re-executes its module-level +# env reads; nothing here may raise. + + +def test_module_import_falls_back_without_build_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("APP_VERSION", raising=False) + monkeypatch.delenv("BUILD_SHA", raising=False) + monkeypatch.delenv("BUILD_DATE", raising=False) + + reloaded = importlib.reload(version_core) + + assert reloaded.BUILD_SHA == "dev" + assert reloaded.APP_VERSION # non-empty: VERSION file content or "0.0.0" default + assert reloaded.BUILD_DATE.endswith("Z") + # format/product helpers must still work off the fallback values (no crash). + assert reloaded.product_version_line("Мера").startswith("Мера v") + + # Reload once more so any test running later in this process sees a module + # state consistent with whatever env pytest was actually invoked under. + importlib.reload(version_core) diff --git a/tradein-mvp/backend/tests/test_yandex_detail.py b/tradein-mvp/backend/tests/test_yandex_detail.py index c3431aa5..f481529d 100644 --- a/tradein-mvp/backend/tests/test_yandex_detail.py +++ b/tradein-mvp/backend/tests/test_yandex_detail.py @@ -385,3 +385,42 @@ class TestHelpers: result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL) assert result is not None assert result.metro_walk_min == 7 + + +# --------------------------------------------------------------------------- +# #2669 — просмотры с разделителем тысяч +# --------------------------------------------------------------------------- +# Форма взята дословно из сохранённого ответа Яндекса +# tests/fixtures/yandex_offer_3402418396407468801.html (сверено 2026-08-07): +#
16 июня, 8 просмотров35,5 м², ... +# 3 850 000 ₽108 451 ₽ за м²... +# — тот же блок, из которого читаются просмотры, отдаёт числа с NBSP-разделителем. +# Прежняя RE_VIEWS `(\d+)\s+просмотр` обрывалась на разделителе и писала в БД +# ПОСЛЕДНИЕ ТРИ ЦИФРЫ (1 234 → 234): не пусто, а тихо неверно. Косвенное +# подтверждение с прода на 2026-08-07: из 1620 непустых views_total_yandex +# ровно НОЛЬ значений ≥ 1000, при 2512 таких у avito. + + +@pytest.mark.parametrize( + ("views_fragment", "expected"), + [ + ("8\xa0просмотров", 8), # дословно из сохранённого ответа + ("1\xa0234 просмотра", 1234), # NBSP-разделитель — до правки давало 234 + ("1 234 просмотра", 1234), # обычный пробел + ("12\xa0345 просмотров", 12345), + ("342 просмотра", 342), # без разделителя — прежнее поведение цело + ], +) +def test_views_total_survives_thousands_separator(views_fragment: str, expected: int) -> None: + html = _make_html(summary_text=f"Екатеринбург, улица Малышева, д. 5 • {views_fragment}") + result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL) + assert result is not None + assert result.views_total == expected, "просмотры Яндекса снова режутся по разделителю тысяч" + + +def test_views_total_absent_stays_none() -> None: + """Нет блока просмотров → None, а не 0: «не пришло» ≠ «ноль просмотров».""" + html = _make_html(summary_text="Екатеринбург, улица Малышева, д. 5 • нет данных") + result = SCRAPER.parse(html, offer_url=_BASE_OFFER_URL) + assert result is not None + assert result.views_total is None 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..a21a9509 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 перед вставкой истории. + Фильтруем по 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,10 @@ 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, мигр. 259. + assert db.commit.call_count == 1 db.rollback.assert_not_called() @@ -200,7 +215,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..c6196e84 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation.py @@ -1,7 +1,7 @@ """Unit tests for YandexValuationScraper — anonymous house-history scraper. Fixture HTML simulates the Yandex valuation page body text containing: -- House meta block (year, floors, type, ceiling, lift, total objects, panorama) +- House meta block (year, floors, type, ceiling, lift, total objects) - 2-3 historical offer entries with full structure Легаси `app.services.scrapers.yandex_valuation` удалён (#2277 финальный шаг @@ -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, ) # --------------------------------------------------------------------------- @@ -80,7 +78,6 @@ def test_parse_house_meta_full(): assert meta.ceiling_height == 2.50 assert meta.has_lift is True assert meta.total_objects == 12 - assert meta.has_panorama is False def test_parse_house_meta_no_lift(): @@ -93,10 +90,16 @@ def test_parse_house_meta_no_lift(): assert meta.ceiling_height == 3.0 -def test_parse_house_meta_with_panorama(): - text = "7 объектов Дом 2010 года Панорама Лифт Кирпичное здание" +def test_parse_house_meta_brick_with_lift(): + """#2674 (хвост): фикстура больше не содержит «Панорама». + + Тест назывался ..._with_panorama и кормил парсеру строку, собранную автором; + зелёный он был по построению. На настоящей странице оценки этого слова нет — + проверено боевым трактом 13.08.2026, 0 вхождений в полном HTML трёх адресов. + Остаток теста осмысленный: кирпич + лифт на короткой мета-строке. + """ + text = "7 объектов Дом 2010 года Лифт Кирпичное здание" meta = YandexValuationScraper._parse_house_meta(text) - assert meta.has_panorama is True assert meta.has_lift is True assert meta.house_type == "brick" @@ -277,7 +280,7 @@ def test_total_floors_extracted_from_dom_meta_not_items(): Real Yandex page has 'M этажей' (plural) in dom-meta and 'N этаж' (singular) per item. """ text = ( - "Дом 2025 года Панорама 25 этажей Монолитное здание 2,7 м потолки Лифт " + "Дом 2025 года 25 этажей Монолитное здание 2,7 м потолки Лифт " "1-комнатная 40 м² 3 этаж 10.01.2026 В экспозиции 5 дней В продаже " "2-комнатная 55 м² 17 этаж 05.01.2026 В экспозиции 10 дней В продаже" ) diff --git a/tradein-mvp/backend/tests/test_yandex_valuation_save.py b/tradein-mvp/backend/tests/test_yandex_valuation_save.py index 9079f0df..4b90d5ef 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 перед вставкой истории. + Фильтруем по 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..c68d74c2 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -23,13 +23,51 @@ # ПЕРЕСОЗДАСТ ВСЕ tradein-контейнеры, включая scraper — прервёт бегущий sweep # (stop_grace_period 120s даёт unit'у до-checkpoint'иться). Допустимо, разово. # -# logging: json-file с ротацией (20m × 3 = ≤60M на сервис). До этого драйвер по -# умолчанию рос без границ. Общий anchor ниже. +# logging: journald (#2741). Было json-file 20m × 3 — оно решало только размер, +# и ценой того, что лог ЖИВЁТ В КОНТЕЙНЕРЕ: `docker rm` уносит его целиком. +# Деплоев ~20/сутки, скрейперы работают ночью, разбор идёт утром → окно жизни +# лога почти никогда не покрывает интересное (#2695, #2698, #2676 — три разбора +# подряд уперлись в «логов уже нет»). +# +# journald: демон отдаёт stdout/stderr в /var/log/journal (persistent, на хосте), +# запись переживает пересоздание контейнера и читается ПО ВРЕМЕНИ, а не «сколько +# осталось от последнего рестарта». +# +# КАК ЧИТАТЬ (проверено на проде 2026-08-06): +# docker logs tradein-scraper # как и раньше: только текущий контейнер +# # история через пересоздания — журнал принадлежит root, а deploy-юзер +# # gendesign состоит в docker, но НЕ в adm/systemd-journal, и sudo просит пароль, +# # поэтому голый `journalctl` у него выдаёт «No entries». Рабочий однострочник: +# docker run --rm -v /:/host:ro alpine chroot /host sh -c \ +# 'TZ=UTC journalctl -t tradein-scraper -o short-iso \ +# --since "2026-08-07 00:39" --until "2026-08-07 01:40"' +# # TZ=UTC — не украшение: `--since/--until` разбираются в ЛОКАЛЬНОМ времени хоста +# # (а он в +03), и флаг `--utc` на это НЕ влияет — он меняет только вывод. Без +# # TZ=UTC окно уезжает на 3 часа и молча отдаёт чужой кусок ночи. Проверено. +# # То же по метке контейнера: CONTAINER_NAME=tradein-scraper (-o cat -f для tail -f) +# Владельцу стоит разово выдать `sudo usermod -aG adm gendesign` — после этого +# journalctl работает напрямую, без docker-обёртки (host-config, не этот файл). +# +# Ротация: журнал общий на хост, режется самим journald по размеру (SystemMaxUse) +# и НЕ обнуляется пересозданием контейнера — то есть глубина истории меряется +# сутками, а не «сколько прошло с последнего деплоя» (это и был баг #2741/#2715: +# 60 МБ json-file при флуде прокручивались за минуты, а деплой обнулял и их). +# Жёсткий потолок по возрасту (MaxRetentionSec) — при желании host drop-in. +# Замер 2026-08-06: /var/log/journal = +# 2.3G за 103 дня (~22 МБ/сутки системных) при дефолтном потолке SystemMaxUse=4G; +# tradein добавляет единицы-десятки МБ/сутки (5.8k карточек за сутки по +# scrape_runs, ~153 Б/строку) → до вытеснения старого ещё недели, требуемые +# «сутки-двое» покрыты с запасом. Дисковый риск нулевой: 4G — это ПОТОЛОК, при +# его достижении journald сам удаляет старейшее. +# Ceiling: journald рейт-лимитит (дефолт 10000 сообщений / 30s на сервис) — при +# превышении в журнал попадёт «Suppressed N messages». Июльский флуд postgres +# (40 МБ за 6 часов ≈ 12 строк/с) от лимита в ~25 раз ниже, но если такое +# появится — это host-config (drop-in journald.conf.d), не этот файл. +# tag: имя контейнера вместо ID — SYSLOG_IDENTIFIER стабилен между пересозданиями. x-logging: &default-logging - driver: json-file + driver: journald options: - max-size: "20m" - max-file: "3" + tag: "{{.Name}}" services: browser: @@ -67,6 +105,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 @@ -164,6 +206,21 @@ services: # Значение ДОЛЖНО совпадать с TRADEIN_INTERNAL_AUTH_SECRET в .env главного # (Caddy) стека. Читается также из backend/.env.runtime (env_file выше). TRADEIN_INTERNAL_AUTH_SECRET: "${TRADEIN_INTERNAL_AUTH_SECRET:-}" + # #2709. Дефолт в коде — redis://localhost:6379/0, и внутри контейнера это + # мгновенный ConnectionRefused (замер: 16.6 мс) → кэш не работал ни дня. + # ⚠️ ПОРЯДОК КРИТИЧЕН, и цена ошибки измерена тем же клиентом, что в + # app/services/cache.py (socket_timeout=2.0, socket_connect_timeout=2.0): + # localhost, отказ соединения 16.6 мс ← как было + # имя не резолвится (NXDOMAIN) 123.5 мс + # имя резолвится, но не отвечает 2003.7 мс ← вот эта яма + # get/set глотают исключение в logger.warning и возвращают промах, поэтому + # яма МОЛЧАЛИВАЯ: 2 с на GET + 2 с на SET на каждый запрос, без единой + # ошибки наверх. Отсюда правило: сначала связность, потом переменная. + # Здесь она задаётся только потому, что связность уже проверена на проде + # ДО этой строки (throwaway-redis на gendesign_shared, из tradein-backend: + # PING 19 мс, SET 0.35 мс, GET 0.31 мс) — см. описание PR. + # db1 — не 0 и не 2: 0 занят celery-брокером gendesign, 2 — glitchtip'ом. + REDIS_URL: "redis://gendesign-redis:6379/1" depends_on: browser: condition: service_started diff --git a/tradein-mvp/frontend/Dockerfile b/tradein-mvp/frontend/Dockerfile index a78e5632..5def62b7 100644 --- a/tradein-mvp/frontend/Dockerfile +++ b/tradein-mvp/frontend/Dockerfile @@ -4,9 +4,13 @@ # ---- deps ---- FROM node:20-alpine AS deps WORKDIR /app -COPY package.json package-lock.json* ./ +# Лок ОБЯЗАТЕЛЕН (без `*`): `npm ci` без него падает, и это правильно — +# отсутствие лока раньше молча давало `npm install`, т.е. состав зависимостей +# определялся датой сборки (#2770). +COPY package.json package-lock.json ./ +# --legacy-peer-deps: React 19 peer-dep mismatches (как в соседнем frontend/). RUN --mount=type=cache,target=/root/.npm \ - npm install --legacy-peer-deps --no-audit --no-fund + npm ci --legacy-peer-deps --no-audit --no-fund # ---- builder ---- @@ -26,6 +30,29 @@ ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL ARG NEXT_PUBLIC_ENABLE_PREVIEW="" ENV NEXT_PUBLIC_ENABLE_PREVIEW=$NEXT_PUBLIC_ENABLE_PREVIEW +# Версия продукта («Мера») + короткий git SHA + дата сборки — ДОЛЖНЫ быть +# build-time ARG (не runtime env): Next.js инлайнит NEXT_PUBLIC_* в статические +# бандлы на `npm run build`, а этот build context (./tradein-mvp/frontend) не +# видит tradein-mvp/VERSION (он на уровень выше, вне build context) — источник +# правды читает CI ДО вызова `docker build` (.forgejo/workflows/deploy-tradein.yml, +# job build-frontend) и передаёт сюда готовыми значениями. Пустые дефолты — для +# локальной сборки без CI; фолбэк на "VERSION-файл/dev/дата сборки" делает уже +# frontend-код, потребляющий эти env (Dockerfile сам файл не читает). +ARG NEXT_PUBLIC_APP_VERSION="" +ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION +ARG NEXT_PUBLIC_BUILD_SHA="" +ENV NEXT_PUBLIC_BUILD_SHA=$NEXT_PUBLIC_BUILD_SHA +ARG NEXT_PUBLIC_BUILD_DATE="" +ENV NEXT_PUBLIC_BUILD_DATE=$NEXT_PUBLIC_BUILD_DATE + +# CHANGELOG.md — источник для /versions (src/lib/changelog.ts). Живёт на +# уровень выше этого build context (tradein-mvp/CHANGELOG.md), поэтому CI +# копирует его СЮДА (tradein-mvp/frontend/CHANGELOG.md) непосредственно +# перед `docker build` (см. .forgejo/workflows/deploy-tradein.yml, job +# build-frontend) — `COPY . .` ниже подхватывает её автоматически вместе с +# остальным контекстом. Локальная сборка без этого шага CI просто не находит +# файл — readChangelog() уже умеет деградировать (пустая история), сам +# Docker-билд при этом не падает (см. glob-COPY в runner stage ниже). COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build @@ -45,6 +72,13 @@ ENV NODE_ENV=production \ COPY --from=builder --chown=node:node /app/public ./public COPY --from=builder --chown=node:node /app/.next/standalone ./ COPY --from=builder --chown=node:node /app/.next/static ./.next/static +# /versions — Server Component, statically prerendered at `npm run build` +# (see src/app/versions/page.tsx) — CHANGELOG.md's content is already baked +# into .next/standalone above. This is a defensive fallback ONLY, in case that +# page ever stops being static: glob (trailing `*`) makes it a no-op when the +# builder stage doesn't have the file either (local build without the CI +# pre-copy step, see builder stage comment above) — never fails the build. +COPY --from=builder --chown=node:node /app/CHANGELOG.md* ./ USER node EXPOSE 3000 diff --git a/tradein-mvp/frontend/package-lock.json b/tradein-mvp/frontend/package-lock.json new file mode 100644 index 00000000..4cc83b4f --- /dev/null +++ b/tradein-mvp/frontend/package-lock.json @@ -0,0 +1,8641 @@ +{ + "name": "tradein-mvp-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tradein-mvp-frontend", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.50.0", + "next": "^15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^2.15.4" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.0.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.0.0", + "eslint-config-next": "^15.0.0", + "jsdom": "^25.0.1", + "typescript": "^5.9.3", + "vitest": "^2.1.9" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@next/env": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.23.tgz", + "integrity": "sha512-Mv3Z9hVbFcPnoLevsZ6rnX1TBtyHb5E17yN7HTPDXSXxeNsGBjUFrdbjRXKKXIOhfth7/cg6Ay7PZ2UFawaWsQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.23.tgz", + "integrity": "sha512-0KnCFpiWVIsbwBhByZ0uIcjYM5xqGrFzN2eOPbwru/wuy5Z1dmA+3gP+PRbi4gl1Ny7an66BqAM/NHkX/50rbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.23.tgz", + "integrity": "sha512-SrEwOROH/rhA03F59hHtdhgtfZMWGzr5duDBWgRQt2rS3mJhqMKOcnNx6txOd0/i3E3D3uFKYFvyHsEiwQxzag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.23.tgz", + "integrity": "sha512-f0FpFbG2EhDCuptBGcfrLcYMDuQAhe6m1QA4VVfXFrIBoFXvXt/olGbBkYkloKlXQtmhuzvtdYyuu/6zf07GIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.23.tgz", + "integrity": "sha512-WlNtfepUXKX2u2ZsJZ8c3c8+tJSRZqsYzoMwLOY72A8ucKCCgxgNhiePA3qzFYahVWrwcQd8jOeJmBinc+VFVQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.23.tgz", + "integrity": "sha512-W/6qKk7UG93mg14PmQC+2urt69MIdwTBLNQ6MJyeC4wOCIHCjz+VfgssvS1pK7mgYBtLC1g6VKNoHD9xB0WWGg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.23.tgz", + "integrity": "sha512-vzefI32mi6VMk96RaTAyxApgfGbiFzQBXVsekEjsDv1fr48mlABTWx0sUYhaYCBHWqCalxmz3DxbxFcbFvzNtw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.23.tgz", + "integrity": "sha512-qppK/3dTGOTI+aoWWBZc3DshFIhrzgL8guATlaN9V6M1QJxbkP/rhEZ22tdICsQ/2WWXopMZ2Jokzj2u3uKY3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.23.tgz", + "integrity": "sha512-Wc29KFOdT7XBcII3Vtmw7aoU8Uk3Mes/FNJfhFeSHdYBFJWMcR/DsI8U9BCPUhq/uycsUVuqSKGthW15tLsigA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.23.tgz", + "integrity": "sha512-/C7wRW4fa9s/PKA18zGPPpVmx8ycgVpP8yOxro4gzGTzjPJdscbAP3ODeFvgiIovxD176Z2J/SXO9t8PJKHLeQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/type-utils": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.66.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001807", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz", + "integrity": "sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.23.tgz", + "integrity": "sha512-z4WcTXNqFHwMG4V8WHb2xrlEPJvwarZa+H/6CR28vxr53icRnQzGXviO11p748BwrMZGl52itdPRzZfzYo0SKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.5.23", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz", + "integrity": "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "15.5.23", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.23.tgz", + "integrity": "sha512-Gvd2WKgvxIXCGotxcI1im/Uf3rS3J3oZGw0g/uskg6AVBZhyE3aAbujkYWzS3xLmEPEtTLfkaVQUKK0KMTSIkA==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.23", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.23", + "@next/swc-darwin-x64": "15.5.23", + "@next/swc-linux-arm64-gnu": "15.5.23", + "@next/swc-linux-arm64-musl": "15.5.23", + "@next/swc-linux-x64-gnu": "15.5.23", + "@next/swc-linux-x64-musl": "15.5.23", + "@next/swc-win32-arm64-msvc": "15.5.23", + "@next/swc-win32-x64-msvc": "15.5.23", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", + "integrity": "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", + "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/tradein-mvp/frontend/package.json b/tradein-mvp/frontend/package.json index b3579017..ef448501 100644 --- a/tradein-mvp/frontend/package.json +++ b/tradein-mvp/frontend/package.json @@ -7,7 +7,10 @@ "build": "next build", "start": "next start", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "check:mera-public-isolation": "node scripts/check-mera-public-isolation.mjs" }, "dependencies": { "@tanstack/react-query": "^5.50.0", @@ -17,12 +20,18 @@ "recharts": "^2.15.4" }, "devDependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@eslint/eslintrc": "^3.0.0", + "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.0.0", "eslint-config-next": "^15.0.0", - "typescript": "^5.9.3" + "jsdom": "^25.0.1", + "typescript": "^5.9.3", + "vitest": "^2.1.9" } } diff --git a/tradein-mvp/frontend/pnpm-lock.yaml b/tradein-mvp/frontend/pnpm-lock.yaml deleted file mode 100644 index 1eff3e29..00000000 --- a/tradein-mvp/frontend/pnpm-lock.yaml +++ /dev/null @@ -1,3708 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@tanstack/react-query': - specifier: ^5.50.0 - version: 5.100.14(react@19.2.6) - next: - specifier: ^15.0.0 - version: 15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: - specifier: ^19.0.0 - version: 19.2.6 - react-dom: - specifier: ^19.0.0 - version: 19.2.6(react@19.2.6) - recharts: - specifier: ^2.15.4 - version: 2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - devDependencies: - '@eslint/eslintrc': - specifier: ^3.0.0 - version: 3.3.5 - '@types/node': - specifier: ^22.0.0 - version: 22.19.19 - '@types/react': - specifier: ^19.0.0 - version: 19.2.15 - '@types/react-dom': - specifier: ^19.0.0 - version: 19.2.3(@types/react@19.2.15) - eslint: - specifier: ^9.0.0 - version: 9.39.4 - eslint-config-next: - specifier: ^15.0.0 - version: 15.5.18(eslint@9.39.4)(typescript@5.9.3) - typescript: - specifier: ^5.9.3 - version: 5.9.3 - -packages: - - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} - engines: {node: '>=6.9.0'} - - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} - - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@napi-rs/wasm-runtime@1.1.4': - resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@next/env@15.5.18': - resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} - - '@next/eslint-plugin-next@15.5.18': - resolution: {integrity: sha512-w4MYq8M26a8PNrfto0JosLf5/3ssln1rsyP96g2DkC8uFVymStM5DLSz5ElxxrPRg2XnTMnFo3kREFlhYvxhWw==} - - '@next/swc-darwin-arm64@15.5.18': - resolution: {integrity: sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@next/swc-darwin-x64@15.5.18': - resolution: {integrity: sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@next/swc-linux-arm64-gnu@15.5.18': - resolution: {integrity: sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@next/swc-linux-arm64-musl@15.5.18': - resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@next/swc-linux-x64-gnu@15.5.18': - resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@next/swc-linux-x64-musl@15.5.18': - resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@next/swc-win32-arm64-msvc@15.5.18': - resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@next/swc-win32-x64-msvc@15.5.18': - resolution: {integrity: sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@nolyfill/is-core-module@1.0.39': - resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} - engines: {node: '>=12.4.0'} - - '@rtsao/scc@1.1.0': - resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - - '@rushstack/eslint-patch@1.16.1': - resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} - - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - - '@tanstack/query-core@5.100.14': - resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==} - - '@tanstack/react-query@5.100.14': - resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==} - peerDependencies: - react: ^18 || ^19 - - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - - '@types/d3-array@3.2.2': - resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} - - '@types/d3-color@3.1.3': - resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} - - '@types/d3-ease@3.0.2': - resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} - - '@types/d3-interpolate@3.0.4': - resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} - - '@types/d3-path@3.1.1': - resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} - - '@types/d3-scale@4.0.9': - resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} - - '@types/d3-shape@3.1.8': - resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} - - '@types/d3-time@3.0.4': - resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} - - '@types/d3-timer@3.0.2': - resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/json5@0.0.29': - resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - - '@types/node@22.19.19': - resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.15': - resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} - - '@typescript-eslint/eslint-plugin@8.59.4': - resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.59.4 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/parser@8.59.4': - resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/project-service@8.59.4': - resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/scope-manager@8.59.4': - resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.59.4': - resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/type-utils@8.59.4': - resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/types@8.59.4': - resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.59.4': - resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.59.4': - resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/visitor-keys@8.59.4': - resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@unrs/resolver-binding-android-arm-eabi@1.12.2': - resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} - cpu: [arm] - os: [android] - - '@unrs/resolver-binding-android-arm64@1.12.2': - resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} - cpu: [arm64] - os: [android] - - '@unrs/resolver-binding-darwin-arm64@1.12.2': - resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} - cpu: [arm64] - os: [darwin] - - '@unrs/resolver-binding-darwin-x64@1.12.2': - resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} - cpu: [x64] - os: [darwin] - - '@unrs/resolver-binding-freebsd-x64@1.12.2': - resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} - cpu: [x64] - os: [freebsd] - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': - resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': - resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': - resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-arm64-musl@1.12.2': - resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': - resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-loong64-musl@1.12.2': - resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': - resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': - resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': - resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': - resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-x64-gnu@1.12.2': - resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-x64-musl@1.12.2': - resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-openharmony-arm64@1.12.2': - resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} - cpu: [arm64] - os: [openharmony] - - '@unrs/resolver-binding-wasm32-wasi@1.12.2': - resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': - resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} - cpu: [arm64] - os: [win32] - - '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': - resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} - cpu: [ia32] - os: [win32] - - '@unrs/resolver-binding-win32-x64-msvc@1.12.2': - resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} - cpu: [x64] - os: [win32] - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} - engines: {node: '>= 0.4'} - - array.prototype.findlast@1.2.5: - resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} - engines: {node: '>= 0.4'} - - array.prototype.findlastindex@1.2.6: - resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} - engines: {node: '>= 0.4'} - - array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} - - array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} - - array.prototype.tosorted@1.1.4: - resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - - ast-types-flow@0.0.8: - resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - axe-core@4.11.4: - resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} - engines: {node: '>=4'} - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bind@1.0.9: - resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - caniuse-lite@1.0.30001793: - resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - client-only@0.0.1: - resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - d3-array@3.2.4: - resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} - engines: {node: '>=12'} - - d3-color@3.1.0: - resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} - engines: {node: '>=12'} - - d3-ease@3.0.1: - resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} - engines: {node: '>=12'} - - d3-format@3.1.2: - resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} - engines: {node: '>=12'} - - d3-interpolate@3.0.1: - resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} - engines: {node: '>=12'} - - d3-path@3.1.0: - resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} - engines: {node: '>=12'} - - d3-scale@4.0.2: - resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} - engines: {node: '>=12'} - - d3-shape@3.2.0: - resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} - engines: {node: '>=12'} - - d3-time-format@4.1.0: - resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} - engines: {node: '>=12'} - - d3-time@3.1.0: - resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} - engines: {node: '>=12'} - - d3-timer@3.0.1: - resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} - engines: {node: '>=12'} - - damerau-levenshtein@1.0.8: - resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decimal.js-light@2.5.1: - resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} - - dom-helpers@5.2.1: - resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - es-abstract@1.24.2: - resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-iterator-helpers@1.3.2: - resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.2: - resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-config-next@15.5.18: - resolution: {integrity: sha512-HuoJU6uUPD00eyiud78IBnT4HLhztFj2V+ild2Uon5ZUrYZKe0Olu2QRD99e9IgL4/H1eg5Onka3BsfRW2U0Xw==} - peerDependencies: - eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 - typescript: '>=3.3.1' - peerDependenciesMeta: - typescript: - optional: true - - eslint-import-resolver-node@0.3.10: - resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} - - eslint-import-resolver-typescript@3.10.1: - resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - eslint: '*' - eslint-plugin-import: '*' - eslint-plugin-import-x: '*' - peerDependenciesMeta: - eslint-plugin-import: - optional: true - eslint-plugin-import-x: - optional: true - - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - - eslint-plugin-import@2.32.0: - resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - - eslint-plugin-jsx-a11y@6.10.2: - resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} - engines: {node: '>=4.0'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - - eslint-plugin-react-hooks@5.2.0: - resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} - engines: {node: '>=10'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - - eslint-plugin-react@7.37.5: - resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} - engines: {node: '>=4'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-equals@5.4.0: - resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} - engines: {node: '>=6.0.0'} - - fast-glob@3.3.1: - resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} - engines: {node: '>=8.6.0'} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.3: - resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} - engines: {node: '>= 0.4'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - - internmap@2.0.3: - resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} - engines: {node: '>=12'} - - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - - is-bun-module@2.0.0: - resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - - is-core-module@2.16.2: - resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} - engines: {node: '>= 0.4'} - - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} - - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} - - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} - - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - iterator.prototype@1.1.5: - resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} - engines: {node: '>= 0.4'} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@1.0.2: - resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} - hasBin: true - - jsx-ast-utils@3.3.5: - resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} - engines: {node: '>=4.0'} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - language-subtag-registry@0.3.23: - resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} - - language-tags@1.0.9: - resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} - engines: {node: '>=0.10'} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - napi-postinstall@0.3.4: - resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - next@15.5.18: - resolution: {integrity: sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==} - engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - - node-exports-info@1.6.0: - resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} - engines: {node: '>= 0.4'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - object.entries@1.1.9: - resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} - engines: {node: '>= 0.4'} - - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} - - object.groupby@1.0.3: - resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} - engines: {node: '>= 0.4'} - - object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - react-dom@19.2.6: - resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} - peerDependencies: - react: ^19.2.6 - - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - - react-smooth@4.0.4: - resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - react-transition-group@4.4.5: - resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} - peerDependencies: - react: '>=16.6.0' - react-dom: '>=16.6.0' - - react@19.2.6: - resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} - engines: {node: '>=0.10.0'} - - recharts-scale@0.4.5: - resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} - - recharts@2.15.4: - resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} - engines: {node: '>=14'} - deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide - peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} - - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - resolve@2.0.0-next.7: - resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} - engines: {node: '>= 0.4'} - hasBin: true - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - safe-array-concat@1.1.4: - resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} - engines: {node: '>=0.4'} - - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.8.1: - resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} - engines: {node: '>=10'} - hasBin: true - - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - stable-hash@0.0.5: - resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} - - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} - - string.prototype.includes@2.0.1: - resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} - engines: {node: '>= 0.4'} - - string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} - engines: {node: '>= 0.4'} - - string.prototype.repeat@1.0.0: - resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} - - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - styled-jsx@5.1.6: - resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - tiny-invariant@1.3.3: - resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - tsconfig-paths@3.15.0: - resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - unrs-resolver@1.12.2: - resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - victory-vendor@36.9.2: - resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} - - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} - - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} - - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} - engines: {node: '>= 0.4'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - -snapshots: - - '@babel/runtime@7.29.2': {} - - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': - dependencies: - eslint: 9.39.4 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.21.2': - dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3 - minimatch: 3.1.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 - - '@eslint/core@0.17.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.5': - dependencies: - ajv: 6.15.0 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.4': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 - - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 - - '@humanfs/types@0.15.0': {} - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@img/colour@1.1.0': - optional: true - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.10.0 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 - optional: true - - '@next/env@15.5.18': {} - - '@next/eslint-plugin-next@15.5.18': - dependencies: - fast-glob: 3.3.1 - - '@next/swc-darwin-arm64@15.5.18': - optional: true - - '@next/swc-darwin-x64@15.5.18': - optional: true - - '@next/swc-linux-arm64-gnu@15.5.18': - optional: true - - '@next/swc-linux-arm64-musl@15.5.18': - optional: true - - '@next/swc-linux-x64-gnu@15.5.18': - optional: true - - '@next/swc-linux-x64-musl@15.5.18': - optional: true - - '@next/swc-win32-arm64-msvc@15.5.18': - optional: true - - '@next/swc-win32-x64-msvc@15.5.18': - optional: true - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@nolyfill/is-core-module@1.0.39': {} - - '@rtsao/scc@1.1.0': {} - - '@rushstack/eslint-patch@1.16.1': {} - - '@swc/helpers@0.5.15': - dependencies: - tslib: 2.8.1 - - '@tanstack/query-core@5.100.14': {} - - '@tanstack/react-query@5.100.14(react@19.2.6)': - dependencies: - '@tanstack/query-core': 5.100.14 - react: 19.2.6 - - '@tybys/wasm-util@0.10.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/d3-array@3.2.2': {} - - '@types/d3-color@3.1.3': {} - - '@types/d3-ease@3.0.2': {} - - '@types/d3-interpolate@3.0.4': - dependencies: - '@types/d3-color': 3.1.3 - - '@types/d3-path@3.1.1': {} - - '@types/d3-scale@4.0.9': - dependencies: - '@types/d3-time': 3.0.4 - - '@types/d3-shape@3.1.8': - dependencies: - '@types/d3-path': 3.1.1 - - '@types/d3-time@3.0.4': {} - - '@types/d3-timer@3.0.2': {} - - '@types/estree@1.0.9': {} - - '@types/json-schema@7.0.15': {} - - '@types/json5@0.0.29': {} - - '@types/node@22.19.19': - dependencies: - undici-types: 6.21.0 - - '@types/react-dom@19.2.3(@types/react@19.2.15)': - dependencies: - '@types/react': 19.2.15 - - '@types/react@19.2.15': - dependencies: - csstype: 3.2.3 - - '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.59.4 - '@typescript-eslint/type-utils': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.4 - eslint: 9.39.4 - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.59.4 - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.59.4 - debug: 4.4.3 - eslint: 9.39.4 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.59.4(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) - '@typescript-eslint/types': 8.59.4 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.59.4': - dependencies: - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/visitor-keys': 8.59.4 - - '@typescript-eslint/tsconfig-utils@8.59.4(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.59.4(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.4 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.59.4': {} - - '@typescript-eslint/typescript-estree@8.59.4(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.59.4(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@5.9.3) - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/visitor-keys': 8.59.4 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.1 - tinyglobby: 0.2.16 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.59.4(eslint@9.39.4)(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@typescript-eslint/scope-manager': 8.59.4 - '@typescript-eslint/types': 8.59.4 - '@typescript-eslint/typescript-estree': 8.59.4(typescript@5.9.3) - eslint: 9.39.4 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.59.4': - dependencies: - '@typescript-eslint/types': 8.59.4 - eslint-visitor-keys: 5.0.1 - - '@unrs/resolver-binding-android-arm-eabi@1.12.2': - optional: true - - '@unrs/resolver-binding-android-arm64@1.12.2': - optional: true - - '@unrs/resolver-binding-darwin-arm64@1.12.2': - optional: true - - '@unrs/resolver-binding-darwin-x64@1.12.2': - optional: true - - '@unrs/resolver-binding-freebsd-x64@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-arm64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-loong64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-x64-gnu@1.12.2': - optional: true - - '@unrs/resolver-binding-linux-x64-musl@1.12.2': - optional: true - - '@unrs/resolver-binding-openharmony-arm64@1.12.2': - optional: true - - '@unrs/resolver-binding-wasm32-wasi@1.12.2': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - optional: true - - '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': - optional: true - - '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': - optional: true - - '@unrs/resolver-binding-win32-x64-msvc@1.12.2': - optional: true - - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - - aria-query@5.3.2: {} - - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - - array-includes@3.1.9: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.2 - get-intrinsic: 1.3.0 - is-string: 1.1.1 - math-intrinsics: 1.1.0 - - array.prototype.findlast@1.2.5: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - es-shim-unscopables: 1.1.0 - - array.prototype.findlastindex@1.2.6: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - es-shim-unscopables: 1.1.0 - - array.prototype.flat@1.3.3: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-shim-unscopables: 1.1.0 - - array.prototype.flatmap@1.3.3: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-shim-unscopables: 1.1.0 - - array.prototype.tosorted@1.1.4: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-shim-unscopables: 1.1.0 - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - - ast-types-flow@0.0.8: {} - - async-function@1.0.0: {} - - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - - axe-core@4.11.4: {} - - axobject-query@4.1.0: {} - - balanced-match@1.0.2: {} - - balanced-match@4.0.4: {} - - brace-expansion@1.1.14: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@5.0.6: - dependencies: - balanced-match: 4.0.4 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bind@1.0.9: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - callsites@3.1.0: {} - - caniuse-lite@1.0.30001793: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - client-only@0.0.1: {} - - clsx@2.1.1: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - concat-map@0.0.1: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - csstype@3.2.3: {} - - d3-array@3.2.4: - dependencies: - internmap: 2.0.3 - - d3-color@3.1.0: {} - - d3-ease@3.0.1: {} - - d3-format@3.1.2: {} - - d3-interpolate@3.0.1: - dependencies: - d3-color: 3.1.0 - - d3-path@3.1.0: {} - - d3-scale@4.0.2: - dependencies: - d3-array: 3.2.4 - d3-format: 3.1.2 - d3-interpolate: 3.0.1 - d3-time: 3.1.0 - d3-time-format: 4.1.0 - - d3-shape@3.2.0: - dependencies: - d3-path: 3.1.0 - - d3-time-format@4.1.0: - dependencies: - d3-time: 3.1.0 - - d3-time@3.1.0: - dependencies: - d3-array: 3.2.4 - - d3-timer@3.0.1: {} - - damerau-levenshtein@1.0.8: {} - - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - debug@3.2.7: - dependencies: - ms: 2.1.3 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decimal.js-light@2.5.1: {} - - deep-is@0.1.4: {} - - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - - detect-libc@2.1.2: - optional: true - - doctrine@2.1.0: - dependencies: - esutils: 2.0.3 - - dom-helpers@5.2.1: - dependencies: - '@babel/runtime': 7.29.2 - csstype: 3.2.3 - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - emoji-regex@9.2.2: {} - - es-abstract@1.24.2: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.9 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.3 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.4 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-iterator-helpers@1.3.2: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-set-tostringtag: 2.1.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - iterator.prototype: 1.1.5 - math-intrinsics: 1.1.0 - - es-object-atoms@1.1.2: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.3 - - es-shim-unscopables@1.1.0: - dependencies: - hasown: 2.0.3 - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - - escape-string-regexp@4.0.0: {} - - eslint-config-next@15.5.18(eslint@9.39.4)(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 15.5.18 - '@rushstack/eslint-patch': 1.16.1 - '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 - eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4) - eslint-plugin-react: 7.37.5(eslint@9.39.4) - eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - - eslint-import-resolver-node@0.3.10: - dependencies: - debug: 3.2.7 - is-core-module: 2.16.2 - resolve: 2.0.0-next.7 - transitivePeerDependencies: - - supports-color - - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 9.39.4 - get-tsconfig: 4.14.0 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.16 - unrs-resolver: 1.12.2 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - eslint: 9.39.4 - eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4) - transitivePeerDependencies: - - supports-color - - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.4 - eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.4(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4))(eslint@9.39.4) - hasown: 2.0.3 - is-core-module: 2.16.2 - is-glob: 4.0.3 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.59.4(eslint@9.39.4)(typescript@5.9.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4): - dependencies: - aria-query: 5.3.2 - array-includes: 3.1.9 - array.prototype.flatmap: 1.3.3 - ast-types-flow: 0.0.8 - axe-core: 4.11.4 - axobject-query: 4.1.0 - damerau-levenshtein: 1.0.8 - emoji-regex: 9.2.2 - eslint: 9.39.4 - hasown: 2.0.3 - jsx-ast-utils: 3.3.5 - language-tags: 1.0.9 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - safe-regex-test: 1.1.0 - string.prototype.includes: 2.0.1 - - eslint-plugin-react-hooks@5.2.0(eslint@9.39.4): - dependencies: - eslint: 9.39.4 - - eslint-plugin-react@7.37.5(eslint@9.39.4): - dependencies: - array-includes: 3.1.9 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.3 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.3.2 - eslint: 9.39.4 - estraverse: 5.3.0 - hasown: 2.0.3 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.5 - object.entries: 1.1.9 - object.fromentries: 2.0.8 - object.values: 1.2.1 - prop-types: 15.8.1 - resolve: 2.0.0-next.7 - semver: 6.3.1 - string.prototype.matchall: 4.0.12 - string.prototype.repeat: 1.0.0 - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@9.39.4: - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 4.2.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - esutils@2.0.3: {} - - eventemitter3@4.0.7: {} - - fast-deep-equal@3.1.3: {} - - fast-equals@5.4.0: {} - - fast-glob@3.3.1: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 - - function-bind@1.1.2: {} - - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.3 - is-callable: 1.2.7 - - functions-have-names@1.2.3: {} - - generator-function@2.0.1: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.3 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.2 - - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - - get-tsconfig@4.14.0: - dependencies: - resolve-pkg-maps: 1.0.0 - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@14.0.0: {} - - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 - - gopd@1.2.0: {} - - has-bigints@1.1.0: {} - - has-flag@4.0.0: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.3: - dependencies: - function-bind: 1.1.2 - - ignore@5.3.2: {} - - ignore@7.0.5: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - internal-slot@1.1.0: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.3 - side-channel: 1.1.0 - - internmap@2.0.3: {} - - is-array-buffer@3.0.5: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-bigint@1.1.0: - dependencies: - has-bigints: 1.1.0 - - is-boolean-object@1.2.2: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-bun-module@2.0.0: - dependencies: - semver: 7.8.1 - - is-callable@1.2.7: {} - - is-core-module@2.16.2: - dependencies: - hasown: 2.0.3 - - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - - is-date-object@1.1.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-extglob@2.1.1: {} - - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-generator-function@1.1.2: - dependencies: - call-bound: 1.0.4 - generator-function: 2.0.1 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-map@2.0.3: {} - - is-negative-zero@2.0.3: {} - - is-number-object@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-number@7.0.0: {} - - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.3 - - is-set@2.0.3: {} - - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 - - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-symbol@1.1.1: - dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 - - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.20 - - is-weakmap@2.0.2: {} - - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-weakset@2.0.4: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - isarray@2.0.5: {} - - isexe@2.0.0: {} - - iterator.prototype@1.1.5: - dependencies: - define-data-property: 1.1.4 - es-object-atoms: 1.1.2 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - has-symbols: 1.1.0 - set-function-name: 2.0.2 - - js-tokens@4.0.0: {} - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@1.0.2: - dependencies: - minimist: 1.2.8 - - jsx-ast-utils@3.3.5: - dependencies: - array-includes: 3.1.9 - array.prototype.flat: 1.3.3 - object.assign: 4.1.7 - object.values: 1.2.1 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - language-subtag-registry@0.3.23: {} - - language-tags@1.0.9: - dependencies: - language-subtag-registry: 0.3.23 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.merge@4.6.2: {} - - lodash@4.18.1: {} - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - math-intrinsics@1.1.0: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.6 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.14 - - minimist@1.2.8: {} - - ms@2.1.3: {} - - nanoid@3.3.12: {} - - napi-postinstall@0.3.4: {} - - natural-compare@1.4.0: {} - - next@15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@next/env': 15.5.18 - '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001793 - postcss: 8.4.31 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-jsx: 5.1.6(react@19.2.6) - optionalDependencies: - '@next/swc-darwin-arm64': 15.5.18 - '@next/swc-darwin-x64': 15.5.18 - '@next/swc-linux-arm64-gnu': 15.5.18 - '@next/swc-linux-arm64-musl': 15.5.18 - '@next/swc-linux-x64-gnu': 15.5.18 - '@next/swc-linux-x64-musl': 15.5.18 - '@next/swc-win32-arm64-msvc': 15.5.18 - '@next/swc-win32-x64-msvc': 15.5.18 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - - node-exports-info@1.6.0: - dependencies: - array.prototype.flatmap: 1.3.3 - es-errors: 1.3.0 - object.entries: 1.1.9 - semver: 6.3.1 - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - object-keys@1.1.1: {} - - object.assign@4.1.7: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - has-symbols: 1.1.0 - object-keys: 1.1.1 - - object.entries@1.1.9: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - - object.fromentries@2.0.8: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.2 - - object.groupby@1.0.3: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - - object.values@1.2.1: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - own-keys@1.0.1: - dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - path-parse@1.0.7: {} - - picocolors@1.1.1: {} - - picomatch@2.3.2: {} - - picomatch@4.0.4: {} - - possible-typed-array-names@1.1.0: {} - - postcss@8.4.31: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - - punycode@2.3.1: {} - - queue-microtask@1.2.3: {} - - react-dom@19.2.6(react@19.2.6): - dependencies: - react: 19.2.6 - scheduler: 0.27.0 - - react-is@16.13.1: {} - - react-is@18.3.1: {} - - react-smooth@4.0.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - fast-equals: 5.4.0 - prop-types: 15.8.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-transition-group: 4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - - react-transition-group@4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@babel/runtime': 7.29.2 - dom-helpers: 5.2.1 - loose-envify: 1.4.0 - prop-types: 15.8.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - - react@19.2.6: {} - - recharts-scale@0.4.5: - dependencies: - decimal.js-light: 2.5.1 - - recharts@2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - clsx: 2.1.1 - eventemitter3: 4.0.7 - lodash: 4.18.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-is: 18.3.1 - react-smooth: 4.0.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - recharts-scale: 0.4.5 - tiny-invariant: 1.3.3 - victory-vendor: 36.9.2 - - reflect.getprototypeof@1.0.10: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 - - regexp.prototype.flags@1.5.4: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 - - resolve-from@4.0.0: {} - - resolve-pkg-maps@1.0.0: {} - - resolve@2.0.0-next.7: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.2 - node-exports-info: 1.6.0 - object-keys: 1.1.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - reusify@1.1.0: {} - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - safe-array-concat@1.1.4: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 - - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - - scheduler@0.27.0: {} - - semver@6.3.1: {} - - semver@7.8.1: {} - - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.1 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - optional: true - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - source-map-js@1.2.1: {} - - stable-hash@0.0.5: {} - - stop-iteration-iterator@1.1.0: - dependencies: - es-errors: 1.3.0 - internal-slot: 1.1.0 - - string.prototype.includes@2.0.1: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - - string.prototype.matchall@4.0.12: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - regexp.prototype.flags: 1.5.4 - set-function-name: 2.0.2 - side-channel: 1.1.0 - - string.prototype.repeat@1.0.0: - dependencies: - define-properties: 1.2.1 - es-abstract: 1.24.2 - - string.prototype.trim@1.2.10: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.2 - has-property-descriptors: 1.0.2 - - string.prototype.trimend@1.0.9: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - - strip-bom@3.0.0: {} - - strip-json-comments@3.1.1: {} - - styled-jsx@5.1.6(react@19.2.6): - dependencies: - client-only: 0.0.1 - react: 19.2.6 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - tiny-invariant@1.3.3: {} - - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - ts-api-utils@2.5.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - tsconfig-paths@3.15.0: - dependencies: - '@types/json5': 0.0.29 - json5: 1.0.2 - minimist: 1.2.8 - strip-bom: 3.0.0 - - tslib@2.8.1: {} - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typed-array-byte-length@1.0.3: - dependencies: - call-bind: 1.0.9 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.9 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.9 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 - - typescript@5.9.3: {} - - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - - undici-types@6.21.0: {} - - unrs-resolver@1.12.2: - dependencies: - napi-postinstall: 0.3.4 - optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.12.2 - '@unrs/resolver-binding-android-arm64': 1.12.2 - '@unrs/resolver-binding-darwin-arm64': 1.12.2 - '@unrs/resolver-binding-darwin-x64': 1.12.2 - '@unrs/resolver-binding-freebsd-x64': 1.12.2 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 - '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 - '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 - '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 - '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 - '@unrs/resolver-binding-linux-x64-musl': 1.12.2 - '@unrs/resolver-binding-openharmony-arm64': 1.12.2 - '@unrs/resolver-binding-wasm32-wasi': 1.12.2 - '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 - '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 - '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - victory-vendor@36.9.2: - dependencies: - '@types/d3-array': 3.2.2 - '@types/d3-ease': 3.0.2 - '@types/d3-interpolate': 3.0.4 - '@types/d3-scale': 4.0.9 - '@types/d3-shape': 3.1.8 - '@types/d3-time': 3.0.4 - '@types/d3-timer': 3.0.2 - d3-array: 3.2.4 - d3-ease: 3.0.1 - d3-interpolate: 3.0.1 - d3-scale: 4.0.2 - d3-shape: 3.2.0 - d3-time: 3.1.0 - d3-timer: 3.0.1 - - which-boxed-primitive@1.1.1: - dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 - - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.2 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.20 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - - which-typed-array@1.1.20: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.9 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - word-wrap@1.2.5: {} - - yocto-queue@0.1.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/__tests__/LoginPage.test.tsx b/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx new file mode 100644 index 00000000..d82503dc --- /dev/null +++ b/tradein-mvp/frontend/src/app/login/__tests__/LoginPage.test.tsx @@ -0,0 +1,111 @@ +/** + * #2766 — отказ по частоте на форме входа должен читаться как отказ по частоте. + * + * PR #2712/#2717 ввели 429 при насыщении проверок пароля. До этого любой не-401 + * падал в общую фразу «Не удалось войти…», а человек, которого притормозил + * лимит, читал её как проблему сети и долбил форму дальше. Здесь закреплено, + * что 429 даёт СВОЁ сообщение, и что оно не путается ни с «неверный пароль», + * ни с общей ошибкой. + * + * Подменяется `fetch`, а не `loginErrorMessage`: так в тесте работает настоящая + * `apiFetch` и настоящий `HTTPError` — то есть проверяется весь путь от кода + * ответа до текста на экране, а не одна функция в вакууме. + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import LoginPage from "../page"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), +})); + +/** + * Ответ на POST /auth/login задаётся тестом; всё остальное (поллинг непрочитанных + * у виджета поддержки на этой же странице) отвечает пустым 200, чтобы фон не + * подмешивал свои ошибки в проверяемое состояние формы. + */ +function stubFetch(loginStatus: number, loginBody: unknown) { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/auth/login")) { + return new Response(JSON.stringify(loginBody), { + status: loginStatus, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("{}", { status: 200 }); + }), + ); +} + +function renderLogin() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +} + +function submitCredentials() { + fireEvent.change(screen.getByLabelText("Логин"), { + target: { value: "praktika" }, + }); + fireEvent.change(screen.getByLabelText("Пароль"), { + target: { value: "hunter2" }, + }); + fireEvent.submit(screen.getByRole("form", { name: "Вход в Меру" })); +} + +describe("LoginPage — сообщение об ошибке входа", () => { + beforeEach(() => vi.unstubAllGlobals()); + afterEach(() => vi.unstubAllGlobals()); + + it("429 — сообщение про частоту попыток, а не про пароль и не общее", async () => { + stubFetch(429, { detail: "Too Many Requests" }); + renderLogin(); + submitCredentials(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent( + "Слишком много попыток. Попробуйте через несколько минут", + ); + expect(alert).not.toHaveTextContent("Неверный логин или пароль"); + expect(alert).not.toHaveTextContent("Не удалось войти"); + }); + + it("401 — сообщение про пароль (429-ветка не поглощает остальные коды)", async () => { + stubFetch(401, { detail: "Invalid credentials" }); + renderLogin(); + submitCredentials(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Неверный логин или пароль"); + expect(alert).not.toHaveTextContent("Слишком много попыток"); + }); + + it("500 — общая фраза (её текст не должен подменять собой 429)", async () => { + stubFetch(500, { detail: "boom" }); + renderLogin(); + submitCredentials(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent( + "Не удалось войти. Проверьте подключение и попробуйте ещё раз", + ); + }); + + it("до отправки формы сообщения об ошибке нет", async () => { + stubFetch(429, { detail: "Too Many Requests" }); + renderLogin(); + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ); + }); +}); 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"; + +/** Значение