diff --git a/.claude/rules/delegation.md b/.claude/rules/delegation.md index 2483f5a0..1821f007 100644 --- a/.claude/rules/delegation.md +++ b/.claude/rules/delegation.md @@ -20,6 +20,9 @@ Эмпирика 2026-06-27: агент-аудитор на 186k tok / 33 calls упал на StructuredOutput; 5 мелких параллельных прошли. - Поверхность больше бюджета → **дели на N узких сабагентов** (parallel при непересекающихся файлах, sequential при зависимостях). НЕ один большой. - Промпт сабагенту: конкретный deliverable + формат ответа + границы («что НЕ делать»). Расплывчатый scope = дубли и мусор. +- **Бюджет живёт В ПРОМПТЕ, а не в голове оркестратора.** Знать лимит недостаточно — агент его не видит. Пиши в промпт явно: потолок вызовов (~20-25) и времени, список «строго запрещено» (типично: не читать исходники приложения, не ходить в git-историю, не диффать смежное), правило деградации «бюджет кончается → отдай что есть, допиши в notes что не успел». + Эмпирика 2026-08-24: два разведчика без потолка ушли на 157 и 178 ходов вместо инвентаризации — один вместо списка веток диффал SQL-миграции и разбирал Caddyfile. +- **`schema:` требует потолка РАЗМЕРА ответа, отдельно от токенов.** Payload `StructuredOutput` >~10k символов не парсится (`InputValidationError`) → повтор → вся работа агента теряется. В промпт: максимум N items, лимит символов на поле, весь ответ ≤~6000 символов, и прямым текстом «неполный ответ несравнимо лучше потерянного». Схему проектируй под краткость: длинные `detail`-поля провоцируют ровно этот отказ. - Windows: очень длинный промпт субагенту может упасть на лимите командной строки (~8191 символ) — ещё один довод за компактность. ## Эскалация oversized-задачи (worker) @@ -28,6 +31,13 @@ Issue/задача выглядит больше одного захода (эв - bot-pipeline: комментарий с планом сплита + label `status/needs-analysis`, снять claim - interactive: вернуть main-сессии план сплита вместо результата +## Целость результата workflow + +- **Завершившийся прогон ≠ успешный.** Читай `` в уведомлении и `journal.jsonl` (по строке `result` на агента). Упавшие агенты возвращают `null`, `parallel()` их молча проглатывает, а стадия синтеза всё равно выдаёт уверенный текст с числами. Прежде чем показывать такой вердикт пользователю — проверь его несущие числа сам. +- **Восстановление:** `TaskStop` → `Workflow({scriptPath, resumeFromRunId})`. Готовые агенты реплеятся из кэша бесплатно, перезапускаются только упавшие. Правка промпта перезапускает ЭТОТ агент и все последующие (правило префикса) — правь точечно, не переписывай скрипт целиком. +- **Диагностика зависшего агента:** возраст последней записи в `agent-*.jsonl` + тип последнего события. `assistant/tool_use` без ответа при неподвижном журнале = завис. Несколько агентов замолчали одновременно = обрыв соединения, обычно лечится сам повтором — не спеши убивать. +- **Windows: скрипт workflow писать только в LF.** Перезапись через python даёт CRLF → запуск отбивается `script contains control characters`. `io.open(..., 'w', newline='\n')`. + ## Единые пороги дробления (analyst / main) - Estimate S(<2h) / M(2-8h) / **L(>8h) → обязан дробиться дальше** (до S/M) diff --git a/.claude/rules/deploy.md b/.claude/rules/deploy.md index c01e72de..95419c2f 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/*.sh`, `.forgejo/workflows/deploy.yml` → `deploy.yml` (main Site Finder stack) +- `ops/*.sh` (#2203) — любой скрипт непосредственно в `ops/` уезжает на VM автоматически, дополнять `paths:` вручную для нового `ops/.sh` не нужно. ⚠️ Одиночная звёздочка не пересекает `/` — **новый подкаталог** внутри `ops/` (по образцу `ops/db-bootstrap/`, `ops/glitchtip-auth-forwarder/`) под этот глоб не попадает и требует своей отдельной строки в `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/tradein.md b/.claude/rules/tradein.md index f54f8d34..3eb19d62 100644 --- a/.claude/rules/tradein.md +++ b/.claude/rules/tradein.md @@ -41,9 +41,21 @@ In-app scheduler (`scrape_schedules`, tick 60s, `python -m app.scheduler_main`, `tradein-mvp/backend/data/sql/NN_*.sql` применяется автоматически на деплое через `_schema_migrations` в `.forgejo/workflows/deploy-tradein.yml` (НЕ init-only, strict exit-1). Idempotency критична — -деструктивный DDL хитит прод на деплое. NN-нумерация уже 3-значная и ИМЕЕТ коллизии (`108_*` ×2, -`084_*` ×2) → перед новым файлом `ls tradein-mvp/backend/data/sql | grep '^NN'` на дубль basename, -не доверяй `tail`. +деструктивный DDL хитит прод на деплое. + +**Номер новой миграции сверяй с `origin/main`, не с локальным `ls`** — локальное дерево не видит +миграций, смерженных после ветвления (так разъехались 212 в #2682 и 234 в #2754): + +```bash +git fetch origin main +git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail +``` + +`-r` обязателен — без него `ls-tree` печатает сам каталог одной строкой, а не файлы. + +Правило целиком — в докстринге `tradein-mvp/backend/tests/test_migration_numbering.py` (единственная +формулировка контракта, #2683); он же гейтит его в CI. Дописывать имя в какой-либо список НЕ надо: +`_manifest_applied.txt` удалён — он отставал и по построению не мог покраснеть. ## Rapid-merge trap diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 522cba14..d5764313 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -51,9 +51,25 @@ jobs: # ОДИН сьют (та же дыра закрыта симметрично в ci.yml) — так на main # уехал красный test_get_role_known_users (2026-07-30 → PR #2587). - 'auth/**' + # Реестр городов — фронтовый файл, но его читает БЭКЕНДОВЫЙ тест + # (tests/test_public_mera_api.py сверяет то, что мы предлагаем + # выбрать, с тем, на что умеет отвечать проба покрытия). Без этой + # строки правка одного лишь дропдауна не гоняла бы сверку — а + # разошлись списки ровно так: город добавили на фронте, в пороги + # покрытия не внесли, и житель Серова получал «вы вне области». + - 'tradein-mvp/frontend/src/lib/city-registry.ts' - '.forgejo/workflows/ci-tradein.yml' frontend: - 'tradein-mvp/frontend/**' + # Caddyfile — по той же причине, что auth/** у бэкенда: он лежит в + # КОРНЕ репы, но его читает фронтовый тест + # (mera-public/__tests__/public-perimeter.test.ts) — тот сверяет, + # что каждый маршрут публичного сайта действительно раздаётся на + # meraocenka.ru. Без этой строки правка одного лишь Caddyfile не + # запускала бы НИ ОДИН гейт, и удаление короткого адреса из + # allowlist уехало бы на main зелёным — а на сайте кнопка «Проверить» + # стала бы ссылкой в 404. + - 'Caddyfile' - '.forgejo/workflows/ci-tradein.yml' browser: # Сайдкар — сервис ВНЕ uv-воркспейса (tradein-mvp/pyproject.toml @@ -85,6 +101,21 @@ jobs: CI_PG: ci-pg-tradein-${{ github.run_id }} steps: - uses: actions/checkout@v4 + with: + # ПОЛНАЯ история, а не дефолтный depth=1 (#2683). + # tests/test_migration_numbering.py сверяет номер новой миграции с + # origin/main и с точкой ветвления. Ровно этот флаг их и даёт: при + # depth=0 checkout идёт refspec'ом `+refs/heads/*:refs/remotes/origin/*` + # (видно в логе прогона), при depth=1 — только `+:refs/remotes/ + # pull/N/head`, то есть ни ветки main, ни общего предка в клоне нет. + # Дотянуть main отдельным `git fetch` НЕЛЬЗЯ: из job-контейнера + # git.gendsgn.ru:443 недостижим (проверено, run 6977 — connection + # refused), сеть есть только у самого checkout. + # + # Гейт при отсутствии эталона краснеет, а не пропускается: молча + # пропущенная проверка и есть тот зелёный, который ничего не проверяет. + # Пак репозитория ~33 MiB — полный fetch дешевле разбора коллизии на проде. + fetch-depth: 0 - name: Поднять Postgres и собрать схему tradein working-directory: . @@ -116,7 +147,7 @@ jobs: # бы, а тесты всё равно скипались. run: | set -u - docker rm -f "$CI_PG" >/dev/null 2>&1 || true + 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 @@ -148,16 +179,13 @@ jobs: "CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE ROLE gendesign_reader;" + # Исключений НЕТ (#2990). Раньше здесь пропускалась 077 — единственная + # миграция, читающая foreign table через postgres_fdw, которой в CI нет. + # Пропуск означал, что гейт не проверял ровно тот файл, который потом + # ронял чистый старт на реальном железе. Теперь 077 сама выходит раньше + # обращения к FDW, если мигрировать нечего, и в CI проходит честно. 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 @@ -183,13 +211,22 @@ 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'ов — сьют гоняется целиком (#2722). # @@ -221,7 +258,7 @@ jobs: # отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры. if: always() working-directory: . - run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true + run: docker rm -fv "$CI_PG" >/dev/null 2>&1 || true # Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ: # ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 53e1d59b..93f3d075 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -65,6 +65,62 @@ jobs: python3 scripts/check-workflow-ports.py --selftest python3 scripts/check-workflow-ports.py + - name: "Guard: Caddy import покрыт volume-маунтом (#3102)" + # Тем же шагом-соседом и по той же причине: дёшево, на каждом PR, + # падение блокирует merge. + # + # ЗАЧЕМ. 2026-08-26 сюда доехал PR, который завёл `import + # ../metrics-*.caddy.snippet` в caddy/sites/infra.caddy, но не добавил + # bind-mount этих файлов в docker-compose.prod.yml. `caddy validate` + # ниже эту дыру НЕ ловит: он копирует ВЕСЬ каталог caddy/ как есть + # (`docker cp caddy ...`), а на проде смонтированы только отдельные + # файлы и два каталога — расхождение между "что лежит в репозитории" и + # "что реально видит контейнер" видно только на реальных маунтах. + # Итог того PR: Caddy на проде не смог адаптировать конфиг, ушёл в + # restart-loop и уронил ВСЕ сайты хоста на ~30 минут. + run: | + python3 scripts/check-caddy-snippet-mounts.py --selftest + python3 scripts/check-caddy-snippet-mounts.py + + - name: "Guard: Caddyfile синтаксически валиден" + # Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит + # на КАЖДОМ PR, стоит секунды, падение блокирует merge. + # + # ЗАЧЕМ. До 16.08.2026 конфиг прокси не проверял НИКТО — ни один + # workflow не звал `caddy validate`/`adapt` (grep по .forgejo/). При + # этом deploy.yml применяет его не через `reload` (тот отказался бы + # принять битый конфиг и оставил бы старый работать), а через + # `up -d --force-recreate caddy`: синтаксическая ошибка уводит контейнер + # в crash-loop, и ложатся ВСЕ домены сразу — gendsgn.ru, meraocenka.ru, + # obsidian, status. То есть цена опечатки в этом файле — полный + # даунтайм, а гейта на неё не было. + # + # `docker cp`, а НЕ `-v "$PWD:/etc/caddy"`. Job сам исполняется внутри + # контейнера, и `docker run` создаёт КОНТЕЙНЕР-БРАТ на том же демоне: + # путь в `-v` резолвится на ХОСТЕ, а `$PWD` — это путь внутри job- + # контейнера, которого на хосте нет. Первая версия этого шага так и + # упала: `open /etc/caddy/Caddyfile: no such file or directory`. + # Копирование не зависит от того, как смонтирован workspace. + # + # Образ тот же `caddy:2`, что в docker-compose.prod.yml — проверяем ровно + # тем парсером, который будет читать конфиг на проде. + # + # Копируем и `caddy/` — Caddyfile делает `import caddy/users.caddy.snippet`, + # и без него validate упадёт на импорте (файл в репозитории есть). + # + # Плейсхолдеры окружения ({env.*}) при validate резолвятся в пустую + # строку — это нормально, синтаксис от их значений не зависит. + run: | + set -euo pipefail + cid=$(docker create -w /work caddy:2 \ + caddy validate --config /work/Caddyfile --adapter caddyfile) + docker cp Caddyfile "$cid:/work/Caddyfile" + docker cp caddy "$cid:/work/caddy" + rc=0 + docker start -a "$cid" || rc=$? + docker rm -f "$cid" >/dev/null + exit "$rc" + - name: "Guard: блокирующий DDL без lock_timeout (#2752)" # Тем же шагом-соседом и по той же причине: гейт бежит на КАЖДОМ PR, # включая tradein-only (у ci.yml нет paths-фильтра на уровне workflow — @@ -74,6 +130,36 @@ jobs: python3 scripts/check-migration-lock-timeout.py --selftest python3 scripts/check-migration-lock-timeout.py + - name: "Guard: shell-скрипты синтаксически валидны (#2917)" + # Соседям по этому job'у (caddy validate, lock_timeout) — тот же довод: + # дёшево, на каждом PR, ловит опечатку до прода. + # + # ЗАЧЕМ ИМЕННО ЭТО. scripts/smoke-mera-perimeter.sh — единственная + # проверка, которая видит публичный периметр МЕРЫ целиком, и до этого + # PR она запускалась только ночным cron'ом. Опечатка в ней обнаружилась + # бы следующим утром — и выглядела бы как регресс периметра, а не как + # сломанный скрипт. Ни один линтер шелла в репозитории не стоит + # (shellcheck нет), поэтому берём то, что есть в каждом образе: `bash -n` + # разбирает файл, не исполняя его. + # + # ГРАНИЦА: `bash -n` ловит СИНТАКСИС, а не смысл — неверный URL или + # перепутанный ожидаемый код он не увидит. Это не замена прогона, + # а защита от того, что скрипт вообще не запустится. + run: | + set -euo pipefail + found=0 + for f in $(git ls-files 'scripts/*.sh' 'ops/*.sh' 'ops/**/*.sh'); do + found=$((found + 1)) + bash -n "$f" || { echo "::error file=$f::синтаксическая ошибка в shell-скрипте"; exit 1; } + done + # Ноль файлов означал бы, что гейт молча ничего не проверяет — + # ровно тот случай, когда зелёный шаг не значит ничего (#2871). + if [ "$found" -eq 0 ]; then + echo "::error::не найдено ни одного .sh — гейт бы прошёл впустую, проверь маску" + exit 1 + fi + echo "✓ синтаксис проверен у $found shell-скриптов" + - uses: dorny/paths-filter@v3 id: filter with: @@ -89,6 +175,15 @@ jobs: # переведён в expired, test_get_role_known_users стал красным и # доехал до main незамеченным (починен в PR #2587). - 'auth/**' + # Тот же класс, что и с auth/** выше (#2950). В backend/tests/ops/ + # лежат гейты на сами workflow-файлы — например «оба прод-деплоя + # обязаны быть в одной группе concurrency». Правка, разводящая + # группы обратно, не трогает 'backend/**' → без этих строк + # backend-tests пропускался бы, гейт не исполнялся, и регрессия + # уезжала в main зелёной. Гейт, который не запускается на той самой + # правке, от которой стережёт, — украшение. + - '.forgejo/workflows/deploy.yml' + - '.forgejo/workflows/deploy-tradein.yml' - '.forgejo/workflows/ci.yml' frontend: - 'frontend/**' @@ -142,7 +237,7 @@ jobs: # здесь не нужен вовсе, в отличие от tradein-лэйна. run: | set -u - docker rm -f "$CI_PG" >/dev/null 2>&1 || true + 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 @@ -233,11 +328,18 @@ jobs: # coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge). # term-missing → видно непокрытые строки прямо в job-логе. run: | + # #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) — @@ -246,20 +348,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: docker rm -f "$CI_PG" >/dev/null 2>&1 || true + 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-infra.yml b/.forgejo/workflows/deploy-infra.yml new file mode 100644 index 00000000..004a50d8 --- /dev/null +++ b/.forgejo/workflows/deploy-infra.yml @@ -0,0 +1,184 @@ +name: Deploy Infra Host + +# Синхронизация /opt/gendesign на ХОСТЕ, КОТОРЫЙ ОСТАЁТСЯ (#3059, #3057). +# +# ЗАЧЕМ. Сегодня Beget — и прод, и инфраструктура одновременно, поэтому его +# рабочее дерево обновляет обычный `deploy.yml` (шаг `git reset --hard +# origin/main` по SSH на `secrets.DEPLOY_HOST`). После переезда 30.08 +# `DEPLOY_HOST` станет указывать на Selectel — и /opt/gendesign на Beget +# перестанет обновляться СОВСЕМ. Молча. +# +# А из этого каталога на Beget продолжат работать: +# - cron-скрипты бэкапов: ops/backup.sh, ops/backup-forgejo.sh, +# ops/check-backup-staleness.sh, ops/lib-backup.sh, ops/docker-prune.sh +# - docker-compose.prod.yml для Forgejo / GlitchTip / CouchDB +# - Caddyfile + caddy/sites/infra.caddy — единственный публичный вход для +# git.gendsgn.ru, errors.gendsgn.ru, obsidian.gendsgn.ru (#3062) +# +# То есть любая будущая правка этих файлов легла бы в main и никогда не доехала +# до машины, которая их исполняет. Это ровно класс #2887 («скрипт запускается по +# cron из /opt/gendesign, куда попадает только через git reset --hard шага +# деплоя»), но не на уровне одного файла, а на уровне целого хоста. +# +# ПОЧЕМУ ОТДЕЛЬНЫЙ WORKFLOW, А НЕ JOB В deploy.yml. Разные адресаты и разные +# вердикты: «выкатили приложение на Selectel» и «синхронизировали инфраструктуру +# на Beget» — два независимых факта, и падение второго не должно читаться как +# неудавшийся деплой продукта. Плюс триггеры разные: инфра-хосту не нужны +# пересборки backend/frontend. +# +# ИНЕРТЕН, ПОКА НЕ ЗАДАН INFRA_DEPLOY_HOST. Сейчас, до переезда, Beget и есть +# DEPLOY_HOST — второй проход по тому же хосту был бы лишним и мог бы состязаться +# с основным деплоем за докер-демон (#2950). Поэтому job не делает ничего, пока +# секрет пуст: включается ОДНОЙ настройкой в момент, когда хосты разъедутся. + +# ── ПОДЛИННОСТЬ ХОСТА (#3029) ──────────────────────────────────────────────── +# Переезд 30.08 (#3057) уводит цель деплоя на Selectel, а раннеры оставляет на +# Beget — SSH становится междоузловым, через интернет. Поэтому у вызова +# appleboy/ssh-action ниже появился вход `fingerprint`. +# ЧТО ЗАДАТЬ: секрет INFRA_DEPLOY_SSH_FINGERPRINT = +# ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}' +# (значение с префиксом `SHA256:`; именно ecdsa — см. разбор в deploy.yml). +# ПОБАЙТОВО: значение сравнивается как есть, без trim — лишний пробел/перевод +# строки при копипасте включает проверку и роняет ssh-шаг с `host key +# fingerprint mismatch`. +# ПОКА СЕКРЕТ НЕ ЗАДАН — поведение прежнее: пустой fingerprint у easyssh-proxy +# v1.5.0 означает ssh.InsecureIgnoreHostKey(), то есть ровно как до этого PR. +# Включается одной настройкой, как INFRA_DEPLOY_HOST (#3059) и fail-open у +# TRADEIN_INTERNAL_AUTH_SECRET (#2989). +# ───────────────────────────────────────────────────────────────────────────── +on: + push: + branches: [main] + paths: + # Ровно то, что исполняется НА ОСТАЮЩЕМСЯ хосте. Намеренно НЕ включены + # backend/** и frontend/** — их образы туда не едут. + - "ops/*.sh" + # ops/*.cron — эталоны crontab. Деплой их не исполняет, но после + # разъезда хостов (#3057) правка crontab-beget.cron иначе доезжала бы + # только до продового хоста: строка ops/*.cron есть лишь в deploy.yml. + # Одиночная звёздочка не пересекает `/`, поэтому это именно файлы в + # корне ops/, как и ops/*.sh рядом. + - "ops/*.cron" + - "Caddyfile" + - "caddy/**" + - "docker-compose.prod.yml" + - "docker-compose.obsidian.yml" + - ".forgejo/workflows/deploy-infra.yml" + workflow_dispatch: + +jobs: + sync-infra-host: + runs-on: ubuntu-latest + steps: + - name: Проверить, разъехались ли хосты + id: gate + env: + INFRA_HOST: ${{ secrets.INFRA_DEPLOY_HOST }} + run: | + set -euo pipefail + if [ -z "${INFRA_HOST:-}" ]; then + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "INFRA_DEPLOY_HOST не задан — хосты ещё не разъехались." + echo "Инфраструктуру обновляет обычный deploy.yml. Ничего не делаю." + else + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "INFRA_DEPLOY_HOST задан — синхронизирую остающийся хост." + fi + + # #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть + # громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет + # ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как + # защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у + # кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь — + # «инертно по умолчанию, включается одной настройкой». Заведут секрет — + # предупреждение исчезнет само. + - name: Подлинность хоста — статус проверки (#3029) + if: steps.gate.outputs.enabled == 'true' + env: + HOST_FINGERPRINT: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }} + run: | + set -euo pipefail + if [ -n "${HOST_FINGERPRINT:-}" ]; then + echo "Подлинность хоста: сверяется по INFRA_DEPLOY_SSH_FINGERPRINT." + else + echo '::warning title=SSH без проверки подлинности хоста::INFRA_DEPLOY_SSH_FINGERPRINT не задан — ключ остающегося хоста НЕ проверяется (#3029). Фолбэка на DEPLOY_SSH_FINGERPRINT здесь нет и быть не должно: это другая машина. По каналу едет INFRA_DEPLOY_SSH_KEY и выполняется git reset на /opt/gendesign. Как снять отпечаток — см. шапку этого файла.' + echo '###############################################################' + echo '# ВНИМАНИЕ (#3029): INFRA_DEPLOY_SSH_FINGERPRINT не задан.' + echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.' + echo '# Как снять отпечаток — см. шапку этого файла.' + echo '###############################################################' + fi + + - name: Синхронизировать /opt/gendesign на остающемся хосте + if: steps.gate.outputs.enabled == 'true' + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.INFRA_DEPLOY_HOST }} + username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }} + key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT }} + # #3029: БЕЗ фолбэка на DEPLOY_SSH_FINGERPRINT — в отличие от user/key/port + # выше. Те у двух хостов совпадают, а отпечаток — это идентичность + # КОНКРЕТНОЙ машины: после переезда INFRA_DEPLOY_HOST=Beget, а + # DEPLOY_HOST=Selectel, и фолбэк означал бы сверку ключа Beget'а с + # отпечатком Selectel'а — гарантированный отказ ровно у того workflow, + # который чинит остающийся хост. Пусто → проверка пропускается. + fingerprint: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }} + script: | + set -euo pipefail + cd /opt/gendesign + git fetch origin main + git reset --hard origin/main + + # Тот же chmod, что и в deploy.yml: cron зовёт скрипты через `bash`, + # но restore-drill и ручные запуски рассчитывают на +x. + chmod +x ops/*.sh 2>/dev/null || true + + # Caddy здесь несёт ТОЛЬКО остающиеся домены (CADDY_SITES=infra). + # reload, а не recreate: конфиг примонтирован read-only, контейнер + # читает тот же файл, который только что обновил git. Если конфиг + # невалиден — reload откажет ГРОМКО, а старый останется работать, + # то есть падение здесь не роняет git/errors/obsidian. + if docker ps --format '{{.Names}}' | grep -q '^gendesign-caddy-1$'; then + docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \ + caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile + echo "✓ конфиг прокси перезагружен" + else + echo "⚠ контейнер caddy не найден — пропускаю reload" + fi + + # infra-postgres (#3061): применить изменения конфигурации сервиса. + # После разъезда хостов этот workflow — ЕДИНСТВЕННОЕ, что доставляет + # docker-compose.prod.yml на Beget, а лёгкий кластер БД описан именно + # там. Без этой строки любой будущий бамп postgres:16-alpine, правка + # mem_limit или healthcheck'а легли бы в main, workflow отрапортовал + # бы «дерево синхронизировано», а контейнер продолжил бы жить со + # старой конфигурацией по `restart: unless-stopped` — молча, ровно + # класс #2887, ради которого этот workflow и написан. + # + # `up -d` с ЯВНЫМ именем сервиса, а не общий: трогается только + # infra-postgres, до Forgejo / GlitchTip / CouchDB дела нет. Явное + # имя заодно активирует профиль `infra` само по себе, без оглядки на + # COMPOSE_PROFILES. Если конфигурация не менялась — compose ничего не + # пересоздаёт, шаг стоит доли секунды. + # + # СОЗДАВАТЬ кластер отсюда мы НЕ хотим — отсюда проверка на + # существующий контейнер. Первый старт — осознанный ручной шаг окна + # переезда (шаг 2 в docker-compose.prod.yml), и делается он с уже + # заполненными INFRA_PG_PASSWORD / FORGEJO_DB_PASS. Стартуй мы вслепую + # — пустой пароль дал бы отравленный том, который потом не + # переинициализировать. + if docker ps -a --format '{{.Names}}' | grep -qx 'gendesign-infra-postgres'; then + docker compose -p gendesign -f docker-compose.prod.yml up -d infra-postgres + echo "✓ infra-postgres приведён к конфигурации из main" + else + echo "⚠ контейнер gendesign-infra-postgres не найден — кластер ещё не поднят вручную, пропускаю" + fi + + # НАМЕРЕННО НЕ ДЕЛАЕТСЯ: + # - docker image prune: конкурирует с деплоем продукта за leases + # докер-демона (#2950). Прун на этом хосте остаётся за + # еженедельным ops/docker-prune.sh. + # - перезапуск Forgejo / GlitchTip / CouchDB: правка ops-скрипта + # не повод ронять git. Их обновление — осознанное действие. + echo "✓ рабочее дерево синхронизировано: $(git rev-parse --short HEAD)" diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml new file mode 100644 index 00000000..51764969 --- /dev/null +++ b/.forgejo/workflows/deploy-metrics.yml @@ -0,0 +1,335 @@ +name: Deploy Metrics + +# Деплой стека наблюдаемости (#3078). Двухсторонний, и это существенно: +# +# server — на ИНФРАСТРУКТУРНЫЙ хост (Beget): Prometheus, Loki, Grafana, +# Alertmanager. Наблюдатель намеренно живёт у другого провайдера, +# чем наблюдаемое. +# agent — на ОБА хоста: node-exporter, cAdvisor, postgres-exporter, Alloy. +# Агент на продуктовом хосте шлёт push'ем, поэтому там не открывается +# ни одного входящего порта. +# +# Продуктовый стек не трогается вовсе: другой project-name, другие compose-файлы, +# deploy.yml остаётся в стороне. + +on: + push: + branches: [main] + paths: + - "docker-compose.metrics.yml" + - "docker-compose.metrics-agent.yml" + - "ops/metrics/**" + - "caddy/sites/infra.caddy" + - "caddy/metrics-ui.caddy.snippet" + - "caddy/metrics-ingest.caddy.snippet" + # Глоб, а не точечный `setup-metrics-secrets.sh` (#2203: класс бага, а не + # один файл). Деплой запускает ТРИ setup-скрипта — secrets, grafana-role и + # exporter-dsn, — а в триггере стоял только первый: правка двух остальных + # не заводила выкат, и на хосте продолжала исполняться старая версия молча. + - "scripts/setup-metrics-*.sh" + - ".forgejo/workflows/deploy-metrics.yml" + workflow_dispatch: + +concurrency: + group: deploy-metrics + cancel-in-progress: false + +jobs: + # ═══ СЕРВЕРНАЯ СТОРОНА — инфраструктурный хост ════════════════════════════ + server: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + # Тот же приём, что в deploy-obsidian.yml (#3062, #3029): адресат и отпечаток + # берутся В ПАРЕ, без перекрёстного фолбэка. Сверять ключ Beget'а с отпечатком + # Poincare — гарантированный отказ. + - name: Адресат и подлинность инфраструктурного хоста + id: target + env: + INFRA_HOST: ${{ secrets.INFRA_DEPLOY_HOST }} + INFRA_FINGERPRINT: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }} + MAIN_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} + run: | + set -euo pipefail + if [ -n "${INFRA_HOST:-}" ]; then + HOST_FINGERPRINT="${INFRA_FINGERPRINT:-}" + SRC="INFRA_DEPLOY_SSH_FINGERPRINT" + else + HOST_FINGERPRINT="${MAIN_FINGERPRINT:-}" + SRC="DEPLOY_SSH_FINGERPRINT" + fi + case "${HOST_FINGERPRINT}" in + *[![:print:]]*) + echo "ОШИБКА: ${SRC} содержит перевод строки или непечатный символ." >&2 + exit 1 + ;; + esac + echo "fingerprint=${HOST_FINGERPRINT}" >> "$GITHUB_OUTPUT" + if [ -z "${HOST_FINGERPRINT:-}" ]; then + echo "::warning title=SSH без проверки подлинности хоста::${SRC} не задан — ключ хоста НЕ проверяется (#3029)." + fi + + - name: Поднять серверный стек + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }} + username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }} + key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }} + fingerprint: ${{ steps.target.outputs.fingerprint }} + command_timeout: 15m + script: | + set -euo pipefail + cd /opt/gendesign + + git fetch origin main + git reset --hard origin/main + + docker network inspect gendesign_shared >/dev/null 2>&1 \ + || docker network create gendesign_shared + + # Окружение нужно в shell, а не только в env_file: проверки вида + # ${VAR:?} у compose работают по переменным ОКРУЖЕНИЯ ПРОЦЕССА. + if [ -f backend/.env.runtime ]; then + set -a; . backend/.env.runtime; set +a + fi + + # ── Проверка ДО подъёма, а не после ──────────────────────────── + # Пустой токен даёт Alertmanager, который стартует зелёным и молча + # ничего не шлёт. Это ровно тот класс тихого отказа, ради которого + # весь стек и заводится, — ловим на пороге. + missing="" + for v in GRAFANA_ADMIN_PASSWORD GLITCHTIP_RO_PASSWORD \ + METRICS_INGEST_USER METRICS_INGEST_PASSWORD; do + eval "val=\${$v:-}" + [ -z "$val" ] && missing="$missing $v" + done + if [ -n "$missing" ]; then + echo "ОШИБКА: в окружении хоста не заданы:$missing" + echo "Запусти один раз: bash scripts/setup-metrics-secrets.sh" + exit 1 + fi + + # Алерты включаются, только когда канал доставки реально задан. + # Поднимать Alertmanager с пустым токеном нельзя: он стартует + # зелёным и молча ничего не шлёт — ровно тот тихий отказ, ради + # которого весь стек и заводится. + PROFILES="" + if [ -n "${METRICS_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${METRICS_TELEGRAM_CHAT_ID:-}" ]; then + PROFILES="alerts" + mkdir -p ops/metrics/alertmanager + + # Топик форумной группы (#3078). Необязателен: без него алерты + # уходят в общую тему. Подставляем ЦЕЛОЙ СТРОКОЙ, а не значением, + # потому что envsubst не умеет условий — при пустом + # METRICS_TELEGRAM_TOPIC_ID в конфиг попал бы `message_thread_id:` + # без значения, и Alertmanager не стартовал бы вовсе. + if [ -n "${METRICS_TELEGRAM_TOPIC_ID:-}" ]; then + METRICS_TELEGRAM_TOPIC_LINE=" message_thread_id: ${METRICS_TELEGRAM_TOPIC_ID}" + echo "Алерты: адресуются в топик ${METRICS_TELEGRAM_TOPIC_ID}." + else + METRICS_TELEGRAM_TOPIC_LINE="" + echo "Алерты: топик не задан — уйдут в общую тему чата." + fi + + METRICS_TELEGRAM_BOT_TOKEN="$METRICS_TELEGRAM_BOT_TOKEN" \ + METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \ + METRICS_TELEGRAM_TOPIC_LINE="$METRICS_TELEGRAM_TOPIC_LINE" \ + envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_TOPIC_LINE}' \ + < ops/metrics/alertmanager/alertmanager.yml.tmpl \ + > ops/metrics/alertmanager/alertmanager.yml + chmod 600 ops/metrics/alertmanager/alertmanager.yml + + # Проверяем ДО подъёма, как и Caddyfile ниже. Битый конфиг + # Alertmanager не «деградирует» — контейнер не стартует вовсе, и + # алертинг молча исчезает целиком. amtool берём из того же образа, + # что и сам Alertmanager, иначе проверяли бы не ту версию схемы. + if ! docker run --rm \ + -v "$PWD/ops/metrics/alertmanager/alertmanager.yml:/tmp/am.yml:ro" \ + --entrypoint amtool "$(grep -oE 'prom/alertmanager:[^ ]+' docker-compose.metrics.yml | head -1)" \ + check-config /tmp/am.yml; then + echo "ОШИБКА: конфиг Alertmanager не проходит проверку — стек не поднимаем." + exit 1 + fi + echo "Алерты: канал задан, конфиг проверен, Alertmanager поднимается." + else + echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078." + fi + + # ── read-only роль для датасорса GlitchTip ───────────────────── + # Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana + # обязан быть безопасен даже при полном доступе к дашбордам. + bash scripts/setup-metrics-grafana-role.sh + + COMPOSE_PROFILES="$PROFILES" \ + docker compose -p gendesign-metrics -f docker-compose.metrics.yml pull --quiet + COMPOSE_PROFILES="$PROFILES" \ + docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d --remove-orphans + + # ── Caddy: СНАЧАЛА проверить, потом применять ────────────────── + # На этом хосте тот же Caddy обслуживает git., errors. и obsidian. + # Синтаксическая ошибка в infra.caddy положила бы их все, включая + # сам Forgejo, из которого идёт деплой. Поэтому validate — обязателен, + # и reload делается только после успешной проверки. + if docker compose -p gendesign -f docker-compose.prod.yml ps caddy --quiet | grep -q .; then + if docker compose -p gendesign -f docker-compose.prod.yml \ + exec -T caddy caddy validate --config /etc/caddy/Caddyfile; then + docker compose -p gendesign -f docker-compose.prod.yml \ + exec -T caddy caddy reload --config /etc/caddy/Caddyfile + echo "Caddy: конфиг проверен и перезагружен." + else + echo "ОШИБКА: Caddyfile не проходит проверку — reload НЕ выполнен." + echo "Работающий Caddy не тронут, домены живы. Чинить конфиг и повторять." + exit 1 + fi + fi + + # ── Приёмка ──────────────────────────────────────────────────── + for i in $(seq 1 30); do + if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then + break + fi + sleep 3 + done + docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps + + # ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════ + agent-apps: + runs-on: ubuntu-latest + needs: server + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - name: Агент на продуктовом хосте + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.DEPLOY_PORT || 22 }} + fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} + command_timeout: 15m + script: | + set -euo pipefail + cd /opt/gendesign + + git fetch origin main + git reset --hard origin/main + + docker network inspect gendesign_shared >/dev/null 2>&1 \ + || docker network create gendesign_shared + + if [ -f backend/.env.runtime ]; then + set -a; . backend/.env.runtime; set +a + fi + + if [ -z "${METRICS_INGEST_PASSWORD:-}" ]; then + echo "ОШИБКА: METRICS_INGEST_PASSWORD не задан — агенту нечем авторизоваться." + echo "Запусти на инфраструктурном хосте: bash scripts/setup-metrics-secrets.sh" + exit 1 + fi + + # DSN экспортеров собираются из уже имеющихся паролей БД, если их + # ещё нет. Отдельных секретов не заводим — лишняя копия пароля это + # лишнее место, откуда он может утечь. + bash scripts/setup-metrics-exporter-dsn.sh + + set -a; . backend/.env.runtime; set +a + + # Профиль экспортеров БД включаем, только если DSN реально собрались. + # Раньше их обязательность стояла в compose (`${VAR:?}`), но compose + # интерполирует ВЕСЬ файл до фильтрации по профилям — и продуктовый + # агент падал на INFRA_EXPORTER_DSN, переменной сервиса, который тут + # не поднимается вовсе. Проверка переехала сюда, где роль известна. + # + # Не падаем, а предупреждаем: alloy / node-exporter / cadvisor и сбор + # логов не должны отваливаться из-за одного ненастроенного экспортера. + # Тот же приём, что у Alertmanager в джобе server выше. + EXPORTER_PROFILE="" + missing_dsn="" + [ -n "${GENDESIGN_EXPORTER_DSN:-}" ] || missing_dsn="$missing_dsn GENDESIGN_EXPORTER_DSN" + [ -n "${TRADEIN_EXPORTER_DSN:-}" ] || missing_dsn="$missing_dsn TRADEIN_EXPORTER_DSN" + if [ -z "$missing_dsn" ]; then + EXPORTER_PROFILE="apps" + else + echo "::warning title=Метрики БД не собираются::не заполнены:$missing_dsn. Хостовые метрики и логи поедут, метрик Postgres не будет. Проверь, что scripts/setup-metrics-exporter-dsn.sh нашёл DATABASE_URL/TRADEIN_DATABASE_URL." + fi + + METRICS_ROLE=apps \ + METRICS_ALLOY_CONFIG=alloy-apps.alloy \ + COMPOSE_PROFILES="$EXPORTER_PROFILE" \ + docker compose -p gendesign-metrics-agent \ + -f docker-compose.metrics-agent.yml pull --quiet + + METRICS_ROLE=apps \ + METRICS_ALLOY_CONFIG=alloy-apps.alloy \ + COMPOSE_PROFILES="$EXPORTER_PROFILE" \ + docker compose -p gendesign-metrics-agent \ + -f docker-compose.metrics-agent.yml up -d + + sleep 10 + METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \ + docker compose -p gendesign-metrics-agent \ + -f docker-compose.metrics-agent.yml ps + + agent-infra: + runs-on: ubuntu-latest + needs: server + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - name: Агент на инфраструктурном хосте + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }} + username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }} + key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }} + fingerprint: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }} + command_timeout: 15m + script: | + set -euo pipefail + cd /opt/gendesign + + if [ -f backend/.env.runtime ]; then + set -a; . backend/.env.runtime; set +a + fi + + # Симметрично продуктовому агенту: профиль экспортера включаем, + # только если DSN есть. Без этого гарда экспортер поднялся бы с + # ПУСТЫМ DATA_SOURCE_NAME (в compose теперь `:-`, а не `:?`) и + # молча не отдавал бы метрик — ровно тот тихий отказ, ради которого + # весь стек и заводится. + # + # NB: INFRA_EXPORTER_DSN сейчас не собирает никто — + # scripts/setup-metrics-exporter-dsn.sh знает только про + # GENDESIGN_/TRADEIN_ и работает на продуктовом хосте. Пока это так, + # ветка ниже всегда даёт предупреждение, и это честно: метрик + # инфраструктурной БД действительно нет. + EXPORTER_PROFILE="" + if [ -n "${INFRA_EXPORTER_DSN:-}" ]; then + EXPORTER_PROFILE="infra" + else + echo "::warning title=Метрики инфраструктурной БД не собираются::INFRA_EXPORTER_DSN не задан. Хостовые метрики и логи поедут, метрик Postgres инфры не будет." + fi + + METRICS_ROLE=infra \ + METRICS_ALLOY_CONFIG=alloy-infra.alloy \ + COMPOSE_PROFILES="$EXPORTER_PROFILE" \ + docker compose -p gendesign-metrics-agent \ + -f docker-compose.metrics-agent.yml pull --quiet + + METRICS_ROLE=infra \ + METRICS_ALLOY_CONFIG=alloy-infra.alloy \ + COMPOSE_PROFILES="$EXPORTER_PROFILE" \ + docker compose -p gendesign-metrics-agent \ + -f docker-compose.metrics-agent.yml up -d + + sleep 10 + METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \ + docker compose -p gendesign-metrics-agent \ + -f docker-compose.metrics-agent.yml ps diff --git a/.forgejo/workflows/deploy-obsidian.yml b/.forgejo/workflows/deploy-obsidian.yml index 1209e8b2..cbba26d2 100644 --- a/.forgejo/workflows/deploy-obsidian.yml +++ b/.forgejo/workflows/deploy-obsidian.yml @@ -18,6 +18,36 @@ name: Deploy Obsidian # единственная директория, которую реально исполняет этот инстанс. # См. issue #2416. +# ── ПОДЛИННОСТЬ ХОСТА (#3029) ──────────────────────────────────────────────── +# Переезд 30.08 (#3057) уводит цель деплоя на Selectel, а раннеры оставляет на +# Beget — SSH становится междоузловым, через интернет. Поэтому у вызова +# appleboy/ssh-action ниже появился вход `fingerprint`. +# ЧТО ЗАДАТЬ: секрет DEPLOY_SSH_FINGERPRINT = +# ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}' +# (значение с префиксом `SHA256:`; именно ecdsa — см. разбор в deploy.yml). +# ПОБАЙТОВО: значение сравнивается как есть, без trim — лишний пробел/перевод +# строки при копипасте включает проверку и роняет ssh-шаг с `host key +# fingerprint mismatch`. +# ПОКА СЕКРЕТ НЕ ЗАДАН — поведение прежнее: пустой fingerprint у easyssh-proxy +# v1.5.0 означает ssh.InsecureIgnoreHostKey(), то есть ровно как до этого PR. +# Включается одной настройкой, как INFRA_DEPLOY_HOST (#3059) и fail-open у +# TRADEIN_INTERNAL_AUTH_SECRET (#2989). +# АДРЕСАТ (#3062). CouchDB/Obsidian ОСТАЁТСЯ на Beget вместе с Forgejo и +# GlitchTip, а DEPLOY_HOST после 30.08 будет указывать на Selectel. Раньше этот +# workflow ходил на DEPLOY_HOST безусловно — то есть в день переезда молча начал +# бы разворачивать стек CouchDB не на той машине: git reset на /opt/gendesign +# продуктового хоста, а волт на Beget тем временем перестал бы обновляться. +# Отказа при этом не было бы — деплой зелёный, адресат другой. +# +# Теперь адресат берётся как INFRA_DEPLOY_HOST, а если он не задан — DEPLOY_HOST. +# До переезда это одна и та же машина, поэтому поведение не меняется; после — +# workflow сам остаётся на инфраструктурном хосте, без правки этого файла. +# +# Отпечаток идёт В ПАРЕ с адресатом и БЕЗ перекрёстного фолбэка: сверять ключ +# Beget'а с отпечатком Selectel'а — гарантированный отказ. Задан INFRA_DEPLOY_HOST +# → берётся INFRA_DEPLOY_SSH_FINGERPRINT; не задан → DEPLOY_SSH_FINGERPRINT. +# Пусто в выбранной ветке → проверка подлинности пропускается, как и раньше. +# ───────────────────────────────────────────────────────────────────────────── on: push: branches: [main] @@ -39,13 +69,74 @@ jobs: steps: - uses: actions/checkout@v4 + # #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть + # громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет + # ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как + # защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у + # кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь — + # «инертно по умолчанию, включается одной настройкой». Заведут секрет — + # предупреждение исчезнет само. + - name: Адресат и подлинность хоста (#3062, #3029) + id: target + env: + INFRA_HOST: ${{ secrets.INFRA_DEPLOY_HOST }} + INFRA_FINGERPRINT: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }} + MAIN_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} + run: | + set -euo pipefail + # Отпечаток — публичный хеш ключа хоста, не секрет: его можно + # передать через output. Приватный ключ так передавать нельзя, + # поэтому он остаётся прямой ссылкой на секрет в шаге ниже. + if [ -n "${INFRA_HOST:-}" ]; then + echo "Адресат: INFRA_DEPLOY_HOST — хосты разъехались, стек CouchDB едет на инфраструктурный хост." + HOST_FINGERPRINT="${INFRA_FINGERPRINT:-}" + FINGERPRINT_SOURCE="INFRA_DEPLOY_SSH_FINGERPRINT" + else + echo "Адресат: DEPLOY_HOST — INFRA_DEPLOY_HOST не задан, хосты ещё одна машина." + HOST_FINGERPRINT="${MAIN_FINGERPRINT:-}" + FINGERPRINT_SOURCE="DEPLOY_SSH_FINGERPRINT" + fi + # $GITHUB_OUTPUT — формат «ключ=значение» построчно, поэтому перевод + # строки внутри значения означает инъекцию произвольного output'а. + # Отпечаток однострочный по определению (SHA256:...), а вот копипаста + # в поле секрета лишний \n добавляет легко — шапка этого файла об этом + # прямо предупреждает. Не вычищаем молча: сверка побайтовая, тихий trim + # изменил бы результат проверки. Падаем с внятным текстом. + case "${HOST_FINGERPRINT}" in + *[![:print:]]*) + echo "ОШИБКА: ${FINGERPRINT_SOURCE} содержит перевод строки или непечатный символ." >&2 + echo "ОШИБКА: значение должно быть одной строкой вида SHA256:xxxx — перезадай секрет без лишних символов." >&2 + exit 1 + ;; + esac + echo "fingerprint=${HOST_FINGERPRINT}" >> "$GITHUB_OUTPUT" + if [ -n "${HOST_FINGERPRINT:-}" ]; then + echo "Подлинность хоста: сверяется по ${FINGERPRINT_SOURCE}." + else + echo "::warning title=SSH без проверки подлинности хоста::${FINGERPRINT_SOURCE} не задан — ключ хоста НЕ проверяется (#3029). По каналу едет ssh-ключ и разворачивается стек CouchDB/Obsidian. После разъезда хостов (#3057) соединение идёт через интернет. Как снять отпечаток — см. шапку этого файла." + echo '###############################################################' + echo "# ВНИМАНИЕ (#3029): ${FINGERPRINT_SOURCE} не задан." + echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.' + echo '# Как снять отпечаток — см. шапку этого файла.' + echo '###############################################################' + fi + - name: Deploy obsidian stack via SSH uses: appleboy/ssh-action@v1.0.3 with: - host: ${{ secrets.DEPLOY_HOST }} - username: ${{ secrets.DEPLOY_USER }} - key: ${{ secrets.DEPLOY_SSH_KEY }} - port: ${{ secrets.DEPLOY_PORT || 22 }} + # #3062: адресат — инфраструктурный хост, если хосты уже разъехались. + # До этого INFRA_DEPLOY_HOST пуст и всё идёт на DEPLOY_HOST, как раньше. + host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }} + # user/key/port с фолбэком: у двух хостов они совпадают, а отдельные + # INFRA_*-секреты может и не завести — тогда работают общие. + username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }} + key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }} + # #3029: подлинность хоста. Отпечаток выбран шагом выше В ПАРЕ с + # адресатом — перекрёстного фолбэка здесь быть не должно, иначе после + # переезда ключ Beget'а сверялся бы с отпечатком Selectel'а. + # Пусто → easyssh-proxy оставляет ssh.InsecureIgnoreHostKey(), как сегодня. + fingerprint: ${{ steps.target.outputs.fingerprint }} script: | set -euo pipefail cd /opt/gendesign diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index d45dd831..90b98b1a 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -3,6 +3,30 @@ name: Deploy Trade-In # Forgejo Actions — отдельный pipeline для подпроекта tradein-mvp/. # Триггерится только на изменения внутри tradein-mvp/ (или этого workflow), # не пересекается с основным deploy.yml. + +# ── ПОДЛИННОСТЬ ХОСТА (#3029) ──────────────────────────────────────────────── +# Переезд 30.08 (#3057) уводит цель деплоя на Selectel, а Forgejo и раннеры +# оставляет на Beget — SSH перестаёт быть петлёй и идёт через интернет. В этом +# workflow ДВА разных SSH-канала, и закрываются они по-разному: +# 1) шаг "Deploy via SSH" (appleboy/ssh-action) → вход `fingerprint`, +# секрет DEPLOY_SSH_FINGERPRINT; +# 2) шаг "Resolve deployed base SHA" — обычный openssh-клиент, ему нужен +# known_hosts, а не SHA256-строка → секрет DEPLOY_KNOWN_HOSTS. +# Как снять значения: +# ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}' +# → DEPLOY_SSH_FINGERPRINT (с префиксом `SHA256:`; почему именно ecdsa — +# см. разбор в deploy.yml: дефолт x/crypto ставит ecdsa выше ed25519) +# ssh-keyscan -p <порт> <хост> +# → DEPLOY_KNOWN_HOSTS (все типы ключей сразу, без -t) +# ПОБАЙТОВО: fingerprint сравнивается как есть, без trim — лишний пробел или +# перевод строки при копипасте включает проверку и роняет ssh-шаг с `host key +# fingerprint mismatch`. +# ПОКА СЕКРЕТЫ НЕ ЗАДАНЫ — поведение прежнее в обоих каналах: пустой fingerprint +# у easyssh-proxy v1.5.0 это ssh.InsecureIgnoreHostKey(), а второй шаг остаётся +# на StrictHostKeyChecking=no, но печатает громкое предупреждение. Включается +# одной настройкой, как INFRA_DEPLOY_HOST (#3059) и fail-open у +# TRADEIN_INTERNAL_AUTH_SECRET (#2989). Ничего не удаляем — только добавляем. +# ───────────────────────────────────────────────────────────────────────────── on: push: branches: [main] @@ -11,8 +35,23 @@ on: - ".forgejo/workflows/deploy-tradein.yml" workflow_dispatch: +# #2950: ОБЩАЯ группа с deploy-tradein.yml — не опечатка и не копипаста. +# Оба деплоя ходят по SSH в ОДИН докер-демон (стеки gendesign-* и tradein-* +# плюс сам forgejo-runner живут на одной VM), и `docker image prune -af` одного +# сносит leases ещё не доехавшего `compose pull` другого: +# unable to lease content: lease does not exist: not found +# 20.08 так и вышло: run 8083 упал за 5с — прун соседнего деплоя отработал через +# 0.4с после обрыва пула. Прод остался на старом коде, при том что голова main +# показывала success (зелёным был чужой, Trade-In'овый деплой той же головы). +# Разные группы + cancel-in-progress: false не спасают: false сериализует раны +# ВНУТРИ группы, а гонка была МЕЖДУ группами. +# Цена: деплои ждут друг друга целиком, вместе с билдами (~6 мин). Осознанно: +# host-lock (flock) сериализовал бы только докер-секцию, но у него своя отказная +# мода — дочерний процесс наследует fd лока и при аварийной смерти job'а лок +# залипает (проверено на хосте: после kill -9 лок остался занят). Сериализацию +# гарантирует планировщик Forgejo, залипать там нечему. concurrency: - group: deploy-tradein-prod + group: deploy-prod cancel-in-progress: false env: @@ -61,24 +100,72 @@ jobs: DEPLOY_USER: ${{ secrets.DEPLOY_USER }} DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + # #3029: строки known_hosts прод-хоста. DEPLOY_SSH_FINGERPRINT здесь НЕ + # подходит: ниже обычный openssh-клиент, а не Go-клиент ssh-action'а, и + # SHA256-отпечаток он на вход не принимает — ему нужен known_hosts. + # Получить: ssh-keyscan -p <порт> <хост> (без -t: пусть в секрете лежат + # все типы ключей сразу, тогда выбор алгоритма клиентом ничего не ломает). + DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} run: | # Write SSH key to a temp file SSH_KEY_FILE=$(mktemp) echo "$DEPLOY_SSH_KEY" > "$SSH_KEY_FILE" chmod 600 "$SSH_KEY_FILE" + # #3029: подлинность хоста для ЭТОГО канала. Раньше здесь стояло + # безусловное -o StrictHostKeyChecking=no, то есть ключ хоста не + # проверялся никогда. После переезда (#3057) соединение идёт через + # интернет, поэтому: секрет задан → пишем known_hosts и требуем + # StrictHostKeyChecking=yes; не задан → оставляем ровно сегодняшнее + # поведение, но ГРОМКО об этом сообщаем. Инертно по умолчанию: пустой + # секрет = поведение до этого PR бит в бит. + KNOWN_HOSTS_FILE=$(mktemp) + if [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$KNOWN_HOSTS_FILE" + chmod 600 "$KNOWN_HOSTS_FILE" + SSH_HOST_OPTS=(-o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$KNOWN_HOSTS_FILE") + echo "Подлинность хоста: сверяется по DEPLOY_KNOWN_HOSTS." + else + SSH_HOST_OPTS=(-o StrictHostKeyChecking=no) + echo "::warning title=SSH без проверки подлинности хоста::DEPLOY_KNOWN_HOSTS не задан — ключ прод-хоста НЕ проверяется (#3029). После переезда на Selectel (#3057) этот SSH идёт через интернет: задайте секрет через ssh-keyscan -p <порт> <хост>." + echo "################################################################" + echo "# ВНИМАНИЕ (#3029): DEPLOY_KNOWN_HOSTS не задан. #" + echo "# Подлинность прод-хоста НЕ проверяется — канал уязвим к MITM. #" + echo "# Задать секрет: ssh-keyscan -p <порт> <хост> #" + echo "################################################################" + fi + # Try to read the marker file from the VPS. Suppress errors — if host is # unreachable or file missing, RAW_SHA will be empty. + # #3029: сюда же попадает и расхождение ключа хоста. Шаг fail-safe по + # построению — пустой RAW_SHA уводит в build-all ниже, — поэтому цена + # ошибки в known_hosts здесь максимум лишняя полная пересборка, а не + # сорванный деплой. Это и делает включение проверки безопасным. + SSH_ERR_FILE=$(mktemp) RAW_SHA=$(ssh -i "$SSH_KEY_FILE" \ - -o StrictHostKeyChecking=no \ + "${SSH_HOST_OPTS[@]}" \ -o ConnectTimeout=10 \ -p "${DEPLOY_PORT:-22}" \ "${DEPLOY_USER}@${DEPLOY_HOST}" \ "cat /opt/gendesign/.tradein-deployed-sha 2>/dev/null || true" \ - 2>/dev/null || true) + 2>"$SSH_ERR_FILE" || true) RAW_SHA=$(echo "$RAW_SHA" | tr -d '[:space:]') - rm -f "$SSH_KEY_FILE" + # #3029: раньше stderr уходил в /dev/null, и «Host key verification + # failed» был неотличим от недоступного хоста — неверный known_hosts + # молча читался как штатный фолбэк на полную пересборку. Теперь эта + # причина называется отдельно. Fail-safe шага не меняется: RAW_SHA всё + # равно пуст, ветка build-all включается ровно как прежде. + if grep -qiE 'host key verification failed|remote host identification has changed|no matching host key' "$SSH_ERR_FILE"; then + echo '::warning title=Ключ хоста не сошёлся с DEPLOY_KNOWN_HOSTS::Проверка подлинности хоста НЕ прошла (#3029) — это не «хост недоступен», а расхождение known_hosts: сменился ключ хоста либо переехал адрес (#3057). Обновите секрет DEPLOY_KNOWN_HOSTS через ssh-keyscan -p <порт> <хост>. Шаг fail-safe: сейчас включится полная пересборка.' + echo "Причина пустого RAW_SHA: проверка ключа хоста, а не недоступность." + sed 's/^/ ssh: /' "$SSH_ERR_FILE" + elif [ -s "$SSH_ERR_FILE" ]; then + echo "ssh stderr (не про ключ хоста — хост недоступен либо иная ошибка):" + sed 's/^/ ssh: /' "$SSH_ERR_FILE" + fi + + rm -f "$SSH_KEY_FILE" "$KNOWN_HOSTS_FILE" "$SSH_ERR_FILE" # Validate: non-empty, looks like a git SHA, and is an ancestor of HEAD. DEPLOYED_SHA="" @@ -176,6 +263,11 @@ jobs: DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test steps: - uses: actions/checkout@v4 + with: + # Как в ci-tradein.yml: tests/test_migration_numbering.py (#2683) требует + # origin/main и общего предка с HEAD, а даёт их именно depth=0 — при + # depth=1 checkout тянет один sha и ветки main в клоне нет. + fetch-depth: 0 - name: Install uv # Официальный standalone-инсталлер: системный `pip install uv` на @@ -227,10 +319,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 @@ -238,6 +369,8 @@ jobs: context: ./tradein-mvp file: ./tradein-mvp/backend/Dockerfile push: true + labels: | + org.opencontainers.image.revision=${{ github.sha }} # APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см. # backend/Dockerfile ARG→ENV) — читает app/core/version.py: # GET /api/v1/trade-in/version + колонтитул PDF-отчёта. @@ -251,6 +384,54 @@ jobs: ${{ 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 + labels: | + org.opencontainers.image.revision=${{ github.sha }} + 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 @@ -267,8 +448,41 @@ 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 за пределы контекста, @@ -279,10 +493,16 @@ jobs: 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 push: true + labels: | + org.opencontainers.image.revision=${{ github.sha }} # basePath=/trade-in baked-in во время build (Next.js) # NB (#2205): НЕ передаём NEXT_PUBLIC_ENABLE_PREVIEW — preview-роут # (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в @@ -303,6 +523,49 @@ jobs: ${{ 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 + labels: | + org.opencontainers.image.revision=${{ github.sha }} + 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 @@ -321,20 +584,96 @@ 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 push: true + labels: | + org.opencontainers.image.revision=${{ github.sha }} cache-from: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max tags: | ${{ 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 + labels: | + org.opencontainers.image.revision=${{ github.sha }} + 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] @@ -348,6 +687,46 @@ jobs: needs.build-frontend.result != 'failure' && needs.build-browser.result != 'failure' steps: + # ── #2950: :latest не старше последнего коммита по компоненту ───────────── + # См. комментарий к тому же шагу в deploy.yml и scripts/check-latest-image-revision.sh. + # Пути = фильтры job'а changes (backend/frontend/browser + infra), которые + # приводят к сборке соответствующего образа. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Login to GHCR — для imagetools inspect гарда (#2950) + env: + GHCR_PAT: ${{ secrets.GHCR_PAT }} + run: echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Гард свежести :latest (#2950) + run: | + INFRA="tradein-mvp/docker-compose.prod.yml tradein-mvp/deploy .forgejo/workflows/deploy-tradein.yml" + scripts/check-latest-image-revision.sh "$IMAGE_BACKEND" 900 -- tradein-mvp/backend tradein-mvp/packages/scraper-kit tradein-mvp/VERSION $INFRA + scripts/check-latest-image-revision.sh "$IMAGE_FRONTEND" 900 -- tradein-mvp/frontend tradein-mvp/VERSION tradein-mvp/CHANGELOG.md $INFRA + scripts/check-latest-image-revision.sh "$IMAGE_BROWSER" 900 -- tradein-mvp/browser $INFRA + # #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть + # громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет + # ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как + # защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у + # кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь — + # «инертно по умолчанию, включается одной настройкой». Заведут секрет — + # предупреждение исчезнет само. + - name: Подлинность хоста — статус проверки (#3029) + env: + HOST_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} + run: | + set -euo pipefail + if [ -n "${HOST_FINGERPRINT:-}" ]; then + echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT." + else + echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029): при пустом отпечатке easyssh-proxy молча оставляет InsecureIgnoreHostKey. По этой же SSH-сессии едут GHCR_PAT и секреты Trade-In вместе с DEPLOY_SSH_KEY. После переезда на Selectel (#3057) канал идёт через интернет. Как снять отпечаток — см. шапку этого файла.' + echo '###############################################################' + echo '# ВНИМАНИЕ (#3029): DEPLOY_SSH_FINGERPRINT не задан.' + echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.' + echo '# Как снять отпечаток — см. шапку этого файла.' + echo '###############################################################' + fi + - name: Deploy via SSH uses: appleboy/ssh-action@v1.0.3 env: @@ -377,9 +756,48 @@ jobs: username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.DEPLOY_PORT }} + # #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy + # оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение. + fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA script: | set -euo pipefail + # #2950: взаимное исключение докер-секции двух прод-деплоев. + # Деплой ПТИЦЫ и деплой Trade-In ходят по SSH в ОДИН докер-демон — + # стеки gendesign-*, tradein-* и сам forgejo-runner живут на этой VM. + # Каждый в конце делает `docker image prune -af`, и прун одного сносит + # leases ещё не доехавшего `compose pull` другого: + # unable to lease content: lease does not exist: not found + # 20.08 так и вышло: run 8083 упал за 5с (прун соседа отработал через + # 0.4с после обрыва пула), прод остался на старом коде. + # + # Секция `concurrency: deploy-prod` в шапке обоих workflow этого НЕ + # обеспечивает: на Forgejo 10.0.3 (gitea-1.22) workflow-level + # concurrency не исполняется — проверено, обе цепочки стартовали на + # одном коммите одновременно. Она оставлена как декларация, которая + # заработает после обновления Forgejo; сегодня работает вот этот лок. + # + # Лок держит живой потомок этого скрипта. Если ssh-сессия оборвётся, + # докер-команды на хосте продолжат работу — и лок продолжит их + # прикрывать, что и требуется. Ожидание ограничено: не дождались за + # 900с — падаем с внятным сообщением, а не молча ждём вечно. + exec 9>/var/lock/gendesign-docker-deploy.lock + # Сначала неблокирующая попытка — чтобы ОЖИДАНИЕ оставляло след в логе. + # Без этого работающий лок ненаблюдаем: flock при успехе молчит, и отличить + # «второй деплой дождался первого» от «они просто разошлись по времени» + # нельзя — а именно это и есть критерий приёмки #2950. + if flock -n 9; then + echo "→ докер-лок свободен, взят сразу" + else + echo "→ докер-лок занят соседним деплоем, жду (до 900с)…" + lock_wait_started=$(date +%s) + if ! flock -w 900 9; then + echo "ERROR: не дождался лока докер-деплоя за 900с." + echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock" + exit 1 + fi + echo "→ докер-лок получен через $(( $(date +%s) - lock_wait_started ))с ожидания" + fi cd /opt/gendesign # repo уже clone'ен — origin = Forgejo. Подтягиваем последний main. @@ -398,6 +816,11 @@ jobs: chmod 600 .env.runtime set -a; source .env.runtime; set +a + # Re-assert +x на deploy-скриптах (#3005, по образцу deploy.yml ops/*.sh из #71). + # Cron зовёт backup-tradein-db.sh через `bash`, так что бит ему не нужен — + # но любой другой вызов сырым путём не должен зависеть от git-режима файла. + chmod +x deploy/*.sh 2>/dev/null || true + # External network для Caddy (он в основном gendesign-стеке) docker network inspect gendesign_shared >/dev/null 2>&1 \ || docker network create gendesign_shared @@ -405,8 +828,47 @@ jobs: # Re-login to GHCR (PAT может быть rotated) echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + # ── Набор compose-файлов (#3059) ────────────────────────────────── + # docker-compose.selectel.yml закрепляет рабочий IP api.telegram.org + # через extra_hosts у backend и tgbot. Файл существовал с 23.08, но НИ + # ОДИН вызов ниже его не подключал — то есть первый же деплой на новом + # хосте поднял бы МЕРУ без закрепления, и бот с пересылкой алертов + # умерли бы молча: у api.telegram.org семь адресов, а с Selectel + # отвечает РОВНО ОДИН (149.154.167.220), и штатный резолвер отдаёт + # мёртвый. + # + # Подключается БЕЗУСЛОВНО, на обоих хостах. Замер 25.08 с Beget: + # 149.154.167.220 -> 302 за 0.18 с + # реальный Bot API /getMe -> {"ok":false,"error_code":401} — то есть + # отвечает именно Telegram, а не заглушка + # Условная логика «оверрайд только на Selectel» была бы лишней машинерией + # ради хоста, который через переезд перестанет быть продовым. + # + # NB: файл — оверрайд стека МЕРЫ (проект gendesign-tradein). Подмешивать + # его к стеку ПТИЦЫ нельзя: там нет сервиса tgbot, и compose отвергает + # весь проект ("has neither an image nor a build context"), а сервис + # backend есть в обоих — закрепление молча легло бы на бэкенд Птицы. + COMPOSE_FILES="-f docker-compose.prod.yml" + if [ -f docker-compose.selectel.yml ]; then + COMPOSE_FILES="$COMPOSE_FILES -f docker-compose.selectel.yml" + echo "→ compose-оверрайд: docker-compose.selectel.yml подключён (пин api.telegram.org)" + else + # ПАДАЕМ, а не предупреждаем. Файл git-tracked, а шагом выше сделан + # `git reset --hard origin/main` — значит его отсутствие означает не + # штатный сценарий, а поломку (удалили/переименовали, не поправив + # это место). Предупреждение в зелёном логе здесь было бы ровно тем + # классом тихого отказа, от которого защищает сама правка: деплой + # «успешен», а бот и пересылка алертов мертвы. Тот же принцип, что у + # health-check'ов #2214 ниже по файлу. + echo "ERROR: docker-compose.selectel.yml не найден в $(pwd)." + echo "ERROR: без него api.telegram.org не закреплён → tgbot и пересылка" + echo "ERROR: алертов умрут МОЛЧА (с Selectel отвечает 1 адрес из 7)." + echo "ERROR: если файл убран намеренно — снять и эту проверку тем же PR." + exit 1 + fi + export IMAGE_TAG="$IMAGE_TAG" - docker compose -p gendesign-tradein -f docker-compose.prod.yml pull + docker compose -p gendesign-tradein $COMPOSE_FILES pull # ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ────────── # Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое @@ -419,14 +881,32 @@ jobs: # код на старой схеме». Откат = просто ничего не поднимали. # (1) Только БД — чтобы прогнать миграции до нового app-кода. - docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres + docker compose -p gendesign-tradein $COMPOSE_FILES up -d --no-deps postgres # (2) Ждём готовности postgres (pg_isready в цикле, НЕ тупой sleep). + # + # `-h 127.0.0.1` ОБЯЗАТЕЛЕН (#2990) — по тому же образцу, что уже в + # ci-tradein.yml:157. На пустом томе образ postgres поднимает + # ВРЕМЕННЫЙ сервер с listen_addresses='' на время прогона + # docker-entrypoint-initdb.d (сюда смонтирован весь + # backend/data/sql/*.sql, см. docker-compose.prod.yml). Этот временный + # сервер отвечает "accepting connections" по unix-сокету уже через + # пару секунд — а pg_isready БЕЗ -h ходит именно по сокету через + # `docker compose exec`. Проба зеленела посреди initdb, до того как + # цепочка миграций реально доехала до конца, и код ниже (детект + # baseline vs пустая БД) видел частично накаченную схему. TCP-порт + # 5432 открывается только когда initdb.d полностью отработал и + # postgres перезапустился как настоящий сервер — проба по 127.0.0.1 + # зеленеет ровно тогда, когда БД реально готова. + # + # 90 попыток × 2с = до 3 минут: на пустом томе postgres прогоняет + # ВСЮ цепочку миграций (270+ файлов) внутри initdb, это медленнее, + # чем ожидание живого сервера на непустом томе (обычный деплой). echo "→ Ожидание готовности postgres..." pg_ready="" - for i in $(seq 1 30); do + for i in $(seq 1 90); do if docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ - pg_isready -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then + pg_isready -h 127.0.0.1 -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then pg_ready="yes"; break fi sleep 2 @@ -444,8 +924,9 @@ jobs: # Tracking через _schema_migrations (порт паттерна из deploy.yml): # каждый .sql применяется РОВНО один раз, failed migration → exit 1 # (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp. - # NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант - # #2216) glob'ом не подхватывается. + # ИМЕННО ЭТОТ цикл делает main эталоном применённого: всё, что доехало + # до main, здесь и применяется, а имя закрепляется в _schema_migrations. + # На этом стоит гейт номеров — tests/test_migration_numbering.py (#2683). # Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это # первый deploy после внедрения tracking на уже-наполненной prod-БД @@ -456,6 +937,35 @@ jobs: psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \ "SELECT to_regclass('public._schema_migrations') IS NOT NULL;" | tr -d '[:space:]') + # Sentinel (#2990): отсутствия _schema_migrations НЕДОСТАТОЧНО, чтобы + # заключить «схема уже накачена». Ровно два разных состояния дают одно + # и то же отсутствие таблицы: + # 1) наполненный прод до внедрения tracking → baseline корректен; + # 2) ПУСТАЯ БД на новом сервере → baseline пометил бы все миграции + # применёнными, ни одной не прогнав, и деплой уехал бы зелёным + # на пустой схеме. Отказ тихий и обнаружился бы уже под нагрузкой. + # + # Раньше различали одной живой таблицей listings — она создаётся + # миграцией 002, то есть почти в САМОМ НАЧАЛЕ цепочки. Этого мало: на + # пустом томе до фикса ожидания готовности (см. выше, -h 127.0.0.1) + # проба зеленела ПОСРЕДИ initdb, когда listings уже создан, а хвост + # цепочки — ещё нет; результат — тихий baseline недокачанной схемы. + # Фикс готовности эту гонку убирает (TCP открывается только после + # полного прохода initdb.d), но сентинел всё равно проверяем по ОБОИМ + # концам цепочки как defense-in-depth: если голова и хвост когда-нибудь + # разъедутся — это тот самый гоночный симптом, и его надо ловить явно, + # а не гадать. + # + # Хвост — houses_geog_gist_idx, индекс из миграции 270 (#2997, самая + # свежая на момент правки #2990). При добавлении новых миграций после + # 270 обнови этот сентинел на объект из новой последней миграции. + schema_head_present=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ + psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \ + "SELECT to_regclass('public.listings') IS NOT NULL;" | tr -d '[:space:]') + schema_tail_present=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ + psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \ + "SELECT to_regclass('public.houses_geog_gist_idx') IS NOT NULL;" | tr -d '[:space:]') + docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on -c " CREATE TABLE IF NOT EXISTS _schema_migrations ( @@ -464,12 +974,23 @@ jobs: ); " - if [ "$migrations_table_existed" != "t" ]; then - # BASELINE: таблицы не было → seed ВСЕ текущие миграции как applied - # БЕЗ их прогона. prod уже работает на этой схеме; помечаем её - # текущим состоянием, чтобы под строгий gate попадали только НОВЫЕ - # (077+) миграции. INSERT ... ON CONFLICT DO NOTHING — идемпотентно. - echo "→ _schema_migrations отсутствовала — baseline существующих миграций (без прогона)" + if [ "$migrations_table_existed" != "t" ] && [ "$schema_head_present" != "t" ] && [ "$schema_tail_present" != "t" ]; then + # ПУСТАЯ БД: ни головы, ни хвоста цепочки — baseline пропускаем + # намеренно. Цикл ниже применит всю цепочку с нуля под + # ON_ERROR_STOP — это и есть штатный путь чистого старта на новом + # сервере (initdb.d уже должен был всё применить сам; этот цикл — + # подстраховка на случай, если монтирование почему-то не сработало). + echo "→ БД пуста (нет ни _schema_migrations, ни listings, ни хвоста цепочки) — baseline ПРОПУЩЕН," + echo " вся цепочка миграций будет применена циклом ниже." + elif [ "$migrations_table_existed" != "t" ] && [ "$schema_head_present" = "t" ] && [ "$schema_tail_present" = "t" ]; then + # BASELINE: таблицы не было, но и голова, и хвост цепочки на месте → + # seed ВСЕ текущие миграции как applied БЕЗ их прогона. Это либо + # наполненный прод до внедрения tracking, либо чистый старт, где + # initdb.d уже честно доехал до конца сам (ожидание готовности это + # теперь гарантирует). В обоих случаях повторный прогон не нужен — + # помечаем текущим состоянием, чтобы под строгий gate попадали + # только НОВЫЕ миграции. INSERT ... ON CONFLICT DO NOTHING — идемпотентно. + echo "→ _schema_migrations отсутствовала, но схема на месте целиком (голова + хвост) — baseline существующих миграций (без прогона)" for sql_file in $(ls -1 backend/data/sql/*.sql 2>/dev/null | sort); do fname=$(basename "$sql_file") echo " baseline: $fname" @@ -478,6 +999,21 @@ jobs: "INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;" done echo "Baseline complete — existing schema marked as applied." + elif [ "$migrations_table_existed" != "t" ]; then + # НЕОДНОЗНАЧНО: ровно один из концов цепочки на месте, второго нет, + # а _schema_migrations отсутствует. Это и есть симптом гонки + # готовности (см. комментарий выше) — молча баселайнить тут нельзя: + # либо схема реально недокачана (baseline пометил бы недостающий + # хвост как применённый без прогона), либо и то и другое пусто, но + # тогда tail-check не должен был сработать. Падаем громко, а не + # гадаем — новый app-код НЕ поднят, старые контейнеры не тронуты. + echo "ERROR: неоднозначное состояние схемы — _schema_migrations нет," + echo " listings присутствует=${schema_head_present}, houses_geog_gist_idx присутствует=${schema_tail_present}." + echo " Похоже на недокачанную схему (гонка готовности postgres) —" + echo " baseline пропущен намеренно, чтобы не пометить недостающие" + echo " миграции применёнными без прогона. Прерываю деплой; нужен" + echo " ручной разбор состояния тома перед повторным запуском." + exit 1 fi for sql_file in $(ls -1 backend/data/sql/*.sql 2>/dev/null | sort); do @@ -662,7 +1198,7 @@ jobs: echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}" fi - docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES + docker compose -p gendesign-tradein $COMPOSE_FILES up -d --no-deps $SERVICES if [ -n "$scraper_stale" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом" @@ -869,3 +1405,88 @@ 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. + # ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ────────────────── + # + # ЗАЧЕМ ЗДЕСЬ. scripts/smoke-mera-perimeter.sh — единственная проверка, которая + # видит периметр целиком (короткие адреса, 301 с длинных, публичный API, + # закрытость B2B-путей на публичном домене). До этого PR он запускался только + # по cron'у 06:17 UTC, то есть регресс жил до суток и находил его либо ночной + # прогон, либо владелец. Для правки, чья логика живёт в конфиге прокси, это + # единственный настоящий гейт — и он был асинхронным. + # + # ПОЧЕМУ ОТДЕЛЬНЫЙ JOB, А НЕ ШАГ В deploy. Вердикты разные: «выкатили» и + # «периметр цел» — два разных факта, и красный смоук не должен читаться как + # неудавшийся деплой. Деплой к этому моменту уже прошёл; смоук говорит, что + # именно получилось. + # + # ПОЧЕМУ ДУБЛИРУЕТСЯ В ДВУХ ПАЙПЛАЙНАХ. Конфиг прокси (deploy.yml) и фронт + # МЕРЫ (deploy-tradein.yml) едут раздельно, и сломать периметр может каждый. + # `workflow_call` под act_runner не гарантирован, поэтому 20 строк повторены + # осознанно вместо зависимости, которая может молча не сработать. + perimeter-smoke: + runs-on: ubuntu-latest + needs: deploy + # Только после РЕАЛЬНОЙ выкатки: при skipped/failed проверять нечего, а + # красный смоук поверх несостоявшегося деплоя увёл бы разбор не туда. + if: always() && needs.deploy.result == 'success' + timeout-minutes: 6 + steps: + - uses: actions/checkout@v4 + + - name: Дождаться, пока периметр отвечает после пересоздания контейнеров + # `up -d --force-recreate` возвращает управление раньше, чем бэкенд + # начинает отвечать. Без ожидания смоук ловил бы не регресс, а гонку. + # Ждём ДВА признака: лэндинг (Caddy + фронт) и API (бэкенд поднялся) — + # одного мало, Caddy отвечает раньше апстрима. + run: | + set -uo pipefail + for i in $(seq 1 30); do + page=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://meraocenka.ru/ || true) + api=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://gendsgn.ru/trade-in/api/v1/me || true) + if [ "$page" = "200" ] && [ "$api" = "401" ]; then + echo "периметр отвечает (попытка $i): лэндинг $page, API $api" + exit 0 + fi + echo "ждём готовности, попытка $i/30: лэндинг '${page:-нет ответа}', API '${api:-нет ответа}'" + sleep 5 + done + # НЕ падаем здесь: вердикт должен вынести смоук, а не таймаут ожидания. + # Иначе «не успел подняться» и «периметр сломан» слились бы в один + # красный шаг без разбора. + echo "::warning::за 150 с периметр так и не ответил ожидаемо — запускаем смоук, его вывод и будет диагнозом" + + - name: Смоук периметра + run: | + chmod +x scripts/smoke-mera-perimeter.sh + ./scripts/smoke-mera-perimeter.sh + + 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 f267444a..99ef6e21 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -4,6 +4,49 @@ name: Deploy # Migration 2026-05-16: GitHub → Forgejo (git.gendsgn.ru) # Builds images on Forgejo runner, pushes to ghcr.io (GitHub Container Registry), # SSH-deploys to Beget VPS (46.173.16.127). + +# ── ПОДЛИННОСТЬ ХОСТА В ДЕПЛОЕ (#3029) ─────────────────────────────────────── +# +# ЗАЧЕМ ИМЕННО СЕЙЧАС. Пока раннер и цель деплоя — одна и та же машина (Beget, +# 46.173.16.127), SSH фактически не покидает петлю, и цена непроверенного ключа +# хоста была низкой. После переезда 30.08 (#3057) цель уезжает на Selectel +# (188.246.224.93), а Forgejo и раннеры ОСТАЮТСЯ на Beget — тот же самый SSH +# становится междоузловым и идёт через интернет. По этой сессии через `envs:` +# едут GHCR_PAT, OPENAI_API_KEY, OBJECTIVE_API_KEY, GLITCHTIP_BACKEND_DSN и сам +# DEPLOY_SSH_KEY: без проверки ключа хоста MITM на маршруте забирает их разом, +# причём молча — деплой при этом выглядит зелёным. +# +# ЧТО ЗАДАТЬ: секрет DEPLOY_SSH_FINGERPRINT — SHA256-отпечаток ХОСТОВОГО ключа +# (не деплой-ключа!). Снять с любой машины: +# ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}' +# Значение кладётся ЦЕЛИКОМ, вместе с префиксом: `SHA256:xxxxxxxx…`. +# СРАВНЕНИЕ ПОБАЙТОВОЕ и без trim. Лишний перевод строки или пробел, прилипший +# при копипасте в UI секретов, делает значение непустым — проверка ВКЛЮЧАЕТСЯ и +# все ssh-шаги падают с `host key fingerprint mismatch`. Вставлять без хвостов. +# +# ПОЧЕМУ ecdsa, А НЕ ed25519 — это грабли, на которые легко наступить. +# appleboy/ssh-action@v1.0.3 = drone-ssh 1.7.3 на easyssh-proxy v1.5.0 поверх +# golang.org/x/crypto v0.17.0. HostKeyAlgorithms клиент не задаёт, значит берётся +# дефолт x/crypto, а там (ssh/common.go, supportedHostKeyAlgos) ecdsa-sha2-nistp256 +# стоит ВЫШЕ ssh-ed25519 и rsa. Со стоковым OpenSSH согласуется ECDSA — отпечаток +# ed25519 просто не совпадёт, и деплой встанет с `host key fingerprint mismatch`. +# +# ПОЧЕМУ ЭТО НЕ ЛОМАЕТ СЕГОДНЯШНИЙ ДЕПЛОЙ. Незаданный секрет разворачивается в +# пустую строку, а easyssh-proxy v1.5.0 (easyssh.go:178) делает буквально: +# hostKeyCallback := ssh.InsecureIgnoreHostKey() +# if config.Fingerprint != "" { …сверять отпечаток… } +# То есть пустой fingerprint = поведение до этого PR бит в бит; сам drone-ssh +# описывает флаг как "default is to skip verification". Проверка включается ОДНОЙ +# настройкой — заведением секрета. Тот же приём, что уже применён в репо: +# deploy-infra.yml инертен, пока пуст INFRA_DEPLOY_HOST (#3059); CADDY_SITES; +# fail-open у TRADEIN_INTERNAL_AUTH_SECRET (#2989). Ничего не удаляем и не +# срезаем — только добавляем, пока конвейер не проехал на новый хост. +# +# ВНИМАНИЕ ПРИ ПЕРЕЕЗДЕ: сменится хост — сменится и отпечаток. Секрет надо +# обновить В ТОТ ЖЕ МОМЕНТ, когда DEPLOY_HOST начнёт указывать на Selectel, +# иначе деплой встанет. Это осознанный размен: лучше громкий отказ, чем тихий +# коннект не туда. +# ───────────────────────────────────────────────────────────────────────────── on: push: branches: [main] @@ -20,8 +63,46 @@ on: # деплоя ниже — без этого триггера правка 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 месяцами + # исполнял бы старую версию — молча и без единого сигнала. + # Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт, + # запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают + # backup.sh, restore-drill.sh, restore.sh, uptime-healthcheck.sh — точечное + # перечисление пришлось бы дополнять при каждом новом скрипте, и про это + # снова забыли бы (см. как этот самый комментарий выше был точечным про + # docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком. + - "ops/*.sh" + # Эталонные crontab'ы двух хостов (#3059). Деплоем не исполняются, но + # должны физически лежать в /opt/gendesign — иначе их нечем будет + # установить в окне: `crontab /opt/gendesign/ops/crontab-<хост>.cron`. + - "ops/*.cron" workflow_dispatch: +# #2950: ОБЩАЯ группа с deploy-tradein.yml — не опечатка и не копипаста. +# Оба деплоя ходят по SSH в ОДИН докер-демон (стеки gendesign-* и tradein-* +# плюс сам forgejo-runner живут на одной VM), и `docker image prune -af` одного +# сносит leases ещё не доехавшего `compose pull` другого: +# unable to lease content: lease does not exist: not found +# 20.08 так и вышло: run 8083 упал за 5с — прун соседнего деплоя отработал через +# 0.4с после обрыва пула. Прод остался на старом коде, при том что голова main +# показывала success (зелёным был чужой, Trade-In'овый деплой той же головы). +# Разные группы + cancel-in-progress: false не спасают: false сериализует раны +# ВНУТРИ группы, а гонка была МЕЖДУ группами. +# Цена: деплои ждут друг друга целиком, вместе с билдами (~6 мин). Осознанно: +# host-lock (flock) сериализовал бы только докер-секцию, но у него своя отказная +# мода — дочерний процесс наследует fd лока и при аварийной смерти job'а лок +# залипает (проверено на хосте: после kill -9 лок остался занят). Сериализацию +# гарантирует планировщик Forgejo, залипать там нечему. concurrency: group: deploy-prod cancel-in-progress: false @@ -38,6 +119,12 @@ jobs: backend: ${{ steps.filter.outputs.backend }} frontend: ${{ steps.filter.outputs.frontend }} infra: ${{ steps.filter.outputs.infra }} + # #2916: правка ТОЛЬКО конфига прокси. `infra` для этого не годится — он + # включает и compose, и сам workflow, где полный деплой обязателен. + # `github.event_name == 'push'` первым множителем НАМЕРЕННО: на + # workflow_dispatch у paths-filter нет диффа, и любой его ответ не должен + # уметь отключить сборку — ручной прогон обязан оставаться полным. + caddy_only: ${{ github.event_name == 'push' && steps.filter.outputs.caddy == 'true' && steps.filter.outputs.non_caddy == 'false' }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -54,14 +141,28 @@ jobs: - 'Caddyfile' - 'caddy/**' - '.forgejo/workflows/deploy.yml' + # Пара фильтров для «правка ТОЛЬКО прокси» (#2916). Одного `caddy` + # мало: он true и когда вместе с конфигом приехал бэкенд — тогда + # нужен обычный полный деплой. `non_caddy` матчит ВСЁ остальное, + # и быстрый путь включается лишь когда он false. + caddy: + - 'Caddyfile' + - 'caddy/**' + non_caddy: + - '**' + - '!Caddyfile' + - '!caddy/**' build-backend: runs-on: ubuntu-latest needs: changes if: | - needs.changes.outputs.backend == 'true' || - needs.changes.outputs.infra == 'true' || - github.event_name == 'workflow_dispatch' + needs.changes.outputs.caddy_only != 'true' && + ( + needs.changes.outputs.backend == 'true' || + needs.changes.outputs.infra == 'true' || + github.event_name == 'workflow_dispatch' + ) steps: - uses: actions/checkout@v4 @@ -71,28 +172,121 @@ 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 target: runner push: true + labels: | + org.opencontainers.image.revision=${{ github.sha }} 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 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 + labels: | + org.opencontainers.image.revision=${{ github.sha }} + 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 if: | - needs.changes.outputs.backend == 'true' || - needs.changes.outputs.infra == 'true' || - github.event_name == 'workflow_dispatch' + needs.changes.outputs.caddy_only != 'true' && + ( + needs.changes.outputs.backend == 'true' || + needs.changes.outputs.infra == 'true' || + github.event_name == 'workflow_dispatch' + ) steps: - uses: actions/checkout@v4 @@ -102,28 +296,111 @@ 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 target: runner-with-chromium push: true + labels: | + org.opencontainers.image.revision=${{ github.sha }} cache-from: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_WORKER }}:buildcache,mode=max tags: | ${{ 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 + labels: | + org.opencontainers.image.revision=${{ github.sha }} + 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 if: | - needs.changes.outputs.frontend == 'true' || - needs.changes.outputs.infra == 'true' || - github.event_name == 'workflow_dispatch' + needs.changes.outputs.caddy_only != 'true' && + ( + needs.changes.outputs.frontend == 'true' || + needs.changes.outputs.infra == 'true' || + github.event_name == 'workflow_dispatch' + ) steps: - uses: actions/checkout@v4 @@ -133,14 +410,53 @@ 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 push: true + labels: | + org.opencontainers.image.revision=${{ github.sha }} build-args: | NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.GLITCHTIP_FRONTEND_DSN }} NEXT_PUBLIC_ENVIRONMENT=production @@ -150,16 +466,105 @@ 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 + labels: | + org.opencontainers.image.revision=${{ github.sha }} + 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] if: | always() && !cancelled() && + needs.changes.outputs.caddy_only != 'true' && needs.build-backend.result != 'failure' && needs.build-worker.result != 'failure' && needs.build-frontend.result != 'failure' steps: + # ── #2950: :latest не старше последнего коммита по компоненту ───────────── + # Forgejo отменяет ещё не стартовавший deploy предыдущего run'а этой группы, + # а следующий run (например ops-only, билды пропущены) катит :latest как есть. + # 21.08.2026 10:35 прод получил новый код только потому, что билды + # предшественника успели за 70 с до pull'а. Гард читает метку ревизии из + # образа в registry (labels на build-push выше), ждёт билд предшественника + # до 15 мин и иначе падает громко — вместо тихого отката при зелёной голове. + # Пути = фильтры job'а changes, которые приводят к сборке (caddy_only не + # собирает — Caddyfile/caddy/** намеренно не в списке). + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Login to GHCR — для imagetools inspect гарда (#2950) + env: + GHCR_PAT: ${{ secrets.GHCR_PAT }} + run: echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin + - name: Гард свежести :latest (#2950) + run: | + INFRA="docker-compose.prod.yml .forgejo/workflows/deploy.yml" + scripts/check-latest-image-revision.sh "$IMAGE_BACKEND" 900 -- backend data/sql $INFRA + scripts/check-latest-image-revision.sh "$IMAGE_WORKER" 900 -- backend data/sql $INFRA + scripts/check-latest-image-revision.sh "$IMAGE_FRONTEND" 900 -- frontend $INFRA + # #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть + # громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет + # ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как + # защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у + # кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь — + # «инертно по умолчанию, включается одной настройкой». Заведут секрет — + # предупреждение исчезнет само. + - name: Подлинность хоста — статус проверки (#3029) + env: + HOST_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} + run: | + set -euo pipefail + if [ -n "${HOST_FINGERPRINT:-}" ]; then + echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT." + else + echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029): при пустом отпечатке easyssh-proxy молча оставляет InsecureIgnoreHostKey. По этой же SSH-сессии едут GHCR_PAT, OPENAI_API_KEY, OBJECTIVE_API_KEY, GLITCHTIP_BACKEND_DSN и сам DEPLOY_SSH_KEY. После переезда на Selectel (#3057) канал идёт через интернет — MITM забирает их разом, а деплой остаётся зелёным. Как снять отпечаток — см. шапку deploy.yml.' + echo '###############################################################' + echo '# ВНИМАНИЕ (#3029): DEPLOY_SSH_FINGERPRINT не задан.' + echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.' + echo '# Как снять отпечаток — см. шапку этого файла.' + echo '###############################################################' + fi + - name: Deploy to VM via SSH uses: appleboy/ssh-action@v1.0.3 env: @@ -178,14 +583,58 @@ jobs: # own-portfolio каннибализации. Non-sensitive (публичные id) → actions # variable. UNSET → каннибализация отдаёт proxy (фича дормант). OWN_DEVELOPER_IDS: ${{ vars.OWN_DEVELOPER_IDS }} + # #3029: guard на пересоздание worker'а во время активного скрап-прогона + # (временная заплатка до чекпоинтов #3074). Откат — переменная репозитория + # в 'off', без коммита. Non-sensitive → actions variable, не secret. + WORKER_RECREATE_GUARD: ${{ vars.WORKER_RECREATE_GUARD }} + WORKER_GUARD_MAX_SKIP_H: ${{ vars.WORKER_GUARD_MAX_SKIP_H }} with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.DEPLOY_PORT }} - envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS + # #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy + # оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение. + fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} + envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS,WORKER_RECREATE_GUARD,WORKER_GUARD_MAX_SKIP_H script: | set -euo pipefail + # #2950: взаимное исключение докер-секции двух прод-деплоев. + # Деплой ПТИЦЫ и деплой Trade-In ходят по SSH в ОДИН докер-демон — + # стеки gendesign-*, tradein-* и сам forgejo-runner живут на этой VM. + # Каждый в конце делает `docker image prune -af`, и прун одного сносит + # leases ещё не доехавшего `compose pull` другого: + # unable to lease content: lease does not exist: not found + # 20.08 так и вышло: run 8083 упал за 5с (прун соседа отработал через + # 0.4с после обрыва пула), прод остался на старом коде. + # + # Секция `concurrency: deploy-prod` в шапке обоих workflow этого НЕ + # обеспечивает: на Forgejo 10.0.3 (gitea-1.22) workflow-level + # concurrency не исполняется — проверено, обе цепочки стартовали на + # одном коммите одновременно. Она оставлена как декларация, которая + # заработает после обновления Forgejo; сегодня работает вот этот лок. + # + # Лок держит живой потомок этого скрипта. Если ssh-сессия оборвётся, + # докер-команды на хосте продолжат работу — и лок продолжит их + # прикрывать, что и требуется. Ожидание ограничено: не дождались за + # 900с — падаем с внятным сообщением, а не молча ждём вечно. + exec 9>/var/lock/gendesign-docker-deploy.lock + # Сначала неблокирующая попытка — чтобы ОЖИДАНИЕ оставляло след в логе. + # Без этого работающий лок ненаблюдаем: flock при успехе молчит, и отличить + # «второй деплой дождался первого» от «они просто разошлись по времени» + # нельзя — а именно это и есть критерий приёмки #2950. + if flock -n 9; then + echo "→ докер-лок свободен, взят сразу" + else + echo "→ докер-лок занят соседним деплоем, жду (до 900с)…" + lock_wait_started=$(date +%s) + if ! flock -w 900 9; then + echo "ERROR: не дождался лока докер-деплоя за 900с." + echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock" + exit 1 + fi + echo "→ докер-лок получен через $(( $(date +%s) - lock_wait_started ))с ожидания" + fi cd /opt/gendesign # Sync compose / Caddyfile / init scripts from the repo. @@ -281,6 +730,19 @@ jobs: # Apply pending SQL migrations set -a; source .env; set +a + # #3029: checkpoint по часам БД ДО миграций — worker-guard ниже (после + # force-recreate блока) сверяет его с applied_at, чтобы честно + # предупредить «worker на старом коде + новая схема», если recreate + # worker'а был пропущен именно в деплое, где данные схемы поменялись. + # `tr -d '[:space:]'` здесь СЛОМАН бы CAST ниже: NOW() отдаёт + # "2026-08-24 09:12:33+00" с пробелом ВНУТРИ значения (дата/время), + # который [:space:] тоже вырезает → "2026-08-2409:12:33+00" не + # парсится как timestamptz. Убираем только CR/LF (psql -tA не + # добавляет ведущих/хвостовых пробелов, только trailing \n). + DEPLOY_MIGRATIONS_START_TS="$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT NOW();" 2>/dev/null | tr -d '\r\n')" \ + || DEPLOY_MIGRATIONS_START_TS="" + docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=on -c " CREATE TABLE IF NOT EXISTS _schema_migrations ( @@ -418,7 +880,17 @@ jobs: # Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись. docker compose -p gendesign -f docker-compose.prod.yml build glitchtip-auth-forwarder - docker compose -p gendesign -f docker-compose.prod.yml up -d + # #3029 fail-safe review fix: голый `up -d` без списка сервисов сам + # пересоздаёт ЛЮБОЙ сервис с изменившимся image — включая worker, + # ДО того как guard ниже (~896) успевает сравнить pulled vs running. + # К моменту проверки они уже совпадают (worker только что + # пересоздан этим самым up -d) → guard видит "не изменился" и + # печатает no-op, хотя worker уже убит и прогон уже потерян. + # Фикс: явно исключаем worker из этого bulk up -d — его recreate + # решается ТОЛЬКО guard-блоком ниже (строка ~977), который видит + # ещё не тронутый running_worker_image. + UP_SERVICES="$(docker compose -p gendesign -f docker-compose.prod.yml config --services | grep -v '^worker$')" + docker compose -p gendesign -f docker-compose.prod.yml up -d $UP_SERVICES # Defense: ensure postgres is in gendesign_shared network for tradein FDW. # `compose up -d` should detect networks: shared addition and recreate @@ -439,8 +911,86 @@ jobs: # требуют --force-recreate — обычный `up -d` не перечитывает env_file # если только image не сменился. На deploy где меняется только runtime # без backend image change — без этого backend остаётся со старым DSN. + # + # #3029: worker исключён из безусловного recreate. Временная заплатка + # до чекпоинтов (#3074) — стиль (digest-сверка pulled vs running, + # $SERVICES) как SCRAPER_RECREATE в deploy-tradein.yml, но здесь + # расхождение digest после skip уходит в WARNING, не в exit 1 (там + # scraper не имеет второго потребителя схемы; здесь пропуск recreate + # worker'а может оставить его читать старую версию схемы — риск, а не + # ошибка деплоя). backend/beat пересоздаются безусловно, как раньше. + WORKER_SERVICES="backend beat" + if [ "${WORKER_RECREATE_GUARD:-on}" != "on" ]; then + echo "→ WORKER_RECREATE_GUARD=off — безусловное пересоздание worker'а (fallback на старое поведение)" + WORKER_SERVICES="$WORKER_SERVICES worker" + else + pulled_worker_image=$(docker image inspect -f '{{.Id}}' "ghcr.io/lekss361/gendesign-worker:$IMAGE_TAG" 2>/dev/null || echo "") + running_worker_image=$(docker inspect -f '{{.Image}}' "$(docker compose -p gendesign -f docker-compose.prod.yml ps -q worker)" 2>/dev/null || echo "") + if [ -z "$pulled_worker_image" ] || [ -z "$running_worker_image" ]; then + echo "WARNING (#3029): не удалось прочитать worker image id (pulled='$pulled_worker_image' running='$running_worker_image') — детект не отработал, fail-safe = пересоздаём worker как обычно." + WORKER_SERVICES="$WORKER_SERVICES worker" + elif [ "$pulled_worker_image" = "$running_worker_image" ]; then + echo "→ образ worker'а не изменился ($pulled_worker_image) — пересоздание и так no-op, worker в recreate" + WORKER_SERVICES="$WORKER_SERVICES worker" + else + # Оба трекера прогонов — строки в БД (lifecycle.py:108 kn_scrape_runs, + # lifecycle.py:267 objective_scrape_runs), литералы статуса сверены с + # кодом: 'running' в обеих таблицах. Анти-зомби: считаем только + # прогоны свежее WORKER_GUARD_MAX_SKIP_H часов (started_at / + # heartbeat_at) — иначе зависший навечно 'running' блокировал бы + # recreate worker'а бесконечно. + # NB: `assignment="$(...)" && next=...` (не отдельная строка) — под + # `set -euo pipefail` (шапка скрипта) присвоение, упавшее КАК + # ПОСЛЕДНЯЯ команда своего стейтмента, роняет весь деплой. Внутри + # AND-списка (не последним звеном) — нет, ошибка молча даёт пустой + # $kn_count/$obj_count, что и проверяем ниже. Тот же приём — + # deploy-tradein.yml psql_out/running_count. + guard_max_h="${WORKER_GUARD_MAX_SKIP_H:-6}" + kn_count="" + kn_out="$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \ + "SELECT COUNT(*) FROM kn_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval);" 2>/dev/null)" \ + && kn_count="$(printf '%s' "$kn_out" | tr -d '[:space:]')" + obj_count="" + obj_out="$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \ + "SELECT COUNT(*) FROM objective_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval);" 2>/dev/null)" \ + && obj_count="$(printf '%s' "$obj_out" | tr -d '[:space:]')" + if ! printf '%s' "$kn_count" | grep -qE '^[0-9]+$' \ + || ! printf '%s' "$obj_count" | grep -qE '^[0-9]+$'; then + echo "WARNING (#3029): не удалось прочитать running-прогоны (psql молчит/пусто/не число) — детект не отработал, fail-safe = пересоздаём worker как обычно." + WORKER_SERVICES="$WORKER_SERVICES worker" + else + total_running=$((kn_count + obj_count)) + if [ "$total_running" -gt 0 ]; then + echo "!!! WORKER RECREATE SKIPPED (#3029) — ${total_running} running runs, worker остаётся на образе ${running_worker_image} !!!" + echo "WARNING (#3029): worker digest разошёлся с pulled — running=${running_worker_image} pulled=${pulled_worker_image}" + docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \ + "SELECT 'kn run_id=' || run_id || ' started_at=' || started_at FROM kn_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval) ORDER BY started_at ASC;" 2>/dev/null || true + docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \ + "SELECT 'objective run_id=' || run_id || ' started_at=' || started_at FROM objective_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval) ORDER BY started_at ASC;" 2>/dev/null || true + if [ -n "$DEPLOY_MIGRATIONS_START_TS" ]; then + migrations_since="" + migrations_since=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \ + psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \ + "SELECT COUNT(*) FROM _schema_migrations WHERE applied_at > CAST('${DEPLOY_MIGRATIONS_START_TS}' AS timestamptz);" 2>/dev/null | tr -d '[:space:]') \ + || migrations_since="" + if printf '%s' "$migrations_since" | grep -qE '^[0-9]+$' && [ "$migrations_since" -gt 0 ]; then + echo "WARNING (#3029): WORKER НА СТАРОМ КОДЕ + НОВАЯ СХЕМА — в этом деплое применились ${migrations_since} data/sql/** миграций, а worker остался на старом образе. Нужен ручной recreate после прогона: docker compose -p gendesign -f docker-compose.prod.yml up -d --force-recreate --no-deps worker" + fi + fi + else + echo "→ активных прогонов (kn/objective) нет — worker пересоздаётся вместе с backend/beat" + WORKER_SERVICES="$WORKER_SERVICES worker" + fi + fi + fi + fi + docker compose -p gendesign -f docker-compose.prod.yml up -d \ - --force-recreate --no-deps backend worker beat + --force-recreate --no-deps $WORKER_SERVICES # Caddy: force-recreate чтобы подхватить изменения в Caddyfile # И в особенности новые volume mounts из docker-compose.prod.yml @@ -467,8 +1017,195 @@ 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. + # ── Быстрый путь: правка ТОЛЬКО конфига прокси (#2916) ──────────────────── + # + # ЗАЧЕМ. `Caddyfile` лежит в фильтре `infra`, поэтому правка одной строки + # allowlist'а ради meraocenka.ru запускала полный деплой Site Finder: + # пересборку трёх образов, `git reset --hard` на боевой VM, применение ВСЕХ + # pending `data/sql/*.sql` в боевой БД gendsgn и `--force-recreate` бэкенда, + # воркера, beat и caddy. То есть радиус поражения правки, относящейся к + # чужому домену, — gendsgn.ru целиком, включая миграции продукта, который + # никто в этот момент катить не собирался. + # + # Публичный периметр МЕРЫ живёт в этом файле и будет меняться часто: новая + # страница = новая строка allowlist'а. + # + # ПОЧЕМУ `reload`, А НЕ `up -d --force-recreate caddy`. Полный деплой + # осознанно пересоздаёт контейнер (комментарий в ci.yml: `reload` отказался бы + # принять битый конфиг и оставил бы работать старый — на общем деплое это + # скрыло бы поломку). Здесь наоборот: правится ТОЛЬКО конфиг, и отказ + # применить битый — ровно то, что нужно. `caddy reload` возвращает ненулевой + # код → job краснеет, а домены продолжают обслуживаться старым конфигом. + # Альтернатива (`--force-recreate`) на опечатке уводит контейнер в crash-loop + # и роняет ВСЕ домены сразу. + # + # Гейт `caddy validate` на PR (#2913) остаётся первой линией; этот шаг — + # вторая, уже против боевого файла после `git reset`. + deploy-caddy: + runs-on: ubuntu-latest + needs: changes + # Только push: на workflow_dispatch человек просит полный деплой, и + # подменять его перезагрузкой конфига нельзя. + if: github.event_name == 'push' && needs.changes.outputs.caddy_only == 'true' + steps: + # #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть + # громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет + # ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как + # защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у + # кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь — + # «инертно по умолчанию, включается одной настройкой». Заведут секрет — + # предупреждение исчезнет само. + - name: Подлинность хоста — статус проверки (#3029) + env: + HOST_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} + run: | + set -euo pipefail + if [ -n "${HOST_FINGERPRINT:-}" ]; then + echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT." + else + echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029). По этому каналу едет DEPLOY_SSH_KEY и выполняется git reset + перезагрузка Caddy на проде: MITM здесь переписывает конфиг прокси всех доменов. После переезда на Selectel (#3057) соединение идёт через интернет. Как снять отпечаток — см. шапку deploy.yml.' + echo '###############################################################' + echo '# ВНИМАНИЕ (#3029): DEPLOY_SSH_FINGERPRINT не задан.' + echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.' + echo '# Как снять отпечаток — см. шапку этого файла.' + echo '###############################################################' + fi + + - name: Синхронизировать конфиг и перезагрузить прокси + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.DEPLOY_PORT }} + # #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy + # оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение. + fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} + script: | + set -euo pipefail + cd /opt/gendesign + git fetch origin main + git reset --hard origin/main + # Конфиг примонтирован read-only с хоста, пересборка не нужна — + # контейнер читает тот же файл, что только что обновил git. + docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \ + caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile + echo "✓ конфиг прокси перезагружен без пересборки и без миграций" + + # ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ────────────────── + # + # ЗАЧЕМ ЗДЕСЬ. scripts/smoke-mera-perimeter.sh — единственная проверка, которая + # видит периметр целиком (короткие адреса, 301 с длинных, публичный API, + # закрытость B2B-путей на публичном домене). До этого PR он запускался только + # по cron'у 06:17 UTC, то есть регресс жил до суток и находил его либо ночной + # прогон, либо владелец. Для правки, чья логика живёт в конфиге прокси, это + # единственный настоящий гейт — и он был асинхронным. + # + # ПОЧЕМУ ОТДЕЛЬНЫЙ JOB, А НЕ ШАГ В deploy. Вердикты разные: «выкатили» и + # «периметр цел» — два разных факта, и красный смоук не должен читаться как + # неудавшийся деплой. Деплой к этому моменту уже прошёл; смоук говорит, что + # именно получилось. + # + # ПОЧЕМУ ДУБЛИРУЕТСЯ В ДВУХ ПАЙПЛАЙНАХ. Конфиг прокси (deploy.yml) и фронт + # МЕРЫ (deploy-tradein.yml) едут раздельно, и сломать периметр может каждый. + # `workflow_call` под act_runner не гарантирован, поэтому 20 строк повторены + # осознанно вместо зависимости, которая может молча не сработать. + perimeter-smoke: + runs-on: ubuntu-latest + needs: [deploy, deploy-caddy] + # Только после РЕАЛЬНОЙ выкатки: при skipped/failed проверять нечего, а + # красный смоук поверх несостоявшегося деплоя увёл бы разбор не туда. + # ЛЮБОЙ из двух путей (#2916): быстрый путь трогает как раз конфиг прокси, + # то есть ровно то, что смоук и проверяет — пропустить его там было бы + # хуже всего. + if: | + always() && + (needs.deploy.result == 'success' || needs.deploy-caddy.result == 'success') + timeout-minutes: 6 + steps: + - uses: actions/checkout@v4 + + - name: Дождаться, пока периметр отвечает после пересоздания контейнеров + # `up -d --force-recreate` возвращает управление раньше, чем бэкенд + # начинает отвечать. Без ожидания смоук ловил бы не регресс, а гонку. + # Ждём ДВА признака: лэндинг (Caddy + фронт) и API (бэкенд поднялся) — + # одного мало, Caddy отвечает раньше апстрима. + run: | + set -uo pipefail + for i in $(seq 1 30); do + page=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://meraocenka.ru/ || true) + api=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://gendsgn.ru/trade-in/api/v1/me || true) + if [ "$page" = "200" ] && [ "$api" = "401" ]; then + echo "периметр отвечает (попытка $i): лэндинг $page, API $api" + exit 0 + fi + echo "ждём готовности, попытка $i/30: лэндинг '${page:-нет ответа}', API '${api:-нет ответа}'" + sleep 5 + done + # НЕ падаем здесь: вердикт должен вынести смоук, а не таймаут ожидания. + # Иначе «не успел подняться» и «периметр сломан» слились бы в один + # красный шаг без разбора. + echo "::warning::за 150 с периметр так и не ответил ожидаемо — запускаем смоук, его вывод и будет диагнозом" + + - name: Смоук периметра + run: | + chmod +x scripts/smoke-mera-perimeter.sh + ./scripts/smoke-mera-perimeter.sh + + deploy-status: + runs-on: ubuntu-latest + needs: [build-backend, build-worker, build-frontend, deploy, deploy-caddy] + if: always() && !cancelled() + steps: + - name: Итог прогона — выкатка обязана быть success, не skipped/failure + # #2916: путей выкатки теперь ДВА — полный деплой и быстрая перезагрузка + # конфига прокси. Успешен прогон, если сработал ЛЮБОЙ из них; ошибка — + # когда не сработал ни один. Требовать `deploy == success` как раньше + # значило бы красить каждую правку прокси, которая как раз прошла. + 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 }}" + echo "deploy-caddy: ${{ needs.deploy-caddy.result }}" + if [ "${{ needs.deploy.result }}" = "success" ]; then + echo "✓ полный деплой прошёл успешно" + elif [ "${{ needs.deploy-caddy.result }}" = "success" ]; then + echo "✓ конфиг прокси перезагружен (быстрый путь, без пересборки и миграций)" + else + echo "::error::выкатка НЕ прошла ни одним путём" \ + "(deploy=${{ needs.deploy.result }}," \ + "deploy-caddy=${{ needs.deploy-caddy.result }})." \ + "Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \ + "Смотри логи build-* / deploy / deploy-caddy выше." + exit 1 + fi diff --git a/.forgejo/workflows/perimeter-smoke.yml b/.forgejo/workflows/perimeter-smoke.yml index db0304ba..1afdf167 100644 --- a/.forgejo/workflows/perimeter-smoke.yml +++ b/.forgejo/workflows/perimeter-smoke.yml @@ -18,6 +18,15 @@ on: schedule: # Раз в сутки, 06:17 UTC — вне пиков, время произвольное. - cron: '17 6 * * *' + # #2917: правка самого смоука должна проверяться сразу, а не следующим утром. + # Проверки read-only (curl по публичным адресам), поэтому прогонять их на + # push в main безопасно и дёшево. Синтаксис скрипта отдельно гейтится в + # ci.yml на каждом PR — здесь проверяется уже поведение против прода. + push: + branches: [main] + paths: + - 'scripts/smoke-mera-perimeter.sh' + - '.forgejo/workflows/perimeter-smoke.yml' concurrency: group: perimeter-smoke-mera diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..dfdb8b77 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf 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/.gitignore b/.gitignore index 53ceef6e..2ece89ee 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,7 @@ ds-bundle/ .design-sync/.cache/ .design-sync/learnings/ .design-sync/node_modules + +# Боевой конфиг Alertmanager собирается на хосте из .tmpl (deploy-metrics.yml): +# содержит токен бота и идентификатор чата, поэтому в репозиторий не попадает. +ops/metrics/alertmanager/alertmanager.yml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3175e0db..0c1d39f7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,8 +26,12 @@ repos: - id: detect-private-key # Python — ruff (lint + format) on backend/ + tradein-mvp/backend/ + # #2864: rev ОБЯЗАН совпадать с версией ruff в backend/uv.lock и tradein-mvp/uv.lock + # (гейт backend/tests/test_2864_ruff_version_alignment.py). Иначе хук и + # `uv run ruff format` форматируют по-разному и играют в пинг-понг на каждом коммите. + # Бампить втроём: rev здесь + `ruff==X` в обоих pyproject.toml + `uv lock` в backend/ и tradein-mvp/. - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.4 + rev: v0.15.20 hooks: - id: ruff args: [--fix] diff --git a/Caddyfile b/Caddyfile index 1e6a0e25..7f99e71a 100644 --- a/Caddyfile +++ b/Caddyfile @@ -31,354 +31,24 @@ # (тег деградирует в "(none)" — forwarder это уже обрабатывает gracefully, не падает). # Событие basic_auth 401 (remote_ip / uri / method) по-прежнему уходит в GlitchTip. -gendsgn.ru { - encode zstd gzip - log { - output file /var/log/caddy/gendsgn.ru.log { - roll_size 50MiB - roll_keep 5 - roll_keep_for 720h - } - format json - } - - # Отдельный лог только для auth-событий. - # Forwarder (ops/glitchtip-auth-forwarder) читает именно этот файл. - # Retention 7 дней (меньше чем main log) — содержит plain Base64 credentials. - log auth_audit { - output file /var/log/caddy/auth_audit.log { - roll_size 10MiB - roll_keep 3 - roll_keep_for 168h - } - format json - } - - route { - # /health и /preview/* — public, без auth, short-circuit. - handle /health { - reverse_proxy backend:8000 - } - - # Static HTML mockups для review (audit alternatives). - # Public access — без auth (по запросу 2026-05-17). - handle_path /preview/* { - root * /srv/preview - file_server browse - } - - # Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру - # «денежного экрана» без бэкенда → axe/lighthouse гоняются без креды. - # Реальных клиентских данных нет (статичная фикстура) → безопасно публично. - # ДО auth-import: route матчит сверху вниз, handle short-circuit'ит. - # Без strip — Next.js basePath=/trade-in ждёт префикс в URL (как @tradein). - # ui-preview + его статика (_next/static — CSS/JS бандлы, без секретов). - # Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS. - @uipreview path /trade-in/ui-preview/* /trade-in/_next/static/* - handle @uipreview { - 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 - } - } - - # #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 - header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} - } - } - - # gendsgn.ru/sale-share — короткий адрес standalone-продукта «Поиск домов». - # 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 - } - - # Matcher `path /trade-in /trade-in/*` ловит И /trade-in (без слеша), - # И /trade-in/ + /trade-in/anything. Без обоих случаев `handle /trade-in/*` - # пропускал /trade-in без слеша → попадал в общий frontend → пустой ответ. - @tradein path /trade-in /trade-in/* - handle @tradein { - # Next.js basePath=/trade-in — фронт сам ждёт префикса в URL - reverse_proxy tradein-frontend:3000 { - # См. комментарий над /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} - } - } - - handle { - reverse_proxy frontend:3000 { - header_up X-Authenticated-User {http.auth.user.id} - } - } - } -} - -www.gendsgn.ru { - redir https://gendsgn.ru{uri} permanent -} - -# МЕРА B2C — публичный периметр (ЭТАП 1 плана B2C-запуска, БЕЗ функционала). +# ── Site-блоки вынесены по хостам (#3059, переезд 30.08) ──────────────────── +# Раньше все восемь доменов жили прямо здесь. После разделения продуктов между +# двумя хостами это стало опасно: деплой синхронизирует рабочее дерево с +# origin/main и перечитывает конфиг, поэтому на Selectel приезжал бы файл +# целиком — и Caddy начинал бы выпускать сертификаты для obsidian/errors/git, +# чей DNS указывает на Beget. ACME падал бы на HTTP-01, с риском упереться в +# rate limit Let's Encrypt. # -# Архитектурное решение: отдельный домен, а НЕ дырка в блоке 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 — т.е. не был случайно проброшен). +# caddy/sites/apps.caddy gendsgn.ru, www, meraocenka, merahome, meraotsenka +# -> уезжают на Selectel +# caddy/sites/infra.caddy obsidian, errors, git +# -> остаются на Beget (Forgejo, GlitchTip, CouchDB) # -# Next.js basePath=/trade-in запечён в prod-образ tradein-frontend (тот же -# контейнер, что обслуживает и gendsgn.ru/trade-in/*, см. build-args в -# .forgejo/workflows/deploy-tradein.yml) — поэтому корень домена rewrite'ится -# на internal-путь /trade-in/mera-public (страница-заглушка, -# tradein-mvp/frontend/src/app/mera-public/). Пользователь префикс /trade-in -# никогда не видит — rewrite меняет путь ТОЛЬКО для Caddy→backend запроса, -# это не HTTP-редирект браузера. -# -# DNS: A-record meraocenka.ru → IP VPS — ТРЕБУЕТСЯ ДО того, как сюда придёт -# реальный трафик. Если записи ещё нет на момент деплоя этого блока: `caddy -# reload`/`up -d --force-recreate caddy` в deploy.yml НЕ падает (конфиг -# синтаксически валиден, ошибка сертификата асинхронна и per-hostname) — Caddy -# просто залогирует неудачную попытку ACME-выпуска для meraocenka.ru (DNS не -# резолвится на этот сервер → HTTP-01/TLS-ALPN challenge недостижим) и продолжит -# ретраить с backoff, ПОКА запись не появится. Остальные site-блоки в этом же -# Caddyfile (gendsgn.ru, obsidian.gendsgn.ru и т.д.) не затрагиваются — -# автоматический HTTPS в Caddy изолирован per-hostname (тот же принцип, что -# уже описан для status.gendsgn.ru ниже). Повторные неудачные попытки ДО -# появления DNS могут исчерпать rate-limit Let's Encrypt (5 failed -# validations/hostname/hour) — не критично, просто подождать; `docker volume -# rm gendesign_caddy_data` для этого НЕ нужен (и вообще требует user-approval). -meraocenka.ru { - encode zstd gzip - - log { - output file /var/log/caddy/meraocenka.ru.log - } - - # Корень домена → лэндинг МЕРЫ (#2615 заменил заглушку этого этапа на - # полноценную страницу). rewrite добавляет basePath-префикс только для - # Caddy→backend хопа, пользователь /trade-in никогда не видит. - handle / { - rewrite * /trade-in/mera-public - reverse_proxy tradein-frontend:3000 { - # Тот же периметр-скраб, что у @uipreview (:87) и @tradein ниже. - # Этот блок вообще не под basic_auth, поэтому анонимный клиент - # тем более может прислать свой X-Authenticated-User. Сейчас - # инертно (лэндинг статичен, backend-вызовов нет), но снимаем - # ради единообразия периметра, а не полагаясь на то, что - # downstream ничего не делает с заголовком — иначе на этапе 5, - # когда откроется публичный /estimate, это станет дырой. - header_up -X-Authenticated-User - } - } - - # Подстраницы САМОГО лэндинга. Нужны с момента мержа #2615: футер ссылается - # на политику обработки ПДн через next/link (`PRIVACY_PATH`), а Next с - # basePath эмитит её как /trade-in/mera-public/privacy. Без этого handle - # ссылка уходила бы в catch-all 404 ниже — то есть обязательный по 152-ФЗ - # документ был бы недоступен с публичной страницы. - # - # Matcher намеренно узкий — ровно поддерево лэндинга, НЕ /trade-in/*. - # B2B-дерево (/trade-in/v2, /trade-in/api/*, /trade-in/admin/*, /history) - # под него не подпадает и по-прежнему отдаёт 404. Регресс-тест на это — - # в scripts/smoke-mera-perimeter.sh. - handle /trade-in/mera-public/* { - reverse_proxy tradein-frontend:3000 { - header_up -X-Authenticated-User - } - } - - # Next.js уже эмитит ссылки на статику с /trade-in-префиксом (тот же - # basePath) — passthrough без rewrite. Нужны для рендера страницы (JS/CSS - # чанки), сами по себе не содержат ни B2B-данных, ни секретов. - # - # Именно `static/*`, а не весь `_next/*` — тот же матчер, что у @uipreview - # (:78), который в проде доказал, что этого хватает для рендера. Широкий - # `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок, - # CPU-нагрузка по запросу), который на лэндинге не используется вообще: - # next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется. - handle /trade-in/_next/static/* { - reverse_proxy tradein-frontend:3000 { - header_up -X-Authenticated-User - } - } - - # #2631: favicon — единственный корневой статик, который браузер запрашивает - # сам; без явного handle падал в allowlist-404. app/favicon.ico отдаёт Next - # по корневому пути через basePath /trade-in. - handle /favicon.ico { - rewrite * /trade-in/favicon.ico - reverse_proxy tradein-frontend:3000 { - header_up -X-Authenticated-User - } - } - - # Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin, - # /scrapers/*, /trade-in/api/*, /history, ...) — 404, НЕ проксируется. - handle { - respond 404 - } -} - -# Домены-спутники МЕРА → 301 на канонический meraocenka.ru. -# Решение 2026-07-31: канонический адрес ровно один, остальные две регистрации -# ловят (а) альтернативный транслит «оценка» — ocenka/otsenka, на слух -# неразличимы, (б) прежний рабочий вариант merahome. Отдельные site-блоки, а не -# matcher внутри основного: Caddy матчит по hostname и выпускает свой -# сертификат на каждый, поэтому DNS A-record нужен для КАЖДОГО из них — иначе -# ACME для этого хоста будет ретраиться (безвредно, см. комментарий выше, но -# лучше завести записи сразу). -# `{uri}` сохраняет путь и query — короткая ссылка с визитки не теряет ?id=. -merahome.ru { - redir https://meraocenka.ru{uri} permanent -} - -meraotsenka.ru { - redir https://meraocenka.ru{uri} permanent -} - -# Obsidian Self-hosted LiveSync (CouchDB backend). -# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap -# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user). -# -# DNS: A-record obsidian.gendsgn.ru → IP VPS. -# Клиенты Obsidian + Self-hosted LiveSync plugin указывают на этот URL. -obsidian.gendsgn.ru { - encode zstd gzip - - reverse_proxy couchdb:5984 { - # Большие документы (vault attachments / images) — увеличиваем timeout - transport http { - response_header_timeout 120s - } - } -} - -# GlitchTip — self-hosted error tracking (Sentry-compatible). -# DNS: A-record errors.gendsgn.ru → IP VPS. -errors.gendsgn.ru { - encode zstd gzip - - reverse_proxy glitchtip-web:8080 - - log { - output file /var/log/caddy/errors.gendsgn.ru.log - } -} - -# 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 -# gendesign_default network. HTTP port 3000 (default Forgejo). -# Был добавлен вручную при migration, потерян при первом auto-deploy после -# изменения Caddyfile (deploy.yml делает git reset --hard). См. fix issue. -git.gendsgn.ru { - encode zstd gzip - - reverse_proxy forgejo:3000 - - log { - output file /var/log/caddy/git.gendsgn.ru.log - } -} +# CADDY_SITES выбирает подмножество. Дефолт `*` = оба файла = ТЕКУЩЕЕ поведение +# Beget, где сейчас обслуживаются все восемь доменов — то есть до переезда +# ничего не меняется. В окне: на Selectel CADDY_SITES=apps, на Beget=infra. +import caddy/sites/{$CADDY_SITES:*}.caddy # Plain HTTP by IP — closed by same auth gate (prevent bypass via direct IP / SSH tunnel). # Caddy issues no TLS here (no hostname). /health remains public. @@ -408,3 +78,19 @@ git.gendsgn.ru { } } # Test deploy flow 2026-05-15T21:43:32Z + +# ───────────────────────────────────────────────────────────────────────────── +# Локальные site-блоки, которых не может быть в git. +# +# Мотив: garmin.gendsgn.ru (личный remote MCP на этом же VPS). Апстрим-сервер +# аутентификации не имеет вовсе, а claude.ai custom connector ходит на голый URL +# без кастомных заголовков — единственный доступный рубеж это секрет в пути. +# Секрет в git класть нельзя, а блок, вписанный руками прямо сюда, сносится +# первым же деплоем (`git reset --hard origin/main`; 2026-08-16 так и вышло — +# контейнер остался жив, но хост пропал вместе со своим сертификатом). +# +# Поэтому: сам блок лежит на VPS как untracked `caddy/local/*.caddy` (reset +# --hard untracked не трогает), а в репозитории живёт только этот import. +# Пустой glob для Caddy не ошибка — `caddy validate` проходит, на машинах без +# локальных блоков строка просто ничего не делает. +import caddy/local/*.caddy 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/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/admin_cadastre.py b/backend/app/api/v1/admin_cadastre.py index b24e794a..f6965d1c 100644 --- a/backend/app/api/v1/admin_cadastre.py +++ b/backend/app/api/v1/admin_cadastre.py @@ -81,12 +81,18 @@ def _resolve_quarters( ) -> list[str]: """Собрать список кварталов согласно scope.""" if scope == "manual_list": - if not quarters: + # Сначала чистим, потом проверяем (#2464). Раньше порядок был обратным, и + # список из одних пробелов проходил проверку `not quarters` как непустой, + # а после strip превращался в []. Дальше по коду это молча создавало job + # с нулём кварталов, ставило его в очередь и возвращало targets_total=0 — + # пустышку, неотличимую в списке заданий от настоящей. + cleaned = [q.strip() for q in (quarters or []) if q.strip()] + if not cleaned: raise HTTPException( status_code=400, detail="scope=manual_list требует непустой список quarters", ) - return [q.strip() for q in quarters if q.strip()] + return cleaned cap = limit or (PILOT_LIMIT if scope == "pilot" else 100000) diff --git a/backend/app/api/v1/admin_leads.py b/backend/app/api/v1/admin_leads.py index f23b839c..a5cc90cf 100644 --- a/backend/app/api/v1/admin_leads.py +++ b/backend/app/api/v1/admin_leads.py @@ -130,7 +130,16 @@ def leads_stats( db: Annotated[Session, Depends(get_db)], months: Annotated[int, Query(ge=1, le=120)] = 12, ) -> dict[str, Any]: - """KPI summary за последние N месяцев.""" + """KPI summary за последние N месяцев. + + Суффикс `_window` — за окно `months`, `_total` — за всё время. + """ + # Почему это важно и почему поля переименованы (#2464): revenue_total и + # deals_total считались по CTE window_leads, то есть за окно, а суффиксом + # обещали итог за всё время — рядом с честными leads_total/sources_total. + # Админка из-за этого показывала карточку «Revenue (всего)» с 12-месячной + # цифрой. Рационал держим комментарием, а не docstring'ом: docstring уходит + # в OpenAPI description и дальше в сгенерированные типы фронта. row = ( db.execute( text( @@ -155,14 +164,14 @@ def leads_stats( WHERE d.deal_id IN ( SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL ) - ) AS revenue_total, + ) AS revenue_window, ( SELECT COUNT(*) FROM prinzip_deals d WHERE d.deal_id IN ( SELECT deal_id FROM window_leads WHERE deal_id IS NOT NULL ) - ) AS deals_total + ) AS deals_window FROM window_leads """ ), @@ -178,8 +187,16 @@ def leads_stats( "converted_window": 0, "conv_pct_window": None, "sources_total": 0, - "revenue_total": None, - "deals_total": 0, + "revenue_window": None, + "deals_window": 0, + # window_months раньше отдавался ТОЛЬКО в непустой ветке — формы ответа + # различались. Оговорка про достижимость: этот `if not row` СЕГОДНЯ не + # срабатывает — запрос агрегатный и всегда возвращает ровно одну строку + # (проверено на пустых таблицах: leads_total=0, leads_window=0, строка + # truthy). То есть правка здесь — согласованность, а не наблюдаемая + # починка; ветка остаётся защитой на случай смены формы запроса, и + # расходиться с основной ей нельзя — именно так пропажа поля и возникла. + "window_months": months, } return { "leads_total": row["leads_total"] or 0, @@ -189,10 +206,10 @@ def leads_stats( float(row["conv_pct_window"]) if row["conv_pct_window"] is not None else None ), "sources_total": row["sources_total"] or 0, - "revenue_total": ( - float(row["revenue_total"]) if row["revenue_total"] is not None else None + "revenue_window": ( + float(row["revenue_window"]) if row["revenue_window"] is not None else None ), - "deals_total": row["deals_total"] or 0, + "deals_window": row["deals_window"] or 0, "window_months": months, } diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index b905d216..d9558635 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -191,9 +191,35 @@ def queue_status( return None deadline = time.monotonic() + 0.8 - with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex: + + # #2464-C: НЕ `with ThreadPoolExecutor(...)`. Его __exit__ зовёт + # shutdown(wait=True), поэтому обещанные ~600 мс худшего случая не выполнялись: + # result(timeout=...) переставал ждать значение, а выход из блока всё равно + # ждал, пока celery inspect отвиснет сам. Для UI-поллинга это ровно та ручка, + # которая обязана возвращаться быстро при недоступном брокере. + # + # ЧЕСТНАЯ ЦЕНА: shutdown(wait=False) оставляет зависший поток дорабатывать в + # фоне. Ограничиваем ЗАПРОС, не процесс — потоки пула не-демоны и джойнятся в + # atexit. Размен осознанный: висящий поллинг-эндпоинт хуже висящего потока. + def _probe_queue_depth() -> int | None: + with celery_app.connection_or_acquire() as conn: + with conn.channel() as channel: + return channel.client.llen("celery") + + ex = concurrent.futures.ThreadPoolExecutor(max_workers=3) + try: f_reserved = ex.submit(_safe, inspect.reserved) f_ping = ex.submit(_safe, inspect.ping) + # #2464: проба глубины очереди раньше шла СИНХРОННО и без таймаута вовсе — + # `connection_or_acquire()` + `llen` по висящему сокету не возвращаются + # никогда. Дедлайн 0.8 с выше ограничивал только inspect, а ручка всё равно + # висела столько, сколько висел брокер: обещание докстроки не выполнялось на + # последнем шаге. Отправляем в тот же пул под тот же дедлайн. + # + # Отправляем ДО чтения результатов, а не после: иначе к моменту старта пробы + # бюджет уже израсходован inspect'ами и ей досталась бы только нижняя + # граница max(0.1, ...). + f_queue = ex.submit(_safe, _probe_queue_depth) try: reserved_raw = f_reserved.result(timeout=max(0.1, deadline - time.monotonic())) except concurrent.futures.TimeoutError: @@ -202,22 +228,20 @@ def queue_status( ping_resp = f_ping.result(timeout=max(0.1, deadline - time.monotonic())) except concurrent.futures.TimeoutError: ping_resp = None + # 3) Pending in broker queue (not yet picked up by any worker). + # Деградация до None намеренна (см. _safe): недоступный брокер не должен + # ронять UI-поллинг, но обязан быть виден в логах. + try: + queue_depth: int | None = f_queue.result(timeout=max(0.1, deadline - time.monotonic())) + except concurrent.futures.TimeoutError: + logger.warning("queue_status: broker queue_depth probe timed out") + queue_depth = None + finally: + ex.shutdown(wait=False, cancel_futures=True) reserved = _flatten(reserved_raw) workers = list((ping_resp or {}).keys()) - # 3) Pending in broker queue (not yet picked up by any worker). - queue_depth: int | None = None - try: - with celery_app.connection_or_acquire() as conn: - with conn.channel() as channel: - queue_depth = channel.client.llen("celery") - except Exception: - # Намеренная деградация для UI-poll; логируем чтобы недоступный broker - # не был невидим в логах (см. .claude/rules/backend.md). - logger.warning("queue_status: broker queue_depth probe failed", exc_info=True) - queue_depth = None - return { "workers": workers, "queue_depth": queue_depth, @@ -1099,18 +1123,48 @@ def cancel_geo_job( db: Annotated[Session, Depends(get_db)], ) -> dict[str, Any]: """Пометить job как cancelled. Worker увидит при следующей итерации.""" - db.execute( - text( - """ - UPDATE nspd_geo_jobs SET status = 'cancelled', finished_at = NOW(), - error = COALESCE(error, 'cancelled by admin') - WHERE job_id = :id AND status IN ('queued','running','paused') - """ - ), - {"id": job_id}, + # #2464: фильтр статуса здесь был всегда (в отличие от resume ниже), но ответ + # возвращал cancelled=True независимо от того, задел ли UPDATE хоть одну строку. + # Несуществующий job_id и уже завершённая задача давали тот же ответ, что + # настоящая отмена — оператор и админ-UI получали подтверждение действия, + # которого не было. + # + # Обоснование держим в КОММЕНТАРИИ, а не в докстринге: FastAPI кладёт докстринг + # в OpenAPI-description, откуда он попадает в опубликованный контракт и в + # сгенерированные типы фронта (frontend/src/lib/api-types.ts). Внутренние замеры + # там не нужны, а gate openapi-codegen-check честно ловит такое расхождение. + row = ( + db.execute( + text( + """ + UPDATE nspd_geo_jobs SET status = 'cancelled', finished_at = NOW(), + error = COALESCE(error, 'cancelled by admin') + WHERE job_id = :id AND status IN ('queued','running','paused') + RETURNING job_id + """ + ), + {"id": job_id}, + ) + .mappings() + .first() ) + if row is None: + current = db.execute( + text("SELECT status FROM nspd_geo_jobs WHERE job_id = :id"), + {"id": job_id}, + ).scalar() + db.commit() + return { + "job_id": job_id, + "cancelled": False, + "status": current, + "reason": ( + "задача не найдена" if current is None else f"статус {current!r} уже терминальный" + ), + } + db.commit() - return {"job_id": job_id, "cancelled": True} + return {"job_id": job_id, "cancelled": True, "status": "cancelled"} @router.post("/geo/jobs/{job_id}/resume") @@ -1118,18 +1172,61 @@ def resume_geo_job( job_id: int, db: Annotated[Session, Depends(get_db)], ) -> dict[str, Any]: - """Re-enqueue paused/failed job. Resume idempotent через pending targets.""" + """Re-enqueue задачу из НЕзавершённого состояния (paused / failed / cancelled).""" + # #2464: UPDATE шёл БЕЗ фильтра статуса — в отличие от соседнего cancel_geo_job, + # который фильтрует явно. Из-за этого «возобновить» можно было завершённую задачу + # (done → снова queued и повторный прогон, затирая результат) и уже бегущую + # (второй worker на тот же job_id — лишние запросы к НСПД, у которого WAF). + # + # Замер на проде 19.08: все 66 задач в терминальных статусах — 61 done, 5 + # cancelled. То есть resume на ЛЮБУЮ существующую делал ровно то, чего не должен. + # + # Второе: ручка возвращала resumed=True всегда, независимо от того, изменилось ли + # что-нибудь. Теперь ответ отражает факт — статус и причина в ответе, задача НЕ + # ставится в очередь. + # + # 'cancelled' оставлен возобновляемым намеренно: cancel — ручное действие + # оператора, и без этого отменённая по ошибке задача не восстанавливалась бы. from app.services.job_settings import get_setting_value from app.workers.tasks.nspd_geo import process_nspd_geo_job - db.execute( - text("UPDATE nspd_geo_jobs SET status='queued', error=NULL WHERE job_id=:id"), - {"id": job_id}, + row = ( + db.execute( + text( + """ + UPDATE nspd_geo_jobs SET status='queued', error=NULL + WHERE job_id = :id AND status IN ('paused','failed','cancelled') + RETURNING job_id + """ + ), + {"id": job_id}, + ) + .mappings() + .first() ) + if row is None: + # Ничего не обновили — либо задачи нет, либо статус неподходящий. Читаем + # текущий статус ДО commit'а, чтобы ответ объяснял отказ, а не молчал. + current = db.execute( + text("SELECT status FROM nspd_geo_jobs WHERE job_id = :id"), + {"id": job_id}, + ).scalar() + db.commit() + return { + "job_id": job_id, + "resumed": False, + "status": current, + "reason": ( + "задача не найдена" + if current is None + else f"статус {current!r} не подлежит возобновлению" + ), + } + db.commit() geo_queue = get_setting_value("nspd_geo", "queue_name", "geo") process_nspd_geo_job.apply_async(args=[job_id], queue=geo_queue) - return {"job_id": job_id, "resumed": True} + return {"job_id": job_id, "resumed": True, "status": "queued"} # ── Newbuilding cross-load ETL (#976) ──────────────────────────────────────── @@ -1317,6 +1414,15 @@ class FreshnessSource(BaseModel): # внутри окна и не ложно-срабатывает (#1947 fix). Default 1 → флагует только если # суммарный выход цикла = 0 (безопасный минимальный catch). min_output_rows: int = 1 + # Data-table режим: условие «строка означает УСПЕХ». Без него свежесть считается по + # факту записи строки, а не по факту получения данных — и провалившийся загрузчик, + # исправно пишущий строку с ошибкой, вечно выглядит свежим. Ровно это и случилось с + # nspd: последний успешный дамп 27.07.2026, а монитор молчал 24 суток, потому что + # каждый упавший harvest обновлял fetched_at_utc (#2956). + # Run-ledger режиму не нужно: там успех уже отделён через FILTER (WHERE status='done'). + # Значение — статическая SQL-строка ИЗ КОДА (не из пользовательского ввода), она + # подставляется в FILTER (WHERE ...) как есть. + success_where: str | None = None # Реестр источников. Run-ledger таблицы (kn/objective/nspd_geo/cadastre) проверены на @@ -1406,6 +1512,12 @@ _FRESHNESS_SOURCES: list[FreshnessSource] = [ # defunct nspd_scrape_runs (manual WAF-ban 2026-04-30) больше НЕ источник истины. table="nspd_quarter_dumps", timestamp_col="fetched_at_utc", + # Свежесть — по УСПЕШНЫМ дампам. Упавший harvest всё равно пишет строку + # (fetched_at_utc проставлен, harvest_error заполнен, счётчики нулевые), и без + # этого условия каждый провал обновлял часы свежести. С 03.08.2026 провалились + # все 61 дамп подряд, последний успешный — 27.07, а источник числился fresh + # (#2956). + success_where="harvest_error IS NULL", # В timestamp-режиме не используется — оставляем валидное имя колонки. work_col="total_features", # Медленный кадастровый + lazy-refresh источник: дампы освежаются по мере @@ -1494,7 +1606,9 @@ def compute_freshness(db: Session) -> dict[str, Any]: Для data-table источников (src.timestamp_col задан, напр. nspd → nspd_quarter_dumps) нет run-ledger семантики (status/started/finished отсутствуют), поэтому: - - last_success_at = last_attempt_at = MAX(timestamp_col) + - last_attempt_at = MAX(timestamp_col); last_success_at — то же, но по + строкам, прошедшим success_where (у источника с колонкой ошибки это + отделяет «строку записали» от «данные получили», #2956) - objects_updated_24h / _7d = COUNT(*) строк, обновлённых в окне - last_status = NULL (косметика только для run-ledger'ов) Остальной downstream (age_days / _classify_freshness / status-маппинг) — общий. @@ -1511,16 +1625,23 @@ def compute_freshness(db: Session) -> dict[str, Any]: # все временные границы передаются параметрами (:d1/:d7). if src.timestamp_col is not None: # Data-table режим: плоская контент-таблица без run-ledger семантики - # (нет status/started/finished). Свежесть = MAX(timestamp_col), - # upd_24h/_7d = COUNT(*) строк, обновлённых в окне. last_status=NULL - # (косметика только для run-ledger'ов). + # (нет status/started/finished). Свежесть = MAX(timestamp_col) по строкам, + # прошедшим success_where (если задан; иначе по всем), upd_24h/_7d = + # COUNT(*) строк, обновлённых в окне. last_status=NULL (косметика только + # для run-ledger'ов). ts = src.timestamp_col + # Успех vs попытка. last_attempt_at — всегда MAX(ts) (строка записана), + # last_success_at — только по строкам, прошедшим success_where. Это тот же + # раздел, что в run-ledger ветке ниже (FILTER (WHERE status = 'done')): + # без него упавший загрузчик, который исправно пишет строку с ошибкой, + # выглядит свежим вечно (#2956). + success_filter = f" FILTER (WHERE {src.success_where})" if src.success_where else "" row = ( db.execute( text( f""" SELECT - MAX({ts}) AS last_success_at, + MAX({ts}){success_filter} AS last_success_at, MAX({ts}) AS last_attempt_at, COALESCE(COUNT(*) FILTER ( WHERE {ts} > NOW() - CAST(:d1 AS interval) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 191ed5c4..9e6ce9df 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -925,14 +925,17 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`). """ try: - row = ( - db.execute( - _NEIGHBORS_SUMMARY_SQL, - {"wkt": geom_wkt, "our_cad": our_cad_num}, + # #2464: SAVEPOINT — сессия общая с analyze_parcel, ошибку глотаем ниже. Без него + # aborted-транзакция дошла бы до persist_analysis_run, и анализ не сохранился бы. + with db.begin_nested(): + row = ( + db.execute( + _NEIGHBORS_SUMMARY_SQL, + {"wkt": geom_wkt, "our_cad": our_cad_num}, + ) + .mappings() + .first() ) - .mappings() - .first() - ) neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else [] overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else [] # #2464 cluster B: честный total из neighbors_total CTE (БЕЗ LIMIT 30) — @@ -1084,11 +1087,12 @@ def _compute_confidence( poi_rows: list[dict[str, Any]], district_row: dict[str, Any] | None, competitor_rows: list[dict[str, Any]], - noise_sources_count: int, + noise_map_rows_nearby: int, air_q: dict[str, Any] | None, weather: dict[str, Any] | None, market_trend: dict[str, Any] | None, zoning: dict[str, Any], + nspd_zoning: dict[str, Any] | None = None, ) -> dict[str, Any]: """X2 (#48) — composite confidence score 0..1 + caveats для site-finder analyze. @@ -1164,15 +1168,37 @@ def _compute_confidence( caveats.append("Нет конкурентов-ЖК в 3км — низкая урбанизация / окраина") # 6) Environmental data freshness - env_ok = sum([bool(noise_sources_count > 0), bool(air_q), bool(weather)]) + # #2464-G: считаем строки шумовой КАРТЫ в радиусе (любого типа, включая + # water/utility), а не отфильтрованные источники для скоринга. Вопрос здесь — + # «есть ли у нас данные по этой точке», и ноль означает непокрытие карты. + # Отфильтрованный список дал бы 0 у трети участков, где рядом просто тихо, и + # оговорка ниже утверждала бы неправду. + env_ok = sum([bool(noise_map_rows_nearby > 0), bool(air_q), bool(weather)]) subscores["environment"] = env_ok / 3.0 - if noise_sources_count == 0: + if noise_map_rows_nearby == 0: caveats.append("Шумовая карта не загружена — noise score = stub") if not air_q: caveats.append("Air Quality API недоступен — exposure unknown") # 7) ПЗЗ coverage — placeholder до G1 + # Зона ПЗЗ приходит ДВУМЯ путями, и признак обязан учитывать оба. + # + # `zoning` — старый per-parcel слой из таблицы `pzz_zones_ekb`. На проде она + # ПУСТА (0 строк, замер 19.08), поэтому `data_available` там всегда False. + # Настоящая зона живёт в `nspd_zoning`: из территориальных зон дампа НСПД, а + # для участков в зазорах между зонами — синтезируется резолвером геопортала + # (см. комментарий PR-A #financial-zoning-decouple выше по файлу). + # + # Пока сюда передавали только `zoning`, подскор был 0.2 у КАЖДОГО участка, а + # оговорка ниже утверждала неправду. Прогон analyze на проде, участок + # 66:41:0402029:25: `nspd_zoning.zone_code = 'Ж-5'`, при этом + # `confidence = 0.61` и оговорка «ПЗЗ zone_code не известен». Отчёт в одном и + # том же ответе показывал зону и заявлял, что зона неизвестна. Подскоров семь, + # значит цена ошибки в композите — (1.0 − 0.2) / 7 = 0.114: 0.61 вместо 0.72. + _nspd = nspd_zoning or {} has_zoning = bool(zoning.get("data_available")) if zoning else False + if not has_zoning: + has_zoning = bool(_nspd.get("zone_code") or _nspd.get("regulation_zone_index")) subscores["zoning"] = 1.0 if has_zoning else 0.2 if not has_zoning: caveats.append( @@ -2319,12 +2345,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 @@ -2512,7 +2557,24 @@ def analyze_parcel( } ) - # 7) Noise score — шумовые источники в радиусе 2 км + # 7) Noise score — шумовые источники в радиусе 2 км. + # + # #2464-G: фильтр по source_type обязателен. Таблица osm_noise_sources_ekb + # держит и НЕшумовые слои — 'water' (870 строк) и 'utility' (1 487), их + # отдельно читает гидрология в 9c ниже. Для скорера они мусор: ключа в + # NOISE_L_BASE у них нет, поэтому `.get(key, 50.0)` выдавал им ровно 50 дБ — + # значение, совпадающее с порогом попадания в список источников. + # + # Главное — `LIMIT 30` берётся ПО БЛИЗОСТИ, поэтому вода вытесняла настоящие + # источники. Замер 19.08 на 1 000 участков (детерминированная выборка по + # cad_num): 19 755 занятых слотов, из них 8 797 (44.5%) — вода и коммуникации; + # 562 участка теряли хотя бы один настоящий источник. + # + # Честно про эффект: сегодня пользователь этого не видит — все вытесненные + # источники оказались тише порога 50 дБ (участков, теряющих ВИДИМЫЙ источник: + # 0 из 729), и максимум дБ не меняется ни у одного. Правка убирает не видимую + # поломку, а скрытый потолок: почти половина бюджета LIMIT уходила на строки, + # которые скорер не умеет оценивать. noise_rows = ( db.execute( text(""" @@ -2522,7 +2584,8 @@ def analyze_parcel( ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography ) AS distance_m FROM osm_noise_sources_ekb n - WHERE ST_DWithin( + WHERE n.source_type IN ('highway', 'railway', 'industrial', 'aerodrome') + AND ST_DWithin( n.geom::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, 2000 @@ -2536,6 +2599,30 @@ def analyze_parcel( .all() ) + # Покрытие шумовой карты — ОТДЕЛЬНО от списка источников, и это не педантизм. + # _compute_confidence спрашивает «загружена ли шумовая карта», а не «шумно ли + # тут»: при нуле она пишет «Шумовая карта не загружена — noise score = stub». + # У 345 участков из 1 000 в радиусе 2 км нет НИ ОДНОГО шумового источника, но + # вода/коммуникации есть. Передай туда len(noise_rows) после фильтра — и треть + # участков получит утверждение о незагруженной карте, которое неверно: карта + # загружена, просто рядом тихо. До этой правки верный ответ получался + # случайно — ровно потому, что в счёт шли и нешумовые строки. + noise_map_rows_nearby: int = ( + db.execute( + text(""" + SELECT COUNT(*) + FROM osm_noise_sources_ekb n + WHERE ST_DWithin( + n.geom::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + 2000 + ) + """), + {"wkt": geom_wkt}, + ).scalar() + or 0 + ) + noise_db_max = 0.0 nearby_noise_sources: list[dict[str, Any]] = [] for nr in noise_rows: @@ -2604,6 +2691,10 @@ def analyze_parcel( .mappings() .all() ) + _flood_proximity = any( + float(r["distance_m"]) < 200 and r["road_class"] in ("river", "canal") + for r in hydro_rows + ) hydrology = { "nearest": [ { @@ -2613,14 +2704,23 @@ def analyze_parcel( } for r in hydro_rows[:5] ], - "flood_risk_flag": any( - float(r["distance_m"]) < 200 and r["road_class"] in ("river", "canal") - for r in hydro_rows - ), + "flood_risk_flag": _flood_proximity, + # #2934: оговорка была написана в расчёте ТОЛЬКО на случай «пойма есть» — + # при flood_risk_flag=false фронт всё равно печатал «Пойма реки (<200м) — + # повышенный риск подтопления», то есть текст противоречил значению рядом. + # Вторая половина («официальные зоны — в Росреестре») верна всегда и + # существенна: этот флаг — близость водного объекта по OSM, а НЕ проверка + # зон затопления. Ни cad_risk_zones (пуста), ни слои risk_* НСПД в него + # не входят. "note": ( - "Пойма реки (<200м) — повышенный риск подтопления. Точные данные о " - "зонах затопления — в Росреестре (ЗОУИТ типа 33: 'Зона затопления, " - "подтопления') через ФГИС ТП." + ( + "Пойма реки или канала ближе 200 м — повышенный риск подтопления. " + if _flood_proximity + else "Рек и каналов ближе 200 м не найдено. " + ) + + "Это близость водного объекта по OSM, а НЕ проверка зон затопления: " + "официальные зоны — ЗОУИТ типа 33 «Зона затопления, подтопления» " + "(Росреестр, ФГИС ТП)." ), } except Exception as e: @@ -3056,11 +3156,23 @@ def analyze_parcel( except Exception as e: logger.warning("district_price_block query failed for %s: %s", cad_num, e) - # B5-6) Risk indicators — flood_zone из cad_risk_zones + noise_score + geology proxy (SF-B5) + # B5-6) Risk indicators — flood_zone + noise_score (SF-B5). + # + # `geology_risk_label` УБРАН (#2934). Он назывался геологическим риском, а + # вычислялся из подтопления и ШУМА: «high» при подтоплении, «medium» при шуме + # ≥65 дБ, иначе «low». Геологии в нём не было ни одного бита. При этом + # `cad_risk_zones` пуста (0 строк, писателя нет — см. #2934 п.6), поэтому + # подтопление приходило только из OSM-прокси «река ближе 200 м», и на + # тихом участке без реки поле всегда говорило «low» — зелёный вердикт, + # ни разу не подкреплённый проверкой геологии. + # + # Замена не нужна: соседний блок `geology` честно отдаёт + # `data_available: false`, когда данных нет. Потребителей у поля не было — + # ни фронт, ни экспортёры, ни §19-allowlist чата его не читали, а в схеме + # `risks: dict[str, Any]`, поэтому OpenAPI не меняется. risks_block: dict[str, Any] = { "flood_zone": False, "noise_score": round(noise_score, 2), - "geology_risk_label": None, } try: with db.begin_nested(): @@ -3082,20 +3194,13 @@ def analyze_parcel( .first() ) _flood = bool(flood_row and int(flood_row["cnt"]) > 0) - # Geology proxy через hydrology flood_risk_flag (уже посчитан выше) + # OSM-прокси «река или канал ближе 200 м» (посчитан выше в hydrology). + # На сегодня это ЕДИНСТВЕННЫЙ работающий источник этого признака: + # cad_risk_zones пуста, поэтому _flood всегда False (#2934 п.6). _geo_flood = hydrology.get("flood_risk_flag", False) if hydrology else False - _has_flood = _flood or _geo_flood - # geology_risk_label: high если flooding, medium если шум > 65дБ, иначе low - if _has_flood: - _geo_label: str | None = "high" - elif noise_db_max >= 65.0: - _geo_label = "medium" - else: - _geo_label = "low" risks_block = { - "flood_zone": _has_flood, + "flood_zone": _flood or _geo_flood, "noise_score": round(noise_score, 2), - "geology_risk_label": _geo_label, } except Exception as e: logger.warning("risks_block query failed for %s: %s", cad_num, e) @@ -3653,7 +3758,14 @@ def analyze_parcel( "source": "gisogd66", } try: - permits_nearby_data = get_permits_nearby(db, geom_wkt, radius_m=500) + # #2464: SAVEPOINT, как у соседних блоков этой же функции (ближайший — разрешения + # 10d-pre2 шестьюдесятью строками выше, где приём применён явно). get_permits_nearby + # делает db.execute на ЭТОЙ сессии и своей защиты не имеет; ошибку глотаем здесь. + # Без savepoint'а упавший запрос оставляет транзакцию в aborted-состоянии, и дальше + # по обработчику падают _geotech_risk (:4141), _neighbors_summary (:4145) и запись + # прогона — то есть теряется весь анализ, а не блок разрешений. + with db.begin_nested(): + permits_nearby_data = get_permits_nearby(db, geom_wkt, radius_m=500) except Exception as e: logger.warning("gisogd permits_nearby query failed for %s: %s", cad_num, e) @@ -3783,11 +3895,12 @@ def analyze_parcel( poi_rows=[dict(p) for p in poi_rows], district_row=dict(district_row) if district_row else None, competitor_rows=[dict(c) for c in competitor_rows], - noise_sources_count=len(noise_rows), + noise_map_rows_nearby=noise_map_rows_nearby, air_q=air_q, weather=weather, market_trend=market_trend, zoning=zoning, + nspd_zoning=nspd_dump_data.get("nspd_zoning"), ) # D4 (#36): aggregate pipeline_24mo diff --git a/backend/app/api/v1/photos.py b/backend/app/api/v1/photos.py index 2f781f8a..5f883ea5 100644 --- a/backend/app/api/v1/photos.py +++ b/backend/app/api/v1/photos.py @@ -85,6 +85,24 @@ def get_photo( upstream = row["photo_url"] photo_name = row["photo_name"] + # #2464-C: отпускаем соединение ДО любой медленной работы — внешнего фетча + # (до 8 с) и генерации миниатюры. SELECT выше открыл транзакцию (SQLAlchemy + # начинает её на первом запросе), и без этого она висела бы idle-in-transaction + # всё это время, занимая соединение пула. + # + # Почему это важно именно здесь: закешировано локально 1 889 фотографий из + # 165 208 (замер 19.08.2026), то есть 98.9% запросов идут «ленивым» путём с + # походом наружу. Пул дефолтный — `create_engine` в app/core/db.py без + # pool_size, значит 5 + 10 overflow = 15 соединений на весь бэкенд. Страница + # отчёта тянет картинки пачкой, и пятнадцать таких запросов занимают пул + # целиком, а за ними встают ВСЕ остальные ручки. + # + # `close()` не делает сессию непригодной: следующий `db.execute` ниже + # прозрачно возьмёт новое соединение и откроет свою транзакцию. Значения из + # `row` уже разложены по локальным переменным выше — после закрытия они + # остаются доступны. + db.close() + headers = {"Cache-Control": "public, max-age=604800, immutable"} # ── size=thumb ────────────────────────────────────────────────────────── diff --git a/backend/app/main.py b/backend/app/main.py index e0ac46cb..4fbddd64 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -48,6 +48,7 @@ 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 import metrics as app_metrics from app.observability.sentry_scrub import scrub_event from app.services.auth_session import resolve_session_token @@ -180,7 +181,15 @@ app.middleware("http")(audit_log_middleware) # `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"}) +# `/metrics` публичен здесь и НЕ публичен снаружи — это два разных периметра, и +# путать их нельзя. Снимает его агент Alloy изнутри docker-сети, где заголовка +# `X-Authenticated-User` нет ни у кого, так что без записи в этом множестве +# скрейп получал бы 401 и метрик не было бы вовсе. Наружу путь при этом не +# открывается: `caddy/sites/apps.caddy` отдаёт бэкенду «Птицы» только `/health` +# и `/api/*`, а `/metrics` там дополнительно закрыт явным `respond 404`. +_PUBLIC_PATHS = frozenset( + {"/health", "/metrics", "/api/v1/ping", "/docs", "/redoc", "/openapi.json"} +) def _propagate_authenticated_user(request: Request, username: str) -> None: @@ -465,6 +474,15 @@ app.add_middleware( allow_headers=["*"], ) +# Метрики — СЛЕДОМ ЗА CORS и, значит, самым внешним слоем: `add_middleware` +# вставляет в начало списка, поэтому зарегистрированный последним оказывается +# снаружи всех. Порядок здесь несущий, а не вкусовой. Изнутри RBAC-гварда не +# видно ни отказов авторизации (401/403 — их отдаёт сам гвард), ни времени, +# которое он тратит на резолв сессии в БД `auth`; а именно этот путь уже давал +# инцидент (#1202, блокирующий I/O в middleware). Снаружи видно и то и другое. +app.add_middleware(app_metrics.MetricsMiddleware) + +app.include_router(app_metrics.router, tags=["observability"]) app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"]) app.include_router(chat.router, prefix="/api/v1/chat", tags=["chat"]) app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"]) @@ -508,3 +526,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/metrics.py b/backend/app/observability/metrics.py new file mode 100644 index 00000000..d8005281 --- /dev/null +++ b/backend/app/observability/metrics.py @@ -0,0 +1,175 @@ +"""Метрики Prometheus для API «Птицы»: счётчики, гистограмма задержки, `/metrics`. + +Часть 3 задачи #3078. До неё числовых рядов у приложения не было вовсе — только +логи и исключения в GlitchTip. Класс отказов «отвечает, но медленно» и «отдаёт +4xx потоком» в такой картине невидим: исключения нет, строка в логе выглядит +обычной, а пользователь видит неработающий продукт. + +ЧТО ИМЕННО СЧИТАЕМ И ПОЧЕМУ ТАК + +`route` — это ШАБЛОН маршрута (`/api/v1/parcels/{cad_num}`), а не путь запроса. +Разница принципиальная, а не косметическая: кадастровый номер в метке дал бы +новый временной ряд на каждый участок. У Prometheus ряд стоит памяти постоянно, +а не в момент запроса, и такая метка кладёт приёмник за сутки — это самый +известный способ уронить мониторинг тем самым мониторингом. + +Незаматченные пути (404, сканеры, чужие боты) сведены в одну метку +``__unmatched__``. Иначе достаточно одного бота, перебирающего адреса, чтобы +получить тот же взрыв рядов через чёрный ход. + +Ошибка внутри приложения фиксируется как 500 в `finally`: исключение проходит +сквозь этот слой наружу, к `ServerErrorMiddleware`, и без `finally` такие +запросы просто не попали бы в счётчик — то есть отсутствовали бы ровно в тот +момент, когда метрики нужнее всего. + +ОДИН ПРОЦЕСС — ОДИН РЕЕСТР + +`Dockerfile:75` запускает `uvicorn` без `--workers`, то есть процесс один и +значения счётчиков целостны. Появится `--workers` или gunicorn — счётчики +станут per-process, и каждый скрейп будет попадать в случайный воркер: график +начнёт пилить вверх-вниз без всякой связи с нагрузкой. Лечится штатным +многопроцессным режимом `prometheus_client` (`PROMETHEUS_MULTIPROC_DIR` + +`MultiProcessCollector`), но это отдельная работа, и делать её заранее «на +всякий случай» не стоит. Здесь оставлена явная отметка, чтобы связь между +`--workers` и сломанными графиками не пришлось искать заново. + +ДОСТУП + +`/metrics` снимает только агент Alloy изнутри docker-сети. Снаружи путь +недостижим: `caddy/sites/apps.caddy` проксирует на бэкенд «Птицы» лишь +`/health` и `/api/*`, а `/metrics` там вдобавок закрыт явным `respond 404` — +чтобы это осталось решением, а не побочным следствием текущего порядка +директив. +""" + +from __future__ import annotations + +import os +import time +from collections.abc import Awaitable, Callable, MutableMapping +from typing import Any + +from fastapi import APIRouter, Response +from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest + +Scope = MutableMapping[str, Any] +Message = MutableMapping[str, Any] +Receive = Callable[[], Awaitable[Message]] +Send = Callable[[Message], Awaitable[None]] +ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]] + +# Метка для всего, что не совпало ни с одним маршрутом. Явная строка, а не +# пустое значение: пустая метка в PromQL неотличима от отсутствующей. +UNMATCHED = "__unmatched__" + +# Границы гистограммы подобраны под «Птицу», а не взяты из примера в документации. +# Быстрые ручки (`/health`, справочники) укладываются в десятки миллисекунд; +# `POST /api/v1/parcels/{cad_num}/analyze` уходит в десятки секунд, потому что +# внутри поход в OSRM и подсчёт геометрии. Без верхних корзин весь тяжёлый хвост +# слипся бы в `+Inf`, и «стало вдвое медленнее» было бы не увидеть. +_DURATION_BUCKETS = (0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, float("inf")) + +REQUESTS = Counter( + "http_requests_total", + "Запросов обслужено", + labelnames=("method", "route", "status"), +) + +DURATION = Histogram( + "http_request_duration_seconds", + "Время ответа целиком, включая авторизацию и middleware", + labelnames=("method", "route"), + buckets=_DURATION_BUCKETS, +) + +# Без меток намеренно. Gauge с меткой маршрута не возвращается в ноль сам: +# после единственного запроса ряд остаётся навсегда, и получается тот же рост +# кардинальности, только медленный и незаметный. +IN_PROGRESS = Gauge( + "http_requests_in_progress", + "Запросов обрабатывается прямо сейчас", +) + +BUILD_INFO = Gauge( + "app_build_info", + "Всегда 1; полезны метки — по ним видно, какая версия отвечала в момент сбоя", + labelnames=("app", "release"), +) +BUILD_INFO.labels( + app="sitefinder", + release=os.getenv("SENTRY_RELEASE") or os.getenv("IMAGE_TAG") or "unknown", +).set(1) + + +def route_label(scope: Scope) -> str: + """Шаблон маршрута из ASGI-scope, либо ``__unmatched__``. + + `scope["route"]` проставляет роутер Starlette в момент матчинга. Наш слой + внешний, поэтому к моменту, когда управление возвращается сюда, поле уже + заполнено — scope это один и тот же dict на весь стек, он не копируется + между слоями. + """ + route = scope.get("route") + path = getattr(route, "path", None) + if isinstance(path, str) and path: + return path + return UNMATCHED + + +class MetricsMiddleware: + """Чистый ASGI-слой, без `BaseHTTPMiddleware`. + + `BaseHTTPMiddleware` заворачивает ответ в собственный поток и на потоковых + ответах ведёт себя иначе, чем голый ASGI. В «Птице» такие ответы есть — + выгрузки PDF/DXF/XLSX идут телом ответа, — и ставить ради подсчёта запросов + слой, который меняет их обработку, не стоит. + + Регистрировать ПОСЛЕДНИМ: `add_middleware` вставляет в начало списка, то + есть последний зарегистрированный оказывается самым внешним. Именно это и + нужно — иначе 401 от RBAC-гварда не попадёт в счётчик, а поток отказов + авторизации это ровно то, что нужно видеть. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope.get("type") != "http": + await self.app(scope, receive, send) + return + + method = scope.get("method", "UNKNOWN") + # 500 по умолчанию: если приложение упадёт исключением, `http.response.start` + # мы не увидим, и запрос обязан быть посчитан как ошибка, а не пропасть. + status = 500 + + async def send_wrapper(message: Message) -> None: + nonlocal status + if message["type"] == "http.response.start": + status = message["status"] + await send(message) + + IN_PROGRESS.inc() + started = time.perf_counter() + try: + await self.app(scope, receive, send_wrapper) + finally: + IN_PROGRESS.dec() + route = route_label(scope) + DURATION.labels(method, route).observe(time.perf_counter() - started) + REQUESTS.labels(method, route, str(status)).inc() + + +router = APIRouter() + + +@router.get("/metrics", include_in_schema=False) +def metrics() -> Response: + """Выгрузка в текстовом формате Prometheus. + + Реестр по умолчанию, а не свой: вместе с нашими метриками он отдаёт + `process_resident_memory_bytes`, `process_open_fds` и счётчики сборщика + мусора. Утечка памяти и исчерпание файловых дескрипторов видны по ним + напрямую, доплачивать за это ничем не нужно. + """ + return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/backend/app/schemas/own_project.py b/backend/app/schemas/own_project.py index 3d878d7e..e3e2c661 100644 --- a/backend/app/schemas/own_project.py +++ b/backend/app/schemas/own_project.py @@ -49,9 +49,7 @@ class OwnPlannedProjectCreate(BaseModel): planned_release_month: date | None = Field( None, description="Планируемый месяц выхода в продажу (нормализуется к 1-му числу)" ) - price_min_per_m2: float | None = Field( - None, ge=0, description="Нижняя граница цены, ₽/м² (≥0)" - ) + price_min_per_m2: float | None = Field(None, ge=0, description="Нижняя граница цены, ₽/м² (≥0)") price_max_per_m2: float | None = Field( None, ge=0, description="Верхняя граница цены, ₽/м² (≥0)" ) diff --git a/backend/app/schemas/parcel.py b/backend/app/schemas/parcel.py index 118f527a..078dde50 100644 --- a/backend/app/schemas/parcel.py +++ b/backend/app/schemas/parcel.py @@ -598,7 +598,8 @@ class TopLayoutRow(BaseModel): total_sold_in_window: int velocity_per_month: float avg_price_per_m2_rub: float | None # NULL если objective не покрывает obj - avg_area_m2: float + # #2867: NULL если сделок за окно нет — средней площади нет; раньше отдавался 0 м². + avg_area_m2: float | None supply_units_in_radius: int sold_pct_of_supply: float | None # NULL если supply=0; clamped at 100.0 is_oversold: bool # True когда raw sum_deals/supply > 100% (несопоставимые окна) diff --git a/backend/app/services/analytics/ddu_price_indicator.py b/backend/app/services/analytics/ddu_price_indicator.py index cf7268e8..df503d0e 100644 --- a/backend/app/services/analytics/ddu_price_indicator.py +++ b/backend/app/services/analytics/ddu_price_indicator.py @@ -316,9 +316,7 @@ def refresh_ddu_price_indicator(db: Session, *, concurrently: bool = True) -> in db.commit() except OperationalError as e: if concurrently and "cannot refresh materialized view" in str(e).lower(): - logger.warning( - "ddu_indicator CONCURRENTLY failed (MV not populated), falling back" - ) + logger.warning("ddu_indicator CONCURRENTLY failed (MV not populated), falling back") db.rollback() db.execute(text("REFRESH MATERIALIZED VIEW mv_ddu_price_indicator")) db.commit() diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 7239d698..8393b48e 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -665,8 +665,7 @@ def prinzip_insights() -> dict[str, Any]: { "district": "Чкаловский / Железнодорожный", "why": ( - "Растущие районы, 0% PRINZIP, низкая конкуренция. " - "Тест 60-80 м² без премиума." + "Растущие районы, 0% PRINZIP, низкая конкуренция. Тест 60-80 м² без премиума." ), }, ], @@ -688,7 +687,7 @@ def prinzip_insights() -> dict[str, Any]: { "name": "Холдинг Форум-групп", "model": ( - "113 тыс м² × sold 54% × Δ +21пп лидер velocity. " "3-к доля 21.5%, ср. 61 м²." + "113 тыс м² × sold 54% × Δ +21пп лидер velocity. 3-к доля 21.5%, ср. 61 м²." ), }, ], @@ -1436,9 +1435,27 @@ def _velocity_baseline( Migrated from domrf_kn_sale_graph (stale since 2026-01) to objective_corpus_room_month (updated weekly via Objective API). - objective_corpus_room_month.district matches domrf_kn_objects.district_name. class filter uses 'class' column (Комфорт/Бизнес/Стандарт). + ВНИМАНИЕ ПРО СЛОВАРЬ РАЙОНОВ. Прежняя редакция утверждала, что + `objective_corpus_room_month.district` совпадает с + `domrf_kn_objects.district_name`. Это неверно, и docstring `_elasticity_coef` + ниже описывает ту же колонку правильно: там МИКРО-вокабуляр ЕКБ. + + Замер прода 20.08.2026: + district (микро) Академический, ВИЗ, Юго-Западный, Уктус, Втузгородок, + Широкая Речка, Центр, Эльмаш, … + district_name (админ) Академический, Чкаловский, Верх-Исетский, Ленинский, + Орджоникидзевский, Кировский, … + + Пересечение частичное: из 8 админ-имён в микро-колонке встречаются 4, и с + сильно меньшим объёмом (Ленинский 55 точек против 621 у Академического; + Чкаловский и Верх-Исетский — ноль). Вызывающий передаёт сюда + `district_row["district_name"]`, то есть АДМИН-имя: для половины районов + выборка пустая, для остальных — заметно урезанная. Резолв admin→micros + (как в `_elasticity_coef`, #1211) здесь НЕ сделан — это отдельная задача, + docstring лишь перестаёт утверждать обратное (#2464). + Returns dict {realised_per_month_median, realised_per_month_avg, objects_count, observations}. All-None means no data → caller falls back. """ @@ -1856,7 +1873,7 @@ def _active_competitors_count( # #38: реальный obj_class в приоритете, иначе obj_class_fallback. if target_class: n = _q( - "AND district_name = :dn" " AND COALESCE(obj_class, obj_class_fallback) = :cls", + "AND district_name = :dn AND COALESCE(obj_class, obj_class_fallback) = :cls", {"rc": region_code, "dn": district_name, "cls": target_class}, ) if n >= 2: diff --git a/backend/app/services/cadastre/bulk_harvest.py b/backend/app/services/cadastre/bulk_harvest.py index 905d15de..8109fb04 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 (иначе сеть @@ -551,10 +578,20 @@ async def backfill_parcel_geom( ) result.grid_walk_requests += n_requests db.commit() + except (NspdBulkWafError, NspdBulkRateLimitError): + # #2464: бан IP / исчерпанные ретраи — НЕ «сбойный квартал». Голый + # except ниже их глотал, хотя его же комментарий обещал обратное: + # «WAF 403 пробросится из client и прервёт прогон». Прервать он не мог — + # ловил сам себя, и цикл шёл дальше по всем оставшимся кварталам, долбя + # уже блокирующий WAF и углубляя бан. Замер прода 20.08: limit=500 + # участков раскладывается на 174 квартала, каждый — grid-walk по 49 + # запросов, то есть до ~8500 обращений вместо остановки на первом. + # Тот же фикс, что в harvest_quarter выше (#2464-A) — там это место + # уже чинили, а это пропустили. + db.rollback() + raise except Exception as e: # Один сбойный квартал не валит весь backfill — лог + продолжаем. - # (WAF 403 пробросится из client и прервёт прогон — это ожидаемо, - # caller-task ловит и не ретраит, как в bulk_harvest.) logger.warning("backfill_parcel_geom: grid-walk failed quarter=%s: %s", quarter, e) db.rollback() continue diff --git a/backend/app/services/chat/intents.py b/backend/app/services/chat/intents.py index d821c7e9..7d66c009 100644 --- a/backend/app/services/chat/intents.py +++ b/backend/app/services/chat/intents.py @@ -251,9 +251,7 @@ def _render_what_to_build(report: dict[str, Any]) -> tuple[str, list[str]]: if summary: lines.append(str(summary)) - if not any( - section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary") - ): + if not any(section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")): lines.append("Раздел рекомендации продукта в отчёте пуст.") return _assemble(lines), sections_used diff --git a/backend/app/services/dadata_client.py b/backend/app/services/dadata_client.py index c6ed661d..e28ed546 100644 --- a/backend/app/services/dadata_client.py +++ b/backend/app/services/dadata_client.py @@ -182,7 +182,24 @@ def _suggest_geocode(address: str, token: str) -> tuple[float, float] | None: logger.info("dadata_client: suggest пусто для %r", address[:60]) return None - data = suggestions[0].get("data") or {} + # #2464: `or {}` ловит только falsy. Если DaData отдаст в `data` список или + # строку (дрейф контракта), `.get` ниже поднимет AttributeError — а он летит + # НАРУЖУ: сюда попадают из clean_address по фолбэку 401/403 (строка 112), то + # есть уже ЗА пределами её try/except, и у вызывающего гео-прохода + # (objective_backfill._geocode) обёртки тоже нет. Один такой ответ уронил бы + # весь проход целиком, а не один адрес. + # + # Соседние уровни в этом же файле проверяются через isinstance — `payload`, + # `suggestions[0]`, `item` в clean_address. Защита пропала ровно на один + # уровень глубже. + data = suggestions[0].get("data") + if not isinstance(data, dict): + logger.warning( + "dadata_client: suggest data не dict (%s) для %r", + type(data).__name__, + address[:60], + ) + return None lat = _coerce_float(data.get("geo_lat")) lon = _coerce_float(data.get("geo_lon")) if lat is None or lon is None: diff --git a/backend/app/services/etl/newbuilding_crossload.py b/backend/app/services/etl/newbuilding_crossload.py index ab5ed376..ad5ef66d 100644 --- a/backend/app/services/etl/newbuilding_crossload.py +++ b/backend/app/services/etl/newbuilding_crossload.py @@ -229,8 +229,7 @@ def run_crossload(db: Session | None = None) -> dict[str, Any]: except Exception as exc: skipped += 1 logger.warning( - "etl_newbuilding_crossload: upsert failed " - "source=%s ext_id=%s: %s", + "etl_newbuilding_crossload: upsert failed source=%s ext_id=%s: %s", params.get("source"), params.get("ext_house_id"), exc, diff --git a/backend/app/services/etl/objective_backfill.py b/backend/app/services/etl/objective_backfill.py index 1e232317..84a06d9c 100644 --- a/backend/app/services/etl/objective_backfill.py +++ b/backend/app/services/etl/objective_backfill.py @@ -364,12 +364,15 @@ class CoreMatchReport: ambiguous — >1 objective-кандидатов по core → в отчёт, разрешение вручную/гео. skipped_taken — objective_complex_name уже занят в mapping (UNIQUE-констрейнт; его domrf-группа уже покрыта — дубли не нужны). + taken_names — сами занятые имена. Нужны гео-проходу (#2464): он разбирает + ambiguous по ВСЕМ кандидатам ядра, а занятого записать нельзя. """ tier_a: list[CoreMatch] = field(default_factory=list) tier_b: list[CoreMatch] = field(default_factory=list) ambiguous: list[CoreMatch] = field(default_factory=list) skipped_taken: list[CoreMatch] = field(default_factory=list) + taken_names: set[str] = field(default_factory=set) def counts(self) -> dict[str, int]: return { @@ -456,6 +459,7 @@ def find_core_matches(db: Session) -> CoreMatchReport: taken_names: set[str] = { str(r[0]) for r in db.execute(_TAKEN_NAMES_SQL, {"group": OBJECTIVE_GROUP}).all() } + report.taken_names = taken_names # domrf-сторона: несопоставленные ЕКБ, latest snapshot per obj_id for row in db.execute(_DOMRF_UNMAPPED_SQL).all(): @@ -696,7 +700,8 @@ class GeoMatch: @dataclass class GeoReject: """Отклонённый гео-кандидат (для отчёта). reason: 'no_address' | - 'no_geocode' | 'too_far' | 'ambiguous_multi' | 'call_limit'. + 'no_geocode' | 'too_far' | 'ambiguous_multi' | 'partial_geocode' | + 'all_candidates_taken' | 'call_limit'. distance_m None когда дистанцию посчитать не удалось (нет адреса/геокода/ координат domrf). @@ -820,6 +825,7 @@ def find_geo_matches(db: Session, *, max_distance_m: float = GEO_MAX_DISTANCE_M) tier_b = core_report.tier_b ambiguous = core_report.ambiguous + taken_names = core_report.taken_names if not tier_b and not ambiguous: logger.info("find_geo_matches: нет tier_b/ambiguous кандидатов — nothing to do") return report @@ -890,7 +896,19 @@ def find_geo_matches(db: Session, *, max_distance_m: float = GEO_MAX_DISTANCE_M) if domrf_pt is None: report.rejected.append(_geo_reject(m, "ambiguous", "no_geocode")) continue - candidates = objective_by_core.get(m.core, []) + # Занятые objective-имена отсеиваем ДО геокода. Записать такое имя + # нельзя в принципе: apply_geo_matches вставляет с + # ON CONFLICT (objective_complex_name, objective_group) DO NOTHING, а + # _TAKEN_NAMES_SQL выбирает ровно по этому ключу. Раньше занятый кандидат + # мог оказаться единственным в радиусе и уходил в confirmed — прогон + # рапортовал подтверждение, которого запись затем молча не делала. + # Замер на проде 19.08: 14 неоднозначных строк (из 930 несопоставленных), + # 28 слотов кандидатов, из них 14 занятых; 3 адреса из 6 к геокоду — + # занятых. После отсева у всех 14 остаётся ровно один кандидат. + candidates = [c for c in objective_by_core.get(m.core, []) if c[0] not in taken_names] + if not candidates: + report.rejected.append(_geo_reject(m, "ambiguous", "all_candidates_taken")) + continue in_radius: list[tuple[str, int | None, str, float]] = [] any_geocoded = False geocoded_count = 0 @@ -934,9 +952,12 @@ def find_geo_matches(db: Session, *, max_distance_m: float = GEO_MAX_DISTANCE_M) reason = "call_limit" if report.call_limit_hit else "no_geocode" report.rejected.append(_geo_reject(m, "ambiguous", reason)) else: - # 0 в радиусе, или >1 в радиусе → остаётся ambiguous + # Отделяем «никто не близко» от «близко несколько». После отсева + # занятых кандидат часто остаётся один, и метка ambiguous_multi при + # пустом in_radius была бы прямой неправдой в отчёте оператору. nearest = min((d for *_, d in in_radius), default=None) - report.rejected.append(_geo_reject(m, "ambiguous", "ambiguous_multi", nearest)) + reason = "ambiguous_multi" if in_radius else "too_far" + report.rejected.append(_geo_reject(m, "ambiguous", reason, nearest)) logger.info( "find_geo_matches: %s call_limit_hit=%s", diff --git a/backend/app/services/exporters/full_report_docx.py b/backend/app/services/exporters/full_report_docx.py index 6d6f139e..2860190d 100644 --- a/backend/app/services/exporters/full_report_docx.py +++ b/backend/app/services/exporters/full_report_docx.py @@ -41,6 +41,7 @@ from typing import TYPE_CHECKING, Any # `_fc_*`-хелперы (нормализация forecast-словаря) — реэкспорт из report_pdf через # full_report_html, тянем оттуда же (одна точка импорта). from app.services.exporters.full_report_html import ( + FLOOD_PROXIMITY_LABEL, _as_dict, _as_list, _development_type_ru, @@ -297,7 +298,13 @@ def _build_zouit(doc: _DocxDocument, result: dict[str, Any]) -> None: summary_pairs: list[tuple[str, Any]] = [ ("Есть ЗОУИТ", has_zouit), - ("Кол-во типов ЗОУИТ", zouit_count), + # Подпись именно «Кол-во ЗОУИТ», а не «типов»: значение приходит из + # encumbrance.zouit_count, а там `len(zouit_rows)` — число ЗАПИСЕЙ cad_zouit, + # пересёкших участок (parcels.py). Типы лежат отдельно, в zouit_types, и + # показаны строкой ниже. Прежняя подпись «Кол-во типов ЗОУИТ» расходилась со + # значением в 717 разборах из 1637 с ЗОУИТ — 43.8%, в среднем завышая «типы» + # в 1.35 раза (#2464). + ("Кол-во ЗОУИТ", zouit_count), ] if zouit_types: summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types))) @@ -404,10 +411,16 @@ def _build_geotech_hydro(doc: _DocxDocument, result: dict[str, Any]) -> None: ("Балльность", geotech.get("seismic_intensity_balls")), ("Многолетняя мерзлота", geotech.get("permafrost")), ("Промобъектов в 500 м", geotech.get("industrial_within_500m")), - ("Риск подтопления", hydro.get("flood_risk_flag")), + (FLOOD_PROXIMITY_LABEL, hydro.get("flood_risk_flag")), ] pairs = [(k, v) for k, v in pairs if v not in (None, "")] _add_kv_table(doc, pairs) + # #2934: та же оговорка, что в HTML-двойнике. Метка — общая константа оттуда же: + # обе таблицы собираются одинаковыми списками пар, и правка в одном файле молча + # разошлась бы с другим. + _hydro_note = hydro.get("note") + if _hydro_note: + doc.add_paragraph(str(_hydro_note)) water_rows = [ [w.get("name") or w.get("subtype"), _fmt_int_ru(w.get("distance_m"))] diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py index 6087e4a4..7ce3b393 100644 --- a/backend/app/services/exporters/full_report_html.py +++ b/backend/app/services/exporters/full_report_html.py @@ -402,6 +402,19 @@ def _kv_row(label: str, value: Any) -> str: return f'{html.escape(label)}{_esc(value)}' +# #2934: метка строки о подтоплении. Прежняя — «Риск подтопления» — утверждала +# результат проверки зон затопления, которой не было: значение берётся из +# hydrology.flood_risk_flag, а это близость реки или канала ближе 200 м по OSM. +# Ни cad_risk_zones (0 строк на проде), ни 11 слоёв risk_* НСПД (0 объектов на 669 +# дампов) в него не входят. `_fmt(False)` печатал «нет», и читатель экспортированного +# документа получал «Риск подтопления — нет» как заключение. +# +# Константа общая с DOCX (`full_report_docx` импортирует хелперы отсюда): строка +# собирается в двух файлах одинаковыми списками пар, и разъезд формулировок был бы +# незаметен до чьей-нибудь жалобы. +FLOOD_PROXIMITY_LABEL = "Река или канал ближе 200 м (OSM)" + + def _kv_table(pairs: list[tuple[str, Any]]) -> str: """Таблица «метка → значение» из списка пар. Пустой список → «нет данных». PURE.""" if not pairs: @@ -469,6 +482,12 @@ def _build_zoning(result: dict[str, Any]) -> str: note = zoning.get("note") note_html = f'

{_esc(note)}

' if note else "" return _no_data() + note_html + # Без этой строки годное легаси-зонирование признавалось пригодным выше и тут же + # терялось: ниже всё читается из nspd_zoning, а он в этой ветке пустой — все пары + # выходили None, отбрасывались фильтром, и §1 печатал «нет данных» ПОВЕРХ + # имеющихся данных. Соседний full_report_docx._build_zoning делает ровно это же + # присваивание (#2464). + nspd_zoning = zoning zone_code = nspd_zoning.get("zone_code") or nspd_zoning.get("regulation_zone_index") pairs: list[tuple[str, Any]] = [ @@ -519,7 +538,13 @@ def _build_zouit(result: dict[str, Any]) -> str: summary_pairs: list[tuple[str, Any]] = [ ("Есть ЗОУИТ", has_zouit), - ("Кол-во типов ЗОУИТ", zouit_count), + # Подпись именно «Кол-во ЗОУИТ», а не «типов»: значение приходит из + # encumbrance.zouit_count, а там `len(zouit_rows)` — число ЗАПИСЕЙ cad_zouit, + # пересёкших участок (parcels.py). Типы лежат отдельно, в zouit_types, и + # показаны строкой ниже. Прежняя подпись «Кол-во типов ЗОУИТ» расходилась со + # значением в 717 разборах из 1637 с ЗОУИТ — 43.8%, в среднем завышая «типы» + # в 1.35 раза (#2464). + ("Кол-во ЗОУИТ", zouit_count), ] if zouit_types: summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types))) @@ -643,10 +668,16 @@ def _build_geotech_hydro(result: dict[str, Any]) -> str: ("Балльность", geotech.get("seismic_intensity_balls")), ("Многолетняя мерзлота", geotech.get("permafrost")), ("Промобъектов в 500 м", geotech.get("industrial_within_500m")), - ("Риск подтопления", hydro.get("flood_risk_flag")), + (FLOOD_PROXIMITY_LABEL, hydro.get("flood_risk_flag")), ] pairs = [(k, v) for k, v in pairs if v not in (None, "")] geotech_table = _kv_table(pairs) + # Оговорка payload'а существовала и терялась ровно здесь, на границе экспортёра: + # фронт её печатает (HydrologyBlock.tsx), а PDF и DOCX — нет. Именно она говорит, + # что официальные зоны затопления живут в ЗОУИТ типа 33, а не в этой строке. + hydro_note = hydro.get("note") + if hydro_note: + geotech_table += f'

{_esc(str(hydro_note))}

' water_rows = [ [w.get("name") or w.get("subtype"), _fmt_int_ru(w.get("distance_m"))] diff --git a/backend/app/services/exporters/layout_tz_pdf.py b/backend/app/services/exporters/layout_tz_pdf.py index 4061dabd..581f200d 100644 --- a/backend/app/services/exporters/layout_tz_pdf.py +++ b/backend/app/services/exporters/layout_tz_pdf.py @@ -50,6 +50,12 @@ def build_layout_tz_html( return "—" return f"{val:,.0f}".replace(",", " ") + " ₽" + def _area_cell(val: float | None) -> str: + """#2867: средняя площадь — None, если сделок за окно нет → «—», а не «0.0».""" + if val is None: + return "—" + return f"{val:.1f}" + def _price_m2_cell(val: float | None) -> str: """Ячейка цены ₽/м² (тыс-разделитель — пробел). None → «—» (graceful).""" if val is None: @@ -69,7 +75,7 @@ def build_layout_tz_html( f"{_html.escape(r.room_bucket)}" f"{_html.escape(r.area_bin)}" f"{r.velocity_per_month:.1f}" - f"{r.avg_area_m2:.1f}" + f"{_area_cell(r.avg_area_m2)}" f"{_price_cell(r.avg_price_per_m2_rub)}" f"{r.total_sold_in_window}" "" diff --git a/backend/app/services/exporters/report_docx.py b/backend/app/services/exporters/report_docx.py index 96663941..6c47930f 100644 --- a/backend/app/services/exporters/report_docx.py +++ b/backend/app/services/exporters/report_docx.py @@ -270,9 +270,7 @@ def _build_scenarios(doc: _DocxDocument, report: dict[str, Any]) -> None: for name, payload in by_scenario.items(): data = _as_dict(payload) rate_path = _as_dict(data.get("rate_path")) - rate_str = ( - ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None - ) + rate_str = ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else None rows.append([name, _scenario_deficit_cell(data), rate_str, data.get("advisory")]) headers = [ diff --git a/backend/app/services/exporters/report_maps.py b/backend/app/services/exporters/report_maps.py index 9e8c2097..3f9e3cbd 100644 --- a/backend/app/services/exporters/report_maps.py +++ b/backend/app/services/exporters/report_maps.py @@ -85,8 +85,7 @@ _CONCEPT_FOOTPRINT_STYLE = { } _MAP_UNAVAILABLE_HTML = ( - '
Карта недоступна — геоданные участка отсутствуют ' - "в отчёте
" + '
Карта недоступна — геоданные участка отсутствуют в отчёте
' ) @@ -145,9 +144,22 @@ def _add_basemap(ax: Any) -> bool: def _fetch() -> None: cx.add_basemap(ax, crs=_WEB_MERCATOR, source=cx.providers.OpenStreetMap.Mapnik) + # #2464-C: НЕ `with ThreadPoolExecutor(...)`. Его __exit__ зовёт + # shutdown(wait=True) и ждёт, пока рабочий поток реально закончит — то есть + # result(timeout=...) ограничивал момент, когда мы перестаём ждать ЗНАЧЕНИЕ, + # а функция всё равно не возвращалась, пока висел tile-сервер. Заявленный + # «таймаут N секунд» не выполнялся: экспорт стоял столько, сколько стояло + # зависание. + # + # ЧЕСТНАЯ ЦЕНА: shutdown(wait=False) оставляет зависший поток жить до конца + # его собственного вызова. Это ограничивает ЗАПРОС, но не процесс — + # ThreadPoolExecutor держит потоки не-демонами и джойнит их в atexit, так что + # остановка воркера всё ещё может подождать зависший фетч. Меняем «висит + # генерация отчёта» на «висит один поток в фоне» — это осознанный размен, + # а не полное устранение. + pool = ThreadPoolExecutor(max_workers=1) try: - with ThreadPoolExecutor(max_workers=1) as pool: - pool.submit(_fetch).result(timeout=_BASEMAP_TIMEOUT_S) + pool.submit(_fetch).result(timeout=_BASEMAP_TIMEOUT_S) return True except FuturesTimeoutError: logger.warning( @@ -157,6 +169,10 @@ def _add_basemap(ax: Any) -> bool: except Exception as exc: # тайлы недоступны: graceful fallback на белый фон, не валим экспорт logger.warning("report_maps: OSM basemap недоступен (%s) — fallback белый фон", exc) return False + finally: + # cancel_futures=True снимает ещё не начатые задачи; начатую — не отменит + # (Python не умеет прерывать поток), она просто доработает в фоне. + pool.shutdown(wait=False, cancel_futures=True) # ── Общие хелперы фигуры ─────────────────────────────────────────────────────── diff --git a/backend/app/services/forecasting/affordability.py b/backend/app/services/forecasting/affordability.py index 04235f7d..ac865265 100644 --- a/backend/app/services/forecasting/affordability.py +++ b/backend/app/services/forecasting/affordability.py @@ -348,9 +348,7 @@ def compute_affordability( # Иначе сценарный платёж считался бы по «голой» key_rate (≈ на 4.5 п.п. # ниже базовой ставки) и был бы НЕсопоставим с monthly_payment_rub (#1639). market_scenario_rate = ( - scenario_rate + _KEY_RATE_MARKET_SPREAD_PP - if scenario_rate is not None - else None + scenario_rate + _KEY_RATE_MARKET_SPREAD_PP if scenario_rate is not None else None ) payment = _annuity(principal, market_scenario_rate, _ANNUITY_TERM_MONTHS) if payment is not None: diff --git a/backend/app/services/forecasting/confidence_engine.py b/backend/app/services/forecasting/confidence_engine.py index 4ef5b203..02aa4d45 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) и только @@ -24,13 +24,17 @@ High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, и причина это ПРОГОВАРИВАЕТ. Честность важнее оптимистичной метки. ПОРОГИ (align с per-service gate'ами, которые читает движок): - • deal_count — зеркало market_metrics._confidence (n_lots/n_sold) + §9.6 _MIN_OBS: + • deal_count — зеркало market_metrics._confidence (n_lots/n_sold) + порог + rate_sensitivity._MIN_OBS: мало сделок → скоростные метрики статистически ненадёжны. • 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 недооценён. - • history_months — зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / _MIN_OBS=8: короткий + • domrf_coverage — имя историческое: фактически это доля БЛИЖНИХ ЖК (3 км) с ценой + из Objective (`analyze.market_data_coverage_pct`), а не покрытие маппинга + domrf↔objective. Продьюсера для второго нет и не было (#2464-H). Прод 13.08: + медиана 40%, среднее 31.7%. Низкое покрытие → рынок и конкуренция оценены хуже. + • history_months — созвучно rate_sensitivity._CONF_HIGH_MIN_OBS=24 (≥2 года) / + _MIN_OBS=8 (НЕ §9.6: там свой _MIN_OBS=30, см. комментарий у констант): короткий ряд → связь rate↔sales / тренды не установлены. • confounded — шок-окно (is_confounded_window, PR2): ряд пересекает структурный разрыв → оценки смещены (НИКОГДА не 'high'). @@ -82,7 +86,8 @@ _SERVICE_RU_DEFAULT: str = "Компонент" # deal_count: число сделок (продаж) за окно. high — длинная плотная выборка, # medium — рабочий минимум, low — статистически ненадёжно (зеркало духа -# market_metrics: n_sold>0 обязателен; §9.6 _MIN_OBS=8 — пол для регрессии). +# market_metrics: n_sold>0 обязателен; rate_sensitivity._MIN_OBS=8 — пол для оценки +# чувствительности. НЕ §9.6: у регрессии §9.6 порог свой, _MIN_OBS=30.) _DEAL_COUNT_HIGH: int = 50 _DEAL_COUNT_LOW: int = 15 @@ -91,14 +96,22 @@ _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 -# history_months: глубина ряда (мес). Зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / -# _MIN_OBS=8 (пол): короткий ряд → тренды/чувствительность не установлены. +# history_months: глубина ряда (мес). Пороги созвучны rate_sensitivity: +# _CONF_HIGH_MIN_OBS=24 (≥2 года Δln-наблюдений) и _MIN_OBS=8 (пол, ниже которого +# чувствительность не считаем). Короткий ряд → тренды/чувствительность не установлены. +# +# #2464 кластер H: раньше обе константы приписывались «§9.6». Это неверный адрес — +# §9.6 (forecasting/regression.py) держит СВОЙ _MIN_OBS=30 (gate-порог для claim) и +# _MIN_FIT_OBS=8 (можно ли вообще фитить). Совпадение цифры 8 в двух модулях и сбило +# ссылку. Значения 24/8 верны, неверна была атрибуция. _HISTORY_MONTHS_HIGH: int = 24 _HISTORY_MONTHS_LOW: int = 12 @@ -252,23 +265,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 +320,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 +503,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/macro_coefficient.py b/backend/app/services/forecasting/macro_coefficient.py index 4fe60ccd..b48dbd0f 100644 --- a/backend/app/services/forecasting/macro_coefficient.py +++ b/backend/app/services/forecasting/macro_coefficient.py @@ -96,10 +96,12 @@ _MACRO_COEF_NEUTRAL: float = 1.0 # режима (зеркалит дух лагов §9.6, где полугодовой лаг ловит ипотечный эффект). _TREND_WINDOW_MONTHS: int = 6 -# ── Named-константы: веса sub-factors (СУММА backed-весов = 0.45) ────────────── +# ── Named-константы: веса sub-factors (СУММА backed-весов = 0.53) ────────────── # Веса — экспертная оценка вклада каждого канала в макрорежим спроса (НЕ фит). # Заданы в ИСХОДНОМ (полном) наборе из 8 каналов; renorm делит на сумму ДОСТУПНЫХ. -# Backed-каналы (rate/mortgage_rate/issuance/overdue) несут основную массу: ставка и +# Backed-каналы (rate/mortgage_rate/issuance/overdue/inflation) несут основную массу: +# 0.18+0.12+0.10+0.05+0.08 = 0.53. Прежде здесь стояло 0.45 — цифра до #946, где +# inflation стал backed-каналом с весом 0.08; сумму тогда не обновили (#2464). Ставка и # стоимость/доступность ипотеки — доминирующий драйвер первичного спроса в РФ. # Degraded-каналы (gov/income/confidence) имеют НЕнулевые веса в схеме (резерв # под будущие ряды), но СЕЙЧАС всегда None → в renorm не попадают. diff --git a/backend/app/services/forecasting/macro_series.py b/backend/app/services/forecasting/macro_series.py index 2143e5cc..c4f07f27 100644 --- a/backend/app/services/forecasting/macro_series.py +++ b/backend/app/services/forecasting/macro_series.py @@ -301,16 +301,19 @@ def get_monthly_macro( ЛЮБЫХ данных всё равно присутствует (все поля None для него — кроме carry key_rate). Graceful: при сбое БД или пустой таблице key_rate сетка месяцев всё равно - возвращается, но с None-полями (НЕ crash). Пустой список [] — только если - сама сетка пуста (months_back < 0). + возвращается, но с None-полями (НЕ crash). + + Пустой список [] недостижим: months_back клампится через max(0, ...), поэтому + даже при отрицательном вводе сетка содержит текущий месяц. Прежняя редакция + обещала [] «при months_back < 0» — это описывало поведение, которого нет (#2464). Args: db: SQLAlchemy sync Session. months_back: глубина ряда в месяцах (по умолчанию _DEFAULT_MONTHS_BACK). Returns: - Список MonthlyMacro по возрастанию month (по непрерывной сетке); - [] только при пустой сетке (months_back < 0). + Список MonthlyMacro по возрастанию month (по непрерывной сетке). + Пустым не бывает: см. про клампинг выше. """ # month-bucketing в локальной tz сервера (single-region, как и весь codebase) today = date.today() 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/forecasting/sales_series.py b/backend/app/services/forecasting/sales_series.py index c0c2630b..f48aa633 100644 --- a/backend/app/services/forecasting/sales_series.py +++ b/backend/app/services/forecasting/sales_series.py @@ -479,8 +479,12 @@ def build_sales_series( bias на старых месяцах — каведат в module docstring). Graceful: при сбое БД / пустых данных возвращается ряд по сетке с units=0, - area/price=None, confidence='low' (НЕ crash). Пустой ряд (months=[]) — только - если сетка пуста (months_back < 0). + area/price=None, confidence='low' (НЕ crash). + + Пустой ряд (months=[]) недостижим: months_back клампится через max(0, ...), + поэтому даже при отрицательном вводе сетка содержит текущий месяц. Прежняя + редакция обещала пустой ряд «при months_back < 0» — это описывало поведение, + которого нет (#2464). Args: db: SQLAlchemy sync Session. diff --git a/backend/app/services/forecasting/special_indices.py b/backend/app/services/forecasting/special_indices.py index 58142935..0035b1e2 100644 --- a/backend/app/services/forecasting/special_indices.py +++ b/backend/app/services/forecasting/special_indices.py @@ -588,8 +588,13 @@ def _timing_overlap( ) -> float | None: """Ось тайминга: временна́я близость окон запуска. PURE. - exp(−|Δмесяцев| / half_life): одновременный выход → 1.0, расхождение в half_life мес - → 0.5, дальше затухает. Чем ближе наши запуски, тем сильнее пересекаются окна продаж + 0.5 ** (|Δмесяцев| / half_life): одновременный выход → 1.0, расхождение в half_life + мес → ровно 0.5, дальше затухает. + + Формула в докстринге раньше была записана как exp(−Δ/half_life) — она даёт при + Δ=half_life не 0.5, а exp(−1) ≈ 0.368, то есть противоречила соседнему же + утверждению «→ 0.5». Верен КОД (строка ниже несёт то же пояснение); расходился + докстринг (#2464 кластер H). Чем ближе наши запуски, тем сильнее пересекаются окна продаж = выше каннибализация. Любая дата None → None (ось НЕДОСТУПНА — НЕ фабрикуем). PURE. """ if candidate_month is None or own_month is None: diff --git a/backend/app/services/generative/catalog.py b/backend/app/services/generative/catalog.py index 2ed0c21c..45f0fb93 100644 --- a/backend/app/services/generative/catalog.py +++ b/backend/app/services/generative/catalog.py @@ -123,7 +123,7 @@ def get_house_type(section_type: str) -> HouseType: return _BY_KEY[section_type] except KeyError as exc: raise KeyError( - f"unknown house type {section_type!r}; " f"available: {', '.join(sorted(_BY_KEY))}" + f"unknown house type {section_type!r}; available: {', '.join(sorted(_BY_KEY))}" ) from exc diff --git a/backend/app/services/generative/exporters/pdf.py b/backend/app/services/generative/exporters/pdf.py index 7ec34e11..8703dee5 100644 --- a/backend/app/services/generative/exporters/pdf.py +++ b/backend/app/services/generative/exporters/pdf.py @@ -163,6 +163,29 @@ def _financial_table(variants: Sequence[ConceptVariant]) -> str: ) +def _sales_phrase(financial: FinancialModel) -> str: + """Фраза о сроке распродажи для методической сноски. PURE. + + #2464: срок был зашит числом «30 мес» — при том, что ставка дисконта в той же + строке берётся из расчёта. 30 — это ФОЛБЭК (`financial._SALES_DURATION_MONTHS`), + применяемый только когда рыночная скорость абсорбции не передана. Иначе окно + считается как площадь/скорость и клампится в [6, 120] мес, то есть сноска обещала + читателю не тот срок, по которому посчитан NPV. + + Оба нужных поля уже есть в схеме: `sales_duration_months` (реализованное окно) и + `schedule_is_default` (честный флаг «норматив, а не рынок»). Отчёт Site Finder флаг + уже читает — full_report_html.py:1335 и full_report_docx.py:855; игнорировал его + только этот экспортёр. + + getattr с дефолтом — тот же оборонительный приём, что у соседних полей: старый + сериализованный вариант без новых ключей не должен ронять экспорт. + """ + months = getattr(financial, "sales_duration_months", None) + if getattr(financial, "schedule_is_default", True) or months is None: + return "распродажа 30 мес (нормативный темп)" + return f"распродажа {months:.0f} мес (по рыночной абсорбции)" + + def _build_html(variants: Sequence[ConceptVariant]) -> str: if not variants: return ( @@ -172,6 +195,7 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str: f"

{_DASH} нет вариантов для отображения

" ) disc_pct = f"{variants[0].financial.discount_rate_used * 100:.0f}%" + sales_phrase = _sales_phrase(variants[0].financial) return ( f"" f"

{html.escape(_TITLE)}

" @@ -179,7 +203,7 @@ def _build_html(variants: Sequence[ConceptVariant]) -> str: f"{_teap_table(variants)}" f"{_financial_table(variants)}" "

NPV / IRR / PBP рассчитаны помесячным DCF по ТИПОВОМУ графику фаз " - f"(ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт {disc_pct} годовых). " + f"(ПИР 6 мес → СМР по типу застройки → {sales_phrase}, дисконт {disc_pct} годовых). " "График фаз и темп продаж — типовые допущения, НЕ график конкретного проекта; " "точность метрик зависит от реального графика. Где IRR помечен «оценочный» — поток " "вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. " diff --git a/backend/app/services/generative/geometry.py b/backend/app/services/generative/geometry.py index 930ce619..c4052623 100644 --- a/backend/app/services/generative/geometry.py +++ b/backend/app/services/generative/geometry.py @@ -316,8 +316,7 @@ def parse_parcel( raise ParcelGeometryError("buildable area degenerated after setback") if buildable.area < MIN_BUILDABLE_AREA_SQM: raise ParcelGeometryError( - f"buildable area {buildable.area:.1f} sqm below minimum " - f"{MIN_BUILDABLE_AREA_SQM} sqm" + f"buildable area {buildable.area:.1f} sqm below minimum {MIN_BUILDABLE_AREA_SQM} sqm" ) effective_step = _coarsen_step_for_budget(buildable, grid_step_m) diff --git a/backend/app/services/generative/placement.py b/backend/app/services/generative/placement.py index 0c4d2ce1..81f1612c 100644 --- a/backend/app/services/generative/placement.py +++ b/backend/app/services/generative/placement.py @@ -295,13 +295,17 @@ def place_program( ) placed_for_item += 1 if placed_for_item < item.count: + # Печатаем ФАКТИЧЕСКИ использованные размеры fp_w/fp_d, а не каталожные + # house.footprint_* (#2464): если элемент программы переопределил габарит, + # прежнее сообщение называло размер, которым никто не пытался ставить, — + # диагностика уводила от причины «участок мал». logger.warning( "program: type=%s placed %d of %d sections (%.0fx%.0f m) — участок мал", item.section_type, placed_for_item, item.count, - house.footprint_w_m, - house.footprint_d_m, + fp_w, + fp_d, ) result = PlacedProgram( diff --git a/backend/app/services/job_settings.py b/backend/app/services/job_settings.py index 4d12d32d..23532bfd 100644 --- a/backend/app/services/job_settings.py +++ b/backend/app/services/job_settings.py @@ -124,22 +124,30 @@ def _fallback(job_type: str) -> dict[str, Any]: def get_all(db) -> list[dict[str, Any]]: """Вернуть все строки job_settings. При ошибке БД — fallback на _DEFAULTS.""" try: - rows = ( - db.execute( - text( - """ - SELECT job_type, enabled, queue_name, cron_schedule, rate_ms, - max_retries, max_concurrency, extra_config, - updated_at, updated_by, description - FROM job_settings - ORDER BY job_type - """ + with db.begin_nested(): + rows = ( + db.execute( + text( + """ + SELECT job_type, enabled, queue_name, cron_schedule, rate_ms, + max_retries, max_concurrency, extra_config, + updated_at, updated_by, description + FROM job_settings + ORDER BY job_type + """ + ) ) + .mappings() + .all() ) - .mappings() - .all() - ) except Exception as e: + # #2464 cluster A: сессия ЧУЖАЯ — её отдаёт вызывающий (admin-ручка, + # beat_schedule, get_setting_value из cadastre_fetch/nspd_geo). Ошибка + # db.execute на Postgres оставляет транзакцию в aborted-состоянии, и все + # последующие запросы этой же сессии падают с «current transaction is + # aborted». Голый db.rollback() здесь НЕЛЬЗЯ: он снёс бы незакоммиченную + # работу вызывающего. Поэтому SAVEPOINT вокруг самого execute (см. + # developer_attribution.py:152, тот же кластер) — откатывается только он. logger.warning("get_all job_settings: БД недоступна — fallback. %s", e) return [_fallback(jt) for jt in _DEFAULTS] @@ -153,23 +161,25 @@ def get_all(db) -> list[dict[str, Any]]: def get_one(job_type: str, db) -> dict[str, Any]: """Вернуть одну строку по job_type. При отсутствии — fallback с warning.""" try: - row = ( - db.execute( - text( - """ - SELECT job_type, enabled, queue_name, cron_schedule, rate_ms, - max_retries, max_concurrency, extra_config, - updated_at, updated_by, description - FROM job_settings - WHERE job_type = :jt - """ - ), - {"jt": job_type}, + with db.begin_nested(): + row = ( + db.execute( + text( + """ + SELECT job_type, enabled, queue_name, cron_schedule, rate_ms, + max_retries, max_concurrency, extra_config, + updated_at, updated_by, description + FROM job_settings + WHERE job_type = :jt + """ + ), + {"jt": job_type}, + ) + .mappings() + .first() ) - .mappings() - .first() - ) except Exception as e: + # См. get_all выше: SAVEPOINT, а не rollback — сессия принадлежит вызывающему. logger.warning("get_one job_settings '%s': БД недоступна — fallback. %s", job_type, e) return _fallback(job_type) diff --git a/backend/app/services/llm/client.py b/backend/app/services/llm/client.py index 772db0b3..c27fdec8 100644 --- a/backend/app/services/llm/client.py +++ b/backend/app/services/llm/client.py @@ -231,9 +231,7 @@ def _call_with_retries( # #1209: cap И серверное Retry-After (раньше min(...,30) применялся # только к exp.backoff). _MAX_BACKOFF_S — единый потолок для обеих # веток, защищает anyio-threadpool от blocking на часы. - raw_wait = float( - e.retry_after if e.retry_after is not None else 2**attempt - ) + raw_wait = float(e.retry_after if e.retry_after is not None else 2**attempt) wait = min(raw_wait, _MAX_BACKOFF_S) logger.warning( "llm: HTTP %s (attempt %d/%d), backing off %.1fs (raw=%.1fs)", diff --git a/backend/app/services/objective_etl.py b/backend/app/services/objective_etl.py index 55efced9..d242d8f6 100644 --- a/backend/app/services/objective_etl.py +++ b/backend/app/services/objective_etl.py @@ -463,7 +463,15 @@ def get_sqlite_info(sqlite_path: str | Path) -> dict[str, Any]: } if not p.exists(): return info - st = p.stat() + # stat() под защитой (#2464): между exists() и stat() файл может исчезнуть — + # его переписывает выгрузка Объектива. Раньше try/except покрывал только + # sqlite3.connect ниже, и OSError отсюда улетал наружу, превращая + # диагностическую функцию в источник отказа. Отдаём то, что успели узнать. + try: + st = p.stat() + except OSError as e: + info["stat_error"] = f"{type(e).__name__}: {e}" + return info info["size_bytes"] = st.st_size info["modified_at"] = st.st_mtime # epoch seconds try: diff --git a/backend/app/services/scrapers/domrf_catalog.py b/backend/app/services/scrapers/domrf_catalog.py index 0896d3fc..28525496 100644 --- a/backend/app/services/scrapers/domrf_catalog.py +++ b/backend/app/services/scrapers/domrf_catalog.py @@ -77,8 +77,16 @@ STATUS_RESERVED = "reserved" # Паттерны для извлечения статуса (issue #1609). # Все морфоварианты: продан/продана/продано, забронирован[аоы]?, реализован[аоы]?. +# +# Группа `neg` (#2464): без неё отрицательные формы давали ОБРАТНЫЙ статус — +# «нереализована» содержит «реализована» и классифицировалась как sold, +# «не продана» → sold, «не забронирована» → reserved, «не в продаже» → free. +# `\s*` покрывает и слитную приставку («нереализована»), и раздельное «не +# продана»; `\b` перед «не» не даёт зацепиться за хвост другого слова («в цене +# продажи» — «не» внутри «цене» не на границе слова). _STATUS_KW_RE = re.compile( - r"(в\s*продаже|свободн[аоы]?|free" + r"(?P\bне\s*)?" + r"(?Pв\s*продаже|свободн[аоы]?|free" r"|продан[аоы]?|реализован[аоы]?|sold" r"|забронирован[аоы]?|бронь|reserved)", re.IGNORECASE | re.UNICODE, @@ -153,6 +161,26 @@ def _classify_status_kw(matched_text: str) -> str | None: return None +def _status_in_text(text_: str) -> str | None: + """Первый НЕотрицаемый статус-токен в тексте, иначе None. + + Отрицание не переворачивается в противоположный статус, а гасит токен: + «не забронирована» не означает ни sold, ни free, а «не продана» — + вывод, а не факт со страницы. Лучше отсутствие статуса, чем неверный. + + Перебираем ВСЕ вхождения, а не только первое: блок «Квартира не продана. + Статус: в продаже» на первом совпадении дал бы None и был бы пропущен + целиком, хотя настоящий статус в нём есть. + """ + for m in _STATUS_KW_RE.finditer(text_): + if m.group("neg"): + continue + classified = _classify_status_kw(m.group("kw")) + if classified: + return classified + return None + + # ── HTML fetching ───────────────────────────────────────────────────────────── @@ -372,7 +400,11 @@ def _extract_plan_from_next_data(html: str) -> str | None: page_props = blob.get("props", {}).get("pageProps") root: Any = page_props if isinstance(page_props, dict) else blob - # BFS по вложенному dict/list; ключ+значение проверяем на plan-hint. + # DFS по вложенному dict/list (stack.pop() — LIFO); ключ+значение проверяем на + # plan-hint. Раньше здесь стояло «BFS» — неверно, и это не косметика: функция + # возвращает ПЕРВОЕ найденное совпадение, а при упоре в cap (20 000 узлов) обход + # успевает посмотреть разные подмножества дерева. То есть порядок влияет и на то, + # какой план найдётся, и на то, найдётся ли (#2464 кластер H). stack: list[Any] = [root] seen = 0 while stack and seen < 20_000: # cap: защита от патологически глубокого JSON @@ -538,11 +570,9 @@ def parse_catalog_flat(html: str) -> dict[str, Any]: status_from_badge: str | None = None for cls, block_text in blocks: if _STATUS_BADGE_CLS_RE.search(cls): - m = _STATUS_KW_RE.search(block_text) - if m: - status_from_badge = _classify_status_kw(m.group(1)) - if status_from_badge: - break + status_from_badge = _status_in_text(block_text) + if status_from_badge: + break if status_from_badge: result["status"] = status_from_badge @@ -551,9 +581,7 @@ def parse_catalog_flat(html: str) -> dict[str, Any]: status_label_value = _find_text_near(blocks, r"^статус$") status_from_label: str | None = None if status_label_value: - m2 = _STATUS_KW_RE.search(status_label_value) - if m2: - status_from_label = _classify_status_kw(m2.group(1)) + status_from_label = _status_in_text(status_label_value) if status_from_label: result["status"] = status_from_label @@ -564,10 +592,9 @@ def parse_catalog_flat(html: str) -> dict[str, Any]: free_candidate: bool = False sold_reserved_found: str | None = None for _cls, block_text in blocks: - m3 = _STATUS_KW_RE.search(block_text) - if not m3: + classified = _status_in_text(block_text) + if not classified: continue - classified = _classify_status_kw(m3.group(1)) if classified in (STATUS_SOLD, STATUS_RESERVED): sold_reserved_found = classified break # точнее nav-текстов, дальше не ищем diff --git a/backend/app/services/scrapers/domrf_catalog_object.py b/backend/app/services/scrapers/domrf_catalog_object.py index ad7ef832..41021090 100644 --- a/backend/app/services/scrapers/domrf_catalog_object.py +++ b/backend/app/services/scrapers/domrf_catalog_object.py @@ -29,6 +29,10 @@ from app.services.scrapers.stealth import BASE_URL, BrowserSession, WafBlockedEr logger = logging.getLogger(__name__) +# Сколько WAF-блоков ПОДРЯД прерывают батч (#2464). Одиночный блок бывает +# переходным (сессия перегреет cookies и восстановится), три подряд — стена. +_WAF_BREAKER_THRESHOLD = 3 + # URL шаблон страницы объекта в каталоге DOM.РФ. # Человекочитаемый вид: https://наш.дом.рф/сервисы/каталог-новостроек/объект/{obj_id} CATALOG_OBJECT_PATH = "/сервисы/каталог-новостроек/объект/{obj_id}" @@ -340,22 +344,35 @@ async def scrape_catalog_object( session: BrowserSession, obj_id: int, snapshot_date: date, -) -> bool: +) -> bool | None: """Scrape одного объекта: fetch HTML → extract __NEXT_DATA__ → parse → UPDATE. Использует SAVEPOINT (begin_nested) для изоляции per-row ошибок. Логирует результат через logger.info. Returns: - True если UPDATE затронул строку, False при ошибке или 0 rows. + True — UPDATE затронул строку; + None — ПРОПУСК: строки (obj_id, snapshot_date) в БД нет. Это не сбой: + obj_id берутся из БД, но снимок мог смениться между выборкой и + UPDATE'ом. Раньше этот случай возвращал False и попадал в + счётчик failed вместе с настоящими ошибками, а объявленный в + контракте счётчик skipped всегда оставался нулём (#2464); + False — сбой: не скачалось, не распарсилось, упал UPDATE. + + Третье состояние сделано через None, а не через новый Literal, намеренно: + прежние True/False сохраняют смысл, поэтому вызывающие и тесты, полагающиеся + на них, не меняются. """ logger.info("catalog_object scrape start obj_id=%d snapshot_date=%s", obj_id, snapshot_date) try: html = await fetch_catalog_object_html(session, obj_id) - except WafBlockedError as exc: - logger.warning("catalog_object WAF blocked obj_id=%d: %s", obj_id, exc) - return False + except WafBlockedError: + # #2464: WAF-блок — не «этот объект не дошёл», а закрытая дверь. Раньше он + # гасился здесь и возвращался как обычная неудача, поэтому батч-цикл шёл + # дальше и слал ЖИВОЙ запрос на каждый оставшийся obj_id в уже забаненную + # сессию. Пробрасываем: решение принимает предохранитель в батче. + raise except Exception as exc: logger.warning("catalog_object fetch failed obj_id=%d: %s", obj_id, exc) return False @@ -394,7 +411,7 @@ async def scrape_catalog_object( obj_id, snapshot_date, ) - return False + return None logger.info( "catalog_object scraped obj_id=%d fields=%d rows_updated=%d", @@ -457,17 +474,67 @@ async def scrape_catalog_objects( # Idempotent — один вызов покрывает весь batch через этот BrowserSession. await session.warm_up() + # #2464: предохранитель на серию WAF-блоков. Замер 20.08: в очереди 13200 + # объектов из 13801, а DOM.РФ отдаёт страницу «Доступ заблокирован [403]» + # с капчей (#2443). Без предохранителя один прогон «Загрузить все» выдал бы + # 13200 живых запросов в забаненную сессию — ровно то, что углубляет бан + # (анти-бан-комментарий к BrowserSession выше про тот же path family). + # Порог не единица: одиночный блок бывает переходным, три подряд — стена. + consecutive_waf = 0 + for obj_id in obj_ids: stats["processed"] += 1 - ok = await scrape_catalog_object(db, session, obj_id, snapshot_date) + try: + ok = await scrape_catalog_object(db, session, obj_id, snapshot_date) + except WafBlockedError as exc: + consecutive_waf += 1 + stats["failed"] += 1 + logger.warning( + "catalog_object WAF blocked obj_id=%d (подряд %d/%d): %s", + obj_id, + consecutive_waf, + _WAF_BREAKER_THRESHOLD, + exc, + ) + if consecutive_waf >= _WAF_BREAKER_THRESHOLD: + stats["aborted_on_waf"] = 1 + logger.error( + "scrape_catalog_objects: %d WAF-блока подряд — прерываю батч," + " обработано %d из %d", + consecutive_waf, + stats["processed"], + len(obj_ids), + ) + break + continue + consecutive_waf = 0 + if ok is None: + # Строки в БД нет — это пропуск, а не сбой (см. контракт выше). + stats["skipped"] += 1 + continue if ok: stats["succeeded"] += 1 + # Фиксируем сразу, а не одним commit'ом в конце (#2464). Раньше весь + # батч жил в одной незакоммиченной транзакции, и любой отказ ПОСЛЕ + # цикла — исключение в BrowserSession.__aexit__, снятие Celery-таски, + # перезапуск контейнера — обнулял все уже успешные UPDATE'ы. + # Это не теория: беговой режим здесь force=True («Загрузить все»), + # то есть SQL без LIMIT. На 20.08.2026 в очереди 13200 объектов из + # 13801 — многочасовой прогон, где отказ в конце стоил бы всего. + # SAVEPOINT внутри scrape_catalog_object к этому моменту уже снят, + # поэтому commit здесь корректен. + try: + db.commit() + except Exception: + db.rollback() + raise else: stats["failed"] += 1 - # Commit outer transaction: SAVEPOINT (`begin_nested`) releases внутри loop, - # но outer tx остаётся autobegin'd — без commit() все UPDATE'ы откатятся - # при db.close() в Celery task. + # Финальный commit. Успешные строки зафиксированы по ходу цикла (см. выше), но + # этот вызов остаётся: он закрывает транзакцию, которую могли autobegin'ить + # неудачные итерации (их SAVEPOINT откатился, а внешняя транзакция открыта), + # и сохраняет прежнее поведение для вызывающих, которые на него полагались. try: db.commit() except Exception: diff --git a/backend/app/services/scrapers/ekb_ppt_tep_parser.py b/backend/app/services/scrapers/ekb_ppt_tep_parser.py index 5756a793..2bceb204 100644 --- a/backend/app/services/scrapers/ekb_ppt_tep_parser.py +++ b/backend/app/services/scrapers/ekb_ppt_tep_parser.py @@ -70,10 +70,32 @@ _TABLE_CAPTION_RE = { def _page_contains_table(text: str, table_no: int) -> bool: - """Возвращает True если текст страницы содержит заголовок таблицы N. + """Возвращает True если на странице встречается строка «Таблица N». - Требуем явный caption «Таблица N» — hint-только режим (оглавление, перекрёстные ссылки) - даёт false positive и подавляется. Если caption присутствует — достаточно. + Это поиск подстроки, без разбора контекста. Строка ОГЛАВЛЕНИЯ + «Таблица 11 Баланс территории ....... 34» от настоящей подписи не отличается — + падеж тот же, именительный, — и вызывающий код ставит found_start=True прямо на + странице содержания, начиная выдирать таблицы оттуда. + + Прежняя редакция этой докстроки утверждала обратное — будто ложные срабатывания + оглавления и перекрёстных ссылок здесь отсеиваются. Такого кода никогда не было + (#2464). Обещание защиты, которой нет, опаснее её отсутствия: читающий не станет + её добавлять. (Старая формулировка тут намеренно пересказана, а не процитирована: + гейт test_2464_tep_docstring_truth ищет обещание по тексту и не отличил бы цитату + от утверждения.) + + Замер 20.08.2026 уточняет и границы проблемы: перекрёстные ссылки в косвенных + падежах регекс НЕ ловит — он требует именительное «Таблица N», поэтому + «приведены в таблице 12», «см. Таблицу 12», «табл. 12» дают False. Опасно + ровно оглавление, а не любое упоминание. + + Почему подавление не реализовано здесь и сейчас: таблица `ekb_ppt_tep` на проде + пуста (0 строк) — URL в `_SEED_DOCS` заглушка, живых PDF нет, эвристику отсева + оглавления не на чем откалибровать. Правило, придуманное без образцов, ловит + ровно те случаи, которые придумали вместе с ним. Условие для реализации — хотя + бы один настоящий документ (#1136); целиться следует в признаки оглавления + (точки-выноски, номер страницы в конце строки, несколько подписей на одной + странице), а не в падежи. """ cap = _TABLE_CAPTION_RE[table_no] return bool(cap.search(text)) @@ -303,9 +325,7 @@ def _parse_table13(raw_rows: list[list[str | None]]) -> list[dict[str, Any]]: seen: set[tuple[str, str, str, str]] = set() result: list[dict[str, Any]] = [] for rec in rows_clean: - area_key = ( - f"{rec['area_ha_num']:.4f}" if rec["area_ha_num"] is not None else rec["area_ha"] - ) + area_key = f"{rec['area_ha_num']:.4f}" if rec["area_ha_num"] is not None else rec["area_ha"] key = (rec["phase"], rec["composition"], rec["zone"], area_key) if key in seen: continue diff --git a/backend/app/services/scrapers/gisogd66.py b/backend/app/services/scrapers/gisogd66.py index 459a7362..03fe8e14 100644 --- a/backend/app/services/scrapers/gisogd66.py +++ b/backend/app/services/scrapers/gisogd66.py @@ -60,8 +60,11 @@ _SECTION = "razdel13" # гоняем его после того, как регион уже покрыл основную массу (см. circuit breaker ниже). SCHEMAS: tuple[str, ...] = ("agate_sverdregion", "agate_ekbgo") -# Группа источника (doc group key) → наш doc_group-код в БД (CHECK IN ('RS','RV')). -GROUP_CODE: dict[str, str] = {"DocRS": "RS", "DocRV": "RV"} +# Группа источника (doc group key) → наш doc_group-код в БД (CHECK IN ('RS','RV','IZ')). +# +# DocIZ («Изменение в Разрешение на строительство») есть на портале с самого начала, +# но в этом словаре его не было — 548 документов не грузились вовсе (#2986). +GROUP_CODE: dict[str, str] = {"DocRS": "RS", "DocRV": "RV", "DocIZ": "IZ"} _HTTP_TIMEOUT = 20.0 _MAX_CONNECTIONS = 5 @@ -259,11 +262,18 @@ def _existing_reg_dates(db: Session, schema: str) -> dict[str, date | None]: def _upsert_permit(db: Session, rec: dict[str, Any]) -> str: - """UPSERT одной записи в gisogd_permits по (doc_group, doc_num). Per-row SAVEPOINT. + """UPSERT одной записи в gisogd_permits по source_key. Per-row SAVEPOINT. - Конфликт-резолв: при коллизии бизнес-ключа обновляем ТОЛЬКО если у новой записи - date_reg НЕ старше сохранённой (EXCLUDED.date_reg >= existing) — предпочитаем - более позднюю регистрацию (или запись без даты не затирает датированную). + Ключ — идентификатор документа на портале (#2986). Раньше ключом был + (doc_group, doc_num), но docNum у ГИСОГД НЕ уникален: разрешение и изменения к + нему носят один номер, и UPSERT оставлял только одно из них. Замер 20.08.2026: + так схлопывалось 2243 документа, а межсхемных дублей — ради которых ключ и + вводился — всего 7, и у них key ОБЩИЙ, то есть новый ключ их тоже склеивает. + + Конфликт-резолв: при коллизии обновляем ТОЛЬКО если у новой записи date_reg НЕ + старше сохранённой (EXCLUDED.date_reg >= existing) — предпочитаем более позднюю + регистрацию (или запись без даты не затирает датированную). Осмыслен ровно для + тех 7 межсхемных совпадений. Returns: 'inserted' | 'updated' | 'skipped_unchanged'. """ @@ -285,13 +295,14 @@ def _upsert_permit(db: Session, rec: dict[str, Any]) -> str: ST_GeomFromGeoJSON(CAST(:geojson AS text)), 4326)) END, NOW(), NOW() ) - ON CONFLICT (doc_group, doc_num) DO UPDATE - SET doc_name = EXCLUDED.doc_name, + ON CONFLICT (source_key) DO UPDATE + SET doc_group = EXCLUDED.doc_group, + doc_num = EXCLUDED.doc_num, + doc_name = EXCLUDED.doc_name, date_doc = EXCLUDED.date_doc, date_reg = EXCLUDED.date_reg, approved_organization = EXCLUDED.approved_organization, source_schema = EXCLUDED.source_schema, - source_key = EXCLUDED.source_key, cad_nums = EXCLUDED.cad_nums, geom = EXCLUDED.geom, updated_at = NOW() diff --git a/backend/app/services/scrapers/izyatie_ocr.py b/backend/app/services/scrapers/izyatie_ocr.py index 2205e52e..5e7b5e67 100644 --- a/backend/app/services/scrapers/izyatie_ocr.py +++ b/backend/app/services/scrapers/izyatie_ocr.py @@ -37,6 +37,33 @@ _RE_ACT_NUMBER = re.compile( ) _RE_ACT_DATE = re.compile(r"от\s+(\d{2})\.(\d{2})\.(\d{4})") +# Слова, по которым дата опознаётся как дата САМОГО акта-основания, а не +# ссылки на другой документ (#2464). «Сообщение о планируемом изъятии» +# открывается списком оснований, где первой строкой почти всегда стоит +# «Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об +# утверждении Генерального плана города»» — Генплан, а не акт об изъятии. +# Брать первую дату подряд означало ставить всем участкам дату Генплана. +# +# Действующее основание изъятия — постановление (Администрации города об +# утверждении проекта планировки/межевания либо Правительства области), +# поэтому дата принимается, только если слово стоит в предшествующем контексте. +# Ссылки-помехи в этих документах — «Решение … Думы» и «Приказ Министерства», +# и ни одна из них слова «постановление» не содержит. +# +# Пробовал требовать ещё и «администраци»: на пяти прод-документах результат +# тот же 5 из 5, но правило ломает законный случай «Постановление № 509-ПП» +# (областное постановление без слова «администрация») — он уже закреплён +# тестом test_act_date_extracted_from_text. Взято более широкое условие: +# на живых данных оно не хуже, а лишнего не отсекает. +_ACT_CONTEXT_WORDS = ("постановлени",) +# Ширина окна контекста. OCR перемешивает колонки таблицы, и между словами +# «Постановление Администрации города» и «от DD.MM.YYYY» вклинивается текст +# соседней колонки («…Администрации города документами) Екатеринбурга от +# 19.04.2019…»), поэтому окно шире самой фразы (~50 символов). Откалибровано +# на пяти прод-документах land_reservation: 80, 120 и 160 дают одинаковые +# 5 из 5, выбрана середина. +_ACT_CONTEXT_WINDOW = 120 + # Паттерн цели: «в целях…», «для …», «под строительство …» — best-effort. _RE_PURPOSE = re.compile( r"(?:для|в целях?|под)\s+([^.;,\n]{10,120})", @@ -207,7 +234,9 @@ def extract_izyatie_records( # Реквизиты акта из заголовка или текста. act_number = _extract_act_number(doc_title) or _extract_act_number(normalized) - act_date = _extract_act_date(doc_title) or _extract_act_date(normalized) + act_date = _extract_act_date(doc_title) or _extract_act_date( + normalized, require_act_context=True + ) purpose = _extract_purpose(doc_title) or _extract_purpose(normalized) # Поиск кад-номеров. @@ -268,19 +297,35 @@ def _extract_act_number(text: str) -> str | None: return re.sub(r"\s+", "", m.group(1)) -def _extract_act_date(text: str) -> str | None: - """Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE.""" - m = _RE_ACT_DATE.search(text) - if not m: - return None - day, month, year = m.group(1), m.group(2), m.group(3) - try: - # Валидируем диапазоны. - d, mo, y = int(day), int(month), int(year) +def _act_context_matches(text: str, pos: int) -> bool: + """Стоит ли перед датой упоминание постановления — акта-основания.""" + ctx = text[max(0, pos - _ACT_CONTEXT_WINDOW) : pos].lower() + return all(word in ctx for word in _ACT_CONTEXT_WORDS) + + +def _extract_act_date(text: str, *, require_act_context: bool = False) -> str | None: + """Извлекает дату акта «от DD.MM.YYYY» → строка «YYYY-MM-DD» для SQL DATE. + + require_act_context=True — брать только дату, перед которой стоит + упоминание постановления (#2464). Нужен для ТЕЛА + документа, где первой датой почти всегда идёт ссылка на Генплан-2004. + Для заголовка не нужен: там ссылок на посторонние акты нет. + + Если подходящей даты нет, возвращается None. Это сознательно: отсутствие + даты честнее, чем дата чужого документа — по ней нельзя ни отфильтровать + актуальные изъятия, ни сверить срок. + """ + for m in _RE_ACT_DATE.finditer(text): + if require_act_context and not _act_context_matches(text, m.start()): + continue + day, month, year = m.group(1), m.group(2), m.group(3) + try: + # Валидируем диапазоны. + d, mo, y = int(day), int(month), int(year) + except ValueError: + continue if 1 <= d <= 31 and 1 <= mo <= 12 and 2000 <= y <= 2100: return f"{y:04d}-{mo:02d}-{d:02d}" - except ValueError: - pass return None diff --git a/backend/app/services/scrapers/nspd_client.py b/backend/app/services/scrapers/nspd_client.py index 9742dd43..a1a06101 100644 --- a/backend/app/services/scrapers/nspd_client.py +++ b/backend/app/services/scrapers/nspd_client.py @@ -260,7 +260,19 @@ class QuarterDump: - core: parcels + buildings + territorial_zones + red_lines + engineering - zouit: 5 ЗОУИТ layers (G3) - risks: 11 risk-zone layers (TIER 3) - Default = только core, чтобы не сжигать rate-limit на 17 запросов. + + По умолчанию берутся core + zouit: `search_by_quarter(include_zouit=True, + include_risks=False)`. Прежняя редакция утверждала обратное — будто по умолчанию + берётся один core ради экономии полутора десятков запросов (#2464). Неверно + вдвойне. Во-первых, `include_zouit` по умолчанию True, и 5 ЗОУИТ-слоёв входят в + дефолтный вызов; докстрока самого метода это говорит правильно. Во-вторых, порядок + величины не тот: territorial_zones/red_lines/engineering и все ЗОУИТ идут через + grid-walk при grid_n=7, то есть по 49 запросов КАЖДЫЙ — дефолтный дамп это сотни + запросов. Экономит rate-limit только `include_risks=False`. + + (Старая формулировка здесь пересказана, а не процитирована: гейт + test_2464_docstring_matches_code ищет обещание по тексту и не отличил бы + цитату от утверждения.) """ quarter_cad: str @@ -559,7 +571,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 +623,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 +669,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 +744,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 +755,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, @@ -811,15 +888,24 @@ class NSPDClient: Шаги: 1. `search_by_cad(quarter_cad, thematic_id=2)` — получить полигон квартала 2. Compute bbox в EPSG:3857 из quarter geometry (или None если NSPD пуст) - 3. Для каждого core layer → `get_features_in_bbox(layer_id, bbox)` - 4. Если include_zouit — то же для 5 ЗОУИТ layers - 5. Если include_risks — то же для 11 risk layers + 3. Core layers: parcels/buildings — legacy `get_features_in_bbox` + (1 запрос); territorial_zones/red_lines/engineering_structures — + `get_features_in_bbox_grid` при grid_n=7, то есть 49 запросов КАЖДЫЙ + (см. _GRID_WALK_LAYERS и docstring get_features_in_bbox_grid) + 4. Если include_zouit — 5 ЗОУИТ layers, все через grid-walk + 5. Если include_risks — 11 risk layers, все через grid-walk Стоимость HTTP: - - core only: 1 (search) + 5 (core layers) = 6 запросов - - +zouit: +5 = 11 запросов - - +risks: +11 = 22 запроса - При rate_ms=600 один dump = ~3.6с (core) / ~6.6с (+zouit) / ~13с (всё). + - core only: 1 (search) + 2*1 (legacy) + 3*49 (grid) = 150 запросов + - +zouit: +5*49 = 395 запросов + - +risks: +11*49 = 934 запроса + При rate_ms=600 один dump = ~90с (core) / ~237с (+zouit) / ~560с (всё). + + Прежняя редакция обещала 6/11/22 запроса и ~3.6с/~6.6с/~13с — цифры для + мира, где все слои идут legacy-путём. Занижение в 25-42 раза, и это не + безобидно: по такой оценке слои включают не задумываясь, а объём запросов + здесь — прямой фактор WAF-риска (#2464; ср. #2956, где НСПД сейчас отдаёт + 403 на IP VPS). Args: quarter_cad: 3-сегментный cad-номер квартала, e.g. '66:41:0204016'. @@ -840,9 +926,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/scrapers/nspd_denorm.py b/backend/app/services/scrapers/nspd_denorm.py index 58082cb7..071be5c6 100644 --- a/backend/app/services/scrapers/nspd_denorm.py +++ b/backend/app/services/scrapers/nspd_denorm.py @@ -324,7 +324,11 @@ def denorm_dump( одной строки не откатывает весь batch. Args: - db: SQLAlchemy Session. Caller отвечает за commit/close после вызова. + db: SQLAlchemy Session. Функция САМА делает commit в конце (см. ниже); + на вызывающем остаётся только close. Прежняя редакция обещала + обратное — «caller отвечает за commit/close», — и вызывающий, + понадеявшийся обернуть это в свою транзакцию, получил бы уже + зафиксированные строки (#2464). quarter_cad: 3-сегментный кадастровый квартал. features: плоский list из features_json JSONB (уже декодированный Python list). diff --git a/backend/app/services/scrapers/page_reservation_parser.py b/backend/app/services/scrapers/page_reservation_parser.py index 9797acc5..96ab63e7 100644 --- a/backend/app/services/scrapers/page_reservation_parser.py +++ b/backend/app/services/scrapers/page_reservation_parser.py @@ -127,10 +127,24 @@ def _parse_act_date(text: str) -> date | None: def _detect_kind(text: str, default_kind: str) -> str: - """Определяет тип операции: 'резервирование' | 'изъятие' | default_kind.""" - if _RE_REZERV.search(text): + """Определяет тип операции: 'резервирование' | 'изъятие' | default_kind. + + Побеждает то слово, что встретилось РАНЬШЕ, а не то, что стоит выше в + коде (#2464). Прежний безусловный приоритет «резервир» переклассифицировал + ВЕСЬ документ — все участки разом, — если постановление об изъятии хоть + раз ссылалось на резервирование (типовая формулировка «ранее + зарезервированных земель», ссылка на утративший силу акт). Тема документа + стоит в заголовке, поэтому позиция первого упоминания — сигнал сильнее + порядка проверок, и он симметричен: заголовок «О резервировании» так же + выигрывает у «изъятия» в теле. + """ + m_rez = _RE_REZERV.search(text) + m_izy = _RE_IZYAT.search(text) + if m_rez and m_izy: + return "резервирование" if m_rez.start() < m_izy.start() else "изъятие" + if m_rez: return "резервирование" - if _RE_IZYAT.search(text): + if m_izy: return "изъятие" return default_kind diff --git a/backend/app/services/scrapers/stealth.py b/backend/app/services/scrapers/stealth.py index 6abf2b13..e8fa949f 100644 --- a/backend/app/services/scrapers/stealth.py +++ b/backend/app/services/scrapers/stealth.py @@ -288,17 +288,47 @@ class BrowserSession: Uses Playwright APIRequest which goes through the browser context — same cookies, same TLS fingerprint as the page itself. + + Ретраи с backoff на транзиентных ответах (429 / 5xx / 0) — так же, как в + get_json выше (#2464). Раньше их не было: один 429 под тем же WAF, под + которым get_json переживает до пяти попыток, ронял загрузку картинки + насовсем, и вызывающий (download_plan_image, download_photos) записывал + это в лог как «не удалось» — неотличимо от «файла нет». + + Непереходные коды (403, 404) поднимаются сразу, без ожидания: повтор их + не изменит, а под WAF лишний стук вредит. """ if self._context is None: raise RuntimeError("BrowserSession not bootstrapped") - async with self._sem: - await jitter_sleep(200, 500) # Lighter throttle for static assets. - self._request_count += 1 - resp = await self._context.request.get( - url, - headers={"Authorization": self.auth} if self.auth else {}, - ) - if resp.status != 200: + last_err: Exception | None = None + for attempt in range(5): + async with self._sem: + await jitter_sleep(200, 500) # Lighter throttle for static assets. + self._request_count += 1 + try: + resp = await self._context.request.get( + url, + headers={"Authorization": self.auth} if self.auth else {}, + ) + except Exception as e: + last_err = e + logger.warning("download_binary err attempt=%d url=%s: %r", attempt, url, e) + await asyncio.sleep(2**attempt) + continue + status = resp.status + if status == 200: + return await resp.body() body = await resp.text() - raise RuntimeError(f"binary http {resp.status}: {body[:200]}") - return await resp.body() + # Разбор статуса — ВНЕ семафора: sleep не должен держать слот. + if status in (429,) or status >= 500: + last_err = RuntimeError(f"binary transient status={status}") + logger.warning( + "download_binary transient status=%d attempt=%d url=%s, backing off", + status, + attempt, + url, + ) + await asyncio.sleep(2**attempt) + continue + raise RuntimeError(f"binary http {status}: {body[:200]}") + raise RuntimeError(f"binary max retries exhausted: {last_err!r}") diff --git a/backend/app/services/site_finder/best_layouts.py b/backend/app/services/site_finder/best_layouts.py index 9c6b73e9..ea7045e5 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( + -- #2867: БЕЗ COALESCE(...,0), как у avg_price_per_m2_rub ниже (#2464-B). + -- Сделок за окно нет → делитель NULL → средней площади нет, и это NULL, + -- а не «0 м²». Замер прода 13.08: 635 пустых пар (проект × комнатность) + -- из 2083, у 80 проектов пусты ВСЕ комнатности — ноль выдумывался ровно + -- там, где окрестность беднее замапленными проектами. Схема объявлена + -- float | None, фронт и PDF печатают «—». + ( SUM(a.area_weighted_sum) - / NULLIF(SUM(a.deals_window), 0), - 0 + / NULLIF(SUM(a.deals_window), 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, @@ -398,8 +413,18 @@ _SUPPLY_ONLY_LOTS_SQL = text(""" WHEN rooms_int IN (1, 2, 3) THEN rooms_int::text ELSE '4+' END AS rb, + -- #2464: площадь неизвестна — это ОТДЕЛЬНАЯ корзина, а не «<25». + -- Прежде NULL сваливался к настоящим студиям: на проде 20.08.2026 + -- в продаже 11 557 квартир без area_pd против 7 013 реально + -- меньших 25 м², то есть корзина «<25» на 62 % состояла из + -- неизвестного и завышала долю мелких лотов в структуре остатков. + -- Зеркала у этого отображения не было: layout_signature.area_bin + -- принимает float и NULL-ветки не имеет вовсе. + -- Исключать такие лоты нельзя — они реально в продаже, и без них + -- предложение занижалось бы на 6.4 %. Медиана площади у этой + -- корзины выйдет NULL (PERCENTILE_CONT игнорирует NULL) — честно. CASE - WHEN area_pd IS NULL THEN '<25' + WHEN area_pd IS NULL THEN 'н/д' WHEN area_pd < 25 THEN '<25' WHEN area_pd < 40 THEN '25-40' WHEN area_pd < 60 THEN '40-60' @@ -1259,7 +1284,8 @@ def get_best_layouts( for r in vel_rows: room_bucket = str(r["room_bucket"]) deals_window = float(r["deals_window"]) if r["deals_window"] is not None else 0.0 - avg_area = float(r["avg_area_m2"]) if r["avg_area_m2"] is not None else 0.0 + # #2867: None остаётся None — «сделок нет» ≠ «0 м²». + avg_area = float(r["avg_area_m2"]) if r["avg_area_m2"] is not None else None price_rub = ( float(r["avg_price_per_m2_rub"]) if r["avg_price_per_m2_rub"] is not None else None ) @@ -1342,7 +1368,10 @@ def get_best_layouts( total_sold_in_window=int(row["sum_deals"]), velocity_per_month=row["velocity_per_month"], avg_price_per_m2_rub=row["avg_price_per_m2_rub"], - avg_area_m2=round(row["avg_area_m2"], 1), + # #2867: None (сделок нет) остаётся None — round(None) ронял бы сборку. + avg_area_m2=( + round(row["avg_area_m2"], 1) if row["avg_area_m2"] is not None else None + ), supply_units_in_radius=row["supply_units_in_radius"], sold_pct_of_supply=row["sold_pct_of_supply"], is_oversold=row["is_oversold"], @@ -1474,6 +1503,7 @@ def _build_recommendation( # Группировка по room_bucket (строки уже могут быть per-bucket из MV GROUP BY) rb_deals: dict[str, float] = {} rb_area_weighted: dict[str, float] = {} + rb_area_total_deals: dict[str, float] = {} # #2867: знаменатель только по рядам с площадью rb_price_weighted: dict[str, float] = {} rb_price_total_deals: dict[str, float] = {} all_competitor_ids: set[int] = set() @@ -1482,7 +1512,12 @@ def _build_recommendation( rb = row["room_bucket"] sd = float(row["sum_deals"]) rb_deals[rb] = rb_deals.get(rb, 0.0) + sd - rb_area_weighted[rb] = rb_area_weighted.get(rb, 0.0) + row["avg_area_m2"] * sd + # #2867: ряд без средней площади (сделок за окно нет) не участвует ни в числителе, + # ни в знаменателе взвешенной площади — как у цены ниже. Иначе его sd считался бы + # сделками «с площадью 0» и занижал среднее. + if row["avg_area_m2"] is not None: + rb_area_weighted[rb] = rb_area_weighted.get(rb, 0.0) + row["avg_area_m2"] * sd + rb_area_total_deals[rb] = rb_area_total_deals.get(rb, 0.0) + sd all_competitor_ids.update(row["competitor_obj_ids"]) if row["avg_price_per_m2_rub"] is not None: rb_price_weighted[rb] = rb_price_weighted.get(rb, 0.0) + ( @@ -1496,8 +1531,12 @@ def _build_recommendation( mix: list[LayoutTzMixRow] = [] for rb, pct in sorted(pct_map.items(), key=lambda x: -x[1]): + # #2867: делим на сделки рядов С площадью, а не на все — иначе ряды без площади + # занижали бы среднее; нет ни одного ряда с площадью → None, не 0. avg_area = ( - round(rb_area_weighted[rb] / rb_deals[rb], 1) if rb_deals.get(rb, 0) > 0 else None + round(rb_area_weighted[rb] / rb_area_total_deals[rb], 1) + if rb_area_total_deals.get(rb, 0) > 0 + else None ) abs_units: int | None = None if target_total_flats is not None: diff --git a/backend/app/services/site_finder/cadastre_fetch.py b/backend/app/services/site_finder/cadastre_fetch.py index 14e76479..76dee34b 100644 --- a/backend/app/services/site_finder/cadastre_fetch.py +++ b/backend/app/services/site_finder/cadastre_fetch.py @@ -98,10 +98,17 @@ def cad_exists_in_db(db: Session, cad_num: str) -> bool: def find_active_on_demand_job(db: Session, cad_num: str) -> int | None: """Найти существующий on-demand job (queued/running/paused) для этого cad. - Возвращает job_id или None. Если в БД есть FAILED on-demand за последние 60 - секунд — тоже None (чтобы повторно пробовать). Если есть DONE job, но cad - отсутствует в БД (на NSPD не нашлось) — тоже None, но caller через - `fetch_status` отличит этот случай как `not_in_nspd`. + Возвращает job_id или None. + + Неуспешные джобы не возвращаются НИКОГДА, независимо от давности: запрос отбирает + только `status IN ('queued','running','paused')`, и других статусов в нём нет. + Прежняя редакция обещала минутное окно давности для неуспешных (#2464) — такой + логики здесь никогда не было, временного фильтра в SQL нет вовсе. Обещание было + вдвойне вредным: оно подразумевало, что неуспешная джоба ПОСТАРШЕ вернётся как + активная (не вернётся), и отправляло отлаживающего искать окно, которого нет. + + Если есть DONE job, но cad отсутствует в БД (на NSPD не нашлось) — тоже None, + но caller через `fetch_status` отличит этот случай как `not_in_nspd`. NB (issue #1356): 'paused' тоже считается active. Job переходит в 'paused' при WAF (consecutive>=8) или Celery soft_time_limit (6h) — нетронутые targets diff --git a/backend/app/services/site_finder/connection_capacity_lookup.py b/backend/app/services/site_finder/connection_capacity_lookup.py index 2c43e552..40fba5d8 100644 --- a/backend/app/services/site_finder/connection_capacity_lookup.py +++ b/backend/app/services/site_finder/connection_capacity_lookup.py @@ -113,32 +113,39 @@ def get_connection_capacity( def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dict]: """Центры питания в радиусе от центроида участка (ST_DWithin geography).""" - rows = ( - db.execute( - text(""" - SELECT sc_name, dzo_name, voltage_class, load_index, - installed_capacity_mva, current_load_mva, reserve_mva, - reserve_unit, district, reserve_asof, - ST_Distance( + # #2464: SAVEPOINT вокруг запроса. Сессия ОБЩАЯ с вызывающим — при сбое без + # savepoint транзакция остаётся aborted, а исключение всплывает до + # _get_connection_capacity (full_report_pdf.py), где оно ГЛОТАЕТСЯ ради + # деградации §3. Дальше падает уже следующий запрос — рыночная цена в + # _generate_concept_result — и концепция молча уходит в class_norm-фолбэк. + # Четыре соседних _query_* в этом же файле savepoint имеют. + with db.begin_nested(): + rows = ( + db.execute( + text(""" + SELECT sc_name, dzo_name, voltage_class, load_index, + installed_capacity_mva, current_load_mva, reserve_mva, + reserve_unit, district, reserve_asof, + ST_Distance( + geom::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography + ) AS distance_m, + ST_Y(geom) AS lat, + ST_X(geom) AS lon + FROM power_supply_centers + WHERE geom IS NOT NULL + AND ST_DWithin( geom::geography, - ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography - ) AS distance_m, - ST_Y(geom) AS lat, - ST_X(geom) AS lon - FROM power_supply_centers - WHERE geom IS NOT NULL - AND ST_DWithin( - geom::geography, - ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, - CAST(:radius_m AS float) - ) - ORDER BY distance_m ASC - """), - {"wkt": parcel_wkt, "radius_m": radius_m}, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + CAST(:radius_m AS float) + ) + ORDER BY distance_m ASC + """), + {"wkt": parcel_wkt, "radius_m": radius_m}, + ) + .mappings() + .all() ) - .mappings() - .all() - ) points: list[dict] = [] for r in rows: @@ -209,24 +216,31 @@ def _query_water_latest(db: Session) -> list[dict]: опубликовать водоотведение на квартал позже водоснабжения — глобальный MAX молча выкинул бы отстающий вид целиком. """ - rows = ( - db.execute( - text(""" - SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day, - w.note, w.period - FROM water_supply_reserves w - WHERE w.period IS NOT NULL - AND w.period = ( - SELECT MAX(w2.period) FROM water_supply_reserves w2 - WHERE w2.period IS NOT NULL - AND w2.system_kind = w.system_kind - ) - ORDER BY w.system_kind, w.system_name - """) + # #2464: SAVEPOINT вокруг запроса. Сессия ОБЩАЯ с вызывающим — при сбое без + # savepoint транзакция остаётся aborted, а исключение всплывает до + # _get_connection_capacity (full_report_pdf.py), где оно ГЛОТАЕТСЯ ради + # деградации §3. Дальше падает уже следующий запрос — рыночная цена в + # _generate_concept_result — и концепция молча уходит в class_norm-фолбэк. + # Четыре соседних _query_* в этом же файле savepoint имеют. + with db.begin_nested(): + rows = ( + db.execute( + text(""" + SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day, + w.note, w.period + FROM water_supply_reserves w + WHERE w.period IS NOT NULL + AND w.period = ( + SELECT MAX(w2.period) FROM water_supply_reserves w2 + WHERE w2.period IS NOT NULL + AND w2.system_kind = w.system_kind + ) + ORDER BY w.system_kind, w.system_name + """) + ) + .mappings() + .all() ) - .mappings() - .all() - ) return [ { "system_kind": r["system_kind"], 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/eias_heat_loader.py b/backend/app/services/site_finder/eias_heat_loader.py index 5429871b..02febce0 100644 --- a/backend/app/services/site_finder/eias_heat_loader.py +++ b/backend/app/services/site_finder/eias_heat_loader.py @@ -156,9 +156,7 @@ def _quarter_from_text(row_text: str) -> tuple[int, int] | None: def build_card_url(org_id: int) -> str: """URL карточки организации в реестре ФАС (грид публикаций форм 14 / 4_6).""" - return ( - f"{_CARD_URL}?reg={_REG}&orgId={org_id}" f"&sphere=WARM&razdel=QUARTER&form={_CARD_FORMS}" - ) + return f"{_CARD_URL}?reg={_REG}&orgId={org_id}&sphere=WARM&razdel=QUARTER&form={_CARD_FORMS}" def build_template_url(guid: str, pub_id: str) -> str: @@ -516,6 +514,23 @@ def load_heat_reserves(db: Session | None = None) -> dict[str, dict]: except Exception as e: logger.exception("load_heat_reserves: org %s failed: %s", org, e) out[org] = {"error": str(e)} + if owns_session: + # Сбойная организация не должна тащить свои частичные записи + # в общий коммит следующих. + db.rollback() + continue + if owns_session: + # #2464: фиксируем ПОСЛЕ КАЖДОЙ организации, а не одним коммитом в + # конце. Раньше одна транзакция оставалась открытой на весь батч — + # восемь организаций, у каждой несколько HTTP-раундов к медленному + # внешнему реестру с таймаутом _HTTP_TIMEOUT=60с. Открытая транзакция + # столько времени держит соединение и тормозит vacuum, а падение в + # конце обнуляло бы всё уже собранное. + # + # ТОЛЬКО на своей сессии: при db, переданном вызывающим, транзакцией + # распоряжается он — коммитить её здесь значило бы зафиксировать + # чужую работу (то же правило, что для плоского rollback). + db.commit() db.commit() except Exception as e: db.rollback() diff --git a/backend/app/services/site_finder/functional_zone_lookup.py b/backend/app/services/site_finder/functional_zone_lookup.py index d06601ad..dc5b6d27 100644 --- a/backend/app/services/site_finder/functional_zone_lookup.py +++ b/backend/app/services/site_finder/functional_zone_lookup.py @@ -46,7 +46,13 @@ def parcel_functional_zones(db: Session, parcel_wkt: str | None) -> list[dict[st if not parcel_wkt: return [] try: - rows = db.execute(_ZONE_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() + # #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel + # (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре), + # и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии — + # падают все следующие, включая запись прогона. Образец рядом: + # ppt_tep_lookup.py делает ровно так же. + with db.begin_nested(): + rows = db.execute(_ZONE_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() except (OperationalError, ProgrammingError) as exc: # Таблица ещё не задеплоена или ошибка БД — graceful degrade (analyze не падает). logger.warning( diff --git a/backend/app/services/site_finder/gate_verdict.py b/backend/app/services/site_finder/gate_verdict.py index 00c833d5..bff1dd4c 100644 --- a/backend/app/services/site_finder/gate_verdict.py +++ b/backend/app/services/site_finder/gate_verdict.py @@ -327,8 +327,15 @@ def compute_gate_verdict( sub17_overlaps: list[dict[str, Any]] = [] # cad_zouit path: сетевое обременение + keyword-blocker (утилитарная охранная зона). cad_utility_overlaps: list[dict[str, Any]] = [] - # Подпись вида сети для cad-detail (первый встреченный network_kind). - cad_utility_label: str | None = None + # Подписи видов сетей для cad-detail. Копим ВСЕ различённые виды, а не первый + # (#2464): покрытие ниже агрегируется по всем overlap'ам bucket'а, поэтому подпись + # от одного вида приписывала бы конкретную причину чужой площади. На проде + # 20.08.2026 это не редкость: 316 пересечений охранных зон РАЗНЫХ видов, 155 + # зон вовлечено (чаще всего «тепловых сетей» × «инженерных коммуникаций»). + # Порядок в списке — по появлению, но наружу отдаём отсортированным: порядок + # overlap'ов задан `ORDER BY reg_numb_border, id`, а он к покрытию отношения + # не имеет, и делать подпись зависящей от него незачем. + cad_utility_labels: list[str] = [] for overlap in nspd_zouit_overlaps or []: src = overlap.get("source", "nspd-quarter-dump") if src == "cad_zouit": @@ -350,9 +357,7 @@ def compute_gate_verdict( warnings.append( Warning( code="ZOUIT_CAD_SZZ", - detail=( - f"СЗЗ ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}" - ), + detail=(f"СЗЗ ({overlap.get('type_zone', '')}): {overlap.get('name', '')}"), ) ) elif net_kind is not None or any( @@ -360,17 +365,16 @@ def compute_gate_verdict( ): # Утилитарная охранная зона — копим для area-gate (см. ниже). cad_utility_overlaps.append(overlap) - if cad_utility_label is None and net_kind is not None: - cad_utility_label = overlap.get("network_kind_label") or network_kind_label( - net_kind - ) + if net_kind is not None: + _lbl = overlap.get("network_kind_label") or network_kind_label(net_kind) + if _lbl and _lbl not in cad_utility_labels: + cad_utility_labels.append(_lbl) else: warnings.append( Warning( code="ZOUIT_CAD_OTHER", detail=( - f"ЗОУИТ cad ({overlap.get('type_zone', '')}): " - f"{overlap.get('name', '')}" + f"ЗОУИТ cad ({overlap.get('type_zone', '')}): {overlap.get('name', '')}" ), ) ) @@ -438,14 +442,20 @@ def compute_gate_verdict( pct = _coverage_pct_label(coverage) # Код-различение сетевого обременения (#1070) vs общего охранного keyword-blocker: # blocker'у с network_kind отдаём ZOUIT_NETWORK_OBREMENENIE, иначе ZOUIT_CAD_BLOCKER. - is_network = cad_utility_label is not None + is_network = bool(cad_utility_labels) + # Все различённые виды через запятую: покрытие — их объединение, и подпись + # обязана это отражать. Множественное число, когда видов больше одного. + cad_utility_label = ", ".join(sorted(cad_utility_labels)) + _обременение = ( + "Сетевые обременения" if len(cad_utility_labels) > 1 else "Сетевое обременение" + ) if coverage > threshold: if is_network: blockers.append( Blocker( code="ZOUIT_NETWORK_OBREMENENIE", detail=( - f"Сетевое обременение ({cad_utility_label}) покрывает {pct}% " + f"{_обременение} ({cad_utility_label}) покрывает {pct}% " f"участка — застройка МКД невозможна" ), ) diff --git a/backend/app/services/site_finder/granddoc_lookup.py b/backend/app/services/site_finder/granddoc_lookup.py index 6b71b9c0..aa2ec85b 100644 --- a/backend/app/services/site_finder/granddoc_lookup.py +++ b/backend/app/services/site_finder/granddoc_lookup.py @@ -88,7 +88,13 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]] if not parcel_wkt: return [] try: - rows = db.execute(_GRANDDOC_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() + # #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel + # (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре), + # и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии — + # падают все следующие, включая запись прогона. Образец рядом: + # ppt_tep_lookup.py делает ровно так же. + with db.begin_nested(): + rows = db.execute(_GRANDDOC_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() except (OperationalError, ProgrammingError) as exc: logger.warning("parcel_granddoc: planning_projects недоступна, skip: %s", exc) return [] diff --git a/backend/app/services/site_finder/ird_overlay_lookup.py b/backend/app/services/site_finder/ird_overlay_lookup.py index 3d7681e8..d5db0dda 100644 --- a/backend/app/services/site_finder/ird_overlay_lookup.py +++ b/backend/app/services/site_finder/ird_overlay_lookup.py @@ -58,7 +58,13 @@ def parcel_ird_overlaps(db: Session, parcel_wkt: str | None) -> dict[str, Any]: if not parcel_wkt: return {"ird_overlaps": [], "ird_by_kind": {}} try: - rows = db.execute(_IRD_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() + # #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel + # (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре), + # и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии — + # падают все следующие, включая запись прогона. Образец рядом: + # ppt_tep_lookup.py делает ровно так же. + with db.begin_nested(): + rows = db.execute(_IRD_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() except (OperationalError, ProgrammingError, DataError) as exc: # OperationalError — БД недоступна / connection drop. # ProgrammingError — ird_overlays ещё не задеплоена (UndefinedTable, pre-migration). diff --git a/backend/app/services/site_finder/market_metrics.py b/backend/app/services/site_finder/market_metrics.py index d6347644..8ead9006 100644 --- a/backend/app/services/site_finder/market_metrics.py +++ b/backend/app/services/site_finder/market_metrics.py @@ -943,8 +943,7 @@ def compute_offer_price_trend( delta_pct = (last_median - first_median) / first_median * 100.0 logger.info( - "offer_price_trend: lat=%.5f lon=%.5f radius=%d snapshots=%d " - "lots_latest=%s delta_pct=%s", + "offer_price_trend: lat=%.5f lon=%.5f radius=%d snapshots=%d lots_latest=%s delta_pct=%s", center_lat, center_lon, radius_m, diff --git a/backend/app/services/site_finder/noise_loader.py b/backend/app/services/site_finder/noise_loader.py index 32755c83..627997a4 100644 --- a/backend/app/services/site_finder/noise_loader.py +++ b/backend/app/services/site_finder/noise_loader.py @@ -91,10 +91,10 @@ def _build_overpass_query(key: str, value: str, el_type: str) -> str: bbox = f"({south},{west},{north},{east})" if el_type == "nwr": # node + way: точки подключения бывают и точкой, и площадкой - return f"[out:json][timeout:30];" f'nwr["{key}"="{value}"]{bbox};' f"out geom;" + return f'[out:json][timeout:30];nwr["{key}"="{value}"]{bbox};out geom;' if el_type == "way": - return f"[out:json][timeout:30];" f'way["{key}"="{value}"]{bbox};' f"out geom;" - return f"[out:json][timeout:30];" f'node["{key}"="{value}"]{bbox};' f"out body;" + return f'[out:json][timeout:30];way["{key}"="{value}"]{bbox};out geom;' + return f'[out:json][timeout:30];node["{key}"="{value}"]{bbox};out body;' async def fetch_overpass_noise() -> list[dict]: diff --git a/backend/app/services/site_finder/ors_client.py b/backend/app/services/site_finder/ors_client.py index 48594de0..1fc7d88e 100644 --- a/backend/app/services/site_finder/ors_client.py +++ b/backend/app/services/site_finder/ors_client.py @@ -24,9 +24,7 @@ logger = logging.getLogger(__name__) _ORS_MATRIX_BASE = "https://api.openrouteservice.org/v2/matrix" # Профили ORS-routing. foot-walking — пеший радиус (метро/школа/магазин), # driving-car — авто (для будущих авто-категорий). -VALID_PROFILES: frozenset[str] = frozenset( - {"foot-walking", "cycling-regular", "driving-car"} -) +VALID_PROFILES: frozenset[str] = frozenset({"foot-walking", "cycling-regular", "driving-car"}) # ORS /matrix ограничивает foot-walking ~2000 пар (sources×destinations) на free tier. MAX_MATRIX_DESTINATIONS = 1000 _DEFAULT_TIMEOUT_S = 12.0 @@ -128,7 +126,16 @@ def matrix_durations_min( if sec is None: out.append(None) # ORS не построил маршрут до этой точки else: - out.append(float(sec) / 60.0) + # #2464: тот же довод, что у проверки длины ниже — нечисловое значение + # дало бы ValueError/TypeError мимо OrsUnavailableError, а вызывающий + # (poi_score.py:348) ловит только её. Принцип в этом файле уже + # сформулирован, просто не применён к самой конверсии. + try: + out.append(float(sec) / 60.0) + except (TypeError, ValueError) as exc: + raise OrsUnavailableError( + f"ORS matrix: нечисловая длительность {sec!r} в durations" + ) from exc # Длина durations должна совпадать с числом destinations — иначе zip(strict=True) # у вызывающего бросит ValueError (не OrsUnavailableError) → 500. Закрываем как ORS-сбой. if len(out) != len(dests): diff --git a/backend/app/services/site_finder/osrm_client_local.py b/backend/app/services/site_finder/osrm_client_local.py index e50bb9b2..d8a1ca1e 100644 --- a/backend/app/services/site_finder/osrm_client_local.py +++ b/backend/app/services/site_finder/osrm_client_local.py @@ -96,8 +96,7 @@ def get_road_distances_m( # Координаты `;`-joined: origin первой (→ sources=0), затем POI по порядку. coords = ";".join( - [_fmt_coord(origin_lon, origin_lat)] - + [_fmt_coord(lon, lat) for lon, lat in destinations] + [_fmt_coord(origin_lon, origin_lat)] + [_fmt_coord(lon, lat) for lon, lat in destinations] ) base = (base_url if base_url is not None else settings.osrm_local_url).rstrip("/") url = f"{base}/table/v1/{profile}/{coords}" @@ -134,7 +133,17 @@ def get_road_distances_m( if d is None: out.append(None) # OSRM не построил маршрут до этой точки else: - out.append(float(d)) + # #2464: конверсия обязана падать в ДОМЕННУЮ ошибку. Весь файл переводит + # любую кривизну ответа в OsrmLocalUnavailableError (строки выше), потому + # что вызывающий (parcels.py:399) ловит ТОЛЬКО её и уходит на прямолинейный + # fallback. Голый float() на нечисловом значении поднял бы ValueError или + # TypeError — они пролетят мимо и дадут 500 на /analyze вместо деградации. + try: + out.append(float(d)) + except (TypeError, ValueError) as exc: + raise OsrmLocalUnavailableError( + f"OSRM table: нечисловое расстояние {d!r} в distances" + ) from exc # Длина должна совпадать с числом destinations — иначе zip у вызывающего # рассинхронит POI↔distance. Закрываем как OSRM-сбой → straight-line fallback. diff --git a/backend/app/services/site_finder/parcel_financial.py b/backend/app/services/site_finder/parcel_financial.py index 0e207ca4..e3e8a2e5 100644 --- a/backend/app/services/site_finder/parcel_financial.py +++ b/backend/app/services/site_finder/parcel_financial.py @@ -118,6 +118,37 @@ def select_calibrated_price( return None, "class_norm" +# Потолки правдоподобия для параметров градрегламента (#2464). Не нормативные +# лимиты, а сито против порчи разбора: самый плотный жилой КСИТ в РФ — единицы, +# самый высокий жилой дом — меньше 100 этажей. Прод 20.08.2026: far 1..4, +# floors 0..5 — запас больше чем семикратный. +_MAX_PLAUSIBLE_FAR: float = 30.0 +_MAX_PLAUSIBLE_FLOORS: int = 100 + + +def _sane(value: float | None, low: float, high: float, name: str) -> float | None: + """Вернуть значение, если оно в (low, high]; иначе None с предупреждением. + + Ноль и отрицательные отбрасываются молча — их отсутствие уже штатно + обрабатывается ветвями ниже, и логировать «в регламенте нет параметра» + незачем. Предупреждаем только о значениях ВНЕ верхней границы: это признак + порчи разбора, и его нужно видеть. + """ + if value is None or value <= low: + return None + if value > high: + logger.warning( + "synthesize_teap: %s=%s вне правдоподобного диапазона (%s, %s] — " + "параметр отброшен, расчёт продолжен по остальным", + name, + value, + low, + high, + ) + return None + return float(value) + + def synthesize_teap_from_buildability( *, area_m2: float | None, @@ -147,6 +178,30 @@ def synthesize_teap_from_buildability( if area_m2 is None or area_m2 <= 0: return None + # ── Санитария входа (#2464) ──────────────────────────────────────────────── + # Параметры приходят из ПЗЗ-регламента (zone_regulation_cache) — это внешние + # разобранные данные, а не наши вычисления. Проверялось только `> 0`, поэтому + # процент застройки 150 дал бы пятно БОЛЬШЕ участка, а дальше — жилую площадь, + # число квартир и выручку, физически невозможные, но поданные как обычные + # цифры финмодели. + # + # Невозможное значение ОТБРАСЫВАЕМ, а не роняем расчёт: если рядом есть КСИТ, + # GFA считается по нему и остаётся верной. Лучше отсутствие параметра, чем + # неверный — тот же принцип, что в остальных правках этого эпика. + # + # Границы взяты с запасом к реальным данным прода 20.08.2026 + # (33 строки zone_regulation_cache: pct 0..100, far 1..4, floors 0..5), + # чтобы ловить порчу разбора, а не отсекать законные значения. + max_building_pct = _sane(max_building_pct, 0.0, 100.0, "max_building_pct") + max_far = _sane(max_far, 0.0, _MAX_PLAUSIBLE_FAR, "max_far") + max_floors_f = _sane( + float(max_floors) if max_floors is not None else None, + 0.0, + float(_MAX_PLAUSIBLE_FLOORS), + "max_floors", + ) + max_floors = int(max_floors_f) if max_floors_f is not None else None + # ── GFA: предпочитаем КСИТ/max_far; иначе % застройки × этажность ─────────── gfa: float if max_far is not None and max_far > 0: @@ -169,8 +224,25 @@ def synthesize_teap_from_buildability( # Нет %застройки → пятно ≈ GFA / этажность. built_area = gfa / max_floors else: + # Нет ни процента, ни этажности — пятно оцениваем как GFA (неявно «один этаж»). built_area = gfa + # Пятно застройки физически не может превышать участок (#2464). Это не эвристика, + # а геометрия. Ветка выше (`built_area = gfa`) нарушала её при КСИТ > 1: участок + # 10 000 м² с far=2 давал пятно 20 000 м². Ограничение вводится ЗДЕСЬ, а не в + # каждой ветке, чтобы инвариант держался и для будущих способов оценки пятна. + if built_area > area_m2: + logger.warning( + "synthesize_teap: пятно %.0f м² превысило участок %.0f м² — ограничено " + "площадью участка (far=%s, pct=%s, floors=%s)", + built_area, + area_m2, + max_far, + max_building_pct, + max_floors, + ) + built_area = area_m2 + # Нежилое (коммерция/офисы 1-го этажа) вырезаем из GFA до расчёта жилой — точно # как compute_teap: жилая считается по ОСТАВШЕЙСЯ GFA, total (gfa) не меняется. office_share = _OFFICE_SHARE_OF_GFA[housing_class] diff --git a/backend/app/services/site_finder/permits_nearby.py b/backend/app/services/site_finder/permits_nearby.py index fb0cee8c..85d7390f 100644 --- a/backend/app/services/site_finder/permits_nearby.py +++ b/backend/app/services/site_finder/permits_nearby.py @@ -50,7 +50,13 @@ _PERMITS_NEARBY_SQL = text(""" ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography ) AS distance_m FROM gisogd_permits - WHERE geom IS NOT NULL + -- Только РНС/РВЭ: агрегат обещает total_count = rs_count + rv_count, а с + -- #2986 в таблице появилась третья группа 'IZ' (изменения в разрешение). + -- Она попадала бы в total и не попадала ни в один из счётчиков — молчаливое + -- расхождение. Показывать ли изменения отдельной строкой в §6 — вопрос + -- продуктовый (см. #2986); до его решения выборка сужена явно, а не молча. + WHERE doc_group IN ('RS', 'RV') + AND geom IS NOT NULL AND ST_DWithin( geom::geography, ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, diff --git a/backend/app/services/site_finder/planning_lookup.py b/backend/app/services/site_finder/planning_lookup.py index 26141f8d..43edde3f 100644 --- a/backend/app/services/site_finder/planning_lookup.py +++ b/backend/app/services/site_finder/planning_lookup.py @@ -45,7 +45,13 @@ def parcel_planning_overlaps(db: Session, parcel_wkt: str | None) -> list[dict[s if not parcel_wkt: return [] try: - rows = db.execute(_PLANNING_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() + # #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel + # (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре), + # и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии — + # падают все следующие, включая запись прогона. Образец рядом: + # ppt_tep_lookup.py делает ровно так же. + with db.begin_nested(): + rows = db.execute(_PLANNING_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all() except (OperationalError, ProgrammingError) as exc: # Таблица ещё не задеплоена / БД-ошибка — graceful degrade. logger.warning("parcel_planning_overlaps: planning_projects недоступна, skip: %s", exc) diff --git a/backend/app/services/site_finder/pzz_loader.py b/backend/app/services/site_finder/pzz_loader.py index 7d3c3a12..87e25c29 100644 --- a/backend/app/services/site_finder/pzz_loader.py +++ b/backend/app/services/site_finder/pzz_loader.py @@ -11,7 +11,7 @@ from app.core.db import SessionLocal logger = logging.getLogger(__name__) -PKK6_URL = "https://pkk.rosreestr.ru/arcgis/rest/services/PKK6/ZONES/" "MapServer/5/query" +PKK6_URL = "https://pkk.rosreestr.ru/arcgis/rest/services/PKK6/ZONES/MapServer/5/query" # bbox ЕКБ: (xmin, ymin, xmax, ymax) в WGS84 EKB_BBOX = (60.5, 56.7, 60.75, 56.95) diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index bb853221..bc25a927 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -160,6 +160,9 @@ def make_empty_result( "harvest_triggered": harvest_triggered, "total_features": total_features, "harvest_eta_seconds": harvest_eta_seconds, + # Дампа нет — про риск-слои не известно ничего. None, а не 0: + # ноль означал бы «спросили и не нашли». + "risks_count": None, }, } @@ -297,6 +300,20 @@ def get_quarter_dump_data( "harvest_triggered": False, "total_features": total_features, "harvest_eta_seconds": None, + # #2934: сколько объектов риск-слоёв лежит в дампе КВАРТАЛА. Нужен, чтобы + # отличить «слой опрошен, на участке чисто» от «слой не дал ничего вообще». + # Пустой `nspd_risk_zones` сам по себе этих случаев не различает, и фронт + # рисовал по нему зелёное «Риски не обнаружены». + # + # Замер 19.08: risks_count = 0 у ВСЕХ 669 дампов (для сравнения zouit_count > 0 + # у 581), то есть сегодня признак всегда говорит «не подтверждено». Это и есть + # правда: одиннадцать слоёв природного риска ни разу не вернули ни одного + # объекта. + # + # Намеренно НЕ отдаём layers_fetched: `layers_fetched.append(...)` в + # nspd_client.py:956 стоит ДО запроса, поэтому как признак «слой опрошен» он + # лжёт. Пока append не перенесён после успешного ответа, наружу его нельзя. + "risks_count": risks_count, } if parcel_wkt is None: diff --git a/backend/app/services/site_finder/reservation_lookup.py b/backend/app/services/site_finder/reservation_lookup.py index 6771911b..fb22a7fd 100644 --- a/backend/app/services/site_finder/reservation_lookup.py +++ b/backend/app/services/site_finder/reservation_lookup.py @@ -45,7 +45,13 @@ def parcel_reservations(db: Session, cad_num: str | None) -> list[dict[str, obje if not cad_num: return [] try: - rows = db.execute(_LOOKUP_SQL, {"cad_num": cad_num}).mappings().all() + # #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel + # (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре), + # и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии — + # падают все следующие, включая запись прогона. Образец рядом: + # ppt_tep_lookup.py делает ровно так же. + with db.begin_nested(): + rows = db.execute(_LOOKUP_SQL, {"cad_num": cad_num}).mappings().all() except (OperationalError, ProgrammingError) as exc: # Таблица ещё не задеплоена / БД-ошибка — graceful degrade. logger.warning("parcel_reservations: land_reservation недоступна, skip: %s", exc) 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/vodokanal_reserve_loader.py b/backend/app/services/site_finder/vodokanal_reserve_loader.py index 5ca8ea8a..47331162 100644 --- a/backend/app/services/site_finder/vodokanal_reserve_loader.py +++ b/backend/app/services/site_finder/vodokanal_reserve_loader.py @@ -458,11 +458,15 @@ def load_water_reserves_from_docx( system_kind: str, docx_bytes: bytes, source_url: str = "", -) -> dict[str, int]: +) -> dict[str, object]: """Парсит docx-байты → UPSERT ЦСВ/ЦСК в water_supply_reserves. Выделено из load_water_reserves для юнит-теста на синтетическом docx. Читает word/document.xml из zip, forward-fill vMerge, извлечение записей. + + Возвращает счётчики (`records`, `inserted`, `updated`, …) И `period` — строку + вида «III кв. 2025» либо None. Раньше тип был `dict[str, int]`, и ради него + период выбрасывался фильтром на выходе, хотя в лог печатался (#2464). """ with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf: document_xml = zf.read("word/document.xml") @@ -485,9 +489,19 @@ def load_water_reserves_from_docx( logger.exception("load_water_reserves_from_docx: outer tx rolled back: %s", e) raise - result = {"records": len(records), **counts, "period": period} # type: ignore[dict-item] + # `period` возвращаем вместе с остальным (#2464). Раньше стоял фильтр + # `isinstance(v, int)`, который выбрасывал его ВСЕГДА — период это строка или + # None. В лог при этом печатался полный словарь, поэтому по логам казалось, что + # период отдаётся, а вызывающий его не получал никогда. + # + # Фильтр ничего не защищал: соседняя ветка `load_water_reserves` кладёт в тот же + # словарь `{"error": str(...)}`, то есть «только int» контрактом не было, а + # единственный потребитель (задача sync_water_reserves) результат логирует и + # возвращает как есть. Период при этом полезен: он говорит, за какой квартал + # данные, — без него «загружено 42 записи» не отличить от прошлогодних. + result: dict[str, object] = {"records": len(records), **counts, "period": period} logger.info("water_reserves[%s] done: %s", system_kind, result) - return {k: v for k, v in result.items() if isinstance(v, int)} + return result def load_water_reserves(db: Session | None = None) -> dict[str, dict]: diff --git a/backend/app/services/site_finder/weight_profiles.py b/backend/app/services/site_finder/weight_profiles.py index 7639c02d..ddd5048a 100644 --- a/backend/app/services/site_finder/weight_profiles.py +++ b/backend/app/services/site_finder/weight_profiles.py @@ -96,11 +96,20 @@ _SELECT_BY_ID = f""" AND id = :profile_id """ +# ORDER BY здесь не украшение (#2464): без него LIMIT 1 брал произвольную строку, +# и при двух дефолтах у одного пользователя выбор мог молча перескакивать между +# ними от запроса к запросу. Соседние запросы этого файла тай-брейк по id уже +# имеют (см. ORDER BY is_default DESC, id ASC выше) — приводим к ним. +# +# Сам случай «два дефолта» с миграции 190 невозможен: частичный уникальный индекс +# user_weight_profiles_one_default (user_id) WHERE is_default. ORDER BY остаётся +# вторым рубежом — на случай, если индекс когда-нибудь снимут. _SELECT_DEFAULT = f""" SELECT {_SELECT_COLS} FROM user_weight_profiles WHERE user_id = :user_id AND is_default = TRUE + ORDER BY id ASC LIMIT 1 """ diff --git a/backend/app/services/site_finder/zone_regulation.py b/backend/app/services/site_finder/zone_regulation.py index cf5fdb6d..88951ba4 100644 --- a/backend/app/services/site_finder/zone_regulation.py +++ b/backend/app/services/site_finder/zone_regulation.py @@ -299,7 +299,13 @@ def get_cached_zone_regulation( if not zone_index: return None try: - row = db.execute(_SELECT_SQL, {"city": city, "zone_index": zone_index}).mappings().first() + # #2464: SAVEPOINT — как у upsert_zone_regulation двадцатью строками выше в + # ЭТОМ ЖЕ файле. Сессия общая с analyze_parcel; без savepoint'а упавший SELECT + # оставляет транзакцию в aborted-состоянии, и падает всё, что идёт следом. + with db.begin_nested(): + row = ( + db.execute(_SELECT_SQL, {"city": city, "zone_index": zone_index}).mappings().first() + ) except (OperationalError, ProgrammingError) as exc: logger.warning("get_cached_zone_regulation: cache недоступна, skip: %s", exc) return None diff --git a/backend/app/services/weather_cache.py b/backend/app/services/weather_cache.py index 179f2245..00fd366a 100644 --- a/backend/app/services/weather_cache.py +++ b/backend/app/services/weather_cache.py @@ -161,8 +161,15 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None: "avg_max_c": round(sum(t_max) / len(t_max), 1) if t_max else None, "avg_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None, }, - "precipitation_total_mm": round(sum(precip), 1) if precip else 0, - "precipitation_days": sum(1 for p in precip if p and p > 0.5), + # #2464: было `if precip else 0`. Ноль здесь означал бы «осадков не + # ожидается» — утверждение о погоде. Но пустой `precip` значит, что + # open-meteo не отдал ряд осадков вовсе, то есть мы НЕ ЗНАЕМ. Все шесть + # соседних агрегатов в этом же словаре при пустых данных дают None + # (min_c/max_c/avg_*/uv_index_max/max_speed_m_s) — осадки были + # единственным исключением, и именно они рисуются на фронте как + # измеренная величина (ptica-adapt заворачивает их в `real(...)`). + "precipitation_total_mm": round(sum(precip), 1) if precip else None, + "precipitation_days": (sum(1 for p in precip if p and p > 0.5) if precip else None), "uv_index_max": round(max(uv), 1) if uv else None, "wind": { "dominant_direction_deg": ( @@ -251,7 +258,10 @@ def _fetch_seasonal_remote(lat: float, lon: float) -> dict[str, Any] | None: "avg_precip_per_day_mm": ( round(sum(precip) / len(precip), 1) if precip else None ), - "total_precip_mm": round(sum(precip), 0) if precip else 0, + # #2464: та же правка, что у прогноза выше. Особенно наглядно + # здесь: соседняя строка avg_precip_per_day_mm считается из ЭТОГО ЖЕ + # списка и при пустом даёт None, а сумма давала 0. + "total_precip_mm": round(sum(precip), 0) if precip else None, "days_observed": len(vals["t_max"]), } return { 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/lifecycle.py b/backend/app/workers/lifecycle.py index c440c56f..6931b4b2 100644 --- a/backend/app/workers/lifecycle.py +++ b/backend/app/workers/lifecycle.py @@ -89,14 +89,24 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None: db = SessionLocal() ids: list[int] = [] try: + # #2464: берём ЛЮБУЮ строку в 'running', без фильтра по objects_snapshot. + # Докстринг этой функции формулирует инвариант прямо: «by definition, on + # worker_ready ANY 'running' row is a zombie because there is no active + # worker». Фильтр ему противоречил: строка без снапшота не попадала в + # выборку и оставалась 'running' НАВСЕГДА — ровно то состояние, ради + # устранения которого функция и заводилась. + # + # Снапшот всё равно нужен — но не для пометки, а для ВОЗОБНОВЛЕНИЯ: + # resume_kn_run восстанавливает обход «using objects_snapshot». Поэтому + # помечаем зомби всех, а resume ставим только тем, кого есть чем + # возобновить. Остальные получают честную причину вместо тишины. rows = ( db.execute( text( """ - SELECT run_id + SELECT run_id, (objects_snapshot IS NOT NULL) AS resumable FROM kn_scrape_runs WHERE status = 'running' - AND objects_snapshot IS NOT NULL ORDER BY started_at ASC LIMIT 20 """ @@ -106,22 +116,45 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None: .all() ) if rows: - ids = [int(r["run_id"]) for r in rows] - # Помечаем найденные как 'zombie' одним апдейтом — resume создаст новые - # run_id со ссылкой resumed_from_run_id. - db.execute( - text( - """ - UPDATE kn_scrape_runs - SET status = 'zombie', - finished_at = NOW(), - error = COALESCE(error, - 'auto-zombie at worker_ready, resume scheduled') - WHERE run_id = ANY(:ids) - """ - ), - {"ids": ids}, - ) + ids = [int(r["run_id"]) for r in rows if r["resumable"]] + orphan_ids = [int(r["run_id"]) for r in rows if not r["resumable"]] + if ids: + # Помечаем как 'zombie' одним апдейтом — resume создаст новые + # run_id со ссылкой resumed_from_run_id. + db.execute( + text( + """ + UPDATE kn_scrape_runs + SET status = 'zombie', + finished_at = NOW(), + error = COALESCE(error, + 'auto-zombie at worker_ready, resume scheduled') + WHERE run_id = ANY(:ids) + """ + ), + {"ids": ids}, + ) + if orphan_ids: + db.execute( + text( + """ + UPDATE kn_scrape_runs + SET status = 'zombie', + finished_at = NOW(), + error = COALESCE(error, + 'auto-zombie at worker_ready, resume невозможен: ' + 'нет objects_snapshot') + WHERE run_id = ANY(:orphans) + """ + ), + {"orphans": orphan_ids}, + ) + logger.warning( + "worker_ready: %d kn-прогонов без objects_snapshot помечены zombie" + " без resume — возобновлять нечем: %s", + len(orphan_ids), + orphan_ids, + ) db.commit() else: logger.info("worker_ready: нет stale kn runs для resume") @@ -209,6 +242,59 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None: logger.warning("worker_ready: failed to enqueue geo resume job=%s: %s", jid, e) logger.info("worker_ready: resume scan finished (geo_jobs=%d)", len(geo_resume_jobs)) + # objective_scrape_runs: тот же инвариант, что у kn — на worker_ready активных + # воркеров нет, значит любая строка в 'running' осиротела. Подметальщика у этой + # таблицы не было вовсе, и на проде 2026-08-20 висело 6 строк со статусом + # 'running' с 17.05 (94 суток), при 71 'done' и НИ ОДНОГО 'failed' — след + # отравления сессии, из-за которого _finish_run(status='failed') не мог + # записаться (починено #2972). Причина устранена, но жёсткое убийство воркера + # (редеплой, OOM) по-прежнему оставляет 'running' навсегда: у Объектива нет + # ни своего cleanup_zombies, ни snapshot'а для resume. + # + # Resume не делаем — возобновлять нечего (снапшота обхода нет), только честно + # закрываем. finished_at ставим НЕ NOW(), а по последнему признаку жизни: + # прогон, умерший 94 дня назад, не должен читаться как «завершён только что». + # Монитору свежести это безразлично в обе стороны — он считает last_success_at + # и recent_output только по status='done', а last_attempt_at/last_status — по + # started_at (см. _FRESHNESS_SOURCES в admin_scrape.py), так что зомби-строки + # в него не попадают ни одним столбцом. + db = SessionLocal() + try: + rows = ( + db.execute( + text( + """ + UPDATE objective_scrape_runs + SET status = 'zombie', + finished_at = COALESCE(heartbeat_at, started_at), + error = COALESCE(error, + 'auto-zombie at worker_ready: воркер перезапущен ' + 'во время прогона, возобновление невозможно') + WHERE status = 'running' + RETURNING run_id + """ + ) + ) + .mappings() + .all() + ) + db.commit() + if rows: + logger.info( + "worker_ready: objective_scrape_runs — помечено зомби: %s", + [int(r["run_id"]) for r in rows], + ) + else: + logger.info("worker_ready: нет осиротевших objective-прогонов") + except Exception as e: + logger.warning("worker_ready objective zombie sweep failed: %s", e) + try: + db.rollback() + except Exception: + pass + finally: + db.close() + # Sanity check: nspd_quarter_dumps table must exist (migration 88). # Logs critical error but does NOT crash the worker — table may be absent # in dev/staging before migration is applied. diff --git a/backend/app/workers/tasks/cbr_macro_sync.py b/backend/app/workers/tasks/cbr_macro_sync.py index 92c27a5d..0c4be0c7 100644 --- a/backend/app/workers/tasks/cbr_macro_sync.py +++ b/backend/app/workers/tasks/cbr_macro_sync.py @@ -110,13 +110,22 @@ def _upsert_inflation(db: Session, rows: list[tuple[date, Decimal]]) -> int: return upserted +# Ретраев здесь НЕТ намеренно, и параметров, обещающих их, тоже быть не должно +# (#2464). Раньше стояло `bind=True, max_retries=2` — но self не использовался, +# self.retry() не вызывался и autoretry_for задан не был, поэтому конфигурация +# ретраев не имела ни малейшего эффекта: таска падала окончательно с первой ошибки, +# а параметр обещал до двух повторов. Соседи, где ретраи действительно нужны, +# задают их явно: autoretry_for в nspd_sync и scrape_cadastre, self.retry() в +# scrape_kn. +# +# Отсутствие ретраев — это и есть задуманное поведение, оно описано в докстринге +# ниже: «первая возникшая ошибка пробрасывается в конце (surfaces в +# Celery/GlitchTip), не глотается». Ряды тянутся по расписанию, следующий тик +# повторит попытку; молча ретраить внутри тика значило бы прятать отказ источника. @celery_app.task( - bind=True, name="tasks.cbr_macro_sync.cbr_macro_sync", - max_retries=2, ) def cbr_macro_sync( - self: Any, from_date: str | None = None, to_date: str | None = None, ) -> dict[str, Any]: diff --git a/backend/app/workers/tasks/developer_registry_refresh.py b/backend/app/workers/tasks/developer_registry_refresh.py index f20f7094..ea72d078 100644 --- a/backend/app/workers/tasks/developer_registry_refresh.py +++ b/backend/app/workers/tasks/developer_registry_refresh.py @@ -28,12 +28,15 @@ from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) +# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло +# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и +# autoretry_for задан не был — конфигурация не имела эффекта. Соседи, где ретраи +# нужны, задают их явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry() +# (scrape_kn). @celery_app.task( - bind=True, name="tasks.developer_registry_refresh.refresh_developer_registry", - max_retries=2, ) -def refresh_developer_registry(self: Any) -> dict[str, Any]: +def refresh_developer_registry() -> dict[str, Any]: """REFRESH MATERIALIZED VIEW CONCURRENTLY developer_registry. Лёгкая задача (реестр ~1024 застройщика). CONCURRENTLY — non-blocking для diff --git a/backend/app/workers/tasks/ekb_ppt_tep_sync.py b/backend/app/workers/tasks/ekb_ppt_tep_sync.py index 76d4341e..bb9901b2 100644 --- a/backend/app/workers/tasks/ekb_ppt_tep_sync.py +++ b/backend/app/workers/tasks/ekb_ppt_tep_sync.py @@ -22,10 +22,22 @@ logger = logging.getLogger(__name__) # ── Seed-документы ───────────────────────────────────────────────────────────── # Образец: ППТ 22823 (2018), пояснительная записка. # -# URL — placeholder. Реальный URL пояснительной записки лежит на ГИСОГД ЕКБ -# (https://gisogd.ekburg.ru/) под номером проекта планировки, но прямой PDF-линк -# требует ручного поиска через UI (#1136). До тех пор — ingest скипает с -# WARNING и метрики остаются {"docs": 0}. +# URL — placeholder, ingest скипает с WARNING, метрики остаются {"docs": 0}. +# Прод 20.08.2026: в таблице ekb_ppt_tep 0 строк — то есть загрузчик не отработал +# ни разу. +# +# ВАЖНО (#2464): хост `gisogd.ekburg.ru`, названный ниже как место, где «лежит +# реальный URL», НЕ СУЩЕСТВУЕТ — DNS не резолвит его ни с рабочей машины, ни с +# прод-хоста (проверено 20.08.2026). Прежняя редакция этого комментария отправляла +# искать документ вручную на портале, которого нет. +# +# Живой портал ГИСОГД Свердловской области — `gisogd66.midural.ru` (его использует +# загрузчик РНС/РВЭ, см. services/scrapers/gisogd66.py). Раздел 13 там — документы +# по земельному участку; проекты планировки лежат в других разделах, перечисление +# групп доступно через `/api/v1/{schema}/gisogddocgroups/{razdel}`. Перебор +# razdel3/4/5 показал разделы «Генеральный план», «Местные нормативы», «Правила +# землепользования и застройки» — точный раздел ППТ и формат ссылки на PDF +# пояснительной записки ещё предстоит найти (#1136). # # Override-пути для прогона: # 1. Передать в task: ingest_ppt_tep([{"doc_ref": "...", "url": "...", diff --git a/backend/app/workers/tasks/izyatie_ocr_ingest.py b/backend/app/workers/tasks/izyatie_ocr_ingest.py index b96952b3..0a65e9be 100644 --- a/backend/app/workers/tasks/izyatie_ocr_ingest.py +++ b/backend/app/workers/tasks/izyatie_ocr_ingest.py @@ -7,14 +7,18 @@ UPSERT-ит в land_reservation (м.136). Reservation_lookup / analyze-wiring (# Дедуп-ключ: ON CONFLICT (cad_num, act_number) — унаследован из reservation_ingest.py. - Если act_number IS NULL (не извлечён из сканов) → конфликт НЕ возникает при NULL-UPSERT - (NULL != NULL в SQL). Чтобы предотвратить дубли при act_number IS NULL, дедуплицируем - по (cad_num, doc_url) на уровне Python перед UPSERT: один URL = один батч, - повторный запуск с тем же URL обновит существующую строку через source+fetched_at - (где act_number IS NULL используем DO NOTHING вместо DO UPDATE — нет stable key). - Решение: для строк с act_number IS NULL добавляем в ON CONFLICT УНИКАЛЬНОСТЬ через - отдельный UPSERT с COALESCE-fallback: если запись с (cad_num, doc_url) уже есть — - UPDATE, иначе INSERT. Реализовано через двухшаговый UPSERT ниже. + Уникальность держит констрейнт uq_land_reservation_cad_act; с миграции 189 он + объявлен как UNIQUE NULLS NOT DISTINCT, поэтому записи без номера акта тоже + конфликтуют между собой и ON CONFLICT DO NOTHING реально их ловит. + + До м.189 констрейнт был обычным UNIQUE, где NULL != NULL: у записей с + act_number IS NULL конфликт не наступал никогда, и каждый недельный прогон + вставлял копию. Замер прода 20.08.2026 до правки — 297 строк, все без номера + акта, 27 групп с дублями, до 11 копий, 270 лишних строк (91% таблицы). + + Прежняя редакция этого docstring обещала python-дедуп по (cad_num, doc_url) + перед UPSERT и «двухшаговый UPSERT ниже». Ни того, ни другого в коде не было — + описание расходилось с реализацией и скрывало накопление дублей (#2464). Beat: еженедельно (пятница 07:00 МСК) — изъятия выходят редко. @@ -45,10 +49,13 @@ logger = logging.getLogger(__name__) # Stable key = (cad_num, act_number). Идемпотентно при повторном прогоне. # # Вариант B (act_number IS NULL): INSERT ... ON CONFLICT DO NOTHING. -# NULL != NULL → (cad_num, NULL) никогда не конфликтует по индексу. -# Python-дедуп per-batch предотвращает дубли в рамках одного прогона. -# Повторные прогоны добавят дубли если строки нет — acceptable (rare, data audit OK). -# Альтернатива (partial unique index на NULL) — задача database-expert, не здесь. +# Работает с миграции 189: uq_land_reservation_cad_act объявлен как +# UNIQUE NULLS NOT DISTINCT, поэтому (cad_num, NULL) конфликтует с такой же +# строкой и повторный прогон становится no-op. +# Прежний комментарий здесь оценивал накопление дублей как «rare, data audit OK» +# и откладывал уникальный индекс. Оценка не подтвердилась: на 20.08.2026 дубли +# составляли 91% таблицы (270 лишних строк из 297), максимум 11 копий одной +# записи. Отложенный вариант и реализован м.189 (#2464). _UPSERT_WITH_ACT_SQL = text( """ diff --git a/backend/app/workers/tasks/location_refresh.py b/backend/app/workers/tasks/location_refresh.py index 24c84b1b..903b9266 100644 --- a/backend/app/workers/tasks/location_refresh.py +++ b/backend/app/workers/tasks/location_refresh.py @@ -30,12 +30,15 @@ from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) +# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло +# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и +# autoretry_for задан не был — конфигурация не имела эффекта. Соседи, где ретраи +# нужны, задают их явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry() +# (scrape_kn). @celery_app.task( - bind=True, name="tasks.location_refresh.location_refresh", - max_retries=2, ) -def location_refresh(self: Any, region: str | None = None) -> dict[str, Any]: +def location_refresh(region: str | None = None) -> dict[str, Any]: """Пересчитать + upsert-нуть district-level индексы по всем районам в `location`. Идемпотентно (ON CONFLICT по district_name). Graceful: сбойный район diff --git a/backend/app/workers/tasks/mv_sales_tracker_refresh.py b/backend/app/workers/tasks/mv_sales_tracker_refresh.py index badee03f..2e87e56c 100644 --- a/backend/app/workers/tasks/mv_sales_tracker_refresh.py +++ b/backend/app/workers/tasks/mv_sales_tracker_refresh.py @@ -26,12 +26,15 @@ from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) +# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло +# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и +# autoretry_for задан не был — конфигурация не имела эффекта. Соседи, где ретраи +# нужны, задают их явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry() +# (scrape_kn). @celery_app.task( - bind=True, name="tasks.mv_sales_tracker_refresh.refresh_sales_tracker_mvs", - max_retries=2, ) -def refresh_sales_tracker_mvs_task(self: Any) -> dict[str, Any]: +def refresh_sales_tracker_mvs_task() -> dict[str, Any]: """REFRESH both sales-tracker MVs (#61). Both MVs are refreshed CONCURRENTLY (non-blocking, require their UNIQUE diff --git a/backend/app/workers/tasks/refresh_analytics.py b/backend/app/workers/tasks/refresh_analytics.py index 5864fb37..d2a5ba77 100644 --- a/backend/app/workers/tasks/refresh_analytics.py +++ b/backend/app/workers/tasks/refresh_analytics.py @@ -17,13 +17,16 @@ from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) +# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло +# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и +# autoretry_for задан не был — конфигурация не имела эффекта. Где ретраи нужны, они +# задаются явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry() +# (scrape_kn.resume_kn_run). Где их сознательно нет — пишется max_retries=0 с +# пояснением (nspd_geo, objective_etl.import_anton_objective). @celery_app.task( - bind=True, name="tasks.refresh_analytics.refresh_ekb_districts_medians", - max_retries=2, ) def refresh_ekb_districts_medians( - self: Any, window_months: int = 24, min_deals: int = 50, ) -> dict[str, Any]: diff --git a/backend/app/workers/tasks/refresh_layout_velocity.py b/backend/app/workers/tasks/refresh_layout_velocity.py index 7f67c4aa..b5706a78 100644 --- a/backend/app/workers/tasks/refresh_layout_velocity.py +++ b/backend/app/workers/tasks/refresh_layout_velocity.py @@ -22,12 +22,16 @@ from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) +# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло +# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и +# autoretry_for задан не был — конфигурация не имела эффекта. Где ретраи нужны, они +# задаются явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry() +# (scrape_kn.resume_kn_run). Где их сознательно нет — пишется max_retries=0 с +# пояснением (nspd_geo, objective_etl.import_anton_objective). @celery_app.task( - bind=True, name="tasks.refresh_layout_velocity.refresh_layout_velocity", - max_retries=2, ) -def refresh_layout_velocity_task(self: Any) -> dict[str, Any]: +def refresh_layout_velocity_task() -> dict[str, Any]: """REFRESH MATERIALIZED VIEW mv_layout_velocity (best_layouts, #113 / #1666). MV рефрешится CONCURRENTLY (non-blocking, требует unique-индекс diff --git a/backend/app/workers/tasks/refresh_quarter_price_index.py b/backend/app/workers/tasks/refresh_quarter_price_index.py index 803e3457..d8f0d3b9 100644 --- a/backend/app/workers/tasks/refresh_quarter_price_index.py +++ b/backend/app/workers/tasks/refresh_quarter_price_index.py @@ -20,12 +20,16 @@ from app.workers.celery_app import celery_app logger = logging.getLogger(__name__) +# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло +# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и +# autoretry_for задан не был — конфигурация не имела эффекта. Где ретраи нужны, они +# задаются явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry() +# (scrape_kn.resume_kn_run). Где их сознательно нет — пишется max_retries=0 с +# пояснением (nspd_geo, objective_etl.import_anton_objective). @celery_app.task( - bind=True, name="tasks.refresh_quarter_price_index.refresh_quarter_price_index_chain", - max_retries=2, ) -def refresh_quarter_price_index_chain(self: Any) -> dict[str, Any]: +def refresh_quarter_price_index_chain() -> dict[str, Any]: """Refresh mv_quarter_price_per_m2 then mv_quarter_price_index in sequence. Both MVs are refreshed CONCURRENTLY (non-blocking). Falls back to diff --git a/backend/app/workers/tasks/rosstat_macro_sync.py b/backend/app/workers/tasks/rosstat_macro_sync.py index cf84c5a3..c24ebe0c 100644 --- a/backend/app/workers/tasks/rosstat_macro_sync.py +++ b/backend/app/workers/tasks/rosstat_macro_sync.py @@ -231,12 +231,22 @@ def _upsert_emiss_rows(db: Session, rows: list[EmissRow]) -> int: return upserted +# Ретраев здесь НЕТ намеренно, и параметров, обещающих их, тоже быть не должно +# (#2464). Раньше стояло `bind=True, max_retries=2` — но self не использовался, +# self.retry() не вызывался и autoretry_for задан не был, поэтому конфигурация +# ретраев не имела ни малейшего эффекта: таска падала окончательно с первой ошибки, +# а параметр обещал до двух повторов. Соседи, где ретраи действительно нужны, +# задают их явно: autoretry_for в nspd_sync и scrape_cadastre, self.retry() в +# scrape_kn. +# +# Отсутствие ретраев — это и есть задуманное поведение, оно описано в докстринге +# ниже: «первая возникшая ошибка пробрасывается в конце (surfaces в +# Celery/GlitchTip), не глотается». Ряды тянутся по расписанию, следующий тик +# повторит попытку; молча ретраить внутри тика значило бы прятать отказ источника. @celery_app.task( - bind=True, name="tasks.rosstat_macro_sync.rosstat_macro_sync", - max_retries=2, ) -def rosstat_macro_sync(self: Any) -> dict[str, Any]: +def rosstat_macro_sync() -> dict[str, Any]: """Загрузить ряды Росстата (open-data + ЕМИСС + xlsx-СМР) и апсертить в macro_indicator. Источники выполняются НЕЗАВИСИМО (per-source try/except): сбой одного источника diff --git a/backend/app/workers/tasks/scrape_kn.py b/backend/app/workers/tasks/scrape_kn.py index d243c047..7ba512a1 100644 --- a/backend/app/workers/tasks/scrape_kn.py +++ b/backend/app/workers/tasks/scrape_kn.py @@ -40,7 +40,20 @@ _RELEASE_LOCK_LUA = ( def _lock_key(region_code: int, developers: list[str] | None) -> str: - devs_key = ",".join(developers) if developers else "*" + """Ключ синглтон-лока. Зависит от МНОЖЕСТВА разработчиков, не от их порядка. + + #2464: раньше список джойнился как пришёл, а приходит он прямо из тела запроса + (`developers` в admin_scrape). Один и тот же набор, поданный в другом порядке, + давал ДРУГОЙ ключ — и синглтон молча переставал быть синглтоном: два свипа шли + параллельно по одним и тем же разработчикам. + + Второе следствие того же: `force_release_lock` строит ключ этой же функцией. + Оператор, снимающий залипший лок и перечисливший разработчиков в ином порядке, + молча не снимал ничего. + + sorted(set(...)) закрывает оба: порядок и повторы ('A','A' ≡ 'A'). + """ + devs_key = ",".join(sorted(set(developers))) if developers else "*" return f"scrape:kn:lock:{region_code}:{devs_key}" @@ -144,7 +157,10 @@ def _region_lock(region_code: int, developers: list[str] | None) -> Iterator[boo logger.warning("release lock %s failed: %s", key, e) -@celery_app.task(bind=True, name="tasks.scrape_kn.scrape_kn_region", max_retries=2) +# bind=True здесь настоящий: self.request.id пишется в kn_scrape_log. А вот +# max_retries=2 был инертен — self.retry() в этой таске не вызывается и +# autoretry_for не задан (#2464). self.retry() ниже принадлежит resume_kn_run. +@celery_app.task(bind=True, name="tasks.scrape_kn.scrape_kn_region") def scrape_kn_region( self: Any, region_code: int, diff --git a/backend/app/workers/tasks/scrape_objective.py b/backend/app/workers/tasks/scrape_objective.py index 1cfa2fca..76270c40 100644 --- a/backend/app/workers/tasks/scrape_objective.py +++ b/backend/app/workers/tasks/scrape_objective.py @@ -146,13 +146,16 @@ def _save_raw( return int(row) +# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло +# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и +# autoretry_for задан не был — конфигурация не имела эффекта. Где ретраи нужны, они +# задаются явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry() +# (scrape_kn.resume_kn_run). Где их сознательно нет — пишется max_retries=0 с +# пояснением (nspd_geo, objective_etl.import_anton_objective). @celery_app.task( - bind=True, name="tasks.scrape_objective.sync_objective_group", - max_retries=2, ) def sync_objective_group( - self: Any, group_name: str | None = None, triggered_by: str = "beat", use_ddu: bool = True, @@ -349,7 +352,7 @@ def sync_objective_group( db.rollback() reports_failed += 1 logger.exception( - "sync_objective_group: parser failed for %s/%s/%s " "raw_id=%s: %s", + "sync_objective_group: parser failed for %s/%s/%s raw_id=%s: %s", section, rtype, rname, @@ -402,10 +405,31 @@ def sync_objective_group( } except Exception as e: if run_id: + # #2464: сессия здесь МОЖЕТ быть отравлена. Исходный сбой бывает + # DB-level (напр. INSERT в _save_raw), и тогда транзакция остаётся в + # aborted-состоянии: следующий execute падает, _finish_run не проходит, + # а голый `except Exception: pass` ниже гасил это молча — строка прогона + # навсегда оставалась в status='running'. Замер прода 20.08: шесть таких + # строк висят с 17.05, то есть 95 суток; уборщика зомби для + # objective_scrape_runs нет. + # + # Сессия здесь СВОЯ (SessionLocal() выше, close в finally), поэтому + # плоский rollback законен: он отбрасывает уже провалившуюся транзакцию + # и ничего чужого не теряет. + try: + db.rollback() + except Exception: + logger.exception("sync_objective_group: rollback перед _finish_run не удался") try: _finish_run(db, run_id, status="failed", error=f"{type(e).__name__}: {e}") except Exception: - pass + # Больше не молча: если и это не прошло, строка останется 'running', + # и знать об этом важнее, чем сохранить тишину в логе. + logger.exception( + "sync_objective_group: не удалось пометить run_id=%s как failed —" + " строка останется в status='running'", + run_id, + ) raise finally: db.close() diff --git a/backend/app/workers/tasks/supply_layers_refresh.py b/backend/app/workers/tasks/supply_layers_refresh.py index eddcd606..667450ed 100644 --- a/backend/app/workers/tasks/supply_layers_refresh.py +++ b/backend/app/workers/tasks/supply_layers_refresh.py @@ -155,12 +155,16 @@ def _upsert_rows(db: Session, rows: list[SupplyLayerRow]) -> tuple[int, int]: return upserted, skipped +# Ретраев здесь нет, и параметров, обещающих их, быть не должно (#2464). Стояло +# `bind=True, max_retries=2`, но self не использовался, self.retry() не вызывался и +# autoretry_for задан не был — конфигурация не имела эффекта. Где ретраи нужны, они +# задаются явно: autoretry_for (nspd_sync, scrape_cadastre) или self.retry() +# (scrape_kn.resume_kn_run). Где их сознательно нет — пишется max_retries=0 с +# пояснением (nspd_geo, objective_etl.import_anton_objective). @celery_app.task( - bind=True, name="tasks.supply_layers_refresh.supply_layers_refresh", - max_retries=2, ) -def supply_layers_refresh(self: Any) -> dict[str, Any]: +def supply_layers_refresh() -> dict[str, Any]: """Пересчитать 3-слойный склад предложения по всем районам и UPSERT в supply_layers. Coherent snapshot: ``run_date = date.today()`` фиксируется ОДИН раз и передаётся diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0411d8d3..e6abb349 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "pytesseract>=0.3.13", # OCR сканов через Tesseract для изъятия ЕКБ (#1062) "contextily>=1.7.0", # OSM basemap-тайлы для серверного рендера карт отчёта (#2259 PR-C) "matplotlib>=3.11.0", # headless (Agg) рендер PNG-карт участка/концепции (#2259 PR-C) + "prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078) ] [dependency-groups] @@ -47,7 +48,7 @@ dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.23.0", "pytest-cov>=5.0.0", # coverage gate в CI (#68): pytest --cov=app --cov-fail-under - "ruff>=0.5.0", + "ruff==0.15.20", "mypy>=1.10.0", "types-redis>=4.6.0", "pre-commit>=3.7.0", diff --git a/backend/scripts/spike_plan_vectorize.py b/backend/scripts/spike_plan_vectorize.py index 2ffd2a77..1f4b3cee 100644 --- a/backend/scripts/spike_plan_vectorize.py +++ b/backend/scripts/spike_plan_vectorize.py @@ -219,9 +219,7 @@ def summarise(results: list[VectorizeResult]) -> None: total_raster = sum(r.raster_bytes for r in results) total_svg = sum(r.svg_bytes for r in results) agg_ratio = total_raster / total_svg if total_svg else float("inf") - print( - f"aggregate : {total_raster}B raster -> {total_svg}B svg " f"({agg_ratio:.2f}x overall)" - ) + print(f"aggregate : {total_raster}B raster -> {total_svg}B svg ({agg_ratio:.2f}x overall)") def build_parser() -> argparse.ArgumentParser: diff --git a/backend/tests/api/v1/test_2464_confidence_zoning_source.py b/backend/tests/api/v1/test_2464_confidence_zoning_source.py new file mode 100644 index 00000000..71cd130a --- /dev/null +++ b/backend/tests/api/v1/test_2464_confidence_zoning_source.py @@ -0,0 +1,147 @@ +"""#2464: достоверность должна видеть зону ПЗЗ из ОБОИХ источников. + +Зона приходит двумя путями: + +* `zoning` — старый per-parcel слой из таблицы `pzz_zones_ekb`. На проде она + **пуста** (0 строк, замер 19.08.2026), поэтому `data_available` там всегда False; +* `nspd_zoning` — территориальные зоны дампа НСПД, а для участков в зазорах между + зонами зона синтезируется резолвером геопортала. + +Пока в `_compute_confidence` передавали только первый, подскор «zoning» был 0.2 у +КАЖДОГО участка, а оговорка утверждала неправду. Прогон analyze на проде, участок +66:41:0402029:25 (вызов функции в живом контейнере): + + nspd_zoning.zone_code = 'Ж-5' <- зона известна + zoning.data_available = False + confidence = 0.61 medium + подскор zoning = 0.2 + оговорка: «ПЗЗ zone_code не известен — нельзя оценить разрешённое использование» + +То есть один и тот же ответ показывал зону Ж-5 и заявлял, что зона неизвестна. +Подскоров семь, цена ошибки в композите — (1.0 − 0.2) / 7 = 0.114. +""" + +from __future__ import annotations + +from typing import Any + +from app.api.v1.parcels import _compute_confidence + +_CAVEAT = "ПЗЗ zone_code не известен" + + +def _confidence(**over: Any) -> dict[str, Any]: + """Вызов с общими аргументами. + + `nspd_zoning` НЕ передаётся, если его явно не попросили: тесты-контроли обязаны + исполняться и на origin/main, где такого параметра ещё нет. Иначе они падали бы + там с TypeError и контролями не были бы — красное «нет такой возможности» + неотличимо от красного «значение неверно». + """ + base: dict[str, Any] = { + "source": "cad_quarter", + "poi_rows": [], + "district_row": None, + "competitor_rows": [], + "noise_map_rows_nearby": 0, + "air_q": None, + "weather": None, + "market_trend": None, + "zoning": {"data_available": False}, + } + base.update(over) + return _compute_confidence(**base) + + +def test_nspd_zone_counts_as_known() -> None: + """Зона есть только в nspd_zoning → подскор полный, ложной оговорки нет.""" + res = _confidence(nspd_zoning={"zone_code": "Ж-5"}) + + assert res["breakdown"]["zoning"] == 1.0 + assert not any(_CAVEAT in c for c in res["caveats"]), ( + "оговорка «зона неизвестна» при известной зоне Ж-5 — ровно то, что видел прод" + ) + + +def test_regulation_zone_index_also_counts() -> None: + """Синтезированная геопорталом зона несёт regulation_zone_index, а не zone_code.""" + res = _confidence(nspd_zoning={"regulation_zone_index": "Ж-2"}) + assert res["breakdown"]["zoning"] == 1.0 + + +def test_no_zone_anywhere_keeps_the_caveat() -> None: + """Контроль: когда зоны нет НИ В ОДНОМ источнике, оговорка остаётся. + + Зелёный по обе стороны правки — иначе правка просто глушила бы предупреждение. + """ + res = _confidence() # без nspd_zoning — исполняется и на origin/main + assert res["breakdown"]["zoning"] == 0.2 + assert any(_CAVEAT in c for c in res["caveats"]) + + +def test_legacy_source_still_honoured() -> None: + """Контроль: если старый слой когда-нибудь наполнится, он по-прежнему считается.""" + res = _confidence(zoning={"data_available": True}) # без nspd_zoning + assert res["breakdown"]["zoning"] == 1.0 + + +def test_composite_gain_matches_the_measured_cost() -> None: + """Цена дефекта в композите — ровно 0.114, как посчитано по проду.""" + without = _confidence() + with_zone = _confidence(nspd_zoning={"zone_code": "Ж-5"}) + + assert len(without["breakdown"]) == 7, "число подскоров изменилось — пересчитать цену" + assert round(with_zone["value"] - without["value"], 2) == 0.11 + + +# ── поведенческая проверка на уровне ручки + проводка ─────────────────────────── +# +# Проверки выше падают на origin/main с TypeError — это «возможности нет», а не +# «значение неверно». Тест ниже красный ПО ПОВЕДЕНИЮ: на origin/main ручка вернёт +# ложную оговорку при известной зоне. Он же сторожит проводку: параметр можно +# добавить в функцию и забыть передать на вызове, и все проверки выше останутся +# зелёными. + + +def test_analyze_does_not_claim_unknown_zone_when_nspd_resolved_it() -> None: + from unittest.mock import patch + + from fastapi.testclient import TestClient + + from app.core.db import get_db + from app.main import app + from tests.api.v1.test_analyze_market_price import ( + _make_db_for_analyze, + _override_db, + _start_patches, + _stop_patches, + ) + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + _start_patches() + try: + with patch( + "app.api.v1.parcels.get_quarter_dump_data", + return_value={ + "nspd_zoning": {"zone_code": "Ж-5", "zone_name": "Многоэтажная жилая"}, + "nspd_zouit_overlaps": [], + "nspd_engineering_nearby": [], + "nspd_dump": {"available": True, "stale": False, "harvest_triggered": False}, + }, + ): + resp = TestClient(app).post("/api/v1/parcels/66:41:0204016:10/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + finally: + app.dependency_overrides.clear() + _stop_patches() + + assert (body.get("nspd_zoning") or {}).get("zone_code") == "Ж-5", ( + "предусловие теста не выполнено: зона не доехала до ответа" + ) + caveats = " ".join(body.get("confidence_caveats") or []) + assert _CAVEAT not in caveats, ( + "ответ показывает зону Ж-5 и одновременно заявляет, что зона неизвестна" + ) + assert (body.get("confidence_breakdown") or {}).get("zoning") == 1.0 diff --git a/backend/tests/api/v1/test_2464_manual_list_validation.py b/backend/tests/api/v1/test_2464_manual_list_validation.py new file mode 100644 index 00000000..43d0f21a --- /dev/null +++ b/backend/tests/api/v1/test_2464_manual_list_validation.py @@ -0,0 +1,68 @@ +"""scope=manual_list не должен создавать задание из пустоты (#2464). + +Проверка непустоты стояла ДО очистки: список из одних пробелов проходил как +непустой, а после `strip` оставался пустым. Дальше по коду создавался job с нулём +кварталов, ставился в очередь и возвращался `targets_total=0` — пустышка, +неотличимая в списке заданий от настоящей. + +Проверяется ПОВЕДЕНИЕ функции резолва: на пробельном вводе она обязана отказать, +а не вернуть пустой список. На origin/main тесты падают тем, что отказа НЕТ. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import HTTPException + + +def _resolve(quarters): + from app.api.v1.admin_cadastre import _resolve_quarters + + # scope=manual_list не ходит в БД — db здесь не используется. + return _resolve_quarters(db=None, scope="manual_list", quarters=quarters, limit=None) + + +@pytest.mark.parametrize( + "quarters", + [ + pytest.param([" "], id="один-пробельный"), + pytest.param(["", " ", "\t"], id="несколько-пустых"), + pytest.param(["\n"], id="перевод-строки"), + ], +) +def test_whitespace_only_list_is_rejected(quarters) -> None: + """Список,из которого после очистки ничего не остаётся, — это пустой список.""" + with pytest.raises(HTTPException) as exc: + _resolve(quarters) + assert exc.value.status_code == 400 + + +@pytest.mark.parametrize( + "quarters", + [pytest.param(None, id="none"), pytest.param([], id="пустой-список")], +) +def test_empty_input_still_rejected(quarters) -> None: + """Контроль: прежние отказы никуда не делись.""" + with pytest.raises(HTTPException) as exc: + _resolve(quarters) + assert exc.value.status_code == 400 + + +def test_valid_list_passes_and_is_trimmed() -> None: + """Контроль: годный список проходит и по-прежнему очищается от пробелов. + + Ловит «починку», которая отказывала бы на любом вводе с пробелами. + """ + assert _resolve([" 66:41:0702017 ", "66:41:0106001"]) == [ + "66:41:0702017", + "66:41:0106001", + ] + + +def test_partially_blank_list_keeps_real_entries() -> None: + """Контроль: смешанный список не отбрасывается целиком — остаются настоящие.""" + assert _resolve([" ", "66:41:0702017", ""]) == ["66:41:0702017"] diff --git a/backend/tests/api/v1/test_2464_parcels_savepoint_pair.py b/backend/tests/api/v1/test_2464_parcels_savepoint_pair.py new file mode 100644 index 00000000..268133ce --- /dev/null +++ b/backend/tests/api/v1/test_2464_parcels_savepoint_pair.py @@ -0,0 +1,78 @@ +"""#2464: два места analyze_parcel глотали ошибку БД без SAVEPOINT. + +Найдено поиском «правило соблюдено рядом — нарушено здесь»: в самой функции +`analyze_parcel` двенадцать соседних блоков оборачивают свой `db.execute` в +`db.begin_nested()`, и у velocity-блока правило записано словами — + + «SAVEPOINT защищает outer transaction если velocity SQL падает — иначе + следующие queries (_geotech_risk и пр.) крашатся с InFailedSqlTransaction» + +Два блока из правила выпадали: + +* `get_permits_nearby` (вызов на общей сессии, своей защиты не имеет, ошибку + глотает вызывающий) — а после него идут `_geotech_risk`, `_neighbors_summary` + и запись прогона: терялся бы весь анализ, а не блок разрешений; +* `_neighbors_summary` — вызывается перед `persist_analysis_run`. + +Двойник сессии (`tests/support/pg_session.py`) воспроизводит семантику Postgres: +обычный MagicMock aborted-состояния не имеет, и проверка «сессия осталась рабочей» +на нём зелена независимо от наличия SAVEPOINT. + +ЧТО ЗДЕСЬ НЕ ПОКРЫТО И ПОЧЕМУ +───────────────────────────── +Прямой тест есть только на `_neighbors_summary` — это отдельная функция, её можно +вызвать. Блок `get_permits_nearby` лежит ВНУТРИ обработчика `analyze_parcel`, и +дотянуться до него можно лишь через полный эндпоинт, чей мок-двойник сессии должен +уметь отвечать на десятки разных запросов. + +Первая версия этого файла «покрывала» его так: тест сам писал +`with db.begin_nested():` вокруг вызова и проверял, что сессия выжила. Такая +проверка зелена независимо от боевого кода — она проверяет собственную обёртку. +Убрана: лучше честно отсутствующее покрытие, чем поддельное. Правка там та же +самая и по тому же доводу, но подтверждена чтением, а не тестом. +""" + +from __future__ import annotations + +import pytest + +from tests.support.pg_session import AbortedTransactionError, PostgresLikeSession + + +def test_neighbors_summary_failure_leaves_session_usable() -> None: + """Сбой запроса соседей не должен помешать сохранить прогон.""" + from app.api.v1 import parcels + + db = PostgresLikeSession() + wkt = "POLYGON((60.6 56.83, 60.61 56.83, 60.61 56.84, 60.6 56.84, 60.6 56.83))" + + out = parcels._neighbors_summary(db, wkt, "66:41:0402029:25") + + assert out["data_available"] is False, "деградация должна остаться graceful" + db.execute("SELECT 1") # на origin/main здесь AbortedTransactionError + assert db.calls == 2 + + +def test_double_still_poisons_without_a_savepoint() -> None: + """Контроль на инструмент: без SAVEPOINT двойник ОБЯЗАН отравляться. + + Без этой проверки обе проверки выше были бы зелёными по построению. + """ + db = PostgresLikeSession() + with pytest.raises(RuntimeError): + db.execute("boom") + with pytest.raises(AbortedTransactionError): + db.execute("SELECT 1") + + +def test_neighbors_summary_healthy_path_unchanged() -> None: + """Контроль: без сбоя поведение прежнее — данные читаются, не fallback.""" + from app.api.v1 import parcels + + db = PostgresLikeSession( + fail_on=(), + row={"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}, + ) + out = parcels._neighbors_summary(db, "POLYGON EMPTY", "66:41:0402029:25") + + assert out.get("data_available") is not False diff --git a/backend/tests/api/v1/test_2464_resume_geo_job_guard.py b/backend/tests/api/v1/test_2464_resume_geo_job_guard.py new file mode 100644 index 00000000..fc6d417d --- /dev/null +++ b/backend/tests/api/v1/test_2464_resume_geo_job_guard.py @@ -0,0 +1,150 @@ +"""#2464: resume_geo_job обновлял статус БЕЗ фильтра — в отличие от cancel_geo_job. + +`cancel_geo_job` строкой выше фильтрует явно: + + WHERE job_id = :id AND status IN ('queued','running','paused') + +а resume не фильтровал вовсе: + + UPDATE nspd_geo_jobs SET status='queued', error=NULL WHERE job_id=:id + +Следствия: завершённую задачу (done) можно было перевести обратно в queued и +прогнать заново, затирая результат; уже бегущую — поставить в очередь второй раз, +получив двух воркеров на один job_id и лишние запросы к НСПД, у которого WAF. + +Замер на проде 19.08: все 66 задач в терминальных статусах (61 done, 5 cancelled). +То есть resume на ЛЮБУЮ существующую делал ровно то, чего не должен. + +Второе: ручка возвращала resumed=True всегда, независимо от того, изменилось ли +что-нибудь. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from app.api.v1 import admin_scrape + + +class _Db: + """Сессия-двойник: помнит SQL и отдаёт статус задачи.""" + + def __init__(self, status: str | None, *, update_matches: bool) -> None: + self.status = status + self._update_matches = update_matches + self.sql_seen: list[str] = [] + + def execute(self, statement: Any, params: Any = None) -> MagicMock: + sql = " ".join(str(statement).split()) + self.sql_seen.append(sql) + r = MagicMock() + if sql.startswith("UPDATE"): + r.mappings.return_value.first.return_value = ( + {"job_id": 1} if self._update_matches else None + ) + else: + r.scalar.return_value = self.status + return r + + def commit(self) -> None: + pass + + +def _resume(db: _Db) -> dict[str, Any]: + with patch("app.workers.tasks.nspd_geo.process_nspd_geo_job") as task: + db.task = task # type: ignore[attr-defined] + return admin_scrape.resume_geo_job(job_id=1, db=db) # type: ignore[arg-type] + + +def test_update_is_guarded_by_status() -> None: + """UPDATE обязан нести фильтр статуса — как у соседнего cancel_geo_job.""" + db = _Db("paused", update_matches=True) + _resume(db) + + upd = next(s for s in db.sql_seen if s.startswith("UPDATE")) + assert "status IN" in upd, "UPDATE без фильтра статуса — возобновляется что угодно" + + +def test_finished_job_is_not_resumed_and_says_so() -> None: + """done → не возобновляем и отвечаем честно, а не resumed=True.""" + db = _Db("done", update_matches=False) + + out = _resume(db) + + assert out["resumed"] is False + assert out["status"] == "done" + assert "не подлежит возобновлению" in out["reason"] + + +def test_missing_job_reports_absence(monkeypatch) -> None: + """Задачи нет → resumed=False с внятной причиной, а не тихий True.""" + db = _Db(None, update_matches=False) + + out = _resume(db) + + assert out["resumed"] is False + assert out["status"] is None + assert "не найдена" in out["reason"] + + +def test_paused_job_is_resumed() -> None: + """Контроль: законный случай по-прежнему работает. + + Проверяется ТОЛЬКО resumed — новый ключ `status` здесь не трогаем, иначе тест + падал бы и на origin/main с KeyError, то есть по причине «в ответе нет поля», а + не «законный путь сломан». Контроль обязан быть зелёным по обе стороны. + """ + db = _Db("paused", update_matches=True) + + assert _resume(db)["resumed"] is True + + +def test_response_reports_the_new_status() -> None: + """Ответ несёт статус, в который перешла задача (новый ключ контракта).""" + db = _Db("paused", update_matches=True) + + assert _resume(db)["status"] == "queued" + + +# ── cancel_geo_job: тот же класс — подтверждение действия, которого не было ───── +# +# Фильтр статуса здесь был всегда, но ответ возвращал cancelled=True независимо от +# того, задел ли UPDATE строку. Несуществующий job_id и уже завершённая задача давали +# тот же ответ, что настоящая отмена. + + +def _cancel(db: _Db) -> dict[str, Any]: + return admin_scrape.cancel_geo_job(job_id=1, db=db) # type: ignore[arg-type] + + +def test_cancel_of_finished_job_is_not_reported_as_success() -> None: + db = _Db("done", update_matches=False) + + out = _cancel(db) + + assert out["cancelled"] is False + assert out["status"] == "done" + assert "терминальный" in out["reason"] + + +def test_cancel_of_missing_job_reports_absence() -> None: + db = _Db(None, update_matches=False) + + out = _cancel(db) + + assert out["cancelled"] is False + assert out["status"] is None + assert "не найдена" in out["reason"] + + +def test_cancel_of_running_job_still_works() -> None: + """Контроль: законная отмена по-прежнему подтверждается. + + Проверяется только `cancelled` — новый ключ `status` намеренно не трогаем, иначе + контроль падал бы на origin/main с KeyError, то есть «в ответе нет поля», а не + «отмена сломана». + """ + db = _Db("running", update_matches=True) + + assert _cancel(db)["cancelled"] is True diff --git a/backend/tests/api/v1/test_2464g_noise_source_filter.py b/backend/tests/api/v1/test_2464g_noise_source_filter.py new file mode 100644 index 00000000..cfd60e80 --- /dev/null +++ b/backend/tests/api/v1/test_2464g_noise_source_filter.py @@ -0,0 +1,156 @@ +"""#2464-G: запрос шумовых источников обязан отсеивать нешумовые слои. + +`osm_noise_sources_ekb` держит не только источники шума. Замер на проде 19.08: + + highway 5 616 есть в NOISE_L_BASE + railway 1 994 есть в NOISE_L_BASE + industrial 585 есть в NOISE_L_BASE + utility 1 487 -> дефолт 50 дБ + water 870 -> дефолт 50 дБ + +`water`/`utility` — предмет отдельного блока гидрологии (9c), для шумового скорера +это мусор: ключа в NOISE_L_BASE у них нет, и `.get(key, 50.0)` выдаёт им ровно 50 дБ, +что совпадает с порогом попадания в список источников. При этом `LIMIT 30` берётся +ПО БЛИЗОСТИ, поэтому вода вытесняла настоящие источники: 8 797 занятых слотов из +19 755 (44.5%) на выборке в 1 000 участков. + +Ловушка, ради которой написан второй тест +───────────────────────────────────────── +Наивная правка — просто добавить фильтр и оставить `noise_sources_count=len(noise_rows)` — +ломает треть участков. `_compute_confidence` спрашивает «загружена ли шумовая карта» +(при нуле пишет «Шумовая карта не загружена — noise score = stub»), а у 345 участков +из 1 000 в радиусе 2 км нет НИ ОДНОГО шумового источника при наличии воды. После +наивной правки они получили бы утверждение о незагруженной карте — неверное. + +До правки верный ответ там выходил СЛУЧАЙНО: в счёт шли и нешумовые строки. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +from fastapi.testclient import TestClient + +from app.main import app +from tests.api.v1.test_analyze_market_price import ( + _GEOJSON, + _WKT, + _make_mapping, + _override_db, + _start_patches, + _stop_patches, +) + +_CAD = "66:41:0204016:10" + +# Строки «шумовой» таблицы вокруг участка: настоящих источников нет, есть вода +# вплотную (10 м → base 50 дБ − 20·log10(1) = ровно 50, порог списка) и коммуникации. +_TABLE_ROWS = [ + {"source_type": "water", "road_class": None, "name": "Пруд", "distance_m": 10.0}, + {"source_type": "utility", "road_class": None, "name": "ЛЭП", "distance_m": 900.0}, +] + + +def _make_db(rows: list[dict[str, Any]]) -> MagicMock: + """Mock Session, ведущий себя как БД: применяет предикат source_type запроса. + + Мок здесь стоит вместо Postgres, поэтому обязан УВАЖАТЬ WHERE — иначе тест + проверял бы не фильтрацию, а то, что мы отдали в мок. + """ + db = MagicMock() + + def _execute(*args: Any, **kwargs: Any) -> MagicMock: + sql = " ".join(str(args[0]).split()) if args else "" + first_val: Any = None + all_val: list[Any] = [] + scalar_val: Any = 0 + + if "AS geom_geojson" in sql: + first_val = _make_mapping( + {"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"} + ) + elif "AS wkt" in sql: + first_val = _make_mapping({"wkt": _WKT}) + elif "AS ekb_reference_median" in sql and "district_name" in sql: + first_val = _make_mapping( + { + "district_name": "Октябрьский", + "ekb_reference_median": 120000, + "quarter_median_12m": None, + "quarter_deals_count": 0, + "dist_to_center": 1500.0, + } + ) + elif "AS lon" in sql and "AS lat" in sql: + first_val = _make_mapping({"lat": 56.84, "lon": 60.605}) + elif "osm_noise_sources_ekb" in sql: + visible = rows + # Уважаем предикат так же, как это сделал бы Postgres. + if "source_type IN (" in sql: + visible = [r for r in rows if r["source_type"] not in ("water", "utility")] + elif "source_type = 'water'" in sql: + visible = [r for r in rows if r["source_type"] == "water"] + elif "source_type = 'industrial'" in sql: + visible = [r for r in rows if r["source_type"] == "industrial"] + if "COUNT(*)" in sql: + scalar_val = len(visible) + else: + all_val = [_make_mapping(r) for r in visible] + + r = MagicMock() + r.mappings.return_value.first.return_value = first_val + r.mappings.return_value.all.return_value = all_val + r.scalar.return_value = scalar_val + return r + + db.execute.side_effect = _execute + ctx = MagicMock() + ctx.__enter__ = MagicMock(return_value=ctx) + ctx.__exit__ = MagicMock(return_value=False) + db.begin_nested.return_value = ctx + return db + + +def _analyze(rows: list[dict[str, Any]]) -> dict[str, Any]: + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(_make_db(rows)) + _start_patches() + try: + resp = TestClient(app).post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + return resp.json() + finally: + app.dependency_overrides.clear() + _stop_patches() + + +def test_water_not_reported_as_noise_source() -> None: + """Пруд в 10 м не должен числиться источником шума. + + На main фильтра нет, водоём получает дефолтные 50 дБ (= порог) и попадает в + список источников с именем «Пруд». + """ + body = _analyze(_TABLE_ROWS) + noise = body.get("noise") or {} + sources = noise.get("nearby_sources") or noise.get("sources") or [] + types = {s.get("source_type") for s in sources} + assert "water" not in types and "utility" not in types, ( + f"нешумовой слой попал в источники шума: {sources}" + ) + + +def test_no_false_map_not_loaded_caveat_when_only_water_nearby() -> None: + """Ловушка наивной правки: карта ЗАГРУЖЕНА, рядом просто тихо. + + Зелёный и на main, и с правкой — но красный, если считать покрытие карты по + отфильтрованному списку источников. Так себя вёл бы «очевидный» вариант фикса, + и он соврал бы 345 участкам из 1 000. + """ + body = _analyze(_TABLE_ROWS) + caveats = " ".join(body.get("confidence_caveats") or []) + assert "Шумовая карта не загружена" not in caveats, ( + "оговорка о незагруженной карте при загруженной карте: " + "покрытие посчитано по отфильтрованному списку, а не по строкам карты" + ) diff --git a/backend/tests/api/v1/test_2934_no_fake_geology_label.py b/backend/tests/api/v1/test_2934_no_fake_geology_label.py new file mode 100644 index 00000000..17ccd476 --- /dev/null +++ b/backend/tests/api/v1/test_2934_no_fake_geology_label.py @@ -0,0 +1,107 @@ +"""«Геологический риск» не выводится из шума (#2934). + +`risks.geology_risk_label` назывался геологическим риском, а вычислялся так: + + high — если подтопление + medium — если шум ≥ 65 дБ + low — иначе + +Геологии в нём не было ни одного бита. При этом `cad_risk_zones` пуста (0 строк, +писателя нет), поэтому подтопление приходило только из OSM-прокси «река ближе +200 м» — и на тихом участке без реки поле ВСЕГДА говорило «low». Зелёный вердикт, +ни разу не подкреплённый проверкой геологии. + +Поле убрано, а не переименовано: соседний блок `geology` честно отдаёт +`data_available: false`, когда данных нет, и потребителей у метки не было — ни +фронт, ни экспортёры, ни §19-allowlist чата её не читали. + +Тесты смотрят на исходник эндпоинта: собрать полный ответ `/analyze` в юните +нельзя (нужны БД, OSM, НСПД), а утверждение проверяемо и так — метка не должна +существовать, и шум не должен участвовать в риск-блоке. +""" + +from __future__ import annotations + +import inspect +import re + + +def _risks_block_source() -> str: + """Кусок analyze-эндпоинта, собирающий risks_block.""" + from app.api.v1 import parcels + + src = inspect.getsource(parcels) + начало = src.index("risks_block: dict[str, Any]") + конец = src.index("# 10) Market trend", начало) + return src[начало:конец] + + +def test_geology_risk_label_is_gone() -> None: + """Головной: метки нет в ответе. + + На origin/main она есть и на тихом участке без реки всегда равна 'low'. + """ + блок = _risks_block_source() + assert '"geology_risk_label"' not in блок, f"метка всё ещё выдаётся:\n{блок[:400]}" + + +def test_noise_no_longer_feeds_the_risk_label() -> None: + """Контроль подмены: шум не должен участвовать в риск-метке. + + Ловит «починку» через переименование — если метку назвать иначе, но по-прежнему + считать из noise_db_max, обман останется тем же. + """ + блок = _risks_block_source() + assert "noise_db_max" not in блок, f"шум по-прежнему участвует в риск-блоке:\n{блок[:400]}" + assert not re.search(r'"(high|medium|low)"', блок), ( + f"в риск-блоке остались словесные градации риска:\n{блок[:400]}" + ) + + +def test_noise_score_itself_is_preserved() -> None: + """Контроль от переусердствования: сам noise_score остаётся — он измерен честно.""" + блок = _risks_block_source() + assert '"noise_score"' in блок, "убран и измеренный noise_score — это перебор" + + +def test_flood_zone_still_reported() -> None: + """Контроль: flood_zone остаётся в ответе (его судьба — отдельный пункт #2934).""" + блок = _risks_block_source() + assert '"flood_zone"' in блок + + +def test_no_consumer_reads_the_removed_field() -> None: + """Контроль обоснования: поле действительно никем не читалось. + + Утверждение «потребителей нет» проверяется, а не декларируется — иначе + удаление поля из публичного ответа держалось бы на слове. + """ + from pathlib import Path + + from app.api.v1 import parcels + + корень = Path(inspect.getsourcefile(parcels)).resolve().parents[4] + попадания: list[str] = [] + for каталог, маски in ( + (корень / "backend" / "app", ("*.py",)), + (корень / "frontend" / "src", ("*.ts", "*.tsx")), + ): + for маска in маски: + for f in каталог.rglob(маска): + if "__fixtures__" in str(f) or "api-types" in f.name: + continue + текст = f.read_text(encoding="utf-8", errors="ignore") + for n, строка in enumerate(текст.splitlines(), 1): + if "geology_risk_label" not in строка: + continue + # Комментарий, объясняющий удаление, — не потребитель. Проверка + # обязана отличать упоминание от использования, иначе краснеет + # на собственном объяснении правки. + голая = строка.lstrip() + комментарий = ( + голая.startswith("#") or голая.startswith("//") or голая.startswith("*") + ) + if комментарий: + continue + попадания.append(f"{f.relative_to(корень)}:{n}") + assert попадания == [], f"поле где-то ещё ЧИТАЕТСЯ (не в комментарии): {попадания}" diff --git a/backend/tests/api/v1/test_analyze_competitors_status.py b/backend/tests/api/v1/test_analyze_competitors_status.py index d4fd67be..7174b6dd 100644 --- a/backend/tests/api/v1/test_analyze_competitors_status.py +++ b/backend/tests/api/v1/test_analyze_competitors_status.py @@ -90,9 +90,9 @@ class TestCompetitorsHaveStatusFields: competitors = [dict(r.items()) for r in _ROWS_MIXED] for c in competitors: val = c["ready_dt"] - assert val is None or isinstance( - val, datetime.date - ), f"ready_dt имеет неожиданный тип {type(val)}: {val}" + assert val is None or isinstance(val, datetime.date), ( + f"ready_dt имеет неожиданный тип {type(val)}: {val}" + ) class TestCompetitorsSortOrder: @@ -111,7 +111,7 @@ 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']}'" + f"Первый конкурент должен быть 'Строящиеся', но получили '{first['site_status']}'" ) def test_flat_count_desc_would_break_order(self) -> None: @@ -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), НЕ сырой. @@ -204,9 +234,9 @@ class TestObjPricingPushdown: "\n", " " ), "obj_lots_latest должен дедупить по physflat-ключу" assert "snapshot_date DESC, ol.id DESC" in sql, "берём последний снапшот физлота" - assert ( - "v_objective_lots_latest" not in sql - ), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)" + assert "v_objective_lots_latest" not in sql, ( + "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)" + ) class TestCompetitorAvgAreaPd: diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index cc61f0ef..35ffb40e 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -285,9 +285,9 @@ def test_inline_weights_rejects_nan() -> None: content=raw_body, headers={"Content-Type": "application/json"}, ) - assert ( - resp.status_code == 422 - ), f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}" + assert resp.status_code == 422, ( + f"Ожидали 422 для NaN-weight, получили {resp.status_code}: {resp.text}" + ) finally: app.dependency_overrides.clear() _stop_patches() diff --git a/backend/tests/api/v1/test_analyze_parcel_meta.py b/backend/tests/api/v1/test_analyze_parcel_meta.py index a5b1ceb8..34c8887d 100644 --- a/backend/tests/api/v1/test_analyze_parcel_meta.py +++ b/backend/tests/api/v1/test_analyze_parcel_meta.py @@ -7,26 +7,14 @@ Стратегия mock: аналогична test_analyze_market_price.py — DB mock через dependency_overrides, тяжёлые сервисы патчим через unittest.mock.patch. -Порядок db.execute calls в analyze_parcel (с #29 G2, #2464 cluster B): - 0. UNION ALL geom + source → .mappings().first() - 1. WKT query → .mappings().first() - 2. District → .mappings().first() - 3. POI rows → .mappings().all() - 4. Competitor rows → .mappings().all() - 5. competitors_total honest COUNT(*) → .scalar() ← NEW #2464 cluster B - 6. Pipeline rows → .mappings().all() - 7. Centroid lat/lon → .mappings().first() - 8. Noise rows → .mappings().all() - 9. Hydrology → .mappings().all() - 10. Utilities → .mappings().all() - 11. parcel_meta (cad_parcels) → .mappings().first() ← NEW #29 G2 - 12. Market trend → .mappings().first() - 13. Zoning (begin_nested) → .mappings().first() - 14. Success recommendation (begin_nested) → .mappings().all() - 15. Market price (begin_nested) → .mappings().first() - 16. Recent permits (begin_nested) → .mappings().all() - 17. _geotech_risk (industrial count) → .scalar() - 18. _neighbors_summary (single statement, neighbors+overlap+total) → .mappings().first() +Диспетчеризация мока — ПО СИГНАТУРЕ SQL, а не по порядковому номеру вызова. +Позиционный вариант ломался при каждом добавлении запроса в analyze_parcel +(следы в истории: #29 G2, #2464 cluster B, затем #2464-G с запросом покрытия +шумовой карты). Такое падение говорит не о parcel_meta, ради которого написан +файл, а о том, что кто-то добавил блок выше по коду — красный CI без отношения +к предмету теста. Матчинг по сигнатуре к порядку нечувствителен; всё, что не +распознано, отдаёт пустой результат (обработчик это переживает — прочие блоки +обёрнуты в try/except SAVEPOINT). """ from __future__ import annotations @@ -75,46 +63,32 @@ def _make_db_for_analyze( pm_mock = _make_mapping(parcel_meta_row) if parcel_meta_row is not None else None - call_idx = [0] - responses: list[Any] = [ - ("first", geom_row), # 0: geom UNION ALL - ("first", wkt_row), # 1: WKT - ("first", district_row), # 2: district - ("all", []), # 3: POI rows - ("all", []), # 4: competitor rows - ("scalar", 0), # 5: competitors_total honest COUNT(*) ← NEW #2464 cluster B - ("all", []), # 6: pipeline rows - ("first", centroid_row), # 7: centroid - ("all", []), # 8: noise rows - ("all", []), # 9: hydrology rows - ("all", []), # 10: utilities rows - ("first", pm_mock), # 11: parcel_meta ← #29 G2 - ("first", None), # 12: market trend - ("first", None), # 13: zoning (begin_nested) - ("all", []), # 14: success recommendation (begin_nested) - ("first", None), # 15: market price (begin_nested) - ("all", []), # 16: recent permits (begin_nested) - ("scalar", 0), # 17: geotech_risk - # 18: _neighbors_summary — ОДИН statement (neighbors + overlap + total через - # json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B) → .mappings().first() - # возвращает ОДНУ строку с этими тремя ключами. - ("first", {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}), - ] - def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock: - idx = call_idx[0] - call_idx[0] += 1 - if idx >= len(responses): - r = MagicMock() - r.mappings.return_value.first.return_value = None - r.mappings.return_value.all.return_value = [] - r.scalar.return_value = 0 - return r - kind, data = responses[idx] + sql = " ".join(str(args[0]).split()) if args else "" + + first_val: Any = None + all_val: list[Any] = [] + + if "AS geom_geojson" in sql: + first_val = geom_row + elif "AS wkt" in sql: + first_val = wkt_row + elif "AS ekb_reference_median" in sql and "district_name" in sql: + first_val = district_row + elif "AS lon" in sql and "AS lat" in sql: + first_val = centroid_row + # Предмет файла: parcel_meta из cad_parcels (#29 G2). + elif "AS permitted_use" in sql and "FROM cad_parcels" in sql: + first_val = pm_mock + # _neighbors_summary — ОДИН statement (neighbors + overlap + total через + # json_agg + neighbors_total CTE, PR #1130 / #2464 cluster B). + elif "neighbors_total_count" in sql or "json_agg" in sql: + first_val = {"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0} + r = MagicMock() - r.mappings.return_value.first.return_value = data - r.mappings.return_value.all.return_value = data if isinstance(data, list) else [] - r.scalar.return_value = data if kind == "scalar" else 0 + r.mappings.return_value.first.return_value = first_val + r.mappings.return_value.all.return_value = all_val + r.scalar.return_value = 0 return r db.execute.side_effect = _execute_side_effect diff --git a/backend/tests/api/v1/test_insights.py b/backend/tests/api/v1/test_insights.py index ad202afd..c84cac60 100644 --- a/backend/tests/api/v1/test_insights.py +++ b/backend/tests/api/v1/test_insights.py @@ -215,9 +215,7 @@ def test_list_insights_filter_confidential_false() -> None: listing = InsightList(total=0, limit=50, offset=0, rows=[]) with patch("app.api.v1.insights.list_insights", return_value=listing) as mock_list: client = TestClient(app) - resp = client.get( - "/api/v1/insights", params={"is_confidential": "false"}, headers=_AUTH - ) + resp = client.get("/api/v1/insights", params={"is_confidential": "false"}, headers=_AUTH) assert resp.status_code == 200, resp.text assert mock_list.call_args.kwargs["is_confidential"] is False @@ -261,9 +259,7 @@ def test_put_insight_returns_updated() -> None: def test_put_insight_not_found_returns_404() -> None: with patch("app.api.v1.insights.update_insight", return_value=None): client = TestClient(app) - resp = client.put( - "/api/v1/insights/999", json={"title": "x"}, headers=_AUTH - ) + resp = client.put("/api/v1/insights/999", json={"title": "x"}, headers=_AUTH) assert resp.status_code == 404, resp.text diff --git a/backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py b/backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py index f435dc5a..ae211c57 100644 --- a/backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py +++ b/backend/tests/api/v1/test_market_pulse_and_neighbors_honesty.py @@ -65,9 +65,9 @@ class TestBuildMarketPulseHonesty: ) assert pulse["competitors_total"] == true_total - assert pulse["competitors_total"] != len( - rows - ), "regression guard: competitors_total НЕ должен деградировать до len(competitor_rows)" + assert pulse["competitors_total"] != len(rows), ( + "regression guard: competitors_total НЕ должен деградировать до len(competitor_rows)" + ) def test_coverage_pct_computed_against_true_total_not_capped_list(self) -> None: """coverage_pct = priced / TRUE total — раньше делилось на len(rows) (капнутый @@ -182,9 +182,9 @@ class TestNeighborsSummaryHonesty: summary = parcels_module._neighbors_summary(db, "POINT(60.6 56.8)", "66:41:0000000:999") assert summary["count_buildings_100m"] == true_total - assert summary["count_buildings_100m"] != len( - neighbors - ), "regression guard: count_buildings_100m НЕ должен деградировать до len(neighbor_rows)" + assert summary["count_buildings_100m"] != len(neighbors), ( + "regression guard: count_buildings_100m НЕ должен деградировать до len(neighbor_rows)" + ) assert summary["neighbors_truncated"] is True def test_neighbors_list_itself_unaffected_by_count_fix(self) -> None: diff --git a/backend/tests/api/v1/test_own_projects.py b/backend/tests/api/v1/test_own_projects.py index 06e67314..1a791839 100644 --- a/backend/tests/api/v1/test_own_projects.py +++ b/backend/tests/api/v1/test_own_projects.py @@ -77,9 +77,7 @@ def _make_out( def test_create_own_project_returns_201_and_sets_created_by() -> None: """POST → 201; created_by берётся из X-Authenticated-User, не из тела.""" expected = _make_out() - with patch( - "app.api.v1.own_projects.create_own_project", return_value=expected - ) as mock_create: + with patch("app.api.v1.own_projects.create_own_project", return_value=expected) as mock_create: client = TestClient(app) resp = client.post( "/api/v1/own-projects", @@ -106,9 +104,7 @@ def test_create_own_project_with_unit_mix() -> None: """unit_mix в теле → пробрасывается в payload сервиса.""" mix = {"studio": 0.3, "1k": 0.4, "2k": 0.2, "3k": 0.1} expected = _make_out(unit_mix=mix) - with patch( - "app.api.v1.own_projects.create_own_project", return_value=expected - ) as mock_create: + with patch("app.api.v1.own_projects.create_own_project", return_value=expected) as mock_create: client = TestClient(app) resp = client.post( "/api/v1/own-projects", @@ -183,9 +179,7 @@ def test_create_own_project_without_auth_header_returns_401() -> None: def test_list_own_projects_returns_envelope() -> None: """GET → OwnPlannedProjectList {total, limit, offset, rows}.""" - listing = OwnPlannedProjectList( - total=2, limit=50, offset=0, rows=[_make_out(1), _make_out(2)] - ) + listing = OwnPlannedProjectList(total=2, limit=50, offset=0, rows=[_make_out(1), _make_out(2)]) with patch("app.api.v1.own_projects.list_own_projects", return_value=listing): client = TestClient(app) resp = client.get("/api/v1/own-projects", headers=_AUTH) @@ -199,9 +193,7 @@ def test_list_own_projects_returns_envelope() -> None: def test_list_own_projects_passes_filters_to_service() -> None: """Фильтры district/obj_class/created_by → в сервис как kwargs.""" listing = OwnPlannedProjectList(total=0, limit=50, offset=0, rows=[]) - with patch( - "app.api.v1.own_projects.list_own_projects", return_value=listing - ) as mock_list: + with patch("app.api.v1.own_projects.list_own_projects", return_value=listing) as mock_list: client = TestClient(app) resp = client.get( "/api/v1/own-projects", @@ -240,9 +232,7 @@ def test_put_own_project_returns_updated() -> None: updated = _make_out(name="Переименовано") with patch("app.api.v1.own_projects.update_own_project", return_value=updated): client = TestClient(app) - resp = client.put( - "/api/v1/own-projects/1", json={"name": "Переименовано"}, headers=_AUTH - ) + resp = client.put("/api/v1/own-projects/1", json={"name": "Переименовано"}, headers=_AUTH) assert resp.status_code == 200, resp.text assert resp.json()["name"] == "Переименовано" diff --git a/backend/tests/api/v1/test_parcel_competitors.py b/backend/tests/api/v1/test_parcel_competitors.py index d49138ab..1e3c0bfd 100644 --- a/backend/tests/api/v1/test_parcel_competitors.py +++ b/backend/tests/api/v1/test_parcel_competitors.py @@ -449,9 +449,9 @@ def test_competitors_avg_price_populated() -> None: ) assert resp.status_code == 200, resp.text comp = resp.json()["competitors"][0] - assert comp["avg_price_per_m2"] == pytest.approx( - 150_000.0 - ), "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter" + assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), ( + "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter" + ) # OBJ-3 #307: domrf-hit → price_source='domrf'. assert comp["price_source"] == "domrf" finally: @@ -705,9 +705,9 @@ def test_sold_count_sql_is_fanout_safe() -> None: objective_lot_id). """ sql = _sold_sql_text() - assert ( - "COUNT(DISTINCT objective_lot_id)" in sql - ), "fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)" + assert "COUNT(DISTINCT objective_lot_id)" in sql, ( + "fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)" + ) # COUNT(*) допустим внутри как агрегат? нет — sold-count агрегирует только distinct lot. assert "COUNT(*)" not in sql, "COUNT(*) задвоит лоты при 1:N маппинге" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a3266cb6..5276e7b2 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -96,15 +96,24 @@ def pytest_sessionfinish(session, exitstatus) -> None: unlisted = sorted(_observed_skips - _allowed_skips()) if not unlisted: return - print( - f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не " + 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..49dde2cc 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 @@ -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/ops/__init__.py b/backend/tests/ops/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py b/backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py new file mode 100644 index 00000000..b944536b --- /dev/null +++ b/backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py @@ -0,0 +1,160 @@ +"""Regression: verify_dump_integrity() больше не удаляет валидные дампы (#2203). + +Что произошло. В обоих бэкап-скриптах (`ops/backup.sh`, +`tradein-mvp/deploy/backup-tradein-db.sh`) проверка трейлера была: + + gunzip -c "$file" | tail -5 | grep -qF "$trailer" + +`$trailer` — это `"-- PostgreSQL database dump complete"` (и `... cluster dump +complete` для globals). Строка начинается с `--`, а GNU grep трактует ведущие +`--` в аргументе как конец списка опций / саму опцию — без разделителя `--` +перед паттерном grep падает: + + grep: unrecognized option '-- PostgreSQL database dump complete' + +Проверка ВСЕГДА возвращала «трейлера нет» — не потому что дамп оборван, а +потому что сама grep-команда не может выполниться. Вызывающий код удалял +только что созданный ВАЛИДНЫЙ дамп и завершался с ошибкой; ретеншен не +успевал отработать (ранний exit) — свежие бэкапы не создавались никогда, +старые копии оставались молча. + +Воспроизведено вручную на проде: `bash +/opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh` удалил свежий дамп с +сообщением «дамп оборван?». + +Фикс — `grep -qF -- "$trailer"`: `--` явно завершает опции grep, дальше +только позиционные аргументы, ведущие `--` в самом трейлере больше не путают +grep с флагом. + +ПОЧЕМУ ЭТОТ КЛАСС БАГОВ НЕ ПОЙМАЛИ РАНЬШЕ: ни один тест не исполнял +verify_dump_integrity() на реальном gzip-потоке — только читали/ревьюили +исходник глазами, а `grep -qF "текст, начинающийся с --"` выглядит +безобидно, пока не запущен. Тест ниже исполняет РЕАЛЬНУЮ функцию +verify_dump_integrity(), извлечённую из обоих скриптов (не копию, не +пересказ), через ту же связку `gunzip -c | tail -5 | grep`, что и в проде — +регресс (пропажа `--`) уронит его немедленно. +""" + +from __future__ import annotations + +import gzip +import shutil +import subprocess +from pathlib import Path + +import pytest + +# backend/tests/ops/<этот файл> → корень репозитория +REPO_ROOT = Path(__file__).resolve().parents[3] + +SCRIPTS = { + "ops/backup.sh": "-- PostgreSQL database dump complete", + "tradein-mvp/deploy/backup-tradein-db.sh": "-- PostgreSQL database dump complete", +} + +# `shutil.which`, а не голое "bash" в subprocess.run: на Windows с установленным +# WSL голое имя резолвится Windows-у CreateProcess В СИСТЕМНУЮ ДИРЕКТОРИЮ РАНЬШЕ +# PATH и находит `System32\bash.exe` (лаунчер WSL) вместо Git Bash. Этот лаунчер +# ломает `-c` со скриптом из нескольких `;`-разделённых команд — каждая часть +# выполняется как будто в НОВОЙ оболочке, состояние (переменные, включая +# результат mktemp) между ними не сохраняется. `shutil.which` ищет по PATH как +# обычно и находит настоящий Git Bash, где всё работает штатно. +BASH = shutil.which("bash") +if BASH is None: # pragma: no cover - тестовое окружение без bash не запустит эти тесты + pytest.skip("bash не найден в PATH — тест требует shell-исполнения", allow_module_level=True) + + +def _extract_function(script_path: Path) -> str: + """Достаёт тело verify_dump_integrity() из файла — не весь скрипт. + + Весь файл source'ить нельзя: ниже функции в обоих скриптах идёт секция + `# --- run ---` / `mkdir -p "$BACKUP_DIR"` и далее реальный + `docker exec ... pg_dump` — этого мы не хотим исполнять в тесте. + """ + assert script_path.is_file(), f"нет {script_path} — переехал скрипт, гейт ослеп" + lines = script_path.read_text(encoding="utf-8").splitlines() + start = next(i for i, line in enumerate(lines) if line.startswith("verify_dump_integrity() {")) + end = next(i for i in range(start, len(lines)) if lines[i] == "}") + body = "\n".join(lines[start : end + 1]) + assert "grep" in body, f"{script_path}: извлечённое тело не похоже на функцию с grep" + return body + + +def _run_verify(script_rel: str, trailer: str, gz_content: bytes) -> subprocess.CompletedProcess: + """Гоняет РЕАЛЬНУЮ verify_dump_integrity() из скрипта на временном .gz. + + `mktemp`/`cat > "$tmpfile"` внутри bash — не python `tempfile` — чтобы не + протаскивать windows-путь через границу python/bash (локальная разработка + идёт под Git Bash на Windows). + """ + func_src = _extract_function(REPO_ROOT / script_rel) + harness = f""" +set -u +log() {{ :; }} # заглушка — сигнатура log() одна и та же в обоих скриптах +{func_src} +tmpfile=$(mktemp --suffix=.sql.gz) +trap 'rm -f "$tmpfile"' EXIT +cat > "$tmpfile" +verify_dump_integrity "$tmpfile" "$1" "test-dump" +""" + return subprocess.run( + [BASH, "-c", harness, "bash", trailer], + input=gz_content, + capture_output=True, + timeout=10, + ) + + +def _gz(text: str) -> bytes: + return gzip.compress(text.encode("utf-8")) + + +def _stderr(result: subprocess.CompletedProcess) -> str: + return result.stderr.decode("utf-8", errors="replace") + + +@pytest.mark.parametrize("script_rel,trailer", SCRIPTS.items()) +def test_verify_dump_integrity_accepts_valid_dump_with_trailer( + script_rel: str, trailer: str +) -> None: + """Дамп с трейлером последней строкой — валиден (return 0).""" + content = f"CREATE TABLE t (id int);\nINSERT INTO t VALUES (1);\n{trailer}\n" + result = _run_verify(script_rel, trailer, _gz(content)) + assert result.returncode == 0, ( + f"{script_rel}: валидный дамп с трейлером в последних 5 строках отклонён " + f"(rc={result.returncode}). stderr:\n{_stderr(result)}" + ) + + +@pytest.mark.parametrize("script_rel,trailer", SCRIPTS.items()) +def test_verify_dump_integrity_rejects_truncated_dump(script_rel: str, trailer: str) -> None: + """Дамп без трейлера (оборван на записи) — return 1, не 0.""" + content = "CREATE TABLE t (id int);\nINSERT INTO t VALUES (1);\n" # без трейлера + result = _run_verify(script_rel, trailer, _gz(content)) + assert result.returncode == 1, ( + f"{script_rel}: оборванный дамп должен быть отклонён (rc=1), получили " + f"rc={result.returncode}. stderr:\n{_stderr(result)}" + ) + + +@pytest.mark.parametrize("script_rel,trailer", SCRIPTS.items()) +def test_verify_dump_integrity_grep_does_not_choke_on_leading_dashdash( + script_rel: str, trailer: str +) -> None: + """Регресс-гвоздь #2203: grep не должен падать 'unrecognized option'. + + Трейлер начинается с `--`; без `--`-разделителя перед паттерном именно + так и было в проде — grep не мог выполниться, и проверка ВСЕГДА + возвращала «трейлера нет» даже на валидном дампе. + """ + content = f"x\n{trailer}\n" + result = _run_verify(script_rel, trailer, _gz(content)) + stderr = _stderr(result) + assert "unrecognized option" not in stderr, ( + f"{script_rel}: grep споткнулся о ведущие '--' в трейлере — нет " + f"разделителя `--` перед паттерном (#2203). stderr:\n{stderr}" + ) + assert result.returncode == 0, ( + f"{script_rel}: валидный дамп с '--'-трейлером всё ещё отклоняется " + f"(rc={result.returncode}). stderr:\n{stderr}" + ) diff --git a/backend/tests/ops/test_2950_deploy_concurrency_group.py b/backend/tests/ops/test_2950_deploy_concurrency_group.py new file mode 100644 index 00000000..a2e8b519 --- /dev/null +++ b/backend/tests/ops/test_2950_deploy_concurrency_group.py @@ -0,0 +1,130 @@ +"""Гейт: докер-секции обоих прод-деплоев исключают друг друга (#2950). + +Что произошло. Деплой ПТИЦЫ и деплой Trade-In ходят по SSH в один и тот же +докер-демон — стеки gendesign-*, tradein-* и сам forgejo-runner живут на одной +VM. Каждый в конце делает `docker image prune -af`, и прун одного сносит leases +ещё не доехавшего `compose pull` другого: + + unable to lease content: lease does not exist: not found + +20.08 деплой ПТИЦЫ упал за 5 секунд, прод остался на старом коде. + +ЧЕМ ЭТО ЧИНИТСЯ СЕГОДНЯ — host-lock, а НЕ секцией `concurrency`. На Forgejo +10.0.3 (gitea-1.22) workflow-level concurrency не исполняется: после мержа +правки, сводившей обе группы к `deploy-prod`, обе цепочки стартовали на одном +коммите ОДНОВРЕМЕННО (`docker ps` на раннере показал их build-джобы бок о бок). +Секция оставлена как декларация на будущее — она заработает при обновлении +Forgejo, — но выдавать её за действующий механизм нельзя, поэтому проверки +разделены: про лок — обязательные, про группу — про декларацию. + +Разводка групп обратно или пропажа `flock` не ломают ни один прогон CI и не +дают ни одного сигнала: отказ проявится только в следующем совпадении окон двух +мержей. Поэтому инвариант зафиксирован здесь. +""" + +from pathlib import Path + +import pytest +import yaml + +# backend/tests/ops/<этот файл> → корень репозитория +REPO_ROOT = Path(__file__).resolve().parents[3] +WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows" + +PROD_DEPLOYS = ("deploy.yml", "deploy-tradein.yml") + +# Один и тот же путь у обоих — иначе взаимного исключения не выйдет. +LOCK_PATH = "/var/lock/gendesign-docker-deploy.lock" + + +def _text(name: str) -> str: + path = WORKFLOWS / name + assert path.is_file(), f"нет {path} — переехал workflow, гейт ослеп" + return path.read_text() + + +def _deploy_script(name: str) -> str: + """Тело ssh-шага, который реально мутирует докер (в нём есть агрессивный прун). + + Именно этот блок обязан брать лок. Второй ssh-шаг (перезагрузка прокси) + образов не тянет, пруну там нечего портить — от него лок не требуется. + """ + text = _text(name) + marker = "docker image prune -af" + assert marker in text, f"{name}: пропал `{marker}` — гейт опирается на него, перепроверить" + return text + + +@pytest.mark.parametrize("name", PROD_DEPLOYS) +def test_prod_deploy_takes_the_host_lock(name: str) -> None: + """Деплой берёт лок на хосте перед докер-секцией.""" + script = _deploy_script(name) + assert f"exec 9>{LOCK_PATH}" in script, ( + f"{name}: не берёт host-lock {LOCK_PATH}. Без него `docker image prune -af` " + "соседнего деплоя оборвёт наш `compose pull` (#2950)." + ) + assert "flock -w" in script, ( + f"{name}: лок открывается, но не захватывается через `flock -w` — " + "открытый fd сам по себе ничего не исключает" + ) + + +@pytest.mark.parametrize("name", PROD_DEPLOYS) +def test_lock_wait_leaves_a_trace(name: str) -> None: + """Ожидание лока видно в логе — иначе работающий лок ненаблюдаем. + + `flock` при успехе молчит. Если брать лок сразу блокирующим вызовом, отличить + «второй деплой дождался первого» от «они просто разошлись по времени» нельзя — + а это и есть критерий приёмки #2950. Поэтому сначала неблокирующая попытка, и + при занятом локе в лог уходит и факт ожидания, и его длительность. + """ + script = _deploy_script(name) + assert "flock -n" in script, ( + f"{name}: лок берётся сразу блокирующим вызовом — ожидание не попадёт в лог, " + "и проверить, что взаимное исключение сработало, будет нечем" + ) + assert "жду" in script, f"{name}: нет строки об ожидании лока" + assert "с ожидания" in script, ( + f"{name}: не печатается длительность ожидания — без неё непонятно, " + "ждал деплой две секунды или четверть часа" + ) + + +@pytest.mark.parametrize("name", PROD_DEPLOYS) +def test_lock_wait_is_bounded_and_loud(name: str) -> None: + """Ожидание ограничено и провал слышен: молча ждать вечно — не вариант.""" + script = _deploy_script(name) + assert "не дождался лока докер-деплоя" in script, ( + f"{name}: нет внятного сообщения на исчерпание ожидания лока — " + "деплой упадёт с голым кодом возврата flock" + ) + + +def test_both_prod_deploys_use_the_same_lock_path() -> None: + """Путь лока общий. Разные пути = два независимых лока = нет исключения.""" + holders = {name: f"exec 9>{LOCK_PATH}" in _deploy_script(name) for name in PROD_DEPLOYS} + assert all(holders.values()), ( + f"лок берут не все прод-деплои: {holders}. Взаимное исключение работает, " + "только когда ОБА ходят через один и тот же файл." + ) + + +# ── Ниже — про секцию concurrency. Сегодня она НЕ исполняется (см. шапку), ──── +# поэтому это проверки декларации, а не работающего механизма. + + +@pytest.mark.parametrize("name", PROD_DEPLOYS) +def test_prod_deploy_declares_shared_concurrency_group(name: str) -> None: + """Группа объявлена и общая — заработает при обновлении Forgejo. + + Отдельно от лока: если однажды эта секция начнёт исполняться, разведённые + группы снова разрешат параллельный запуск. + """ + conc = yaml.safe_load(_text(name)).get("concurrency") or {} + assert conc.get("group") == "deploy-prod", ( + f"{name}: группа concurrency = {conc.get('group')!r}, ожидалась общая 'deploy-prod'" + ) + assert conc.get("cancel-in-progress") is False, ( + f"{name}: cancel-in-progress должен быть false — отменённый деплой оставляет " + "прод на старом коде ровно так же, как упавший" + ) diff --git a/backend/tests/ops/test_3059_alert_retry.py b/backend/tests/ops/test_3059_alert_retry.py new file mode 100644 index 00000000..071080c1 --- /dev/null +++ b/backend/tests/ops/test_3059_alert_retry.py @@ -0,0 +1,188 @@ +"""Regression: алерт больше не теряется от одного сетевого отказа (#3059). + +Что происходит. Путь Selectel→Telegram теряет соединения. Замер 26.08.2026 с +Poincare, 40 подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.154.167.220: + + успешно 37 из 40, отказов 3 (7.5%) — все TimeoutError + время успешных: min 0.14s, медиана 0.15s, max 0.17s + +Отказы происходят на СТАДИИ ПОДКЛЮЧЕНИЯ (быстрые и стабильные успешные +попытки на фоне редких таймаутов), а не от перегрузки Telegram. Три остальных +дата-центра с Selectel недостижимы вовсе, поэтому запасного адреса нет и +закрепление (#3093) потери убрать не может — оно лишь выбирает единственный +работающий адрес. + +Как это ломало алерты: + +* `ops/uptime-healthcheck.sh` — ОДИН `curl`, дальше `|| log WARN`. Каждый + отказ терял уведомление целиком. Watchdog, который не может дозваться, — + худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс, + что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ + циклом `for attempt in $(seq 1 ...)` — то есть ретраил то, что измеряет, но + не то, чем докладывает. +* `ops/lib-backup.sh` — тоже один `curl`, но с падением в запасной канал + (почта, #3070). Формально алерт не терялся, фактически КАЖДЫЙ транзиентный + таймаут впустую сжигал последнее средство вместо простого переподключения. + +Фикс — цикл из трёх попыток в обеих `notify()`. Не `curl --retry`: семантика +`--max-time` при ретраях зависит от версии curl, а цикл гарантирует таймаут +на КАЖДУЮ попытку и повторяет идиому, уже принятую в uptime-healthcheck.sh. + +Дубль вместо потери — осознанный размен: `sendMessage` не идемпотентен, но +отказ случается ДО отправки запроса, так что повтор почти никогда не +продублирует доставленное. Лишний алерт безвреден, пропущенный — нет. + +ПОЧЕМУ ЭТОТ КЛАСС БАГОВ НЕ ЛОВИЛСЯ: одиночный `curl ... || log WARN` выглядит +безобидно при чтении — «ошибку же логируем». Виден он только замером частоты +отказов канала. Тесты ниже исполняют РЕАЛЬНЫЕ `notify()`, извлечённые из обоих +скриптов, с подставным `curl`, который отказывает заданное число раз, и +считают ФАКТИЧЕСКОЕ число попыток. Возврат к одиночному вызову уронит их +немедленно. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +# backend/tests/ops/<этот файл> → корень репозитория +REPO_ROOT = Path(__file__).resolve().parents[3] + +UPTIME = "ops/uptime-healthcheck.sh" +LIB_BACKUP = "ops/lib-backup.sh" + +# См. подробное обоснование shutil.which в +# backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py: голое "bash" на +# Windows с WSL резолвится в System32\bash.exe и ломает многокомандный `-c`. +BASH = shutil.which("bash") +if BASH is None: # pragma: no cover - окружение без bash не запустит эти тесты + pytest.skip("bash не найден в PATH — тест требует shell-исполнения", allow_module_level=True) + + +def _extract_function(script_rel: str, name: str = "notify") -> str: + """Достаёт тело одной функции из скрипта — не весь файл. + + Весь скрипт source'ить нельзя: uptime-healthcheck.sh ниже функций реально + ходит по прод-URL, а lib-backup.sh рассчитан на вызов из backup.sh. + + Сопоставление точное (`name() {`), иначе `notify` поймал бы + `notify_fallback_mail` — соседнюю функцию в том же файле. + """ + path = REPO_ROOT / script_rel + assert path.is_file(), f"нет {path} — скрипт переехал, гейт ослеп" + lines = path.read_text(encoding="utf-8").splitlines() + head = f"{name}() {{" + start = next((i for i, line in enumerate(lines) if line.startswith(head)), None) + assert start is not None, f"{script_rel}: не нашёл функцию {name}()" + end = next((i for i in range(start + 1, len(lines)) if lines[i] == "}"), None) + assert end is not None, f"{script_rel}: не нашёл конец функции {name}()" + body = "\n".join(lines[start : end + 1]) + assert "curl" in body, f"{script_rel}: извлечённое тело {name}() не содержит curl" + return body + + +# Заглушка curl: считает вызовы и отказывает первые $FAIL_TIMES раз. +# Код 28 — curl'овский "operation timeout", ровно то, что наблюдалось в замере. +_HARNESS = r""" +set -u +stubdir=$(mktemp -d) +trap 'rm -rf "$stubdir"' EXIT +export COUNTER="$stubdir/calls" +echo 0 > "$COUNTER" +export FAIL_TIMES=@@FAIL_TIMES@@ + +cat > "$stubdir/curl" <<'STUB' +#!/usr/bin/env bash +n=$(cat "$COUNTER") +n=$((n + 1)) +echo "$n" > "$COUNTER" +if [ "$n" -le "$FAIL_TIMES" ]; then exit 28; fi +exit 0 +STUB +chmod +x "$stubdir/curl" +export PATH="$stubdir:$PATH" + +log() { echo "LOG: $*" >&2; } +notify_fallback_mail() { echo "FALLBACK_MAIL_CALLED" >&2; } + +TELEGRAM_BOT_TOKEN=stub-token +TELEGRAM_CHAT_ID=stub-chat +CURL_TIMEOUT=1 +NOTIFY_RETRY_DELAY=0 + +@@FUNC@@ + +notify "тестовый алерт" +echo "RC=$?" +echo "CURL_CALLS=$(cat "$COUNTER")" +""" + + +def _run_notify(script_rel: str, fail_times: int) -> tuple[int, str, str]: + """Гоняет РЕАЛЬНУЮ notify() из скрипта с curl, падающим fail_times раз. + + Возвращает (сколько раз позван curl, stdout, stderr). + """ + harness = _HARNESS.replace("@@FUNC@@", _extract_function(script_rel)).replace( + "@@FAIL_TIMES@@", str(fail_times) + ) + proc = subprocess.run([BASH, "-c", harness], capture_output=True, timeout=30) + out = proc.stdout.decode("utf-8", errors="replace") + err = proc.stderr.decode("utf-8", errors="replace") + calls = next( + (int(line.split("=", 1)[1]) for line in out.splitlines() if line.startswith("CURL_CALLS=")), + -1, + ) + assert calls >= 0, f"харнесс не отработал.\nstdout:\n{out}\nstderr:\n{err}" + return calls, out, err + + +@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP]) +def test_transient_failure_is_retried_not_lost(script_rel: str) -> None: + """Один отказ — алерт всё равно доставляется со второй попытки. + + Это ядро регресса: до фикса curl звался РОВНО ОДИН раз и первый же + таймаут (7.5% по замеру) означал потерю уведомления. + """ + calls, out, err = _run_notify(script_rel, fail_times=1) + assert calls == 2, f"{script_rel}: ожидалась повторная попытка, а curl позван {calls} раз" + assert "RC=0" in out, f"{script_rel}: notify() должна вернуть успех.\nstderr:\n{err}" + assert "НЕ ДОСТАВЛЕН" not in err, f"{script_rel}: доставленный алерт помечен потерянным" + + +@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP]) +def test_gives_up_after_three_attempts(script_rel: str) -> None: + """Повторы ограничены: три попытки, а не бесконечный цикл. + + Верхняя граница важна не меньше нижней — hourly-крон не должен зависать + на недоступном Telegram. + """ + calls, _out, _err = _run_notify(script_rel, fail_times=99) + assert calls == 3, f"{script_rel}: ожидалось ровно 3 попытки, а curl позван {calls} раз" + + +def test_uptime_reports_undelivered_alert_loudly() -> None: + """Когда все три попытки провалились — это видно в логе, а не молча.""" + _calls, _out, err = _run_notify(UPTIME, fail_times=99) + assert "НЕ ДОСТАВЛЕН" in err, f"недоставленный алерт должен логироваться громко.\n{err}" + + +def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None: + """Транзиентный таймаут не должен трогать запасной канал. + + Почта (#3070) — последнее средство на случай, когда Telegram недоступен + ПО-НАСТОЯЩЕМУ. До фикса её дёргал каждый пропущенный SYN. + """ + _calls, _out, err = _run_notify(LIB_BACKUP, fail_times=1) + assert "FALLBACK_MAIL_CALLED" not in err, "запасной канал сожжён на одном транзиентном отказе" + + +def test_backup_falls_back_to_mail_when_telegram_is_really_down() -> None: + """А когда Telegram действительно недоступен — почта всё-таки уходит.""" + _calls, _out, err = _run_notify(LIB_BACKUP, fail_times=99) + assert err.count("FALLBACK_MAIL_CALLED") == 1, ( + f"после трёх отказов ожидался ровно один вызов запасного канала.\n{err}" + ) diff --git a/backend/tests/ops/test_3078_alert_topic.py b/backend/tests/ops/test_3078_alert_topic.py new file mode 100644 index 00000000..67bf2766 --- /dev/null +++ b/backend/tests/ops/test_3078_alert_topic.py @@ -0,0 +1,114 @@ +"""Алерты можно адресовать в топик форумной группы (#3078). + +Зачем. Бот, которым шлются тревоги, — тот же, что пересылает сообщения +поддержки, а его чат форумный. Без `message_thread_id` Alertmanager кладёт +тревоги в общую тему, вперемешку с клиентской перепиской. + +Поле поддерживается: проверено `amtool check-config` на том же образе, что +поднимается в проде (`prom/alertmanager:v0.28.0`) — конфиг с +`message_thread_id: 42` принимается. Схема Alertmanager строгая и неизвестные +поля отвергает, так что успешная проверка означает именно поддержку поля. + +Почему подставляется ЦЕЛАЯ СТРОКА, а не значение. `envsubst` не умеет условий. +Если бы в шаблоне стояло `message_thread_id: ${METRICS_TELEGRAM_TOPIC_ID}`, то +при незаданном топике в конфиг попало бы `message_thread_id:` без значения — +и Alertmanager не стартовал бы вовсе. А это не деградация, а полное исчезновение +алертинга: контейнер просто не поднимется. Поэтому деплой формирует либо всю +строку с отступом, либо пустую. + +Тесты рендерят шаблон обоими способами и разбирают результат как YAML — +проверяется фактический конфиг, а не наличие нужных слов в тексте. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml", reason="PyYAML нужен для разбора конфига") + +REPO_ROOT = Path(__file__).resolve().parents[3] +TMPL = REPO_ROOT / "ops" / "metrics" / "alertmanager" / "alertmanager.yml.tmpl" +WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml" + +TOPIC_LINE = " message_thread_id: 42" + + +def _render(topic_line: str) -> dict: + """Повторяет подстановку деплоя и разбирает результат как YAML.""" + assert TMPL.is_file(), f"нет {TMPL} — шаблон переехал, гейт ослеп" + text = TMPL.read_text(encoding="utf-8") + rendered = ( + text.replace("${METRICS_TELEGRAM_BOT_TOKEN}", "123:ABC") + .replace("${METRICS_TELEGRAM_CHAT_ID}", "-100123") + .replace("${METRICS_TELEGRAM_TOPIC_LINE}", topic_line) + ) + return yaml.safe_load(rendered) + + +def _telegram_configs(cfg: dict) -> list[dict]: + out = [] + for r in cfg.get("receivers", []): + out.extend(r.get("telegram_configs", []) or []) + assert out, "в конфиге не нашлось ни одного telegram_configs" + return out + + +def test_topic_lands_in_every_telegram_receiver() -> None: + """Топик проставляется во ВСЕХ получателях, а не только в основном. + + Получателей два — `telegram` и `telegram-heartbeat`. Если heartbeat уйдёт + в общую тему, «мониторинг жив» будет капать мимо, и это заметят не сразу. + """ + cfgs = _telegram_configs(_render(TOPIC_LINE)) + assert len(cfgs) >= 2, f"ожидалось минимум два получателя telegram, найдено {len(cfgs)}" + for c in cfgs: + assert c.get("message_thread_id") == 42, f"топик не проставлен: {c}" + + +def test_without_topic_field_is_absent_not_empty() -> None: + """Без топика поля нет вовсе — не пустое значение. + + Ядро регресса: `message_thread_id:` без значения уронил бы Alertmanager, + то есть выключил бы алертинг целиком, а не «просто отправил бы в общую тему». + """ + cfgs = _telegram_configs(_render("")) + for c in cfgs: + assert "message_thread_id" not in c, f"поле осталось при незаданном топике: {c}" + assert c.get("chat_id") == -100123, "chat_id пострадал при пустой подстановке" + + +def test_deploy_computes_whole_line_and_passes_it_to_envsubst() -> None: + """Деплой формирует строку целиком и объявляет переменную в envsubst. + + `envsubst` подставляет ТОЛЬКО перечисленные ему переменные. Забыть добавить + новую в список — значит оставить в готовом конфиге литерал + `${METRICS_TELEGRAM_TOPIC_LINE}`, на котором Alertmanager не стартует. + """ + assert WORKFLOW.is_file(), f"нет {WORKFLOW} — воркфлоу переехал, гейт ослеп" + text = WORKFLOW.read_text(encoding="utf-8") + + assert "METRICS_TELEGRAM_TOPIC_ID" in text, "деплой не читает переменную топика" + assert re.search( + r"METRICS_TELEGRAM_TOPIC_LINE=\"\s+message_thread_id: \$\{METRICS_TELEGRAM_TOPIC_ID\}\"", + text, + ), "строка топика собирается не целиком — при пустом значении конфиг сломается" + + envsubst = re.search(r"envsubst '([^']+)'", text) + assert envsubst, "не нашёл вызов envsubst" + assert "${METRICS_TELEGRAM_TOPIC_LINE}" in envsubst.group(1), ( + "переменная топика не объявлена в envsubst — в конфиг попадёт литерал плейсхолдера" + ) + + +def test_config_is_validated_before_stack_comes_up() -> None: + """Конфиг проверяется до подъёма — как Caddyfile. + + Битый Alertmanager не деградирует, а не стартует: алертинг исчезает молча. + """ + text = WORKFLOW.read_text(encoding="utf-8") + assert "amtool" in text and "check-config" in text, ( + "нет проверки конфига Alertmanager перед подъёмом стека" + ) diff --git a/backend/tests/ops/test_3078_exporter_profiles.py b/backend/tests/ops/test_3078_exporter_profiles.py new file mode 100644 index 00000000..318fa0bd --- /dev/null +++ b/backend/tests/ops/test_3078_exporter_profiles.py @@ -0,0 +1,89 @@ +"""Regression: обязательная переменная чужой роли не роняет агента (#3078). + +Что произошло. `docker-compose.metrics-agent.yml` объявлял DSN экспортеров через +`${VAR:?...}` — «обязательна, иначе ошибка». Экспортеры при этом разложены по +профилям: два продуктовых в `apps`, инфраструктурный в `infra`. + +Compose интерполирует **весь файл до фильтрации по профилям**. Поэтому на +продуктовом хосте (`COMPOSE_PROFILES=apps`) команда падала на переменной +сервиса, который там не поднимается вовсе: + + error while interpolating services.postgres-exporter-infra.environment. + DATA_SOURCE_NAME: required variable INFRA_EXPORTER_DSN is missing a value + +Джоба `agent-apps` падала целиком — вместе с alloy, node-exporter и cadvisor, +которым никакой DSN не нужен. Симметрично упал бы и инфраструктурный агент, на +двух продуктовых переменных. + +Фикс — `:-` в compose (интерполяция больше не может упасть) плюс проверка в +`deploy-metrics.yml`, где роль ИЗВЕСТНА: профиль экспортеров включается, только +если нужные этому хосту DSN реально заполнены, иначе `::warning` и агент всё +равно поднимается без экспортера. + +Почему это не «ослабление»: пустой `DATA_SOURCE_NAME` поднял бы экспортер, +который молча не отдаёт метрик, — тот самый тихий отказ, ради которого весь +стек и заводится. Громкость не убрана, а перенесена туда, где известно, какая +переменная нужна. + +Тесты ниже структурные: проверяют оба конца инварианта — что обязательности не +вернулись в compose и что каждый профиль-гейт реально стоит в деплое. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +# backend/tests/ops/<этот файл> → корень репозитория +REPO_ROOT = Path(__file__).resolve().parents[3] +AGENT_COMPOSE = REPO_ROOT / "docker-compose.metrics-agent.yml" +WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml" + + +def test_no_required_var_syntax_on_profile_gated_services() -> None: + """Ни один DSN экспортера не объявлен обязательным через `:?`. + + Ядро регресса: `:?` у сервиса под профилем роняет ЛЮБУЮ compose-команду на + хосте другой роли, потому что интерполяция идёт до фильтрации. + """ + assert AGENT_COMPOSE.is_file(), f"нет {AGENT_COMPOSE} — файл переехал, гейт ослеп" + text = AGENT_COMPOSE.read_text(encoding="utf-8") + + offenders = re.findall(r"\$\{([A-Z_]*EXPORTER_DSN):\?", text) + assert not offenders, ( + f"обязательные переменные вернулись: {offenders}. " + "Compose интерполирует весь файл до профилей — это уронит агента чужой роли." + ) + + +def test_every_exporter_dsn_is_referenced_by_a_profile_gate_in_deploy() -> None: + """Каждая DSN-переменная из compose проверяется в деплое перед включением профиля. + + Обратный конец инварианта: раз обязательность убрали из compose, она обязана + быть в деплое — иначе экспортер поднимется с пустым DSN и замолчит. + """ + assert WORKFLOW.is_file(), f"нет {WORKFLOW} — воркфлоу переехал, гейт ослеп" + compose_text = AGENT_COMPOSE.read_text(encoding="utf-8") + wf_text = WORKFLOW.read_text(encoding="utf-8") + + dsn_vars = set(re.findall(r"\$\{([A-Z_]*EXPORTER_DSN)[:\-}]", compose_text)) + assert dsn_vars, "не нашёл ни одной DSN-переменной — изменился синтаксис compose" + + for var in sorted(dsn_vars): + assert var in wf_text, ( + f"{var} используется в compose, но нигде не проверяется в deploy-metrics.yml — " + "экспортер поднимется с пустым DATA_SOURCE_NAME и молча не отдаст метрик" + ) + + +def test_profiles_are_computed_not_hardcoded() -> None: + """`COMPOSE_PROFILES` берётся из вычисленной переменной, а не зашит строкой. + + Захардкоженный `COMPOSE_PROFILES=apps` включает экспортеры безусловно и + обходит проверку DSN выше. + """ + wf_text = WORKFLOW.read_text(encoding="utf-8") + hardcoded = re.findall(r"COMPOSE_PROFILES=(apps|infra)\b", wf_text) + assert not hardcoded, ( + f"жёстко заданные профили: {hardcoded} — они обходят проверку заполненности DSN" + ) diff --git a/backend/tests/ops/test_3078_metrics_role_discovery.py b/backend/tests/ops/test_3078_metrics_role_discovery.py new file mode 100644 index 00000000..8d26bbfc --- /dev/null +++ b/backend/tests/ops/test_3078_metrics_role_discovery.py @@ -0,0 +1,181 @@ +"""Regression: привилегированная роль ищется у контейнера, а не угадывается (#3078). + +Что произошло. `scripts/setup-metrics-grafana-role.sh` заводит read-only роль +для датасорса Grafana и для этого ищет роль с правом CREATE ROLE. Искал он её +перебором трёх имён: + + for candidate in glitchtip forgejo postgres; do + +Ни одно из трёх не совпадает ни с одним реальным кластером проекта — имя роли +задаётся переменной `POSTGRES_USER` образа postgres и у нас везде своё: + + gendesign-infra-postgres → infra + gendesign-postgres-1 → gendesign + tradein-postgres → tradein + +Поэтому деплой стека наблюдаемости падал на первом же прогоне после мержа #3099 +(задача 23657, 26.08 08:55): + + err: ОШИБКА: не нашёл роль с правом CREATE ROLE в gendesign-infra-postgres + +и вместе с ним пропускались зависимые джобы `agent-apps` / `agent-infra` — ни +одного контейнера стека не поднялось ни на одном хосте. + +Замер на живом контейнере 26.08 (read-only, ничего не создавалось): + + POSTGRES_USER изнутри контейнера: infra + кандидат glitchtip → отказ + кандидат forgejo → отказ + кандидат postgres → отказ + кандидат infra → 1 + +Фикс — спросить контейнер вместо угадывания: `POSTGRES_USER` это ровно та +переменная, которой роль создана при initdb, то есть источник истины. Прежний +список оставлен ПОСЛЕ него запасным путём — он пригодится кластеру, поднятому +не из образа postgres, где переменная пуста. + +ИРОНИЯ, РАДИ КОТОРОЙ ЭТОТ ТЕСТ: в комментарии над самим перебором было написано +«угадывать «postgres» неверно» — и дальше шло угадывание. Тест ниже исполняет +РЕАЛЬНЫЙ скрипт с подставным `docker` и проверяет фактический выбор роли, а не +наличие правильных слов в комментарии. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +# backend/tests/ops/<этот файл> → корень репозитория +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / "scripts" / "setup-metrics-grafana-role.sh" + +# См. обоснование shutil.which в test_2203_backup_trailer_grep_dashdash.py: +# голое "bash" на Windows с WSL резолвится в System32\bash.exe. +BASH = shutil.which("bash") +if BASH is None: # pragma: no cover - окружение без bash не запустит эти тесты + pytest.skip("bash не найден в PATH — тест требует shell-исполнения", allow_module_level=True) + +# Подставной `docker`: отвечает за контейнер, отдаёт заданный POSTGRES_USER и +# принимает psql только от роли PRIV_ROLE. Код 28 не нужен — psql на отказе +# просто выходит ненулём, как в проде при неверной роли. +_DOCKER_STUB = r"""#!/usr/bin/env bash +case "$1" in + inspect) exit 0 ;; + exec) + args="$*" + case "$args" in + *"printf %s"*) printf '%s' "${PG_USER_STUB:-}"; exit 0 ;; + esac + u=""; prev="" + for a in "$@"; do + if [ "$prev" = "-U" ]; then u="$a"; break; fi + prev="$a" + done + case "$args" in + *-tAc*) + if [ "$u" = "${PRIV_ROLE:-}" ]; then echo 1; exit 0; fi + exit 1 ;; + *) + cat >/dev/null 2>&1 || true + if [ "$u" = "${PRIV_ROLE:-}" ]; then exit 0; fi + exit 1 ;; + esac ;; +esac +exit 0 +""" + +_HARNESS = r""" +set -u +stubdir=$(mktemp -d) +cat > "$stubdir/docker" <<'STUB' +@@STUB@@ +STUB +chmod +x "$stubdir/docker" +export PATH="$stubdir:$PATH" +export PG_USER_STUB='@@PG_USER@@' +export PRIV_ROLE='@@PRIV@@' +export GLITCHTIP_RO_PASSWORD='stub-pass' +bash '@@SCRIPT@@' +echo "RC=$?" +""" + + +def _run(pg_user: str, priv_role: str) -> tuple[str, str]: + """Гоняет РЕАЛЬНЫЙ скрипт: контейнер сообщает pg_user, привилегии есть у priv_role.""" + assert SCRIPT.is_file(), f"нет {SCRIPT} — скрипт переехал, гейт ослеп" + harness = ( + _HARNESS.replace("@@STUB@@", _DOCKER_STUB) + .replace("@@PG_USER@@", pg_user) + .replace("@@PRIV@@", priv_role) + .replace("@@SCRIPT@@", SCRIPT.as_posix()) + ) + proc = subprocess.run([BASH, "-c", harness], capture_output=True, timeout=30) + return ( + proc.stdout.decode("utf-8", errors="replace"), + proc.stderr.decode("utf-8", errors="replace"), + ) + + +def test_picks_role_reported_by_container() -> None: + """Прод-случай: роль `infra`, которой нет ни в одном угадываемом имени. + + Ядро регресса — до фикса скрипт перебирал glitchtip/forgejo/postgres, + получал отказ на всех трёх и выходил с ошибкой. + """ + out, err = _run(pg_user="infra", priv_role="infra") + assert "привилегированная роль: infra" in out, f"роль из контейнера не выбрана.\n{out}\n{err}" + assert "RC=0" in out, f"скрипт должен отработать успешно.\nstdout:\n{out}\nstderr:\n{err}" + assert "не нашёл роль" not in err + + +@pytest.mark.parametrize("pg_user", ["gendesign", "tradein"]) +def test_works_for_other_project_clusters(pg_user: str) -> None: + """Два других кластера проекта — их имена тоже не входили в перебор.""" + out, _err = _run(pg_user=pg_user, priv_role=pg_user) + assert f"привилегированная роль: {pg_user}" in out + + +def test_falls_back_to_name_list_when_variable_is_empty() -> None: + """Кластер не из образа postgres: POSTGRES_USER пуст → работает прежний перебор. + + Инвариант: фикс ДОБАВЛЯЕТ источник истины, а не отменяет запасной путь. + """ + out, _err = _run(pg_user="", priv_role="forgejo") + assert "привилегированная роль: forgejo" in out, f"запасной перебор сломан.\n{out}" + + +def test_still_fails_loudly_when_no_role_has_the_right() -> None: + """Когда привилегий нет ни у кого — по-прежнему громкая ошибка, а не тихий успех.""" + out, err = _run(pg_user="nobody", priv_role="__никто__") + assert "не нашёл роль с правом CREATE ROLE" in err, f"ошибка должна остаться громкой.\n{err}" + assert "RC=0" not in out, "скрипт не должен рапортовать успех, не создав роль" + + +def test_every_setup_script_the_workflow_runs_is_also_a_trigger() -> None: + """Скрипт, который деплой запускает, обязан заводить этот же деплой. + + Иначе правка скрипта не вызывает выкат, и на хосте молча остаётся старая + версия — тот же класс, что #2203 закрыл глобом `ops/*.sh`. Конкретно здесь + в `paths:` стоял только `setup-metrics-secrets.sh`, а запускались три + скрипта. + """ + import fnmatch + import re + + wf = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml" + assert wf.is_file(), f"нет {wf} — воркфлоу переехал, гейт ослеп" + text = wf.read_text(encoding="utf-8") + + invoked = set(re.findall(r"bash\s+(scripts/setup-metrics-[\w-]+\.sh)", text)) + assert invoked, "не нашёл ни одного запускаемого setup-скрипта — изменился синтаксис вызова" + + patterns = re.findall(r'^\s+-\s+"([^"]+)"\s*$', text, re.M) + assert patterns, "не нашёл ни одного paths-шаблона" + + for script in sorted(invoked): + assert any(fnmatch.fnmatch(script, p) for p in patterns), ( + f"{script} запускается деплоем, но не входит ни в один шаблон paths: {patterns}" + ) diff --git a/backend/tests/scrapers/test_cbr_macro.py b/backend/tests/scrapers/test_cbr_macro.py index 5b03f0c3..ffffc1e3 100644 --- a/backend/tests/scrapers/test_cbr_macro.py +++ b/backend/tests/scrapers/test_cbr_macro.py @@ -41,7 +41,7 @@ FIXTURE_KEYRATE_XML = ( # где угодно), несмотря на schema-блок и namespace на KR. FIXTURE_KEYRATE_DIFFGRAM = ( '' - "" + '' '' '' diff --git a/backend/tests/scrapers/test_emiss_sdmx.py b/backend/tests/scrapers/test_emiss_sdmx.py index 8b356e1d..6cba33ba 100644 --- a/backend/tests/scrapers/test_emiss_sdmx.py +++ b/backend/tests/scrapers/test_emiss_sdmx.py @@ -129,9 +129,9 @@ def test_income_extracts_sverdlovsk_only() -> None: assert all(r.indicator_type == "income_per_capita" for r in rows) assert all(r.unit == "руб" for r in rows) assert all(r.frequency == "quarterly" for r in rows) - assert all( - r.period_type == "quarter" for r in rows - ), "квартальные строки должны иметь period_type='quarter'" + assert all(r.period_type == "quarter" for r in rows), ( + "квартальные строки должны иметь period_type='quarter'" + ) def test_income_concrete_values_and_dates() -> None: diff --git a/backend/tests/scrapers/test_nspd_bulk_client.py b/backend/tests/scrapers/test_nspd_bulk_client.py index bc57857a..ef93c852 100644 --- a/backend/tests/scrapers/test_nspd_bulk_client.py +++ b/backend/tests/scrapers/test_nspd_bulk_client.py @@ -602,6 +602,6 @@ async def test_list_objects_in_building_real() -> None: assert listing.objdoc_id == 42065602 assert listing.flats_count > 150, f"Ожидали >150 помещений, получили {listing.flats_count}" - assert ( - len(listing.flats_cad_nums) > 150 - ), f"Ожидали >150 cad_nums помещений, получили {len(listing.flats_cad_nums)}" + assert len(listing.flats_cad_nums) > 150, ( + f"Ожидали >150 cad_nums помещений, получили {len(listing.flats_cad_nums)}" + ) diff --git a/backend/tests/scrapers/test_nspd_grid_walk.py b/backend/tests/scrapers/test_nspd_grid_walk.py index 377ba20c..a34bceac 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"}) @@ -282,9 +346,9 @@ class TestClassifyEngineeringKind: ], ) def test_classify(self, props: dict[str, Any], expected: str) -> None: - assert ( - classify_engineering_kind(props) == expected - ), f"props={props!r} → expected {expected!r}" + assert classify_engineering_kind(props) == expected, ( + f"props={props!r} → expected {expected!r}" + ) def test_field_priority_params_name_over_purpose(self) -> None: """params_name проверяется раньше purpose.""" @@ -358,13 +422,13 @@ class TestFetchLayerDispatch: called_layer_ids = [call.args[0] for call in mock_grid.call_args_list] from app.services.scrapers.nspd_client import LAYERS - assert ( - LAYERS["territorial_zones"] in called_layer_ids - ), "territorial_zones должен использовать grid-walk" + assert LAYERS["territorial_zones"] in called_layer_ids, ( + "territorial_zones должен использовать grid-walk" + ) assert LAYERS["red_lines"] in called_layer_ids, "red_lines должен использовать grid-walk" - assert ( - LAYERS["engineering_structures"] in called_layer_ids - ), "engineering_structures должен использовать grid-walk" + assert LAYERS["engineering_structures"] in called_layer_ids, ( + "engineering_structures должен использовать grid-walk" + ) # parcels и buildings — legacy, не grid called_legacy_ids = [call.args[0] for call in mock_legacy.call_args_list] assert LAYERS["parcels"] in called_legacy_ids, "parcels должен идти через legacy" diff --git a/backend/tests/scrapers/test_rosstat_emiss.py b/backend/tests/scrapers/test_rosstat_emiss.py index bb3c2c30..525a2c13 100644 --- a/backend/tests/scrapers/test_rosstat_emiss.py +++ b/backend/tests/scrapers/test_rosstat_emiss.py @@ -42,7 +42,7 @@ CONSTRUCTION_XLSX = (_FIXTURES / "rosstat_construction_stroitel.xlsx").read_byte FIXTURE_META = ( "property,value\n" "standardversion,https://rosstat.gov.ru/storage/.../versiya_4.0.pdf\n" - 'identifier,7708234640-population\n' + "identifier,7708234640-population\n" 'title,"Численность постоянного населения"\n' "created,20180905\n" "modified,20140919\n" @@ -58,7 +58,7 @@ FIXTURE_META = ( # значение в 'total', год в 'year'. Запятая — разделитель полей; значения — целые. FIXTURE_POPULATION_DATA = ( "number,year,kode,region,municipalities,total,urban,rural\n" - '1,2014,7900000000,Республика Адыгея,Муниципальные образования Республики Адыгеи,' + "1,2014,7900000000,Республика Адыгея,Муниципальные образования Республики Адыгеи," "446406,209929,236477\n" "2,2014,6500000000,Свердловская область,Муниципальные образования Свердловской области," "4320677,3500000,820677\n" diff --git a/backend/tests/services/analysis_runs/test_repository_run_history.py b/backend/tests/services/analysis_runs/test_repository_run_history.py index f5201e01..fe54cb16 100644 --- a/backend/tests/services/analysis_runs/test_repository_run_history.py +++ b/backend/tests/services/analysis_runs/test_repository_run_history.py @@ -60,8 +60,16 @@ def test_list_runs_for_light_projection_base_table_order_limit() -> None: # LIGHT: result-блоб НЕ выбирается assert "RESULT" not in upper, "list_runs_for НЕ должен тянуть тяжёлый result" # выбраны именно метаданные - for col in ("ID", "CAD_NUM", "CREATED_AT", "STATUS", "SCHEMA_VERSION", - "DISTRICT", "CONFIDENCE", "CREATED_BY"): + for col in ( + "ID", + "CAD_NUM", + "CREATED_AT", + "STATUS", + "SCHEMA_VERSION", + "DISTRICT", + "CONFIDENCE", + "CREATED_BY", + ): assert col in upper, f"ожидали колонку {col} в LIGHT-проекции" assert "ORDER BY CREATED_AT DESC" in flat assert "LIMIT CAST(:LIMIT AS INTEGER)" in upper diff --git a/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py b/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py new file mode 100644 index 00000000..733ca4df --- /dev/null +++ b/backend/tests/services/cadastre/test_2464_backfill_waf_aborts.py @@ -0,0 +1,116 @@ +"""WAF-блок обязан прерывать backfill_parcel_geom, а не переходить к следующему кварталу (#2464). + +Голый `except Exception` вокруг per-quarter grid-walk ловил и `NspdBulkWafError`, при том что +его собственный комментарий обещал обратное: «WAF 403 пробросится из client и прервёт прогон». +Прервать он не мог — ловил сам себя, и цикл шёл дальше по всем оставшимся кварталам. + +Цена: замер прода 20.08.2026 — `limit=500` участков раскладывается на **174 квартала**, каждый +grid-walk делает 49 запросов. То есть под баном один прогон выдавал бы до ~8500 обращений к уже +блокирующему WAF вместо остановки на первом — ровно то поведение, что углубляет бан. + +Соседний `harvest_quarter` в этом же файле починен предыдущей волной эпика (#2464-A) с тем же +доводом и прод-замером «23 job'а, 50 WAF-блоков, 0 упавших». Здесь то же место пропустили. + +Проверяется ПОВЕДЕНИЕ: сколько кварталов успел тронуть прогон и вышла ли ошибка наружу. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import asyncio +from typing import Any +from unittest.mock import MagicMock, patch + +# Участки из ТРЁХ разных кварталов — чтобы «пошёл дальше» было отличимо от «остановился». +_CADS = [ + "66:41:0000001:1", + "66:41:0000001:2", + "66:41:0000002:1", + "66:41:0000003:1", +] + + +def _db() -> MagicMock: + db = MagicMock() + rows = MagicMock() + rows.scalars.return_value.all.return_value = _CADS + db.execute.return_value = rows + return db + + +def _run(exc: BaseException | None) -> tuple[Any, list[str]]: + """Прогон с подменённым grid-walk. Возвращает (исключение|результат, тронутые кварталы).""" + from app.services.cadastre import bulk_harvest as mod + + touched: list[str] = [] + + async def _fake_grid_walk(*, quarter: str, **_kw: Any): + touched.append(quarter) + if exc is not None: + raise exc + return (0, 1, 0) + + with ( + patch.object(mod, "_grid_walk_category", _fake_grid_walk), + patch.object(mod, "_mark_geom_unavailable", lambda *_a, **_kw: 0), + ): + try: + res = asyncio.run(mod.backfill_parcel_geom(db=_db(), client=MagicMock(), limit=500)) + return res, touched + except BaseException as raised: + return raised, touched + + +def test_waf_block_aborts_the_whole_run() -> None: + """Первый же WAF-блок обязан выйти наружу и остановить обход. + + На origin/main исключение проглатывается: прогон доходит до конца, тронув все + кварталы, и возвращает результат как ни в чём не бывало. + """ + from app.scrapers.nspd_bulk_client import NspdBulkWafError + + outcome, touched = _run(NspdBulkWafError("HTTP 403 WAF block")) + + assert isinstance(outcome, NspdBulkWafError), ( + f"WAF-блок не вышел наружу, прогон вернул {type(outcome).__name__} — " + f"обход продолжился и тронул кварталы: {touched}" + ) + assert len(touched) == 1, ( + f"после WAF-блока тронуто кварталов: {len(touched)} ({touched}) — " + "каждый следующий это ещё 49 запросов в уже закрытую дверь" + ) + + +def test_rate_limit_also_aborts() -> None: + """Исчерпанные ретраи — тоже не «сбойный квартал». Тот же контракт, что у соседа.""" + from app.scrapers.nspd_bulk_client import NspdBulkRateLimitError + + outcome, touched = _run(NspdBulkRateLimitError("429")) + + assert isinstance(outcome, NspdBulkRateLimitError) + assert len(touched) == 1 + + +def test_ordinary_error_still_skips_only_that_quarter() -> None: + """Контроль: обычный сбой по-прежнему пропускает ОДИН квартал и не валит прогон. + + Ловит «починку», которая начала бы обрывать обход на любой ошибке — тогда один + битый квартал стоил бы всей ночной докачки. + """ + outcome, touched = _run(RuntimeError("битый ответ одного квартала")) + + assert not isinstance(outcome, BaseException), ( + f"обычная ошибка обрушила весь прогон: {outcome!r}" + ) + assert len(touched) == 3, f"тронуто кварталов {len(touched)}, ожидалось 3: {touched}" + + +def test_healthy_run_walks_every_quarter() -> None: + """Контроль: без ошибок обход проходит все кварталы — правка ничего не отрезала.""" + outcome, touched = _run(None) + + assert not isinstance(outcome, BaseException) + assert len(touched) == 3, f"тронуто кварталов {len(touched)}, ожидалось 3: {touched}" diff --git a/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py b/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py new file mode 100644 index 00000000..711dec1b --- /dev/null +++ b/backend/tests/services/exporters/test_2464_concept_pdf_sales_window.py @@ -0,0 +1,124 @@ +"""#2464: методическая сноска концепт-PDF обещала не тот срок распродажи. + +В сноске стояло «распродажа 30 мес» — числом, при том что ставка дисконта в той же +строке берётся из расчёта (`variants[0].financial.discount_rate_used`). + +30 месяцев — это ФОЛБЭК `financial._SALES_DURATION_MONTHS`, применяемый только когда +рыночная скорость абсорбции не передана. Иначе окно считается как +`residential_area_sqm / market_velocity` и клампится в [6, 120] мес. То есть читатель +экспортированного документа получал срок, по которому NPV НЕ считался. + +Оба нужных поля давно есть в схеме — `sales_duration_months` и честный флаг +`schedule_is_default`. Отчёт Site Finder их читает (full_report_html.py:1335, +full_report_docx.py:855); игнорировал только этот экспортёр. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +# `_sales_phrase` импортируем ВНУТРИ тестов, а не в шапке: на origin/main её ещё нет, +# и импорт здесь уронил бы СБОРКУ всего модуля — тогда поведенческая проверка внизу +# не исполнилась бы вовсе, а красное означало бы «функции нет», а не «в документе +# не тот срок». + + +def test_market_driven_window_is_reported_as_measured() -> None: + """Окно построено по рынку → в сноске стоит ФАКТИЧЕСКОЕ число, а не 30.""" + from app.services.generative.exporters.pdf import _sales_phrase + + fin = SimpleNamespace(schedule_is_default=False, sales_duration_months=54.0) + + phrase = _sales_phrase(fin) + + assert "54" in phrase + assert "30" not in phrase + assert "рыночной абсорбции" in phrase + + +def test_default_schedule_says_it_is_a_norm() -> None: + """Окно дефолтное → 30 мес, но с явной пометкой «нормативный», а не как факт.""" + from app.services.generative.exporters.pdf import _sales_phrase + + fin = SimpleNamespace(schedule_is_default=True, sales_duration_months=30.0) + + phrase = _sales_phrase(fin) + + assert "30" in phrase + assert "норматив" in phrase + + +def test_missing_window_falls_back_to_the_norm() -> None: + """Контроль: старый сериализованный вариант без поля не роняет экспорт.""" + from app.services.generative.exporters.pdf import _sales_phrase + + assert "30" in _sales_phrase(SimpleNamespace()) + assert "30" in _sales_phrase(SimpleNamespace(schedule_is_default=False)) + + +def test_market_window_is_rounded_not_truncated_to_zero() -> None: + """Дробное окно печатается целым числом месяцев, а не '54.0' и не '0'.""" + from app.services.generative.exporters.pdf import _sales_phrase + + phrase = _sales_phrase(SimpleNamespace(schedule_is_default=False, sales_duration_months=53.6)) + assert "54 мес" in phrase + + +# ── поведенческая проверка на уровне готового документа ──────────────────────── +# +# Проверки выше падают на origin/main с ImportError — там нет самой функции, то есть +# красное значит «возможности нет», а не «в документе не то». Тест ниже красный ПО +# СОДЕРЖИМОМУ: собирает HTML тем же кодом, что уходит в PDF, и требует, чтобы +# фактический срок стоял в сноске. + + +def _variant(*, schedule_is_default: bool, sales_months: float) -> SimpleNamespace: + """Минимальный вариант: только поля, которые читают таблицы и сноска.""" + financial = SimpleNamespace( + discount_rate_used=0.18, + schedule_is_default=schedule_is_default, + sales_duration_months=sales_months, + irr=0.21, + irr_is_proxy=False, + npv_rub=1.0, + payback_months=24.0, + margin_pct=15.0, + cost_rub=1.0, + land_rub=1.0, + pir_rub=1.0, + construction_rub=1.0, + networks_rub=1.0, + marketing_rub=1.0, + developer_services_rub=1.0, + contingency_rub=1.0, + gross_margin_rub=1.0, + profit_before_tax_rub=1.0, + net_profit_rub=1.0, + profit_tax_rub=1.0, + vat_rub=1.0, + revenue_rub=1.0, + revenue_residential_rub=1.0, + revenue_parking_rub=1.0, + revenue_office_rub=1.0, + roi=0.1, + ) + teap = SimpleNamespace( + built_area_sqm=1.0, + total_floor_area_sqm=1.0, + residential_area_sqm=1.0, + apartments_count=1, + parking_spaces=1, + density=1.0, + ) + return SimpleNamespace(strategy="balanced", teap=teap, financial=financial) + + +def test_document_carries_the_measured_window_not_the_norm() -> None: + from app.services.generative.exporters.pdf import _build_html + + html = _build_html([_variant(schedule_is_default=False, sales_months=54.0)]) + + assert "распродажа 54 мес" in html, ( + "в методической сноске стоит не тот срок, по которому посчитан NPV" + ) + assert "распродажа 30 мес," not in html diff --git a/backend/tests/services/exporters/test_2464_html_zoning_fallback.py b/backend/tests/services/exporters/test_2464_html_zoning_fallback.py new file mode 100644 index 00000000..9036e280 --- /dev/null +++ b/backend/tests/services/exporters/test_2464_html_zoning_fallback.py @@ -0,0 +1,90 @@ +"""HTML-экспортёр не должен выбрасывать легаси-зонирование, которое сам же проверил (#2464). + +`_build_zoning` при пустом `nspd_zoning` берёт легаси `result["zoning"]`, проверяет, что +там есть `zone_code`, — и дальше читает поля из так и оставшегося ПУСТЫМ `nspd_zoning`. +То есть данные признаны годными и тут же потеряны: все пары получаются None, отбрасываются +фильтром, и таблица уходит в отчёт без единой строки. + +Соседний экспортёр `full_report_docx._build_zoning` — та же структура слово в слово — эту +ветку закрывает одной строкой `nspd_zoning = zoning`. Здесь её не было. + +Достижимость. Сегодня ветка не срабатывает на проде: легаси-зонирование заполняется из +`pzz_zones_ekb`, а та пуста намеренно — задача `pzz-sync` отключена в beat-расписании, +потому что Росреестр закрыл PKK6 (#233, #259). Так что это латентный дефект, а не +наблюдаемый отказ. Чинится он всё равно: HTML-сборка — основа полного PDF-отчёта +(`full_report_pdf` импортирует `build_full_report_html`), и в день, когда таблицу +наполнят, зонирование молча пропадёт из главного документа. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from typing import Any + +# Легаси-зонирование в той форме, в какой его собирает parcels.py из pzz_zones_ekb. +_LEGACY_ZONING: dict[str, Any] = { + "zone_code": "Ж-4", + "zone_name": "Зона многоэтажной жилой застройки", + "description": "Многоэтажные жилые дома", + "data_available": True, + "source": "rosreestr-pkk6-cached", +} + + +def _zoning_html(result: dict[str, Any]) -> str: + from app.services.exporters.full_report_html import _build_zoning + + return _build_zoning(result) + + +def test_legacy_zoning_reaches_the_table() -> None: + """Код зоны из легаси-источника обязан попасть в таблицу отчёта.""" + out = _zoning_html({"nspd_zoning": None, "zoning": dict(_LEGACY_ZONING)}) + + assert "Ж-4" in out, ( + "код зоны из легаси-зонирования не попал в таблицу: ветка признала данные " + "годными и продолжила читать из пустого nspd_zoning" + ) + assert "Зона многоэтажной жилой застройки" in out, "наименование зоны потеряно" + + +def test_legacy_zoning_does_not_render_as_no_data() -> None: + """Таблица с данными не должна выглядеть как «данных нет». + + Отдельно от предыдущего: даже если код зоны когда-нибудь попадёт в вывод другим + путём, заглушка «нет данных» рядом с реальными данными — сама по себе дефект. + """ + out = _zoning_html({"nspd_zoning": None, "zoning": dict(_LEGACY_ZONING)}) + + from app.services.exporters.full_report_html import _NO_DATA + + assert _NO_DATA not in out, f"в таблице с данными стоит заглушка {_NO_DATA!r}" + + +def test_nspd_zoning_still_wins_when_present() -> None: + """Контроль: при живом nspd_zoning легаси не подмешивается. + + Ловит «починку», которая слила бы два источника: приоритет НСПД должен остаться. + """ + out = _zoning_html( + { + "nspd_zoning": {"zone_code": "ЦС-1", "zone_name": "Зона делового назначения"}, + "zoning": dict(_LEGACY_ZONING), + } + ) + + assert "ЦС-1" in out + assert "Ж-4" not in out, "легаси-зона подмешалась поверх живого nspd_zoning" + + +def test_absent_zoning_still_says_no_data() -> None: + """Контроль: когда данных нет НИГДЕ, отчёт по-прежнему честно говорит об этом.""" + from app.services.exporters.full_report_html import _NO_DATA + + out = _zoning_html({"nspd_zoning": None, "zoning": {"zone_code": None, "note": "PKK6 закрыт"}}) + + assert _NO_DATA in out, "исчезла честная заглушка «данных нет»" + assert "PKK6 закрыт" in out, "потеряна пояснительная сноска к отсутствию данных" diff --git a/backend/tests/services/exporters/test_2464_zouit_count_label.py b/backend/tests/services/exporters/test_2464_zouit_count_label.py new file mode 100644 index 00000000..f2c46189 --- /dev/null +++ b/backend/tests/services/exporters/test_2464_zouit_count_label.py @@ -0,0 +1,116 @@ +"""Подпись к числу ЗОУИТ не должна обещать «типы», когда там записи (#2464). + +`encumbrance.zouit_count` приходит из `parcels.py` как `len(zouit_rows)` — число ЗАПИСЕЙ +`cad_zouit`, пересёкших участок. Типы лежат отдельно, в `zouit_types`. Оба экспортёра +подписывали это число как «Кол-во типов ЗОУИТ». + +Расхождение не редкое: из 1637 разборов с ЗОУИТ в 717 (43.8 %) число записей не равно +числу типов, в среднем завышая «типы» в 1.35 раза (максимум по проду — 7 записей при +3 типах). + +Инвариант проверяется по смыслу, а не сверкой с выбранной строкой: **если подпись обещает +типы, показанное число обязано равняться числу типов**. Такая формулировка переживёт любое +разумное переименование и не даст «починить» тест подгонкой подписи. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import re +from typing import Any + +import pytest + +# Пять записей ЗОУИТ, но всего два различных типа — ровно тот случай, где подпись врала. +_OVERLAPS: list[dict[str, Any]] = [ + {"type_zone": "Охранная зона ЛЭП", "name": "ВЛ-110 №1", "coverage_pct": 12.0}, + {"type_zone": "Охранная зона ЛЭП", "name": "ВЛ-110 №2", "coverage_pct": 8.0}, + {"type_zone": "Охранная зона ЛЭП", "name": "ВЛ-110 №3", "coverage_pct": 3.0}, + {"type_zone": "Приаэродромная территория", "name": "ПАТ подзона 3", "coverage_pct": 100.0}, + {"type_zone": "Приаэродромная территория", "name": "ПАТ подзона 4", "coverage_pct": 100.0}, +] +_RESULT: dict[str, Any] = { + "encumbrance": { + "has_zouit": True, + "zouit_count": 5, + "zouit_types": ["Охранная зона ЛЭП", "Приаэродромная территория"], + }, + "nspd_zouit_overlaps": _OVERLAPS, +} + +_RECORDS = 5 +_TYPES = 2 + + +def _html_pairs() -> list[tuple[str, str]]: + """KV-пары из HTML-блока ЗОУИТ: [(подпись, значение), …].""" + from app.services.exporters.full_report_html import _build_zouit + + out = _build_zouit(dict(_RESULT)) + return [ + (re.sub(r"<[^>]+>", "", k).strip(), re.sub(r"<[^>]+>", "", v).strip()) + for k, v in re.findall(r"\s*(.*?)\s*(.*?)\s*", out, re.S) + ] or [ + (re.sub(r"<[^>]+>", "", k).strip(), re.sub(r"<[^>]+>", "", v).strip()) + for k, v in re.findall( + r"\s*]*>(.*?)\s*]*>(.*?)\s*", out, re.S + ) + ] + + +def _docx_pairs() -> list[tuple[str, str]]: + """KV-пары из DOCX-блока ЗОУИТ.""" + from docx import Document + + from app.services.exporters.full_report_docx import _build_zouit + + doc = Document() + _build_zouit(doc, dict(_RESULT)) + pairs: list[tuple[str, str]] = [] + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells] + if len(cells) == 2: + pairs.append((cells[0], cells[1])) + return pairs + + +@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")]) +def test_label_promising_types_must_show_types(pairs_fn: Any, fmt: str) -> None: + """Подпись со словом «тип» обязана нести число типов, а не записей.""" + pairs = pairs_fn() + assert pairs, f"{fmt}: не разобрал ни одной KV-пары — тест ослеп" + + for label, value in pairs: + if "тип" not in label.lower(): + continue + digits = re.fullmatch(r"\d+", value.strip()) + if not digits: + continue # строка «Типы: A, B» — это перечисление, а не счётчик + assert int(value) == _TYPES, ( + f"{fmt}: подпись {label!r} обещает типы, а показывает {value} — " + f"это число ЗАПИСЕЙ ({_RECORDS}), типов же {_TYPES}" + ) + + +@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")]) +def test_record_count_is_still_shown(pairs_fn: Any, fmt: str) -> None: + """Контроль: само число записей из отчёта не пропало — подпись правится, не значение.""" + pairs = pairs_fn() + counters = [(k, v) for k, v in pairs if re.fullmatch(r"\d+", v.strip())] + assert any(int(v) == _RECORDS for _, v in counters), ( + f"{fmt}: число ЗОУИТ-записей ({_RECORDS}) исчезло из сводки: {counters}" + ) + + +@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")]) +def test_types_are_still_listed(pairs_fn: Any, fmt: str) -> None: + """Контроль: перечисление типов на месте — читатель по-прежнему видит, какие они.""" + pairs = pairs_fn() + joined = " ".join(f"{k} {v}" for k, v in pairs) + assert "Охранная зона ЛЭП" in joined and "Приаэродромная территория" in joined, ( + f"{fmt}: типы ЗОУИТ пропали из сводки" + ) diff --git a/backend/tests/services/exporters/test_2934_flood_row_honesty.py b/backend/tests/services/exporters/test_2934_flood_row_honesty.py new file mode 100644 index 00000000..3ae8b247 --- /dev/null +++ b/backend/tests/services/exporters/test_2934_flood_row_honesty.py @@ -0,0 +1,80 @@ +"""#2934: строка о подтоплении в экспортируемых документах не должна читаться +как заключение о зонах затопления. + +Что было: в §2 «Окружение» → «Геотехника и гидрология» PDF и DOCX печатали + + Риск подтопления | нет + +Значение берётся из ``hydrology.flood_risk_flag`` — это близость реки или канала +ближе 200 м по OSM. Проверкой зон затопления оно не является. Замер на проде +19.08.2026: + + cad_risk_zones 0 строк + слои risk_* НСПД 0 объектов на 669 дампов + +То есть ни один источник зон затопления в эту строку не входит, а ``_fmt(False)`` +превращает флаг в слово «нет» — читатель экспортированного документа получает +«Риск подтопления — нет» как вывод. + +Оговорка при этом СУЩЕСТВОВАЛА: ``hydrology["note"]`` печатается фронтом +(HydrologyBlock.tsx), но терялась на границе экспортёра. +""" + +from __future__ import annotations + +from typing import Any + +from app.services.exporters.full_report_html import build_full_report_html_part_a + +# Константу НЕ импортируем на уровне модуля: на origin/main её ещё нет, и импорт +# уронил бы СБОРКУ всего файла — тогда красное означало бы «модуль не собрался», а +# не «документ утверждает не то». Проверки ниже смотрят на текст, который реально +# попадает в документ. +_NOTE = "Рек и каналов ближе 200 м не найдено. Это близость водного объекта по OSM" +_OLD_LABEL = "Риск подтопления" + + +def _result(flag: bool, note: str | None = _NOTE) -> dict[str, Any]: + hydro: dict[str, Any] = {"flood_risk_flag": flag, "nearest": []} + if note is not None: + hydro["note"] = note + return { + "geotech_risk": {"seismic_label": "минимальная сейсмика", "permafrost": False}, + "hydrology": hydro, + } + + +def test_label_no_longer_claims_a_flood_verdict() -> None: + """Метка называет измеренное, а не вывод, которого не делали.""" + html = build_full_report_html_part_a(_result(flag=False), cad="00:00:0000000:0000") + + assert _OLD_LABEL not in html, ( + "метка утверждает результат проверки зон затопления, которой не было" + ) + assert "ближе 200 м" in html, "метка должна называть измеренное — близость водотока" + + +def test_note_reaches_the_exported_document() -> None: + """Оговорка была написана и молча выбрасывалась на границе экспортёра.""" + html = build_full_report_html_part_a(_result(flag=False), cad="X") + assert "близость водного объекта по OSM" in html + + +def test_missing_note_does_not_break_the_section() -> None: + """Контроль: payload без note по-прежнему рендерится (зелёный по обе стороны).""" + html = build_full_report_html_part_a(_result(flag=True, note=None), cad="X") + assert "Геотехника" in html or "минимальная сейсмика" in html + + +def test_docx_twin_uses_the_same_label() -> None: + """Метка общая: две таблицы собираются одинаковыми списками пар, и правка в + одном файле разошлась бы с другим незаметно. + + Импорт внутри теста, а не в шапке — иначе на origin/main не собрался бы весь + модуль и остальные проверки не исполнились бы вовсе. + """ + from app.services.exporters import full_report_docx, full_report_html + + assert full_report_docx.FLOOD_PROXIMITY_LABEL is full_report_html.FLOOD_PROXIMITY_LABEL, ( + "DOCX держит свою копию метки — форматы разъедутся при следующей правке" + ) diff --git a/backend/tests/services/exporters/test_excel.py b/backend/tests/services/exporters/test_excel.py index b639775f..549a075d 100644 --- a/backend/tests/services/exporters/test_excel.py +++ b/backend/tests/services/exporters/test_excel.py @@ -356,9 +356,9 @@ class TestContractKeysWritten: payload_12mo = _scenario("base", deficit_12mo=0.34) cell_value = _scenario_deficit_cell(payload_12mo) # Для основного горизонта — голое число, не строка с «(гор. N мес)». - assert not isinstance( - cell_value, str - ), f"для 12-мес горизонта ожидается скаляр, получено '{cell_value}'" + assert not isinstance(cell_value, str), ( + f"для 12-мес горизонта ожидается скаляр, получено '{cell_value}'" + ) assert cell_value == 0.34 def test_overall_score_in_cells(self) -> None: diff --git a/backend/tests/services/forecasting/test_confidence_engine.py b/backend/tests/services/forecasting/test_confidence_engine.py index 49c48a8d..572f0566 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/forecasting/test_normalize.py b/backend/tests/services/forecasting/test_normalize.py index f85229b3..be5cc9d7 100644 --- a/backend/tests/services/forecasting/test_normalize.py +++ b/backend/tests/services/forecasting/test_normalize.py @@ -150,9 +150,9 @@ class TestSeasonalFactors: """ months = _months(36) adj = seasonal_factors(months, [0] * 36) - assert ( - adj.n_full_years == 0 - ), f"expected 0 full years on all-zero series, got {adj.n_full_years}" + assert adj.n_full_years == 0, ( + f"expected 0 full years on all-zero series, got {adj.n_full_years}" + ) assert adj.applied is False assert all(f == 1.0 for f in adj.factors.values()) @@ -166,9 +166,9 @@ class TestSeasonalFactors: # Только январь-июнь каждого года ненулевые → нет полного покрытия 12 мес. units = [10 if d.month <= 6 else 0 for d in months] adj = seasonal_factors(months, units) - assert ( - adj.n_full_years == 0 - ), f"partial-coverage years should not count as full, got {adj.n_full_years}" + assert adj.n_full_years == 0, ( + f"partial-coverage years should not count as full, got {adj.n_full_years}" + ) assert adj.applied is False def test_real_nonzero_series_passes_guard_and_applies(self) -> None: diff --git a/backend/tests/services/forecasting/test_scenarios.py b/backend/tests/services/forecasting/test_scenarios.py index 94999a10..2588cb99 100644 --- a/backend/tests/services/forecasting/test_scenarios.py +++ b/backend/tests/services/forecasting/test_scenarios.py @@ -609,9 +609,7 @@ class TestDetectCollapsed: def test_empty_forecasts_not_collapsed(self) -> None: # Нет данных → нет вердикта «схлопнулось» (не помечаем пустой отчёт collapsed). cons = _scenario("conservative", forecasts=[]) - base = _scenario( - "base", forecasts=[_forecast_stub(horizon=12)] - ) + base = _scenario("base", forecasts=[_forecast_stub(horizon=12)]) aggr = _scenario("aggressive", forecasts=[]) assert _detect_collapsed([cons, base, aggr]) is False @@ -750,9 +748,7 @@ class TestComputeScenariosCollapseDetection: # demand aggressive на 1e-3 от cons/base — за пределами abs_tol=1e-6. offset = 1e-3 if call_count["n"] == 3 else 0.0 return [ - _forecast_stub( - horizon=h, deficit_index=0.05, projected_demand_units=100.0 + offset - ) + _forecast_stub(horizon=h, deficit_index=0.05, projected_demand_units=100.0 + offset) for h in horizons ] diff --git a/backend/tests/services/generative/test_market_price_lookup.py b/backend/tests/services/generative/test_market_price_lookup.py index 5667f38e..abdd5fe3 100644 --- a/backend/tests/services/generative/test_market_price_lookup.py +++ b/backend/tests/services/generative/test_market_price_lookup.py @@ -61,9 +61,9 @@ def test_objective_median_sql_dedups_inline_not_via_view() -> None: sql = str(concepts._OBJECTIVE_MEDIAN_SQL) assert "DISTINCT ON (" in sql, "должен дедупить физлоты inline" assert "snapshot_date DESC" in sql, "берём последний снапшот физлота" - assert ( - "v_objective_lots_latest" not in sql - ), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)" + assert "v_objective_lots_latest" not in sql, ( + "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)" + ) def test_objective_median_selected_when_sample_large_enough() -> None: diff --git a/backend/tests/services/generative/test_teap_financial.py b/backend/tests/services/generative/test_teap_financial.py index a8757ff4..0909f1f9 100644 --- a/backend/tests/services/generative/test_teap_financial.py +++ b/backend/tests/services/generative/test_teap_financial.py @@ -82,9 +82,7 @@ def _teap(residential: float, gfa: float, parking: int = 10) -> TEAP: def test_financial_revenue_includes_parking() -> None: t = _teap(residential=1000.0, gfa=1300.0, parking=10) - model = financial.compute_financial( - teap=t, housing_class="comfort", land_cost_rub=50_000_000.0 - ) + model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=50_000_000.0) # revenue = жильё (1000 * 145_000) + паркинг comfort (10 * 1_300_000). assert model.revenue_residential_rub == 1000.0 * 145_000.0 assert model.revenue_parking_rub == 10 * 1_300_000.0 @@ -93,9 +91,7 @@ def test_financial_revenue_includes_parking() -> None: def test_financial_cost_cascade_includes_all_lines() -> None: t = _teap(residential=1000.0, gfa=1300.0, parking=10) - model = financial.compute_financial( - teap=t, housing_class="comfort", land_cost_rub=50_000_000.0 - ) + model = financial.compute_financial(teap=t, housing_class="comfort", land_cost_rub=50_000_000.0) # СМР = GFA*СМР + паркинг comfort*себест (10 * 1_000_000). assert model.construction_rub == 1300.0 * 88_000.0 + 10 * 1_000_000.0 # Каждая статья каскада > 0 при ненулевых вводных. @@ -299,15 +295,18 @@ def test_financial_parking_margin_positive_for_all_classes() -> None: cost = financial._PARKING_COST_PER_SPOT[hc] # type: ignore[index] assert price > cost, hc # Конкретные маржи из спецификации: econom +450k, comfort +300k, business +100k. - assert financial._PARKING_PRICE_PER_SPOT["econom"] - financial._PARKING_COST_PER_SPOT[ - "econom" - ] == 450_000.0 - assert financial._PARKING_PRICE_PER_SPOT["comfort"] - financial._PARKING_COST_PER_SPOT[ - "comfort" - ] == 300_000.0 - assert financial._PARKING_PRICE_PER_SPOT["business"] - financial._PARKING_COST_PER_SPOT[ - "business" - ] == 100_000.0 + assert ( + financial._PARKING_PRICE_PER_SPOT["econom"] - financial._PARKING_COST_PER_SPOT["econom"] + == 450_000.0 + ) + assert ( + financial._PARKING_PRICE_PER_SPOT["comfort"] - financial._PARKING_COST_PER_SPOT["comfort"] + == 300_000.0 + ) + assert ( + financial._PARKING_PRICE_PER_SPOT["business"] - financial._PARKING_COST_PER_SPOT["business"] + == 100_000.0 + ) def test_financial_econom_parking_cheaper_than_business() -> None: @@ -399,12 +398,18 @@ def test_synthesize_program_zero_site_area_no_division_error() -> None: def test_synthesize_program_sections_do_not_affect_teap() -> None: # sections — метаданные программы; площади уже свёрнуты → ТЭП от них не зависит. one = teap.synthesize_teap_from_program( - total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0, - housing_class="comfort", sections=1, + total_footprint_sqm=2000.0, + floors=10, + site_area_sqm=5000.0, + housing_class="comfort", + sections=1, ) six = teap.synthesize_teap_from_program( - total_footprint_sqm=2000.0, floors=10, site_area_sqm=5000.0, - housing_class="comfort", sections=6, + total_footprint_sqm=2000.0, + floors=10, + site_area_sqm=5000.0, + housing_class="comfort", + sections=6, ) assert one == six diff --git a/backend/tests/services/llm/test_client.py b/backend/tests/services/llm/test_client.py index 838165a2..63c56f60 100644 --- a/backend/tests/services/llm/test_client.py +++ b/backend/tests/services/llm/test_client.py @@ -127,9 +127,7 @@ def test_call_cap_returns_fallback(_enabled: None, monkeypatch: pytest.MonkeyPat """call_index >= llm_max_calls_per_request → fallback, провайдер не вызывается.""" monkeypatch.setattr(settings, "llm_max_calls_per_request", 2) prov = _FakeOpenAILike() - res = complete( - system_prompt="sys", payload=SafePayload(text="hi"), provider=prov, call_index=2 - ) + res = complete(system_prompt="sys", payload=SafePayload(text="hi"), provider=prov, call_index=2) assert res.reason == "call_cap" assert prov.calls == 0 @@ -241,9 +239,7 @@ def test_rate_limited_retries_then_fallback( assert prov.calls == 3 -def test_rate_limited_retry_after_capped( - _enabled: None, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_rate_limited_retry_after_capped(_enabled: None, monkeypatch: pytest.MonkeyPatch) -> None: """#1209: серверный Retry-After (86400с при quota-exhaustion) должен капаться _MAX_BACKOFF_S — иначе time.sleep блокирует anyio-threadpool на часы. """ diff --git a/backend/tests/services/llm/test_provider.py b/backend/tests/services/llm/test_provider.py index eccf3434..18ba9f5b 100644 --- a/backend/tests/services/llm/test_provider.py +++ b/backend/tests/services/llm/test_provider.py @@ -88,9 +88,7 @@ def test_complete_builds_request_body(monkeypatch: pytest.MonkeyPatch) -> None: return _chat_response() monkeypatch.setattr(OpenAIProvider, "_post", fake_post) - resp = _provider().complete( - [{"role": "user", "content": "привет"}], max_output_tokens=256 - ) + resp = _provider().complete([{"role": "user", "content": "привет"}], max_output_tokens=256) assert captured["body"]["model"] == "gpt-4o-mini" assert captured["body"]["max_tokens"] == 256 diff --git a/backend/tests/services/scrapers/test_2464_act_date_citation.py b/backend/tests/services/scrapers/test_2464_act_date_citation.py new file mode 100644 index 00000000..17adb51b --- /dev/null +++ b/backend/tests/services/scrapers/test_2464_act_date_citation.py @@ -0,0 +1,149 @@ +"""act_date — дата акта об изъятии, а не первой попавшейся ссылки (#2464). + +`_extract_act_date` брал ПЕРВОЕ «от DD.MM.YYYY» во всём OCR-тексте. «Сообщение о +планируемом изъятии» открывается списком оснований, и первой строкой там почти +всегда стоит + + «Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 + «Об утверждении Генерального плана города»» + +— то есть Генплан, а не акт об изъятии. На проде это дало 11 строк из 27 +(два РАЗНЫХ документа: развязка на Сибирском тракте и улица Энергостроителей) +с одной датой 2004-07-06 при проектах 2020 и 2022 годов. + +Проверено не по догадке: все пять исходных PDF были загружены и распознаны тем +же трактом, что использует загрузчик (`ocr_pdf_text` в прод-контейнере). Тексты +цитат ниже — дословно оттуда, включая перемешивание колонок OCR'ом. + +Правило («постановлени» в 120 символах перед датой) откалибровано на всех пяти +документах сразу, а не на одном: окна 80/120/160 дают одинаковые 5 из 5. +Более узкий вариант (плюс «администраци») давал те же 5 из 5, но ломал законный +случай «Постановление № 509-ПП» — областной акт без слова «администрация», +уже закреплённый тестом test_act_date_extracted_from_text. Взято широкое условие. + +Патченный код прогнан по всем пяти распознанным текстам целиком: 27 записей — +ровно столько же, сколько строк в land_reservation, из них 11 меняют дату +2004-07-06 на настоящую, 16 остаются прежними. +""" + +from __future__ import annotations + +from app.services.scrapers.izyatie_ocr import _extract_act_date, extract_izyatie_records + +# Заголовок раздела — дословно с прода (basis_act в land_reservation). +ЗАГОЛОВОК = ( + "Сообщение о планируемом изъятии земельных участков и объектов недвижимого " + "имущества для муниципальных нужд" +) +КАД = "66:41:0303004:22" + + +def _дата_записи(текст_документа: str) -> str | None: + """act_date так, как её увидит land_reservation — через сквозной путь. + + Зовём `extract_izyatie_records`, а не приватный хелпер: его сигнатура + одинакова на обеих сторонах, поэтому против origin/main тест краснеет + НЕВЕРНЫМ ЗНАЧЕНИЕМ (дата Генплана), а не TypeError из-за нового параметра. + """ + записи = extract_izyatie_records(f"{текст_документа}\n{КАД}", ЗАГОЛОВОК, "http://x/y") + assert записи, "кад-номер не разобран — тест смотрел бы не туда" + return записи[0]["act_date"] + + +# ── Дословные фрагменты OCR прод-документов ─────────────────────────────────── + +# 89adb28a… — развязка Базовый/Комсомольская/Сибирский тракт. На проде дал 2004-07-06. +ДОК_РАЗВЯЗКА = ( + "Администрация города Екатеринбурга\n\n" + "— Решение Екатеринбургской городской Думы от\n06.07.2004 № 60/1 «Об утверждении\n" + "Генерального плана развития городского округа»;\n\n" + "— Приказ Министерства строительства и развития\nинфраструктуры Свердловской области от\n" + "30.12.2021 № 746-П;\n\n" + "— Постановление Администрации города\nЕкатеринбурга от 27.05.2022 № 1413\n" + "«Об утверждении проекта планировки и проекта межевания территории»" +) + +# 9b9d9a99… — улица Энергостроителей. На проде тоже дал 2004-07-06. +ДОК_ЭНЕРГОСТРОИТЕЛЕЙ = ( + "Администрация города Екатеринбурга\n\n" + "— Решение Екатеринбургской городской Думы от\n06.07.2004 № 60/1 «Об утверждении\n" + "Генерального плана развития городского округа»;\n\n" + "— Приказ Министерства строительства и развития\nинфраструктуры Свердловской области от\n" + "30.12.2021 № 746-П «О внесении изменений в Генеральный план на период до 2025 года»;\n\n" + "— Постановление Администрации города\nЕкатеринбурга от 12.02.2020 № 259 «Об утверждении\n" + "проекта планировки и проекта межевания территории»" +) + +# bf2bb1bd… — OCR перемешал колонки таблицы: слова фразы идут вперемешку. +ДОК_ПЕРЕМЕШАННЫЙ = ( + "Постановление Администрации города утверждении проекта планировки\n" + "Екатеринбурга от 29.09.2015 № 2687 территории и проекта межевания\n" + "«Об утверждении» (в ред. Постановления Администрации города\n" + "Екатеринбурга от 14.07.2022 № 1995)" +) + +# ccd00215… — то же перемешивание, но короче. +ДОК_ПЕРЕМЕШАННЫЙ_2 = ( + "Реквизиты документов (при наличии таких Постановление Администрации города\n" + "документами) Екатеринбурга от 19.04.2019 № 863 «Об\nутверждении проекта межевания территории»" +) + +# 188a5c36… — первой же датой идёт нужное постановление. +ДОК_ПРЯМОЙ = ( + "Реквизиты документов (при наличии таких документами)\n" + "Постановление Администрации города Екатеринбурга от 24.06.2019 № 1504\n" + "«Об утверждении проекта планировки и проекта межевания территории»" +) + + +def test_genplan_citation_is_not_taken_as_act_date() -> None: + """Головной: дата Генплана-2004 не должна становиться датой изъятия. + + На origin/main возвращается '2004-07-06' — дата чужого документа, + на 18 лет раньше настоящего основания. + """ + got = _дата_записи(ДОК_РАЗВЯЗКА) + assert got != "2004-07-06", "взята дата Решения Думы об утверждении Генерального плана" + assert got == "2022-05-27", f"ожидали дату постановления № 1413, получили {got!r}" + + +def test_second_document_with_same_genplan_citation() -> None: + """Тот же дефект во втором документе — он и дал одинаковую дату у 11 строк.""" + got = _дата_записи(ДОК_ЭНЕРГОСТРОИТЕЛЕЙ) + assert got == "2020-02-12", f"ожидали дату постановления № 259, получили {got!r}" + + +def test_ministry_order_is_not_taken_either() -> None: + """Контроль: приказ Министерства между Генпланом и постановлением тоже не берётся.""" + got = _дата_записи(ДОК_РАЗВЯЗКА) + assert got != "2021-12-30", "взята дата приказа Министерства строительства" + + +def test_ocr_column_scramble_still_resolves() -> None: + """Контроль ширины окна: перемешанные OCR'ом колонки не должны ломать разбор. + + Окно шире самой фразы (~50 символов) именно ради этих двух документов: + между «Постановление» и «от DD.MM.YYYY» вклинивается текст соседней колонки. + """ + assert _дата_записи(ДОК_ПЕРЕМЕШАННЫЙ) == "2015-09-29" + assert _дата_записи(ДОК_ПЕРЕМЕШАННЫЙ_2) == "2019-04-19" + + +def test_correct_documents_unchanged() -> None: + """Контроль от переусердствования: там, где на проде было верно, ничего не меняется.""" + assert _дата_записи(ДОК_ПРЯМОЙ) == "2019-06-24" + + +def test_no_act_citation_gives_none_not_a_wrong_date() -> None: + """Контроль: без основания — None, а не дата постороннего документа. + + Отсутствие даты честнее: по чужой дате нельзя ни отфильтровать актуальные + изъятия, ни сверить срок, и она неотличима от настоящей. + """ + текст = "Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 «Об утверждении»" + assert _дата_записи(текст) is None + + +def test_title_path_does_not_require_context() -> None: + """Контроль: у заголовка ссылок на чужие акты нет, там правило не применяется.""" + assert _extract_act_date("Постановление от 27.05.2022 № 1413") == "2022-05-27" diff --git a/backend/tests/services/scrapers/test_2464_catalog_batch_commits.py b/backend/tests/services/scrapers/test_2464_catalog_batch_commits.py new file mode 100644 index 00000000..a44d7e03 --- /dev/null +++ b/backend/tests/services/scrapers/test_2464_catalog_batch_commits.py @@ -0,0 +1,138 @@ +"""Успешные объекты каталога фиксируются по ходу батча, а не одним commit'ом в конце (#2464). + +`scrape_catalog_objects` держал весь батч в одной незакоммиченной транзакции: `db.commit()` +стоял ПОСЛЕ блока `async with BrowserSession(...)`. Любой отказ после цикла — исключение в +`BrowserSession.__aexit__`, снятие Celery-таски, перезапуск контейнера — обнулял все уже +успешные UPDATE'ы. + +Масштаб не гипотетический. Беговой режим здесь `force=True` («Загрузить все»), то есть SQL +без LIMIT: на 20.08.2026 в очереди 13200 объектов из 13801 (последний успешный скрейп — +19.05, до блокировки DOM.РФ, #2443). Многочасовой прогон, где отказ в конце стоил бы всего. + +Тест не ходит в сеть и в БД: `BrowserSession` и `scrape_catalog_object` подменяются, сессия +БД — счётчик вызовов. Проверяется поведение — доживают ли успехи до фиксации, — а не +наличие новых символов. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import asyncio +from datetime import date +from typing import Any +from unittest.mock import patch + +import pytest + + +class _RecordingSession: + """Сессия-счётчик: помнит порядок commit/rollback.""" + + def __init__(self) -> None: + self.events: list[str] = [] + + def commit(self) -> None: + self.events.append("commit") + + def rollback(self) -> None: + self.events.append("rollback") + + @property + def commits(self) -> int: + return self.events.count("commit") + + +class _FakeBrowserSession: + """Подмена BrowserSession: ничего не делает, но умеет упасть на выходе.""" + + raise_on_exit: BaseException | None = None + + def __init__(self, *_a: Any, **_kw: Any) -> None: + pass + + async def __aenter__(self) -> _FakeBrowserSession: + return self + + async def __aexit__(self, *_exc: Any) -> None: + if _FakeBrowserSession.raise_on_exit is not None: + exc = _FakeBrowserSession.raise_on_exit + _FakeBrowserSession.raise_on_exit = None + raise exc + + async def warm_up(self) -> None: + return None + + +def _run(db: _RecordingSession, obj_ids: list[int], *, fail_on_exit: bool = False) -> Any: + from app.services.scrapers import domrf_catalog_object as mod + + async def _fake_scrape(_db: Any, _s: Any, obj_id: int, _d: date) -> bool: + # Чётные — успех, нечётные — неудача: проверяем, что фиксируются именно успехи. + return obj_id % 2 == 0 + + _FakeBrowserSession.raise_on_exit = ( + RuntimeError("падение на выходе из сессии") if fail_on_exit else None + ) + + with ( + patch.object(mod, "BrowserSession", _FakeBrowserSession), + patch.object(mod, "scrape_catalog_object", _fake_scrape), + ): + return asyncio.run( + mod.scrape_catalog_objects( + db=db, obj_ids=obj_ids, snapshot_date=date(2026, 8, 20), region_code=66 + ) + ) + + +def test_successes_survive_a_late_failure() -> None: + """Отказ ПОСЛЕ цикла не должен стоить уже собранных объектов. + + На origin/main commit стоит после `async with`, поэтому исключение в __aexit__ + случается ДО единственной фиксации — ни один UPDATE не доживает. + """ + db = _RecordingSession() + + with pytest.raises(RuntimeError, match="падение на выходе"): + _run(db, [2, 4, 6, 8], fail_on_exit=True) + + assert db.commits >= 4, ( + f"зафиксировано {db.commits} раз при 4 успешных объектах — успехи не пережили " + "отказ после цикла: весь батч висел в одной транзакции" + ) + + +def test_commit_happens_per_success_not_per_object() -> None: + """Фиксируются успехи, а не каждая итерация — неудачные строки коммитить нечего.""" + db = _RecordingSession() + stats = _run(db, [1, 2, 3, 4, 5, 6]) + + assert stats["succeeded"] == 3 + assert stats["failed"] == 3 + # 3 успеха по ходу + финальная фиксация в конце. + assert db.commits == 4, f"ожидали 3 по ходу + 1 финальную, получили {db.commits}" + + +def test_empty_batch_touches_nothing() -> None: + """Контроль: пустой список не открывает и не фиксирует ничего.""" + db = _RecordingSession() + stats = _run(db, []) + + assert stats["processed"] == 0 + assert db.events == [], f"пустой батч тронул сессию: {db.events}" + + +def test_all_failed_still_closes_transaction() -> None: + """Контроль: батч без единого успеха всё равно закрывает транзакцию. + + Неудачная итерация откатывает свой SAVEPOINT, но внешняя транзакция остаётся + autobegin'нутой — финальный commit обязан остаться на месте. + """ + db = _RecordingSession() + stats = _run(db, [1, 3, 5]) + + assert stats["succeeded"] == 0 + assert db.commits == 1, f"ожидали одну финальную фиксацию, получили {db.commits}" diff --git a/backend/tests/services/scrapers/test_2464_catalog_skipped_counter.py b/backend/tests/services/scrapers/test_2464_catalog_skipped_counter.py new file mode 100644 index 00000000..8e204f38 --- /dev/null +++ b/backend/tests/services/scrapers/test_2464_catalog_skipped_counter.py @@ -0,0 +1,96 @@ +"""«Объекта нет в БД» считается пропуском, а не сбоем (#2464). + +`stats["skipped"]` был объявлен в контракте, возвращался и печатался в лог — и никогда не +увеличивался. Все исходы сваливались в `failed`: и WAF-блок, и битый разбор, и «UPDATE +затронул 0 строк». По такому счётчику нельзя отличить временную помеху от настоящей +регрессии разбора, а сам он всегда показывал ноль. + +Законный источник пропуска в коде есть: `obj_id` берутся из БД, но снимок мог смениться +между выборкой и UPDATE'ом — тогда строки `(obj_id, snapshot_date)` уже нет. Ветка с этим +логом («not in DB?») существовала и возвращала False. + +Третье состояние сделано через `None`, а не новым `Literal`: прежние True/False сохраняют +смысл, поэтому существующие вызывающие и тесты не переписываются. Это отдельно проверено +контролем ниже. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import asyncio +from datetime import date +from typing import Any +from unittest.mock import MagicMock, patch + +_SNAPSHOT = date(2026, 8, 20) + + +class _FakeSession: + def __init__(self, *_a: Any, **_kw: Any) -> None: + pass + + async def __aenter__(self) -> _FakeSession: + return self + + async def __aexit__(self, *_exc: Any) -> None: + return None + + async def warm_up(self) -> None: + return None + + +def _run(outcomes: dict[int, Any]) -> dict[str, Any]: + from app.services.scrapers import domrf_catalog_object as mod + + async def _fake(_db: Any, _s: Any, obj_id: int, _d: date) -> Any: + return outcomes[obj_id] + + with ( + patch.object(mod, "BrowserSession", _FakeSession), + patch.object(mod, "scrape_catalog_object", _fake), + ): + return asyncio.run( + mod.scrape_catalog_objects( + db=MagicMock(), + obj_ids=list(outcomes), + snapshot_date=_SNAPSHOT, + region_code=66, + ) + ) + + +def test_missing_row_counts_as_skipped_not_failed() -> None: + """Пропуск обязан попасть в skipped, а не в failed. + + На origin/main этой ветки нет: skipped остаётся нулём, а всё уходит в failed. + """ + stats = _run({1: True, 2: None, 3: False, 4: None}) + + assert stats["skipped"] == 2, f"skipped={stats['skipped']}, ожидалось 2: {stats}" + assert stats["failed"] == 1, f"failed={stats['failed']}, ожидалось 1 (только настоящий сбой)" + assert stats["succeeded"] == 1 + assert stats["processed"] == 4 + + +def test_counters_sum_to_processed() -> None: + """Контроль: сумма трёх счётчиков сходится с processed. + + Ловит «починку», при которой пропуск считался бы дважды или терялся. + """ + stats = _run({1: True, 2: None, 3: False, 4: True, 5: None, 6: False}) + assert stats["succeeded"] + stats["failed"] + stats["skipped"] == stats["processed"] + + +def test_old_bool_contract_still_works() -> None: + """Контроль: True/False сохраняют прежний смысл. + + Это и есть довод в пользу None вместо нового Literal — существующие вызывающие + и тесты (напр. test_domrf_catalog_object_browsersession_throttle) не меняются. + """ + stats = _run({1: True, 2: True, 3: False}) + assert stats["succeeded"] == 2 + assert stats["failed"] == 1 + assert stats["skipped"] == 0 diff --git a/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py b/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py new file mode 100644 index 00000000..151be295 --- /dev/null +++ b/backend/tests/services/scrapers/test_2464_catalog_waf_breaker.py @@ -0,0 +1,169 @@ +"""Серия WAF-блоков прерывает батч каталога, а не гонит его до конца (#2464). + +`scrape_catalog_object` гасил `WafBlockedError` и возвращал его как обычную неудачу, +поэтому цикл шёл дальше и слал ЖИВОЙ запрос на каждый оставшийся obj_id в уже +забаненную сессию. + +Цена. Замер прода 20.08.2026: в очереди 13200 объектов из 13801, а DOM.РФ отдаёт +страницу «Доступ заблокирован [403]» с капчей (#2443). Один прогон «Загрузить все» +выдал бы 13200 живых запросов в забаненную сессию — ровно то, что углубляет бан. +Анти-бан-комментарий к BrowserSession в этом же файле предупреждает про тот же +path family, из-за которого бан и случился. + +Порог намеренно не единица: одиночный блок бывает переходным, три подряд — стена. + +Про двусторонность — честно. Правка состоит из ДВУХ частей, и проверяются они по-разному: + + • `scrape_catalog_object` перестал глотать WafBlockedError. Это ДВУСТОРОННЕ: + test_per_object_reraises_waf подменяет `fetch_catalog_object_html` (настоящий + источник блока) и гоняет НАСТОЯЩУЮ функцию — на origin/main она возвращает False, + тест краснеет по «DID NOT RAISE». + + • предохранитель в батч-цикле. Логики этой на origin/main НЕТ, поэтому тесты ниже + подменяют `scrape_catalog_object` и меряют новый цикл в изоляции: сколько объектов + он успел тронуть. Красное на main здесь означало бы «нет такого кода», а не + «неверное поведение», — на двустороннюю проверку они не претендуют. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import asyncio +from datetime import date +from typing import Any +from unittest.mock import MagicMock, patch + +_SNAPSHOT = date(2026, 8, 20) +_OBJ_IDS = list(range(1, 21)) # двадцать объектов — «до конца» хорошо отличимо от «прервались» + + +class _FakeSession: + def __init__(self, *_a: Any, **_kw: Any) -> None: + pass + + async def __aenter__(self) -> _FakeSession: + return self + + async def __aexit__(self, *_exc: Any) -> None: + return None + + async def warm_up(self) -> None: + return None + + +def _run(behaviour) -> tuple[dict[str, Any], list[int]]: + """behaviour(obj_id) → True | False | raises. Возвращает (stats, тронутые obj_id).""" + from app.services.scrapers import domrf_catalog_object as mod + + touched: list[int] = [] + + async def _fake(_db: Any, _s: Any, obj_id: int, _d: date) -> bool: + touched.append(obj_id) + return behaviour(obj_id) + + with ( + patch.object(mod, "BrowserSession", _FakeSession), + patch.object(mod, "scrape_catalog_object", _fake), + ): + stats = asyncio.run( + mod.scrape_catalog_objects( + db=MagicMock(), obj_ids=list(_OBJ_IDS), snapshot_date=_SNAPSHOT, region_code=66 + ) + ) + return stats, touched + + +def _waf(_obj_id: int) -> bool: + from app.services.scrapers.stealth import WafBlockedError + + raise WafBlockedError("HTTP 200 text/html — страница блокировки") + + +def test_waf_streak_aborts_the_batch() -> None: + """Все запросы блокируются → прогон обязан остановиться на пороге, а не на 20-м. + + На origin/main WAF гасится внутри и цикл проходит все 20 объектов. + """ + stats, touched = _run(_waf) + + assert len(touched) == 3, ( + f"тронуто объектов: {len(touched)} из {len(_OBJ_IDS)} — предохранитель не сработал, " + "каждый следующий это живой запрос в забаненную сессию" + ) + assert stats.get("aborted_on_waf") == 1, f"батч не помечен как прерванный: {stats}" + assert stats["processed"] == 3 + + +def test_single_waf_does_not_abort() -> None: + """Контроль: одиночный блок не рвёт прогон — он бывает переходным. + + Ловит «починку» через abort-on-first: тогда один переходный блок стоил бы + всей ночной докачки. + """ + stats, touched = _run(lambda oid: _waf(oid) if oid == 5 else True) + + assert len(touched) == len(_OBJ_IDS), f"прогон оборвался на одиночном блоке: {len(touched)}" + assert "aborted_on_waf" not in stats + assert stats["succeeded"] == len(_OBJ_IDS) - 1 + + +def test_streak_resets_after_success() -> None: + """Контроль: успех между блоками сбрасывает счётчик — считаем ПОДРЯД идущие. + + Без сброса два разрозненных блока плюс третий где-то в конце оборвали бы прогон. + """ + blocked = {2, 4, 6, 8, 10} + stats, touched = _run(lambda oid: _waf(oid) if oid in blocked else True) + + assert len(touched) == len(_OBJ_IDS), f"прогон оборвался: тронуто {len(touched)}" + assert "aborted_on_waf" not in stats + assert stats["failed"] == len(blocked) + + +def test_healthy_batch_untouched() -> None: + """Контроль: без блоков прогон проходит все объекты и ничего не помечает.""" + stats, touched = _run(lambda _oid: True) + + assert len(touched) == len(_OBJ_IDS) + assert stats["succeeded"] == len(_OBJ_IDS) + assert "aborted_on_waf" not in stats + + +def test_per_object_reraises_waf() -> None: + """ДВУСТОРОННЯЯ часть: настоящая scrape_catalog_object обязана пробросить WAF. + + Подменяется `fetch_catalog_object_html` — настоящий источник блока, — а сама + функция работает как в проде. На origin/main она гасит исключение и возвращает + False, поэтому тест краснеет по «DID NOT RAISE». + """ + import pytest + + from app.services.scrapers import domrf_catalog_object as mod + from app.services.scrapers.stealth import WafBlockedError + + async def _blocked(_session: Any, _obj_id: int) -> str: + raise WafBlockedError("HTTP 200 text/html — страница блокировки") + + with patch.object(mod, "fetch_catalog_object_html", _blocked): + with pytest.raises(WafBlockedError): + asyncio.run(mod.scrape_catalog_object(MagicMock(), MagicMock(), 42, _SNAPSHOT)) + + +def test_per_object_still_swallows_ordinary_errors() -> None: + """Контроль к предыдущему: обычный сбой сети по-прежнему гасится в False. + + Ловит «починку», которая начала бы пробрасывать вообще всё — тогда один битый + объект рвал бы батч. + """ + from app.services.scrapers import domrf_catalog_object as mod + + async def _broken(_session: Any, _obj_id: int) -> str: + raise RuntimeError("оборвалось соединение на одном объекте") + + with patch.object(mod, "fetch_catalog_object_html", _broken): + assert ( + asyncio.run(mod.scrape_catalog_object(MagicMock(), MagicMock(), 42, _SNAPSHOT)) is False + ) diff --git a/backend/tests/services/scrapers/test_2464_detect_kind_position.py b/backend/tests/services/scrapers/test_2464_detect_kind_position.py new file mode 100644 index 00000000..2fb124a1 --- /dev/null +++ b/backend/tests/services/scrapers/test_2464_detect_kind_position.py @@ -0,0 +1,83 @@ +"""Тип постановления определяется по первому упоминанию, а не по порядку проверок (#2464). + +`_detect_kind` проверял «резервир» ПЕРВЫМ и возвращал «резервирование» безусловно. +Постановление об изъятии, где резервирование упомянуто вскользь — типовая +формулировка «ранее зарезервированных земель», ссылка на утративший силу акт, — +переклассифицировало **весь документ**: `kind` в `extract_reservations` один на +все участки, поэтому ошибка размножается на каждую строку `land_reservation`. + +Побеждает то слово, что встретилось раньше. Тема документа стоит в заголовке, +поэтому позиция — сигнал сильнее порядка проверок, и он симметричен: заголовок +«О резервировании» так же выигрывает у «изъятия» в теле. + +Формулировки взяты с прода: `basis_act` в `land_reservation` (источник +`izyatie_ekb_ocr`, 27 строк, все «изъятие») начинается со слов «Сообщение о +планируемом изъятии земельных участков…». +""" + +from __future__ import annotations + +from app.services.scrapers.page_reservation_parser import ( + _detect_kind, + extract_reservations, +) + +# Заголовок — с прода, дословно. +_ЗАГОЛОВОК_ИЗЪЯТИЕ = ( + "Сообщение о планируемом изъятии земельных участков и объектов недвижимого " + "имущества для муниципальных нужд в целях строительства улицы Энергостроителей" +) + + +def test_izyatie_in_title_wins_over_later_rezervirovanie() -> None: + """Головной: упоминание резервирования в теле не переписывает тему заголовка. + + На origin/main возвращается «резервирование» — неверное значение, а не отказ. + """ + text = ( + f"{_ЗАГОЛОВОК_ИЗЪЯТИЕ}. Изъятию подлежат участки, ранее зарезервированные " + "постановлением о резервировании земель от 12.03.2019." + ) + assert _detect_kind(text, "изъятие") == "изъятие", ( + "документ об изъятии классифицирован по случайному упоминанию в теле" + ) + + +def test_all_parcels_of_the_document_are_affected() -> None: + """Ошибка типа размножается на КАЖДЫЙ участок — kind в документе один на всех.""" + text = ( + f"{_ЗАГОЛОВОК_ИЗЪЯТИЕ}. Изъять земельные участки с кадастровыми номерами " + "66:41:0303004:22, 66:41:0303004:23, 66:41:0206032:8499, ранее " + "зарезервированные постановлением о резервировании земель." + ) + records = extract_reservations(text, default_kind="изъятие") + assert len(records) == 3, f"разобрано участков: {len(records)}" + kinds = {r.reservation_kind for r in records} + assert kinds == {"изъятие"}, f"типы участков: {kinds} — ошибка ушла бы во все строки" + + +def test_rezervirovanie_in_title_still_wins() -> None: + """Контроль симметрии: заголовок о резервировании выигрывает у изъятия в теле. + + Ловит «починку» через простую смену порядка проверок — она сломала бы этот случай. + """ + text = ( + "Постановление о резервировании земель для муниципальных нужд. " + "Зарезервированные участки не подлежат изъятию до истечения срока." + ) + assert _detect_kind(text, "изъятие") == "резервирование" + + +def test_single_keyword_documents_unchanged() -> None: + """Контроль: документы с одним словом-маркером распознаются как раньше.""" + assert _detect_kind("Об изъятии земельного участка", "резервирование") == "изъятие" + assert _detect_kind("О резервировании земель", "изъятие") == "резервирование" + + +def test_no_keyword_falls_back_to_default() -> None: + """Контроль: без маркеров остаётся default_kind, а не выдуманный тип.""" + assert _detect_kind("Об утверждении схемы расположения участка", "изъятие") == "изъятие" + assert ( + _detect_kind("Об утверждении схемы расположения участка", "резервирование") + == "резервирование" + ) diff --git a/backend/tests/services/scrapers/test_2464_download_binary_retry.py b/backend/tests/services/scrapers/test_2464_download_binary_retry.py new file mode 100644 index 00000000..43a442ed --- /dev/null +++ b/backend/tests/services/scrapers/test_2464_download_binary_retry.py @@ -0,0 +1,156 @@ +"""download_binary переживает транзиентный ответ, как и get_json (#2464). + +Обе функции ходят через один и тот же браузерный контекст, под один и тот же WAF. +`get_json` держит до пяти попыток с экспоненциальным backoff на 429/5xx/0, а +`download_binary` не имел ретраев вовсе: один 429 ронял загрузку картинки +насовсем, и вызывающий (`download_plan_image`, `download_photos`) писал в лог +«не удалось» — неотличимо от «файла нет». + +Правильный образец лежал в этом же классе, двадцатью строками выше. + +Тесты гоняют настоящий `download_binary` на двойнике Playwright-контекста; +`asyncio.sleep` подменён, чтобы backoff не тратил время теста, — но факт ожидания +проверяется отдельно, иначе «ретраит» и «долбит без пауз» неотличимы. +""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import patch + +import pytest + +from app.services.scrapers.stealth import BrowserSession + + +class _Resp: + def __init__(self, status: int, body: bytes = b"", text: str = "") -> None: + self.status = status + self._body = body + self._text = text + + async def body(self) -> bytes: + return self._body + + async def text(self) -> str: + return self._text + + +class _Request: + """Отдаёт заранее заданную очередь ответов и считает вызовы.""" + + def __init__(self, ответы: list[Any]) -> None: + self._ответы = list(ответы) + self.calls = 0 + + async def get(self, url: str, headers: dict | None = None) -> _Resp: + self.calls += 1 + item = self._ответы.pop(0) if self._ответы else _Resp(200, b"ok") + if isinstance(item, Exception): + raise item + return item + + +class _Context: + def __init__(self, ответы: list[Any]) -> None: + self.request = _Request(ответы) + + +def _session(ответы: list[Any]) -> tuple[BrowserSession, _Context]: + s = BrowserSession.__new__(BrowserSession) + ctx = _Context(ответы) + s._context = ctx # type: ignore[attr-defined] + s._sem = asyncio.Semaphore(1) # type: ignore[attr-defined] + s._request_count = 0 # type: ignore[attr-defined] + s.auth = None # type: ignore[attr-defined] + return s, ctx + + +def _run(ответы: list[Any]) -> tuple[Any, _Context, list[float]]: + """Прогнать download_binary, вернуть (результат|исключение, контекст, паузы).""" + паузы: list[float] = [] + + async def _fake_sleep(d: float) -> None: + паузы.append(d) + + s, ctx = _session(ответы) + with ( + patch("app.services.scrapers.stealth.asyncio.sleep", _fake_sleep), + patch("app.services.scrapers.stealth.jitter_sleep", lambda *a, **k: _fake_sleep(0)), + ): + try: + res: Any = asyncio.run(s.download_binary("https://x/y.png")) + except Exception as exc: + res = exc + return res, ctx, паузы + + +def test_transient_429_is_retried_not_fatal() -> None: + """Головной: 429 с последующим успехом обязан дать байты, а не исключение. + + На origin/main первая же попытка поднимает RuntimeError — картинка теряется. + """ + res, ctx, _ = _run([_Resp(429, text="rate limited"), _Resp(200, b"PNGDATA")]) + assert res == b"PNGDATA", f"вместо байтов получили {res!r}; попыток={ctx.request.calls}" + assert ctx.request.calls == 2, f"повтор не выполнен: попыток={ctx.request.calls}" + + +def test_transient_5xx_is_retried() -> None: + """503 — тоже транзиент, как и в get_json.""" + res, ctx, _ = _run([_Resp(503, text="bad gw"), _Resp(502, text="bad gw"), _Resp(200, b"OK")]) + assert res == b"OK", f"{res!r}" + assert ctx.request.calls == 3 + + +def test_backoff_actually_waits() -> None: + """Контроль от «ретраит, но долбит без пауз»: паузы растут экспоненциально. + + Без этой проверки цикл без sleep выглядел бы в тестах так же, как с ним, — + а под WAF разница между ними решающая. + """ + _, _, паузы = _run([_Resp(429), _Resp(429), _Resp(200, b"OK")]) + задержки = [p for p in паузы if p > 0] + assert задержки[:2] == [1, 2], f"backoff не экспоненциальный: {задержки}" + + +def test_permanent_403_is_not_retried() -> None: + """Контроль от переусердствования: 403 поднимается сразу. + + Повтор его не изменит, а лишний стук под WAF вредит. + """ + res, ctx, _ = _run([_Resp(403, text="forbidden"), _Resp(200, b"OK")]) + assert isinstance(res, RuntimeError), f"403 не поднял ошибку: {res!r}" + assert "403" in str(res) + assert ctx.request.calls == 1, f"403 был повторён: попыток={ctx.request.calls}" + + +def test_404_is_not_retried() -> None: + """Контроль: отсутствующий файл — не транзиент.""" + res, ctx, _ = _run([_Resp(404, text="no such file")]) + assert isinstance(res, RuntimeError) and "404" in str(res) + assert ctx.request.calls == 1 + + +def test_exhausted_retries_raise_with_the_last_error() -> None: + """Контроль честности отказа: после пяти попыток — ошибка, а не пустые байты.""" + res, ctx, _ = _run([_Resp(429) for _ in range(5)]) + assert isinstance(res, RuntimeError), f"{res!r}" + assert "exhausted" in str(res), str(res) + assert ctx.request.calls == 5, f"попыток={ctx.request.calls}" + + +def test_network_exception_is_retried_too() -> None: + """Обрыв соединения — тоже транзиент (зеркалит ветку except в get_json).""" + res, ctx, _ = _run([ConnectionError("boom"), _Resp(200, b"OK")]) + assert res == b"OK", f"{res!r}" + assert ctx.request.calls == 2 + + +@pytest.mark.parametrize("status", [200]) +def test_success_on_first_try_makes_one_request(status: int) -> None: + """Контроль: успех с первой попытки не порождает лишних запросов.""" + res, ctx, паузы = _run([_Resp(status, b"OK")]) + assert res == b"OK" + assert ctx.request.calls == 1 + assert [p for p in паузы if p > 0] == [], f"лишние паузы: {паузы}" diff --git a/backend/tests/services/scrapers/test_2464_status_negation.py b/backend/tests/services/scrapers/test_2464_status_negation.py new file mode 100644 index 00000000..d54591ce --- /dev/null +++ b/backend/tests/services/scrapers/test_2464_status_negation.py @@ -0,0 +1,111 @@ +"""Отрицательные формы не дают ОБРАТНЫЙ статус продажи (#2464). + +`_STATUS_KW_RE` искал ключевые слова без защиты от отрицания, поэтому русские +отрицательные формы классифицировались наоборот: + + «нереализована» → содержит «реализована» → sold + «не продана» → содержит «продана» → sold + «не забронирована» → содержит «забронирована» → reserved + «не в продаже» → содержит «в продаже» → free + +То есть свободная квартира могла попасть в базу проданной. Отрицание теперь +гасит токен, а не переворачивает его: «не забронирована» не значит ни sold, ни +free, а «не продана → free» — вывод, а не факт со страницы. Лучше отсутствие +статуса, чем неверный. + +Тесты идут через `parse_catalog_flat` (полный путь разбора HTML), а не только +через хелпер: инвертированный статус — это то, что доехало бы до БД. + +Текущий эффект на проде — НУЛЕВОЙ, и это проверено, а не предположено: +`catalog_updated_at` пуст у всех 983 088 строк `domrf_kn_flats`, таска +`scrape_kn_catalog_flats` закомментирована в beat_schedule.py (WAF-cooldown). +Правка предупредительная: при включении пути дефект инвертировал бы статусы молча. +""" + +from __future__ import annotations + +import pytest + +from app.services.scrapers.domrf_catalog import ( + STATUS_FREE, + STATUS_RESERVED, + STATUS_SOLD, + parse_catalog_flat, +) + + +def _html_with_badge(badge_text: str, nav_text: str = "") -> str: + """Тот же каркас, что в test_domrf_catalog_parse.py: nav + статус-бейдж.""" + return ( + "" + f'

' + f'
{badge_text}
' + "

Площадь 45 м²

" + "" + ) + + +@pytest.mark.parametrize( + "badge,было_бы", + [ + ("Нереализована", STATUS_SOLD), + ("Не реализована", STATUS_SOLD), + ("Не продана", STATUS_SOLD), + ("Квартира не продана", STATUS_SOLD), + ("Не забронирована", STATUS_RESERVED), + ("Не в продаже", STATUS_FREE), + ], +) +def test_negated_form_does_not_invert_status(badge: str, было_бы: str) -> None: + """Головной: отрицание не должно давать статус, тем более противоположный. + + На origin/main каждый случай возвращает `было_бы` — ровно обратное значение. + """ + result = parse_catalog_flat(_html_with_badge(badge)) + got = result.get("status") + assert got != было_бы, ( + f"«{badge}» классифицировано как {got!r} — отрицание перевёрнуто в свою " + "противоположность и уехало бы в domrf_kn_flats.status" + ) + assert got is None, f"«{badge}» дало статус {got!r}; ожидали отсутствие статуса" + + +@pytest.mark.parametrize( + "badge,ожидаем", + [ + ("Продана", STATUS_SOLD), + ("Продано", STATUS_SOLD), + ("Реализована", STATUS_SOLD), + ("Забронирована", STATUS_RESERVED), + ("Бронь", STATUS_RESERVED), + ("В продаже", STATUS_FREE), + ("Свободна", STATUS_FREE), + ], +) +def test_positive_forms_unchanged(badge: str, ожидаем: str) -> None: + """Контроль от переусердствования: обычные формы распознаются как раньше. + + Ловит «починку» через грубое гашение всего, что рядом со словом «не». + """ + result = parse_catalog_flat(_html_with_badge(badge)) + assert result.get("status") == ожидаем, f"«{badge}» перестало распознаваться" + + +def test_negation_does_not_swallow_a_real_status_in_same_block() -> None: + """Контроль: отрицание гасит СВОЙ токен, а не весь блок. + + «Квартира не продана. Статус: в продаже» — настоящий статус в блоке есть. + Разбор по первому совпадению вернул бы None и потерял бы его. + """ + result = parse_catalog_flat(_html_with_badge("Квартира не продана. Статус: в продаже")) + assert result.get("status") == STATUS_FREE, ( + f"настоящий статус в блоке потерян: {result.get('status')!r}" + ) + + +def test_ne_inside_another_word_is_not_a_negation() -> None: + """Контроль границы слова: «не» внутри другого слова не считается отрицанием.""" + result = parse_catalog_flat(_html_with_badge("Цене снижена, квартира продана")) + assert result.get("status") == STATUS_SOLD, ( + f"«не» в хвосте слова «Цене» съело настоящий статус: {result.get('status')!r}" + ) diff --git a/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py b/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py new file mode 100644 index 00000000..fc809698 --- /dev/null +++ b/backend/tests/services/scrapers/test_2464_tep_docstring_truth.py @@ -0,0 +1,103 @@ +"""Докстрока `_page_contains_table` не обещает защиты, которой нет (#2464). + +Прежняя редакция утверждала: + + «Требуем явный caption «Таблица N» — hint-только режим (оглавление, + перекрёстные ссылки) даёт false positive и подавляется.» + +Кода подавления в функции никогда не было — это `bool(cap.search(text))`, то есть +обычный поиск подстроки. Строка оглавления «Таблица 11 Баланс территории ..... 34» от +настоящей подписи не отличается: падеж тот же, именительный. + +Замер 20.08.2026 уточнил и сам пункт эпика: приведённый в нём пример перекрёстной +ссылки «показатели приведены в таблице 12» регекс НЕ ловит — он требует именительное +«Таблица N». Опасно ровно оглавление, а не любое упоминание. Это зафиксировано +характеризующим тестом, чтобы следующая попытка подавления целилась в признаки +оглавления, а не в падежи. + +Обещание защиты, которой нет, опаснее её отсутствия: читающий не станет её +добавлять. Тот же класс, что «объявлено ≠ исполняется». + +Почему подавление не реализовано, а докстрока приведена к коду: таблица +`ekb_ppt_tep` на проде пуста (0 строк) — URL в `_SEED_DOCS` заглушка, живых PDF нет, +эвристику отсева оглавления не на чем откалибровать. Правило, придуманное без +образцов, ловит ровно те случаи, которые придумали вместе с ним. +""" + +from __future__ import annotations + +import inspect + +from app.services.scrapers import ekb_ppt_tep_parser as mod + + +def test_docstring_does_not_promise_suppression_that_is_absent() -> None: + """Головной: докстрока не должна обещать подавление false positive. + + На origin/main она содержит «даёт false positive и подавляется», а в теле + функции — один `cap.search(text)`. Красное значение конкретно: обещание есть, + реализации нет. + """ + doc = inspect.getdoc(mod._page_contains_table) or "" + src = inspect.getsource(mod._page_contains_table) + тело = src.split('"""')[-1] + # Ищем именно ОБЕЩАНИЕ («подавляется»), а не разговор о подавлении: новая + # редакция объясняет, почему подавление НЕ реализовано, и не должна краснеть. + обещает = "подавляется" in doc + реализует = any(kw in тело for kw in ("оглавлен", "leader", "count(", "finditer", "rsplit")) + assert not (обещает and not реализует), ( + "докстрока обещает подавление false positive, а в теле только поиск " + f"подстроки:\n{тело.strip()}" + ) + + +def test_docstring_names_the_actual_behaviour() -> None: + """Докстрока обязана честно называть false positive, а не умалчивать о нём. + + Просто убрать обещание было бы недостаточно: молчание читается как «всё + хорошо». Ловит «починку» через вычёркивание неудобной фразы. + """ + doc = (inspect.getdoc(mod._page_contains_table) or "").lower() + assert "false positive" in doc or "оглавлен" in doc, ( + "докстрока не предупреждает о ложных срабатываниях на оглавлении" + ) + + +def test_toc_line_really_is_a_false_positive() -> None: + """Характеризующий: строка оглавления действительно проходит как подпись. + + Зелёный с обеих сторон — он фиксирует ФАКТИЧЕСКОЕ поведение, о котором теперь + говорит докстрока. Если кто-то реализует подавление, тест покраснеет и + заставит обновить и докстроку, и его. + """ + оглавление = "СОДЕРЖАНИЕ\nТаблица 11 Баланс территории .......... 34\nТаблица 12 ТЭП .... 36" + assert mod._page_contains_table(оглавление, 11) is True, "оглавление обязано срабатывать" + + # А вот перекрёстные ссылки в косвенных падежах регекс НЕ ловит: он требует + # именительное «Таблица N». Пункт эпика приводил «приведены в таблице 12» как + # пример false positive — замер это опроверг, и докстрока теперь говорит точнее. + for мимо in ( + "Основные показатели приведены в таблице 12 настоящей записки.", + "см. Таблицу 12 ниже", + "Данные в табл. 12", + ): + assert mod._page_contains_table(мимо, 12) is False, f"неожиданно сработало: {мимо!r}" + + assert mod._page_contains_table("Никаких таблиц тут нет", 11) is False + + +def test_seed_comment_does_not_send_to_a_dead_host() -> None: + """Комментарий сида не должен отправлять искать документ на несуществующем хосте. + + `gisogd.ekburg.ru` не резолвится ни с рабочей машины, ни с прод-хоста + (проверено 20.08.2026), а прежний комментарий называл его местом, где «лежит + реальный URL». Живой портал — gisogd66.midural.ru. + """ + from pathlib import Path + + src = Path(inspect.getsourcefile(mod)).resolve().parents[3] + текст = (src / "app" / "workers" / "tasks" / "ekb_ppt_tep_sync.py").read_text(encoding="utf-8") + assert "НЕ СУЩЕСТВУЕТ" in текст or "не резолв" in текст.lower(), ( + "комментарий сида не предупреждает, что gisogd.ekburg.ru мёртв" + ) + assert "gisogd66.midural.ru" in текст, "не назван живой портал ГИСОГД-СО" diff --git a/backend/tests/services/scrapers/test_domrf_catalog_object.py b/backend/tests/services/scrapers/test_domrf_catalog_object.py index 34012edd..30da2a2d 100644 --- a/backend/tests/services/scrapers/test_domrf_catalog_object.py +++ b/backend/tests/services/scrapers/test_domrf_catalog_object.py @@ -42,7 +42,7 @@ def test_extract_next_data_from_html() -> None: def test_extract_next_data_single_quotes() -> None: """Тег с одинарными кавычками тоже должен парситься.""" - html = "" + html = '' result = extract_next_data(html) assert "props" in result diff --git a/backend/tests/services/scrapers/test_domrf_catalog_parse.py b/backend/tests/services/scrapers/test_domrf_catalog_parse.py index a9214814..786aec07 100644 --- a/backend/tests/services/scrapers/test_domrf_catalog_parse.py +++ b/backend/tests/services/scrapers/test_domrf_catalog_parse.py @@ -76,13 +76,7 @@ def _html_with_badge(badge_text: str, nav_text: str = "другие кварти def _html_with_label(label: str, value: str, nav_text: str = "") -> str: """HTML с лейблом «Статус» и значением в следующем блоке.""" nav = f"{nav_text}" if nav_text else "" - return ( - "" - f"{nav}" - f"{label}" - f"{value}" - "" - ) + return f"{nav}{label}{value}" def _html_blocks_only(blocks: list[tuple[str, str]]) -> str: diff --git a/backend/tests/services/scrapers/test_domrf_flat_plan_url.py b/backend/tests/services/scrapers/test_domrf_flat_plan_url.py index ef613b7b..acdb2e0f 100644 --- a/backend/tests/services/scrapers/test_domrf_flat_plan_url.py +++ b/backend/tests/services/scrapers/test_domrf_flat_plan_url.py @@ -115,10 +115,7 @@ def test_plan_from_img_proximity_to_label() -> None: """ без plan-hint в атрибутах, но рядом с блоком «Планировка».""" plain = f"{BASE_URL}/api/ext/file/imgabc.png" html = ( - "" - "
Планировка
" - f'' - "" + f'
Планировка
' ) got = extract_plan_image_url(html, _collector_for(html)) assert got == plain diff --git a/backend/tests/services/scrapers/test_domrf_kn_upsert_sql.py b/backend/tests/services/scrapers/test_domrf_kn_upsert_sql.py index c0f7de00..f31b97bf 100644 --- a/backend/tests/services/scrapers/test_domrf_kn_upsert_sql.py +++ b/backend/tests/services/scrapers/test_domrf_kn_upsert_sql.py @@ -60,14 +60,14 @@ class TestUpsertObjectSqlDoUpdateSet: # clear back to NULL, not get stuck "still problematic" forever). sql = " ".join(str(UPSERT_OBJECT_SQL).split()) for col in _OBJECT_PREVIOUSLY_OMITTED: - assert ( - f"{col} = EXCLUDED.{col}" in sql - ), f"{col} should be direct `= EXCLUDED.{col}` (not COALESCE):\n{sql}" + assert f"{col} = EXCLUDED.{col}" in sql, ( + f"{col} should be direct `= EXCLUDED.{col}` (not COALESCE):\n{sql}" + ) # guard against a COALESCE(EXCLUDED.col, ...) formulation, which would # permanently pin a stale value once ever set instead of refreshing it. - assert ( - f"COALESCE(EXCLUDED.{col}," not in sql - ), f"{col} must not be wrapped in COALESCE (needs to be able to clear to NULL)" + assert f"COALESCE(EXCLUDED.{col}," not in sql, ( + f"{col} must not be wrapped in COALESCE (needs to be able to clear to NULL)" + ) def test_all_insert_columns_covered_by_do_update_set_or_conflict_target(self) -> None: # Locks in the class of bug (INSERT populates a column, DO UPDATE SET diff --git a/backend/tests/services/scrapers/test_okn_egrkn_client.py b/backend/tests/services/scrapers/test_okn_egrkn_client.py index 6d1740c6..fb7a6d87 100644 --- a/backend/tests/services/scrapers/test_okn_egrkn_client.py +++ b/backend/tests/services/scrapers/test_okn_egrkn_client.py @@ -77,9 +77,9 @@ def test_coord_swap_ekb_point_inside_bbox() -> None: wrong_lat = float(coords[1]) # 60.61 → это вне ЕКБ если использовать как lat wrong_lon = float(coords[0]) # 56.83 → это вне ЕКБ если использовать как lon # Финляндия/Норвегия: lat=60.61 формально в диапазоне 55–58.5 FALSE → lat перепутан - assert not ( - 55.0 <= wrong_lat <= 58.5 and 58.0 <= wrong_lon <= 63.0 - ), f"Тест ошибочно принял перепутанные координаты: wrong_lat={wrong_lat} wrong_lon={wrong_lon}" + assert not (55.0 <= wrong_lat <= 58.5 and 58.0 <= wrong_lon <= 63.0), ( + f"Тест ошибочно принял перепутанные координаты: wrong_lat={wrong_lat} wrong_lon={wrong_lon}" + ) def test_coord_swap_inverted_would_fail_bbox_check() -> None: diff --git a/backend/tests/services/site_finder/test_2464_capacity_savepoints.py b/backend/tests/services/site_finder/test_2464_capacity_savepoints.py new file mode 100644 index 00000000..760d448e --- /dev/null +++ b/backend/tests/services/site_finder/test_2464_capacity_savepoints.py @@ -0,0 +1,85 @@ +"""Сбой в connection-capacity не должен травить общую сессию (#2464). + +`_query_power_points` и `_query_water_latest` делали `db.execute` без SAVEPOINT, тогда как +четыре соседних `_query_*` в том же файле его имеют. Сессия здесь ОБЩАЯ с вызывающим, и +цепочка выходила такая: + + сбой в _query_power_points + → исключение всплывает (в get_connection_capacity try/except нет) + → его ГЛОТАЕТ _get_connection_capacity в full_report_pdf.py ради деградации §3 + → транзакция остаётся aborted + → падает уже СЛЕДУЮЩИЙ запрос — рыночная цена в _generate_concept_result + → концепция молча уходит в class_norm-фолбэк + +То есть отказ проявляется не там, где случился, и выглядит как «нет рыночных данных». + +Проверяется на `PostgresLikeSession` (tests/support/pg_session.py) — двойнике с настоящей +семантикой aborted-транзакции. На `MagicMock` такой тест был бы зелёным по построению: +у него нет aborted-состояния, и любой следующий execute «успешен». +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +from tests.support.pg_session import AbortedTransactionError, PostgresLikeSession + +_QUERIES = ("_query_power_points", "_query_water_latest") + + +def _call(name: str, db: PostgresLikeSession): + from app.services.site_finder import connection_capacity_lookup as mod + + fn = getattr(mod, name) + if name == "_query_power_points": + return fn(db, "POLYGON((0 0,0 1,1 1,1 0,0 0))", 5000) + return fn(db) + + +@pytest.mark.parametrize("name", _QUERIES) +def test_failure_leaves_session_usable(name: str) -> None: + """После проглоченного вызывающим сбоя следующий запрос обязан пройти. + + На origin/main второй execute падает с AbortedTransactionError — ровно та + отложенная поломка, что уводит концепцию в class_norm. + """ + db = PostgresLikeSession(fail_on=(1,)) + + with pytest.raises(RuntimeError): # сам сбой пробрасывается, это ожидаемо + _call(name, db) + + # Вызывающий (full_report_pdf._get_connection_capacity) исключение глотает, + # и дальше по коду идёт СЛЕДУЮЩИЙ запрос на той же сессии. + db.execute("SELECT 1") # на origin/main здесь AbortedTransactionError + + +@pytest.mark.parametrize("name", _QUERIES) +def test_savepoint_is_actually_entered(name: str) -> None: + """Запрос действительно идёт внутри SAVEPOINT, а не просто «не падает». + + Отдельно от предыдущего: тест выше прошёл бы и если бы кто-то «починил» + функцию, убрав из неё запрос целиком. + """ + db = PostgresLikeSession(fail_on=(), rows=[]) # здоровый путь + _call(name, db) + assert db.savepoints_entered >= 1, ( + f"{name}: запрос выполнен вне SAVEPOINT — при сбое сессия останется aborted" + ) + assert db.calls >= 1, f"{name}: запрос вообще не выполнялся" + + +def test_double_reproduces_poisoning_without_savepoint() -> None: + """Контроль на сам двойник: без SAVEPOINT он ДОЛЖЕН травить сессию. + + Без этой проверки зелёные тесты выше нельзя отличить от «двойник ничего не + моделирует» — та же ловушка, что с MagicMock. + """ + db = PostgresLikeSession(fail_on=(1,)) + with pytest.raises(RuntimeError): + db.execute("BOOM") + with pytest.raises(AbortedTransactionError): + db.execute("SELECT 1") diff --git a/backend/tests/services/site_finder/test_2464_default_profile_order.py b/backend/tests/services/site_finder/test_2464_default_profile_order.py new file mode 100644 index 00000000..6d23bb7a --- /dev/null +++ b/backend/tests/services/site_finder/test_2464_default_profile_order.py @@ -0,0 +1,32 @@ +"""Читающий запрос дефолтного профиля детерминирован по id (#2464). + +`_SELECT_DEFAULT` брал `LIMIT 1` без `ORDER BY`: при двух дефолтах у одного пользователя +выбор молча перескакивал между ними от запроса к запросу. Соседние запросы этого файла +тай-брейк по id уже имели. + +Сам случай «два дефолта» с миграции 190 невозможен — частичный уникальный индекс. Этот +тест держит ВТОРОЙ рубеж: если индекс когда-нибудь снимут, выбор не должен снова стать +произвольным. Живой БД не требует, поэтому лежит отдельно от tests/sql — чтобы +исполняться на любой машине, а не пропускаться вместе с ними. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import re + + +def test_select_default_is_ordered() -> None: + """Второй рубеж: читающий запрос детерминирован по id. + + Индекс делает два дефолта невозможными, но если его когда-нибудь снимут, + выбор не должен снова стать произвольным. + """ + from app.services.site_finder.weight_profiles import _SELECT_DEFAULT + + assert re.search(r"ORDER BY\s+id\s+ASC", _SELECT_DEFAULT), ( + f"в _SELECT_DEFAULT нет тай-брейка по id:\n{_SELECT_DEFAULT}" + ) diff --git a/backend/tests/services/site_finder/test_2464_heat_loader_tx.py b/backend/tests/services/site_finder/test_2464_heat_loader_tx.py new file mode 100644 index 00000000..cb4cfad1 --- /dev/null +++ b/backend/tests/services/site_finder/test_2464_heat_loader_tx.py @@ -0,0 +1,105 @@ +"""Загрузчик теплоснабжения фиксирует по организациям, а не одной транзакцией на всё (#2464). + +`load_heat_reserves` открывал сессию, проходил по ВОСЬМИ организациям — у каждой несколько +HTTP-раундов к медленному внешнему реестру с таймаутом 60 с — и коммитил один раз в самом +конце. Одна транзакция оставалась открытой на всё это время: держала соединение, тормозила +vacuum, а падение в конце обнуляло бы всё уже собранное. + +Отдельная тонкость, из-за которой наивная правка была бы неверной: функция умеет принимать +ЧУЖУЮ сессию (`db` аргументом). На ней коммитить по ходу нельзя — транзакцией распоряжается +вызывающий, и промежуточный commit зафиксировал бы его работу. То же правило, что для +плоского rollback. + +Тесты считают коммиты на сессии-двойнике, то есть проверяют поведение. На origin/main +коммит ровно один — красное по числу, а не по отсутствию символа. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from typing import Any +from unittest.mock import patch + + +class _Session: + def __init__(self) -> None: + self.commits = 0 + self.rollbacks = 0 + self.closed = False + + def commit(self) -> None: + self.commits += 1 + + def rollback(self) -> None: + self.rollbacks += 1 + + def close(self) -> None: + self.closed = True + + +def _run(*, own: bool, failing: set[str] | None = None) -> tuple[_Session, list[str], int]: + from app.services.site_finder import eias_heat_loader as mod + + failing = failing or set() + db = _Session() + visited: list[str] = [] + + def _fake_org(session: Any, org: str, _org_id: int) -> dict: + visited.append(org) + if org in failing: + raise RuntimeError(f"внешний реестр не ответил по {org}") + return {"rows": 1} + + with ( + patch.object(mod, "SessionLocal", lambda: db), + patch.object(mod, "load_org_reserves", _fake_org), + ): + mod.load_heat_reserves() if own else mod.load_heat_reserves(db=db) + + return db, visited, len(mod.ORGS) + + +def test_own_session_commits_per_organization() -> None: + """На своей сессии — коммит после каждой организации. + + На origin/main коммит ровно один на весь батч. + """ + db, visited, n_orgs = _run(own=True) + + assert len(visited) == n_orgs, f"обошли {len(visited)} организаций из {n_orgs}" + assert db.commits >= n_orgs, ( + f"коммитов {db.commits} при {n_orgs} организациях — транзакция остаётся открытой " + "на весь батч, поверх десятков минут внешнего HTTP" + ) + + +def test_borrowed_session_is_not_committed_per_organization() -> None: + """Контроль: на ЧУЖОЙ сессии промежуточных коммитов быть не должно. + + Иначе правка фиксировала бы работу вызывающего — та же ошибка, что плоский + rollback на общей сессии. + """ + db, visited, n_orgs = _run(own=False) + + assert len(visited) == n_orgs + assert db.commits == 1, ( + f"на чужой сессии {db.commits} коммитов — транзакцией распоряжается вызывающий" + ) + assert not db.closed, "чужая сессия закрыта — её закрывает вызывающий" + + +def test_failing_organization_does_not_stop_the_rest() -> None: + """Контроль: сбой одной организации не рвёт обход остальных.""" + db, visited, n_orgs = _run(own=True, failing={mod_org()}) + + assert len(visited) == n_orgs, f"обход прервался: {visited}" + assert db.rollbacks >= 1, "частичные записи сбойной организации не откачены" + + +def mod_org() -> str: + from app.services.site_finder import eias_heat_loader as mod + + return mod.ORGS[1][0] diff --git a/backend/tests/services/site_finder/test_2464_ird_lookups_savepoint.py b/backend/tests/services/site_finder/test_2464_ird_lookups_savepoint.py new file mode 100644 index 00000000..ad9039ff --- /dev/null +++ b/backend/tests/services/site_finder/test_2464_ird_lookups_savepoint.py @@ -0,0 +1,90 @@ +"""#2464: шесть lookup'ов ИРД глотали ошибку БД без SAVEPOINT. + +`build_ird_analyze_block` зовёт их подряд, В ОДНОМ словарном литерале, на ОБЩЕЙ +сессии `/analyze`. Каждый ловит `(OperationalError, ProgrammingError)` и возвращает +пустой результат — «таблица ещё не задеплоена, деградируем мягко». + +На Postgres упавший запрос оставляет транзакцию в aborted-состоянии. Значит первый +же сбой ронял не свой блок, а все следующие lookup'ы, `_geotech_risk`, +`_neighbors_summary` и запись прогона — то есть весь анализ. + +Правильный образец лежал рядом: `ppt_tep_lookup` (та же семья, читает ту же +`planning_projects`) оборачивает свой `db.execute` в `db.begin_nested()`. В +`zone_regulation` образец вообще в том же файле: `upsert_zone_regulation` обёрнут, +а `get_cached_zone_regulation` двадцатью строками ниже — нет. + +Двойник сессии воспроизводит семантику Postgres: у `MagicMock` aborted-состояния +нет, и проверка «сессия осталась рабочей» на нём зелена независимо от SAVEPOINT. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from sqlalchemy.exc import OperationalError + +from tests.support.pg_session import AbortedTransactionError, PostgresLikeSession + +_WKT = "POLYGON((60.6 56.83, 60.61 56.83, 60.61 56.84, 60.6 56.84, 60.6 56.83))" + + +def _sqla_error() -> OperationalError: + """Обработчики ловят конкретные типы SQLAlchemy, а не голый Exception.""" + return OperationalError("SELECT 1", {}, Exception("relation does not exist")) + + +def _call(name: str, db: Any) -> Any: + from app.services.site_finder import ( + functional_zone_lookup, + granddoc_lookup, + ird_overlay_lookup, + planning_lookup, + reservation_lookup, + zone_regulation, + ) + + return { + "planning": lambda: planning_lookup.parcel_planning_overlaps(db, _WKT), + "granddoc": lambda: granddoc_lookup.parcel_granddoc(db, _WKT), + "functional_zone": lambda: functional_zone_lookup.parcel_functional_zones(db, _WKT), + "reservation": lambda: reservation_lookup.parcel_reservations(db, _WKT), + "ird_overlay": lambda: ird_overlay_lookup.parcel_ird_overlaps(db, _WKT), + "zone_regulation": lambda: zone_regulation.get_cached_zone_regulation(db, "Ж-5"), + }[name]() + + +@pytest.mark.parametrize( + "name", + ["planning", "granddoc", "functional_zone", "reservation", "ird_overlay", "zone_regulation"], +) +def test_lookup_failure_leaves_shared_session_usable(name: str) -> None: + """Сбой одного lookup'а не должен уносить остальные пять и запись прогона.""" + db = PostgresLikeSession(error=_sqla_error()) + + _call(name, db) # деградирует мягко, как и обещает докстринг + + db.execute("SELECT 1") # следующий блок анализа + assert db.calls == 2, f"{name}: сессия не восстановилась после отката SAVEPOINT" + + +def test_double_still_poisons_without_a_savepoint() -> None: + """Контроль на инструмент: без SAVEPOINT двойник ОБЯЗАН отравляться. + + Без него все шесть проверок выше были бы зелёными по построению. + """ + db = PostgresLikeSession() + with pytest.raises(RuntimeError): + db.execute("boom") + with pytest.raises(AbortedTransactionError): + db.execute("SELECT 1") + + +def test_healthy_lookup_still_reads_rows() -> None: + """Контроль: без сбоя поведение прежнее — строки читаются, не пустой sentinel.""" + from app.services.site_finder import zone_regulation + + db = PostgresLikeSession(fail_on=(), row={"zone_index": "Ж-5", "max_far": 2.4}) + out = zone_regulation.get_cached_zone_regulation(db, "Ж-5") + + assert out is not None and out["zone_index"] == "Ж-5" diff --git a/backend/tests/services/site_finder/test_2464_teap_input_sanity.py b/backend/tests/services/site_finder/test_2464_teap_input_sanity.py new file mode 100644 index 00000000..e885f4a8 --- /dev/null +++ b/backend/tests/services/site_finder/test_2464_teap_input_sanity.py @@ -0,0 +1,132 @@ +"""Невозможные параметры регламента не превращаются в финансовые цифры (#2464). + +`synthesize_teap_from_buildability` проверяла только `> 0`. Процент застройки 150 +давал пятно БОЛЬШЕ участка, а дальше — жилую площадь, число квартир и выручку, +физически невозможные, но поданные как обычные цифры финмодели. + +Параметры приходят из ПЗЗ-регламента (`zone_regulation_cache`) — это внешние +разобранные данные, то есть граница доверия. + +Невозможное значение теперь отбрасывается, а не роняет расчёт: если рядом есть +КСИТ, GFA считается по нему и остаётся верной. Лучше отсутствие параметра, чем +неверный. + +Границы взяты с запасом к реальным данным прода 20.08.2026 (33 строки +zone_regulation_cache: pct 0..100, far 1..4, floors 0..5). +""" + +from __future__ import annotations + +import pytest + +from app.services.site_finder.parcel_financial import synthesize_teap_from_buildability + +_КЛАСС = "comfort" +_ПЛОЩАДЬ = 10_000.0 + + +def _teap(**kw): + params = dict( + area_m2=_ПЛОЩАДЬ, + max_far=None, + max_building_pct=None, + max_floors=None, + housing_class=_КЛАСС, + ) + params.update(kw) + return synthesize_teap_from_buildability(**params) # type: ignore[arg-type] + + +def test_building_pct_above_100_does_not_exceed_the_parcel() -> None: + """Головной: пятно застройки не может быть больше самого участка. + + На origin/main pct=150 даёт built = 15 000 м² на участке 10 000 м². + """ + t = _teap(max_building_pct=150.0, max_far=2.0) + assert t is not None, "расчёт не должен падать — КСИТ задан, GFA выводима" + assert t.built_area_sqm <= _ПЛОЩАДЬ, ( + f"пятно {t.built_area_sqm} больше участка {_ПЛОЩАДЬ} — " + "физически невозможное число подано как обычная цифра финмодели" + ) + + +def test_far_is_used_when_pct_is_impossible() -> None: + """Отбрасываем только испорченный параметр, остальной расчёт остаётся верным.""" + t = _teap(max_building_pct=150.0, max_far=2.0) + assert t is not None + assert t.total_floor_area_sqm == pytest.approx(_ПЛОЩАДЬ * 2.0), ( + f"GFA посчитана не по КСИТ: {t.total_floor_area_sqm}" + ) + + +def test_impossible_far_is_dropped() -> None: + """КСИТ 500 — признак порчи разбора, а не сверхплотной застройки.""" + t = _teap(max_far=500.0, max_building_pct=40.0, max_floors=10) + assert t is not None + assert t.total_floor_area_sqm == pytest.approx(_ПЛОЩАДЬ * 0.4 * 10), ( + f"использован невозможный КСИТ: GFA={t.total_floor_area_sqm}" + ) + + +def test_impossible_floors_is_dropped() -> None: + """Этажность 900 отбрасывается; при живом КСИТ расчёт продолжается.""" + t = _teap(max_far=2.0, max_floors=900) + assert t is not None + assert t.total_floor_area_sqm == pytest.approx(_ПЛОЩАДЬ * 2.0) + assert t.built_area_sqm <= _ПЛОЩАДЬ + + +def test_all_inputs_impossible_returns_none_not_a_number() -> None: + """Контроль честности: если вменяемых параметров не осталось — None. + + Не «ноль» и не выдуманное значение: caller штатно показывает отсутствие + финоценки с caveat. + """ + assert _teap(max_building_pct=150.0, max_far=500.0, max_floors=900) is None + + +@pytest.mark.parametrize( + "pct,far,floors", + [ + (80.0, 2.4, 2), + (100.0, 4.0, 5), + (80.0, 2.5, None), + (None, 4.0, None), + (40.0, None, 10), + ], +) +def test_real_prod_combinations_unchanged(pct, far, floors) -> None: + """Контроль от переусердствования: сочетания С ПРОДА считаются как раньше. + + Взяты из zone_regulation_cache 20.08.2026 — если защита отсечёт хоть одно, + она отсекает законные значения, а не порчу. + """ + t = _teap(max_building_pct=pct, max_far=far, max_floors=floors) + assert t is not None, f"законное сочетание отвергнуто: pct={pct} far={far} floors={floors}" + assert t.total_floor_area_sqm > 0 + + +def test_boundary_100_pct_is_allowed() -> None: + """Граница включительно: 100 % застройки законно (плотная городская зона, есть на проде).""" + t = _teap(max_building_pct=100.0, max_floors=5) + assert t is not None + assert t.built_area_sqm == pytest.approx(_ПЛОЩАДЬ) + + +def test_footprint_never_exceeds_parcel_even_without_pct_and_floors() -> None: + """Второй дефект той же функции, найденный этими тестами (#2464). + + Ветка «нет ни процента, ни этажности → пятно = GFA» неявно предполагает один + этаж. При КСИТ > 1 она давала пятно БОЛЬШЕ участка: 10 000 м² с far=2 → 20 000 м². + Дефект существовал и до правки санитарии — на origin/main этот тест красный + сам по себе, без всяких невозможных входных значений. + """ + t = _teap(max_far=2.0) # только КСИТ: ни процента, ни этажности + assert t is not None + assert t.built_area_sqm <= _ПЛОЩАДЬ, ( + f"пятно {t.built_area_sqm} на участке {_ПЛОЩАДЬ} — нарушена геометрия, " + "а не только правдоподобие регламента" + ) + assert t.total_floor_area_sqm == pytest.approx(_ПЛОЩАДЬ * 2.0), ( + "GFA не должна меняться от ограничения пятна" + ) diff --git a/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py b/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py new file mode 100644 index 00000000..18f17a18 --- /dev/null +++ b/backend/tests/services/site_finder/test_2464_zouit_label_all_kinds.py @@ -0,0 +1,124 @@ +"""Подпись сетевого обременения называет ВСЕ виды, а не первый попавшийся (#2464). + +`cad_utility_label` брался от первого overlap'а с распознанным `network_kind`, а +покрытие агрегировалось по ВСЕМУ bucket'у. Участок под двумя видами охранных зон +получал в отчёте конкретную причину, дающую малую часть площади: + + «Сетевое обременение (теплосеть) покрывает 74% участка» + ← при том, что теплосеть даёт 10%, а остальные 64% — инженерные коммуникации. + +Это не редкость. Прод 20.08.2026: **316 пересечений охранных зон РАЗНЫХ видов**, +155 зон вовлечено; самая частая пара — «тепловых сетей» × «инженерных коммуникаций» +(296 из 316). + +Подпись, кроме того, зависела от порядка overlap'ов (`ORDER BY reg_numb_border, id`), +который к покрытию отношения не имеет. Теперь виды сортируются. +""" + +from __future__ import annotations + +from app.services.site_finder.gate_verdict import compute_gate_verdict + +_ЗОНА = {"zone_code": "Ж-2", "zone_name": "Жилая"} + + +def _overlap(type_zone: str, coverage: float) -> dict: + return { + "source": "cad_zouit", + "type_zone": type_zone, + "layer": type_zone, + "name": type_zone, + "coverage_pct": coverage, + } + + +def _блокер(verdict: dict, code: str) -> dict | None: + return next((b for b in verdict["blockers"] if b["code"] == code), None) + + +def test_label_names_every_kind_that_feeds_the_coverage() -> None: + """Головной: обе сети названы, потому что обе вошли в покрытие. + + На origin/main в подписи стоит только «теплосеть», хотя 64 % площади дают + инженерные коммуникации. + """ + overlaps = [ + _overlap("Охранная зона тепловых сетей", 0.10), + _overlap("Охранная зона инженерных коммуникаций", 0.64), + ] + v = compute_gate_verdict(_ЗОНА, overlaps, [], {"available": True}) + b = _блокер(v, "ZOUIT_NETWORK_OBREMENENIE") + assert b is not None, f"нет сетевого блокера: {v['blockers']}" + detail = b["detail"] + assert "инженер" in detail.lower(), f"вид, дающий большую часть покрытия, не назван: {detail!r}" + assert "тепл" in detail.lower(), f"второй вид пропал: {detail!r}" + + +def test_plural_wording_when_several_kinds() -> None: + """Множественное число, когда обременений несколько — читается как факт, а не описка.""" + overlaps = [ + _overlap("Охранная зона тепловых сетей", 0.30), + _overlap("Охранная зона объектов электроэнергетики", 0.44), + ] + v = compute_gate_verdict(_ЗОНА, overlaps, [], {"available": True}) + b = _блокер(v, "ZOUIT_NETWORK_OBREMENENIE") + assert b is not None + assert b["detail"].startswith("Сетевые обременения"), b["detail"] + + +def test_single_kind_wording_unchanged() -> None: + """Контроль от переусердствования: один вид — прежняя формулировка в единственном числе.""" + v = compute_gate_verdict( + _ЗОНА, + [_overlap("Охранная зона трубопроводов (газопроводов)", 0.8)], + [], + {"available": True}, + ) + b = _блокер(v, "ZOUIT_NETWORK_OBREMENENIE") + assert b is not None + assert b["detail"].startswith("Сетевое обременение ("), b["detail"] + + +def test_label_is_independent_of_overlap_order() -> None: + """Контроль детерминизма: порядок overlap'ов не должен менять подпись. + + Он задаётся `ORDER BY reg_numb_border, id` — к покрытию отношения не имеет. + """ + a = _overlap("Охранная зона тепловых сетей", 0.30) + b_ = _overlap("Охранная зона инженерных коммуникаций", 0.44) + d1 = _блокер( + compute_gate_verdict(_ЗОНА, [a, b_], [], {"available": True}), + "ZOUIT_NETWORK_OBREMENENIE", + ) + d2 = _блокер( + compute_gate_verdict(_ЗОНА, [b_, a], [], {"available": True}), + "ZOUIT_NETWORK_OBREMENENIE", + ) + assert d1 is not None and d2 is not None + assert d1["detail"] == d2["detail"], ( + f"подпись зависит от порядка:\n{d1['detail']}\n{d2['detail']}" + ) + + +def test_duplicate_kind_named_once() -> None: + """Контроль: две зоны ОДНОГО вида не дают дубль в подписи.""" + overlaps = [ + _overlap("Охранная зона тепловых сетей", 0.40), + _overlap("Охранная зона тепловых сетей", 0.35), + ] + v = compute_gate_verdict(_ЗОНА, overlaps, [], {"available": True}) + b = _блокер(v, "ZOUIT_NETWORK_OBREMENENIE") + assert b is not None + assert b["detail"].lower().count("тепл") == 1, b["detail"] + assert b["detail"].startswith("Сетевое обременение ("), b["detail"] + + +def test_thin_coverage_still_warning_not_blocker() -> None: + """Контроль: правка подписи не трогает area-gate — тонкая полоса остаётся warning.""" + v = compute_gate_verdict( + _ЗОНА, + [_overlap("Охранная зона тепловых сетей", 0.05)], + [{"name": "ТП-1"}], + {"available": True}, + ) + assert v["blockers"] == [], v["blockers"] diff --git a/backend/tests/services/site_finder/test_2464a_savepoint_regressions.py b/backend/tests/services/site_finder/test_2464a_savepoint_regressions.py new file mode 100644 index 00000000..1ab2fa3e --- /dev/null +++ b/backend/tests/services/site_finder/test_2464a_savepoint_regressions.py @@ -0,0 +1,185 @@ +"""#2464 кластер A: снятие SAVEPOINT обязано ронять тест. + +Три места кластера отмечены закрытыми, код в них правильный — но мутационный +прогон 19.08.2026 показал, что снятие `with db.begin_nested():` не роняет ни одного +теста: + + saturation.py снят SAVEPOINT -> 465 passed + zone_regulation.py снят SAVEPOINT -> 195 passed + pat_lookup.py снят SAVEPOINT -> 21 passed + +Причина общая: существующие проверки построены на `MagicMock`, у которого нет +aborted-состояния, поэтому второй `execute` проходит при любом коде. Такая проверка +ловит только проглоченное исключение, но не отсутствие SAVEPOINT. + +Здесь используется двойник из `tests/support/pg_session.py`, воспроизводящий +семантику Postgres. Проверяется СВОЙСТВО — «после сбоя сессия вызывающего осталась +рабочей», — а не наличие вызова `begin_nested` в коде: последнее зафиксировало бы +реализацию, а первое переживёт любой корректный способ починки. + +Отдельно проверяется сам двойник: без SAVEPOINT он ОБЯЗАН отравляться. Без этого +контроля тесты ниже были бы зелёными по построению — ровно та ловушка, которую они +призваны закрыть. +""" + +from __future__ import annotations + +import pytest +from sqlalchemy.exc import OperationalError + +from app.services.scrapers.ekb_geoportal_client import ZoneRegulation +from app.services.site_finder.pat_lookup import parcel_pat_subzones +from app.services.site_finder.saturation import compute_district_saturation +from app.services.site_finder.zone_regulation import upsert_zone_regulation +from tests.support.pg_session import AbortedTransactionError, PostgresLikeSession + +_WKT = "POLYGON((60.6 56.83, 60.61 56.83, 60.61 56.84, 60.6 56.84, 60.6 56.83))" + + +def _sqla_error() -> OperationalError: + """Обработчики ловят конкретные типы SQLAlchemy, а не голый Exception.""" + return OperationalError("SELECT 1", {}, Exception("relation does not exist")) + + +# ── контроль на сам двойник ──────────────────────────────────────────────────── + + +def test_double_poisons_the_session_without_a_savepoint() -> None: + db = PostgresLikeSession() + with pytest.raises(RuntimeError): + db.execute("boom") + with pytest.raises(AbortedTransactionError): + db.execute("SELECT 1") + + +def test_double_recovers_when_the_savepoint_unwinds() -> None: + db = PostgresLikeSession() + with pytest.raises(RuntimeError), db.begin_nested(): + db.execute("boom") + db.execute("SELECT 1") # не должно бросить + assert db.savepoints_entered == 1 + + +# ── сами регрессии ───────────────────────────────────────────────────────────── + + +def test_saturation_leaves_the_caller_session_usable() -> None: + """`compute_district_saturation` делит сессию с analyze_parcel.""" + db = PostgresLikeSession() + + assert compute_district_saturation(db, "Чкаловский") is None + + db.execute("SELECT 1") # следующий блок analyze_parcel + assert db.calls == 2 + + +def test_pat_subzones_leaves_the_caller_session_usable() -> None: + """`parcel_pat_subzones` вызывается из /analyze на общей request-сессии.""" + db = PostgresLikeSession(error=_sqla_error()) + + assert parcel_pat_subzones(db, _WKT) == [] + + db.execute("SELECT 1") + assert db.calls == 2 + + +def test_zone_regulation_upsert_leaves_the_caller_session_usable() -> None: + """`upsert_zone_regulation` пишет в кэш посреди чужой транзакции.""" + db = PostgresLikeSession(error=_sqla_error()) + reg = ZoneRegulation( + zone_index="Ж-5", + zone_full_name="Ж-5 зона", + main_vri=[], + conditional_vri=[], + auxiliary_vri=[], + limit_params=[], + ) + + assert upsert_zone_regulation(db, reg) is None + + db.execute("SELECT 1") + assert db.calls == 2 + + +# ── контроль: без сбоя поведение прежнее ─────────────────────────────────────── + + +def test_no_failure_means_no_savepoint_side_effects() -> None: + """Исправная БД: pat_lookup отдаёт строки, лишних откатов нет.""" + db = PostgresLikeSession( + fail_on=(), + rows=[ + { + "subzone_no": "ПАТ-1", + "name": "Кольцово", + "restriction": "высота", + "aerodrome": "USSS", + } + ], + ) + + out = parcel_pat_subzones(db, _WKT) + + assert out and out[0]["subzone_no"] == "ПАТ-1" + assert db.aborted is False + + +# ── connection_capacity_lookup: пять SAVEPOINT, снятие ОДНОГО не ловилось ─────── +# +# У файла есть свой тест (`test_connection_capacity_savepoint.py`), и он краснеет, +# если снять ВСЕ пять. Но проверка там — `assert db.begin_nested.call_count >= 1`, +# то есть «хоть один savepoint был», а не «этот запрос обёрнут». Мутационный прогон +# 19.08: снятие одного из пяти проходит незамеченным, а рефакторинг одной функции — +# ровно такой случай. +# +# Проверки ниже адресные: у каждой своя функция и свой двойник, поэтому снятие +# SAVEPOINT в любой ОДНОЙ из них роняет ровно свою проверку. + + +def test_gas_city_grs_leaves_the_caller_session_usable() -> None: + """Роняем ВТОРОЙ запрос: первый принадлежит `_query_gas_outlet_counts`, который + эта функция зовёт внутри себя и у которого свой SAVEPOINT. Целиться надо в + запрос самой city_grs, иначе проверка сторожила бы соседа.""" + from app.services.site_finder.connection_capacity_lookup import _query_gas_city_grs + + db = PostgresLikeSession(fail_on=(2,), error=_sqla_error()) + out = _query_gas_city_grs(db) + + assert out["city_grs"] == [] + db.execute("SELECT 1") + + +def test_gas_outlet_counts_leaves_the_caller_session_usable() -> None: + from app.services.site_finder.connection_capacity_lookup import _query_gas_outlet_counts + + db = PostgresLikeSession(error=_sqla_error()) + _query_gas_outlet_counts(db) + + db.execute("SELECT 1") + + +def test_gas_outlet_points_leaves_the_caller_session_usable() -> None: + from app.services.site_finder.connection_capacity_lookup import _query_gas_outlet_points + + db = PostgresLikeSession(error=_sqla_error()) + assert _query_gas_outlet_points(db, _WKT) == [] + + db.execute("SELECT 1") + + +def test_heat_latest_leaves_the_caller_session_usable() -> None: + from app.services.site_finder.connection_capacity_lookup import _query_heat_latest + + db = PostgresLikeSession(error=_sqla_error()) + _query_heat_latest(db) + + db.execute("SELECT 1") + + +def test_nearby_network_zones_leaves_the_caller_session_usable() -> None: + from app.services.site_finder.connection_capacity_lookup import _query_nearby_network_zones + + db = PostgresLikeSession(error=_sqla_error()) + assert _query_nearby_network_zones(db, _WKT) == [] + + db.execute("SELECT 1") diff --git a/backend/tests/services/site_finder/test_best_layouts.py b/backend/tests/services/site_finder/test_best_layouts.py index 8825c9c5..6ddc3906 100644 --- a/backend/tests/services/site_finder/test_best_layouts.py +++ b/backend/tests/services/site_finder/test_best_layouts.py @@ -543,14 +543,14 @@ def test_supply_joins_flats_per_object_latest_snapshot() -> None: sql_text = str(_SUPPLY_BATCH_SQL.text) # per-object последний снимок flats assert "flats_latest" in sql_text, "нет flats_latest CTE → supply снова на глобал-max снимке" - assert ( - "DISTINCT ON (f.obj_id)" in sql_text - ), "flats не дедупятся per-object → нет per-obj снимка" + assert "DISTINCT ON (f.obj_id)" in sql_text, ( + "flats не дедупятся per-object → нет per-obj снимка" + ) # НЕ должно быть джойна по единой внешней дате-параметру (регрессия #1944) assert ":latest_snap" not in sql_text, "глобальный :latest_snap вернулся → supply=0 регрессия" - assert ( - "MAX(snapshot_date)" not in sql_text - ), "глобальный MAX(snapshot_date) → supply=0 регрессия" + assert "MAX(snapshot_date)" not in sql_text, ( + "глобальный MAX(snapshot_date) → supply=0 регрессия" + ) # ── Тесты _cap_and_redistribute (Fix SF-09 review) ─────────────────────────── @@ -592,16 +592,16 @@ def test_cap_and_redistribute_invariants( """ result, cap_skipped = _cap_and_redistribute(pct_map) - assert ( - cap_skipped == expect_pathological - ), f"cap_skipped={cap_skipped} но ожидали {expect_pathological} для {pct_map}" - assert ( - sum(result.values()) == 100 - ), f"sum={sum(result.values())} != 100 для {pct_map} → {result}" + assert cap_skipped == expect_pathological, ( + f"cap_skipped={cap_skipped} но ожидали {expect_pathological} для {pct_map}" + ) + assert sum(result.values()) == 100, ( + f"sum={sum(result.values())} != 100 для {pct_map} → {result}" + ) if not expect_pathological: - assert ( - max(result.values()) <= MAX_BUCKET_SHARE_PCT - ), f"max={max(result.values())} > cap={MAX_BUCKET_SHARE_PCT} для {pct_map} → {result}" + assert max(result.values()) <= MAX_BUCKET_SHARE_PCT, ( + f"max={max(result.values())} > cap={MAX_BUCKET_SHARE_PCT} для {pct_map} → {result}" + ) @pytest.mark.parametrize( @@ -623,14 +623,14 @@ def test_cap_reproduced_failing_cases( ) -> None: """Review round-2 reproduced cases: 2-bucket — pathological, 3-bucket — fit cap.""" result, cap_skipped = _cap_and_redistribute(deals) - assert ( - cap_skipped == expect_pathological - ), f"cap_skipped={cap_skipped} ожидали {expect_pathological} для {label}" + assert cap_skipped == expect_pathological, ( + f"cap_skipped={cap_skipped} ожидали {expect_pathological} для {label}" + ) assert sum(result.values()) == 100, f"sum != 100 для {label} → {result}" if not expect_pathological: - assert ( - max(result.values()) <= MAX_BUCKET_SHARE_PCT - ), f"max={max(result.values())} > {MAX_BUCKET_SHARE_PCT} для {label} → {result}" + assert max(result.values()) <= MAX_BUCKET_SHARE_PCT, ( + f"max={max(result.values())} > {MAX_BUCKET_SHARE_PCT} для {label} → {result}" + ) def test_cap_iteration_count_bounded() -> None: @@ -773,9 +773,9 @@ def test_group_radius_objects_prod_case_groups() -> None: assert len(groups) == 6, f"ожидалось 6 групп, получено {len(groups)}: {got}" # load-bearing инвариант: 7 ключей и безымянные Эфесы разделены >300 м assert frozenset({15731}) in got, "7 ключей должна быть отдельной группой" - assert ( - frozenset({55320, 55321, 55322}) in got - ), "3 безымянных Эфеса — один кластер, отдельный от 7 ключей" + assert frozenset({55320, 55321, 55322}) in got, ( + "3 безымянных Эфеса — один кластер, отдельный от 7 ключей" + ) assert got == expected @@ -1155,9 +1155,9 @@ def test_supply_only_velocity_group_not_duplicated() -> None: ] for call in supply_calls: params = call.args[1] if len(call.args) > 1 else call.kwargs - assert "Траектория" not in ( - params.get("names") or [] - ), "velocity-проект не должен уходить в supply-only запрос" + assert "Траектория" not in (params.get("names") or []), ( + "velocity-проект не должен уходить в supply-only запрос" + ) def test_supply_only_empty_snapshot_yields_empty_block() -> None: diff --git a/backend/tests/services/site_finder/test_competitors_parking.py b/backend/tests/services/site_finder/test_competitors_parking.py index 58fb795d..fecc42eb 100644 --- a/backend/tests/services/site_finder/test_competitors_parking.py +++ b/backend/tests/services/site_finder/test_competitors_parking.py @@ -133,18 +133,14 @@ def _competitors_response(*obj_ids: int) -> CompetitorsResponse: def test_parking_wire_happy_path(monkeypatch: Any) -> None: """top-конкурент сматчился на здание → получает parking_ratio из НСПД.""" - monkeypatch.setattr( - competitors_mod, "get_competitors", lambda **_: _competitors_response(101) - ) + monkeypatch.setattr(competitors_mod, "get_competitors", lambda **_: _competitors_response(101)) monkeypatch.setattr( competitors_mod, "resolve_cad_for_domrf", lambda *_a, **_k: BuildingMatch("66:41:0106036:183", 40995027, 12.3), ) listing = ObjectsListing(objdoc_id=40995027, flats_count=200, parking_count=100) - monkeypatch.setattr( - competitors_mod, "get_building_premises_for_match", lambda _m: listing - ) + monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", lambda _m: listing) out = competitors_mod.get_competitors_parking( MagicMock(), "66:41:0303001:1", CompetitorsRequest() @@ -192,9 +188,7 @@ def test_parking_wire_limits_to_top_n(monkeypatch: Any) -> None: def test_parking_wire_no_geom_match_graceful(monkeypatch: Any) -> None: """geom-match промахнулся → конкурент в items с None-полями, matched_count=0.""" - monkeypatch.setattr( - competitors_mod, "get_competitors", lambda **_: _competitors_response(7) - ) + monkeypatch.setattr(competitors_mod, "get_competitors", lambda **_: _competitors_response(7)) monkeypatch.setattr(competitors_mod, "resolve_cad_for_domrf", lambda *_a, **_k: None) premises = MagicMock() monkeypatch.setattr(competitors_mod, "get_building_premises_for_match", premises) @@ -212,9 +206,7 @@ def test_parking_wire_no_geom_match_graceful(monkeypatch: Any) -> None: def test_parking_wire_premises_none_keeps_cad(monkeypatch: Any) -> None: """Здание сматчилось, но НСПД premises=None → cad есть, parking_ratio None.""" - monkeypatch.setattr( - competitors_mod, "get_competitors", lambda **_: _competitors_response(9) - ) + monkeypatch.setattr(competitors_mod, "get_competitors", lambda **_: _competitors_response(9)) monkeypatch.setattr( competitors_mod, "resolve_cad_for_domrf", @@ -234,9 +226,7 @@ def test_parking_wire_premises_none_keeps_cad(monkeypatch: Any) -> None: def test_parking_wire_premises_exception_graceful(monkeypatch: Any) -> None: """Неожиданное исключение в premises-lookup → конкурент без паркинга, не 500.""" - monkeypatch.setattr( - competitors_mod, "get_competitors", lambda **_: _competitors_response(11) - ) + monkeypatch.setattr(competitors_mod, "get_competitors", lambda **_: _competitors_response(11)) monkeypatch.setattr( competitors_mod, "resolve_cad_for_domrf", diff --git a/backend/tests/services/site_finder/test_pat_subzones.py b/backend/tests/services/site_finder/test_pat_subzones.py index 826f8ef0..61b79e38 100644 --- a/backend/tests/services/site_finder/test_pat_subzones.py +++ b/backend/tests/services/site_finder/test_pat_subzones.py @@ -321,9 +321,9 @@ def test_json_path_candidates_end_with_expected_suffix() -> None: expected_suffix = pathlib.Path("data") / "pat" / "koltsovo_367p_subzones.json" for candidate in _JSON_PATH_CANDIDATES: # последние 3 части пути совпадают с ожидаемыми - assert ( - candidate.parts[-3:] == expected_suffix.parts - ), f"Кандидат {candidate} не заканчивается на {expected_suffix}" + assert candidate.parts[-3:] == expected_suffix.parts, ( + f"Кандидат {candidate} не заканчивается на {expected_suffix}" + ) def test_resolve_json_path_finds_real_file() -> None: diff --git a/backend/tests/services/site_finder/test_permits_nearby.py b/backend/tests/services/site_finder/test_permits_nearby.py index ada3c976..f39f8832 100644 --- a/backend/tests/services/site_finder/test_permits_nearby.py +++ b/backend/tests/services/site_finder/test_permits_nearby.py @@ -189,3 +189,42 @@ def test_db_error_propagates_not_swallowed() -> None: db.execute.side_effect = RuntimeError("db down") with pytest.raises(RuntimeError, match="db down"): get_permits_nearby(db, _WKT) + + +def test_sql_excludes_the_amendments_group() -> None: + """Выборка сужена до РНС/РВЭ явно — иначе total_count перестанет сходиться (#2986). + + С #2986 в gisogd_permits появилась третья группа 'IZ' (изменения в разрешение). + Агрегат обещает `total_count = rs_count + rv_count`, а строки 'IZ' попадали бы в + total и не попадали ни в один счётчик. Показывать ли изменения в §6 — вопрос + продуктовый; до его решения сужение должно стоять в запросе явно, а не + держаться на том, что таких строк «пока нет». + """ + sql = str(_PERMITS_NEARBY_SQL) + assert "doc_group IN ('RS', 'RV')" in sql, ( + f"запрос не сужен по группе — строки 'IZ' сломают total_count:\n{sql}" + ) + + +def test_total_equals_rs_plus_rv_even_if_iz_leaks_in() -> None: + """Контроль инварианта на данных: даже если строка 'IZ' просочилась, счёт сходится. + + Проверяем не текст запроса, а поведение агрегата: сумма частей равна целому. + Тест краснеет, если кто-то снимет сужение и не добавит счётчик для 'IZ'. + """ + rows = [ + { + "doc_group": g, + "doc_name": f"Документ {g}", + "doc_num": f"№{i}", + "date_doc": dt.date(2026, 2, 26), + "date_reg": dt.date(2026, 2, 26), + "approved_organization": "Администрация", + "distance_m": 10.0 + i, + } + for i, g in enumerate(("RS", "RV", "RS")) + ] + db = MagicMock() + db.execute.return_value.mappings.return_value.all.return_value = rows + res = get_permits_nearby(db, _WKT) + assert res["total_count"] == res["rs_count"] + res["rv_count"], res diff --git a/backend/tests/services/site_finder/test_supply_layers.py b/backend/tests/services/site_finder/test_supply_layers.py index 0e11e83f..7a10105f 100644 --- a/backend/tests/services/site_finder/test_supply_layers.py +++ b/backend/tests/services/site_finder/test_supply_layers.py @@ -395,12 +395,12 @@ class TestLayer2Hidden: norm = " ".join(sql.split()) # Оба FILTER-предложения (n_with_free_flats COUNT и hidden_units SUM) должны # гейтить flat_count IS NOT NULL наравне с free_flats IS NOT NULL. - assert ( - norm.count("flat_count IS NOT NULL") == 2 - ), f"expected flat_count IS NOT NULL guard on both COUNT and SUM filters:\n{sql}" - assert ( - norm.count("free_flats IS NOT NULL") == 2 - ), f"expected free_flats IS NOT NULL guard on both COUNT and SUM filters:\n{sql}" + assert norm.count("flat_count IS NOT NULL") == 2, ( + f"expected flat_count IS NOT NULL guard on both COUNT and SUM filters:\n{sql}" + ) + assert norm.count("free_flats IS NOT NULL") == 2, ( + f"expected free_flats IS NOT NULL guard on both COUNT and SUM filters:\n{sql}" + ) def test_row_with_null_flat_count_and_positive_free_flats_excluded_end_to_end( self, diff --git a/backend/tests/services/test_2464_docs_match_code.py b/backend/tests/services/test_2464_docs_match_code.py new file mode 100644 index 00000000..99eea44c --- /dev/null +++ b/backend/tests/services/test_2464_docs_match_code.py @@ -0,0 +1,94 @@ +"""Числа в документации сверяются с кодом, а не живут отдельно (#2464). + +Оба расхождения ниже — реальные, найденные 20.08.2026, и оба были незаметны: цифра в +комментарии не проверяется ничем, а расходится тихо при первой же правке констант. + + • macro_coefficient: комментарий обещал сумму backed-весов 0.45. С #946 inflation + стал backed-каналом с весом 0.08, сумма стала 0.53 — комментарий не обновили. + + • nspd_client.search_by_quarter: docstring обещал 6/11/22 запроса и ~3.6с/~6.6с/~13с. + Фактически три из пяти core-слоёв и ВСЕ zouit/risk идут grid-walk'ом по 49 запросов: + 150/395/934 запроса, ~90с/~237с/~560с. Занижение в 25-42 раза — а объём запросов + здесь прямой фактор WAF-риска. + +Гейт сверяет то, что НАПИСАНО, с тем, что ВЫЧИСЛЯЕТСЯ из констант. Красное здесь +означает «текст разошёлся с кодом», а не «текст непривычно отформатирован». +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import re +from pathlib import Path + + +def test_backed_weight_sum_in_comment_matches_constants() -> None: + """Сумма из комментария обязана совпасть с суммой backed-констант.""" + from app.services.forecasting import macro_coefficient as mc + + src = Path(mc.__file__).read_text() + m = re.search(r"СУММА backed-весов = ([0-9.]+)", src) + assert m is not None, "в файле пропала строка «СУММА backed-весов = …» — гейт ослеп" + documented = float(m.group(1)) + + actual = round( + mc._W_RATE + mc._W_MORTG_RATE + mc._W_ISSUANCE + mc._W_OVERDUE + mc._W_INFLATION, 4 + ) + assert documented == actual, ( + f"в комментарии сумма backed-весов {documented}, по константам {actual} — " + "текст разошёлся с кодом" + ) + + +def _documented_request_counts(src: str) -> tuple[int, int, int]: + core = re.search(r"core only:.*?= (\d+) запрос", src) + zouit = re.search(r"\+zouit:\s*\+\d+\*\d+\s*= (\d+) запрос", src) + risks = re.search(r"\+risks:\s*\+\d+\*\d+\s*= (\d+) запрос", src) + assert core and zouit and risks, "в docstring пропала смета HTTP-запросов — гейт ослеп" + return int(core.group(1)), int(zouit.group(1)), int(risks.group(1)) + + +def test_request_cost_in_docstring_matches_layer_dispatch() -> None: + """Смета запросов обязана следовать из _GRID_WALK_LAYERS, а не из памяти автора.""" + from app.services.scrapers import nspd_client as nc + + src = Path(nc.__file__).read_text() + doc_core, doc_zouit, doc_risks = _documented_request_counts(src) + + grid = set(nc._GRID_WALK_LAYERS) + core_layers = [ + "parcels", + "buildings", + "territorial_zones", + "red_lines", + "engineering_structures", + ] + grid_n = 7 + per_grid = grid_n * grid_n + + core_grid = sum(1 for c in core_layers if c in grid) + core_legacy = len(core_layers) - core_grid + n_zouit = len(nc.NSPDClient.QUARTER_ZOUIT_LAYERS) + n_risks = len(nc.NSPDClient.QUARTER_RISK_LAYERS) + + exp_core = 1 + core_legacy + core_grid * per_grid + exp_zouit = exp_core + n_zouit * per_grid + exp_risks = exp_zouit + n_risks * per_grid + + assert (doc_core, doc_zouit, doc_risks) == (exp_core, exp_zouit, exp_risks), ( + f"docstring обещает {doc_core}/{doc_zouit}/{doc_risks} запросов, " + f"по диспетчеризации слоёв выходит {exp_core}/{exp_zouit}/{exp_risks}" + ) + + +def test_grid_walk_membership_is_not_empty() -> None: + """Контроль на сам гейт: если _GRID_WALK_LAYERS опустеет, расчёт станет + тавтологически совпадать с любой мелкой цифрой в docstring.""" + from app.services.scrapers import nspd_client as nc + + grid = set(nc._GRID_WALK_LAYERS) + assert len(grid) >= 10, f"grid-walk слоёв всего {len(grid)} — смета считается не по тому" + assert "territorial_zones" in grid, "core-слой выпал из grid-walk — пересчитать смету" diff --git a/backend/tests/services/test_2464_docstring_matches_code.py b/backend/tests/services/test_2464_docstring_matches_code.py new file mode 100644 index 00000000..b14e07cc --- /dev/null +++ b/backend/tests/services/test_2464_docstring_matches_code.py @@ -0,0 +1,110 @@ +"""Две докстроки обещали то, чего в коде нет (#2464). + +1. `QuarterDump` (nspd_client): «Default = только core, чтобы не сжигать rate-limit + на 17 запросов». Фактический дефолт `search_by_quarter` — `include_zouit=True`, + то есть 5 ЗОУИТ-слоёв входят в дефолтный вызов. Числа 17 тоже нет: grid-walk при + grid_n=7 даёт по 49 запросов на слой. Причём докстрока самого метода 640 строками + ниже пишет верно — «Default True»: правильный образец лежал рядом с дефектом. + +2. `find_active_on_demand_job` (cadastre_fetch): «Если в БД есть FAILED on-demand за + последние 60 секунд — тоже None (чтобы повторно пробовать)». В SQL нет ни слова + 'failed', ни какого-либо временного фильтра. Обещание вдвойне вредно: оно + подразумевало, что failed СТАРШЕ 60 секунд вернётся как активный (не вернётся), + и отправляло отлаживающего искать окно, которого нет. + +Гейты сверяют утверждение докстроки с кодом, а не читаемость текста. +""" + +from __future__ import annotations + +import inspect + + +def _body(fn) -> str: + """Тело функции без её докстроки. + + НЕ `split('\"\"\"')[-1]`: SQL внутри сам обёрнут в тройные кавычки, поэтому + последний кусок — это хвост ПОСЛЕ запроса, и проверка шла бы по огрызку. + Отбрасываем ровно первую докстроку и склеиваем остальное обратно. + """ + части = inspect.getsource(fn).split('"""') + return '"""'.join(части[2:]) if len(части) > 2 else части[-1] + + +def test_quarter_dump_docstring_matches_the_real_default() -> None: + """Головной 1: если докстрока обещает «только core» — дефолт обязан быть False. + + На origin/main она это обещает, а `include_zouit` по умолчанию True. + """ + from app.services.scrapers.nspd_client import NSPDClient, QuarterDump + + doc = inspect.getdoc(QuarterDump) or "" + default = inspect.signature(NSPDClient.search_by_quarter).parameters["include_zouit"].default + обещает_только_core = "Default = только core" in doc + assert not (обещает_только_core and default is True), ( + f"докстрока QuarterDump обещает «Default = только core», " + f"а include_zouit по умолчанию {default}" + ) + + +def test_quarter_dump_docstring_does_not_claim_17_requests() -> None: + """Контроль числа: «17 запросов» противоречит grid-walk (49 запросов на слой). + + Просто убрать слово «core» было бы недостаточно — довод про rate-limit + держался на выдуманном числе. + """ + from app.services.scrapers.nspd_client import QuarterDump + + doc = inspect.getdoc(QuarterDump) or "" + assert "не сжигать rate-limit на 17 запросов" not in doc, ( + "в докстроке осталось число 17, противоречащее grid-walk" + ) + + +def test_on_demand_docstring_does_not_promise_a_60s_window() -> None: + """Головной 2: обещание окна «60 секунд» должно подтверждаться SQL. + + На origin/main докстрока его обещает, а в запросе нет ни 'failed', + ни временного фильтра. + """ + from app.services.site_finder.cadastre_fetch import find_active_on_demand_job + + doc = inspect.getdoc(find_active_on_demand_job) or "" + тело = _body(find_active_on_demand_job) + обещает_окно = "за последние 60" in doc + есть_фильтр = any(kw in тело.lower() for kw in ("interval", "now()", "failed")) + assert not (обещает_окно and not есть_фильтр), ( + f"докстрока обещает окно «за последние 60 секунд», а в SQL нет ни " + f"временного фильтра, ни статуса failed:\n{тело.strip()[:400]}" + ) + + +def test_on_demand_sql_really_ignores_failed() -> None: + """Характеризующий: запрос отбирает ровно три статуса, failed среди них нет. + + Зелёный с обеих сторон — фиксирует фактическое поведение, о котором теперь + говорит докстрока. Если кто-то добавит окно, тест покраснеет и заставит + обновить и текст. + """ + from app.services.site_finder.cadastre_fetch import find_active_on_demand_job + + тело = _body(find_active_on_demand_job) + assert "'queued', 'running', 'paused'" in тело, тело[:300] + assert "failed" not in тело.lower(), "в запросе появился failed — обнови докстроку" + + +def test_docstrings_state_the_actual_behaviour() -> None: + """Контроль от вычёркивания: обе докстроки обязаны НАЗЫВАТЬ фактическое поведение. + + Молча убрать неверную фразу — не починка: молчание читается как «всё хорошо». + """ + from app.services.scrapers.nspd_client import QuarterDump + from app.services.site_finder.cadastre_fetch import find_active_on_demand_job + + qd = inspect.getdoc(QuarterDump) or "" + assert "include_zouit" in qd, "не назван фактический дефолт дампа" + + od = inspect.getdoc(find_active_on_demand_job) or "" + assert "НИКОГДА" in od or "никогда" in od, ( + "не сказано, что failed не возвращается независимо от давности" + ) diff --git a/backend/tests/services/test_2464_kn_lock_key.py b/backend/tests/services/test_2464_kn_lock_key.py new file mode 100644 index 00000000..24c0bbef --- /dev/null +++ b/backend/tests/services/test_2464_kn_lock_key.py @@ -0,0 +1,59 @@ +"""Ключ синглтон-лока kn-свипа зависит от множества, а не от порядка (#2464). + +`developers` приходит прямо из тела запроса (`admin_scrape`), порядок произвольный. +Раньше список джойнился как пришёл: один и тот же набор в другом порядке давал ДРУГОЙ +ключ — синглтон молча переставал быть синглтоном, два свипа шли параллельно по одним и +тем же разработчикам. + +Второе следствие того же корня: `force_release_lock` строит ключ этой же функцией. +Оператор, снимающий залипший лок и перечисливший разработчиков в ином порядке, молча не +снимал ничего. + +Тесты сравнивают ЗНАЧЕНИЯ ключей — на origin/main они не совпадают, то есть красное +означает «ключи разошлись», а не «функции нет». +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + + +def _key(region: int, devs: list[str] | None) -> str: + from app.workers.tasks.scrape_kn import _lock_key + + return _lock_key(region, devs) + + +def test_same_set_different_order_gives_same_key() -> None: + """Порядок не должен создавать второй лок для того же набора.""" + a = _key(66, ["6208_0", "1234_5", "9999_1"]) + b = _key(66, ["9999_1", "6208_0", "1234_5"]) + assert a == b, f"один набор дал два ключа:\n {a}\n {b}" + + +def test_duplicates_do_not_change_the_key() -> None: + """Повтор в списке — тот же набор, тот же лок.""" + assert _key(66, ["6208_0", "6208_0"]) == _key(66, ["6208_0"]) + + +def test_different_sets_stay_separate() -> None: + """Контроль: разные наборы обязаны остаться разными локами. + + Ловит «починку» через огрубление ключа (напр. отбросить developers вовсе) — + тогда свипы по разным разработчикам блокировали бы друг друга. + """ + assert _key(66, ["A"]) != _key(66, ["B"]) + assert _key(66, ["A"]) != _key(66, ["A", "B"]) + + +def test_region_still_separates_locks() -> None: + """Контроль: регион остаётся частью ключа.""" + assert _key(66, ["A"]) != _key(74, ["A"]) + + +def test_empty_and_none_mean_all_developers() -> None: + """Контроль: прежняя семантика '*' для пустого/None сохранена.""" + assert _key(66, None).endswith(":*") + assert _key(66, []).endswith(":*") diff --git a/backend/tests/services/test_2464_three_small_leaks.py b/backend/tests/services/test_2464_three_small_leaks.py new file mode 100644 index 00000000..a3a5279f --- /dev/null +++ b/backend/tests/services/test_2464_three_small_leaks.py @@ -0,0 +1,129 @@ +"""Три места, где код делал не то, что говорил (#2464). + +1. `load_water_reserves_from_docx` (vodokanal): собирал `result` с ключом `period`, + печатал полный словарь в лог, а возвращал `{k: v for … if isinstance(v, int)}` — + период это строка или None, поэтому он выбрасывался ВСЕГДА. По логам казалось, + что период отдаётся; вызывающий не получал его ни разу. + +2. `get_sqlite_info` (objective_etl): TOCTOU — `p.exists()`, затем незащищённый + `p.stat()`. try/except покрывал только `sqlite3.connect` ниже, поэтому OSError + из stat улетал наружу и превращал диагностическую функцию в источник отказа. + +3. `place_program` (placement): предупреждение «участок мал» печатало КАТАЛОЖНЫЕ + `house.footprint_*`, хотя ставили по `fp_w`/`fp_d`. Если элемент программы + переопределил габарит, сообщение называло размер, которым никто не пытался + ставить, — диагностика уводила от причины. +""" + +from __future__ import annotations + +import inspect +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# ── 1. period больше не выбрасывается ──────────────────────────────────────── + + +def test_water_result_keeps_period() -> None: + """Головной: период обязан доехать до вызывающего, раз он попал в лог. + + На origin/main фильтр `isinstance(v, int)` выбрасывает его всегда. + """ + from app.services.site_finder import vodokanal_reserve_loader as mod + + with ( + patch.object(mod, "parse_docx_table_rows", lambda _x: [["шапка"]]), + patch.object(mod, "extract_water_rows", lambda _m: [{"name": "ЦСВ-1"}]), + patch.object(mod, "_dedupe_names", lambda r: r), + patch.object(mod, "infer_period", lambda _u: "III кв. 2025"), + patch.object(mod, "_upsert_water_rows", lambda *a, **k: {"inserted": 1, "updated": 0}), + patch.object(mod.zipfile, "ZipFile", MagicMock()), + ): + res = mod.load_water_reserves_from_docx(MagicMock(), "supply", b"", "http://x") + assert res.get("period") == "III кв. 2025", ( + f"период выброшен из ответа: {res} — по логам он есть, у вызывающего нет" + ) + assert res.get("records") == 1 and res.get("inserted") == 1, res + + +def test_water_return_annotation_allows_non_int() -> None: + """Контроль: фильтр стоял ради аннотации `dict[str, int]` — она тоже исправлена. + + Иначе следующий проход mypy вернул бы фильтр обратно. + """ + from app.services.site_finder.vodokanal_reserve_loader import load_water_reserves_from_docx + + ann = inspect.signature(load_water_reserves_from_docx).return_annotation + assert "int]" not in str(ann), f"аннотация всё ещё требует только int: {ann}" + + +# ── 2. TOCTOU в get_sqlite_info ────────────────────────────────────────────── + + +def test_sqlite_info_survives_file_vanishing_between_exists_and_stat() -> None: + """Головной: файл исчез между exists() и stat() — функция не падает. + + На origin/main OSError улетает наружу: try/except покрывает только connect. + """ + from app.services import objective_etl as mod + + настоящий_stat = Path.stat + + def _stat(self, *a, **k): + if str(self).endswith("исчезающий.sqlite"): + raise FileNotFoundError(2, "No such file or directory") + return настоящий_stat(self, *a, **k) + + with ( + patch.object(Path, "exists", lambda self: True), + patch.object(Path, "stat", _stat), + ): + info = mod.get_sqlite_info("/tmp/исчезающий.sqlite") + + assert info["exists"] is True + assert "stat_error" in info, f"ошибка stat не отражена в ответе: {info}" + assert "size_bytes" not in info, "размер выдуман при отсутствующем файле" + + +def test_sqlite_info_missing_file_unchanged() -> None: + """Контроль: отсутствующий файл по-прежнему даёт exists=False без ошибок.""" + from app.services.objective_etl import get_sqlite_info + + info = get_sqlite_info("/tmp/такого-файла-нет-2464.sqlite") + assert info["exists"] is False + assert "stat_error" not in info, info + + +# ── 3. предупреждение placement называет фактические габариты ──────────────── + + +def test_placement_warning_uses_actual_footprint_not_catalog() -> None: + """Головной: в сообщении должны стоять fp_w/fp_d, а не house.footprint_*. + + На origin/main при переопределённом габарите печатается каталожный размер. + """ + from app.services.generative import placement as mod + + src = inspect.getsource(mod.place_program) + хвост = src[src.index("участок мал") :] + assert "fp_w," in хвост and "fp_d," in хвост, ( + f"в предупреждении не фактические габариты:\n{хвост[:320]}" + ) + assert "house.footprint_w_m," not in хвост and "house.footprint_d_m," not in хвост, ( + f"в предупреждении остался каталожный размер:\n{хвост[:320]}" + ) + + +@pytest.mark.parametrize("имя", ["fp_w", "fp_d"]) +def test_placement_uses_those_names_for_placement(имя: str) -> None: + """Контроль: fp_w/fp_d — это действительно те размеры, которыми ставят. + + Без него тест выше проверял бы совпадение имён, а не смысл. + """ + from app.services.generative import placement as mod + + src = inspect.getsource(mod.place_program) + assert "_centered_footprint(cell.cx, cell.cy, fp_w, fp_d)" in src, src[:200] + assert имя in src diff --git a/backend/tests/services/test_2464a_job_settings_savepoint.py b/backend/tests/services/test_2464a_job_settings_savepoint.py new file mode 100644 index 00000000..c842536f --- /dev/null +++ b/backend/tests/services/test_2464a_job_settings_savepoint.py @@ -0,0 +1,139 @@ +"""#2464 кластер A: job_settings не должен отравлять ЧУЖУЮ сессию. + +`get_all`/`get_one` глотают ошибку БД и возвращают fallback. Сессию им отдаёт +вызывающий — admin-ручка, `beat_schedule`, либо `get_setting_value` из +`cadastre_fetch`/`nspd_geo`. На Postgres упавший `db.execute` оставляет транзакцию +в aborted-состоянии, и ВСЕ последующие запросы этой же сессии падают с +«current transaction is aborted, commands ignored until end of transaction block» — +падает не тот, кто виноват. + +Голый `db.rollback()` здесь запрещён: он снёс бы незакоммиченную работу +вызывающего. Правильное средство — SAVEPOINT вокруг самого execute +(`developer_attribution.py:152`, тот же кластер). + +О МОКЕ +────── +Мок ниже ВОСПРОИЗВОДИТ семантику Postgres: после упавшего execute сессия помечается +aborted и дальнейшие запросы падают, пока откат SAVEPOINT её не восстановит. + +Это существенно. Обычный «мок с пустым begin_nested» (см. tests/test_saturation.py) +такую проверку не даёт: у него нет aborted-состояния, поэтому второй execute +проходит в любом случае — и тест зелёный независимо от того, есть SAVEPOINT в коде +или нет. Проверка, которую нельзя уронить, сняв защиту, защиты не проверяет. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import Any + +import pytest + +from app.services.job_settings import get_all, get_one + + +class AbortedTransactionError(RuntimeError): + """Аналог psycopg InFailedSqlTransaction.""" + + +class _PostgresLikeDb: + """Сессия с aborted-состоянием и настоящей семантикой SAVEPOINT.""" + + def __init__(self, *, fail_first: bool = True) -> None: + self.calls = 0 + self.aborted = False + self._fail_first = fail_first + self._savepoint_depth = 0 + + @contextmanager + def begin_nested(self): # type: ignore[no-untyped-def] + self._savepoint_depth += 1 + try: + yield + except Exception: + # Откат SAVEPOINT: снимаем aborted, внешняя транзакция цела. + self.aborted = False + raise + finally: + self._savepoint_depth -= 1 + + def execute(self, *_args: Any, **_kwargs: Any) -> Any: + if self.aborted: + raise AbortedTransactionError( + "current transaction is aborted, commands ignored until end of transaction block" + ) + self.calls += 1 + if self.calls == 1 and self._fail_first: + # Ошибка внутри транзакции переводит её в aborted. + self.aborted = True + raise RuntimeError("simulated DB failure") + return _Result() + + +# Строка в форме, которую ждёт _row_to_dict: одиннадцать колонок SELECT'а. +_DB_ROW: dict[str, Any] = { + "job_type": "scrape_kn", + "enabled": True, + "queue_name": "celery", + "cron_schedule": "0 3 * * *", + "rate_ms": 1000, + "max_retries": 3, + "max_concurrency": 1, + "extra_config": {"marker": "из-БД"}, + "updated_at": None, + "updated_by": None, + "description": "тестовая строка", +} + + +class _Result: + def mappings(self) -> _Result: + return self + + def all(self) -> list[dict[str, Any]]: + return [_DB_ROW] + + def first(self) -> dict[str, Any]: + return _DB_ROW + + +def test_get_all_leaves_the_caller_session_usable() -> None: + """После сбоя внутри get_all следующий запрос вызывающего должен пройти.""" + db = _PostgresLikeDb() + + rows = get_all(db) + assert rows, "fallback не вернулся — сломано само graceful-поведение" + + # Это и есть проверка: на origin/main здесь AbortedTransaction. + assert db.execute("SELECT 1").mappings().first() == _DB_ROW + + +def test_get_one_leaves_the_caller_session_usable() -> None: + db = _PostgresLikeDb() + + row = get_one("scrape_kn", db) + assert row, "fallback не вернулся" + + assert db.execute("SELECT 1").mappings().first() == _DB_ROW + + +def test_mock_actually_poisons_without_a_savepoint() -> None: + """Контроль на сам мок: без SAVEPOINT он ОБЯЗАН отравляться. + + Без этой проверки тесты выше были бы зелёными по построению — ровно та ловушка, + из-за которой существующий мок в test_saturation.py ничего не проверяет. + """ + db = _PostgresLikeDb() + with pytest.raises(RuntimeError): + db.execute("boom") # первый вызов падает и переводит в aborted + with pytest.raises(AbortedTransactionError): + db.execute("SELECT 1") + + +def test_healthy_session_is_not_disturbed() -> None: + """Контроль: без сбоя поведение прежнее — данные из БД, а не fallback.""" + db = _PostgresLikeDb(fail_first=False) + rows = get_all(db) + assert len(rows) == 1 + assert rows[0]["job_type"] == "scrape_kn" + assert rows[0]["extra_config"] == {"marker": "из-БД"}, "вернулся fallback вместо данных БД" diff --git a/backend/tests/services/test_2464h_timing_overlap_formula.py b/backend/tests/services/test_2464h_timing_overlap_formula.py new file mode 100644 index 00000000..460c10bd --- /dev/null +++ b/backend/tests/services/test_2464h_timing_overlap_formula.py @@ -0,0 +1,45 @@ +"""#2464 кластер H: докстринг `_timing_overlap` обещал не ту формулу. + +Было записано `exp(−|Δмесяцев| / half_life)` и тут же — «расхождение в half_life +мес → 0.5». Эти два утверждения несовместимы: `exp(−1) ≈ 0.368`, а не 0.5. + +Код при этом верен — он считает `0.5 ** (Δ/half_life)` и даёт ровно 0.5 на +половинном периоде. Расходился докстринг. + +Тест закрепляет СВОЙСТВО, а не текст комментария: на Δ = half_life значение равно +0.5, и оно НЕ равно exp(−1). Второе утверждение существенно — без него тест прошёл +бы и на экспоненциальной формуле, если бы кто-то «починил» код по старому +докстрингу. +""" + +from __future__ import annotations + +import math +from datetime import date + +from app.services.forecasting.special_indices import _timing_overlap + + +def test_half_life_gives_exactly_one_half() -> None: + same = date(2026, 1, 1) + later = date(2026, 7, 1) # ровно 6 месяцев + + value = _timing_overlap(later, same, half_life_months=6.0) + + assert value is not None + assert abs(value - 0.5) < 1e-9, "на половинном периоде значение обязано быть 0.5" + assert abs(value - math.exp(-1)) > 0.1, ( + "значение совпало с exp(−1) — значит формула экспоненциальная, " + "а докстринг обещает половинное затухание" + ) + + +def test_simultaneous_launch_is_one() -> None: + same = date(2026, 1, 1) + assert _timing_overlap(same, same, half_life_months=6.0) == 1.0 + + +def test_missing_date_returns_none_not_a_number() -> None: + """Контроль: недоступную ось не фабрикуем (обещание того же докстринга).""" + assert _timing_overlap(None, date(2026, 1, 1)) is None + assert _timing_overlap(date(2026, 1, 1), None) is None diff --git a/backend/tests/services/test_analytics_queries_domrf_dedup.py b/backend/tests/services/test_analytics_queries_domrf_dedup.py index a6db1805..77e0edf1 100644 --- a/backend/tests/services/test_analytics_queries_domrf_dedup.py +++ b/backend/tests/services/test_analytics_queries_domrf_dedup.py @@ -116,9 +116,9 @@ class TestActiveCompetitorsCountSqlShape: # site_status legitimately appears in the CTE's SELECT list (it's the # column DISTINCT ON needs to expose) -- what must NOT appear is a # filter predicate on it inside the CTE's WHERE. - assert ( - "site_status = 'Строящиеся'" not in cte_body - ), f"site_status must not pre-filter the DISTINCT ON CTE (volatile field):\n{cte_body}" + assert "site_status = 'Строящиеся'" not in cte_body, ( + f"site_status must not pre-filter the DISTINCT ON CTE (volatile field):\n{cte_body}" + ) assert "site_status = 'Строящиеся'" in outer_body def test_district_and_class_are_volatile_applied_after_distinct_on(self) -> None: @@ -136,12 +136,12 @@ class TestActiveCompetitorsCountSqlShape: ) cte_body, outer_body = _split_latest_cte(_executed_sql(db, 0)) # CTE WHERE must scope on ONLY the stable region_cd — no volatile predicate. - assert ( - "district_name = :dn" not in cte_body - ), f"district_name (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}" - assert ( - "COALESCE(obj_class, obj_class_fallback) = :cls" not in cte_body - ), f"obj_class (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}" + assert "district_name = :dn" not in cte_body, ( + f"district_name (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}" + ) + assert "COALESCE(obj_class, obj_class_fallback) = :cls" not in cte_body, ( + f"obj_class (volatile) must not pre-filter the DISTINCT ON CTE:\n{cte_body}" + ) # Both live in the outer WHERE, applied to the deduped true-latest row. assert "district_name = :dn" in outer_body assert "COALESCE(obj_class, obj_class_fallback) = :cls" in outer_body @@ -161,9 +161,9 @@ class TestActiveCompetitorsCountSqlShape: where_start = cte_body.index("WHERE ") + len("WHERE ") where_end = cte_body.index("ORDER BY", where_start) where_clause = cte_body[where_start:where_end].strip() - assert ( - where_clause == "region_cd = :rc" - ), f"CTE WHERE must scope on ONLY stable region_cd, got: {where_clause!r}" + assert where_clause == "region_cd = :rc", ( + f"CTE WHERE must scope on ONLY stable region_cd, got: {where_clause!r}" + ) def test_no_double_colon_cast(self) -> None: import re 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_dadata_client.py b/backend/tests/services/test_dadata_client.py index 9b8f0e63..06805759 100644 --- a/backend/tests/services/test_dadata_client.py +++ b/backend/tests/services/test_dadata_client.py @@ -142,3 +142,84 @@ def test_clean_address_403_falls_back_to_suggest(monkeypatch) -> None: coords = dc.clean_address("г Екатеринбург, ул Мира, 19") assert coords is not None assert abs(coords[0] - 56.8447) < 1e-6 and abs(coords[1] - 60.6547) < 1e-6 + + +# ── #2464: дрейф формы `data` не должен ронять весь гео-проход ────────────────── + + +def _suggest_fallback_client(data_field: Any): + """Клиент, у которого cleaner отдаёт 403 (фолбэк на suggest), а suggest — + ответ с произвольным содержимым поля `data`.""" + + class _Resp: + def __init__(self, status_code, payload): + self.status_code = status_code + self._payload = payload + + def json(self): + return self._payload + + class _Client: + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def post(self, url, **kwargs): + if "cleaner" in url: + return _Resp(403, {"message": "Feature 'CLEAN' disabled"}) + return _Resp(200, {"suggestions": [{"value": "адрес", "data": data_field}]}) + + return _Client + + +@pytest.mark.parametrize( + "bad_data", + [ + pytest.param(["не словарь"], id="список"), + pytest.param("строка", id="строка"), + pytest.param(42, id="число"), + ], +) +def test_suggest_fallback_survives_non_dict_data(monkeypatch, bad_data: Any) -> None: + """`data` не словарь → None, а не AttributeError. + + Раньше стояло `data = suggestions[0].get("data") or {}` — `or {}` ловит только + falsy, поэтому истинное не-словарное значение доходило до `.get` и роняло + AttributeError. Летел он НАРУЖУ: в suggest попадают из clean_address по + фолбэку 403, то есть уже за пределами её try/except, а у вызывающего + (objective_backfill._geocode) обёртки нет вовсе — один такой ответ убил бы + весь гео-проход, а не один адрес. + + Соседние уровни того же файла проверяются через isinstance (`payload`, + `suggestions[0]`, `item`); защита пропала ровно на уровень глубже. + """ + from app.services import dadata_client as dc + + monkeypatch.setattr(dc.settings, "dadata_api_token", "test-token") + monkeypatch.setattr(dc.settings, "dadata_api_secret", "test-secret") + monkeypatch.setattr(dc.httpx, "Client", _suggest_fallback_client(bad_data)) + + assert dc.clean_address("г Екатеринбург, ул Мира, 19") is None + + +def test_suggest_fallback_still_reads_a_proper_dict(monkeypatch) -> None: + """Контроль: правильная форма по-прежнему отдаёт координаты (зелёный с обеих + сторон — иначе «починка» могла бы свестись к «всегда None»).""" + from app.services import dadata_client as dc + + monkeypatch.setattr(dc.settings, "dadata_api_token", "test-token") + monkeypatch.setattr(dc.settings, "dadata_api_secret", "test-secret") + monkeypatch.setattr( + dc.httpx, + "Client", + _suggest_fallback_client({"geo_lat": "56.8447", "geo_lon": "60.6547"}), + ) + + coords = dc.clean_address("г Екатеринбург, ул Мира, 19") + assert coords is not None + assert abs(coords[0] - 56.8447) < 1e-6 diff --git a/backend/tests/services/test_ekburg_permits.py b/backend/tests/services/test_ekburg_permits.py index 51c47cd2..1bd8f8b9 100644 --- a/backend/tests/services/test_ekburg_permits.py +++ b/backend/tests/services/test_ekburg_permits.py @@ -397,12 +397,12 @@ class TestMsk66ToWgs84: result = msk66_to_wgs84("1534814.7997", "394813.2001") assert result is not None lon, lat = result - assert ( - abs(lon - 60.619637) < _TOL_LON_DEG - ), f"lon={lon:.6f} далеко от эталонного 60.619637 (Δ={abs(lon - 60.619637):.6f}°)" - assert ( - abs(lat - 56.871948) < _TOL_LAT_DEG - ), f"lat={lat:.6f} далеко от эталонного 56.871948 (Δ={abs(lat - 56.871948):.6f}°)" + assert abs(lon - 60.619637) < _TOL_LON_DEG, ( + f"lon={lon:.6f} далеко от эталонного 60.619637 (Δ={abs(lon - 60.619637):.6f}°)" + ) + assert abs(lat - 56.871948) < _TOL_LAT_DEG, ( + f"lat={lat:.6f} далеко от эталонного 56.871948 (Δ={abs(lat - 56.871948):.6f}°)" + ) def test_koltsovo_cad_crosscheck(self) -> None: """X=1544026.7997/Y=381585.4401 — сверка с centroid cad_parcels 66:41:0503018:248. @@ -412,12 +412,12 @@ class TestMsk66ToWgs84: result = msk66_to_wgs84("1544026.7997", "381585.4401") assert result is not None lon, lat = result - assert ( - abs(lon - 60.768255) < _TOL_LON_DEG - ), f"lon={lon:.6f} далеко от эталонного 60.768255 (Δ={abs(lon - 60.768255):.6f}°)" - assert ( - abs(lat - 56.752330) < _TOL_LAT_DEG - ), f"lat={lat:.6f} далеко от эталонного 56.752330 (Δ={abs(lat - 56.752330):.6f}°)" + assert abs(lon - 60.768255) < _TOL_LON_DEG, ( + f"lon={lon:.6f} далеко от эталонного 60.768255 (Δ={abs(lon - 60.768255):.6f}°)" + ) + assert abs(lat - 56.752330) < _TOL_LAT_DEG, ( + f"lat={lat:.6f} далеко от эталонного 56.752330 (Δ={abs(lat - 56.752330):.6f}°)" + ) def test_smorodinovaya_cad_crosscheck(self) -> None: """X=1526550.1397/Y=387129.3801 — сверка с centroid cad_parcels 66:41:0306057:89. @@ -427,12 +427,12 @@ class TestMsk66ToWgs84: result = msk66_to_wgs84("1526550.1397", "387129.3801") assert result is not None lon, lat = result - assert ( - abs(lon - 60.483224) < _TOL_LON_DEG - ), f"lon={lon:.6f} далеко от эталонного 60.483224 (Δ={abs(lon - 60.483224):.6f}°)" - assert ( - abs(lat - 56.803547) < _TOL_LAT_DEG - ), f"lat={lat:.6f} далеко от эталонного 56.803547 (Δ={abs(lat - 56.803547):.6f}°)" + assert abs(lon - 60.483224) < _TOL_LON_DEG, ( + f"lon={lon:.6f} далеко от эталонного 60.483224 (Δ={abs(lon - 60.483224):.6f}°)" + ) + assert abs(lat - 56.803547) < _TOL_LAT_DEG, ( + f"lat={lat:.6f} далеко от эталонного 56.803547 (Δ={abs(lat - 56.803547):.6f}°)" + ) def test_respublikanskaya_cad_crosscheck(self) -> None: """X=1531655.2797/Y=398430.2401 — ул. Республиканская 1а, cad 66:41:0106051:10. @@ -443,12 +443,12 @@ class TestMsk66ToWgs84: result = msk66_to_wgs84("1531655.2797", "398430.2401") assert result is not None lon, lat = result - assert ( - abs(lon - 60.568320) < _TOL_LON_DEG - ), f"lon={lon:.6f} далеко от эталонного 60.568320 (Δ={abs(lon - 60.568320):.6f}°)" - assert ( - abs(lat - 56.904674) < _TOL_LAT_DEG - ), f"lat={lat:.6f} далеко от эталонного 56.904674 (Δ={abs(lat - 56.904674):.6f}°)" + assert abs(lon - 60.568320) < _TOL_LON_DEG, ( + f"lon={lon:.6f} далеко от эталонного 60.568320 (Δ={abs(lon - 60.568320):.6f}°)" + ) + assert abs(lat - 56.904674) < _TOL_LAT_DEG, ( + f"lat={lat:.6f} далеко от эталонного 56.904674 (Δ={abs(lat - 56.904674):.6f}°)" + ) def test_kosmonavtov_in_ekb(self) -> None: """Пр. Космонавтов — попадает в ЕКБ-регион.""" diff --git a/backend/tests/services/test_functional_zone_lookup.py b/backend/tests/services/test_functional_zone_lookup.py index e7301728..2cc44131 100644 --- a/backend/tests/services/test_functional_zone_lookup.py +++ b/backend/tests/services/test_functional_zone_lookup.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import contextmanager from typing import Any from sqlalchemy.exc import OperationalError, ProgrammingError @@ -21,6 +22,15 @@ class _Result: class _FakeDB: + @contextmanager + def begin_nested(self): # type: ignore[no-untyped-def] + """#2464: lookup оборачивает свой db.execute в SAVEPOINT (сессия общая с + analyze_parcel). Здесь достаточно пустого контекст-менеджера: эти тесты про + логику самого lookup'а. Что SAVEPOINT РЕАЛЬНО откатывает aborted-транзакцию, + проверяет tests/services/site_finder/test_2464_ird_lookups_savepoint.py на + двойнике с настоящей семантикой Postgres.""" + yield + def __init__( self, rows: list[dict[str, Any]] | None = None, diff --git a/backend/tests/services/test_granddoc_lookup.py b/backend/tests/services/test_granddoc_lookup.py index 9eda6beb..dc029ed3 100644 --- a/backend/tests/services/test_granddoc_lookup.py +++ b/backend/tests/services/test_granddoc_lookup.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import contextmanager from datetime import date from typing import Any @@ -24,6 +25,15 @@ class _Result: class _FakeDB: + @contextmanager + def begin_nested(self): # type: ignore[no-untyped-def] + """#2464: lookup оборачивает свой db.execute в SAVEPOINT (сессия общая с + analyze_parcel). Здесь достаточно пустого контекст-менеджера: эти тесты про + логику самого lookup'а. Что SAVEPOINT РЕАЛЬНО откатывает aborted-транзакцию, + проверяет tests/services/site_finder/test_2464_ird_lookups_savepoint.py на + двойнике с настоящей семантикой Postgres.""" + yield + def __init__( self, rows: list[dict[str, Any]] | None = None, diff --git a/backend/tests/services/test_ird_overlay_lookup.py b/backend/tests/services/test_ird_overlay_lookup.py index 3e9898c6..0ed3b958 100644 --- a/backend/tests/services/test_ird_overlay_lookup.py +++ b/backend/tests/services/test_ird_overlay_lookup.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import contextmanager from typing import Any import pytest @@ -22,6 +23,15 @@ class _Result: class _FakeDB: + @contextmanager + def begin_nested(self): # type: ignore[no-untyped-def] + """#2464: lookup оборачивает свой db.execute в SAVEPOINT (сессия общая с + analyze_parcel). Здесь достаточно пустого контекст-менеджера: эти тесты про + логику самого lookup'а. Что SAVEPOINT РЕАЛЬНО откатывает aborted-транзакцию, + проверяет tests/services/site_finder/test_2464_ird_lookups_savepoint.py на + двойнике с настоящей семантикой Postgres.""" + yield + def __init__( self, rows: list[dict[str, Any]] | None = None, raise_exc: Exception | None = None ) -> None: @@ -111,7 +121,7 @@ def test_empty_when_no_overlap() -> None: "operational-error: connection drop / БД недоступна", ), ( - ProgrammingError("stmt", {}, Exception("relation \"ird_overlays\" does not exist")), + ProgrammingError("stmt", {}, Exception('relation "ird_overlays" does not exist')), "programming-error: UndefinedTable — pre-migration / D9b не задеплоен", ), ( diff --git a/backend/tests/services/test_newbuilding_crossload.py b/backend/tests/services/test_newbuilding_crossload.py index 232cc7f1..fecd061d 100644 --- a/backend/tests/services/test_newbuilding_crossload.py +++ b/backend/tests/services/test_newbuilding_crossload.py @@ -162,10 +162,10 @@ def test_upsert_sql_coalesce_external_ids(): assert "COALESCE" in do_update_section, "DO UPDATE должен содержать COALESCE" # yandex_jk_id — строка вида "YANDEX_JK_ID = COALESCE(" - assert ( - "YANDEX_JK_ID = COALESCE(" in do_update_section - ), "yandex_jk_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом" + assert "YANDEX_JK_ID = COALESCE(" in do_update_section, ( + "yandex_jk_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом" + ) # cian_internal_house_id — строка вида "CIAN_INTERNAL_HOUSE_ID = COALESCE(" - assert ( - "CIAN_INTERNAL_HOUSE_ID = COALESCE(" in do_update_section - ), "cian_internal_house_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом" + assert "CIAN_INTERNAL_HOUSE_ID = COALESCE(" in do_update_section, ( + "cian_internal_house_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом" + ) diff --git a/backend/tests/services/test_objective_backfill.py b/backend/tests/services/test_objective_backfill.py index 17da2198..4a62db37 100644 --- a/backend/tests/services/test_objective_backfill.py +++ b/backend/tests/services/test_objective_backfill.py @@ -850,3 +850,105 @@ def test_apply_geo_matches_on_conflict_counts_skip() -> None: assert result["inserted"] == 0 assert result["conflict_skipped"] == 1 mock_db.commit.assert_called_once() + + +# ── ambiguous: занятые кандидаты отсеиваются до геокода (#2464) ────────────── +# +# Записать занятое objective-имя нельзя: apply_geo_matches вставляет с +# ON CONFLICT (objective_complex_name, objective_group) DO NOTHING, а +# _TAKEN_NAMES_SQL выбирает ровно по этому ключу. Поэтому геокод занятого — трата +# квоты DaData, а его попадание в confirmed — отчёт о подтверждении, за которым +# нет записи. + + +def test_geo_ambiguous_ignores_taken_candidate_and_resolves() -> None: + """Занятый кандидат ближе свободного → раньше оба были «в радиусе» и случай + оставался ambiguous. Занятый не участвует → свободный резолвится.""" + taken_lat, taken_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 100.0) + free_lat, free_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 200.0) + + mock_db = _make_geo_db( + objective_rows=[ + ("Меридиан", ["Девелопер"], 7003), + ('Бутик-квартал "Меридиан"', ["Другой"], 7004), # то же ядро «меридиан» + ], + taken_names=["Меридиан"], + domrf_rows=[(303, 'ЖК "Меридиан"', "ООО Девелопер")], + address_rows=[ + ("Меридиан", "г Екатеринбург, ул Занятая, 1"), + ('Бутик-квартал "Меридиан"', "г Екатеринбург, ул Свободная, 2"), + ], + coord_rows=[(303, _DOMRF_LAT, _DOMRF_LON)], + ) + + geocoded: list[str] = [] + + def _fake_geocode(addr: str) -> tuple[float, float]: + geocoded.append(addr) + return (taken_lat, taken_lon) if "Занятая" in addr else (free_lat, free_lon) + + with patch(_CLEAN_ADDRESS_PATH, side_effect=_fake_geocode): + report = find_geo_matches(mock_db, max_distance_m=500) + + assert len(report.confirmed) == 1 + assert report.confirmed[0].objective_project_name == 'Бутик-квартал "Меридиан"' + # квота DaData не тратится на кандидата, которого запись всё равно отбросит + assert not any("Занятая" in a for a in geocoded) + + +def test_geo_ambiguous_taken_candidate_not_confirmed_when_only_one_near() -> None: + """Занятый в радиусе, свободный далеко: раньше подтверждался ЗАНЯТЫЙ и прогон + рапортовал confirmed при нуле записей. Теперь честный reject 'too_far'.""" + taken_lat, taken_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 100.0) + far_lat, far_lon = _offset_coords(_DOMRF_LAT, _DOMRF_LON, 5000.0) + + mock_db = _make_geo_db( + objective_rows=[ + ("Меридиан", ["Девелопер"], 7003), + ('Бутик-квартал "Меридиан"', ["Другой"], 7004), + ], + taken_names=["Меридиан"], + domrf_rows=[(303, 'ЖК "Меридиан"', "ООО Девелопер")], + address_rows=[ + ("Меридиан", "г Екатеринбург, ул Занятая, 1"), + ('Бутик-квартал "Меридиан"', "г Екатеринбург, ул Далёкая, 2"), + ], + coord_rows=[(303, _DOMRF_LAT, _DOMRF_LON)], + ) + + def _fake_geocode(addr: str) -> tuple[float, float]: + return (taken_lat, taken_lon) if "Занятая" in addr else (far_lat, far_lon) + + with patch(_CLEAN_ADDRESS_PATH, side_effect=_fake_geocode): + report = find_geo_matches(mock_db, max_distance_m=500) + + assert report.confirmed == [] + assert len(report.rejected) == 1 + # 'ambiguous_multi' здесь был бы неправдой: близко не «несколько», а никто + assert report.rejected[0].reason == "too_far" + + +def test_geo_ambiguous_all_candidates_taken_rejects_without_geocode() -> None: + """Все кандидаты ядра заняты → отдельная причина отказа и ни одного вызова + DaData (раньше геокодились оба и случай выглядел как ambiguous_multi).""" + mock_db = _make_geo_db( + objective_rows=[ + ("Меридиан", ["Девелопер"], 7003), + ('Бутик-квартал "Меридиан"', ["Другой"], 7004), + ], + taken_names=["Меридиан", 'Бутик-квартал "Меридиан"'], + domrf_rows=[(303, 'ЖК "Меридиан"', "ООО Девелопер")], + address_rows=[ + ("Меридиан", "г Екатеринбург, ул А, 1"), + ('Бутик-квартал "Меридиан"', "г Екатеринбург, ул Б, 2"), + ], + coord_rows=[(303, _DOMRF_LAT, _DOMRF_LON)], + ) + + with patch(_CLEAN_ADDRESS_PATH, side_effect=AssertionError("DaData не должна вызываться")): + report = find_geo_matches(mock_db, max_distance_m=500) + + assert report.confirmed == [] + assert len(report.rejected) == 1 + assert report.rejected[0].reason == "all_candidates_taken" + assert report.dadata_calls == 0 diff --git a/backend/tests/services/test_planning_lookup.py b/backend/tests/services/test_planning_lookup.py index 3207f5d6..8ec8a37e 100644 --- a/backend/tests/services/test_planning_lookup.py +++ b/backend/tests/services/test_planning_lookup.py @@ -6,6 +6,7 @@ from __future__ import annotations +from contextlib import contextmanager from typing import Any from sqlalchemy.exc import ProgrammingError @@ -25,6 +26,15 @@ class _Result: class _DB: + @contextmanager + def begin_nested(self): # type: ignore[no-untyped-def] + """#2464: lookup оборачивает свой db.execute в SAVEPOINT (сессия общая с + analyze_parcel). Здесь достаточно пустого контекст-менеджера: эти тесты про + логику самого lookup'а. Что SAVEPOINT РЕАЛЬНО откатывает aborted-транзакцию, + проверяет tests/services/site_finder/test_2464_ird_lookups_savepoint.py на + двойнике с настоящей семантикой Postgres.""" + yield + def __init__(self, rows: list[dict[str, Any]] | Exception) -> None: self._rows = rows diff --git a/backend/tests/services/test_recommend_mix_velocity.py b/backend/tests/services/test_recommend_mix_velocity.py index 73ea6e0a..38804a2c 100644 --- a/backend/tests/services/test_recommend_mix_velocity.py +++ b/backend/tests/services/test_recommend_mix_velocity.py @@ -422,9 +422,9 @@ class TestRealisticSrokFallback: ) srok = result["summary"]["months_to_sellout_total"] assert srok is not None - assert ( - lo <= srok <= hi - ), f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]" + assert lo <= srok <= hi, ( + f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]" + ) def test_scope_has_n_competitors(self) -> None: """scope.n_competitors присутствует и равен district+class competitors.""" @@ -522,9 +522,9 @@ class TestObjectivePerBucketPath: # Studio: macro_mult = sat_factor × trend_factor = 1.0 × 1.0 = 1.0 studio = bkt_map.get("Студии 15-30") assert studio is not None - assert studio["velocity_per_month"] == pytest.approx( - 3.5, rel=0.01 - ), f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5" + assert studio["velocity_per_month"] == pytest.approx(3.5, rel=0.01), ( + f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5" + ) assert studio.get("velocity_source") == "objective_per_bucket" def test_objective_velocities_vary(self) -> None: diff --git a/backend/tests/services/test_reservation_lookup.py b/backend/tests/services/test_reservation_lookup.py index 644402de..3cfbbe47 100644 --- a/backend/tests/services/test_reservation_lookup.py +++ b/backend/tests/services/test_reservation_lookup.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import contextmanager from datetime import date from typing import Any @@ -24,6 +25,15 @@ class _Result: class _FakeDB: + @contextmanager + def begin_nested(self): # type: ignore[no-untyped-def] + """#2464: lookup оборачивает свой db.execute в SAVEPOINT (сессия общая с + analyze_parcel). Здесь достаточно пустого контекст-менеджера: эти тесты про + логику самого lookup'а. Что SAVEPOINT РЕАЛЬНО откатывает aborted-транзакцию, + проверяет tests/services/site_finder/test_2464_ird_lookups_savepoint.py на + двойнике с настоящей семантикой Postgres.""" + yield + def __init__( self, rows: list[dict[str, Any]] | None = None, diff --git a/backend/tests/services/test_weather_cache.py b/backend/tests/services/test_weather_cache.py index c5cab6f1..8e3f6a7c 100644 --- a/backend/tests/services/test_weather_cache.py +++ b/backend/tests/services/test_weather_cache.py @@ -553,9 +553,9 @@ class TestWindDirectionAllNone: f"ожидался None, получено {wind['dominant_direction_deg']!r} — " "вероятно, fabricated 0.0° из-за atan2(0,0)" ) - assert ( - wind["dominant_direction_label"] is None - ), f"ожидался None, получено {wind['dominant_direction_label']!r}" + assert wind["dominant_direction_label"] is None, ( + f"ожидался None, получено {wind['dominant_direction_label']!r}" + ) def test_missing_wind_key_gives_none_direction(self) -> None: """winddirection_10m_dominant отсутствует в ответе → dominant_direction_deg/label = None.""" @@ -594,3 +594,87 @@ class TestWindDirectionAllNone: # Один сэмпл 90° → circular mean ровно 90° → rose[2] = "Восток" assert wind["dominant_direction_deg"] == 90 assert wind["dominant_direction_label"] == "Восток" + + +# ────────────────────────────────────────────────────────────────────────────── +# 8. осадков нет в ответе → precipitation_* = None, а не 0 (#2464) +# +# Тот же класс, что раздел 7 выше (wind_d all-None → None, не 0.0°): «ноль» здесь +# был бы утверждением о погоде («сухо»), тогда как пустой ряд значит, что мы просто +# не знаем. Все шесть соседних агрегатов того же словаря при пустых данных дают None +# — осадки были единственным исключением, и именно они рисуются на фронте как +# измеренная величина (ptica-adapt заворачивал их в `real(...)` безусловно). +# ────────────────────────────────────────────────────────────────────────────── + + +def _make_forecast_response_precip_all_none() -> dict[str, Any]: + """Forecast, где все precipitation_sum = null (Open-Meteo так делает штатно).""" + return { + "daily": { + "time": ["2026-06-12", "2026-06-13", "2026-06-14"], + "temperature_2m_max": [20.0, 21.0, 22.0], + "temperature_2m_min": [10.0, 11.0, 12.0], + "precipitation_sum": [None, None, None], + "uv_index_max": [5.0, 5.0, 5.0], + "winddirection_10m_dominant": [180, 180, 180], + "windspeed_10m_max": [3.0, 4.0, 3.0], + } + } + + +def _make_forecast_response_precip_missing() -> dict[str, Any]: + """Forecast без ключа precipitation_sum вовсе.""" + return { + "daily": { + "time": ["2026-06-12", "2026-06-13", "2026-06-14"], + "temperature_2m_max": [20.0, 21.0, 22.0], + "temperature_2m_min": [10.0, 11.0, 12.0], + "uv_index_max": [5.0, 5.0, 5.0], + "winddirection_10m_dominant": [180, 180, 180], + "windspeed_10m_max": [3.0, 4.0, 3.0], + } + } + + +class TestPrecipitationUnknownIsNotZero: + """Регрессия: без ряда осадков не должно возвращаться 0 (= «сухо»).""" + + def _call_with_payload(self, payload: dict[str, Any]) -> dict[str, Any] | None: + get = MagicMock(return_value=_make_httpx_response(payload)) + client_ctx = MagicMock() + client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get)) + client_ctx.__exit__ = MagicMock(return_value=None) + with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx): + return weather_cache.get_weather_cached(56.84, 60.59) + + def test_all_none_precip_gives_none_not_zero(self) -> None: + result = self._call_with_payload(_make_forecast_response_precip_all_none()) + + assert result is not None, "ответ должен строиться и без ряда осадков" + assert result["precipitation_total_mm"] is None, ( + f"ожидался None, получено {result['precipitation_total_mm']!r} — " + "ноль здесь читается как «осадков не будет»" + ) + assert result["precipitation_days"] is None + + def test_missing_precip_key_gives_none(self) -> None: + result = self._call_with_payload(_make_forecast_response_precip_missing()) + + assert result is not None + assert result["precipitation_total_mm"] is None + assert result["precipitation_days"] is None + + def test_real_dry_week_is_still_a_measured_zero(self) -> None: + """КОНТРОЛЬ: настоящая сухая неделя — это измеренный 0.0, а не None. + + Без этой проверки правку можно было бы «сделать» так, что осадки всегда + None, и оба теста выше стали бы зелёными по неверной причине. + """ + payload = _make_forecast_response_precip_all_none() + payload["daily"]["precipitation_sum"] = [0.0, 0.0, 0.0] + + result = self._call_with_payload(payload) + + assert result is not None + assert result["precipitation_total_mm"] == 0.0 + assert result["precipitation_days"] == 0 diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt index 02ca21bc..0c08811c 100644 --- a/backend/tests/skip_allowlist.txt +++ b/backend/tests/skip_allowlist.txt @@ -34,6 +34,7 @@ tests/test_layout_tz_pdf.py # (`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 @@ -95,3 +96,83 @@ tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test 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 + +# ── #2956: свежесть data-table источника считается по успешным строкам ──────── +# Нужен живой Postgres: тест создаёт ВРЕМЕННЫЕ копии всех таблиц реестра freshness +# и гоняет по ним настоящий SQL compute_freshness. В CI ЭТИ ТЕСТЫ ИДУТ — postgres- +# сервис поднят (#2745), как и для соседних tests/sql/*. Записи нужны только для +# машины без БД и без туннеля на 15432. +tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_failed_dumps_do_not_refresh_the_clock +tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_successful_dump_still_counts_as_fresh +tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_attempt_is_still_recorded +tests/sql/test_2956_freshness_ignores_failed_dumps.py::test_only_failures_means_no_success_at_all + +# ── #2464: контракт суффиксов в /admin/leads/stats ──────────────────────────── +# Нужен живой Postgres: тест создаёт ВРЕМЕННЫЕ prinzip_leads/prinzip_deals и +# вызывает leads_stats на данных, где итог заведомо не равен окну. В CI ЭТИ ТЕСТЫ +# ИДУТ (postgres-сервис, #2745); записи нужны для машины без БД и без туннеля. +tests/sql/test_2464_leads_stats_suffix_contract.py::test_window_suffixed_fields_match_the_window +tests/sql/test_2464_leads_stats_suffix_contract.py::test_total_suffixed_fields_are_all_time +tests/sql/test_2464_leads_stats_suffix_contract.py::test_revenue_and_deals_are_named_by_their_scope +tests/sql/test_2464_leads_stats_suffix_contract.py::test_window_months_present_on_empty_data +# ── #2464: дедуп land_reservation (миграция 189) ────────────────────────────── +# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию таблицы, проверяют семантику +# UNIQUE NULLS NOT DISTINCT и репетируют миграцию на засеянных дублях. В CI ИДУТ +# (postgres-сервис, #2745); записи нужны для машины без БД и без туннеля. +tests/sql/test_2464_land_reservation_dedup.py::test_nulls_not_distinct_deduplicates +tests/sql/test_2464_land_reservation_dedup.py::test_plain_unique_does_not_deduplicate +tests/sql/test_2464_land_reservation_dedup.py::test_records_with_act_number_still_deduplicate +tests/sql/test_2464_land_reservation_dedup.py::test_different_parcels_are_not_collapsed +tests/sql/test_2464_land_reservation_dedup.py::test_migration_dedup_statement_matches_the_key +tests/sql/test_2464_land_reservation_dedup.py::test_migration_body_runs_on_a_prod_shaped_replica + +# ── #2464: единственный дефолтный профиль весов (миграция 190) ──────────────── +# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ user_weight_profiles, применяют +# миграцию и проверяют, что вторая установка дефолта отвергается базой. В CI ИДУТ +# (postgres-сервис, #2745); записи нужны для машины без БД и без туннеля. +tests/sql/test_2464_default_profile_unique.py::test_second_default_is_rejected_by_the_database +tests/sql/test_2464_default_profile_unique.py::test_without_migration_two_defaults_slip_through +tests/sql/test_2464_default_profile_unique.py::test_reassigning_default_still_works +tests/sql/test_2464_default_profile_unique.py::test_different_users_keep_their_own_defaults + +# ── #2464: корзина площади «н/д» в supply-only (best_layouts) ───────────────── +# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ таблицу objective_lots, которая +# затеняет боевую в пределах сессии, и прогоняют НАСТОЯЩИЙ _SUPPLY_ONLY_LOTS_SQL +# на засеянных лотах. В CI ИДУТ (postgres-сервис, #2745); записи нужны для машины +# без БД и без туннеля. +tests/sql/test_2464_area_bin_unknown.py::test_lot_without_area_is_not_counted_as_tiny +tests/sql/test_2464_area_bin_unknown.py::test_unknown_area_gets_its_own_bucket +tests/sql/test_2464_area_bin_unknown.py::test_total_lots_preserved +tests/sql/test_2464_area_bin_unknown.py::test_normal_buckets_unchanged +# ── #2464: backfill act_date (миграция 191) ────────────────────────────────── +# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию land_reservation в прод-форме +# (9+2 строки с датой Генплана + контрольные посторонние) и прогоняют ТЕЛО миграции +# целиком. В CI ИДУТ (postgres-сервис, #2745); записи нужны для машины без БД. +tests/sql/test_2464_act_date_backfill.py::test_genplan_dates_are_replaced_with_real_ones +tests/sql/test_2464_act_date_backfill.py::test_no_2004_rows_left_in_target_documents +tests/sql/test_2464_act_date_backfill.py::test_other_documents_with_same_date_are_untouched +tests/sql/test_2464_act_date_backfill.py::test_correct_rows_do_not_move +tests/sql/test_2464_act_date_backfill.py::test_migration_is_idempotent +tests/sql/test_2464_act_date_backfill.py::test_without_the_migration_rows_stay_wrong + +# ── #2986: бизнес-ключ gisogd_permits по source_key (миграция 191) ──────────── +# Нужен живой Postgres: тесты создают ВРЕМЕННУЮ копию таблицы в прод-форме (со СТАРЫМ +# ключом), прогоняют тело миграции и проверяют, что разрешение и изменения к нему +# живут раздельно, а межсхемный дубль по-прежнему склеивается. Два гейта на лоадер +# (GROUP_CODE и цель ON CONFLICT) базы НЕ требуют и в этот список НЕ входят. +# В CI ИДУТ (postgres-сервис, #2745); записи нужны для машины без БД. +tests/sql/test_2986_permits_source_key.py::test_old_key_collapses_permit_and_its_amendment +tests/sql/test_2986_permits_source_key.py::test_after_migration_both_documents_survive +tests/sql/test_2986_permits_source_key.py::test_cross_schema_duplicate_still_merges +tests/sql/test_2986_permits_source_key.py::test_migration_allows_the_izmeneniya_group + +# #2998 — горизонт партиций rosreestr_deals. Четыре DB-теста поднимают rosreestr_deals из +# 01_schema в СХЕМЕ-ПЕСОЧНИЦЕ (герметично, прод не трогают), применяют миграцию 193 и +# читают горизонт из pg_inherits: до 193 красно по значению (01-схема кончается на 2026q1), +# после — партиция есть на публикуемый квартал + на следующий; 193 идемпотентна. +# В CI ИДУТ (postgres-сервис, #2745); записи нужны для машины без БД. Календарный +# test_calendar_helper_matches_known_publication базы НЕ требует и в список НЕ входит. +tests/sql/test_2998_rosreestr_partition_horizon.py::test_schema_01_alone_is_red_for_the_publishable_quarter +tests/sql/test_2998_rosreestr_partition_horizon.py::test_migration_193_is_idempotent_and_closes_the_gap +tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_for_every_publishable_quarter +tests/sql/test_2998_rosreestr_partition_horizon.py::test_partition_exists_one_quarter_ahead diff --git a/backend/tests/sql/test_2464_act_date_backfill.py b/backend/tests/sql/test_2464_act_date_backfill.py new file mode 100644 index 00000000..9442753f --- /dev/null +++ b/backend/tests/sql/test_2464_act_date_backfill.py @@ -0,0 +1,204 @@ +"""Миграция 191 чинит ровно 11 строк с датой Генплана и ничего кроме (#2464). + +До #2981 `_extract_act_date` брал первое «от DD.MM.YYYY» во всём OCR-тексте, и в +`act_date` уезжала дата из «Решение Екатеринбургской городской Думы от 06.07.2004 +№ 60/1 «Об утверждении Генерального плана города»». + +#2981 чинит извлечение **только вперёд**: UPSERT загрузчика заканчивается +`ON CONFLICT DO NOTHING`, а не DO UPDATE, поэтому пятничный прогон существующие +строки не перезапишет. Отсюда разовая миграция. + +Замер прода 20.08.2026: 11 строк с `act_date='2004-07-06'` — 9 по документу +89adb28a… (развязка) и 2 по 9b9d9a99… (Энергостроителей). Верные даты взяты из +самих PDF, распознанных тем же трактом, что использует загрузчик. + +Тест герметичный: временная копия таблицы, боевые данные не читаются и не меняются. +Проверяется тело миграции целиком, а не переписанный от руки UPDATE. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import re +from datetime import date +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +_MIGRATION = ( + Path(__file__).resolve().parents[3] + / "data" + / "sql" + / "191_land_reservation_act_date_backfill.sql" +) + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( + "DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign" + ) + return ( + raw + if raw.startswith("postgresql+") + else raw.replace("postgresql://", "postgresql+psycopg://") + ) + + +def _db_reachable() -> tuple[bool, str]: + try: + eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) + with eng.connect() as c: + c.execute(text("SELECT 1")) + return True, "" + except Exception as exc: + return False, str(exc) + + +_DB_OK, _DB_ERR = _db_reachable() +pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}") + +_URL_РАЗВЯЗКА = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/89adb28a3677e7df933e2d9ce0f205c8" +_URL_ЭНЕРГО = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/9b9d9a998f578db56315bb816fc2ebf5" +_URL_ЧУЖОЙ = "https://xn--80acgfbsl1azdqr.xn--p1ai/file/ffffffffffffffffffffffffffffffff" + +_TEMP = """ +CREATE TEMP TABLE land_reservation ( + id bigserial PRIMARY KEY, + cad_num text NOT NULL, + act_date date, + doc_url text +) ON COMMIT DROP +""" + +# Прод-форма: 9 строк развязки + 2 Энергостроителей + контрольные посторонние. +_SEED = ( + [(f"66:41:0603006:{i}", date(2004, 7, 6), _URL_РАЗВЯЗКА) for i in range(9)] + + [(f"66:41:0303004:{i}", date(2004, 7, 6), _URL_ЭНЕРГО) for i in range(2)] + + [ + # Чужой документ с той же датой — трогать НЕЛЬЗЯ: 06.07.2004 может быть + # и настоящей датой акта, сужение по doc_url именно для этого. + ("66:41:0000001:1", date(2004, 7, 6), _URL_ЧУЖОЙ), + # Верные строки других документов — не должны сдвинуться. + ("66:41:0502080:22", date(2015, 9, 29), _URL_ЧУЖОЙ), + ] +) + + +def _body() -> list[str]: + """Тело миграции без комментариев, разбитое на выполнимые куски.""" + raw = _MIGRATION.read_text(encoding="utf-8") + без_комментов = "\n".join(ln for ln in raw.splitlines() if not ln.lstrip().startswith("--")) + return [ + chunk.strip() + for chunk in re.split(r";\s*(?:\n|$)", без_комментов) + if chunk.strip() and chunk.strip().upper() not in {"BEGIN", "COMMIT"} + ] + + +def _run() -> dict[tuple[str, date], int]: + session = sessionmaker(bind=create_engine(_dsn()))() + try: + session.execute(text(_TEMP)) + for cad, d, url in _SEED: + session.execute( + text("INSERT INTO land_reservation (cad_num, act_date, doc_url) VALUES (:c,:d,:u)"), + {"c": cad, "d": d, "u": url}, + ) + for chunk in _body(): + session.execute(text(chunk)) + rows = session.execute( + text( + "SELECT right(doc_url,32) AS h, act_date, count(*)" + " FROM land_reservation GROUP BY 1,2" + ) + ).all() + return {(str(r[0]), r[1]): int(r[2]) for r in rows} + finally: + session.rollback() + session.close() + + +def test_genplan_dates_are_replaced_with_real_ones() -> None: + """Головной: 9 + 2 строки получают дату своего постановления.""" + res = _run() + assert res.get(("89adb28a3677e7df933e2d9ce0f205c8", date(2022, 5, 27))) == 9, res + assert res.get(("9b9d9a998f578db56315bb816fc2ebf5", date(2020, 2, 12))) == 2, res + + +def test_no_2004_rows_left_in_target_documents() -> None: + """Контроль полноты: в этих двух документах даты 2004 года не остаётся.""" + res = _run() + for h in ("89adb28a3677e7df933e2d9ce0f205c8", "9b9d9a998f578db56315bb816fc2ebf5"): + assert res.get((h, date(2004, 7, 6))) is None, f"остались строки 2004 у {h}: {res}" + + +def test_other_documents_with_same_date_are_untouched() -> None: + """Контроль сужения: та же дата в ЧУЖОМ документе не трогается. + + Без условия по doc_url миграция переписала бы любую строку с 06.07.2004, + включая те, где эта дата настоящая. + """ + res = _run() + assert res.get(("ffffffffffffffffffffffffffffffff", date(2004, 7, 6))) == 1, res + + +def test_correct_rows_do_not_move() -> None: + """Контроль от переусердствования: верные даты остаются на месте.""" + res = _run() + assert res.get(("ffffffffffffffffffffffffffffffff", date(2015, 9, 29))) == 1, res + + +def test_migration_is_idempotent() -> None: + """Повторный запуск ничего не меняет — условие act_date='2004-07-06' не выполнится.""" + session = sessionmaker(bind=create_engine(_dsn()))() + try: + session.execute(text(_TEMP)) + for cad, d, url in _SEED: + session.execute( + text("INSERT INTO land_reservation (cad_num, act_date, doc_url) VALUES (:c,:d,:u)"), + {"c": cad, "d": d, "u": url}, + ) + for _ in range(2): + for chunk in _body(): + session.execute(text(chunk)) + n = session.execute( + text("SELECT count(*) FROM land_reservation WHERE act_date = DATE '2022-05-27'") + ).scalar() + assert n == 9, f"после двух прогонов строк с 2022-05-27: {n}" + finally: + session.rollback() + session.close() + + +def test_without_the_migration_rows_stay_wrong() -> None: + """Контроль-двойник: БЕЗ прогона миграции строки остаются с датой Генплана. + + Обязателен: без него тест неотличим от «оно и так было правильно». + """ + session = sessionmaker(bind=create_engine(_dsn()))() + try: + session.execute(text(_TEMP)) + for cad, d, url in _SEED: + session.execute( + text("INSERT INTO land_reservation (cad_num, act_date, doc_url) VALUES (:c,:d,:u)"), + {"c": cad, "d": d, "u": url}, + ) + # Миграцию НЕ прогоняем. + n = session.execute( + text("SELECT count(*) FROM land_reservation WHERE act_date = DATE '2004-07-06'") + ).scalar() + assert n == 12, f"засеяно не то состояние — строк с датой Генплана: {n}" + assert ( + session.execute( + text("SELECT count(*) FROM land_reservation WHERE act_date = DATE '2022-05-27'") + ).scalar() + == 0 + ), "верная дата появилась без миграции — тест смотрел бы не туда" + finally: + session.rollback() + session.close() diff --git a/backend/tests/sql/test_2464_area_bin_unknown.py b/backend/tests/sql/test_2464_area_bin_unknown.py new file mode 100644 index 00000000..0fd59e2c --- /dev/null +++ b/backend/tests/sql/test_2464_area_bin_unknown.py @@ -0,0 +1,169 @@ +"""Лоты без площади — отдельная корзина, а не «<25 м²» (#2464). + +`_SUPPLY_ONLY_LOTS_SQL` раскладывал `area_pd IS NULL` в ту же корзину `'<25'`, +что и настоящие студии. Замер прода 20.08.2026 (последний снапшот на физлот, +premise_kind='квартира', не проданные): + + в продаже 181 353 + без area_pd 11 557 (6.4 %) + реально < 25 м² 7 013 + +То есть корзина «<25» состояла из неизвестного на 62 % и завышала долю мелких +лотов в структуре остатков — это блок «По предложению (без темпа продаж)» +отчёта. Зеркала у такого отображения не было: `layout_signature.area_bin` +принимает float и NULL-ветки не имеет вовсе, а velocity-MV по площади не +группирует. Исключать лоты тоже нельзя — они реально в продаже. + +Тест герметичный и прогоняет НАСТОЯЩИЙ SQL: временная таблица `objective_lots` +затеняет боевую в пределах сессии (temp-схема идёт раньше public в search_path), +поэтому запрос из модуля выполняется дословно, а прод-данные не читаются. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from decimal import Decimal + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( + "DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign" + ) + return ( + raw + if raw.startswith("postgresql+") + else raw.replace("postgresql://", "postgresql+psycopg://") + ) + + +def _db_reachable() -> tuple[bool, str]: + try: + eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) + with eng.connect() as c: + c.execute(text("SELECT 1")) + return True, "" + except Exception as exc: + return False, str(exc) + + +_DB_OK, _DB_ERR = _db_reachable() +pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}") + +_TEMP_TABLE = """ +CREATE TEMP TABLE objective_lots ( + id bigserial PRIMARY KEY, + project_name text, + corpus_name text, + section text, + floor integer, + lot_number text, + rooms_int smallint, + area_pd numeric, + price_per_m2_rub numeric, + is_sold boolean, + status text, + premise_kind text, + snapshot_date date +) ON COMMIT DROP +""" + +# Один проект, четыре лота: без площади, настоящая студия, 30 м², 50 м². +_ROWS = [ + ("ЖК Пример", "к1", "с1", 3, "101", 1, None, 200000, "квартира"), + ("ЖК Пример", "к1", "с1", 3, "102", 1, Decimal("22.0"), 200000, "квартира"), + ("ЖК Пример", "к1", "с1", 4, "201", 1, Decimal("30.0"), 210000, "квартира"), + ("ЖК Пример", "к1", "с1", 5, "301", 2, Decimal("50.0"), 220000, "квартира"), +] + + +def _buckets() -> dict[str, int]: + """Выполнить настоящий запрос модуля на временной таблице → {корзина: лотов}.""" + from app.services.site_finder.best_layouts import ( + _SUPPLY_ONLY_LOTS_SQL, + _SUPPLY_ONLY_PREMISE_KIND, + _SUPPLY_ONLY_PRICE_MAX, + _SUPPLY_ONLY_PRICE_MIN, + ) + + session = sessionmaker(bind=create_engine(_dsn()))() + try: + session.execute(text(_TEMP_TABLE)) + for pn, corp, sec, fl, lot, rooms, area, price, kind in _ROWS: + session.execute( + text( + "INSERT INTO objective_lots (project_name, corpus_name, section, floor," + " lot_number, rooms_int, area_pd, price_per_m2_rub, is_sold, status," + " premise_kind, snapshot_date) VALUES (:pn,:corp,:sec,:fl,:lot,:rooms," + ":area,:price,false,'в продаже',:kind,CURRENT_DATE)" + ), + { + "pn": pn, + "corp": corp, + "sec": sec, + "fl": fl, + "lot": lot, + "rooms": rooms, + "area": area, + "price": price, + "kind": kind, + }, + ) + rows = ( + session.execute( + _SUPPLY_ONLY_LOTS_SQL, + { + "names": ["ЖК Пример"], + "premise_kind": _SUPPLY_ONLY_PREMISE_KIND, + "price_min": _SUPPLY_ONLY_PRICE_MIN, + "price_max": _SUPPLY_ONLY_PRICE_MAX, + }, + ) + .mappings() + .all() + ) + return {str(r["area_bin"]): int(r["lots_count"]) for r in rows} + finally: + session.rollback() + session.close() + + +def test_lot_without_area_is_not_counted_as_tiny() -> None: + """Головной: лот без площади не должен попадать в корзину настоящих студий. + + На origin/main корзина «<25» содержит 2 лота вместо одного — второй это + лот с area_pd IS NULL. + """ + b = _buckets() + assert b.get("<25") == 1, ( + f"в корзину «<25» попал лот без площади: {b} — на проде так туда " + "уезжают 11 557 квартир при 7 013 настоящих" + ) + + +def test_unknown_area_gets_its_own_bucket() -> None: + """Лот без площади обязан быть виден отдельно, а не исчезнуть. + + Исключение занизило бы предложение на 6.4 %: лоты реально в продаже. + """ + b = _buckets() + assert b.get("н/д") == 1, f"корзины «н/д» нет — лот без площади потерян: {b}" + + +def test_total_lots_preserved() -> None: + """Контроль сохранности: сумма по корзинам равна числу лотов в продаже.""" + b = _buckets() + assert sum(b.values()) == len(_ROWS), f"лоты потеряны или задвоены: {b}" + + +def test_normal_buckets_unchanged() -> None: + """Контроль от переусердствования: обычные площади раскладываются как раньше.""" + b = _buckets() + assert b.get("25-40") == 1, f"корзина 25-40: {b}" + assert b.get("40-60") == 1, f"корзина 40-60: {b}" diff --git a/backend/tests/sql/test_2464_default_profile_unique.py b/backend/tests/sql/test_2464_default_profile_unique.py new file mode 100644 index 00000000..4a1599f3 --- /dev/null +++ b/backend/tests/sql/test_2464_default_profile_unique.py @@ -0,0 +1,164 @@ +"""У пользователя не может быть двух дефолтных профилей весов (#2464). + +`create_profile`/`update_profile` делают «снять is_default у всех → поставить новому» +двумя отдельными операторами. Между ними инвариант нарушен, и при одновременных запросах +у пользователя может оказаться ДВА профиля с `is_default=TRUE`. Читающий `_SELECT_DEFAULT` +брал `LIMIT 1` без `ORDER BY` — выбор молча перескакивал между ними. + +Правка из двух рубежей: + • миграция 190 — частичный уникальный индекс «не более одного дефолта на пользователя»; + • `ORDER BY id ASC` в `_SELECT_DEFAULT` — детерминированный выбор, если индекс снимут. + +Проверяется ПОВЕДЕНИЕ на живом Postgres: вторая установка дефолта обязана отвергаться +базой. Тест герметичный — таблица создаётся временной в своей же сессии. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import sessionmaker + +_MIGRATION = ( + Path(__file__).resolve().parents[3] + / "data" + / "sql" + / "190_user_weight_profiles_one_default.sql" +) + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( + "DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign" + ) + return ( + raw + if raw.startswith("postgresql+") + else raw.replace("postgresql://", "postgresql+psycopg://") + ) + + +def _db_reachable() -> tuple[bool, str]: + try: + eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) + with eng.connect() as c: + c.execute(text("SELECT 1")) + return True, "" + except Exception as exc: + return False, str(exc) + + +_DB_OK, _DB_ERR = _db_reachable() +pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}") + +_TABLE = """ +CREATE TEMP TABLE user_weight_profiles ( + id bigserial PRIMARY KEY, + user_id text NOT NULL, + profile_name text, + weights jsonb, + is_default boolean DEFAULT false, + description text +) ON COMMIT DROP; +""" + + +@pytest.fixture +def db(): + engine = create_engine(_dsn()) + session = sessionmaker(bind=engine)() + try: + session.execute(text(_TABLE)) + n = session.execute(text("SELECT count(*) FROM user_weight_profiles")).scalar() + assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)" + yield session + finally: + session.rollback() + session.close() + engine.dispose() + + +def _apply_migration(db) -> None: + sql = _MIGRATION.read_text() + body = sql[sql.index("BEGIN;") + len("BEGIN;") : sql.rindex("COMMIT;")] + # Комментарии снимаем ДО разбиения: «;» внутри комментария разрезала бы SQL. + code = "\n".join( + ln for ln in body.splitlines() if ln.strip() and not ln.lstrip().startswith("--") + ) + for chunk in code.split(";"): + if chunk.strip(): + db.execute(text(chunk)) + + +def _add(db, *, user: str, name: str, default: bool) -> None: + db.execute( + text( + "INSERT INTO user_weight_profiles (user_id, profile_name, is_default)" + " VALUES (:u, :n, :d)" + ), + {"u": user, "n": name, "d": default}, + ) + + +def test_second_default_is_rejected_by_the_database(db) -> None: + """Второй дефолт у того же пользователя обязан отвергаться. + + Без миграции такая вставка проходит, и у пользователя оказывается два дефолта. + """ + _apply_migration(db) + _add(db, user="u1", name="первый", default=True) + + with pytest.raises(IntegrityError): + _add(db, user="u1", name="второй", default=True) + + +def test_without_migration_two_defaults_slip_through(db) -> None: + """Фальсификация: без индекса два дефолта вставляются молча. + + Без этой проверки зелёный тест выше неотличим от «оно и так не вставлялось». + """ + _add(db, user="u1", name="первый", default=True) + _add(db, user="u1", name="второй", default=True) + n = db.execute( + text("SELECT count(*) FROM user_weight_profiles WHERE user_id='u1' AND is_default") + ).scalar() + assert n == 2, f"без индекса дефолтов {n}, ожидалось 2 — тест выше ничего не доказывает" + + +def test_reassigning_default_still_works(db) -> None: + """Контроль: штатная переустановка дефолта проходит. + + Порядок в коде — сначала снять у всех, потом поставить новому. Индекс не должен + этому мешать, иначе пользователь не сможет сменить дефолтный профиль. + """ + _apply_migration(db) + _add(db, user="u1", name="первый", default=True) + _add(db, user="u1", name="второй", default=False) + + db.execute(text("UPDATE user_weight_profiles SET is_default = FALSE WHERE user_id='u1'")) + db.execute( + text( + "UPDATE user_weight_profiles SET is_default = TRUE" + " WHERE user_id='u1' AND profile_name='второй'" + ) + ) + name = db.execute( + text("SELECT profile_name FROM user_weight_profiles WHERE user_id='u1' AND is_default") + ).scalar() + assert name == "второй" + + +def test_different_users_keep_their_own_defaults(db) -> None: + """Контроль: индекс не мешает разным пользователям иметь свой дефолт.""" + _apply_migration(db) + _add(db, user="u1", name="a", default=True) + _add(db, user="u2", name="b", default=True) + n = db.execute(text("SELECT count(*) FROM user_weight_profiles WHERE is_default")).scalar() + assert n == 2 diff --git a/backend/tests/sql/test_2464_land_reservation_dedup.py b/backend/tests/sql/test_2464_land_reservation_dedup.py new file mode 100644 index 00000000..067302af --- /dev/null +++ b/backend/tests/sql/test_2464_land_reservation_dedup.py @@ -0,0 +1,215 @@ +"""ON CONFLICT DO NOTHING в land_reservation обязан реально ловить дубли (#2464). + +`_UPSERT_NO_ACT_SQL` (workers/tasks/izyatie_ocr_ingest.py) заканчивается +`ON CONFLICT DO NOTHING`, а единственный подходящий констрейнт был +`UNIQUE (cad_num, act_number)` с обычной NULL-семантикой. В Postgres NULL != NULL, +поэтому у записей БЕЗ номера акта конфликт не наступал никогда — каждый недельный +прогон вставлял копию. + +Замер прода 20.08.2026 до правки: 297 строк, все с `act_number IS NULL`, 27 групп с +дублями, до 11 копий, 270 лишних строк — 91 % таблицы. + +Миграция 189 дедуплицирует таблицу и пересоздаёт констрейнт как +`UNIQUE NULLS NOT DISTINCT`. Здесь проверяется САМ МЕХАНИЗМ на временной копии: +с новой семантикой повторная вставка — no-op, со старой — дубль. Второе +утверждение обязательно: без него тест не отличить от «оно и так работало». + +Тест герметичный: таблицы временные, боевые данные не читаются и не меняются. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import re +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +_MIGRATION = ( + Path(__file__).resolve().parents[3] + / "data" + / "sql" + / "189_land_reservation_nulls_not_distinct.sql" +) + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( + "DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign" + ) + return ( + raw + if raw.startswith("postgresql+") + else raw.replace("postgresql://", "postgresql+psycopg://") + ) + + +def _db_reachable() -> tuple[bool, str]: + try: + eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) + with eng.connect() as c: + c.execute(text("SELECT 1")) + return True, "" + except Exception as exc: + return False, str(exc) + + +_DB_OK, _DB_ERR = _db_reachable() +pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}") + +_TABLE = """ +CREATE TEMP TABLE land_reservation ( + id bigserial PRIMARY KEY, + cad_num text NOT NULL, + act_number text, + doc_url text, + reservation_kind text, + is_active boolean DEFAULT true, + fetched_at timestamptz DEFAULT now() +) ON COMMIT DROP; +""" + +_INSERT = """ +INSERT INTO land_reservation (cad_num, act_number, doc_url, reservation_kind) +VALUES (:cad, :act, :url, 'изъятие') +ON CONFLICT DO NOTHING +""" + + +@pytest.fixture +def db(): + engine = create_engine(_dsn()) + session = sessionmaker(bind=engine)() + try: + session.execute(text(_TABLE)) + n = session.execute(text("SELECT count(*) FROM land_reservation")).scalar() + assert n == 0, f"запрос попал НЕ во временную таблицу ({n} строк)" + yield session + finally: + session.rollback() + session.close() + engine.dispose() + + +def _add_constraint(db, nulls_not_distinct: bool) -> None: + kind = "UNIQUE NULLS NOT DISTINCT" if nulls_not_distinct else "UNIQUE" + db.execute( + text(f"ALTER TABLE land_reservation ADD CONSTRAINT uq_t {kind} (cad_num, act_number)") + ) + + +def _insert_twice(db) -> int: + for _ in range(2): + db.execute( + text(_INSERT), {"cad": "66:41:0303004:22", "act": None, "url": "https://x/y.pdf"} + ) + return int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) + + +def test_nulls_not_distinct_deduplicates(db) -> None: + """С новой семантикой повторная вставка act-less записи — no-op.""" + _add_constraint(db, nulls_not_distinct=True) + assert _insert_twice(db) == 1, "дубль всё равно вставился" + + +def test_plain_unique_does_not_deduplicate(db) -> None: + """Фальсификация: со СТАРЫМ констрейнтом дубль обязан появиться. + + Без этой проверки зелёный тест выше неотличим от «оно и так работало». + """ + _add_constraint(db, nulls_not_distinct=False) + assert _insert_twice(db) == 2, ( + "обычный UNIQUE неожиданно поймал дубль — значит тест выше ничего не доказывает" + ) + + +def test_records_with_act_number_still_deduplicate(db) -> None: + """Контроль: записи С номером акта дедуплицировались и раньше — не сломали.""" + _add_constraint(db, nulls_not_distinct=True) + for _ in range(2): + db.execute(text(_INSERT), {"cad": "66:41:1", "act": "12-АК", "url": "https://x/1.pdf"}) + assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 1 + + +def test_different_parcels_are_not_collapsed(db) -> None: + """Контроль: разные участки без номера акта остаются разными строками. + + Ловит «починку» через слишком широкий ключ. + """ + _add_constraint(db, nulls_not_distinct=True) + for cad in ("66:41:1", "66:41:2", "66:41:3"): + db.execute(text(_INSERT), {"cad": cad, "act": None, "url": "https://x/z.pdf"}) + assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 3 + + +def test_migration_dedup_statement_matches_the_key(db) -> None: + """DELETE в миграции обязан чистить ровно по ключу констрейнта. + + Расхождение ключа дедупа и ключа констрейнта означало бы, что после DELETE + констрейнт всё равно не создастся — миграция упала бы на проде. + """ + sql = _MIGRATION.read_text() + assert "UNIQUE NULLS NOT DISTINCT (cad_num, act_number)" in sql + delete_stmt = re.search(r"DELETE FROM land_reservation.*?;", sql, re.S) + assert delete_stmt is not None, "в миграции нет DELETE — дедуп не выполняется" + body = delete_stmt.group(0) + assert "a.cad_num = b.cad_num" in body, "дедуп не по cad_num" + assert "a.act_number IS NULL" in body and "b.act_number IS NULL" in body, ( + "дедуп затрагивает записи С номером акта — они и так были уникальны" + ) + assert "a.id > b.id" in body, "не задан выживающий (минимальный id)" + + +def test_migration_body_runs_on_a_prod_shaped_replica(db) -> None: + """Репетиция миграции: 11 копий → 1 строка, констрейнт создаётся. + + Сильнее проверки регулярками: исполняются РЕАЛЬНЫЕ выражения из файла миграции. + Если DELETE чистит не по тому ключу, ADD CONSTRAINT здесь же и упадёт — как + упал бы на проде. + """ + # Засев как на проде: одна группа, 11 точных копий, плюс соседний участок. + for _ in range(11): + db.execute( + text( + "INSERT INTO land_reservation (cad_num, act_number, doc_url, reservation_kind)" + " VALUES ('66:41:0303004:22', NULL, 'https://x/y.pdf', 'изъятие')" + ) + ) + db.execute( + text( + "INSERT INTO land_reservation (cad_num, act_number, doc_url, reservation_kind)" + " VALUES ('66:41:0206032:8499', NULL, 'https://x/z.pdf', 'изъятие')" + ) + ) + assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 12 + + sql = _MIGRATION.read_text() + body = sql[sql.index("BEGIN;") + len("BEGIN;") : sql.rindex("COMMIT;")] + # Комментарии снимаем ДО разбиения на выражения — иначе точка с запятой внутри + # комментария разрежет SQL посередине. Обе ловушки этот тест уже ловил на себе: + # сперва пропуск куска, начинающегося с «--» (потерялся DELETE, репетиция упала + # на ADD CONSTRAINT), затем «;» в тексте комментария. + code = "\n".join( + ln for ln in body.splitlines() if ln.strip() and not ln.lstrip().startswith("--") + ) + for chunk in code.split(";"): + stmt = chunk.strip() + if stmt: + db.execute(text(stmt)) + + rows = db.execute( + text("SELECT cad_num, count(*) FROM land_reservation GROUP BY 1 ORDER BY 1") + ).all() + assert [(r[0], r[1]) for r in rows] == [ + ("66:41:0206032:8499", 1), + ("66:41:0303004:22", 1), + ], f"после миграции осталось не по одной строке: {rows}" + + # И теперь повторная вставка действительно no-op. + db.execute(text(_INSERT), {"cad": "66:41:0303004:22", "act": None, "url": "https://x/y.pdf"}) + assert int(db.execute(text("SELECT count(*) FROM land_reservation")).scalar()) == 2 diff --git a/backend/tests/sql/test_2464_leads_stats_suffix_contract.py b/backend/tests/sql/test_2464_leads_stats_suffix_contract.py new file mode 100644 index 00000000..f83a868f --- /dev/null +++ b/backend/tests/sql/test_2464_leads_stats_suffix_contract.py @@ -0,0 +1,189 @@ +"""Суффикс поля в /admin/leads/stats обязан соответствовать смыслу величины (#2464). + +В ответе рядом стоят величины двух видов: за всё время (`leads_total`, `sources_total`) и +за окно `months` (`leads_window`, `converted_window`, `conv_pct_window`). Соглашение +читается однозначно по самим именам. + +`revenue_total` и `deals_total` его нарушали: считались по CTE `window_leads`, то есть за +окно, а суффиксом обещали итог. Админка из-за этого печатала карточку «Revenue (всего)» с +12-месячной цифрой. + +Проверяется ИНВАРИАНТ, а не набор имён: для данных, где итог заведомо не равен окну, +каждое поле `*_total` обязано совпасть с итогом, каждое `*_window` — с окном. Такая +формулировка краснеет на origin/main по НЕВЕРНОМУ ЗНАЧЕНИЮ, а не по отсутствию ключа, и +переживёт любое разумное переименование. + +Тест герметичный: обе таблицы создаются ВРЕМЕННЫМИ в своей же сессии; по конвенции +`tests/sql/*` DSN по умолчанию смотрит в туннель к прод-базе, поэтому в фикстуре стоит +проверка, что затенение сработало. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( + "DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign" + ) + return ( + raw + if raw.startswith("postgresql+") + else raw.replace("postgresql://", "postgresql+psycopg://") + ) + + +def _db_reachable() -> tuple[bool, str]: + try: + eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) + with eng.connect() as c: + c.execute(text("SELECT 1")) + return True, "" + except Exception as exc: + return False, str(exc) + + +_DB_OK, _DB_ERR = _db_reachable() +pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}") + +_SCHEMA = """ +CREATE TEMP TABLE prinzip_leads ( + lead_id bigint, created_at timestamptz, source text, converted boolean, + deal_id bigint) ON COMMIT DROP; +CREATE TEMP TABLE prinzip_deals ( + deal_id bigint, deal_price numeric) ON COMMIT DROP; +""" + +_WINDOW_MONTHS = 12 + +# Внутри окна: 2 заявки, обе со сделками по 1 000 000. +# Снаружи (три года назад): 3 заявки, сделки по 5 000 000 — итог заведомо не равен окну. +_IN_WINDOW_LEADS = 2 +_OUT_WINDOW_LEADS = 3 +_ALL_TIME_LEADS = _IN_WINDOW_LEADS + _OUT_WINDOW_LEADS +_IN_WINDOW_REVENUE = 2_000_000.0 +_IN_WINDOW_DEALS = 2 + + +@pytest.fixture +def db(): + engine = create_engine(_dsn()) + session = sessionmaker(bind=engine)() + try: + session.execute(text(_SCHEMA)) + for table in ("prinzip_leads", "prinzip_deals"): + n = session.execute(text(f"SELECT count(*) FROM {table}")).scalar() + assert n == 0, ( + f"{table}: запрос попал НЕ во временную таблицу ({n} строк) — " + "тест читал бы боевые данные" + ) + yield session + finally: + session.rollback() + session.close() + engine.dispose() + + +@pytest.fixture +def seeded(db): + rows = [ + (1, "0 days", "site", True, 101, 1_000_000), + (2, "10 days", "site", True, 102, 1_000_000), + (3, "1100 days", "avito", True, 103, 5_000_000), + (4, "1101 days", "avito", True, 104, 5_000_000), + (5, "1102 days", "vk", True, 105, 5_000_000), + ] + for lead_id, ago, source, converted, deal_id, price in rows: + db.execute( + text( + "INSERT INTO prinzip_leads (lead_id, created_at, source, converted, deal_id)" + " VALUES (:l, NOW() - CAST(:ago AS interval), :s, :c, :d)" + ), + {"l": lead_id, "ago": ago, "s": source, "c": converted, "d": deal_id}, + ) + db.execute( + text("INSERT INTO prinzip_deals (deal_id, deal_price) VALUES (:d, :p)"), + {"d": deal_id, "p": price}, + ) + return db + + +def _stats(db) -> dict: + from app.api.v1.admin_leads import leads_stats + + return leads_stats(db=db, months=_WINDOW_MONTHS) + + +def test_window_suffixed_fields_match_the_window(seeded) -> None: + """Всё, что названо `_window`, обязано считаться по окну. + + На origin/main эти величины лежат под именами `revenue_total`/`deals_total`, + поэтому проверка ниже (по `_total`) и краснеет — здесь же контроль, что + оконные значения не поехали. + """ + stats = _stats(seeded) + assert stats["leads_window"] == _IN_WINDOW_LEADS + assert stats["converted_window"] == _IN_WINDOW_LEADS + + +def test_total_suffixed_fields_are_all_time(seeded) -> None: + """КАЖДОЕ поле `*_total` обязано быть за всё время, а не за окно. + + На origin/main `revenue_total` = 2 000 000 (только окно) при итоге 17 000 000, + и `deals_total` = 2 при итоге 5 — красное по неверному ЗНАЧЕНИЮ. + """ + stats = _stats(seeded) + all_time_revenue = float( + seeded.execute(text("SELECT COALESCE(SUM(deal_price), 0) FROM prinzip_deals")).scalar() + ) + all_time_deals = int(seeded.execute(text("SELECT COUNT(*) FROM prinzip_deals")).scalar()) + expected = { + "leads_total": _ALL_TIME_LEADS, + "revenue_total": all_time_revenue, + "deals_total": all_time_deals, + } + + for key, value in stats.items(): + if not key.endswith("_total"): + continue + if key not in expected: + continue + assert value == expected[key], ( + f"поле {key!r} обещает суффиксом величину за ВСЁ время, а равно {value} " + f"при итоге {expected[key]} — это цифра за окно {_WINDOW_MONTHS} мес" + ) + + +def test_revenue_and_deals_are_named_by_their_scope(seeded) -> None: + """Выручка и сделки должны нести суффикс, соответствующий их охвату. + + Отдельно от предыдущего: там проверяется значение под именем, здесь — что имя + вообще выбрано по охвату. Ловит «починку», которая оставила бы `_total` и + просто перестала показывать поле в UI. + """ + stats = _stats(seeded) + assert stats.get("revenue_window") == _IN_WINDOW_REVENUE, ( + f"revenue_window = {stats.get('revenue_window')}, ожидалось {_IN_WINDOW_REVENUE}" + ) + assert stats.get("deals_window") == _IN_WINDOW_DEALS + + +def test_window_months_present_on_empty_data(db) -> None: + """Контроль: на пустых данных ответ сохраняет форму и ширину окна. + + Оговорка, чтобы тест не читался как покрытие ветки `if not row`: он туда НЕ + попадает. Запрос агрегатный и на пустых таблицах возвращает обычную строку + (leads_total=0, leads_window=0), поэтому исполняется основная ветка. Ветка + пустого ответа сегодня недостижима — её согласованность правится вслепую, + и проверить её этим тестом нельзя. + """ + stats = _stats(db) + assert "window_months" in stats, f"нет window_months в пустом ответе: {sorted(stats)}" + assert stats["window_months"] == _WINDOW_MONTHS diff --git a/backend/tests/sql/test_2956_freshness_ignores_failed_dumps.py b/backend/tests/sql/test_2956_freshness_ignores_failed_dumps.py new file mode 100644 index 00000000..c30077ec --- /dev/null +++ b/backend/tests/sql/test_2956_freshness_ignores_failed_dumps.py @@ -0,0 +1,195 @@ +"""Свежесть data-table источника считается по УСПЕШНЫМ строкам, а не по факту записи (#2956). + +Что случилось на проде. `nspd_quarter_dumps` — контент-таблица: harvest пишет строку и +когда всё получилось, и когда упал (тогда `harvest_error` заполнен, счётчики нулевые, но +`fetched_at_utc` всё равно проставлен). Монитор свежести брал `MAX(fetched_at_utc)` без +разбора — и каждый ПРОВАЛ обновлял часы свежести. + +Последний успешный дамп — 27.07.2026. С 03.08 провалились все 61 подряд. Источник при +этом числился `ok` (возраст ~3 дня при пороге 14), сторож молчал 24 суток. Слепота по +построению: монитор мерил «записали ли мы строку», а не «получили ли мы данные». + +Правильный образец лежал в соседней ветке того же `if`: run-ledger режим отделяет успех +от попытки через `FILTER (WHERE status = 'done')`. + +Тест герметичный: все таблицы реестра создаются ВРЕМЕННЫМИ в своей же сессии, поэтому +он не зависит от схемы CI-базы и ничего не читает из настоящих таблиц. Скипается, если +Postgres недоступен (та же конвенция, что в tests/sql/test_velocity_alerts.py). +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( + "DATABASE_URL", + "postgresql+psycopg://gendesign@localhost:15432/gendesign", + ) + return ( + raw + if raw.startswith("postgresql+") + else raw.replace("postgresql://", "postgresql+psycopg://") + ) + + +def _db_reachable() -> tuple[bool, str]: + try: + eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) + with eng.connect() as c: + c.execute(text("SELECT 1")) + return True, "" + except Exception as exc: + return False, str(exc) + + +_DB_OK, _DB_ERR = _db_reachable() +pytestmark = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}") + +# Таблицы реестра freshness. Создаём ВСЕ, иначе compute_freshness упадёт на первой же +# отсутствующей — он проходит по всему реестру, а не только по проверяемому источнику. +_TEMP_SCHEMA = """ +CREATE TEMP TABLE kn_scrape_runs ( + status text, started_at timestamptz, finished_at timestamptz, + objects_count int, flats_count int) ON COMMIT DROP; +CREATE TEMP TABLE objective_scrape_runs ( + status text, started_at timestamptz, finished_at timestamptz, rows_lots int) ON COMMIT DROP; +CREATE TEMP TABLE nspd_geo_jobs ( + status text, started_at timestamptz, finished_at timestamptz, + created_at timestamptz, targets_done int) ON COMMIT DROP; +CREATE TEMP TABLE cadastre_jobs ( + status text, started_at timestamptz, finished_at timestamptz, + created_at timestamptz, targets_done int) ON COMMIT DROP; +CREATE TEMP TABLE gisogd_permits (id bigint, fetched_at timestamptz) ON COMMIT DROP; +CREATE TEMP TABLE nspd_quarter_dumps ( + quarter_cad text, fetched_at_utc timestamptz, + total_features int, harvest_error text) ON COMMIT DROP; +""" + + +@pytest.fixture +def db(): + engine = create_engine(_dsn()) + session = sessionmaker(bind=engine)() + try: + session.execute(text(_TEMP_SCHEMA)) + # Защита, а не украшение: если ЛЮБАЯ из таблиц реестра не создалась временной + # (опечатка в имени, изменившийся реестр), запросы уйдут в НАСТОЯЩУЮ таблицу — + # тест станет зависеть от боевых данных и сможет их читать. По конвенции репо + # (tests/sql/*) DSN по умолчанию смотрит в туннель к прод-базе, так что цена + # такой опечатки реальна. Пустая таблица сразу после создания — признак того, + # что затенение сработало: у настоящих таблиц строки есть. + for table in ( + "kn_scrape_runs", + "objective_scrape_runs", + "nspd_geo_jobs", + "cadastre_jobs", + "gisogd_permits", + "nspd_quarter_dumps", + ): + n = session.execute(text(f"SELECT count(*) FROM {table}")).scalar() + assert n == 0, ( + f"{table}: запрос попал НЕ во временную таблицу ({n} строк) — " + "тест читал бы боевые данные, а его выводы были бы про них" + ) + yield session + finally: + session.rollback() + session.close() + engine.dispose() + + +def _nspd(db) -> dict: + from app.api.v1.admin_scrape import compute_freshness + + payload = compute_freshness(db) + rows = [s for s in payload["sources"] if s["source"] == "nspd"] + assert len(rows) == 1, ( + f"источник nspd не найден в реестре: {[s['source'] for s in payload['sources']]}" + ) + return rows[0] + + +def _insert_dump(db, *, days_ago: float, error: str | None, features: int) -> None: + db.execute( + text( + """ + INSERT INTO nspd_quarter_dumps + (quarter_cad, fetched_at_utc, total_features, harvest_error) + VALUES (:cad, :ts, :feat, :err) + """ + ), + { + "cad": f"66:41:{int(days_ago * 100):07d}", + "ts": datetime.now(UTC) - timedelta(days=days_ago), + "feat": features, + "err": error, + }, + ) + + +def test_failed_dumps_do_not_refresh_the_clock(db) -> None: + """Прод-картина: успех 24 дня назад, дальше только провалы каждый день. + + На origin/main источник получает age_days ≈ 1 и статус ok — ровно то молчание, + что длилось 24 суток. С правкой возраст считается от последнего УСПЕХА. + """ + _insert_dump(db, days_ago=24.0, error=None, features=1831) + for day in range(1, 18): # 17 провалов подряд, самый свежий — вчера + _insert_dump( + db, days_ago=float(day), error="TimeoutError: The read operation timed out", features=0 + ) + + src = _nspd(db) + assert src["age_days"] is not None, "возраст не посчитан — успехов не нашлось вовсе" + assert src["age_days"] > 20, ( + f"возраст {src['age_days']}д взят от ПРОВАЛИВШЕГОСЯ дампа: провал обновил часы " + "свежести, монитор ослеп (#2956)" + ) + assert src["status"] in {"stale", "failed"}, ( + f"статус {src['status']!r} при последнем успехе 24 дня назад и пороге " + f"fresh_days=14 — источник числится живым, алёрта не будет" + ) + + +def test_successful_dump_still_counts_as_fresh(db) -> None: + """Контроль: свежий УСПЕХ по-прежнему даёт ok — правка не ужесточила лишнего.""" + _insert_dump(db, days_ago=1.0, error=None, features=1200) + _insert_dump(db, days_ago=0.5, error="TimeoutError", features=0) + + src = _nspd(db) + assert src["status"] == "ok", f"свежий успешный дамп даёт статус {src['status']!r}" + assert src["age_days"] < 2 + + +def test_attempt_is_still_recorded(db) -> None: + """Контроль: попытки не потеряны — last_attempt_at видит и провалившиеся строки. + + Разделение успех/попытка должно быть именно разделением, а не отбрасыванием: иначе + в UI пропадёт признак «загрузчик ходит, но не приносит». + """ + _insert_dump(db, days_ago=24.0, error=None, features=1831) + _insert_dump(db, days_ago=0.2, error="TimeoutError", features=0) + + src = _nspd(db) + assert src["last_attempt_at"] is not None + assert src["last_success_at"] is not None + assert src["last_attempt_at"] > src["last_success_at"], ( + "последняя попытка должна быть новее последнего успеха — иначе провалы не видны вообще" + ) + + +def test_only_failures_means_no_success_at_all(db) -> None: + """Ни одного успеха → failed, а не «свежо, потому что строки пишутся».""" + for day in range(1, 5): + _insert_dump(db, days_ago=float(day), error="TimeoutError", features=0) + + src = _nspd(db) + assert src["last_success_at"] is None, "успехом сочтена строка с harvest_error" + assert src["status"] == "failed", f"статус {src['status']!r} при полном отсутствии успехов" diff --git a/backend/tests/sql/test_2986_permits_source_key.py b/backend/tests/sql/test_2986_permits_source_key.py new file mode 100644 index 00000000..1214708c --- /dev/null +++ b/backend/tests/sql/test_2986_permits_source_key.py @@ -0,0 +1,260 @@ +"""Бизнес-ключ gisogd_permits — key документа на портале, а не (группа, номер) (#2986). + +`UNIQUE (doc_group, doc_num)` вводился, чтобы склеивать ОДИН документ, пришедший из +двух схем портала. Замер 20.08.2026 показал, что эта задача почти отсутствует, а +побочный эффект огромен — `docNum` у ГИСОГД не уникален: разрешение и изменения к +нему носят один номер. + + группа документов различных key различных docNum схлопывается + DocRS 6098 6096 4305 1793 + DocRV 5419 5415 4969 450 + DocIZ 548 547 393 155 (+не грузилась) + + общих docNum между схемами (DocRS): 2 ← ради этого и вводился ключ + общих key между схемами (DocRS): 2 ← те же два, key их тоже склеит + +Пример 66-06-06-2026: портал отдаёт два документа (key …719586 — разрешение, +key …752293 — изменения к нему), а на проде осталось только изменение. Так вытеснено +598 из 4320 строк РНС (13.8 %). + +Тесты герметичные: временная копия таблицы, боевые данные не читаются и не меняются. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import re +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +_MIGRATION = ( + Path(__file__).resolve().parents[3] + / "data" + / "sql" + / "192_gisogd_permits_key_by_source_key.sql" +) + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( + "DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign" + ) + return ( + raw + if raw.startswith("postgresql+") + else raw.replace("postgresql://", "postgresql+psycopg://") + ) + + +def _db_reachable() -> tuple[bool, str]: + try: + eng = create_engine(_dsn(), connect_args={"connect_timeout": 3}) + with eng.connect() as c: + c.execute(text("SELECT 1")) + return True, "" + except Exception as exc: + return False, str(exc) + + +_DB_OK, _DB_ERR = _db_reachable() + +# Прод-форма таблицы ДО миграции 191 (важные для ключа поля). +_TEMP_OLD = """ +CREATE TEMP TABLE gisogd_permits ( + id bigserial PRIMARY KEY, + doc_group text NOT NULL, + doc_num text NOT NULL, + doc_name text, + date_reg date, + source_schema text NOT NULL, + source_key text NOT NULL, + CONSTRAINT chk_gisogd_permits_doc_group CHECK (doc_group IN ('RS', 'RV')), + CONSTRAINT gisogd_permits_doc_group_doc_num_key UNIQUE (doc_group, doc_num) +) ON COMMIT DROP +""" + +# Два РАЗНЫХ документа с одним номером — дословно с портала. +_РАЗРЕШЕНИЕ = { + "doc_group": "RS", + "doc_num": "66-06-06-2026", + "doc_name": "Разрешение на строительство № 66-06-06-2026 от 26.02.2026", + "date_reg": "2026-02-26", + "source_schema": "agate_sverdregion", + "source_key": "1000130002719586", +} +_ИЗМЕНЕНИЕ = { + "doc_group": "RS", + "doc_num": "66-06-06-2026", + "doc_name": "Изменения в разрешение на строительство №66-06-06-2026 от 26.02.2026", + "date_reg": "2026-08-04", + "source_schema": "agate_sverdregion", + "source_key": "1000130002752293", +} + +_INSERT_COLS = ( + "(doc_group, doc_num, doc_name, date_reg, source_schema, source_key)" + " VALUES (:doc_group,:doc_num,:doc_name,CAST(:date_reg AS date)," + ":source_schema,:source_key)" +) +# Старый UPSERT — цитата прежнего кода, нужна как контроль механизма. +_OLD_UPSERT = ( + f"INSERT INTO gisogd_permits {_INSERT_COLS}" + " ON CONFLICT (doc_group, doc_num) DO UPDATE SET doc_name = EXCLUDED.doc_name," + " date_reg = EXCLUDED.date_reg, source_key = EXCLUDED.source_key" + " WHERE EXCLUDED.date_reg > gisogd_permits.date_reg" +) +_NEW_UPSERT = ( + f"INSERT INTO gisogd_permits {_INSERT_COLS}" + " ON CONFLICT (source_key) DO UPDATE SET doc_name = EXCLUDED.doc_name," + " date_reg = EXCLUDED.date_reg" +) + + +def _migration_chunks() -> list[str]: + raw = _MIGRATION.read_text(encoding="utf-8") + без_комментов = "\n".join(ln for ln in raw.splitlines() if not ln.lstrip().startswith("--")) + return [ + ch.strip() + for ch in re.split(r";\s*(?:\n|$)", без_комментов) + if ch.strip() and ch.strip().upper() not in {"BEGIN", "COMMIT"} + ] + + +def _session(): + return sessionmaker(bind=create_engine(_dsn()))() + + +# ── Гейт на лоадер: работает БЕЗ базы, двусторонний ─────────────────────────── + + +def test_loader_knows_the_amendments_group() -> None: + """DocIZ обязан быть в GROUP_CODE — иначе 548 документов не грузятся вовсе. + + На origin/main словарь содержит только DocRS/DocRV — конкретное неверное + значение, а не отсутствующий символ. + """ + from app.services.scrapers.gisogd66 import GROUP_CODE + + assert GROUP_CODE.get("DocIZ") == "IZ", ( + f"группа изменений не грузится; GROUP_CODE = {GROUP_CODE}" + ) + + +def test_loader_upserts_by_source_key() -> None: + """UPSERT обязан конфликтовать по source_key, а не по (doc_group, doc_num). + + На origin/main в тексте запроса стоит старый ключ — он и схлопывает + разрешение с изменениями к нему. + """ + import inspect + + from app.services.scrapers.gisogd66 import _upsert_permit + + src = inspect.getsource(_upsert_permit) + assert "ON CONFLICT (source_key)" in src, "UPSERT конфликтует не по source_key" + assert "ON CONFLICT (doc_group, doc_num)" not in src, ( + "старый ключ всё ещё в запросе — разрешение и изменения схлопнутся" + ) + + +# ── Механизм и миграция: нужен живой Postgres ──────────────────────────────── + +_pg = pytest.mark.skipif(not _DB_OK, reason=f"Postgres недоступен: {_DB_ERR}") + + +@_pg +def test_old_key_collapses_permit_and_its_amendment() -> None: + """Демонстрация механизма: со СТАРЫМ ключом остаётся одна строка из двух. + + Не двусторонний тест, а показ причины: и старая схема, и старый UPSERT здесь + процитированы явно, поэтому он одинаково зелёный на обеих сторонах. Нужен, + чтобы утверждение «ключ схлопывает разные документы» было проверяемым, а не + декларативным. + """ + s = _session() + try: + s.execute(text(_TEMP_OLD)) + s.execute(text(_OLD_UPSERT), _РАЗРЕШЕНИЕ) + s.execute(text(_OLD_UPSERT), _ИЗМЕНЕНИЕ) + rows = s.execute(text("SELECT doc_name, source_key FROM gisogd_permits")).all() + assert len(rows) == 1, f"ожидали схлопывание, получили {len(rows)} строк" + assert "Изменения" in rows[0][0], ( + f"вытеснено не то: осталось {rows[0][0]!r} — на проде остаётся именно изменение" + ) + finally: + s.rollback() + s.close() + + +@_pg +def test_after_migration_both_documents_survive() -> None: + """Головной: после миграции разрешение и изменения к нему живут раздельно.""" + s = _session() + try: + s.execute(text(_TEMP_OLD)) + for chunk in _migration_chunks(): + s.execute(text(chunk)) + s.execute(text(_NEW_UPSERT), _РАЗРЕШЕНИЕ) + s.execute(text(_NEW_UPSERT), _ИЗМЕНЕНИЕ) + rows = s.execute(text("SELECT doc_name FROM gisogd_permits ORDER BY source_key")).all() + assert len(rows) == 2, f"документы всё ещё схлопываются: {rows}" + имена = " | ".join(r[0] for r in rows) + assert "Разрешение на строительство" in имена and "Изменения" in имена, имена + finally: + s.rollback() + s.close() + + +@_pg +def test_cross_schema_duplicate_still_merges() -> None: + """Контроль: настоящий межсхемный дубль (ОБЩИЙ key) по-прежнему склеивается. + + Ради этого случая старый ключ и вводился — новый обязан его сохранить. + """ + s = _session() + try: + s.execute(text(_TEMP_OLD)) + for chunk in _migration_chunks(): + s.execute(text(chunk)) + s.execute(text(_NEW_UPSERT), _РАЗРЕШЕНИЕ) + из_другой_схемы = dict(_РАЗРЕШЕНИЕ, source_schema="agate_ekbgo") + s.execute(text(_NEW_UPSERT), из_другой_схемы) + n = s.execute(text("SELECT count(*) FROM gisogd_permits")).scalar() + assert n == 1, f"межсхемный дубль размножился: {n} строк" + finally: + s.rollback() + s.close() + + +@_pg +def test_migration_allows_the_izmeneniya_group() -> None: + """Контроль CHECK: после миграции doc_group='IZ' принимается, мусор — нет.""" + s = _session() + try: + s.execute(text(_TEMP_OLD)) + for chunk in _migration_chunks(): + s.execute(text(chunk)) + s.execute( + text(f"INSERT INTO gisogd_permits {_INSERT_COLS}"), + dict(_ИЗМЕНЕНИЕ, doc_group="IZ", source_key="izm-1"), + ) + assert s.execute(text("SELECT count(*) FROM gisogd_permits")).scalar() == 1 + s.execute(text("SAVEPOINT p")) + try: + s.execute( + text(f"INSERT INTO gisogd_permits {_INSERT_COLS}"), + dict(_ИЗМЕНЕНИЕ, doc_group="XX", source_key="mus-1"), + ) + raise AssertionError("CHECK пропустил мусорную группу 'XX'") + except Exception as exc: + assert "chk_gisogd_permits_doc_group" in str(exc), str(exc)[:120] + s.execute(text("ROLLBACK TO SAVEPOINT p")) + finally: + s.rollback() + s.close() diff --git a/backend/tests/sql/test_2998_rosreestr_partition_horizon.py b/backend/tests/sql/test_2998_rosreestr_partition_horizon.py new file mode 100644 index 00000000..1236bade --- /dev/null +++ b/backend/tests/sql/test_2998_rosreestr_partition_horizon.py @@ -0,0 +1,193 @@ +"""У rosreestr_deals есть партиция под каждый квартал, который Росреестр уже мог +опубликовать (#2998). + +Таблица партиционирована по period_start_date, и партиции создавались списком в +01_schema_rosreestr_deals.sql — «2024 Q3 — 2026 Q1». Дальше этого горизонта таблица +ничего не знала, и никакой механизм новые партиции не создаёт. Q2 2026 вышел 10.07, +poll заметил его 14.08, а загрузка 21.08 упала: + + ERROR: no partition of relation "rosreestr_deals" found for row + DETAIL: Partition key of the failing row contains (period_start_date) = (2026-04-01). + +То есть даже оператор, запустив 02_load_all_quarters.sh по подсказке poll, получил +бы отказ. Миграция 193 добавляет Q2–Q4 2026; этот тест следит, чтобы горизонт не +отставал снова: партиция обязана существовать на ПОСЛЕДНИЙ квартал, который по +календарю уже мог быть опубликован (публикация отстаёт от конца квартала ~на 10 +дней: Q2 2026 вышел 10.07), плюс на следующий — чтобы предупреждение приходило за +квартал до отказа, а не в день публикации. + +Герметично, как соседние DB-тесты: в своей СХЕМЕ-песочнице поднимается +rosreestr_deals из 01_schema (без индексов), применяется миграция 193, горизонт +читается из pg_inherits по этой схеме. Прод не трогается; в CI идёт на +postgres-сервисе. Без БД — skip с причиной. + +Красная сторона воспроизводима, а не предполагается: до миграции 193 горизонт +01-схемы кончается на 2026q1, и головная проверка краснеет по значению +(«нет партиции на квартал 2026-04-01»). +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import re +import uuid +from datetime import date, timedelta +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, text + +_SQL_DIR = Path(__file__).resolve().parents[3] / "data" / "sql" +_SCHEMA_01 = _SQL_DIR / "01_schema_rosreestr_deals.sql" +_MIGRATION = _SQL_DIR / "193_partitions_rosreestr_2026_q2_q4.sql" + +# Публикация квартала отстаёт от его конца; берём запас, чтобы не требовать партицию +# раньше, чем данные вообще могут появиться. Q2 2026 (конец 30.06) опубликован 10.07. +_PUBLICATION_LAG_DAYS = 20 + + +def _dsn() -> str: + raw = os.environ.get("TEST_DATABASE_URL") or os.environ.get( + "DATABASE_URL", "postgresql+psycopg://gendesign@localhost:15432/gendesign" + ) + return raw.replace("postgresql://", "postgresql+psycopg://", 1) + + +def _quarter_start(d: date) -> date: + return date(d.year, 3 * ((d.month - 1) // 3) + 1, 1) + + +def _next_quarter(q: date) -> date: + return date(q.year + (q.month == 10), 1 if q.month == 10 else q.month + 3, 1) + + +def _latest_publishable_quarter(today: date) -> date: + """Начало последнего квартала, чей дамп по календарю уже мог выйти.""" + q = _quarter_start(today) + prev_q = _quarter_start(q - timedelta(days=1)) + if (today - q).days >= _PUBLICATION_LAG_DAYS: + return prev_q + return _quarter_start(prev_q - timedelta(days=1)) + + +def _schema_01_without_indexes() -> str: + """01_schema без CREATE INDEX: индексы тесту не нужны, а часть их — по колонкам, + которых в песочнице проверять нечего. Партиции и сама таблица — целиком.""" + sql = _SCHEMA_01.read_text(encoding="utf-8") + return re.sub(r"CREATE INDEX[^;]*;", "", sql, flags=re.S) + + +def _partition_starts(conn, schema: str) -> set[date]: + rows = conn.execute( + text( + """ + SELECT pg_get_expr(c.relpartbound, c.oid) AS bound + FROM pg_inherits i + JOIN pg_class c ON c.oid = i.inhrelid + JOIN pg_class p ON p.oid = i.inhparent + JOIN pg_namespace n ON n.oid = p.relnamespace + WHERE p.relname = 'rosreestr_deals' AND n.nspname = :schema + """ + ), + {"schema": schema}, + ).scalars() + out: set[date] = set() + for b in rows: + frm = b.split("FROM ('", 1)[1].split("'", 1)[0] + out.add(date.fromisoformat(frm)) + return out + + +@pytest.fixture(scope="module") +def sandbox(): + """Схема-песочница с rosreestr_deals из 01_schema. Удаляется в конце модуля.""" + try: + eng = create_engine(_dsn(), future=True) + conn = eng.connect() + conn.execute(text("SELECT 1")) + conn.rollback() # SELECT 1 авто-открыл транзакцию — закрыть, иначе begin() падает + except Exception as e: # pragma: no cover - среда без БД + pytest.skip(f"нет Postgres для проверки партиций: {e}") + schema = f"t2998_{uuid.uuid4().hex[:10]}" + with conn.begin(): + conn.execute(text(f"CREATE SCHEMA {schema}")) + conn.execute(text(f"SET LOCAL search_path TO {schema}")) + conn.execute(text(_schema_01_without_indexes())) + try: + yield conn, schema + finally: + conn.rollback() + with conn.begin(): + conn.execute(text(f"DROP SCHEMA {schema} CASCADE")) + conn.close() + + +def _apply_193(conn, schema: str) -> None: + conn.rollback() # предыдущие SELECT авто-открыли транзакцию + with conn.begin(): + conn.execute(text(f"SET LOCAL search_path TO {schema}")) + conn.execute(text(_MIGRATION.read_text(encoding="utf-8"))) + + +def test_schema_01_alone_is_red_for_the_publishable_quarter(sandbox) -> None: + """Красная сторона: ДО миграции 193 партиции на публикуемый квартал нет. + + Это ровно состояние origin/main на 21.08.2026, когда загрузка Q2 упала. + Проверка по значению: горизонт 01-схемы кончается на 2026q1. + """ + conn, schema = sandbox + have = _partition_starts(conn, schema) + assert have, "песочница пуста — 01_schema не применилась" + assert max(have) == date(2026, 1, 1), ( + f"горизонт 01-схемы ожидался 2026q1, есть {sorted(have)[-2:]}" + ) + need = date(2026, 4, 1) # публикуемый квартал на дату инцидента + assert need not in have, "красная сторона не состоялась: 01-схема уже знает Q2 2026" + + +def test_migration_193_is_idempotent_and_closes_the_gap(sandbox) -> None: + """Миграция применяется дважды без ошибки и добавляет Q2–Q4 2026.""" + conn, schema = sandbox + _apply_193(conn, schema) + _apply_193(conn, schema) + have = _partition_starts(conn, schema) + assert {date(2026, 4, 1), date(2026, 7, 1), date(2026, 10, 1)} <= have, sorted(have)[-4:] + + +def test_partition_exists_for_every_publishable_quarter(sandbox) -> None: + """Головной сторож горизонта: на каждый уже-публикуемый квартал есть партиция. + + Сегодня это Q2 2026 (после 193 — есть). Когда календарь дойдёт до квартала, + которого миграции ещё не создали, тест покраснеет по значению — и это его работа. + """ + conn, schema = sandbox + _apply_193(conn, schema) + have = _partition_starts(conn, schema) + need = _latest_publishable_quarter(date.today()) + assert need in have, ( + f"нет партиции на квартал {need} — загрузка опубликованного дампа упадёт с " + f"«no partition of relation rosreestr_deals found for row»; есть: {sorted(have)[-3:]}" + ) + + +def test_partition_exists_one_quarter_ahead(sandbox) -> None: + """Контроль горизонта: партиция на СЛЕДУЮЩИЙ квартал тоже есть — предупреждение + должно приходить за квартал до отказа, а не в день публикации.""" + conn, schema = sandbox + _apply_193(conn, schema) + have = _partition_starts(conn, schema) + need = _next_quarter(_latest_publishable_quarter(date.today())) + assert need in have, ( + f"нет партиции на следующий квартал {need} — запаса нет, следующая публикация " + f"снова упрётся в отсутствие партиции; есть: {sorted(have)[-3:]}" + ) + + +def test_calendar_helper_matches_known_publication() -> None: + """Контроль калибровки (без БД): 21.08.2026 → Q2 2026; 05.07 → ещё Q1; 25.07 → Q2.""" + assert _latest_publishable_quarter(date(2026, 8, 21)) == date(2026, 4, 1) + assert _latest_publishable_quarter(date(2026, 7, 5)) == date(2026, 1, 1) + assert _latest_publishable_quarter(date(2026, 7, 25)) == date(2026, 4, 1) diff --git a/backend/tests/sql/test_auth_sql_migrations.py b/backend/tests/sql/test_auth_sql_migrations.py index 3c02f7b9..cd446547 100644 --- a/backend/tests/sql/test_auth_sql_migrations.py +++ b/backend/tests/sql/test_auth_sql_migrations.py @@ -1,11 +1,11 @@ """Инварианты миграций БД `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, поэтому здесь проверяются инварианты, которые -можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого -дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя. +Снимка «уже применённого» здесь нет и не нужно: у соседнего стека такой файл-список был +(tradein data/sql/_manifest_applied.txt) и его удалили в #2683 — он отставал от каталога +и по построению не мог покраснеть. Аналог гейта для tradein теперь берёт эталон из git: +tradein-mvp/backend/tests/test_migration_numbering.py. Здесь же проверяются инварианты, +выполнимые БЕЗ всякого эталона: они верны на новом каталоге с первого дня и ловят +регрессии, которые иначе всплывают только на проде во время деплоя. Тест не требует БД — только чтение файлов. """ @@ -115,9 +115,9 @@ def test_migrations_are_transactional() -> None: ] 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)." + assert not broken, ( + f"Миграции без обёртки BEGIN;/COMMIT;: {broken} (.claude/rules/sql.md → Structure)." + ) def test_no_concurrent_index_in_migrations() -> None: @@ -177,6 +177,6 @@ def test_deploy_workflow_applies_auth_migrations() -> None: 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." + assert "ops/db-bootstrap/create_auth_db.sql" in workflow, ( + f"В {_DEPLOY_WORKFLOW.name} нет bootstrap-шага создания БД auth." + ) diff --git a/backend/tests/sql/test_ddu_price_indicator.py b/backend/tests/sql/test_ddu_price_indicator.py index df9bd034..9033a404 100644 --- a/backend/tests/sql/test_ddu_price_indicator.py +++ b/backend/tests/sql/test_ddu_price_indicator.py @@ -40,8 +40,7 @@ _DB_OK, _DB_ERR = _db_reachable() pytestmark = pytest.mark.skipif( not _DB_OK, reason=( - "Нет доступной postgres БД (TEST_DATABASE_URL/DATABASE_URL) — " - f"тест #99 пропущен: {_DB_ERR}" + f"Нет доступной postgres БД (TEST_DATABASE_URL/DATABASE_URL) — тест #99 пропущен: {_DB_ERR}" ), ) @@ -107,10 +106,7 @@ def conn(): def _insert_quarter(cur, q_start, bucket_area, price_m2, n) -> None: """Insert n single-flat ДДУ rows at given per-unit area + price/m².""" - rows = [ - ("002001003000", "ДДУ", 66, q_start, bucket_area, 1, price_m2) - for _ in range(n) - ] + rows = [("002001003000", "ДДУ", 66, q_start, bucket_area, 1, price_m2) for _ in range(n)] cur.executemany( "INSERT INTO rd (realestate_type_code, doc_type, region_code, " "period_start_date, area, deal_count, price_per_sqm) " @@ -136,7 +132,7 @@ def _setup(cur: psycopg.Cursor) -> None: # Bucket 4 (60-80 m²): 2025-Q3 present, 2025-Q4 SPARSE (<10 → filtered), # 2026-Q1 present. index_previous for 2026-Q1 must compare to 2025-Q3. _insert_quarter(cur, "2025-07-01", 70, 150000, 11) - _insert_quarter(cur, "2025-10-01", 70, 999999, 3) # below min_deals → dropped + _insert_quarter(cur, "2025-10-01", 70, 999999, 3) # below min_deals → dropped _insert_quarter(cur, "2026-01-01", 70, 165000, 11) # Packaged-deal trap: one row area=350 deal_count=7 → per-unit 50 m² (bucket 3), # NOT bucket 6. Price chosen mid-range so it doesn't move the median much. diff --git a/backend/tests/sql/test_velocity_alerts.py b/backend/tests/sql/test_velocity_alerts.py index 44b34bfe..7baf7631 100644 --- a/backend/tests/sql/test_velocity_alerts.py +++ b/backend/tests/sql/test_velocity_alerts.py @@ -39,8 +39,7 @@ _DB_OK, _DB_ERR = _db_reachable() pytestmark = pytest.mark.skipif( not _DB_OK, reason=( - "Нет доступной postgres БД (TEST_DATABASE_URL/DATABASE_URL) — " - f"тест #17 пропущен: {_DB_ERR}" + f"Нет доступной postgres БД (TEST_DATABASE_URL/DATABASE_URL) — тест #17 пропущен: {_DB_ERR}" ), ) @@ -107,8 +106,15 @@ def _setup(cur: psycopg.Cursor) -> None: ) snap = "2026-04-28" # stale scrape date; data months end 2025-12 (4-mo gap) months = [ - "2025-04-01", "2025-05-01", "2025-06-01", "2025-07-01", "2025-08-01", - "2025-09-01", "2025-10-01", "2025-11-01", "2025-12-01", + "2025-04-01", + "2025-05-01", + "2025-06-01", + "2025-07-01", + "2025-08-01", + "2025-09-01", + "2025-10-01", + "2025-11-01", + "2025-12-01", ] # obj 1 — sharp drop: prior ~15/mo, recent ~4/mo -> alert dropper = [16, 14, 15, 17, 13, 14, 5, 4, 3] diff --git a/backend/tests/support/__init__.py b/backend/tests/support/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/support/pg_session.py b/backend/tests/support/pg_session.py new file mode 100644 index 00000000..abd91ece --- /dev/null +++ b/backend/tests/support/pg_session.py @@ -0,0 +1,99 @@ +"""Сессия-двойник с настоящей семантикой aborted-транзакции Postgres. + +Зачем отдельный помощник, а не MagicMock: у мока нет состояния «транзакция +оборвана», поэтому второй `execute` в тесте проходит независимо от того, обёрнут +первый в SAVEPOINT или нет. Проверка на отравление сессии на таком моке зелена по +построению — она может поймать только проглоченное исключение. + +Замер 19.08.2026 (мутационный прогон по кластеру A эпика #2464): у трёх мест из +одиннадцати снятие SAVEPOINT не роняет НИ ОДНОГО теста — saturation (465 passed), +zone_regulation (195), pat_lookup (21). Код там правильный; беззащитны регрессии. + +Семантика, которую двойник воспроизводит: + * упавший `execute` переводит транзакцию в aborted; + * любой следующий `execute` в aborted-состоянии падает с тем же текстом, что + отдаёт Postgres, — до конца транзакции; + * выход из `begin_nested()` с исключением = ROLLBACK TO SAVEPOINT: снимает + aborted, внешняя транзакция остаётся рабочей. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import Any + +_ABORTED_MESSAGE = "current transaction is aborted, commands ignored until end of transaction block" + + +class AbortedTransactionError(RuntimeError): + """Аналог psycopg InFailedSqlTransaction.""" + + +class PostgresLikeSession: + """Минимальная сессия: aborted-состояние + рабочий SAVEPOINT. + + fail_on: номера вызовов `execute` (с единицы), которые должны упасть. + error: исключение, которым падать (важно — обработчики ловят конкретные типы + SQLAlchemy, а не голый Exception). + rows / row: что отдавать при успешном вызове. + """ + + def __init__( + self, + *, + fail_on: tuple[int, ...] = (1,), + error: BaseException | None = None, + rows: list[Any] | None = None, + row: Any = None, + ) -> None: + self.calls = 0 + self.aborted = False + self.savepoints_entered = 0 + self._fail_on = fail_on + self._error = error or RuntimeError("simulated DB failure") + self._rows = rows if rows is not None else [] + self._row = row + + @contextmanager + def begin_nested(self): # type: ignore[no-untyped-def] + self.savepoints_entered += 1 + try: + yield + except BaseException: + # ROLLBACK TO SAVEPOINT — внешняя транзакция снова пригодна. + self.aborted = False + raise + + def execute(self, *_args: Any, **_kwargs: Any) -> _Result: + if self.aborted: + raise AbortedTransactionError(_ABORTED_MESSAGE) + self.calls += 1 + if self.calls in self._fail_on: + self.aborted = True + raise self._error + return _Result(self._rows, self._row) + + # ── прочее, что могут дёрнуть обработчики ──────────────────────────────── + def commit(self) -> None: + if self.aborted: + raise AbortedTransactionError(_ABORTED_MESSAGE) + + def rollback(self) -> None: + self.aborted = False + + +class _Result: + def __init__(self, rows: list[Any], row: Any) -> None: + self._rows, self._row = rows, row + + def mappings(self) -> _Result: + return self + + def all(self) -> list[Any]: + return self._rows + + def first(self) -> Any: + return self._row + + def scalar(self) -> Any: + return self._row diff --git a/backend/tests/test_2464c_photos_session_release.py b/backend/tests/test_2464c_photos_session_release.py new file mode 100644 index 00000000..dae3c691 --- /dev/null +++ b/backend/tests/test_2464c_photos_session_release.py @@ -0,0 +1,168 @@ +"""#2464-C: сессия БД не должна держаться на время внешнего HTTP-фетча. + +`GET /api/v1/photos/{obj}/{file}` берёт сессию через `Depends(get_db)`, делает +SELECT — и (SQLAlchemy открывает транзакцию на первом запросе) ДЕРЖИТ соединение +пула всё время синхронного похода к ДОМ.РФ. + +Цена на проде, замер 19.08.2026 по `domrf_kn_photos`: + + всего фотографий 165 208 + закешировано локально 1 889 (1.1%) + пойдут «ленивым» путём 163 319 (98.9%) + +То есть почти каждый запрос картинки — это удержание соединения на время +внешнего фетча (`_UPSTREAM_TIMEOUT` = 8 с, connect 4 с). Пул при этом +дефолтный: `create_engine(...)` в `app/core/db.py` без `pool_size`, то есть +5 + 10 overflow = 15 соединений на весь бэкенд. Страница отчёта тянет +несколько картинок разом — пятнадцать таких запросов занимают пул целиком, и +за ними встают ВСЕ остальные ручки. + +Хуже, чем просто занятое соединение: транзакция открыта и висит +idle-in-transaction, что мешает vacuum'у. + +Тест проверяет ФАКТ отпускания соединения в момент фетча, а не наличие +`db.close()` в тексте — иначе он бы фиксировал реализацию, а не свойство. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session + + +@pytest.fixture +def _session_with_photo_row(tmp_path: Path): + """Настоящая сессия SQLAlchemy (SQLite) с одной строкой фотографии. + + Настоящая, а не MagicMock: проверяется свойство сессии (`in_transaction`), + и на моке оно было бы выдумкой. + """ + engine = create_engine(f"sqlite:///{tmp_path / 'photos.db'}", future=True) + with engine.begin() as conn: + conn.execute( + text( + "CREATE TABLE domrf_kn_photos (" + " obj_id INTEGER, obj_file_id TEXT, local_path TEXT," + " thumb_path TEXT, photo_url TEXT, photo_name TEXT, size_bytes INTEGER)" + ) + ) + conn.execute( + text( + "INSERT INTO domrf_kn_photos VALUES" + " (1, 'f1', NULL, NULL, 'https://upstream.example/p.jpg', 'p.jpg', 100)" + ) + ) + session = Session(engine, future=True) + yield session + session.close() + engine.dispose() + + +def test_connection_released_before_upstream_fetch(_session_with_photo_row, monkeypatch) -> None: + """В момент похода наружу транзакция закрыта — соединение вернулось в пул. + + На main: SELECT открыл транзакцию, она висит все 8 с фетча. + """ + from app.api.v1 import photos + + seen: dict[str, bool] = {} + + def _spy_fetch(url: str): + seen["in_transaction"] = _session_with_photo_row.in_transaction() + return None # не ходим наружу: ДОМ.РФ трогать нельзя + + monkeypatch.setattr(photos, "_fetch_upstream", _spy_fetch) + + resp = photos.get_photo(db=_session_with_photo_row, obj_id=1, file_id="f1", size="thumb") + + assert seen.get("in_transaction") is False, ( + "во время внешнего фетча сессия держит открытую транзакцию — " + "соединение пула занято, и при пуле в 15 штук страница отчёта его исчерпает" + ) + # Поведение не изменилось: картинки локально нет → редирект на upstream. + assert resp.status_code == 302 + + +def test_still_serves_cached_thumb(_session_with_photo_row, tmp_path: Path) -> None: + """Контроль: закешированная миниатюра по-прежнему отдаётся с диска. + + Зелёный с обеих сторон — доказывает, что раннее закрытие сессии не сломало + быстрый путь (98.9% запросов идут не им, но именно он — цель кеша). + """ + from app.api.v1 import photos + + thumb = tmp_path / "t.webp" + thumb.write_bytes(b"webp") + _session_with_photo_row.execute( + text("UPDATE domrf_kn_photos SET thumb_path = :t"), {"t": str(thumb)} + ) + _session_with_photo_row.commit() + + resp = photos.get_photo(db=_session_with_photo_row, obj_id=1, file_id="f1", size="thumb") + assert getattr(resp, "path", None) == str(thumb) + + +def test_missing_row_still_404(_session_with_photo_row) -> None: + """Контроль: незарегистрированная фотография по-прежнему 404, а не 500.""" + from fastapi import HTTPException + + from app.api.v1 import photos + + with pytest.raises(HTTPException) as exc: + photos.get_photo(db=_session_with_photo_row, obj_id=999, file_id="nope", size="thumb") + assert exc.value.status_code == 404 + + +def test_session_usable_after_close_for_thumb_update( + _session_with_photo_row, tmp_path: Path, monkeypatch +) -> None: + """Сессия переиспользуется ПОСЛЕ close(): UPDATE thumb_path + commit доезжают. + + Это самый рискованный участок #2928: `db.close()` стоит выше по коду, а ниже + сессия ещё раз работает с БД. Поведение штатное (`close()` возвращает + соединение в пул, следующий execute берёт новое и открывает свою транзакцию), + но на проде эта ветка сегодня не исполняется НИ РАЗУ: замер 19.08.2026 — + 0 строк `domrf_kn_photos` с непустым local_path и пустым thumb_path (все 1 889 + закешированных уже с миниатюрами). То есть первый же ленивый фетч новой + фотографии пойдёт по коду, который не проверял никто. + + Дополнительно фиксируем, что генерация миниатюры (медленная работа) идёт уже + без открытой транзакции — то же свойство, что и для внешнего фетча. + """ + from app.api.v1 import photos + + original = tmp_path / "orig.jpg" + original.write_bytes(b"jpeg") + _session_with_photo_row.execute( + text("UPDATE domrf_kn_photos SET local_path = :p"), {"p": str(original)} + ) + _session_with_photo_row.commit() + + generated = tmp_path / "orig.webp" + seen: dict[str, bool] = {} + + def _fake_make_thumbnail(src: Path): + seen["in_transaction"] = _session_with_photo_row.in_transaction() + generated.write_bytes(b"webp") + return generated + + monkeypatch.setattr(photos, "make_thumbnail", _fake_make_thumbnail) + + resp = photos.get_photo(db=_session_with_photo_row, obj_id=1, file_id="f1", size="thumb") + + assert seen.get("in_transaction") is False, ( + "миниатюра генерируется при открытой транзакции — соединение пула занято" + ) + assert getattr(resp, "path", None) == str(generated) + + # Главное: запись ПОСЛЕ close() действительно доехала до БД — читаем ОТДЕЛЬНЫМ + # соединением. Через ту же сессию проверять нельзя: она видит собственную + # незакоммиченную транзакцию, и пропажа commit() прошла бы незамеченной. + with _session_with_photo_row.get_bind().connect() as fresh: + stored = fresh.execute( + text("SELECT thumb_path FROM domrf_kn_photos WHERE obj_id = 1") + ).scalar_one() + assert stored == str(generated), "UPDATE после close() не закоммичен" diff --git a/backend/tests/test_2464c_timeout_guard_returns.py b/backend/tests/test_2464c_timeout_guard_returns.py new file mode 100644 index 00000000..6ca04c9a --- /dev/null +++ b/backend/tests/test_2464c_timeout_guard_returns.py @@ -0,0 +1,232 @@ +"""#2464 кластер C: защита таймаутом обязана ОГРАНИЧИВАТЬ время, а не только ожидание. + +`with ThreadPoolExecutor(...) as pool:` на выходе зовёт `shutdown(wait=True)` — +он ждёт, пока рабочий поток реально закончит. Поэтому `future.result(timeout=T)` +ограничивает только момент, когда мы перестаём ждать ЗНАЧЕНИЕ; сама функция всё +равно не вернётся, пока висящий вызов не отвиснет. + +То есть заявленный «таймаут N секунд» не выполняется: при зависшем tile-сервере +экспорт стоит столько, сколько стоит зависание, а не N. + +Тесты меряют ВРЕМЯ ВОЗВРАТА, а не наличие except-ветки: ветка была и раньше, +она просто ничего не ограничивала. +""" + +from __future__ import annotations + +import sys +import threading +import time +import types + +import pytest + + +@pytest.fixture +def _fake_contextily(monkeypatch): + """Подменяет contextily модулем, чей add_basemap ВИСНЕТ. + + Висим ограниченно (3 с), а не вечно: тест обязан завершаться и на сломанном + коде — иначе красный прогон превращается в зависший. + """ + released = threading.Event() + + def _hang(*_a, **_kw): + released.wait(timeout=3.0) + + fake = types.ModuleType("contextily") + fake.add_basemap = _hang + fake.providers = types.SimpleNamespace(OpenStreetMap=types.SimpleNamespace(Mapnik=object())) + monkeypatch.setitem(sys.modules, "contextily", fake) + yield released + released.set() + + +def test_basemap_returns_within_its_own_timeout(_fake_contextily) -> None: + """_add_basemap возвращается около своего таймаута, а не ждёт зависший фетч. + + На main: `with ThreadPoolExecutor` держит выход до конца _hang → ~3 с. + После правки: ~_BASEMAP_TIMEOUT_S. + """ + from app.services.exporters import report_maps + + monkey_timeout = 0.3 + orig = report_maps._BASEMAP_TIMEOUT_S + report_maps._BASEMAP_TIMEOUT_S = monkey_timeout + try: + t0 = time.monotonic() + ok = report_maps._add_basemap(ax=object()) + elapsed = time.monotonic() - t0 + finally: + report_maps._BASEMAP_TIMEOUT_S = orig + + assert ok is False, "зависший фетч не должен считаться успехом" + assert elapsed < 1.5, ( + f"вернулись за {elapsed:.2f} с при таймауте {monkey_timeout} с — " + "значит ждали зависший поток, и заявленный таймаут ничего не ограничивает" + ) + + +def test_basemap_success_path_still_works(monkeypatch) -> None: + """Контроль: рабочий тайл-сервер по-прежнему даёт True. + + Зелёный с обеих сторон правки — доказывает, что правка не превратила + успешный путь в отказ. + """ + calls: list[int] = [] + + fake = types.ModuleType("contextily") + fake.add_basemap = lambda *_a, **_kw: calls.append(1) + fake.providers = types.SimpleNamespace(OpenStreetMap=types.SimpleNamespace(Mapnik=object())) + monkeypatch.setitem(sys.modules, "contextily", fake) + + from app.services.exporters import report_maps + + assert report_maps._add_basemap(ax=object()) is True + assert calls == [1] + + +# ── queue_status: тот же дефект на UI-поллинге ─────────────────────────────── + + +def test_queue_status_returns_when_broker_hangs(monkeypatch) -> None: + """`GET /queue` возвращается по своему дедлайну даже при висящем брокере. + + Докстрока обещает «worst-case latency ≈ 600 ms even if no worker is + reachable» — ради этого inspect и уносили в поток. Но `with + ThreadPoolExecutor(...)` на выходе ждал зависший вызов, и обещание не + выполнялось: ручка, которую фронт опрашивает по таймеру, висела столько, + сколько висел брокер. + """ + import threading + import time + import types + from unittest.mock import MagicMock + + from app.api.v1 import admin_scrape + + released = threading.Event() + + def _hang(*_a, **_kw): + released.wait(timeout=3.0) + return None + + fake_inspect = types.SimpleNamespace(reserved=_hang, ping=_hang) + fake_control = types.SimpleNamespace(inspect=lambda **_kw: fake_inspect) + + # connection_or_acquire — контекст-менеджер; отдаём канал, чей llen мгновенен. + class _Chan: + client = types.SimpleNamespace(llen=lambda _q: 0) + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + class _Conn: + def channel(self): + return _Chan() + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + fake_celery = types.SimpleNamespace( + control=fake_control, + connection_or_acquire=lambda: _Conn(), + ) + fake_mod = types.ModuleType("app.workers.celery_app") + fake_mod.celery_app = fake_celery + monkeypatch.setitem(sys.modules, "app.workers.celery_app", fake_mod) + + db = MagicMock() + db.execute.return_value.mappings.return_value.all.return_value = [] + + try: + t0 = time.monotonic() + admin_scrape.queue_status(db=db) + elapsed = time.monotonic() - t0 + finally: + released.set() + + assert elapsed < 2.0, ( + f"ручка вернулась за {elapsed:.2f} с при обещанных ~0.6 с — " + "значит выход из блока ждал зависший inspect, и обещание докстроки ложно" + ) + + +def test_queue_status_returns_when_the_queue_probe_itself_hangs(monkeypatch) -> None: + """Виснет НЕ inspect, а сама проба глубины очереди. + + Тест выше подменяет `llen` мгновенным, поэтому этот случай не покрывал. + А именно он ломал обещание докстроки на последнем шаге: `inspect` был + ограничен дедлайном 0.8 с, а `connection_or_acquire()` + `llen` шли синхронно + и без таймаута вовсе. По висящему сокету (не «connection refused», а + чёрная дыра) ручка не возвращалась никогда. + + Как и выше, висим ограниченно (3 с): тест обязан завершаться и на сломанном + коде, иначе красный прогон превращается в зависший. + """ + import threading + import time + import types + from unittest.mock import MagicMock + + from app.api.v1 import admin_scrape + + released = threading.Event() + + # inspect отвечает мгновенно — весь бюджет остаётся пробе очереди. + fake_inspect = types.SimpleNamespace(reserved=lambda: None, ping=lambda: {}) + fake_control = types.SimpleNamespace(inspect=lambda **_kw: fake_inspect) + + def _hanging_llen(_queue: str) -> int: + released.wait(timeout=3.0) + return 0 + + class _Chan: + client = types.SimpleNamespace(llen=_hanging_llen) + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + class _Conn: + def channel(self): + return _Chan() + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + fake_celery = types.SimpleNamespace( + control=fake_control, + connection_or_acquire=lambda: _Conn(), + ) + fake_mod = types.ModuleType("app.workers.celery_app") + fake_mod.celery_app = fake_celery + monkeypatch.setitem(sys.modules, "app.workers.celery_app", fake_mod) + + db = MagicMock() + db.execute.return_value.mappings.return_value.all.return_value = [] + + try: + t0 = time.monotonic() + out = admin_scrape.queue_status(db=db) + elapsed = time.monotonic() - t0 + finally: + released.set() + + assert elapsed < 2.0, ( + f"ручка вернулась за {elapsed:.2f} с при обещанных ~0.8 с — " + "проба глубины очереди по-прежнему идёт без таймаута" + ) + # Деградация честная: не смогли измерить — отдаём None, а не выдуманный ноль. + assert out["queue_depth"] is None diff --git a/backend/tests/test_2864_ruff_version_alignment.py b/backend/tests/test_2864_ruff_version_alignment.py new file mode 100644 index 00000000..fb463f97 --- /dev/null +++ b/backend/tests/test_2864_ruff_version_alignment.py @@ -0,0 +1,71 @@ +"""pre-commit и uv.lock держат ОДНУ версию ruff (#2864). + +Хук `ruff-pre-commit` пинился на v0.7.4, а CI и `uv run ruff format` брали ruff из +lock-файлов (0.15.12 в backend/, 0.15.20 в tradein-mvp/). Старый и новый ruff +по-разному форматируют длинные `assert ..., "сообщение"` — хук на каждом коммите +переписывал нетронутые строки, следующий `uv run ruff format` возвращал как было. +`ruff format --check` намеренно не в CI-гейте, поэтому дрейф нигде не краснел. + +Гейт читает три источника истины и требует равенства. Красный на origin/main по +значению: rev v0.7.4 против 0.15.12 и 0.15.20. Бампить втроём: rev в +.pre-commit-config.yaml + `ruff==X` в обоих pyproject.toml + `uv lock`. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +КОРЕНЬ = Path(__file__).resolve().parents[2] + +_REV = re.compile( + r"repo:\s*https://github\.com/astral-sh/ruff-pre-commit\s*\n\s*rev:\s*v?([\w.]+)", +) +_LOCK_RUFF = re.compile(r'^name = "ruff"\nversion = "([\w.]+)"', re.MULTILINE) +_PYPROJECT_RUFF = re.compile(r'"ruff([<>=!~]+[^"]*)"') + + +def _precommit_rev() -> str: + текст = (КОРЕНЬ / ".pre-commit-config.yaml").read_text(encoding="utf-8") + m = _REV.search(текст) + assert m, "в .pre-commit-config.yaml нет блока ruff-pre-commit с rev" + return m.group(1) + + +def _lock_version(lock: str) -> str: + m = _LOCK_RUFF.search((КОРЕНЬ / lock).read_text(encoding="utf-8")) + assert m, f"в {lock} нет пакета ruff" + return m.group(1) + + +def test_precommit_rev_matches_both_locks() -> None: + """Головной: хук и оба lock'а — одна версия ruff.""" + rev = _precommit_rev() + locks = { + "backend/uv.lock": _lock_version("backend/uv.lock"), + "tradein-mvp/uv.lock": _lock_version("tradein-mvp/uv.lock"), + } + расхождения = {k: v for k, v in locks.items() if v != rev} + assert not расхождения, ( + f"pre-commit ruff rev=v{rev}, а в lock'ах {расхождения} — хук и " + "`uv run ruff format` будут форматировать по-разному (#2864)" + ) + + +def test_pyproject_pins_exact_ruff() -> None: + """Контроль от повторного дрейфа: `ruff==X` в обоих pyproject, X = rev. + + С `>=` любой `uv lock --upgrade` молча уводит lock от хука — ровно так + и разъехались 0.7.4 / 0.15.12 / 0.15.20. + """ + rev = _precommit_rev() + for f in ("backend/pyproject.toml", "tradein-mvp/backend/pyproject.toml"): + m = _PYPROJECT_RUFF.search((КОРЕНЬ / f).read_text(encoding="utf-8")) + assert m, f"в {f} нет зависимости ruff" + assert m.group(1) == f"=={rev}", f"{f}: ruff{m.group(1)}, ожидался ruff=={rev}" + + +def test_gate_reads_real_values() -> None: + """Контроль самого гейта: парсеры возвращают версии, а не падают на пустом.""" + assert re.fullmatch(r"\d+\.\d+\.\d+", _precommit_rev()) + assert re.fullmatch(r"\d+\.\d+\.\d+", _lock_version("backend/uv.lock")) diff --git a/backend/tests/test_2867_avg_area_nullable.py b/backend/tests/test_2867_avg_area_nullable.py new file mode 100644 index 00000000..2f95a392 --- /dev/null +++ b/backend/tests/test_2867_avg_area_nullable.py @@ -0,0 +1,130 @@ +"""avg_area_m2 в топ-планировках — NULL вместо 0 м², когда сделок за окно нет (#2867). + +`_INLINE_VELOCITY_SQL` считал среднюю площадь как +`COALESCE(SUM(area_weighted_sum) / NULLIF(SUM(deals_window), 0), 0)` — пустое окно +продаж давало делитель NULL и COALESCE подставлял 0: «средняя площадь проданной +квартиры — 0 м²» вместо «сделок не было, средней нет». Замер прода 13.08: 635 пустых +пар (проект × комнатность) из 2083, у 80 проектов пусты все комнатности. + +Контракт исправлен насквозь: схема `float | None`, SQL без COALESCE (как у соседнего +avg_price_per_m2_rub, #2464-B), сборка рядов не round'ит None, взвешенная площадь +микса делится на сделки рядов С площадью, PDF печатает «—», фронт/типы — `number | null`. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import inspect +import re + + +def test_velocity_sql_does_not_coalesce_area_to_zero() -> None: + """Головной (SQL): в deals_per_bucket нет COALESCE(... area ..., 0). + + На origin/main он есть — с комментарием «остаётся намеренно… отдельным заходом: #2867». + """ + from app.services.site_finder import best_layouts as m + + sql = str(m._INLINE_VELOCITY_SQL) + blk = sql[sql.index("deals_per_bucket AS (") : sql.index("AS avg_area_m2")] + # Только код: SQL-комментарии (--) вырезаем — иначе гейт спорит с собственным + # объяснением правки, где слово COALESCE упомянуто (наступал на это трижды). + code = "\n".join(line for line in blk.splitlines() if not line.strip().startswith("--")) + assert "COALESCE(" not in code, ( + "средняя площадь по-прежнему COALESCE'ится в 0 при пустом окне:\n" + code[-300:] + ) + + +def test_schema_declares_avg_area_nullable() -> None: + """Контракт API: TopLayoutRow.avg_area_m2 допускает None.""" + from app.schemas.parcel import TopLayoutRow + + ann = TopLayoutRow.model_fields["avg_area_m2"].annotation + assert ann is not float, f"avg_area_m2 объявлен как {ann!r} — None ронял бы контракт" + TopLayoutRow.model_validate( + { + "room_bucket": "1", + "area_bin": "25-40", + "project_name": "x", + "signature": "s", + "competitor_obj_ids": [], + "competitor_count": 0, + "total_sold_in_window": 0, + "velocity_per_month": 0.0, + "avg_price_per_m2_rub": None, + "avg_area_m2": None, + "supply_units_in_radius": 0, + "sold_pct_of_supply": None, + "is_oversold": False, + "rank": 1, + } + ) + + +def test_row_assembly_keeps_none_not_zero() -> None: + """Сборка рядов: NULL из БД остаётся None, а не превращается в 0.0 / round(None). + + На origin/main — `else 0.0`: пустое окно отдаёт 0 м². Проверяем по исходнику + двух мест сборки, потому что собрать полный ответ без БД нельзя. + """ + from app.services.site_finder import best_layouts as m + + src = inspect.getsource(m) + assert 'if r["avg_area_m2"] is not None else None' in src, "в сборке ряда None → 0.0" + assert 'round(row["avg_area_m2"], 1) if row["avg_area_m2"] is not None else None' in src, ( + "round(row['avg_area_m2']) без проверки на None" + ) + + +def test_mix_weighted_area_excludes_rows_without_area() -> None: + """Контроль смысла: взвешенная площадь микса делится на сделки рядов С площадью. + + Иначе ряд без площади (None) считался бы сделками «с площадью 0» и занижал среднее. + То же правило, что у цены в соседних строках. + """ + from app.services.site_finder import best_layouts as m + + src = inspect.getsource(m) + assert "rb_area_total_deals" in src, "нет отдельного знаменателя для площади" + assert re.search(r"rb_area_weighted\[rb\]\s*/\s*rb_area_total_deals\[rb\]", src), ( + "площадь по-прежнему делится на все сделки (rb_deals), а не на ряды с площадью" + ) + + +def test_pdf_renders_dash_for_missing_area() -> None: + """PDF: строка с avg_area_m2=None печатает «—», а не падает на :.1f. + + На origin/main `f"{r.avg_area_m2:.1f}"` на None бросает TypeError — это отсутствие + возможности, а не неверное значение; поэтому рядом контроль: число печатается как + раньше с одним знаком. + """ + from app.schemas.parcel import TopLayoutRow + from app.services.exporters import layout_tz_pdf as pdf + + def row(area): + return TopLayoutRow( + room_bucket="2", + area_bin="40-60", + project_name="ЖК", + signature="sig", + competitor_obj_ids=[1], + competitor_count=1, + total_sold_in_window=3, + velocity_per_month=1.5, + avg_price_per_m2_rub=150000.0, + avg_area_m2=area, + supply_units_in_radius=10, + sold_pct_of_supply=30.0, + is_oversold=False, + rank=1, + ) + + assert row(None).avg_area_m2 is None and row(38.5).avg_area_m2 == 38.5 + src = inspect.getsource(pdf) + assert "_area_cell" in src, "нет ячейки площади с обработкой None" + # прямая проверка самой ячейки через исходник функции — она вложенная в рендер + assert 'return "—"' in src + assert "{val:.1f}" in src, "число перестало печататься с одним знаком" diff --git a/backend/tests/test_2950_latest_image_revision_gate.py b/backend/tests/test_2950_latest_image_revision_gate.py new file mode 100644 index 00000000..ad75bc28 --- /dev/null +++ b/backend/tests/test_2950_latest_image_revision_gate.py @@ -0,0 +1,145 @@ +"""Гард свежести :latest перед деплоем (#2950) — коды выхода по значению. + +Сценарий, ради которого гард существует: Forgejo отменил ещё не стартовавший deploy +run'а A, run B (билды пропущены) катит :latest, а :latest несёт ревизию СТАРШЕ +последнего коммита по компоненту — билд A не доехал. Гард обязан ждать и, не +дождавшись, падать; а при ревизии не старше последнего коммита — пропускать. + +`docker` подменяется шимом на PATH: он печатает конфиг образа с меткой ревизии из +переменной окружения FAKE_REV (пусто — метки нет; FAKE_DOCKER_FAIL=1 — registry не +ответил). Временный git-репозиторий даёт настоящие коммиты для `git log`/merge-base. +""" + +from __future__ import annotations + +import os +import stat +import subprocess +from pathlib import Path + +import pytest + +КОРЕНЬ = Path(__file__).resolve().parents[2] +СКРИПТ = КОРЕНЬ / "scripts" / "check-latest-image-revision.sh" + +_SHIM = """#!/usr/bin/env bash +# подменный docker: только `buildx imagetools inspect --format ...` +if [ "${FAKE_DOCKER_FAIL:-}" = "1" ]; then echo "unauthorized" >&2; exit 1; fi +FMT='{"linux/amd64": {"config": {"Labels": {"org.opencontainers.image.revision": "%s"}}}}' +if [ -n "${FAKE_REV:-}" ]; then + printf "$FMT" "$FAKE_REV" +else + printf '{"linux/amd64": {"config": {"Labels": {}}}}' +fi +""" + + +def _git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + env={ + **os.environ, + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@t", + }, + ).stdout.strip() + + +def _commit(repo: Path, rel: str, msg: str) -> str: + p = repo / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(msg, encoding="utf-8") + _git(repo, "add", rel) + _git(repo, "commit", "-q", "-m", msg) + return _git(repo, "rev-parse", "HEAD") + + +@pytest.fixture() +def repo(tmp_path: Path) -> dict[str, object]: + r = tmp_path / "repo" + r.mkdir() + _git(r, "init", "-q") + c1 = _commit(r, "backend/a.py", "c1 backend") + c2 = _commit(r, "frontend/b.ts", "c2 frontend") + c3 = _commit(r, "backend/c.py", "c3 backend") + c4 = _commit(r, "frontend/d.ts", "c4 frontend") + shim_dir = tmp_path / "bin" + shim_dir.mkdir() + shim = shim_dir / "docker" + shim.write_text(_SHIM, encoding="utf-8") + shim.chmod(shim.stat().st_mode | stat.S_IEXEC) + return {"path": r, "c1": c1, "c2": c2, "c3": c3, "c4": c4, "bin": shim_dir} + + +def _run( + repo: dict[str, object], rev: str | None, *paths: str, fail: bool = False, timeout: str = "1" +) -> subprocess.CompletedProcess[str]: + env = { + **os.environ, + "PATH": f"{repo['bin']}{os.pathsep}{os.environ['PATH']}", + "CHECK_LATEST_SLEEP_S": "0.2", + "FAKE_REV": rev or "", + "FAKE_DOCKER_FAIL": "1" if fail else "", + } + return subprocess.run( + [str(СКРИПТ), "ghcr.io/x/img", timeout, "--", *paths], + cwd=repo["path"], + env=env, + capture_output=True, + text=True, + ) + + +def test_fresh_latest_passes(repo: dict[str, object]) -> None: + """:latest собран из последнего коммита по backend (c3) → 0.""" + r = _run(repo, str(repo["c3"]), "backend") + assert r.returncode == 0, r.stdout + r.stderr + assert "содержит последний коммит" in r.stdout + + +def test_newer_revision_passes(repo: dict[str, object]) -> None: + """Ревизия новее последнего коммита по компоненту (dispatch собрал голову c4) → 0.""" + r = _run(repo, str(repo["c4"]), "backend") + assert r.returncode == 0, r.stdout + r.stderr + + +def test_stale_latest_fails_after_timeout(repo: dict[str, object]) -> None: + """Головной: :latest из c1, а по backend последний — c3 → ждёт, затем 1 с ::error.""" + # timeout=3: дедлайн считается целыми секундами (`date +%s`), и при 1 с первый + # опрос может прийти уже за дедлайном — гард честно падает сразу, а тест ждёт + # строки ожидания. 3 с при паузе 0.2 с дают её гарантированно. + r = _run(repo, str(repo["c1"]), "backend", timeout="3") + assert r.returncode == 1, r.stdout + r.stderr + assert "::error::" in r.stderr and "отстаёт" in r.stderr + assert "жду билд предшественника" in r.stdout, "гард обязан ЖДАТЬ, а не падать сразу" + + +def test_revision_from_other_component_does_not_cover(repo: dict[str, object]) -> None: + """Контроль путей: для frontend последний — c4; :latest из c3 (до c4) → 1.""" + r = _run(repo, str(repo["c3"]), "frontend") + assert r.returncode == 1, r.stdout + r.stderr + + +def test_missing_label_fails(repo: dict[str, object]) -> None: + """Без метки ревизии нельзя утверждать свежесть → 1, а не молчаливый пропуск.""" + r = _run(repo, None, "backend") + assert r.returncode == 1, r.stdout + r.stderr + assert "метки нет" in r.stderr + + +def test_registry_error_fails(repo: dict[str, object]) -> None: + """Registry не отвечает → 1 (не «проверить нельзя — значит можно»).""" + r = _run(repo, str(repo["c3"]), "backend", fail=True) + assert r.returncode == 1, r.stdout + r.stderr + + +def test_usage_errors_are_distinct(repo: dict[str, object]) -> None: + """Ошибка вызова — код 2, чтобы её не приняли за «отстаёт».""" + r = subprocess.run([str(СКРИПТ), "img"], cwd=repo["path"], capture_output=True, text=True) + assert r.returncode == 2 diff --git a/backend/tests/test_audit_middleware.py b/backend/tests/test_audit_middleware.py index c02e684f..e046d1a0 100644 --- a/backend/tests/test_audit_middleware.py +++ b/backend/tests/test_audit_middleware.py @@ -87,9 +87,10 @@ def test_classify_path_forecast() -> None: def test_classify_path_forecast_export_not_confused_with_forecast() -> None: """forecast/export должен дать action='export', НЕ 'forecast'.""" - assert audit_mod.classify_path( - "/api/v1/parcels/66:41:0204016:10/forecast/export" - ) == ("export", "66:41:0204016:10") + assert audit_mod.classify_path("/api/v1/parcels/66:41:0204016:10/forecast/export") == ( + "export", + "66:41:0204016:10", + ) def test_classify_path_unmatched_returns_none() -> None: @@ -134,9 +135,10 @@ def test_classify_path_insight_nested_path_not_matched() -> None: def test_classify_path_parcels_method_ignored() -> None: """Parcels-паттерны не зависят от method — analyze матчится при любом методе.""" - assert audit_mod.classify_path( - "/api/v1/parcels/66:41:0204016:10/analyze", "GET" - ) == ("analyze", "66:41:0204016:10") + assert audit_mod.classify_path("/api/v1/parcels/66:41:0204016:10/analyze", "GET") == ( + "analyze", + "66:41:0204016:10", + ) # --------------------------------------------------------------------------- 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_gas_grs_loader.py b/backend/tests/test_gas_grs_loader.py index 54687e72..4725dc3a 100644 --- a/backend/tests/test_gas_grs_loader.py +++ b/backend/tests/test_gas_grs_loader.py @@ -167,8 +167,7 @@ def test_parse_no_table_returns_empty() -> None: def test_parse_no_header_row_returns_empty() -> None: """Нет строки-заголовка (маркеры «наименование»+«проектн») → пусто (defensive).""" html = ( - "" - "
Колонка АКолонка Б
12
" + "
Колонка АКолонка Б
12
" ) assert gg.parse_grs_table(html) == [] diff --git a/backend/tests/test_gate_verdict.py b/backend/tests/test_gate_verdict.py index bc5bd67c..f0dfed29 100644 --- a/backend/tests/test_gate_verdict.py +++ b/backend/tests/test_gate_verdict.py @@ -129,9 +129,7 @@ def test_residential_main_vri_zh5_with_mkd_true(): def test_residential_main_vri_overrides_subcategory(): """main_vri ИЖС перебивает даже raw_props subcategory=2 (authoritative wins).""" - assert ( - is_residential_zone("Ж-2", None, {"subcategory": 2}, main_vri=_VRI_IZHS_ONLY) is False - ) + assert is_residential_zone("Ж-2", None, {"subcategory": 2}, main_vri=_VRI_IZHS_ONLY) is False def test_residential_empty_main_vri_falls_back_to_regex(): 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_metrics.py b/backend/tests/test_metrics.py new file mode 100644 index 00000000..8454ba48 --- /dev/null +++ b/backend/tests/test_metrics.py @@ -0,0 +1,116 @@ +"""Слой метрик: метка маршрута не должна взрывать кардинальность (#3078). + +Проверяется не «эндпоинт отвечает 200», а ровно то, чем метрики убивают сами +себя. У Prometheus временной ряд стоит памяти постоянно, а не в момент запроса, +поэтому кадастровый номер, попавший в метку, кладёт приёмник за сутки. Отказ +при этом отложенный и не выглядит как ошибка кода — обычный тест на статус его +не увидит никогда. + +Маршруты-пробы названы уникально (`__metrics_probe__`): реестр +`prometheus_client` глобален на процесс, и совпади имя с боевым, тесты начали бы +влиять друг на друга через общий счётчик. По той же причине сравниваются +приращения, а не абсолютные значения. +""" + +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from prometheus_client import REGISTRY + +from app.observability import metrics as m + +_ROUTE = "/__metrics_probe__/{item_id}" +_BOOM = "/__metrics_probe_boom__" + + +@pytest.fixture +def client() -> TestClient: + app = FastAPI() + app.include_router(m.router) + + @app.get(_ROUTE) + def probe(item_id: str) -> dict[str, str]: + return {"item": item_id} + + @app.get(_BOOM) + def boom() -> dict[str, str]: + raise RuntimeError("нарочно — проверяем, что упавший запрос посчитан") + + # Последним, как в app/main.py: add_middleware вставляет в начало списка, + # значит зарегистрированный последним оказывается самым внешним. + app.add_middleware(m.MetricsMiddleware) + return TestClient(app, raise_server_exceptions=False) + + +def _count(route: str, status: str, method: str = "GET") -> float: + value = REGISTRY.get_sample_value( + "http_requests_total", {"method": method, "route": route, "status": status} + ) + return value or 0.0 + + +def test_route_label_is_template_not_path(client: TestClient) -> None: + """Три разных идентификатора дают ОДИН ряд, а не три.""" + before = _count(_ROUTE, "200") + + for item in ("66:41:0301001:1", "66:41:0301001:2", "66:41:0301001:3"): + assert client.get(f"/__metrics_probe__/{item}").status_code == 200 + + assert _count(_ROUTE, "200") - before == 3.0 + + body = client.get("/metrics").text + for item in ("0301001:1", "0301001:2", "0301001:3"): + assert item not in body, f"идентификатор утёк в метку: {item}" + + +def test_unmatched_paths_collapse_into_one_series(client: TestClient) -> None: + """Сканер, перебирающий адреса, не должен плодить ряды.""" + before = _count(m.UNMATCHED, "404") + + assert client.get("/wp-admin/setup-config.php").status_code == 404 + assert client.get("/.env").status_code == 404 + assert client.get("/явно-нет-такого-пути").status_code == 404 + + assert _count(m.UNMATCHED, "404") - before == 3.0 + assert "wp-admin" not in client.get("/metrics").text + + +def test_exception_is_counted_as_500(client: TestClient) -> None: + """Исключение проходит сквозь слой наружу — без finally запрос бы потерялся.""" + before = _count(_BOOM, "500") + assert client.get(_BOOM).status_code == 500 + assert _count(_BOOM, "500") - before == 1.0 + + +def test_in_progress_returns_to_baseline(client: TestClient) -> None: + """inc/dec сходятся, в том числе на упавшем запросе. + + Значение 1 — это сам скрейп, который в момент выгрузки ещё в обработке. + Разъехавшийся счётчик выглядел бы как вечно растущая линия «запросов в + работе» при простаивающем сервисе. + """ + client.get("/__metrics_probe__/x") + client.get(_BOOM) + body = client.get("/metrics").text + assert "http_requests_in_progress 1.0" in body + + +def test_exposition_carries_histogram_and_build_info(client: TestClient) -> None: + client.get("/__metrics_probe__/x") + body = client.get("/metrics").text + assert "http_request_duration_seconds_bucket{" in body + assert "http_request_duration_seconds_count{" in body + assert 'app_build_info{app="sitefinder"' in body + + +def test_metrics_path_is_public_for_the_in_network_agent() -> None: + """Без этой записи скрейп получал бы 401 и метрик не было бы вовсе. + + Наружу путь при этом не открыт: `caddy/sites/apps.caddy` отдаёт бэкенду + только `/health` и `/api/*`, а на `/metrics` там стоит явный `respond 404`. + """ + from app.main import _PUBLIC_PATHS + + assert "/metrics" in _PUBLIC_PATHS diff --git a/backend/tests/test_ors_client.py b/backend/tests/test_ors_client.py index 5f7f0a07..dc1a7d8a 100644 --- a/backend/tests/test_ors_client.py +++ b/backend/tests/test_ors_client.py @@ -121,3 +121,32 @@ def test_matrix_bad_response_raises(with_key, monkeypatch): _install_transport(monkeypatch, handler) with pytest.raises(ors_client.OrsUnavailableError): ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81)]) + + +def test_non_numeric_duration_degrades_instead_of_500(with_key, monkeypatch): + """#2464: нечисловая длительность → OrsUnavailableError, а не ValueError. + + Довод уже сформулирован в самом файле — у проверки длины ниже написано, что + ValueError «не OrsUnavailableError → 500». К самой конверсии принцип применён + не был: вызывающий (poi_score.py:348) ловит только доменную ошибку. + """ + + def handler(_request): + return httpx.Response(200, json={"durations": [[0.0, "не число"]]}) + + _install_transport(monkeypatch, handler) + + with pytest.raises(ors_client.OrsUnavailableError): + ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) + + +def test_list_instead_of_duration_also_degrades(with_key, monkeypatch): + """TypeError тоже обязан стать доменной ошибкой, не только ValueError.""" + + def handler(_request): + return httpx.Response(200, json={"durations": [[0.0, [300]]]}) + + _install_transport(monkeypatch, handler) + + with pytest.raises(ors_client.OrsUnavailableError): + ors_client.matrix_durations_min(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) diff --git a/backend/tests/test_osrm_client_local.py b/backend/tests/test_osrm_client_local.py index fd53dab8..b67fc990 100644 --- a/backend/tests/test_osrm_client_local.py +++ b/backend/tests/test_osrm_client_local.py @@ -41,9 +41,7 @@ def test_builds_correct_url_and_parses_meters(monkeypatch): return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, 1234.5, 6789.0]]}) _install_transport(monkeypatch, handler) - out = osrm.get_road_distances_m( - 60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)] - ) + out = osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) # Дорожные расстояния (метры), self-index сброшен. assert out == [1234.5, 6789.0] @@ -164,3 +162,48 @@ def test_length_mismatch_raises_unavailable(monkeypatch): _install_transport(monkeypatch, handler) with pytest.raises(osrm.OsrmLocalUnavailableError, match="!= destinations"): osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) + + +def test_non_numeric_distance_degrades_instead_of_500(monkeypatch): + """#2464: нечисловое значение в distances → доменная ошибка, а не ValueError. + + Весь файл переводит любую кривизну ответа в OsrmLocalUnavailableError, потому что + вызывающий (parcels.py:399) ловит ТОЛЬКО её и уходит на прямолинейный fallback. + Голый `float(d)` был исключением из этого правила: ValueError/TypeError пролетели + бы мимо обработчика и дали 500 на /analyze вместо деградации. + """ + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, "не число", 10.0]]}) + + _install_transport(monkeypatch, handler) + + with pytest.raises(osrm.OsrmLocalUnavailableError): + osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) + + +def test_dict_instead_of_distance_also_degrades(monkeypatch): + """TypeError (не только ValueError) тоже обязан стать доменной ошибкой.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, {"m": 5}, 10.0]]}) + + _install_transport(monkeypatch, handler) + + with pytest.raises(osrm.OsrmLocalUnavailableError): + osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) + + +def test_null_distance_is_still_a_legitimate_none(monkeypatch): + """Контроль: null — это «маршрут не построен», а не поломка ответа. + + Зелёный с обеих сторон: правка не должна превращать законный None в ошибку. + """ + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"code": "Ok", "distances": [[0.0, None, 10.0]]}) + + _install_transport(monkeypatch, handler) + out = osrm.get_road_distances_m(60.6, 56.8, [(60.61, 56.81), (60.62, 56.82)]) + + assert out == [None, 10.0] diff --git a/backend/tests/test_poi_score.py b/backend/tests/test_poi_score.py index ae5aeae3..97cfcffb 100644 --- a/backend/tests/test_poi_score.py +++ b/backend/tests/test_poi_score.py @@ -28,9 +28,9 @@ def test_category_weight_metro(): metro_w = _category_weight("metro_stop") for cat in CATEGORY_WEIGHTS: if cat != "metro_stop" and cat != "default": - assert metro_w >= _category_weight( - cat - ), f"metro_stop weight {metro_w} должен быть >= {cat} weight {_category_weight(cat)}" + assert metro_w >= _category_weight(cat), ( + f"metro_stop weight {metro_w} должен быть >= {cat} weight {_category_weight(cat)}" + ) def test_category_weight_unknown_returns_default(): @@ -130,9 +130,9 @@ def test_metro_beats_school_at_equal_distance(): ] db = _MockDb(rows) result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605) - assert ( - result.top_poi[0].category == "metro_stop" - ), "При равном расстоянии метро (category_weight=6.0) должно быть выше школы (5.0)" + assert result.top_poi[0].category == "metro_stop", ( + "При равном расстоянии метро (category_weight=6.0) должно быть выше школы (5.0)" + ) def test_metro_first_when_close(): @@ -145,7 +145,7 @@ def test_metro_first_when_close(): result = compute_poi_weighted_top7(db, "66:41:0204016:10", 56.838, 60.605) assert result.top_poi[0].category == "metro_stop", ( "Метро (weight=6.0) в 50м должно быть впереди школы (weight=5.0) в 300м — " - f"metro_weight={(1/(50+100))*6:.5f} vs school_weight={(1/(300+100))*5:.5f}" + f"metro_weight={(1 / (50 + 100)) * 6:.5f} vs school_weight={(1 / (300 + 100)) * 5:.5f}" ) @@ -194,9 +194,9 @@ def test_score_contribution_in_range(): db = _MockDb(rows) result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605) for item in result.top_poi: - assert ( - 0.0 <= item.score_contribution <= 100.0 - ), f"{item.category} score_contribution={item.score_contribution} вне 0..100" + assert 0.0 <= item.score_contribution <= 100.0, ( + f"{item.category} score_contribution={item.score_contribution} вне 0..100" + ) def test_metro_at_zero_distance_scores_high(): @@ -205,9 +205,9 @@ def test_metro_at_zero_distance_scores_high(): rows = [_make_row("Метро у дома", "metro_stop", 0.0)] db = _MockDb(rows) result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605) - assert ( - result.poi_weighted_score >= 19.0 - ), f"Метро у дома (d=0) должно давать ≥19/100, получили {result.poi_weighted_score}" + assert result.poi_weighted_score >= 19.0, ( + f"Метро у дома (d=0) должно давать ≥19/100, получили {result.poi_weighted_score}" + ) def test_score_contribution_sum_equals_total(): diff --git a/backend/tests/test_quarter_dump_lookup.py b/backend/tests/test_quarter_dump_lookup.py index 7563d8a9..bf58373b 100644 --- a/backend/tests/test_quarter_dump_lookup.py +++ b/backend/tests/test_quarter_dump_lookup.py @@ -399,8 +399,7 @@ def _make_zouit_row( def test_cad_zouit_overlaps_includes_geom_geojson() -> None: """#255: каждый overlap содержит geom_geojson (string из ST_AsGeoJSON).""" geojson_str = ( - '{"type":"Polygon","coordinates":' - "[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}" + '{"type":"Polygon","coordinates":[[[60.6,56.8],[60.7,56.8],[60.7,56.9],[60.6,56.8]]]}' ) rows = [ _make_zouit_row( @@ -549,3 +548,35 @@ def test_early_exit_partial_counts() -> None: assert result["nspd_zoning"]["zone_code"] == "66:41-7.2" assert result["nspd_zouit_overlaps"] == [] assert result["nspd_engineering_nearby"] == [] + + +# ── #2934: признак покрытия риск-слоёв доезжает до ответа ────────────────────── +# +# Пустой `nspd_risk_zones` неоднозначен: «слой опрошен, на участке чисто» и «слой +# не дал ничего вообще» выглядят одинаково. Фронт рисовал по нему зелёную плашку +# «Риски не обнаружены», то есть утверждал результат проверки, которой не было. +# +# Замер на проде 19.08.2026: risks_count = 0 у ВСЕХ 669 дампов (для сравнения +# zouit_count > 0 у 581). Одиннадцать слоёв природного риска ни разу не вернули +# ни одного объекта. + + +def test_empty_result_reports_unknown_risk_coverage() -> None: + """Дампа нет → про риск-слои неизвестно НИЧЕГО. + + Именно None, а не 0: ноль означал бы «спросили и не нашли». + """ + result = make_empty_result() + + assert "risks_count" in result["nspd_dump"], ( + "признак покрытия не отдаётся — фронт не сможет отличить «чисто» от «не спрашивали»" + ) + assert result["nspd_dump"]["risks_count"] is None + + +def test_empty_result_stays_independent_between_calls() -> None: + """Контроль: новое поле не сломало mutation-safety (см. шапку файла).""" + a = make_empty_result() + a["nspd_dump"]["risks_count"] = 42 + + assert make_empty_result()["nspd_dump"]["risks_count"] is None diff --git a/backend/tests/test_sentry_init.py b/backend/tests/test_sentry_init.py index 19ff11d1..6abc2b9c 100644 --- a/backend/tests/test_sentry_init.py +++ b/backend/tests/test_sentry_init.py @@ -476,9 +476,9 @@ def test_local_variables_never_reach_transport(module: str) -> None: assert probe["counts"]["exception"] == 1 assert "sentry-wiring-probe boom" in payload, "событие с исключением не доехало" - assert ( - probe["markers"]["local_var"] not in payload - ), f"{module}: значение локальной переменной ушло в мониторинг" + assert probe["markers"]["local_var"] not in payload, ( + f"{module}: значение локальной переменной ушло в мониторинг" + ) @pytest.mark.parametrize("module", ["app.main", "app.workers.celery_app"]) @@ -493,7 +493,7 @@ def test_scrub_failure_does_not_spawn_second_event(module: str) -> None: """ probe = json.loads(_probe(module)) - assert ( - probe["scrub_handler_entries"] == 1 - ), "сбой скраба вернулся вторым событием: строка журнала уходит в мониторинг" + 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/workers/tasks/test_gas_outlet_sync.py b/backend/tests/workers/tasks/test_gas_outlet_sync.py index 4cd5c056..2da47ad4 100644 --- a/backend/tests/workers/tasks/test_gas_outlet_sync.py +++ b/backend/tests/workers/tasks/test_gas_outlet_sync.py @@ -27,9 +27,9 @@ def test_beat_schedule_includes_gas_outlet_sync_weekly_tuesday() -> None: with patch.object(beat_schedule, "_build_beat_schedule_from_db", return_value={}): schedule = beat_schedule.build_beat_schedule() - assert ( - "gas-outlet-points-sync-weekly" in schedule - ), f"gas-outlet-points-sync-weekly отсутствует в beat: {sorted(schedule.keys())}" + assert "gas-outlet-points-sync-weekly" in schedule, ( + f"gas-outlet-points-sync-weekly отсутствует в beat: {sorted(schedule.keys())}" + ) entry = schedule["gas-outlet-points-sync-weekly"] assert entry["task"] == "tasks.connection_capacity_sync.sync_gas_outlet_points" # crontab day_of_week = вторник (2). diff --git a/backend/tests/workers/tasks/test_okn_objects_sync.py b/backend/tests/workers/tasks/test_okn_objects_sync.py index 466ed6dd..5984ca3d 100644 --- a/backend/tests/workers/tasks/test_okn_objects_sync.py +++ b/backend/tests/workers/tasks/test_okn_objects_sync.py @@ -86,9 +86,9 @@ def test_extract_coords_inverted_would_fail_bbox() -> None: inverted_lon = feature["geometry"]["coordinates"][0] # 56.83 → не в bbox [58–63] inverted_lat = feature["geometry"]["coordinates"][1] # 60.61 → не в bbox [55–58.5] inverted_inside = (55.0 <= inverted_lat <= 58.5) and (58.0 <= inverted_lon <= 63.0) - assert ( - not inverted_inside - ), "Перепутанные координаты прошли bbox-фильтр — _extract_coords не обнаружит баг coord swap" + assert not inverted_inside, ( + "Перепутанные координаты прошли bbox-фильтр — _extract_coords не обнаружит баг coord swap" + ) # Проверяем что реальный _extract_coords возвращает КОРРЕКТНЫЕ (lon, lat) result = _extract_coords(feature) @@ -141,9 +141,9 @@ def test_address_passes_include_ekb_districts() -> None: "Верх-Исетский", "Железнодорожный", } - assert expected_districts <= set( - _ADDRESS_PASSES - ), f"Не все районы ЕКБ в _ADDRESS_PASSES: missing={expected_districts - set(_ADDRESS_PASSES)}" + assert expected_districts <= set(_ADDRESS_PASSES), ( + f"Не все районы ЕКБ в _ADDRESS_PASSES: missing={expected_districts - set(_ADDRESS_PASSES)}" + ) def test_address_passes_order_city_before_district() -> None: diff --git a/backend/tests/workers/test_2464_objective_run_not_stuck.py b/backend/tests/workers/test_2464_objective_run_not_stuck.py new file mode 100644 index 00000000..f90048a1 --- /dev/null +++ b/backend/tests/workers/test_2464_objective_run_not_stuck.py @@ -0,0 +1,136 @@ +"""Упавший прогон Объектива помечается failed, а не остаётся running навсегда (#2464). + +Обработчик в `sync_objective_group` выглядел так: + + except Exception as e: + if run_id: + try: + _finish_run(db, run_id, status="failed", ...) + except Exception: + pass # ← молча + +Если исходный сбой был DB-level (напр. INSERT в `_save_raw`), транзакция остаётся в +aborted-состоянии: `_finish_run` падает уже на своём execute, это гасится голым `pass`, +и строка прогона навсегда остаётся в `status='running'`. + +Замер прода 20.08.2026: шесть таких строк висят с 17.05 — 2274 часа, 95 суток. Уборщика +зомби для `objective_scrape_runs` нет (в отличие от cadastre). + +Проверяется на `PostgresLikeSession` — двойнике с настоящей семантикой aborted-транзакции. +На `MagicMock` тест был бы зелёным по построению: у него нет aborted-состояния, и любой +следующий execute «успешен». +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from typing import Any +from unittest.mock import patch + +import pytest + +from tests.support.pg_session import PostgresLikeSession + +_RUN_ID = 42 + + +class _Session(PostgresLikeSession): + """Двойник + close() и журнал УСПЕШНО выполненного SQL.""" + + def __init__(self, **kw: Any) -> None: + super().__init__(**kw) + self.sql: list[str] = [] + self.closed = False + + def execute(self, statement: Any = None, *a: Any, **kw: Any): # type: ignore[no-untyped-def] + res = super().execute(statement, *a, **kw) + self.sql.append(str(statement)) + return res + + def close(self) -> None: + self.closed = True + + +def _run_with_poisoned_session() -> _Session: + """Прогон, где _start_url травит сессию, а тело падает следом.""" + from app.workers.tasks import scrape_objective as mod + + db = _Session(fail_on=(1,)) + + def _fake_start(session: Any, *_a: Any, **_kw: Any) -> int: + # Первый же execute падает → транзакция aborted (как DB-сбой в _save_raw). + try: + session.execute("INSERT INTO objective_scrape_runs ...") + except RuntimeError: + pass + return _RUN_ID + + class _BoomClient: + def __init__(self, *_a: Any, **_kw: Any) -> None: + raise RuntimeError("сбой сразу после старта прогона") + + with ( + # Без ключа функция выходит на первой строке — подменяем, иначе тест + # проверял бы ранний return, а не обработчик ошибки. + patch.object(mod.settings, "objective_api_key", "test-key"), + patch.object(mod, "SessionLocal", lambda: db), + patch.object(mod, "_start_run", _fake_start), + patch.object(mod, "ObjectiveClient", _BoomClient), + pytest.raises(RuntimeError), + ): + mod.sync_objective_group(group_name="test", triggered_by="unit") + + return db + + +def _marked_failed(db: _Session) -> bool: + return any("objective_scrape_runs" in s and "status" in s for s in db.sql) + + +def test_failed_run_is_marked_even_on_poisoned_session() -> None: + """Прогон обязан получить status='failed' даже когда транзакция отравлена. + + На origin/main этого UPDATE в журнале нет: execute падает на aborted-сессии, + и голый `except Exception: pass` гасит отказ. + """ + db = _run_with_poisoned_session() + + assert _marked_failed(db), ( + "UPDATE статуса прогона не выполнился — строка осталась в status='running' " + f"навсегда. Выполненный SQL: {db.sql}" + ) + + +def test_session_is_closed_anyway() -> None: + """Контроль: сессия закрывается в finally независимо от исхода.""" + db = _run_with_poisoned_session() + assert db.closed, "сессия не закрыта — утечка соединения на аварийном пути" + + +def test_original_error_still_propagates() -> None: + """Контроль: исходная ошибка не проглатывается — она и есть причина падения. + + Ловит «починку», которая заодно погасила бы исключение: тогда Celery считал бы + прогон успешным. + """ + from app.workers.tasks import scrape_objective as mod + + db = _Session(fail_on=()) + + class _BoomClient: + def __init__(self, *_a: Any, **_kw: Any) -> None: + raise RuntimeError("характерный текст ошибки") + + with ( + patch.object(mod.settings, "objective_api_key", "test-key"), + patch.object(mod, "SessionLocal", lambda: db), + patch.object(mod, "_start_run", lambda *_a, **_kw: _RUN_ID), + patch.object(mod, "ObjectiveClient", _BoomClient), + pytest.raises(RuntimeError, match="характерный текст ошибки"), + ): + mod.sync_objective_group(group_name="test", triggered_by="unit") + + assert _marked_failed(db), "на ЗДОРОВОЙ сессии пометка тем более обязана пройти" diff --git a/backend/tests/workers/test_2464_objective_zombie_sweep.py b/backend/tests/workers/test_2464_objective_zombie_sweep.py new file mode 100644 index 00000000..1291ea35 --- /dev/null +++ b/backend/tests/workers/test_2464_objective_zombie_sweep.py @@ -0,0 +1,135 @@ +"""На worker_ready осиротевшие прогоны Объектива закрываются, а не висят вечно (#2464). + +`objective_scrape_runs` не подметалась ничем: `worker_ready` знал только про +`kn_scrape_runs` и `nspd_geo_jobs`. На проде 2026-08-20 в ней висело **6 строк +'running' с 17.05** (94 суток) при 71 'done' и **ни одном 'failed'** — след +отравления сессии, из-за которого `_finish_run(status='failed')` не мог +записаться (причина починена #2972). Причина устранена, но жёсткое убийство +воркера по-прежнему оставляет 'running' навсегда. + +Тесты смотрят на выполненный SQL — то есть на поведение функции, а не на её текст. +На origin/main запроса к `objective_scrape_runs` нет вовсе, поэтому головной тест +там красный по существу («строка не закрыта»), а не из-за отсутствующего символа. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from typing import Any +from unittest.mock import MagicMock, patch + + +class _Result: + def __init__(self, rows: list[dict]) -> None: + self._rows = rows + + def mappings(self) -> _Result: + return self + + def all(self) -> list[dict]: + return self._rows + + def scalar(self) -> Any: + return None + + def first(self) -> Any: + return None + + +class _Session: + def __init__(self) -> None: + self.sql: list[str] = [] + + def execute(self, statement: Any = None, params: Any = None, *a: Any, **kw: Any) -> _Result: + text_ = str(statement) + self.sql.append(text_) + if "objective_scrape_runs" in text_ and "RETURNING" in text_: + return _Result([{"run_id": 1}, {"run_id": 2}]) + return _Result([]) + + def commit(self) -> None: + pass + + def rollback(self) -> None: + pass + + def close(self) -> None: + pass + + +def _run() -> _Session: + from app.workers import lifecycle as mod + + db = _Session() + import app.core.db as core_db + + with ( + patch.object(core_db, "SessionLocal", lambda: db), + patch.dict( + "sys.modules", + { + "app.workers.tasks.scrape_kn": MagicMock(), + "app.workers.tasks.nspd_geo": MagicMock(_ZOMBIE_PAUSED_THRESHOLD="30 minutes"), + }, + ), + ): + mod._resume_zombie_runs() + return db + + +def _objective_update(db: _Session) -> str | None: + for sql in db.sql: + if "UPDATE objective_scrape_runs" in sql and "zombie" in sql: + return sql + return None + + +def test_orphan_objective_run_is_closed() -> None: + """Головной: worker_ready обязан закрыть 'running'-прогоны Объектива. + + На origin/main такого запроса нет вовсе — строки остаются 'running' навсегда. + """ + db = _run() + assert _objective_update(db) is not None, ( + "worker_ready не трогает objective_scrape_runs — осиротевший прогон " + f"останется 'running' навсегда; выполнено запросов: {len(db.sql)}" + ) + + +def test_only_running_rows_are_touched() -> None: + """Контроль от переусердствования: закрываем только 'running'. + + Без этого условия подметание переписало бы 'done'-прогоны и стёрло историю. + """ + sql = _objective_update(db := _run()) + assert sql is not None + assert "WHERE status = 'running'" in sql, f"нет сужения по статусу:\n{sql}" + assert "'done'" not in sql, f"запрос упоминает 'done' — риск задеть завершённые:\n{sql}" + assert db is not None + + +def test_finished_at_is_last_sign_of_life_not_now() -> None: + """Контроль честности времени: прогон, умерший 94 дня назад, не «завершён сейчас». + + NOW() здесь соврал бы и оператору в списке прогонов, и любому будущему + потребителю finished_at. + """ + sql = _objective_update(_run()) + assert sql is not None + assert "COALESCE(heartbeat_at, started_at)" in sql, ( + f"finished_at ставится не по последнему признаку жизни:\n{sql}" + ) + assert "finished_at = NOW()" not in sql, ( + f"finished_at = NOW() — время завершения соврано:\n{sql}" + ) + + +def test_kn_sweep_still_runs() -> None: + """Контроль от регресса: добавление Объектива не сломало подметание kn.""" + db = _run() + assert any("kn_scrape_runs" in s for s in db.sql), ( + f"подметание kn_scrape_runs пропало; выполнено: {db.sql}" + ) diff --git a/backend/tests/workers/test_2464_retry_config_is_real.py b/backend/tests/workers/test_2464_retry_config_is_real.py new file mode 100644 index 00000000..7c517638 --- /dev/null +++ b/backend/tests/workers/test_2464_retry_config_is_real.py @@ -0,0 +1,121 @@ +"""Таска, объявившая max_retries, обязана ретраи РЕАЛИЗОВАТЬ (#2464). + +`cbr_macro_sync` и `rosstat_macro_sync` были объявлены как `bind=True, max_retries=2`, но +`self` не использовался, `self.retry()` не вызывался и `autoretry_for` задан не был. То +есть конфигурация ретраев не имела ни малейшего эффекта: таска падала окончательно с первой +ошибки, а параметр обещал до двух повторов. Ни красного, ни ошибки — параметр просто +декорация, и отличить её от работающей настройки можно только чтением тела. + +Гейт разбирает КАЖДЫЙ декоратор `@celery_app.task(...)` через ast: если в нём есть +`max_retries`, то либо в том же декораторе должен стоять `autoretry_for`, либо в теле +функции — вызов `self.retry(...)`. + +Оговорка про силу проверки: гейт СТРУКТУРНЫЙ, он читает исходники. Поведенческим его +сделать нельзя — инертная конфигурация по определению ничего не меняет в поведении, и +поймать её можно только по несоответствию объявления и кода. +""" + +from __future__ import annotations + +import ast +import os +from pathlib import Path + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +_TASKS_DIR = Path(__file__).resolve().parents[2] / "app" / "workers" + + +def _task_decorators(tree: ast.AST): + """(имя функции, декоратор celery-таски, узел функции) для каждой таски модуля.""" + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + continue + for dec in node.decorator_list: + if not isinstance(dec, ast.Call): + continue + target = dec.func + name = getattr(target, "attr", None) or getattr(target, "id", None) + if name == "task": + yield node.name, dec, node + + +def _calls_self_retry(fn: ast.AST) -> bool: + for node in ast.walk(fn): + if isinstance(node, ast.Call): + f = node.func + if isinstance(f, ast.Attribute) and f.attr == "retry": + owner = getattr(f.value, "id", None) + if owner == "self": + return True + return False + + +def _offenders() -> list[str]: + bad: list[str] = [] + for path in sorted(_TASKS_DIR.rglob("*.py")): + tree = ast.parse(path.read_text()) + for fn_name, dec, fn_node in _task_decorators(tree): + kwargs = {kw.arg: kw.value for kw in dec.keywords if kw.arg} + if "max_retries" not in kwargs: + continue + # max_retries=0 — ЧЕСТНОЕ объявление «ретраев нет», а не пустое обещание. + # Такие места в репозитории есть и снабжены пояснением (nspd_geo: + # «resume через worker_ready, не через retry»; objective_etl: «при сбое + # лучше человек посмотрит»). Флагуем только ненулевое обещание. + v = kwargs["max_retries"] + if isinstance(v, ast.Constant) and v.value == 0: + continue + if "autoretry_for" in kwargs or _calls_self_retry(fn_node): + continue + bad.append(f"{path.relative_to(_TASKS_DIR.parent.parent)}::{fn_name}") + return bad + + +def test_declared_retries_are_actually_implemented() -> None: + """max_retries без autoretry_for и без self.retry() — обещание без исполнения.""" + bad = _offenders() + assert not bad, ( + "таски объявляют max_retries, но ретраи не реализуют — параметр не имеет эффекта:\n " + + "\n ".join(bad) + + "\nЛибо задай autoretry_for / вызови self.retry(), либо убери max_retries." + ) + + +def test_gate_sees_the_tasks_at_all() -> None: + """Контроль на сам гейт: он должен что-то находить. + + Если разбор перестанет узнавать декораторы (переименуют celery_app, сменят + обёртку), проверка выше станет тавтологически зелёной. + """ + found = 0 + for path in sorted(_TASKS_DIR.rglob("*.py")): + found += sum(1 for _ in _task_decorators(ast.parse(path.read_text()))) + assert found >= 20, f"гейт нашёл всего {found} celery-тасок — разбор сломался" + + +def test_gate_recognises_both_valid_forms() -> None: + """Контроль: обе законные формы ретраев распознаются, а не только одна. + + Иначе гейт краснел бы на исправных тасках и его бы отключили. + """ + src_autoretry = ( + "@celery_app.task(bind=True, max_retries=2, autoretry_for=(ValueError,))\n" + "def t(self):\n return 1\n" + ) + src_self_retry = ( + "@celery_app.task(bind=True, max_retries=2)\n" + "def t(self):\n raise self.retry(countdown=1)\n" + ) + src_inert = "@celery_app.task(bind=True, max_retries=2)\ndef t(self):\n return 1\n" + + def _is_ok(src: str) -> bool: + tree = ast.parse(src) + for _n, dec, fn in _task_decorators(tree): + kwargs = {kw.arg for kw in dec.keywords if kw.arg} + return "autoretry_for" in kwargs or _calls_self_retry(fn) + raise AssertionError("декоратор не распознан") + + assert _is_ok(src_autoretry), "форма autoretry_for не распознана" + assert _is_ok(src_self_retry), "форма self.retry() не распознана" + assert not _is_ok(src_inert), "инертная форма ошибочно признана исправной" diff --git a/backend/tests/workers/test_2464_zombie_any_running.py b/backend/tests/workers/test_2464_zombie_any_running.py new file mode 100644 index 00000000..b1ba844d --- /dev/null +++ b/backend/tests/workers/test_2464_zombie_any_running.py @@ -0,0 +1,157 @@ +"""На worker_ready зомби помечается ЛЮБАЯ строка в 'running' (#2464). + +Докстринг `_resume_zombie_runs` формулирует инвариант прямо: + + No time threshold: by definition, on worker_ready ANY 'running' row is a zombie + because there is no active worker. Previously we required heartbeat … stayed in + 'running' status forever and required manual cancel/resume. + +А запрос добавлял `AND objects_snapshot IS NOT NULL`. Строка без снапшота в выборку не +попадала и оставалась `'running'` НАВСЕГДА — ровно то состояние, ради устранения которого +функция и заводилась. + +Снапшот нужен, но не для пометки, а для ВОЗОБНОВЛЕНИЯ: `resume_kn_run` восстанавливает +обход «using objects_snapshot». Поэтому зомби помечаются все, а resume ставится только тем, +кого есть чем возобновить; остальные получают честную причину. + +Тесты смотрят на выполненный SQL и на поставленные resume-задачи — то есть на поведение. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from typing import Any +from unittest.mock import MagicMock, patch + + +class _Result: + def __init__(self, rows: list[dict]) -> None: + self._rows = rows + + def mappings(self) -> _Result: + return self + + def all(self) -> list[dict]: + return self._rows + + def scalar(self) -> Any: + return None + + def first(self) -> Any: + return None + + +class _Session: + """Отдаёт заданные строки на SELECT и запоминает весь выполненный SQL.""" + + def __init__(self, rows: list[dict]) -> None: + self._rows = rows + self.sql: list[tuple[str, dict]] = [] + self.commits = 0 + + def execute(self, statement: Any = None, params: Any = None, *a: Any, **kw: Any) -> _Result: + text_ = str(statement) + self.sql.append((text_, params or {})) + if "SELECT run_id" in text_ and "kn_scrape_runs" in text_: + # Двойник ОБЯЗАН соблюдать ровно тот фильтр, о котором идёт спор. + # Без этого тест краснел бы на origin/main по ложной причине: там + # строка без снапшота отсекается запросом и до пометки не доходит, + # а наивный двойник отдавал бы её всё равно — и главный тест + # («строка не помечена») проходил бы на обеих сторонах. + # Ищем именно условие в WHERE (с ведущим AND), а не подвыражение в + # SELECT: правка выносит `(objects_snapshot IS NOT NULL) AS resumable` + # в список полей, и совпадение по голой подстроке отсекало бы строки + # и у исправленной версии тоже. + if "AND objects_snapshot IS NOT NULL" in text_: + return _Result([r for r in self._rows if r["resumable"]]) + return _Result(self._rows) + return _Result([]) + + def commit(self) -> None: + self.commits += 1 + + def rollback(self) -> None: + pass + + def close(self) -> None: + pass + + +def _run(rows: list[dict]) -> tuple[_Session, list[int]]: + from app.workers import lifecycle as mod + + db = _Session(rows) + enqueued: list[int] = [] + + resume_task = MagicMock() + resume_task.apply_async.side_effect = lambda args=None, **_kw: enqueued.append(args[0]) + + # SessionLocal импортируется ВНУТРИ функции (late import), поэтому подменяем + # его в модуле-источнике, а не на lifecycle. + import app.core.db as core_db + + with ( + patch.object(core_db, "SessionLocal", lambda: db), + patch.dict( + "sys.modules", + {"app.workers.tasks.scrape_kn": MagicMock(resume_kn_run=resume_task)}, + ), + ): + mod._resume_zombie_runs() + return db, enqueued + + +def _updated_ids(db: _Session) -> set[int]: + out: set[int] = set() + for sql, params in db.sql: + if "UPDATE kn_scrape_runs" in sql and "zombie" in sql: + for key in ("ids", "orphans"): + out.update(params.get(key) or []) + return out + + +def test_running_row_without_snapshot_is_marked_zombie() -> None: + """Строка без objects_snapshot обязана быть помечена, а не остаться 'running'. + + На origin/main она вообще не попадает в выборку: фильтр отсекает её, + и никакого UPDATE по ней не выполняется. + """ + db, _ = _run([{"run_id": 7, "resumable": False}]) + + assert 7 in _updated_ids(db), ( + "прогон без снапшота не помечен zombie — останется в 'running' навсегда; " + f"выполненный SQL: {[s for s, _ in db.sql]}" + ) + + +def test_unresumable_row_gets_no_resume_task() -> None: + """Контроль: возобновлять нечем — resume не ставим. + + Ловит «починку», которая ставила бы resume всем подряд: resume_kn_run + восстанавливает обход ИЗ objects_snapshot и без него упадёт. + """ + _, enqueued = _run([{"run_id": 7, "resumable": False}]) + assert enqueued == [], f"поставлен resume для невозобновляемого прогона: {enqueued}" + + +def test_resumable_row_still_gets_resume() -> None: + """Контроль: прежнее поведение для строк со снапшотом не тронуто.""" + db, enqueued = _run([{"run_id": 3, "resumable": True}]) + assert 3 in _updated_ids(db) + assert enqueued == [3] + + +def test_mixed_batch_splits_correctly() -> None: + """Контроль: смешанная выборка — помечены все, resume только у пригодных.""" + db, enqueued = _run( + [ + {"run_id": 1, "resumable": True}, + {"run_id": 2, "resumable": False}, + {"run_id": 3, "resumable": True}, + ] + ) + assert _updated_ids(db) == {1, 2, 3}, f"помечены не все: {_updated_ids(db)}" + assert sorted(enqueued) == [1, 3], f"resume поставлен не тем: {enqueued}" diff --git a/backend/tests/workers/test_nspd_geo.py b/backend/tests/workers/test_nspd_geo.py index 8ee58bfb..77e3381a 100644 --- a/backend/tests/workers/test_nspd_geo.py +++ b/backend/tests/workers/test_nspd_geo.py @@ -569,9 +569,9 @@ def test_soft_time_limit_exceeded_flushes_heartbeat(monkeypatch: Any) -> None: for sql, params in captured if "heartbeat_at = NOW()" in sql and "targets_done" in sql ] - assert ( - heartbeat_updates - ), "SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise" + assert heartbeat_updates, ( + "SoftTimeLimitExceeded handler должен flush'нуть heartbeat с counters перед raise" + ) def test_soft_time_limit_exceeded_does_not_overwrite_paused_with_failed( diff --git a/backend/tests/workers/test_scrape_freshness_check.py b/backend/tests/workers/test_scrape_freshness_check.py index 4e05af2e..b014daf9 100644 --- a/backend/tests/workers/test_scrape_freshness_check.py +++ b/backend/tests/workers/test_scrape_freshness_check.py @@ -24,8 +24,11 @@ def _source( def _payload(sources: list[dict[str, Any]], overall: str) -> dict[str, Any]: - return {"generated_at": "2026-06-25T09:00:00+00:00", "overall_status": overall, - "sources": sources} + return { + "generated_at": "2026-06-25T09:00:00+00:00", + "overall_status": overall, + "sources": sources, + } def _run_with(payload_or_exc: Any) -> tuple[dict[str, Any], MagicMock]: diff --git a/backend/uv.lock b/backend/uv.lock index 1b93d369..0e4a8791 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -799,6 +799,7 @@ dependencies = [ { name = "pdfplumber" }, { name = "pillow" }, { name = "playwright" }, + { name = "prometheus-client" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -850,6 +851,7 @@ requires-dist = [ { name = "pdfplumber", specifier = ">=0.10.0" }, { name = "pillow", specifier = ">=10.4.0" }, { name = "playwright", specifier = ">=1.45.0" }, + { name = "prometheus-client", specifier = ">=0.21.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, { name = "pydantic", specifier = ">=2.7.0" }, { name = "pydantic-settings", specifier = ">=2.3.0" }, @@ -877,7 +879,7 @@ dev = [ { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, - { name = "ruff", specifier = ">=0.5.0" }, + { name = "ruff", specifier = "==0.15.20" }, { name = "types-redis", specifier = ">=4.6.0" }, ] @@ -1891,6 +1893,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -2515,27 +2526,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" +version = "0.15.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] diff --git a/caddy/local/.gitignore b/caddy/local/.gitignore new file mode 100644 index 00000000..f4e427bf --- /dev/null +++ b/caddy/local/.gitignore @@ -0,0 +1,4 @@ +# Локальные site-блоки Caddy с секретами (см. import в конце Caddyfile). +# Кладутся руками на VPS и намеренно не версионируются — в git только каталог. +* +!.gitignore diff --git a/caddy/metrics-ingest.caddy.snippet b/caddy/metrics-ingest.caddy.snippet new file mode 100644 index 00000000..b99d098f --- /dev/null +++ b/caddy/metrics-ingest.caddy.snippet @@ -0,0 +1,15 @@ +# Приём метрик и логов от агентов — машинный контур metrics.gendsgn.ru/ingest/*. +# +# ОТДЕЛЬНАЯ УЧЁТКА, А НЕ ТА ЖЕ, ЧТО У ВИТРИНЫ. Пароль приёмника лежит в открытом +# виде в окружении продуктового хоста (агенту нужно им авторизоваться), то есть +# компрометация Poincare раскрывает его автоматически. Если бы это была учётка +# витрины, вместе с ней утёк бы и доступ к самим дашбордам и к Prometheus, где +# видна вся картина инфраструктуры. Разделение ограничивает ущерб записью. +# +# Пароль — METRICS_INGEST_PASSWORD, задан на ОБОИХ хостах (на Beget как источник +# истины, на Poincare для агента). Сюда попадает только bcrypt-хеш. +# Сгенерирован scripts/setup-metrics-secrets.sh 2026-08-26. + +basic_auth bcrypt "GenDesign Metrics Ingest" { + alloy JDJhJDE0JGMvTmxIenZUbW00cEtJVm01OGl0dmVRL0VSNk1mNjIwbDFvQWl1VTRwaEVaa1FHWXFUdEVl # агент Alloy, пароль в METRICS_INGEST_PASSWORD +} diff --git a/caddy/metrics-ui.caddy.snippet b/caddy/metrics-ui.caddy.snippet new file mode 100644 index 00000000..7652bd8c --- /dev/null +++ b/caddy/metrics-ui.caddy.snippet @@ -0,0 +1,19 @@ +# Витрина мониторинга — внешний контур доступа к metrics.gendsgn.ru. +# +# Пароль живёт в окружении хоста как METRICS_UI_PASSWORD (backend/.env.runtime), +# сюда попадает только bcrypt-хеш — его публикация ничего не раскрывает. +# Сгенерирован ops/../scripts/setup-metrics-secrets.sh 2026-08-26. +# +# ЭТО ВТОРОЙ СЛОЙ, А НЕ ЕДИНСТВЕННЫЙ. У Grafana есть собственный вход с ролями +# (GF_USERS_ALLOW_SIGN_UP=false), и он остаётся включённым. Внешний basic_auth +# нужен потому, что дашборд смотрит в интернет: он отсекает сканеры и любую +# будущую дыру в самой Grafana до того, как она станет доступной снаружи. +# Если два запроса пароля подряд окажутся неудобны — убрать ОДНУ строку +# `import caddy/metrics-ui.caddy.snippet` из infra.caddy, вход Grafana останется. +# +# Формат: username base64(bcrypt_hash) # комментарий +# Caddy 2.11+ требует именно base64 от bcrypt-хеша. + +basic_auth bcrypt "GenDesign Metrics" { + metrics JDJhJDE0JFpObUNJUzVGZnd3blRKQXE3cDIxVWVEaG1udjY0cHVyVmY3OE9Ga2xubjE5RC90elZkL0dD # витрина, пароль в METRICS_UI_PASSWORD на Beget +} diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy new file mode 100644 index 00000000..21401781 --- /dev/null +++ b/caddy/sites/apps.caddy @@ -0,0 +1,500 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# caddy/sites/apps.caddy — домены Site Finder / МЕРА, переезжающие на Selectel +# +# Переезд GenDesign с Beget (46.173.16.127) на Selectel Poincare +# (188.246.224.93), окно 30.08.2026. Пять доменов из этого файла переезжают +# на новый VPS, три инфраструктурных (obsidian/errors/git.gendsgn.ru, +# см. caddy/sites/infra.caddy) остаются на Beget. +# +# Домены в этом файле (после переезда — на Selectel): +# - gendsgn.ru — Site Finder + Trade-In, основной прод-домен +# - www.gendsgn.ru — 301 на apex (канонический URL) +# - meraocenka.ru — публичный лендинг МЕРА B2C (канонический домен) +# - merahome.ru — 301 на meraocenka.ru (домен-спутник) +# - meraotsenka.ru — 301 на meraocenka.ru (домен-спутник, альт. транслит) +# +# ПОЧЕМУ ЭТОТ ФАЙЛ ВООБЩЕ СУЩЕСТВУЕТ (не один Caddyfile на оба хоста). +# Деплой делает git reset --hard на origin/main + `caddy reload` — на прод +# приезжает ВЕСЬ репозиторий, включая site-блоки для доменов, которых на этом +# хосте физически нет. Если бы этот файл жил как +# site-блоки прямо в корневом Caddyfile, Caddy на Selectel пытался бы +# выпускать сертификаты ACME для obsidian/errors/git.gendsgn.ru (DNS которых +# указывает на Beget) — challenge недостижим, попытки ретраятся с backoff и +# могут упереться в rate limit Let's Encrypt (5 failed validations/hostname/ +# hour). Симметрично на Beget после переезда — попытки для gendsgn.ru и +# остальных доменов из этого файла. Разделение на apps.caddy / infra.caddy + +# переменная CADDY_SITES убирает эту проблему: каждый хост импортирует +# ТОЛЬКО свой набор доменов. +# +# КАК ПЕРЕКЛЮЧАЕТСЯ. Корневой Caddyfile содержит +# `import caddy/sites/{$CADDY_SITES:*}.caddy` — Caddy подставляет переменные +# окружения в текст Caddyfile ДО парсинга (включая пути import), поэтому +# значение CADDY_SITES определяет, какой файл(ы) реально импортируются: +# - CADDY_SITES не задан (default `*`) → import caddy/sites/*.caddy → +# ОБА файла, apps.caddy И infra.caddy — текущее поведение Beget ДО +# переезда, когда один VPS обслуживал все восемь доменов. +# - CADDY_SITES=apps (Selectel, после cutover) → только этот файл. +# - CADDY_SITES=infra (Beget, после cutover) → только infra.caddy. +# Переменная задаётся в environment секции caddy-сервиса в +# docker-compose.prod.yml (или runtime-переменной окружения на хосте) — по +# одному значению на хост, меняется руками один раз при переезде, не через +# код. +# ═══════════════════════════════════════════════════════════════════════════ + +# Caddy config. +# +# - gendsgn.ru — main production site, auto-TLS via Let's Encrypt. +# - www.gendsgn.ru — 301 redirect to apex (canonical URL). +# - :80 — fallback for raw IP access. Closed by same auth gate (pilot phase). +# +# `handle /api/*` (NOT `handle_path`) — we keep the /api prefix because +# the FastAPI router is mounted at /api/v1/*. +# +# Auth gate: basic_auth for pilot phase (2026-05-23). +# 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 +# ineffective. With route { }, handlers are matched top-to-bottom as written. + +# #2213: global `log_credentials` УБРАН. Он заставлял Caddy писать raw +# Authorization header (Base64 `user:password`, тривиально обратимый) в ОБА лог-файла +# (gendsgn.ru.log + auth_audit.log). Даже при root-only доступе и коротком retention +# это plaintext-пароли пилотов на диске — недопустимый риск против скромной выгоды. +# Trade-off: glitchtip-auth-forwarder больше не извлекает `attempted_username` из 401 +# (тег деградирует в "(none)" — forwarder это уже обрабатывает gracefully, не падает). +# Событие basic_auth 401 (remote_ip / uri / method) по-прежнему уходит в GlitchTip. + +gendsgn.ru { + encode zstd gzip + + log { + output file /var/log/caddy/gendsgn.ru.log { + roll_size 50MiB + roll_keep 5 + roll_keep_for 720h + } + format json + } + + # Отдельный лог только для auth-событий. + # Forwarder (ops/glitchtip-auth-forwarder) читает именно этот файл. + # Retention 7 дней (меньше чем main log) — содержит plain Base64 credentials. + log auth_audit { + output file /var/log/caddy/auth_audit.log { + roll_size 10MiB + roll_keep 3 + roll_keep_for 168h + } + format json + } + + route { + # `/metrics` наружу не отдаётся — ни бэкендом, ни фронтом (#3078). + # Сегодня он и так недостижим: бэкенду «Птицы» ниже уходят только + # /health и /api/*, остальное забирает фронт, у которого такого + # маршрута нет. Но в бэкенде путь ОТКРЫТ без авторизации — иначе + # агент Alloy изнутри docker-сети получал бы 401 (см. комментарий у + # `_PUBLIC_PATHS` в backend/app/main.py). Одной строки `handle + # /metrics { reverse_proxy backend:8000 }`, добавленной когда-нибудь + # по невнимательности, хватит, чтобы выставить наружу внутреннее + # устройство продукта. Явный 404 делает закрытость решением, а не + # следствием текущего порядка директив, и стоит первым — route + # матчит сверху вниз и short-circuit'ит. + handle /metrics { + respond 404 + } + + # /health и /preview/* — public, без auth, short-circuit. + handle /health { + reverse_proxy backend:8000 + } + + # Static HTML mockups для review (audit alternatives). + # Public access — без auth (по запросу 2026-05-17). + handle_path /preview/* { + root * /srv/preview + file_server browse + } + + # Trade-In UI preview — public CI surface (#801). Рендерит mock-фикстуру + # «денежного экрана» без бэкенда → axe/lighthouse гоняются без креды. + # Реальных клиентских данных нет (статичная фикстура) → безопасно публично. + # ДО auth-import: route матчит сверху вниз, handle short-circuit'ит. + # Без strip — Next.js basePath=/trade-in ждёт префикс в URL (как @tradein). + # ui-preview + его статика (_next/static — CSS/JS бандлы, без секретов). + # Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS. + @uipreview path /trade-in/ui-preview/* /trade-in/_next/static/* + handle @uipreview { + 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 + } + } + + # #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 + header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET} + } + } + + # gendsgn.ru/sale-share — короткий адрес standalone-продукта «Поиск домов». + # 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 + } + + # Matcher `path /trade-in /trade-in/*` ловит И /trade-in (без слеша), + # И /trade-in/ + /trade-in/anything. Без обоих случаев `handle /trade-in/*` + # пропускал /trade-in без слеша → попадал в общий frontend → пустой ответ. + @tradein path /trade-in /trade-in/* + handle @tradein { + # Next.js basePath=/trade-in — фронт сам ждёт префикса в URL + reverse_proxy tradein-frontend:3000 { + # См. комментарий над /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 ../users.caddy.snippet + + handle /api/* { + reverse_proxy backend:8000 { + header_up X-Authenticated-User {http.auth.user.id} + } + } + + handle { + reverse_proxy frontend:3000 { + header_up X-Authenticated-User {http.auth.user.id} + } + } + } +} + +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 + } + + # `/metrics` наружу не отдаётся (#3078). Здесь действует белый список и + # финальный `handle { respond 404 }`, так что путь и без этой строки не + # проходит, — но у бэкенда «Меры» он ОТКРЫТ без авторизации ради агента + # Alloy внутри docker-сети (см. `_PUBLIC_PATHS` в app/core/rbac.py). + # Явный отказ на публичном домене делает закрытость решением, а не + # следствием того, что список пока никто не расширил. + handle /metrics { + respond 404 + } + + # Корень домена → лэндинг МЕРЫ (#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) и уходят в заявку эквайеру, + # поэтому обязаны резолвиться сами по себе. + # + # ЭТО ЕДИНСТВЕННЫЙ ВИД АДРЕСА, КОТОРЫЙ ВИДИТ ЧЕЛОВЕК (решение владельца, + # 15.08.2026). Раньше навигация внутри сайта ходила по длинным + # /trade-in/mera-public/... — так короткие адреса и длинные существовали + # параллельно. Теперь длинные отдают 301 на короткие (см. handle ниже), а + # ссылки на страницах эмитятся сразу короткими (см. `PublicLink` во + # фронте — обычный , потому что next/link подставляет basePath). + # + # `rewrite`, а не `redir`: адрес в строке браузера должен остаться коротким + # — модератор эквайера открывает ссылку из заявки и видит ровно тот URL, + # который в ней указан. Каноничность для поисковиков задана отдельно, через + # `alternates.canonical` на каждой странице. + # + # Пути перечислены поимённо, а не шаблоном: allowlist-by-default этого + # site-блока — часть периметра (#2545), и превращать его в «любой корневой + # путь проксируется» нельзя. Новая публичная страница = новая строка здесь + # (и проверка в scripts/smoke-mera-perimeter.sh). + # + # NB: корень «/» СЮДА НЕ ВХОДИТ — он выше, отдельным handle. Причина + # техническая: здесь цель собирается как `/trade-in/mera-public{path}`, а + # для «/» это дало бы `/trade-in/mera-public/` со слэшем на конце. Next при + # `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант + # без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на + # «/», и запрос закольцуется. + # `/v3` — ВРЕМЕННОЕ превью второго варианта дизайна, а не публичная + # страница: владелец сравнивает его с текущим лэндингом. Оно `noindex` и + # ни с одной страницы на него нет ссылки. Убрать эту строку в тот момент, + # когда вариант выберут и он станет корнем. + @meraPages path /estimate /oferta /refund /privacy /v3 + handle @meraPages { + rewrite * /trade-in/mera-public{path} + reverse_proxy tradein-frontend:3000 { + header_up -X-Authenticated-User + } + } + + # Тот же адрес со слэшем на конце → 301 на канонический вид без слэша. + # Слэш дописывают мессенджеры, автолинкификаторы и сами люди, а матчер + # `path` требует точного совпадения — без этой ветки `/oferta/` отдавал бы + # голый 404 (так было и до этого PR, с момента #2615). Заодно это + # замыкает цепочку для длинных адресов со слэшем: они приходят на короткий + # со слэшем и здесь нормализуются. + @meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|v3)/$ + handle @meraShortSlash { + redir * /{re.shortslash.1} permanent + } + + # Длинные адреса поддерева → 301 на короткие. Один канонический адрес у + # страницы, а не два работающих. + # + # Зачем вообще оставлять длинные: они уже разошлись — ими ссылались подвал + # и шапка до 15.08.2026, они могли попасть в закладки и в переписку. 301 + # (а не 404) сохраняет эти ссылки живыми и заодно передаёт поисковикам, что + # канонический адрес один. + # + # ЗДЕСЬ ЖЕ ЧИНИТСЯ БАГ: прежний матчер был `/trade-in/mera-public/*` — со + # слэшем и звёздочкой, поэтому ГОЛЫЙ `/trade-in/mera-public` (без хвоста) + # под него не подпадал и падал в catch-all 404. Ровно на этот адрес вела + # ссылка «Главная» в подвале v3, то есть она была мёртвой (замер на проде + # 15.08.2026). Первый матчер ниже ловит обе формы — со слэшем и без. + # + # `redir * <куда>`, а НЕ `redir <куда>`. Первый аргумент директивы, если он + # начинается со слэша, Caddy разбирает как inline path-matcher — то есть + # `redir / permanent` означает «для пути / редиректить на permanent», а не + # «редиректить на /». Проверено на живом Caddy: без `*` длинные адреса + # отдавали пустой 200 (матчер не совпадал, директива не срабатывала, тело + # пустое) — хуже, чем 404, потому что выглядит как рабочая пустая страница. + @meraLongRoot path /trade-in/mera-public /trade-in/mera-public/ + handle @meraLongRoot { + redir * / permanent + } + + # Длинные адреса страниц → короткие. Пути перечислены ПОИМЁННО, обе формы + # (со слэшем на конце и без) — не шаблоном и не регекспом. + # + # ПОЧЕМУ НЕ РЕГЕКСП С ЗАХВАТОМ ХВОСТА. Очевидный вариант + # `path_regexp ^/trade-in/mera-public/(.+)$` + `redir /{re.…1}` — открытый + # редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому + # `/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`, + # а браузеры трактуют `/\` как `//` — Location уводит на ЧУЖОЙ хост. Это + # готовая фишинговая заготовка с домена, который напечатан внутри оферты и + # уходит модератору эквайера. Проверено на живом Caddy, воспроизводится. + # С поимённым списком такой путь просто не матчится и падает в 404 ниже. + # + # ПОЧЕМУ `uri strip_prefix` + `{uri}`, А НЕ `redir /oferta` в каждой ветке. + # `{uri}` переносит query-строку: уже размещённые ссылки с UTM-метками + # после редиректа не теряют атрибуцию. Обёртка `route` обязательна — + # порядок директив внутри `handle` определяет Caddy, и без неё `redir` + # выполняется РАНЬШЕ `uri`, отдавая Location, равный исходному адресу + # (бесконечный цикл; поймано на локальном стенде). + @meraLongPages path /trade-in/mera-public/estimate /trade-in/mera-public/estimate/ /trade-in/mera-public/oferta /trade-in/mera-public/oferta/ /trade-in/mera-public/refund /trade-in/mera-public/refund/ /trade-in/mera-public/privacy /trade-in/mera-public/privacy/ /trade-in/mera-public/v3 /trade-in/mera-public/v3/ + handle @meraLongPages { + route { + uri strip_prefix /trade-in/mera-public + redir * {uri} permanent + } + } + + # 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 + } + } + + # Публичный API МЕРЫ — ЕДИНСТВЕННЫЙ путь этого домена, доходящий до + # бэкенда. Под /api/public/ по определению не лежит ничего закрытого: + # гарантию даёт структура пакета app/api/public/, а не аккуратность этого + # матчера (разбор — в app/api/public/mera.py). Матчер тем не менее узкий: + # /trade-in/api/v1/* по-прежнему падает в catch-all 404 ниже. + # + # ПОЧЕМУ ПУТЬ С ПРЕФИКСОМ /trade-in, А НЕ КОРОТКИЙ /api/public/*. + # Тот же URL обязан работать и на gendsgn.ru/trade-in/mera-public — ту же + # страницу оттуда открывают для QA (там она за basic_auth). На gendsgn.ru + # корневой /api/* уже занят бэкендом Site Finder, то есть короткий путь + # потребовал бы там ВТОРОГО handle, выигрывающего у существующего по + # специфичности — то есть работоспособность публичной формы зависела бы от + # порядка сортировки матчеров в чужом site-блоке. С префиксом /trade-in + # запрос ловит уже существующий `handle /trade-in/api/*` (:123), и здесь + # нужен ровно один новый handle. Цена — префикс /trade-in виден в devtools + # публичного домена; он там и так виден на всех чанках Next (basePath). + # + # strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не + # часть маршрута FastAPI. + # + # X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130): + # публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с + # анонимного домена — расширять доверие без нужды. + handle /trade-in/api/public/* { + uri strip_prefix /trade-in + reverse_proxy tradein-backend:8000 { + header_up -X-Authenticated-User + } + } + + # Allowlist-by-default: любой другой путь (включая B2B — /v2, /admin, + # /scrapers/*, /trade-in/api/v1/*, /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 +} diff --git a/caddy/sites/infra.caddy b/caddy/sites/infra.caddy new file mode 100644 index 00000000..6c289ae4 --- /dev/null +++ b/caddy/sites/infra.caddy @@ -0,0 +1,115 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# caddy/sites/infra.caddy — инфраструктурные домены, остающиеся на Beget +# +# Переезд GenDesign с Beget (46.173.16.127) на Selectel Poincare +# (188.246.224.93), окно 30.08.2026. Три домена этого файла ОСТАЮТСЯ на +# Beget — переезжают только Site Finder/МЕРА-домены (см. +# caddy/sites/apps.caddy). Общий механизм переключения (переменная +# CADDY_SITES, `import caddy/sites/{$CADDY_SITES:*}.caddy` в корневом +# Caddyfile, мотив с ACME rate-limit) описан в шапке apps.caddy — не +# дублируем здесь, читай там. +# +# Домены в этом файле (после переезда — на Beget, CADDY_SITES=infra): +# - obsidian.gendsgn.ru — Obsidian Self-hosted LiveSync (CouchDB backend) +# - errors.gendsgn.ru — GlitchTip, self-hosted error tracking +# - git.gendsgn.ru — Forgejo, self-hosted git + Actions CI +# +# На Selectel (CADDY_SITES=apps) этот файл не импортируется вообще — Caddy +# там даже не видит эти хосты и не пытается выпускать для них сертификаты. +# ═══════════════════════════════════════════════════════════════════════════ + +# Obsidian Self-hosted LiveSync (CouchDB backend). +# Auto-TLS Let's Encrypt. CORS уже включён на стороне CouchDB через bootstrap +# (см. scripts/setup-couchdb.sh). Basic-auth — на стороне CouchDB (admin user). +# +# DNS: A-record obsidian.gendsgn.ru → IP VPS. +# Клиенты Obsidian + Self-hosted LiveSync plugin указывают на этот URL. +obsidian.gendsgn.ru { + encode zstd gzip + + reverse_proxy couchdb:5984 { + # Большие документы (vault attachments / images) — увеличиваем timeout + transport http { + response_header_timeout 120s + } + } +} + +# GlitchTip — self-hosted error tracking (Sentry-compatible). +# DNS: A-record errors.gendsgn.ru → IP VPS. +errors.gendsgn.ru { + encode zstd gzip + + reverse_proxy glitchtip-web:8080 + + log { + output file /var/log/caddy/errors.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 +# gendesign_default network. HTTP port 3000 (default Forgejo). +# Был добавлен вручную при migration, потерян при первом auto-deploy после +# изменения Caddyfile (deploy.yml делает git reset --hard). См. fix issue. +git.gendsgn.ru { + encode zstd gzip + + reverse_proxy forgejo:3000 + + log { + output file /var/log/caddy/git.gendsgn.ru.log + } +} + +# Мониторинг — Grafana + приёмник метрик и логов (задача #3078). +# DNS: A-record metrics.gendsgn.ru → 46.173.16.127 (заведена 2026-08-26). +# +# ПОЧЕМУ ЗДЕСЬ, А НЕ В apps.caddy. Наблюдатель сознательно поставлен у ДРУГОГО +# провайдера, чем наблюдаемое: продукт живёт на Selectel Poincare, и если ляжет +# он, мониторинг обязан выжить и сказать об этом. Стек поднимается отдельным +# compose-проектом gendesign-metrics и виден Caddy через сеть gendesign_shared. +# +# ДВА КОНТУРА С РАЗНЫМИ УЧЁТКАМИ: +# /ingest/* — машинный. Агент Alloy с Poincare шлёт сюда remote_write и логи +# исходящим HTTPS. Благодаря этому на Poincare не открыт НИ ОДИН +# входящий порт сверх 22/80/443. +# всё прочее — витрина Grafana под отдельным паролем. +# Пароль приёмника по построению лежит в открытом виде на продуктовом хосте; +# разделив учётки, мы не отдаём вместе с ним доступ к дашбордам. +metrics.gendsgn.ru { + encode zstd gzip + + # handle_path срезает префикс: Prometheus получает /api/v1/write, + # как если бы обращались к нему напрямую. + handle_path /ingest/prometheus/* { + import ../metrics-ingest.caddy.snippet + reverse_proxy prometheus:9090 + } + + # Loki ожидает путь /loki/api/v1/push — он и остаётся после среза /ingest/loki. + handle_path /ingest/loki/* { + import ../metrics-ingest.caddy.snippet + reverse_proxy loki:3100 + } + + # Вход ОДИН — собственный вход Grafana (#3078). Внешний basic_auth снят по + # решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет + # собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`. + # + # Что при этом теряется, чтобы решение было осознанным: basic_auth отсекал + # сканеры до Grafana и прикрывал бы её собственную будущую уязвимость. Теперь + # страница входа Grafana видна из интернета напрямую. + # + # Приём метрик НАРОЧНО остаётся под basic_auth (см. handle_path выше): туда + # ходит агент по паролю, который лежит в открытом виде в окружении + # продуктового хоста, и отдельная учётка там ограничивает ущерб записью. + handle { + reverse_proxy grafana:3000 + } + + log { + output file /var/log/caddy/metrics.gendsgn.ru.log + } +} diff --git a/data/sql/02_load_all_quarters.sh b/data/sql/02_load_all_quarters.sh index 69326c0c..51f63383 100644 --- a/data/sql/02_load_all_quarters.sh +++ b/data/sql/02_load_all_quarters.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Loads all 7 quarters of dataset_СДЕЛКИ into rosreestr_deals via staging. +# Loads quarters of dataset_СДЕЛКИ into rosreestr_deals via staging (see JOBS below). # Q3 2024 uses ';' separator, all later quarters use '~'. # # Usage: @@ -42,6 +42,12 @@ declare -a JOBS=( "2025Q3:2025-07-01:dataset_СДЕЛКИ_r-r_01-92_y_2025_q_3.csv:~" "2025Q4:2025-10-01:dataset_СДЕЛКИ_r-r_01-92_y_2025_q_4.csv:~" "2026Q1:2026-01-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_1.csv:~" + # #2998: партиции под Q2–Q4 2026 — миграция 193. Загрузчик скипает квартал, для + # которого нет CSV в data/raw/, поэтому строки на ещё не опубликованные кварталы + # безопасны: они ждут файла, а не падают. + "2026Q2:2026-04-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_2.csv:~" + "2026Q3:2026-07-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_3.csv:~" + "2026Q4:2026-10-01:dataset_СДЕЛКИ_r-r_01-92_y_2026_q_4.csv:~" ) # Возможные имена zip-ов в data/raw/ (можно класть как есть, скрипт распакует). @@ -82,7 +88,12 @@ resolve_csv() { for job in "${JOBS[@]}"; do IFS=':' read -r SRC_Q PERIOD FILE_CANDIDATES SEP <<< "$job" - FILE=$(resolve_csv "$FILE_CANDIDATES" "$SRC_Q") + # `|| true` обязателен (#2998): resolve_csv сигналит «файла нет» кодом 1, а скрипт + # идёт под `set -e` — без этого первый же квартал без CSV в data/raw/ молча ронял + # ВЕСЬ прогон до строки SKIP, и до реально лежащего файла (например, одного Q2 на + # VPS) загрузчик не доходил никогда. Проверено на проде 21.08: с одним Q2-файлом в + # каталоге скрипт завершался с rc=0, не напечатав ни одной строки. + FILE=$(resolve_csv "$FILE_CANDIDATES" "$SRC_Q" || true) if [[ -z "$FILE" ]]; then echo "=== $SRC_Q SKIP — нет CSV в data/raw/ (искал: $FILE_CANDIDATES; sdelki_${SRC_Q,,}.csv.zip)" continue diff --git a/data/sql/189_land_reservation_nulls_not_distinct.sql b/data/sql/189_land_reservation_nulls_not_distinct.sql new file mode 100644 index 00000000..1701fe91 --- /dev/null +++ b/data/sql/189_land_reservation_nulls_not_distinct.sql @@ -0,0 +1,58 @@ +-- 189_land_reservation_nulls_not_distinct.sql +-- #2464 — дедуп land_reservation и UNIQUE NULLS NOT DISTINCT на живой таблице. +-- +-- БАГ. `_UPSERT_NO_ACT_SQL` (workers/tasks/izyatie_ocr_ingest.py) заканчивается +-- `ON CONFLICT DO NOTHING`, а единственный подходящий констрейнт — +-- `uq_land_reservation_cad_act UNIQUE (cad_num, act_number)` с обычной NULL-семантикой. +-- В Postgres NULL != NULL, поэтому у записей БЕЗ номера акта конфликт не наступает +-- никогда: `ON CONFLICT DO NOTHING` не срабатывает, и каждый недельный прогон +-- вставляет копию. Docstring таски при этом обещает per-batch дедуп и двухшаговый +-- upsert по (cad_num, doc_url) — ни того, ни другого в коде нет. +-- +-- ЗАМЕР ПРОДА 2026-08-20 (до правки): +-- строк всего 297 +-- из них с act_number IS NULL 297 (то есть все) +-- групп (cad_num, doc_url) с дублями 27 +-- максимум копий в группе 11 +-- лишних строк 270 (91% таблицы) +-- +-- Проверено, что ключ подходит: ни у одного cad_num нет более одного doc_url +-- (max = 1), то есть NULLS NOT DISTINCT по (cad_num, act_number) НЕ схлопнет +-- разные документы одного участка. Дубли внутри групп — точные копии: по одному +-- различному значению reservation_kind и act_date на группу. +-- +-- ЧТО УДАЛЯЕТСЯ. Строки-копии сверх первой (по возрастанию id) в каждой группе +-- (cad_num, act_number) среди act_number IS NULL. Это порождение бага, а не +-- пользовательские данные; таблица — кэш OCR-разбора PDF с сайта, пересобираемый +-- прогоном таски. Первая строка группы (минимальный id) сохраняется целиком. +-- +-- ПОЧЕМУ ОТДЕЛЬНОЙ МИГРАЦИЕЙ, а не правкой CREATE TABLE: та же причина, что в +-- м.158 — исходный файл уже в _schema_migrations и на деплое пропускается. +-- Прецеденты NULLS NOT DISTINCT в репо: м.110, м.125, м.140, м.158. Prod = PG16.4. +-- Apply after: 188_regrant_quarter_price_index_fdw.sql + +BEGIN; + +-- #2752: блокирующий DDL обязан иметь lock_timeout — иначе ALTER TABLE встанет в +-- очередь за чужой сессией и уведёт за собой запросы приложения. Таблица крошечная +-- (297 строк), сам DDL мгновенный; пять секунд — про ОЖИДАНИЕ блокировки, не про +-- работу. Не дождались — миграция падает, а не подвешивает прод. +SET LOCAL lock_timeout = '5s'; + +-- 1) Дедуп: оставляем строку с минимальным id в каждой группе. +DELETE FROM land_reservation a + USING land_reservation b + WHERE a.act_number IS NULL + AND b.act_number IS NULL + AND a.cad_num = b.cad_num + AND a.id > b.id; + +-- 2) Пересоздаём констрейнт с NULL-семантикой, при которой ON CONFLICT матчит. +ALTER TABLE land_reservation + DROP CONSTRAINT IF EXISTS uq_land_reservation_cad_act; + +ALTER TABLE land_reservation + ADD CONSTRAINT uq_land_reservation_cad_act + UNIQUE NULLS NOT DISTINCT (cad_num, act_number); + +COMMIT; diff --git a/data/sql/190_user_weight_profiles_one_default.sql b/data/sql/190_user_weight_profiles_one_default.sql new file mode 100644 index 00000000..f81842d2 --- /dev/null +++ b/data/sql/190_user_weight_profiles_one_default.sql @@ -0,0 +1,33 @@ +-- 190_user_weight_profiles_one_default.sql +-- #2464 — «дефолтный профиль весов» становится единственным на уровне БД. +-- +-- БАГ. create_profile/update_profile делают «снять is_default у всех → поставить +-- новому» двумя отдельными операторами. Между ними инвариант нарушен, и при +-- одновременных запросах у пользователя может оказаться ДВА профиля с +-- is_default=TRUE. Читающий запрос _SELECT_DEFAULT брал LIMIT 1 без ORDER BY, +-- то есть выбор молча перескакивал между ними от запроса к запросу. +-- +-- ЧТО ДЕЛАЕМ. Частичный уникальный индекс — «не более одного дефолта на +-- пользователя». Порядок операторов в коде уже правильный (сначала снять, потом +-- поставить), поэтому индекс не мешает штатной переустановке дефолта: после +-- UPDATE ... SET is_default=FALSE дефолтов ноль, и следующая установка проходит. +-- +-- БЕЗОПАСНОСТЬ. Проверено на проде 2026-08-20: нарушений нет — у admin один +-- дефолт, у __system__ ноль, ни одного пользователя с двумя. Таблица крошечная +-- (4 строки), создание индекса мгновенное. +-- +-- Idempotent: CREATE UNIQUE INDEX IF NOT EXISTS. +-- Apply after: 189_land_reservation_nulls_not_distinct.sql + +BEGIN; + +-- #2752: блокирующий DDL обязан иметь lock_timeout, иначе встанет в очередь за +-- чужой сессией и уведёт за собой запросы приложения. Пять секунд — про ОЖИДАНИЕ +-- блокировки, не про работу: таблица в четыре строки индексируется мгновенно. +SET LOCAL lock_timeout = '5s'; + +CREATE UNIQUE INDEX IF NOT EXISTS user_weight_profiles_one_default + ON user_weight_profiles (user_id) + WHERE is_default; + +COMMIT; diff --git a/data/sql/191_land_reservation_act_date_backfill.sql b/data/sql/191_land_reservation_act_date_backfill.sql new file mode 100644 index 00000000..71dc7637 --- /dev/null +++ b/data/sql/191_land_reservation_act_date_backfill.sql @@ -0,0 +1,42 @@ +-- 191: разовое исправление act_date у строк, куда уехала дата Генплана-2004 (#2464). +-- +-- До #2981 `_extract_act_date` брал ПЕРВОЕ «от DD.MM.YYYY» во всём OCR-тексте. +-- «Сообщение о планируемом изъятии» открывается списком оснований, где первой +-- строкой стоит «Решение Екатеринбургской городской Думы от 06.07.2004 № 60/1 +-- «Об утверждении Генерального плана города»». Эта дата и попадала в act_date. +-- +-- #2981 чинит извлечение, но только ВПЕРЁД: UPSERT загрузчика заканчивается +-- `ON CONFLICT DO NOTHING`, а не DO UPDATE, поэтому следующий недельный прогон +-- (пятница 07:00) существующие строки не перезапишет. Без этой миграции 11 строк +-- остались бы с датой 2004 года навсегда. +-- +-- Верные даты взяты не из догадки: оба PDF загружены с екатеринбург.рф и +-- распознаны тем же трактом, что использует загрузчик (ocr_pdf_text), и в обоих +-- настоящее основание — постановление Администрации города: +-- +-- 89adb28a… развязка Базовый/Комсомольская/Сибирский тракт +-- «Постановление Администрации города Екатеринбурга +-- от 27.05.2022 № 1413 «Об утверждении проекта планировки…»» → 9 строк +-- 9b9d9a99… улица Энергостроителей +-- «Постановление Администрации города Екатеринбурга +-- от 12.02.2020 № 259 «Об утверждении проекта планировки…»» → 2 строки +-- +-- Идемпотентна: условие `act_date = '2004-07-06'` при повторном запуске не +-- выполнится. Сужение по doc_url обязательно — без него UPDATE задел бы любую +-- будущую строку, где 06.07.2004 окажется настоящей датой акта. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +UPDATE land_reservation + SET act_date = DATE '2022-05-27' + WHERE act_date = DATE '2004-07-06' + AND doc_url LIKE '%89adb28a3677e7df933e2d9ce0f205c8'; + +UPDATE land_reservation + SET act_date = DATE '2020-02-12' + WHERE act_date = DATE '2004-07-06' + AND doc_url LIKE '%9b9d9a998f578db56315bb816fc2ebf5'; + +COMMIT; diff --git a/data/sql/192_gisogd_permits_key_by_source_key.sql b/data/sql/192_gisogd_permits_key_by_source_key.sql new file mode 100644 index 00000000..43ca5c96 --- /dev/null +++ b/data/sql/192_gisogd_permits_key_by_source_key.sql @@ -0,0 +1,57 @@ +-- 192: бизнес-ключ gisogd_permits — идентификатор документа на портале (#2986). +-- +-- Было: UNIQUE (doc_group, doc_num). Ключ выбирался, чтобы склеивать ОДИН документ, +-- пришедший из двух схем портала. Замер 20.08.2026 показал, что задача, ради которой +-- он введён, почти отсутствует, а побочный эффект огромен: +-- +-- общих docNum между схемами (DocRS): 2 +-- общих key между схемами (DocRS): 2 ← те же два +-- +-- группа документов различных key различных docNum схлопывается +-- DocRS 6098 6096 4305 1793 +-- DocRV 5419 5415 4969 450 +-- DocIZ 548 547 393 155 +-- +-- Причина: docNum у ГИСОГД не уникален. Разрешение и изменения к нему носят ОДИН +-- номер. Пример 66-06-06-2026: портал отдаёт два документа (key 1000130002719586 — +-- само разрешение, key 1000130002752293 — изменения к нему), а UPSERT с +-- предпочтением позднего date_reg оставлял только изменение. Самого разрешения в +-- данных не оставалось. На проде так вытеснено 598 из 4320 строк РНС (13.8%). +-- +-- Стало: UNIQUE (source_key) — идентификатор документа на портале. Он разделяет +-- разрешение и изменения (разные key) и по-прежнему склеивает настоящие межсхемные +-- дубли (у них key ОБЩИЙ — те самые 7 записей по всем трём группам). +-- +-- Дедуп перед сменой ключа не нужен: на проде 20.08.2026 source_key уже уникален — +-- 9182 различных на 9182 строки, NOT NULL. Констрейнт встаёт как есть. +-- +-- CHECK расширен значением 'IZ': группа DocIZ (изменения) есть на портале с самого +-- начала, но в GROUP_CODE лоадера её не было, и 548 документов не грузились вовсе. + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE gisogd_permits + DROP CONSTRAINT IF EXISTS gisogd_permits_doc_group_doc_num_key; + +ALTER TABLE gisogd_permits + DROP CONSTRAINT IF EXISTS chk_gisogd_permits_doc_group; + +ALTER TABLE gisogd_permits + ADD CONSTRAINT chk_gisogd_permits_doc_group + CHECK (doc_group IN ('RS', 'RV', 'IZ')); + +ALTER TABLE gisogd_permits + ADD CONSTRAINT uq_gisogd_permits_source_key UNIQUE (source_key); + +-- (doc_group, doc_num) перестаёт быть уникальным, но остаётся рабочим фильтром +-- («все документы по номеру разрешения») — оставляем как обычный индекс. +CREATE INDEX IF NOT EXISTS idx_gisogd_permits_group_num + ON gisogd_permits (doc_group, doc_num); + +COMMENT ON CONSTRAINT uq_gisogd_permits_source_key ON gisogd_permits IS + 'Бизнес-ключ = key документа на портале ГИСОГД-СО. docNum НЕ уникален: ' + 'разрешение и изменения к нему носят один номер (#2986).'; + +COMMIT; diff --git a/data/sql/193_partitions_rosreestr_2026_q2_q4.sql b/data/sql/193_partitions_rosreestr_2026_q2_q4.sql new file mode 100644 index 00000000..d0a26ef8 --- /dev/null +++ b/data/sql/193_partitions_rosreestr_2026_q2_q4.sql @@ -0,0 +1,29 @@ +-- Партиции rosreestr_deals на Q2–Q4 2026 (#2998). +-- +-- Почему это понадобилось. Схема 01_schema_rosreestr_deals.sql создавала партиции +-- списком «2024 Q3 — 2026 Q1» — дальше этого горизонта таблица ничего не знала, и +-- никакой механизм новые партиции не создаёт. Q2 2026 опубликован Росреестром +-- 10.07, poll заметил его 14.08, а загрузка упёрлась бы в +-- ERROR: no partition of relation "rosreestr_deals" found for row +-- DETAIL: Partition key of the failing row contains (period_start_date) = (2026-04-01) +-- — ровно это и случилось при первой попытке загрузить 21.08. То есть даже оператор, +-- запустив 02_load_all_quarters.sh по подсказке poll, получил бы отказ. +-- +-- Создаём с запасом на три квартала вперёд, чтобы каждый новый квартал не начинался +-- с этой же ошибки. Q1 2027 и далее — следующая такая же миграция (или автосоздание, +-- если до него дойдут руки; пока его нет нигде — проверено grep'ом по app/ и data/sql/). +-- +-- Индексы НЕ перечисляем: партиции наследуют четыре индекса родителя автоматически +-- (проверено по rosreestr_deals_2026q1: pkey(id, period_start_date), doc_type partial, +-- realestate_type_code, (region_code, quarter_cad_number)). +-- +-- Range FROM inclusive, TO exclusive — [start_q, start_next_q). Идемпотентно. + +SET LOCAL lock_timeout = '5s'; + +CREATE TABLE IF NOT EXISTS rosreestr_deals_2026q2 PARTITION OF rosreestr_deals + FOR VALUES FROM ('2026-04-01') TO ('2026-07-01'); +CREATE TABLE IF NOT EXISTS rosreestr_deals_2026q3 PARTITION OF rosreestr_deals + FOR VALUES FROM ('2026-07-01') TO ('2026-10-01'); +CREATE TABLE IF NOT EXISTS rosreestr_deals_2026q4 PARTITION OF rosreestr_deals + FOR VALUES FROM ('2026-10-01') TO ('2027-01-01'); diff --git a/docker-compose.metrics-agent.yml b/docker-compose.metrics-agent.yml new file mode 100644 index 00000000..94bae061 --- /dev/null +++ b/docker-compose.metrics-agent.yml @@ -0,0 +1,241 @@ +# Стек наблюдаемости — АГЕНТСКАЯ сторона. Поднимается на КАЖДОМ хосте: и на Beget +# (рядом с сервером), и на Poincare (рядом с продуктом). +# +# docker compose -p gendesign-metrics-agent -f docker-compose.metrics-agent.yml up -d +# +# Что делает: собирает метрики хоста (node-exporter), контейнеров (cAdvisor), баз +# (postgres-exporter) и логи journald, после чего Alloy отправляет всё на приёмник. +# +# ПОЧЕМУ АГЕНТ, А НЕ СКРЕЙП ИЗ ЦЕНТРА. Prometheus на Beget не может дотянуться до +# экспортеров на Poincare, не открыв там входящие порты — а ufw на Poincare намеренно +# держит только 22/80/443. Агент решает это push'ем: исходящий HTTPS уже разрешён. +# Побочный выигрыш — при обрыве канала Alloy копит в WAL и досылает, тогда как +# pull-скрейп просто теряет точки. +# +# КОНФИГУРАЦИЯ ЗАВИСИТ ОТ ХОСТА — задаётся переменными окружения, файл один: +# +# На Beget (инфраструктура, приёмник рядом): +# METRICS_ROLE=infra +# METRICS_ALLOY_CONFIG=alloy-infra.alloy # пишет напрямую в prometheus:9090 / loki:3100 +# COMPOSE_PROFILES=infra +# +# На Poincare (продукт, приёмник за интернетом): +# METRICS_ROLE=apps +# METRICS_ALLOY_CONFIG=alloy-apps.alloy # пишет в https://metrics.gendsgn.ru под basic_auth +# COMPOSE_PROFILES=apps +# METRICS_INGEST_USER / METRICS_INGEST_PASSWORD — учётка приёмника +# +# Профили решают, какие postgres-exporter'ы поднимать: на Poincare две базы +# (Птица + МЕРА), на Beget одна (infra-postgres с forgejo и glitchtip). + +x-logging: &default-logging + driver: journald + +services: + # ── Alloy: единый агент метрик и логов ─────────────────────────────────────── + # Почему Alloy, а не Promtail: у Promtail EOL 2 марта 2026. Alloy читает journald + # нативно и умеет одновременно скрейпить Prometheus-эндпоинты. + alloy: + image: grafana/alloy:v1.6.1 + container_name: gendesign-alloy + restart: unless-stopped + # root нужен для чтения /var/log/journal. Штатный пользователь `alloy` требует + # членства в группах adm и systemd-journal — внутри контейнера этих групп с + # правильными gid хоста нет, и агент молча читает НОЛЬ записей. Проверено как + # известные грабли: отказ выглядит как «логов просто нет». + user: root + command: + - "run" + - "--server.http.listen-addr=0.0.0.0:12345" + - "--storage.path=/var/lib/alloy/data" + - "/etc/alloy/config.alloy" + env_file: + - path: ./backend/.env.runtime + required: false + - path: ./backend/.env + required: false + environment: + # Метка хоста попадает во все ряды и логи — без неё графики двух машин + # сливаются в один и разобрать, где что, невозможно. + METRICS_HOST_LABEL: ${METRICS_ROLE:?zadaj METRICS_ROLE=infra ili apps} + METRICS_INGEST_USER: ${METRICS_INGEST_USER:-} + METRICS_INGEST_PASSWORD: ${METRICS_INGEST_PASSWORD:-} + volumes: + - ./ops/metrics/alloy/${METRICS_ALLOY_CONFIG:?zadaj METRICS_ALLOY_CONFIG}:/etc/alloy/config.alloy:ro + # Явный путь к журналу обязателен. Без него libsystemd применяет + # SD_JOURNAL_LOCAL_ONLY и хостовые логи из контейнера не видны — при этом + # ошибки нет, просто пустой поток. + - /var/log/journal:/var/log/journal:ro + - /etc/machine-id:/etc/machine-id:ro + - alloy_data:/var/lib/alloy/data + expose: + - "12345" + networks: + - shared + mem_limit: 512m + logging: *default-logging + healthcheck: + # В образе grafana/alloy нет wget/curl/nc, только bash — шлём + # сырой HTTP-запрос через /dev/tcp и проверяем код 200 в ответе. + test: + [ + "CMD-SHELL", + "bash -c 'exec 3<>/dev/tcp/localhost/12345 && printf \"GET /-/ready HTTP/1.0\r\n\r\n\" >&3 && head -1 <&3 | grep -q 200' || exit 1", + ] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + # ── node-exporter: CPU, память, диск, сеть, IO хоста ───────────────────────── + node-exporter: + image: prom/node-exporter:v1.8.2 + container_name: gendesign-node-exporter + restart: unless-stopped + command: + - "--path.procfs=/host/proc" + - "--path.sysfs=/host/sys" + - "--path.rootfs=/host/root" + # Без исключения оверлеев метрика файловой системы засоряется десятками + # слоёв docker, и «свободное место на диске» перестаёт читаться глазами. + - "--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc|var/lib/docker/.+)($$|/)" + - "--collector.filesystem.fs-types-exclude=^(autofs|binfmt_misc|cgroup2?|configfs|debugfs|devpts|devtmpfs|fusectl|hugetlbfs|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs)$$" + pid: host + volumes: + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /:/host/root:ro,rslave + expose: + - "9100" + networks: + - shared + mem_limit: 128m + logging: *default-logging + + # ── cAdvisor: память и CPU по контейнерам ──────────────────────────────────── + # Читает докер-сокет, поэтому видит ВСЕ контейнеры хоста независимо от сетей — + # отдельно подключать его к gendesign_default не нужно. + cadvisor: + # v0.55.1, не v0.52.x: Docker 29 работает через containerd-snapshotter + # (Storage Driver = overlayfs), а 0.52 искал метаданные слоя в легаси + # /var/lib/docker/image//layerdb, которого при снапшоттере + # просто нет. Итог был — ни одной метрики по контейнерам. + image: gcr.io/cadvisor/cadvisor:v0.55.1 + container_name: gendesign-cadvisor + restart: unless-stopped + privileged: true + devices: + - /dev/kmsg:/dev/kmsg + command: + # Урезаем набор метрик: полный cAdvisor выдаёт тысячи рядов на контейнер и + # раздувает TSDB на порядок ради данных, которые никто не смотрит. + - "--docker_only=true" + - "--housekeeping_interval=30s" + - "--disable_metrics=percpu,sched,tcp,udp,advtcp,process,hugetlb,referenced_memory,cpu_topology,resctrl" + - "--store_container_labels=false" + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + expose: + - "8080" + networks: + - shared + mem_limit: 384m + logging: *default-logging + + # ── postgres-exporter: Птица (только на Poincare) ──────────────────────────── + # WAL/сутки, n_tup_upd против n_tup_hot_upd, рост TOAST, коннекты, горизонт + # vacuum — то, из-за отсутствия чего раздутие копилось 91 день незамеченным. + postgres-exporter-gendesign: + image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0 + container_name: gendesign-pg-exporter-gendesign + restart: unless-stopped + profiles: ["apps"] + env_file: + - path: ./backend/.env.runtime + required: false + environment: + # `:-`, а НЕ `:?`. Compose интерполирует ВЕСЬ файл до фильтрации по + # профилям, поэтому `:?` роняет команду из-за переменной сервиса, который + # на этом хосте вообще не поднимается: продуктовый агент падал на + # INFRA_EXPORTER_DSN (профиль infra), инфраструктурный упал бы на двух + # продуктовых. Громкость не потеряна — перенесена в deploy-metrics.yml, + # где роль ИЗВЕСТНА и проверяется ровно тот DSN, который нужен здесь. + DATA_SOURCE_NAME: ${GENDESIGN_EXPORTER_DSN:-} + PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml + volumes: + - ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro + expose: + - "9187" + networks: + - shared + - product + mem_limit: 128m + logging: *default-logging + + # ── postgres-exporter: МЕРА (только на Poincare) ───────────────────────────── + postgres-exporter-tradein: + image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0 + container_name: gendesign-pg-exporter-tradein + restart: unless-stopped + profiles: ["apps"] + env_file: + - path: ./backend/.env.runtime + required: false + environment: + # `:-`, а не `:?` — см. пояснение у экспортера Птицы выше. + DATA_SOURCE_NAME: ${TRADEIN_EXPORTER_DSN:-} + PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml + volumes: + - ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro + expose: + - "9187" + networks: + - shared + mem_limit: 128m + logging: *default-logging + + # ── postgres-exporter: инфраструктурная БД (только на Beget) ───────────────── + # forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip + # ничем не ограничен — политики ретенции у GlitchTip нет вообще. + postgres-exporter-infra: + image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0 + container_name: gendesign-pg-exporter-infra + restart: unless-stopped + profiles: ["infra"] + env_file: + - path: ./backend/.env.runtime + required: false + environment: + # `:-`, а не `:?` — см. пояснение у экспортера Птицы выше. + DATA_SOURCE_NAME: ${INFRA_EXPORTER_DSN:-} + PG_EXPORTER_EXTEND_QUERY_PATH: /etc/pg-queries.yml + volumes: + - ./ops/metrics/postgres/queries.yml:/etc/pg-queries.yml:ro + expose: + - "9187" + networks: + - shared + - infra + mem_limit: 128m + logging: *default-logging + +volumes: + alloy_data: + +networks: + shared: + external: true + name: gendesign_shared + # Сеть продуктового стека Птицы — в ней gendesign-postgres-1 на Poincare. + product: + external: true + name: gendesign_default + # На Beget это та же по имени сеть, но с инфраструктурными контейнерами. + # Разные алиасы в файле нужны, чтобы профили не тянули лишнюю сеть на чужом хосте. + infra: + external: true + name: gendesign_default diff --git a/docker-compose.metrics.yml b/docker-compose.metrics.yml new file mode 100644 index 00000000..0ab29cd0 --- /dev/null +++ b/docker-compose.metrics.yml @@ -0,0 +1,206 @@ +# Стек наблюдаемости — СЕРВЕРНАЯ сторона. Живёт на инфраструктурном хосте (Beget). +# +# Деплоится ОТДЕЛЬНО от продуктового стека, своим project-name: +# docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d +# +# ПОЧЕМУ ЗДЕСЬ, А НЕ РЯДОМ С ПРОДОМ. После переезда 25.08 продукт живёт на Selectel +# Poincare, инфраструктура (Forgejo, GlitchTip, CouchDB) осталась на Beget. Наблюдатель +# сознательно поставлен у ДРУГОГО провайдера, чем наблюдаемое: если ляжет Poincare, +# мониторинг должен об этом сказать, а не лечь вместе с ним. +# +# ТРАНСПОРТ — PUSH, А НЕ PULL. Prometheus не ходит на Poincare за метриками: там агент +# Alloy сам шлёт remote_write и loki.write исходящим HTTPS через metrics.gendsgn.ru. +# Поэтому на Poincare не открывается НИ ОДНОГО входящего порта — ufw там остаётся +# 22/80/443. Приёмник включён флагом --web.enable-remote-write-receiver ниже. +# +# СЕТИ. Обе внешние, обе уже существуют на Beget: +# gendesign_shared — через неё Caddy ходит на grafana:3000 (и couchdb, garmin-mcp) +# gendesign_default — в ней infra-postgres, glitchtip, forgejo: их скрейпит агент +# Тот же приём, что у Caddy — он подключён к обеим. +# +# ПОРТЫ НАРУЖУ НЕ ПУБЛИКУЮТСЯ. Только expose. Наружу выходит один хост +# metrics.gendsgn.ru через Caddy под basic_auth (caddy/sites/infra.caddy). +# 3000/9090/3100/9093 на 0.0.0.0 не биндятся сознательно: на Beget уже был случай, +# когда redis уехал в интернет из-за лишней записи ports в базовом compose-файле. + +x-logging: &default-logging + driver: journald + +services: + # ── Prometheus: хранилище временных рядов + движок правил ──────────────────── + prometheus: + image: prom/prometheus:v3.1.0 + container_name: gendesign-prometheus + restart: unless-stopped + user: "65534:65534" # nobody — том создаётся снаружи, см. deploy-metrics.yml + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + # Ретенция задана ЯВНО и по обоим измерениям. Без --storage.tsdb.retention.size + # диск съедается молча: по умолчанию ограничение только по времени, а сколько + # это в байтах — зависит от числа рядов, которое растёт само. + - "--storage.tsdb.retention.time=30d" + - "--storage.tsdb.retention.size=8GB" + # Приёмник push-метрик от агентов. Без флага remote_write отвечает 404, + # и агент на Poincare будет молча копить в WAL, а графики останутся пустыми. + - "--web.enable-remote-write-receiver" + # Нужен для перезагрузки правил без рестарта (deploy-metrics.yml дёргает). + - "--web.enable-lifecycle" + - "--web.external-url=https://metrics.gendsgn.ru/prometheus" + - "--web.route-prefix=/" + volumes: + - ./ops/metrics/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./ops/metrics/prometheus/rules:/etc/prometheus/rules:ro + # file_sd для job "alertmanager" — см. комментарий в prometheus.yml. + # Забыть этот монт — значит вернуть "no such host" из-за отсутствующего + # файла целей (был инцидент, когда похожий пропущенный монт положил Caddy). + - ./ops/metrics/prometheus/alertmanager_targets.yml:/etc/prometheus/alertmanager_targets.yml:ro + - prometheus_data:/prometheus + expose: + - "9090" + networks: + - shared + - infra + mem_limit: 2g + logging: *default-logging + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + # ── Loki: логи. Monolithic-режим — рекомендован до ~20 ГБ/сутки ────────────── + # У нас ~39 МБ/сутки (замер 24.08), запас в 500 раз. Микросервисный режим здесь + # был бы чистой сложностью без выигрыша. + loki: + image: grafana/loki:3.3.2 + container_name: gendesign-loki + restart: unless-stopped + user: "10001:10001" + command: ["-config.file=/etc/loki/loki-config.yml"] + volumes: + - ./ops/metrics/loki/loki-config.yml:/etc/loki/loki-config.yml:ro + - loki_data:/loki + expose: + - "3100" + networks: + - shared + mem_limit: 1g + logging: *default-logging + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3100/ready"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 60s + + # ── Alertmanager: маршрутизация и дедупликация алертов ─────────────────────── + # Шлёт в Telegram НАПРЯМУЮ с Beget, а не через бэкенд МЕРЫ. Это осознанно: + # доставка алерта не должна зависеть от хоста, про который алерт. Если Poincare + # лёг, сообщение об этом обязано уйти без его участия. + alertmanager: + image: prom/alertmanager:v0.28.0 + container_name: gendesign-alertmanager + restart: unless-stopped + user: "65534:65534" + # За профилем: канал доставки алертов — открытый вопрос #3078 (тот же чат, + # что у вебхука GlitchTip, или отдельный; порог ночной побудки). Пока он не + # решён, стек метрик поднимается и работает без Alertmanager, а не ждёт его. + # Включается добавлением `alerts` в COMPOSE_PROFILES — см. часть 5. + profiles: ["alerts"] + command: + - "--config.file=/etc/alertmanager/alertmanager.yml" + - "--storage.path=/alertmanager" + - "--web.external-url=https://metrics.gendsgn.ru/alertmanager" + env_file: + - path: ./backend/.env.runtime + required: false + - path: ./backend/.env + required: false + environment: + # Токен и чат берутся из окружения — в репозиторий не попадают. + # Отсутствие проверяется в deploy-metrics.yml ДО подъёма: пустой токен + # даёт Alertmanager, который стартует зелёным и молча ничего не шлёт. + METRICS_TELEGRAM_BOT_TOKEN: ${METRICS_TELEGRAM_BOT_TOKEN:-} + METRICS_TELEGRAM_CHAT_ID: ${METRICS_TELEGRAM_CHAT_ID:-} + volumes: + - ./ops/metrics/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + - alertmanager_data:/alertmanager + expose: + - "9093" + networks: + - shared + mem_limit: 256m + logging: *default-logging + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:9093/-/healthy"] + interval: 30s + timeout: 10s + retries: 5 + + # ── Grafana: витрина ───────────────────────────────────────────────────────── + grafana: + image: grafana/grafana:11.5.1 + container_name: gendesign-grafana + restart: unless-stopped + user: "472:472" + env_file: + - path: ./backend/.env.runtime + required: false + - path: ./backend/.env + required: false + environment: + # Доступ закрыт Caddy basic_auth снаружи. Внутренний вход Grafana оставлен + # включённым намеренно: анонимный Viewer + отключённый логин лишили бы + # возможности что-то настроить, а один общий пароль в Caddy не даёт + # разделения ролей внутри. + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-} + GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/ + GF_SERVER_SERVE_FROM_SUB_PATH: "false" + # Телеметрия наружу — выключена. Отдельный хост, отдельный провайдер, и не + # хочется, чтобы наблюдатель сам ходил в интернет без нужды. + GF_ANALYTICS_REPORTING_ENABLED: "false" + GF_ANALYTICS_CHECK_FOR_UPDATES: "false" + GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false" + GF_NEWS_NEWS_FEED_ENABLED: "false" + GF_USERS_ALLOW_SIGN_UP: "false" + # Датасорс к БД GlitchTip: пароль read-only роли из окружения. + GLITCHTIP_RO_PASSWORD: ${GLITCHTIP_RO_PASSWORD:-} + TRADEIN_RO_PASSWORD: ${TRADEIN_RO_PASSWORD:-} + GENDESIGN_RO_PASSWORD: ${GENDESIGN_RO_PASSWORD:-} + volumes: + - ./ops/metrics/grafana/provisioning:/etc/grafana/provisioning:ro + - ./ops/metrics/grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana_data:/var/lib/grafana + expose: + - "3000" + networks: + - shared + - infra + mem_limit: 512m + logging: *default-logging + depends_on: + - prometheus + - loki + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + +volumes: + prometheus_data: + loki_data: + grafana_data: + alertmanager_data: + +networks: + shared: + external: true + name: gendesign_shared + infra: + external: true + name: gendesign_default diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9e04bd6b..475c4c5b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -78,6 +78,16 @@ services: 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} @@ -88,7 +98,21 @@ services: - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - - ./backend/db/init:/docker-entrypoint-initdb.d:ro + # GENDESIGN_PG_INITDB_DIR (#2989): та же мина initdb, что описана у trade-in + # (см. tradein-mvp/docker-compose.prod.yml) — на СВЕЖЕМ томе postgres + # прогоняет всё из этого каталога как initdb-миграции ДО восстановления + # дампа Site Finder. На первом старте нового хоста задать переменную на + # ПУСТОЙ каталог — схема тогда приезжает восстановлением дампа, а не + # initdb-цепочкой; после restore переменную снять. + # Дефолт = текущий прод-путь, поведение Beget не меняется. + # + # ⚠️ Postgres этого стека (Site Finder) НЕ параметризован по mem_limit/ + # shared_buffers, в отличие от trade-in выше — сейчас у него этих лимитов + # вовсе нет (стоковый shared_buffers=128MB на базе ~15 ГБ, wal_compression + # off). Осознанно НЕ трогается в этой правке (#2989 — только initdb-мина, + # добавление лимитов сервису без них — отдельная задача с отдельным риском + # для текущего прода). См. описание PR. + - ${GENDESIGN_PG_INITDB_DIR:-./backend/db/init}:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"] interval: 5s @@ -313,6 +337,220 @@ services: # beat перестроит из `celery_app.conf.beat_schedule` на старте. command: ["celery", "-A", "app.workers.celery_app", "beat", "--loglevel=info", "--schedule=/tmp/celerybeat-schedule"] + # ── infra-postgres: лёгкий кластер ОСТАЮЩЕЙСЯ инфраструктуры (#3061) ──────── + # Переезд продукта Beget (46.173.16.127) → Selectel Poincare (188.246.224.93), + # окно 30.08.2026. Контейнер `gendesign-postgres-1` уезжает целиком вместе с + # томом postgres_data (~15 ГБ) — а внутри него сегодня живут ЧЕТЫРЕ базы: + # gendesign 15 ГБ — Site Finder, уезжает + # auth 7.9 МБ — доступы «Меры»/«Птицы», уезжает с продуктом + # glitchtip 729 МБ — errors.gendsgn.ru, ОСТАЁТСЯ на Beget + # forgejo 265 МБ — git.gendsgn.ru + Actions CI, ОСТАЁТСЯ на Beget + # То есть в момент, когда postgres_data уедет, без базы останутся GlitchTip и + # Forgejo — а из Forgejo идёт сам деплой. Этот сервис — их новый дом. + # + # ПОЧЕМУ ОТДЕЛЬНЫЙ ЛЁГКИЙ КЛАСТЕР, А НЕ «оставить postgis на Beget». Держать + # на инфра-хосте postgis/postgis:16-3.4 с 15-гигабайтным томом ради ~1 ГБ + # реальных данных — это лишний диск, лишний RAM (у того сервиса ещё и + # shm_size: 1gb, #2812) и обновления PostGIS ради двух схем, которым геометрия + # не нужна вообще: forgejo (Go) и glitchtip (Django) — обычные реляционные + # схемы, ни geometry/geography, ни GIST по geom. Отсюда postgres:16-alpine — + # МАЖОРНАЯ ВЕРСИЯ ТА ЖЕ (16), значит дампы переносятся один-в-один, без + # апгрейда формата хранения. + # + # ПОРЯДОК ПЕРЕЕЗДА (все умолчания этого файла = сегодняшнее поведение, мерж и + # деплой сами по себе НИЧЕГО не переключают — сервис сидит в профиле `infra`, + # которого нет ни в одном .env): + # 1. дописать INFRA_PG_PASSWORD и FORGEJO_DB_PASS в /opt/gendesign/.env на + # Beget — сегодня их не читает никто, добавление безопасно. ОБА, а не + # один: initdb-скрипт падает на пустом любом из них, и падает уже ПОСЛЕ + # создания PGDATA, то есть повторно не выполнится никогда (см. мину + # initdb ниже и healthcheck, который такой полупустой кластер не пустит + # в healthy); + # 2. поднять пустой кластер: COMPOSE_PROFILES=glitchtip,infra в том же .env + # и `docker compose -p gendesign -f docker-compose.prod.yml up -d + # infra-postgres`. initdb заводит роли и ПУСТЫЕ базы. На прод это не + # влияет — в кластер ещё никто не ходит. Дождаться `healthy` в + # `docker ps`: до появления обеих баз healthcheck красный намеренно; + # 3. руками, в окно: pg_dump forgejo/glitchtip из gendesign-postgres-1 → + # psql ЦЕЛЕВОЙ РОЛЬЮ в gendesign-infra-postgres (почему именно ролью, а + # не суперюзером — в шапке ops/db-bootstrap/infra-postgres/01-*.sh). + # Этот шаг закрывает ops/split-infra-postgres.sh: гасит писателей, снимает + # дампы, заливает, сверяет. По умолчанию — СУХОЙ ПРОГОН, перенос только по + # --apply; прогнать сухой прогон стоит ЗАРАНЕЕ, засветло: он ловит забытый + # пароль в конфиге и расхождение версий до окна, а не в окне; + # 3a. СРАЗУ ЖЕ, тем же движением — прописать в конфиге бэкапа Forgejo + # (/etc/default/gendesign-backup-forgejo) `PG_CONTAINER=gendesign-postgres-1`. + # Не косметика: с момента заливки дампа непустая база forgejo лежит в + # ДВУХ запущенных контейнерах, а автоопределение в ops/backup-forgejo.sh + # на такую неоднозначность намеренно останавливается с exit 1 — то есть + # ночной бэкап git-хоста перестанет сниматься вовсе, и заметить это + # некому (канал оповещений check-backup-staleness.sh на этом хосте не + # настроен, см. ops/crontab-beget.cron). Пока боевой кластер старый — + # указываем старый; на шаге 4 меняем значение на gendesign-infra-postgres; + # после гашения старого кластера строку убираем совсем; + # 4. cutover: GLITCHTIP_DB_HOST=infra-postgres в /opt/gendesign/.env и + # `HOST = infra-postgres:5432` в app.ini Forgejo + # (/home/gendesign/forgejo/, его compose-файла в этом репозитории нет), + # затем перезапуск этих двух приложений и правка PG_CONTAINER из 3a. + # Откат = вернуть обе строки как было. Старый кластер к этому моменту не + # тронут: снятие дампов ничего в нём не меняет. + infra-postgres: + image: postgres:16-alpine + # Стабильное имя вместо compose-генерируемого `gendesign-infra-postgres-1`: + # ровно это имя перечислено в PG_CONTAINER_CANDIDATES у ops/backup-forgejo.sh + # (он ходит в БД через `docker exec "$PG_CONTAINER"` и выбирает контейнер по + # факту наличия данных, а не по зашитой константе — разбор в шапке того + # скрипта). Тот же приём уже применён у glitchtip-web, glitchtip-worker и + # gendesign-auth-forwarder. + container_name: gendesign-infra-postgres + logging: *default-logging + restart: unless-stopped + # РОВНО ОДИН профиль, и он НОВЫЙ — `infra`. Ни в одном /opt/gendesign/.env + # такого значения сегодня нет, поэтому мерж этой правки и любой следующий + # деплой для сервиса — честный no-op: compose его не видит, контейнер не + # создаётся, том не создаётся. + # + # ПОЧЕМУ НЕ `glitchtip`, хотя технически он подошёл бы. На Beget в + # /opt/gendesign/.env уже стоит COMPOSE_PROFILES=glitchtip (его выставляет + # scripts/bootstrap_glitchtip.sh), а docker-compose.prod.yml перечислен в + # paths: у .forgejo/workflows/deploy.yml, и шаг деплоя делает + # `set -a; source .env` и затем `docker compose -p gendesign up -d`. Общий + # профиль с glitchtip-web/worker означал бы, что кластер стартует на БОЕВОМ + # хосте в тот же час, когда PR влит, — то есть переключателем оказался бы + # сам мерж, что прямо противоречит требованию «все умолчания сохраняют + # сегодняшнее поведение». Хуже того, старт был бы СЛОМАННЫМ и МОЛЧАЛИВЫМ: + # INFRA_PG_PASSWORD в .env к тому моменту ещё нет, официальный энтрипойнт + # отказывается инициализировать кластер без пароля суперюзера, а + # `restart: unless-stopped` превращает это в бесконечный restart-loop. Деплой + # при этом зелёный — `up -d` не ждёт сервис, на который никто не depends_on, + # а health-check шага деплоя смотрит только на backend. + # Отдельный профиль переносит старт кластера туда, где ему и место: в шаг 2 + # порядка переезда, то есть в правку одной переменной на VM + # (COMPOSE_PROFILES=glitchtip,infra), выполняемую осознанно и с уже + # заполненными паролями. Откат — убрать `,infra`. + # + # ГАРАНТИЯ «GlitchTip не поднимется без своей БД» от этого не теряется: до + # cutover он ходит в общий кластер `postgres` и к infra-postgres отношения + # не имеет, а после cutover COMPOSE_PROFILES на остающемся хосте уже + # содержит infra — иначе шага 4 просто не было бы. + # + # Профиль назван по ХОСТУ, а не по приложению, потому что главный + # потребитель этого кластера — Forgejo, которого в данном compose нет вовсе + # (живёт отдельным стеком в /home/gendesign/forgejo, см. шапку + # ops/backup-forgejo.sh): привязать «БД для Forgejo» через depends_on не к + # чему. Если GlitchTip когда-нибудь выключат, COMPOSE_PROFILES=infra всё + # равно поднимет кластер — git.gendsgn.ru не должен зависеть от судьбы + # трекера ошибок. + # На Selectel профиль `infra` не активируется никогда (и GlitchTip, и Forgejo + # остаются на Beget) → сервис там не стартует и тома не создаёт. + profiles: ["infra"] + environment: + # Суперюзерская пара САМОГО кластера — это НЕ роли forgejo/glitchtip. + # Ею initdb-скрипт заводит прикладные роли и базы, ею же удобно ходить + # `docker exec`-ом в окно миграции и при разборе инцидентов. + # У пароля намеренно НЕТ дефолта-заглушки: с пустым INFRA_PG_PASSWORD + # официальный образ откажется инициализировать кластер с внятным + # "Database is uninitialized and superuser password is not specified", + # тогда как тихий `changeme` на хосте с публичным Forgejo — это дыра, + # которую никто не заметит. + POSTGRES_DB: ${INFRA_PG_DB:-infra} + POSTGRES_USER: ${INFRA_PG_USER:-infra} + POSTGRES_PASSWORD: ${INFRA_PG_PASSWORD} + # Читаются ТОЛЬКО initdb-скриптом (ops/db-bootstrap/infra-postgres/ + # 01-roles-and-databases.sh) — он создаёт ими роли-владельцы баз. + # GLITCHTIP_DB_PASS переиспользуется как есть, новая переменная не + # заводится: это тот же пароль, что уже стоит в DATABASE_URL у + # glitchtip-web/worker, и после cutover он ОБЯЗАН совпадать — иначе + # GlitchTip не залогинится в перенесённую базу. + FORGEJO_DB_PASS: ${FORGEJO_DB_PASS} + GLITCHTIP_DB_PASS: ${GLITCHTIP_DB_PASS} + volumes: + - infra_postgres_data:/var/lib/postgresql/data + # ⚠️ ТА ЖЕ МИНА initdb, что описана у сервиса postgres выше (#2989) и в + # tradein-mvp/docker-compose.prod.yml: каталог /docker-entrypoint-initdb.d + # исполняется ТОЛЬКО на ПУСТОМ томе и ТОЛЬКО один раз — на самом первом + # старте, до того как в кластер попадут любые данные. + # Здесь это ровно то, что нужно: сначала initdb создаёт роли и пустые базы, + # ПОТОМ руками заливаются дампы (роли обязаны существовать до + # восстановления — дампы сняты с --no-owner --clean --if-exists). + # Обратная сторона та же: если том infra_postgres_data уже не пуст, скрипты + # МОЛЧА не выполнятся — ни ошибки, ни строчки в логе, и правка скрипта + # после первого старта сама не применится. Прогнать заново = + # `docker volume rm gendesign_infra_postgres_data` (кластер пересоздастся + # с нуля) либо выполнить те же команды psql руками. + - ./ops/db-bootstrap/infra-postgres:/docker-entrypoint-initdb.d:ro + # Healthcheck проверяет НЕ ТОЛЬКО «кластер принимает соединения», но и что + # обе базы на месте — и это главное здесь, а не перестраховка. + # + # ЗАЧЕМ. Мина initdb выше имеет злую разновидность: ОТРАВЛЕННЫЙ ТОМ. Порядок + # шагов официального энтрипойнта — сначала initdb создаёт PGDATA (пишет + # PG_VERSION), и ТОЛЬКО ПОТОМ исполняется /docker-entrypoint-initdb.d. + # Значит любое падение bootstrap-скрипта (проще всего — забыть один из двух + # паролей в .env: FORGEJO_DB_PASS новый, а GLITCHTIP_DB_PASS там уже есть, + # так что «половина» выглядит совершенно правдоподобно) роняет контейнер + # УЖЕ ПОСЛЕ инициализации тома. На следующем рестарте энтрипойнт видит + # непустой PGDATA, объявляет DATABASE_ALREADY_EXISTS и пропускает + # initdb.d НАВСЕГДА — кластер поднимается, но ролей forgejo/glitchtip и их + # баз в нём нет и больше не появится. + # Один `pg_isready` такой кластер от готового не отличает: он зелёный, + # `docker ps` показывает healthy, и обнаружилось бы это в ночь переезда, на + # заливке дампа, репликой `role "forgejo" does not exist` — в окно, когда + # разбираться уже некогда. Проверка наличия обеих баз делает отравленный том + # видимым сразу и в самом обычном месте — в колонке STATUS у `docker ps`. + # Лечение то же: `docker volume rm gendesign_infra_postgres_data` и поднять + # заново с заполненными паролями. + # + # Идём в служебную базу `postgres`, а не в ${POSTGRES_DB}: pg_database + # виден из любой базы, а лишняя привязка к имени тут ни к чему. `-tA` даёт + # голое `t`/`f` без рамок и пробелов, `grep -qx t` не даст `f` пролезть как + # непустой вывод. Проверка остаётся зелёной и после переезда: базы никуда не + # деваются, а восстановление дампа с `--clean --if-exists` дропает объекты + # ВНУТРИ базы, но не саму базу. + healthcheck: + test: + - CMD-SHELL + - >- + pg_isready -q -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}" && + psql -U "$${POSTGRES_USER}" -d postgres -tAc + "SELECT count(*) = 2 FROM pg_database WHERE datname IN ('forgejo', 'glitchtip')" + | grep -qx t + interval: 10s + timeout: 5s + retries: 10 + # 20s хватало на «кластер поднялся»; теперь в старт входит ещё и прогон + # initdb.d (создание двух баз из template0) — это доли секунды, но + # запас до 30s снимает мигание unhealthy на холодном старте. + start_period: 30s + # ~1 ГБ данных против 15 ГБ у основного кластера (glitchtip 729 МБ + forgejo + # 265 МБ). Считаем по-крупному: стоковый shared_buffers 128 МБ + ~25 + # соединений (пул Forgejo + web/celery GlitchTip) по ~8 МБ + запас на + # autovacuum и на восстановление дампа в окно миграции + # (maintenance_work_mem 64 МБ) ≈ 400 МБ пика. 512 МБ даёт над этим воздух и + # при этом на порядок меньше, чем съел бы оставленный на хосте postgis. + # Ниже (256 МБ) опускать не стоит — OOM-kill пришёлся бы ровно на заливку + # дампов, то есть на самый неудобный момент. + mem_limit: 512m + # shm_size НЕ поднимаем. У основного postgres 1 ГБ понадобился под DSM + # параллельных планов по таблицам Site Finder (#2812); ни Forgejo, ни + # GlitchTip на своих объёмах параллельных планов не строят — дефолтных + # 64 МБ хватает с запасом. + # + # Портов НЕТ и на хост ничего не пробрасывается — сознательно. У основного + # postgres есть `127.0.0.1:5432` ради SSH-туннеля к рабочей БД + # (`ssh -N gendesign` → localhost:15432), здесь такой нужды нет: обе базы + # обслуживаются своими приложениями по докер-сети, а разовая миграция и + # разбор инцидентов идут через `docker exec gendesign-infra-postgres psql`. + # Лишний слушающий сокет на хосте, где стоит ПУБЛИЧНЫЙ Forgejo, — чистый + # прирост поверхности атаки без единого выигрыша. + # + # Сеть ровно одна и именно default (`gendesign_default`): в ней сидит + # контейнер `forgejo` из отдельного стека /home/gendesign/forgejo, поэтому он + # достучится сюда по имени `infra-postgres`. `default` перечислен ЯВНО — как + # только у сервиса появляется блок networks:, неявная привязка к дефолтной + # сети пропадает (та же грабля описана у postgres и redis выше). `shared` не + # нужна: с trade-in этот кластер не разговаривает. + networks: [default] + glitchtip-web: image: glitchtip/glitchtip:6.1.6 container_name: glitchtip-web @@ -321,19 +559,58 @@ services: # Bootstrap script activates the profile after DB + secrets are ready. # On subsequent deploys, set COMPOSE_PROFILES=glitchtip in /opt/gendesign/.env. profiles: ["glitchtip"] + # depends_on: БД здесь больше НЕТ, остался только redis (#3061). Зависимость + # обязана быть верной в ОБЕИХ фазах переезда, а она статична — имя сервиса + # нельзя вывести из GLITCHTIP_DB_HOST. Разобраны оба варианта: + # * оставить `postgres: service_healthy` — после cutover этот сервис на + # Beget не запускается вовсе (его том с Site Finder уехал), а depends_on + # на незапущенный сервис НЕ игнорируется: compose поднимет postgres + # вместе с glitchtip-web, то есть воскресит 15-гигабайтный postgis ровно + # там, откуда его убирали; + # * поставить `infra-postgres: service_healthy` — сделало бы доступность + # работающего сегодня GlitchTip заложником кластера, который до cutover + # ПУСТ и никем не используется: любая заминка с его паролями или + # healthcheck'ом роняла бы трекер ошибок. Это прямо нарушает требование + # «мерж и деплой ничего не меняют до переноса данных». + # Терять при этом нечего: `service_healthy` у postgres доказывал лишь то, что + # кластер принимает соединения, а не что база glitchtip и её миграции на + # месте. Холодный старт с ещё не готовой БД GlitchTip переживает — контейнер + # падает и поднимается заново по `restart: always` (ниже) с экспоненциальным + # backoff'ом и сходится за десятки секунд. redis оставлен: он в дефолтном + # профиле, присутствует в обеих фазах и нужен и брокеру celery, и веб-морде. depends_on: - postgres: - condition: service_healthy redis: condition: service_started environment: - DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@postgres:5432/glitchtip + # GLITCHTIP_DB_HOST (#3061) — переключатель хоста БД на время переезда. + # Дефолт `postgres` = СЕГОДНЯШНЕЕ поведение байт-в-байт, поэтому мерж этой + # правки сам по себе ничего не меняет. Переключение на лёгкий кластер — + # одна строка `GLITCHTIP_DB_HOST=infra-postgres` в /opt/gendesign/.env на + # остающемся хосте, и только ПОСЛЕ того, как база glitchtip перенесена туда + # дампом. Откат — убрать строку и перезапустить оба glitchtip-контейнера. + # ⚠️ ПУСТОЕ значение переменной — НЕ то же самое, что «не задана»: + # `GLITCHTIP_DB_HOST=` даст DSN вида postgres://glitchtip:pass@:5432/..., + # то есть хост исчезнет из строки подключения и Django пойдёт в локальный + # сокет. Подстановка `:-` срабатывает только для НЕЗАДАННОЙ переменной. Та + # же грабля описана у CADDY_SITES в блоке caddy ниже: либо не задавать + # вовсе, либо задавать имя сервиса. + DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@${GLITCHTIP_DB_HOST:-postgres}:5432/glitchtip 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 @@ -354,20 +631,44 @@ services: container_name: glitchtip-worker logging: *default-logging profiles: ["glitchtip"] + # depends_on без БД — по тем же причинам, что у glitchtip-web выше (там же + # разбор обоих отвергнутых вариантов). Для celery это ещё безопаснее: он и + # так обязан переживать обрыв соединения с базой в рантайме, а не только на + # старте. depends_on: - postgres: - condition: service_healthy redis: condition: service_started command: ./bin/run-celery-with-beat.sh environment: - DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@postgres:5432/glitchtip + # Тот же переключатель GLITCHTIP_DB_HOST, что у glitchtip-web (полное + # обоснование и грабля с ПУСТЫМ значением — в комментарии там). Значение + # обязано совпадать с web: это один и тот же экземпляр базы, и разъехавшись, + # они дадут веб-морду и воркер, читающих разные кластеры. + DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASS}@${GLITCHTIP_DB_HOST:-postgres}:5432/glitchtip 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] + # GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15, + # см. tradein-mvp/backend/app/api/v1/glitchtip.py): вебхуки шлёт РЕАЛЬНО + # этот celery-воркер (apps/alerts/webhooks.py send_webhook — не glitchtip-web), + # получателю `webhook` нужен доступ к http://tradein-backend:8000/... — + # tradein-backend сидит на gendesign_shared, у glitchtip-* её раньше не было + # вообще (та же грабля, что #2709 у redis: сеть должна быть общей ДО того, + # как переменная окружения с URL вообще имеет смысл). default — обязательно + # явно, иначе воркер потеряет Postgres/Redis-брокер (см. комментарий у redis + # выше про неявную привязку к default). + networks: [default, shared] caddy: image: caddy:2 @@ -382,9 +683,43 @@ services: # Пустой дефолт = fail-open (backend не проверяет) → ничего не ломается до # провижининга. Значение задаётся руками в /opt/gendesign/.env на VPS. TRADEIN_INTERNAL_AUTH_SECRET: ${TRADEIN_INTERNAL_AUTH_SECRET:-} + # #3059: какие site-блоки импортирует Caddyfile (`import + # caddy/sites/{$CADDY_SITES:*}.caddy`). Плейсхолдер раскрывает САМ Caddy + # из окружения СВОЕГО контейнера, а не compose из файла окружения хоста — + # поэтому переменную обязательно пробрасывать сюда. Без этой строки + # значение с хоста до Caddy не доходит и всегда работает дефолт `*`, + # то есть оба файла сразу, а разделение хостов молча не срабатывает: + # после переезда (#3057) Caddy на продовом хосте начал бы выпускать + # сертификаты для obsidian/errors/git, чей DNS остаётся на + # инфраструктурном хосте — HTTP-01 падает, а Let's Encrypt считает + # неудачи (5 на домен в час). + # Дефолт `*` = сегодняшнее поведение бит в бит: пока переменная не задана + # ни на одном хосте, импортируются оба файла, как и до этой строки. + # В окне: на продовом хосте CADDY_SITES=apps, на инфраструктурном=infra. + CADDY_SITES: ${CADDY_SITES:-*} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy/users.caddy.snippet:/etc/caddy/caddy/users.caddy.snippet:ro + # #3102 postmortem: сниппеты метрик импортируются из caddy/sites/infra.caddy + # (`import ../metrics-*.caddy.snippet`). Смонтированы ПОФАЙЛОВО, как + # users.caddy.snippet — сам каталог caddy/ внутрь не пробрасывается. + # Забыть монт = Caddy не адаптирует конфиг и уходит в restart-loop, + # роняя ВСЕ сайты хоста, а не только metrics.gendsgn.ru. + - ./caddy/metrics-ingest.caddy.snippet:/etc/caddy/caddy/metrics-ingest.caddy.snippet:ro + - ./caddy/metrics-ui.caddy.snippet:/etc/caddy/caddy/metrics-ui.caddy.snippet:ro + # Untracked локальные site-блоки (см. import в конце Caddyfile). Каталог + # держится в git через caddy/local/.gitignore — иначе docker создал бы + # отсутствующий bind-source сам, root-owned пустышкой. + - ./caddy/local:/etc/caddy/caddy/local:ro + # Site-блоки, разложенные по хостам (#3059). Каддифайл импортирует их как + # `import caddy/sites/{$CADDY_SITES:*}.caddy` — без переменной берутся оба + # файла (текущее поведение Beget, все восемь доменов), CADDY_SITES=apps на + # Selectel даёт пять уезжающих, =infra на Beget после переезда — три + # остающихся (git / errors / obsidian). + # ⚠️ ПУСТОЕ значение переменной — не то же самое, что «не задана»: Caddy + # подставит пустую строку, получится путь caddy/sites/.caddy и конфиг не + # соберётся. Либо не задавать вовсе, либо задавать apps/infra. + - ./caddy/sites:/etc/caddy/caddy/sites:ro - ./preview:/srv/preview:ro - caddy_data:/data - caddy_config:/config @@ -428,6 +763,10 @@ services: volumes: postgres_data: + # Данные лёгкого кластера остающейся инфраструктуры (#3061). Отдельный том, а + # не соседство в postgres_data: тот уезжает на Selectel целиком, а этот обязан + # остаться на Beget — 30.08.2026 их жизненные циклы расходятся навсегда. + infra_postgres_data: redis_data: caddy_data: caddy_config: diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 00000000..ceb435db --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,215 @@ +# Наблюдаемость: метрики, логи, алерты + +Задача #3078. Стек живёт по адресу `https://metrics.gendsgn.ru/`. + +## Зачем это заведено + +Метрик в проекте не было ни одной — ни Prometheus, ни экспортеров, ни `/metrics` +в бэкендах. Единственным каналом наблюдения оставался journald, а единственным +сигналом об аварии — исключение в GlitchTip. Из-за этого целый класс отказов был +невидим в принципе: задача рапортует `done`, строк ноль, исключения нет, метрики +нет. Так протухли данные на семь месяцев (#2846/#2998), так 34 дня был мёртв +`house_imv_backfill` (#2698), так 8 суток писал ноль записей `newbuilding_enrich` +(#2767), так 91 день копилось раздутие `listings` (#2992). + +Grafana **не заменяет** GlitchTip. Ошибки остаются там: Grafana OSS не принимает +Sentry DSN ни одним компонентом, а 549 строк скрубберов в `before_send` — это +требование 152-ФЗ, а не фича трекера. Здесь появляется другой класс данных — +числовые ряды и алерты по трендам вместо алертов по событиям. + +## Топология + +``` + Poincare (188.246.224.93) — продукт Beget (46.173.16.127) — инфраструктура + ┌──────────────────────────────┐ ┌────────────────────────────────────┐ + │ node-exporter │ │ Prometheus ← приёмник remote_write │ + │ cAdvisor │ HTTPS │ Loki ← приёмник логов │ + │ postgres-exporter × 2 │ ──────────► │ Grafana ← витрина │ + │ Alloy ──── push ────────────┼─ 443 ─────► │ Alertmanager (за профилем alerts) │ + └──────────────────────────────┘ │ node-exporter, cAdvisor, Alloy │ + │ postgres-exporter (infra) │ + └────────────────────────────────────┘ +``` + +**Наблюдатель стоит у другого провайдера, чем наблюдаемое.** Если ляжет Poincare, +мониторинг обязан выжить и сказать об этом, а не лечь вместе с ним. + +**Транспорт — push.** Prometheus не ходит на Poincare: там агент сам шлёт +исходящим HTTPS. Поэтому на продуктовом хосте не открыто ни одного входящего +порта сверх 22/80/443. Побочный выигрыш: при обрыве канала Alloy копит в WAL и +досылает, а pull-скрейп в той же ситуации просто потерял бы точки — то есть +именно в аварии, ради которой мониторинг и заводится. + +## Что где лежит + +| Файл | Назначение | +|---|---| +| `docker-compose.metrics.yml` | серверная сторона, только Beget | +| `docker-compose.metrics-agent.yml` | агенты, оба хоста; профили `apps` / `infra` | +| `ops/metrics/prometheus/` | конфиг и правила алертов | +| `ops/metrics/loki/` | конфиг Loki | +| `ops/metrics/alloy/alloy-infra.alloy` | агент на Beget — пишет напрямую по docker-сети | +| `ops/metrics/alloy/alloy-apps.alloy` | агент на Poincare — пишет по HTTPS под basic_auth | +| `ops/metrics/postgres/queries.yml` | дополнительные запросы экспортера БД | +| `backend/app/observability/metrics.py` | счётчики и `/metrics` «Птицы» | +| `tradein-mvp/backend/app/observability/metrics.py` | то же для «Меры» | +| `ops/metrics/grafana/` | источники данных и дашборды | +| `caddy/sites/infra.caddy` | site-блок `metrics.gendsgn.ru` | +| `scripts/setup-metrics-secrets.sh` | разовая подготовка учёток | + +## Первый запуск + +```bash +# 1. На инфраструктурном хосте — один раз. Пароли не печатает, печатает хеши. +ssh gendesign 'cd /opt/gendesign && bash scripts/setup-metrics-secrets.sh' + +# 2. Хеши из вывода вставить в caddy/metrics-ui.caddy.snippet и +# caddy/metrics-ingest.caddy.snippet, закоммитить. + +# 3. Деплой. +# Forgejo → Actions → Deploy Metrics → Run workflow +``` + +Пароль витрины смотреть на хосте, в переписку не копировать: + +```bash +ssh gendesign "grep '^METRICS_UI_PASSWORD=' /opt/gendesign/backend/.env.runtime" +``` + +## Два контура доступа, две учётки + +`metrics.gendsgn.ru/ingest/*` — машинный, для агента. Пароль этого контура по +построению лежит в открытом виде на продуктовом хосте: агенту нужно им +авторизоваться. Значит, компрометация Poincare раскрывает его автоматически. +Если бы это была учётка витрины, вместе с ней утёк бы доступ к дашбордам и к +Prometheus, где видна вся инфраструктура. Разделение ограничивает ущерб записью. + +Всё остальное — витрина Grafana под отдельным паролем. Это **второй слой**: у +Grafana остаётся собственный вход с ролями. Внешний basic_auth отсекает сканеры +и любую будущую дыру в самой Grafana до того, как она станет достижимой снаружи. +Если два запроса пароля подряд неудобны — убрать одну строку `import +caddy/metrics-ui.caddy.snippet` из `infra.caddy`; вход Grafana останется. + +## GlitchTip читается прямым SQL, а не плагином + +Плагин `grafana/sentry-datasource` GlitchTip официально документирует, но на +нашей 6.1.6 половина путей нерабочая: `stats_v2` с фильтром по проекту отдаёт +500 (открытый баг GlitchTip #381 с 2025-01-10), Events/Discover — 404 (#416), +Metrics/Spans/Tags вообще Sentry-only. В самом `grafana/sentry-datasource` слово +«glitchtip» не встречается ни разу — апстрим связку не тестирует. + +Прямой SQL правок в GlitchTip не требует вовсе, нужен только read-only +пользователь (`scripts/setup-metrics-grafana-role.sh`, прав на запись нет). +Схема проверена на живой базе 26.08: + +- `issue_events_issue` — `count`, `first_seen`, `last_seen`, `status`, `level`, + `project_id`, `title`, `culprit` +- `issue_events_issueaggregate` — `(issue_id, organization_id, date, count)`, + партиционирована по неделям с почасовыми под-партициями +- `issue_events_issueevent` — колонка времени называется **`timestamp`** + (плюс `created`); колонки `received` в этой версии нет + +Отдельной таблицы `IssueIndex` **не существует** — все агрегаты лежат на самой +`issue_events_issue`. В более ранних описаниях этой задачи она упоминалась; +это была ошибка, проверено глазами. + +**Граница:** доступ read-only. Тренды — в Grafana, а assign / resolve / ignore, +стектрейсы и breadcrumbs — только в GlitchTip. Окна остаётся два: витрина и +рабочее место. Это осознанно, а не недоделка. + +## Метрики приложений + +У «Птицы» и «Меры» появился `/metrics` — HTTP-счётчики, гистограмма времени +ответа, число запросов в работе и версия сборки, плюс `process_*` от реестра +`prometheus_client` (память процесса, дескрипторы, сборщик мусора). + +**Метка `route` — это шаблон маршрута**, `/api/v1/parcels/{cad_num}`, а не путь +запроса. Разница принципиальная, а не косметическая: кадастровый номер в метке +даёт новый временной ряд на каждый участок, а ряд у Prometheus стоит памяти +постоянно, а не в момент запроса. Это самый известный способ уронить мониторинг +тем самым мониторингом. Всё незаматченное сведено в одну метку +``__unmatched__`` — иначе тот же взрыв рядов устроит любой бот, перебирающий +адреса. Свойство сторожится тестами (`tests/test_metrics.py` в обоих проектах), +а не комментарием. + +**Слой регистрируется последним и потому оказывается самым внешним** +(`add_middleware` вставляет в начало списка). Порядок несущий: изнутри +RBAC-гварда не видно ни отказов авторизации, ни времени, которое он тратит на +резолв сессии в БД `auth`, — а именно этот путь уже давал инцидент с блокирующим +I/O в middleware (#1202). + +**Путь публичен внутри и закрыт снаружи** — это два разных периметра. Скрейп +идёт изнутри docker-сети, где заголовка `X-Authenticated-User` нет ни у кого, +поэтому `/metrics` внесён в `_PUBLIC_PATHS` обоих бэкендов: без этого агент +получал бы 401 и метрик не было бы вовсе. Наружу путь при этом не открывается — +ни через `gendsgn.ru`, ни через `meraocenka.ru`, и вдобавок закрыт явным +`respond 404` в обоих site-блоках. Явный отказ стоит там ради регрессии: одной +строки `handle /metrics { reverse_proxy backend:8000 }`, добавленной +когда-нибудь по невнимательности, хватит, чтобы выставить наружу внутреннее +устройство продукта. + +Ограничитель частоты и аудит запросов «Меры» трогать не пришлось: первый +смотрит только на пути под `/api/`, второй — на `/api/` и известного +пользователя. Скрейп раз в 30 секунд не попадает ни под один; иначе метрики +пропадали бы пачками под нагрузкой, а `user_events` получала бы 2880 строк в +сутки ни о чём. + +**Один процесс — один реестр.** Оба контейнера запускают `uvicorn` без +`--workers`, поэтому значения счётчиков целостны. Появятся воркеры — счётчики +станут per-process, каждый скрейп попадёт в случайный из них, и график начнёт +пилить вверх-вниз без связи с нагрузкой. Лечится штатным многопроцессным +режимом `prometheus_client` (`PROMETHEUS_MULTIPROC_DIR` + +`MultiProcessCollector`); делать это заранее незачем, но связь `--workers` → +сломанные графики стоит знать до, а не после. + +Celery-воркеры своего `/metrics` не отдают: у них нет HTTP-сервера, а поднимать +его в каждом воркере ради счётчиков — отдельная конструкция со своим временем +жизни. Прогоны фоновых задач будут видны иначе — через `scrape_runs` (часть 4), +и это лучше: там уже лежит история, а не только то, что происходит прямо сейчас. + +## Алерты + +Пока выключены профилем. Канал доставки — открытый вопрос #3078: тот же чат, что +у вебхука GlitchTip, или отдельный, и при каком пороге будить ночью. Стек метрик +работает и без них, но **при срабатывании правила никто не будет уведомлён** — +деплой пишет об этом предупреждением, чтобы это не стало сюрпризом. + +Включение: задать `METRICS_TELEGRAM_BOT_TOKEN` и `METRICS_TELEGRAM_CHAT_ID` в +окружении инфраструктурного хоста и перезапустить деплой. Профиль `alerts` +включится сам. + +Alertmanager шлёт в Telegram **напрямую с Beget**, а не через бэкенд МЕРЫ, хотя +рабочий путь доставки там уже есть. Причина простая: сообщение о том, что лёг +Poincare, не должно идти через сервис на Poincare. + +Отдельно живёт правило `Watchdog` — оно горит всегда и раз в 12 часов +подтверждает, что цепочка правило → Alertmanager → Telegram → человек цела. +Существует ради того, чтобы его отсутствие было заметно: молчащий канал — самый +частый способ узнать об аварии последним. В проекте МЕРЫ наружу не ушло ни одного +сообщения с 30 мая, и выяснилось это случайно (#2673). + +## Известные грабли + +- **Alloy запущен от root.** Штатный пользователь `alloy` требует членства в + группах `adm` и `systemd-journal`; внутри контейнера этих групп с нужными gid + нет, и агент молча читает ноль записей журнала. Отказ выглядит как «логов + просто нет». +- **Путь к журналу задан явно** (`/var/log/journal`). Без него libsystemd + применяет `SD_JOURNAL_LOCAL_ONLY`, и хостовый журнал из контейнера не виден — + тоже без ошибки. +- **`retention_period` у Loki не работает без `retention_enabled` у компактора.** + Loki примет конфиг и будет копить вечно. +- **Ретенция Prometheus задана и по времени, и по размеру.** Только по времени — + значит, объём в байтах зависит от числа рядов, которое растёт само. +- **Caddy проверяется до перезагрузки.** На Beget тот же Caddy обслуживает + `git.`, `errors.` и `obsidian.`; ошибка в `infra.caddy` положила бы их все, + включая Forgejo, из которого идёт деплой. + +## Что ещё не сделано + +- экспортер поверх `scrape_runs`: success_ratio, свежесть приёмника, утилизация, + счётчик `cancelled` (часть 4) +- алерты и синтетический heartbeat (часть 5) +- `pg_stat_statements` для кластера Птицы — требует рестарта прод-БД, отдельно +- честные `started_at` / `finished_at` у прогонов (#2702) — предусловие для + графиков пропускной способности: пока start/finish врут, врут и графики diff --git a/frontend/src/app/admin/leads/page.tsx b/frontend/src/app/admin/leads/page.tsx index 7c7e96c6..db159983 100644 --- a/frontend/src/app/admin/leads/page.tsx +++ b/frontend/src/app/admin/leads/page.tsx @@ -62,8 +62,8 @@ interface LeadsStats { converted_window: number; conv_pct_window: number | null; sources_total: number; - revenue_total: number | null; - deals_total: number; + revenue_window: number | null; + deals_window: number; window_months: number; } @@ -344,9 +344,9 @@ export default function AdminLeadsPage() { } /> diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx index c42d70fa..620bd97d 100644 --- a/frontend/src/components/site-finder/BestLayoutsBlock.tsx +++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx @@ -348,7 +348,9 @@ function TopLayoutsTable({ fontVariantNumeric: "tabular-nums", }} > - {row.avg_area_m2.toFixed(1)} + {row.avg_area_m2 != null + ? row.avg_area_m2.toFixed(1) + : "—"} - {/* No dump */} - {!data.dump_available && ( -
- Дамп квартала не загружен — 0 точек подключения -
- )} + {/* No dump */} + {!data.dump_available && ( +
+ Дамп квартала не загружен — 0 точек подключения +
+ )} - {/* Empty state */} - {data.dump_available && totalCount === 0 && ( -
- 0 точек подключения в этом квартале -
- )} + {/* Empty state */} + {data.dump_available && totalCount === 0 && ( +
+ 0 точек подключения в этом квартале +
+ )} - {/* Toggle-all */} - {totalCount > 0 && ( - - )} - - {/* Per-category */} -
- {CP_ALL_CATEGORIES.map((cat) => { - const structs = grouped.get(cat) ?? []; - const style = CP_CATEGORY_STYLES[cat]; - if (structs.length === 0) return null; - const active = visibleCategories.has(cat); - return ( + {/* Toggle-all */} + {totalCount > 0 && ( - ); - })} -
+ )} - {/* Summary */} - {data.dump_available && totalCount > 0 && ( -
- {data.summary.nearest_structure_distance_m !== null && ( - + {CP_ALL_CATEGORIES.map((cat) => { + const structs = grouped.get(cat) ?? []; + const style = CP_CATEGORY_STYLES[cat]; + if (structs.length === 0) return null; + const active = visibleCategories.has(cat); + return ( + + ); + })} +
+ + {/* Summary */} + {data.dump_available && totalCount > 0 && ( +
- Ближайший:{" "} - {Math.round(data.summary.nearest_structure_distance_m)} м - + {data.summary.nearest_structure_distance_m !== null && ( + + Ближайший:{" "} + {Math.round(data.summary.nearest_structure_distance_m)} м + + )} + {data.summary.in_protection_zone && ( + + В охранной зоне + + )} + {data.summary.protection_zones_intersecting > 0 && + !data.summary.in_protection_zone && ( + + Охранных зон:{" "} + {data.summary.protection_zones_intersecting} + + )} +
)} - {data.summary.in_protection_zone && ( - - В охранной зоне - - )} - {data.summary.protection_zones_intersecting > 0 && - !data.summary.in_protection_zone && ( - - Охранных зон: {data.summary.protection_zones_intersecting} - - )} - - )} )} @@ -308,7 +315,9 @@ export function CpLayerControlPanel({ }} /> {ml.label} - {ml.count} + + {ml.count ?? "н/д"} + ); })} diff --git a/frontend/src/components/site-finder/EnvironmentTab.tsx b/frontend/src/components/site-finder/EnvironmentTab.tsx index 4d4a90fa..7b6228d5 100644 --- a/frontend/src/components/site-finder/EnvironmentTab.tsx +++ b/frontend/src/components/site-finder/EnvironmentTab.tsx @@ -361,16 +361,23 @@ function WeatherBlock({
Осадки
-
- {weather.precipitation_total_mm} мм - - {" "} - (за {weather.forecast_days} дн.) - -
-
- {weather.precipitation_days} дн. с осадками -
+ {weather.precipitation_total_mm != null ? ( + <> +
+ {weather.precipitation_total_mm} мм + + {" "} + (за {weather.forecast_days} дн.) + +
+
+ {weather.precipitation_days} дн. с осадками +
+ + ) : ( + /* #2464: «0 мм» здесь читалось бы как «сухо». Данных нет — так и пишем. */ +
нет данных
+ )}
{weather.uv_index_max != null && ( diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index 27cae5de..fd20992f 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -107,7 +107,10 @@ export function LandTab({ data }: Props) { Природные риски (НСПД) - + )} diff --git a/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx b/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx index 2c45c1bf..0dfdc058 100644 --- a/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx +++ b/frontend/src/components/site-finder/NspdRiskZonesBlock.tsx @@ -5,6 +5,15 @@ import type { RiskZone } from "@/types/nspd"; interface Props { riskZones: RiskZone[] | null | undefined; parcelAreaSqm?: number | null; + /** + * #2934: сколько объектов риск-слоёв в дампе квартала. Пустой `riskZones` без + * этого признака неоднозначен, и блок рисовал по нему зелёное «Риски не + * обнаружены» — то есть утверждал результат проверки, которой не было. + * + * `undefined`/`null` — дампа нет; `0` — слой не дал объектов на весь квартал; + * `>0` — слой отработал, и отсутствие пересечений с участком уже настоящий факт. + */ + quarterRisksCount?: number | null; } // Severity mapping: layer key suffix → severity tier @@ -45,10 +54,52 @@ function formatArea(sqm: number | null): string | null { return `${Math.round(sqm).toLocaleString("ru-RU")} м²`; } -export function NspdRiskZonesBlock({ riskZones, parcelAreaSqm }: Props) { +export function NspdRiskZonesBlock({ + riskZones, + parcelAreaSqm, + quarterRisksCount, +}: Props) { const zones = riskZones ?? []; if (zones.length === 0) { + // Зелёный success-стиль оставляем ТОЛЬКО когда слой реально что-то отдал по + // кварталу: тогда «на участке ничего не пересекается» — измеренный факт. + // Замер 19.08: risks_count = 0 у всех 669 дампов, слои risk_* не вернули ни + // одного объекта, — то есть сегодня показывается нейтральная ветка. + const layerAnswered = (quarterRisksCount ?? 0) > 0; + if (!layerAnswered) { + return ( +
+ + Не проверено + + + Слои природного риска НСПД не вернули объектов по этому кварталу — + отсутствие риска не подтверждено + +
+ ); + } + return (
- {stats.total_precip_mm} мм осадков + {/* #2464: null — нормали по осадкам не пришли, это не «ноль миллиметров». */} + {stats.total_precip_mm != null + ? `${stats.total_precip_mm} мм осадков` + : "осадки — нет данных"}
); diff --git a/frontend/src/components/site-finder/SiteMap.tsx b/frontend/src/components/site-finder/SiteMap.tsx index e2943592..ff2c9e45 100644 --- a/frontend/src/components/site-finder/SiteMap.tsx +++ b/frontend/src/components/site-finder/SiteMap.tsx @@ -949,7 +949,12 @@ export function SiteMap({ key: "risk", label: "Зоны риска", color: MARKET_COLORS.risk, - count: riskZoneList.length, + // #2934: «0» в одном ряду с измеренными «Конкуренты 14» и + // «Красные линии 2» читается как измеренный ноль. Слои + // природного риска НСПД не вернули ни одного объекта на 669 + // дампов (замер 19.08), поэтому при пустом слое показываем + // «н/д», а не число. + count: riskZoneList.length > 0 ? riskZoneList.length : null, }, { key: "opportunity", diff --git a/frontend/src/components/site-finder/__tests__/NspdRiskZonesBlock.test.tsx b/frontend/src/components/site-finder/__tests__/NspdRiskZonesBlock.test.tsx new file mode 100644 index 00000000..9f4bfd14 --- /dev/null +++ b/frontend/src/components/site-finder/__tests__/NspdRiskZonesBlock.test.tsx @@ -0,0 +1,53 @@ +/** + * #2934: блок природных рисков не должен утверждать «рисков нет», когда слой + * ничего не отдал. + * + * Бэкенд ВСЕГДА присылает `nspd_risk_zones` (пустым списком), поэтому условие + * показа в LandTab (`!== undefined`) истинно всегда, а ветка `zones.length === 0` + * рисовала зелёную плашку «Риски не обнаружены» с текстом «Риск-зоны НСПД на + * участке не выявлены». + * + * Замер на проде 19.08.2026: risks_count = 0 у всех 669 дампов, слои risk_* НСПД + * не вернули ни одного объекта — то есть эта плашка показывалась всегда и всегда + * без основания. + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { NspdRiskZonesBlock } from "../NspdRiskZonesBlock"; +import type { RiskZone } from "@/types/nspd"; + +const ZONE: RiskZone = { + layer: "risk_flooding", + subtype: "затопление", + geom_wkt: null, + intersection_area_sqm: 1234, +} as unknown as RiskZone; + +describe("NspdRiskZonesBlock (#2934)", () => { + it("слой не дал объектов по кварталу → «не проверено», а не «рисков нет»", () => { + render(); + + expect(screen.getByText("Не проверено")).toBeInTheDocument(); + expect(screen.queryByText("Риски не обнаружены")).not.toBeInTheDocument(); + }); + + it("дампа нет → тоже «не проверено» (признак null)", () => { + render(); + + expect(screen.getByText("Не проверено")).toBeInTheDocument(); + }); + + it("слой отработал по кварталу, но участок не пересекает → «рисков не обнаружено» законно", () => { + render(); + + expect(screen.getByText("Риски не обнаружены")).toBeInTheDocument(); + }); + + it("контроль: найденные зоны рисуются как прежде", () => { + render(); + + expect(screen.queryByText("Не проверено")).not.toBeInTheDocument(); + expect(screen.queryByText("Риски не обнаружены")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx b/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx index bfaa31e5..d9899583 100644 --- a/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx +++ b/frontend/src/components/site-finder/analysis/Section5Atmosphere.tsx @@ -356,8 +356,9 @@ function WeatherWindBlock({ }: { wind: ParcelAnalysisWind; uvMax: number | null | undefined; - precipMm: number; - precipDays: number; + // #2464: null — ряда осадков не было. «0 мм» читалось бы как «сухо». + precipMm: number | null; + precipDays: number | null; forecastDays: number; source: string; }) { @@ -397,7 +398,9 @@ function WeatherWindBlock({ style={{ display: "inline", marginRight: 4, verticalAlign: "middle" }} aria-hidden /> - {precipMm} мм · {precipDays} дн. с осадками за {forecastDays} дн. + {precipMm != null + ? `${precipMm} мм · ${precipDays} дн. с осадками за ${forecastDays} дн.` + : "осадки — нет данных"} {uvMax != null && (
diff --git a/frontend/src/components/site-finder/ptica/InvestScoreBlock.tsx b/frontend/src/components/site-finder/ptica/InvestScoreBlock.tsx index 5824e9ff..45c0e6b7 100644 --- a/frontend/src/components/site-finder/ptica/InvestScoreBlock.tsx +++ b/frontend/src/components/site-finder/ptica/InvestScoreBlock.tsx @@ -80,11 +80,14 @@ export function InvestScoreBlock({
Риск - + {inv.risk.value} + {/* #2934: «предв.» было только в атрибуте title — на экране его не + видно, а на печати и подавно. Пометка о том, что оценка + предварительная, должна читаться там же, где значение. */} + {inv.risk.caption && ( + {inv.risk.caption} + )}
diff --git a/frontend/src/components/site-finder/ptica/__tests__/InvestScoreBlock.risk.test.tsx b/frontend/src/components/site-finder/ptica/__tests__/InvestScoreBlock.risk.test.tsx new file mode 100644 index 00000000..d7c87468 --- /dev/null +++ b/frontend/src/components/site-finder/ptica/__tests__/InvestScoreBlock.risk.test.tsx @@ -0,0 +1,50 @@ +/** + * #2934: пометка «предв.» у строки «Риск» должна читаться на экране. + * + * Значение берётся из adaptRiskGauge = `100 - buildability`, то есть обратной + * застраиваемости. К природным опасностям (затопление, оползни, карст) оно + * отношения не имеет, а источники природного риска пусты: cad_risk_zones 0 строк, + * слои risk_* НСПД 0 объектов на 669 дампов (замер 19.08.2026). + * + * До правки «предв.» лежало ТОЛЬКО в атрибуте title — на экране невидимо, при + * печати тем более, а значение при этом красилось зелёным. + */ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { InvestScoreBlock } from "../InvestScoreBlock"; +import analyzeFixture from "@/lib/mocks/parcel-analyze.json"; +import type { ParcelAnalysis } from "@/types/site-finder"; + +/** Участок с gate_verdict — иначе застраиваемость null и строка «Риск» пуста. */ +function analysisWithGate(): ParcelAnalysis { + return { + ...(analyzeFixture as unknown as ParcelAnalysis), + gate_verdict: { + can_build_mkd: true, + verdict_label: "Можно", + blockers: [], + warnings: [], + checks_performed: [], + source: "nspd_dump", + }, + } as unknown as ParcelAnalysis; +} + +describe("InvestScoreBlock — строка «Риск» (#2934)", () => { + it("показывает пометку «предв.» видимым текстом", () => { + render( + {}} + />, + ); + + // Предусловие: строка «Риск» вообще отрисована со значением. + expect(screen.getByText("Риск")).toBeInTheDocument(); + expect(screen.getByText("Низкий")).toBeInTheDocument(); + + // На origin/main «предв.» живёт только в атрибуте title — getByText не найдёт. + expect(screen.getByText("предв.")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts b/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts index c6c7a3cd..618f3f11 100644 --- a/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts +++ b/frontend/src/components/site-finder/ptica/__tests__/ptica-adapt.honesty.test.ts @@ -17,6 +17,8 @@ import { describe, expect, it } from "vitest"; import { adaptBuySignalGauge, adaptEconomyCard, + adaptInvestScore, + adaptRiskGauge, adaptFinanceDrawer, adaptInvestmentClearance, adaptLegalDrawer, @@ -352,3 +354,44 @@ describe("ptica-adapt gate (#1892 ЗОУИТ area-gate)", () => { expect(v.tone).toBe("good"); }); }); + +describe("ptica-adapt risk placeholder (#2934)", () => { + /** Участок, у которого застраиваемость СЧИТАЕТСЯ: без gate_verdict она null, + * и тон был бы "none" просто из-за отсутствия данных — проверка прошла бы + * вхолостую и на неисправленном коде тоже. */ + function analysisWithGate(): ParcelAnalysis { + return { + ...analysis, + gate_verdict: { + can_build_mkd: true, + verdict_label: "Можно", + blockers: [], + warnings: [], + checks_performed: [], + source: "nspd_dump", + }, + } as unknown as ParcelAnalysis; + } + + it("«Риск» не окрашивается как вердикт, пока значение не настоящее", () => { + const a = analysisWithGate(); + const gauge = adaptRiskGauge(a); + // Предусловие: значение реально посчитано, иначе тон был бы "none" по другой + // причине и проверка ничего бы не доказывала. + expect(gauge.value).not.toBeNull(); + expect(gauge.tone).toBe("good"); // именно тот случай, что красился зелёным + expect(gauge.isReal).toBe(false); + + expect(adaptInvestScore(a).riskTone).toBe("none"); + }); + + it("подпись «предв.» доезжает до поля, а не только до тултипа", () => { + const inv = adaptInvestScore(analysisWithGate()); + expect(inv.risk.caption).toBe("предв."); + expect(inv.risk.isReal).toBe(false); + }); + + it("контроль: «Потенциал» приглушён как и раньше", () => { + expect(adaptInvestScore(analysis).potentialTone).toBe("none"); + }); +}); diff --git a/frontend/src/components/site-finder/ptica/ptica-adapt.ts b/frontend/src/components/site-finder/ptica/ptica-adapt.ts index c8ac2df0..ae994023 100644 --- a/frontend/src/components/site-finder/ptica/ptica-adapt.ts +++ b/frontend/src/components/site-finder/ptica/ptica-adapt.ts @@ -353,9 +353,16 @@ export function adaptInvestScore( risk.value != null ? { value: risk.label, isReal: false, caption: "предв." } : placeholder("предв."); - // Hero «Риск» value color (prototype .v.warn): green when low, yellow otherwise. - const riskTone: PticaValueTone = - risk.value == null ? "none" : risk.tone === "good" ? "good" : "warn"; + // #2934: плейсхолдер не красим как вердикт. `adaptRiskGauge` считает риск как + // `100 - buildability`, то есть обратную застраиваемость — к природным опасностям + // (затопление, оползни, карст) он отношения не имеет, и сам помечен + // `isReal: false`. Зелёное «Низкий» читалось как «рисков нет», хотя источники + // природного риска пусты: cad_risk_zones 0 строк, слои risk_* НСПД 0 объектов на + // 669 дампов (замер 19.08). + // + // Приглушаем так же, как уже приглушён `potentialTone` — соглашение в этом же + // файле: пока значение не настоящее, цвета-вердикта у него нет. + const riskTone: PticaValueTone = "none"; const overall = report?.scoring?.overall ?? report?.exec_summary.key_numbers.overall_score; @@ -1406,9 +1413,21 @@ export function adaptEnvironmentDrawer( }, { k: "Осадки (сумма)", - field: real(`${formatInt(wt.precipitation_total_mm)} мм`), + // #2464: раньше стояло real(...) безусловно, и при отсутствии ряда осадков + // «0 мм» подавалось как ИЗМЕРЕННОЕ значение. Образец рядом — avg_min_c + // строкой выше: есть значение → real, нет → notReal. + field: + wt.precipitation_total_mm != null + ? real(`${formatInt(wt.precipitation_total_mm)} мм`) + : notReal("нет данных об осадках"), + }, + { + k: "Дней с осадками", + field: + wt.precipitation_days != null + ? real(formatInt(wt.precipitation_days)) + : notReal("нет данных об осадках"), }, - { k: "Дней с осадками", field: real(formatInt(wt.precipitation_days)) }, ] : [{ k: "Погода", field: notReal("нет данных погоды") }]; diff --git a/frontend/src/lib/api-types.ts b/frontend/src/lib/api-types.ts index 9b8febee..2f7dd5e7 100644 --- a/frontend/src/lib/api-types.ts +++ b/frontend/src/lib/api-types.ts @@ -1763,7 +1763,7 @@ export interface paths { put?: never; /** * Resume Geo Job - * @description Re-enqueue paused/failed job. Resume idempotent через pending targets. + * @description Re-enqueue задачу из НЕзавершённого состояния (paused / failed / cancelled). */ post: operations["resume_geo_job_api_v1_admin_scrape_geo_jobs__job_id__resume_post"]; delete?: never; @@ -2048,6 +2048,8 @@ export interface paths { /** * Leads Stats * @description KPI summary за последние N месяцев. + * + * Суффикс `_window` — за окно `months`, `_total` — за всё время. */ get: operations["leads_stats_api_v1_admin_leads_stats_get"]; put?: never; @@ -5483,7 +5485,7 @@ export interface components { /** Avg Price Per M2 Rub */ avg_price_per_m2_rub: number | null; /** Avg Area M2 */ - avg_area_m2: number; + avg_area_m2: number | null; /** Supply Units In Radius */ supply_units_in_radius: number; /** Sold Pct Of Supply */ diff --git a/frontend/src/types/best-layouts.ts b/frontend/src/types/best-layouts.ts index ec7fdea7..033f68a4 100644 --- a/frontend/src/types/best-layouts.ts +++ b/frontend/src/types/best-layouts.ts @@ -27,7 +27,8 @@ export interface TopLayoutRow { total_sold_in_window: number; velocity_per_month: number; avg_price_per_m2_rub: number | null; - avg_area_m2: number; + /** #2867: null — сделок за окно нет, средней площади нет (раньше отдавался 0). */ + avg_area_m2: number | null; supply_units_in_radius: number; sold_pct_of_supply: number | null; // clamped at 100.0 is_oversold: boolean; // raw ratio was >100% (incompatible time windows) diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts index dd772f6f..a1190263 100644 --- a/frontend/src/types/nspd.ts +++ b/frontend/src/types/nspd.ts @@ -65,6 +65,16 @@ export interface NspdDumpMeta { // auto-stop re-poll после ETA*1.5. null когда dump уже available или harvest // не запущен (lock уже взят другим запросом). harvest_eta_seconds?: number | null; + /** + * #2934: сколько объектов риск-слоёв лежит в дампе КВАРТАЛА. + * + * Отличает «слой опрошен, на участке чисто» (>0) от «слой не дал ничего вообще» + * (0). Пустой `nspd_risk_zones` сам по себе этих случаев не различает, а UI + * рисовал по нему зелёное «Риски не обнаружены». + * + * `null` — дампа нет, про риск-слои не известно ничего. + */ + risks_count?: number | null; } // Risk zones (issue #94 TIER 3) — from /analyze nspd_risk_zones field diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 758195c8..cd2f9e2c 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -95,8 +95,9 @@ export interface ParcelAnalysisWeather { avg_max_c: number | null; avg_min_c: number | null; }; - precipitation_total_mm: number; - precipitation_days: number; + /** #2464: null — open-meteo не отдал ряд осадков. Ноль означал бы «сухо». */ + precipitation_total_mm: number | null; + precipitation_days: number | null; uv_index_max: number | null; wind: ParcelAnalysisWind; source: string; @@ -120,7 +121,8 @@ export interface SeasonStats { max_t_c: number; min_t_c: number; avg_precip_per_day_mm: number; - total_precip_mm: number; + /** #2464: null — данных по осадкам нет, это НЕ «ноль миллиметров». */ + total_precip_mm: number | null; days_observed: number; } diff --git a/ops/backup-couchdb.sh b/ops/backup-couchdb.sh new file mode 100755 index 00000000..3530936d --- /dev/null +++ b/ops/backup-couchdb.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# Backup CouchDB (волт Obsidian, LiveSync) в S3. +# +# ЗАЧЕМ. До этого скрипта у волта не было автоматического бэкапа ВООБЩЕ: +# ops/backup.sh закрывает Site Finder, backup-tradein-db.sh — МЕРУ, +# backup-forgejo.sh — Forgejo, а CouchDB не закрывал никто. При этом в волте +# лежит вся накопленная база знаний проекта — решения, разборы инцидентов, +# ограничения, — и восстановить её из кода нельзя. Единственной копией был +# ручной месячный прогон на машине владельца. +# +# ПОЧЕМУ КОНТЕЙНЕР ОСТАНАВЛИВАЕТСЯ. Файлы CouchDB пишутся append-only, но +# снимок работающего узла может застать запись между обновлением данных и +# обновлением заголовка базы, а .shards/ и _dbs.couch — рассогласованными между +# собой. Документация CouchDB прямо говорит: file-level копия делается на +# остановленном узле. Поэтому здесь stop -> tar -> start, а не tar на живом. +# Простой — секунды (данных ~85 МБ), окно ночное, и LiveSync к обрывам связи +# устойчив по своей природе: клиенты досинхронизируются сами. +# +# ГАРАНТИЯ ПОДЪЁМА. Остановленный и не поднятый обратно CouchDB — отказ хуже +# отсутствия бэкапа, поэтому старт висит на trap EXIT: он отработает при любом +# выходе, включая ошибку tar, нехватку места и прерывание с клавиатуры. +# Отдельно проверяется, что контейнер после старта действительно отвечает; +# если нет — notify(). +# +# S3 ОБЯЗАТЕЛЕН. Как в backup-forgejo.sh и (после #3085) в backup.sh: без ключей +# скрипт падает, а не делает молча локальную копию. Смысл бэкапа — копия ВНЕ +# машины; локальная копия рядом с оригиналом не переживает потерю хоста. +# +# ЧТО КЛАДЁТСЯ. Два тома целиком: данные (базы, шарды, индексы) и конфиг +# (local.d — админ, порты, CORS). Конфиг без данных бесполезен, данные без +# конфига поднимаются, но требуют повторного bootstrap — держим оба. +# +# ВОССТАНОВЛЕНИЕ. Том обязан быть ПУСТ перед распаковкой: если развернуть +# снимок поверх живого каталога, останутся файлы, которых в снимке уже нет, и +# CouchDB не соберёт шарды. Порядок — остановить контейнер, очистить том, +# распаковать в него архив, повторить для тома конфига, поднять контейнер. +# Проверять восстановимость надо на ОТДЕЛЬНОМ томе, а не на боевом. +# +# Конфиг: /etc/default/gendesign-backup-couchdb +# (см. ops/gendesign-backup-couchdb.default.example) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./lib-backup.sh +source "$SCRIPT_DIR/lib-backup.sh" + +# --- config (env-overridable) --- +COUCHDB_CONTAINER="${COUCHDB_CONTAINER:-gendesign-couchdb}" +COUCHDB_DATA_VOLUME="${COUCHDB_DATA_VOLUME:-gendesign-obsidian_couchdb_data}" +COUCHDB_CONFIG_VOLUME="${COUCHDB_CONFIG_VOLUME:-gendesign-obsidian_couchdb_config}" + +LOCAL_BACKUP_DIR="${LOCAL_BACKUP_DIR:-/opt/gendesign/backups/couchdb}" +KEEP="${KEEP:-7}" + +# Порог — про ОБЪЁМ, а не про «нашлись ли файлы»: пустой tar.gz весит около +# сотни байт, и без порога такой «успешный» бэкап неотличим от настоящего. +MIN_DATA_BYTES="${MIN_DATA_BYTES:-1048576}" # 1 МБ: волт заведомо больше +MIN_CONFIG_BYTES="${MIN_CONFIG_BYTES:-256}" + +SENTINEL_FILE="${SENTINEL_FILE:-${LOCAL_BACKUP_DIR}/.last_success}" +START_TIMEOUT="${START_TIMEOUT:-60}" + +COUCHDB_BACKUP_ENV_FILE="${COUCHDB_BACKUP_ENV_FILE:-/etc/default/gendesign-backup-couchdb}" +# shellcheck source=/dev/null +[[ -f "$COUCHDB_BACKUP_ENV_FILE" ]] && source "$COUCHDB_BACKUP_ENV_FILE" + +COUCHDB_S3_ENDPOINT="${COUCHDB_S3_ENDPOINT:-}" +COUCHDB_S3_BUCKET="${COUCHDB_S3_BUCKET:-}" +COUCHDB_S3_PREFIX="${COUCHDB_S3_PREFIX:-couchdb/}" +COUCHDB_S3_ACCESS_KEY="${COUCHDB_S3_ACCESS_KEY:-}" +COUCHDB_S3_SECRET_KEY="${COUCHDB_S3_SECRET_KEY:-}" + +# Фолбэк на конфиг основного бэкапа. Отдельный ключ для волта — правильнее +# (узкая область, как у forgejo), но требовать его как условие запуска значит +# оставить волт вообще без бэкапа до того дня, когда до ключа дойдут руки. +# Поэтому: заданы COUCHDB_S3_* — берём их; не заданы — берём S3_* оттуда, где +# они уже настроены и проверены. Префикс при этом всё равно свой (couchdb/), +# так что чужие серии в бакете не перемешиваются. +MAIN_BACKUP_ENV_FILE="${MAIN_BACKUP_ENV_FILE:-/etc/default/gendesign-backup}" +if [[ -z "$COUCHDB_S3_ENDPOINT" && -z "$COUCHDB_S3_ACCESS_KEY" && -f "$MAIN_BACKUP_ENV_FILE" ]]; then + # source В ПОДОБОЛОЧКЕ, наружу выносим ровно четыре значения. + # Прямой `source` здесь был бы тихой диверсией: конфиг основного бэкапа + # задаёт свои LOCAL_BACKUP_DIR, KEEP и SENTINEL_FILE, и они затёрли бы уже + # вычисленные выше значения для волта. Архивы легли бы в чужой каталог, а + # write_sentinel в конце обновил бы сентинел ОСНОВНОГО бэкапа — его сторож + # свежести зеленел бы по чужому прогону, то есть пропущенный дамп базы + # перестал бы замечаться вовсе. + eval "$( + # shellcheck source=/dev/null + source "$MAIN_BACKUP_ENV_FILE" >/dev/null 2>&1 || true + printf 'COUCHDB_S3_ENDPOINT=%q\n' "${S3_ENDPOINT:-}" + printf 'COUCHDB_S3_BUCKET=%q\n' "${S3_BUCKET:-}" + printf 'COUCHDB_S3_ACCESS_KEY=%q\n' "${S3_ACCESS_KEY:-}" + printf 'COUCHDB_S3_SECRET_KEY=%q\n' "${S3_SECRET_KEY:-}" + )" + [[ -n "$COUCHDB_S3_ACCESS_KEY" ]] && \ + log "S3: отдельный ключ для волта не задан — беру настройки из ${MAIN_BACKUP_ENV_FILE}, префикс ${COUCHDB_S3_PREFIX}" +fi + +# --- guard 0: S3 обязателен, и проверяется ДО всякой остановки контейнера --- +# Порядок важен: незачем ронять волт ради бэкапа, которому некуда уехать. +if [[ -z "$COUCHDB_S3_ENDPOINT" || -z "$COUCHDB_S3_BUCKET" || -z "$COUCHDB_S3_ACCESS_KEY" || -z "$COUCHDB_S3_SECRET_KEY" ]]; then + log "ERROR: бэкап CouchDB НЕ НАСТРОЕН." >&2 + log "ERROR: не заданы COUCHDB_S3_ENDPOINT / COUCHDB_S3_BUCKET / COUCHDB_S3_ACCESS_KEY / COUCHDB_S3_SECRET_KEY." >&2 + log "ERROR: заполни ${COUCHDB_BACKUP_ENV_FILE} (образец — ops/gendesign-backup-couchdb.default.example)" >&2 + log "ERROR: либо настрой S3_* в ${MAIN_BACKUP_ENV_FILE:-/etc/default/gendesign-backup} — оттуда берётся фолбэк." >&2 + log "ERROR: локальная копия рядом с оригиналом смысла не имеет — не делаю её и контейнер не трогаю." >&2 + exit 1 +fi + +# --- guard 1: контейнер и оба тома существуют --- +if ! docker inspect "$COUCHDB_CONTAINER" >/dev/null 2>&1; then + log "ERROR: контейнер ${COUCHDB_CONTAINER} не найден — переопредели COUCHDB_CONTAINER." >&2 + exit 1 +fi +for v in "$COUCHDB_DATA_VOLUME" "$COUCHDB_CONFIG_VOLUME"; do + if ! docker volume inspect "$v" >/dev/null 2>&1; then + log "ERROR: том ${v} не найден — переопредели COUCHDB_DATA_VOLUME / COUCHDB_CONFIG_VOLUME." >&2 + exit 1 + fi +done + +mkdir -p "$LOCAL_BACKUP_DIR" +TS="$(date -u +%Y%m%d_%H%M%S)" +data_out="${LOCAL_BACKUP_DIR}/couchdb-data_${TS}.tar.gz" +config_out="${LOCAL_BACKUP_DIR}/couchdb-config_${TS}.tar.gz" + +# --- гарантия подъёма: старт висит на EXIT, а не на успешном пути --- +COUCHDB_WAS_STOPPED=0 +restart_couchdb() { + local rc=$? + if [[ "$COUCHDB_WAS_STOPPED" == "1" ]]; then + log "Поднимаю ${COUCHDB_CONTAINER} обратно." + if ! docker start "$COUCHDB_CONTAINER" >/dev/null 2>&1; then + log "CRITICAL: не удалось запустить ${COUCHDB_CONTAINER} — волт лежит." >&2 + notify "CouchDB backup: контейнер ${COUCHDB_CONTAINER} ОСТАНОВЛЕН и не поднялся. Волт недоступен, нужно вмешательство." + return "$rc" + fi + local up=0 + for _ in $(seq 1 "$START_TIMEOUT"); do + if docker exec "$COUCHDB_CONTAINER" curl -fsS http://localhost:5984/_up >/dev/null 2>&1; then + up=1 + break + fi + sleep 1 + done + if [[ "$up" == "1" ]]; then + log "CouchDB отвечает." + else + log "CRITICAL: ${COUCHDB_CONTAINER} запущен, но /_up молчит ${START_TIMEOUT}с." >&2 + notify "CouchDB backup: контейнер поднят, но /_up не отвечает ${START_TIMEOUT}с. Проверь волт." + fi + fi + return "$rc" +} +trap restart_couchdb EXIT + +log "Останавливаю ${COUCHDB_CONTAINER} для снятия консистентного снимка." +docker stop "$COUCHDB_CONTAINER" >/dev/null +COUCHDB_WAS_STOPPED=1 + +# tar идёт в одноразовом контейнере: том примонтирован read-only, писать он +# может только в каталог бэкапов. +# +# Про владельца. Читать файлы тома надо от root (внутри они принадлежат uid +# CouchDB), поэтому контейнер запускается от root — и архив наружу выходит +# root:root, в отличие от остальных серий в этом каталоге. Само по себе это +# ротацию не ломает (удаление зависит от прав КАТАЛОГА, а он наш), но ставит +# каталог в зависимость от того, что никто не включит на нём sticky-бит, и +# ломает ручную работу с файлами из-под непривилегированного пользователя. +# Поэтому chown делаем тут же, внутри контейнера, где root ещё есть. +HOST_UID="$(id -u)" +HOST_GID="$(id -g)" +tar_volume() { + local volume="$1" out="$2" name + name="$(basename "$out")" + docker run --rm \ + -v "${volume}":/src:ro \ + -v "${LOCAL_BACKUP_DIR}":/out \ + alpine:3 \ + sh -c "tar czf /out/${name} -C /src . && chown ${HOST_UID}:${HOST_GID} /out/${name}" +} + +log "Снимок данных: ${COUCHDB_DATA_VOLUME}" +tar_volume "$COUCHDB_DATA_VOLUME" "$data_out" +log "Снимок конфига: ${COUCHDB_CONFIG_VOLUME}" +tar_volume "$COUCHDB_CONFIG_VOLUME" "$config_out" + +# Контейнер поднимается СРАЗУ после копирования, до проверок и выгрузки: +# держать волт лежачим на время верификации архивов и заливки в S3 незачем. +log "Поднимаю ${COUCHDB_CONTAINER} — копирование закончено." +docker start "$COUCHDB_CONTAINER" >/dev/null +COUCHDB_WAS_STOPPED=0 +for _ in $(seq 1 "$START_TIMEOUT"); do + docker exec "$COUCHDB_CONTAINER" curl -fsS http://localhost:5984/_up >/dev/null 2>&1 && break + sleep 1 +done + +# --- проверки целостности --- +check_archive() { + local f="$1" floor="$2" what="$3" + local bytes + bytes=$(stat -c %s "$f" 2>/dev/null || echo 0) + if (( bytes < floor )); then + log "ERROR: ${what} — всего ${bytes} байт (< ${floor}), снимок пустой или битый." >&2 + notify "CouchDB backup: ${what} — ${bytes} байт, ниже порога ${floor}. Бэкап НЕ засчитан." + exit 1 + fi + if ! tar -tzf "$f" >/dev/null 2>&1; then + log "ERROR: ${what} не читается как tar.gz." >&2 + notify "CouchDB backup: архив ${what} повреждён, tar -tzf не прошёл. Бэкап НЕ засчитан." + exit 1 + fi + log "${what} OK: $(basename "$f") (${bytes} байт, файлов: $(tar -tzf "$f" | wc -l))" +} +check_archive "$data_out" "$MIN_DATA_BYTES" "снимок данных" +check_archive "$config_out" "$MIN_CONFIG_BYTES" "снимок конфига" + +# --- выгрузка в S3 (обязательна — см. guard 0) --- +for f in "$data_out" "$config_out"; do + key="${COUCHDB_S3_PREFIX%/}/$(basename "$f")" + log "Выгружаю в s3://${COUCHDB_S3_BUCKET}/${key}" + # AWS_CA_BUNDLE — та же поправка, что в backup.sh и backup-forgejo.sh: + # у aws-cli v2 свой вшитый набор корней, и корня Selectel в нём нет. + docker run --rm \ + -e AWS_ACCESS_KEY_ID="$COUCHDB_S3_ACCESS_KEY" \ + -e AWS_SECRET_ACCESS_KEY="$COUCHDB_S3_SECRET_KEY" \ + -e AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \ + -v "$LOCAL_BACKUP_DIR":/backup:ro \ + amazon/aws-cli:latest \ + --endpoint-url "$COUCHDB_S3_ENDPOINT" \ + s3 cp --no-progress "/backup/$(basename "$f")" "s3://${COUCHDB_S3_BUCKET}/${key}" +done +log "Выгрузка в S3 прошла." + +# --- локальная ротация --- +for pattern in "couchdb-data_" "couchdb-config_"; do + # shellcheck disable=SC2012 + ls -1t "${LOCAL_BACKUP_DIR}/${pattern}"*.tar.gz 2>/dev/null \ + | tail -n +"$((KEEP + 1))" \ + | xargs -r rm -f +done + +log "Бэкап CouchDB готов. Локальных копий оставлено: до ${KEEP} на каждый том." + +# Сентинел пишется ТОЛЬКО после выгрузки: сторож свежести обязан зеленеть по +# факту копии вне машины, а не по факту того, что скрипт дошёл до конца. +write_sentinel "$SENTINEL_FILE" diff --git a/ops/backup-forgejo.sh b/ops/backup-forgejo.sh new file mode 100755 index 00000000..ec05661c --- /dev/null +++ b/ops/backup-forgejo.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash +# Backup for self-hosted Forgejo (git.gendsgn.ru) — repos + DB, off-box to S3 +# under its OWN, more-restricted key (#2203). +# +# Forgejo is deployed OUTSIDE this repo's checkout, per +# infra/Forgejo_Migration_BotServer_To_Beget_2026-05-16.md (vault): +# - compose dir on the VM: /home/gendesign/forgejo/ +# - repo data: /home/gendesign/forgejo/data/forgejo/git/repositories/ +# - DB: NOT a dedicated container — a separate `forgejo` user+database +# inside a SHARED postgres container. Снимается всегда одинаково, +# `pg_dump -U forgejo forgejo` через `docker exec`; меняется только то, +# В КАКОМ контейнере эта база лежит, и вот это уже не константа (см. ниже). +# +# ДВА ДОМА У БАЗЫ forgejo (#3061). Переезд продукта Beget → Selectel, окно +# 30.08.2026: контейнер `gendesign-postgres-1` уезжает целиком вместе с томом +# postgres_data, а Forgejo остаётся на Beget. Отсюда две фазы: +# ДО переезда — база forgejo живёт в общем кластере основного приложения, +# контейнер `gendesign-postgres-1`; +# ПОСЛЕ — в лёгком кластере остающейся инфраструктуры, контейнер +# `gendesign-infra-postgres` (сервис infra-postgres в +# docker-compose.prod.yml). +# +# ПОЧЕМУ ИМЯ КОНТЕЙНЕРА НЕ ЗАШИТО КОНСТАНТОЙ. Раньше здесь стояло +# `PG_CONTAINER="${PG_CONTAINER:-gendesign-postgres-1}"`. Беда в том, что после +# переноса старый контейнер не исчезает мгновенно — он ещё какое-то время жив и +# хранит СВОЮ копию базы forgejo, которая с момента переноса протухает с каждым +# коммитом и каждым PR. Константа означала бы, что бэкап продолжает исправно +# дампить именно её. И ни одна проверка ниже этого не поймала бы: дамп непустой, +# маркер "-- PostgreSQL database dump complete" на месте, размер заведомо выше +# MIN_DB_DUMP_BYTES — все пороги про ОБЪЁМ и ЦЕЛОСТНОСТЬ, ни один не про +# СВЕЖЕСТЬ. На выходе — здоровый с виду бэкап, который бэкапом не является, и +# узнаём мы об этом ровно в момент восстановления. Поэтому контейнер выбирается +# по ФАКТУ наличия ДАННЫХ среди запущенных (guard 2 ниже), а неоднозначность — +# база нашлась сразу в двух — это не повод угадать, а повод остановиться. +# +# Two artifacts per run: +# 1. DB dump — `pg_dump --no-owner --clean --if-exists` (repos/issues/PRs/ +# users/settings/Actions config — everything except the git data itself). +# 2. Repo bundle — a tar.gz of the bare-repo tree under FORGEJO_REPOS_DIR. +# This is functionally a "bundle of repositories": bare repos already +# ARE just refs+objects, byte-identical to what `git bundle` would +# capture, and tar-ing the whole tree in one shot is far more robust +# than enumerating individual repos (new repos need zero script changes, +# nothing to keep in sync). LFS objects/attachments/avatars under +# FORGEJO_DATA_DIR are OUT of scope for this pass — repo code + full DB +# (which has LFS pointers, not the blobs) covers the "backup the code" +# ask; note this gap in the PR if larger asset backup is wanted later. +# +# Off-box S3 upload is MANDATORY here, not optional like ops/backup.sh — +# the whole point of this script is getting Forgejo's code off the VM it +# lives on, so with no S3 creds configured this refuses to run rather than +# silently producing a local-only "backup" that doesn't meet that bar. +# +# The S3 key here is a SEPARATE, narrower-scoped service user than the one +# ops/backup.sh and backup-tradein-db.sh use (`gendsgn-backup-writer`, which +# can write anywhere in the bucket root) — this one can ONLY PutObject under +# s3://gendsgn-backups/forgejo/*, nothing else, so a compromised Forgejo host +# can't touch the main/tradein DB backups sitting in the same bucket. See the +# PR description for the exact bucket policy JSON (mirrors the existing +# gendsgn-backup-writer policy pattern — vault meta/00_credentials.md +# "Selectel S3 — бэкап-бакет gendsgn-backups"). +# +# THE KEY DOES NOT EXIST YET (as of #2203) — a human needs to create the +# service user + policy in the Selectel panel first. Until then this script +# is expected to fail loudly and exit non-zero; that is the correct, +# intentional behaviour, not a bug. +# +# Missed-run detection (#2203): same sentinel/staleness-check pattern as +# ops/backup.sh — see ops/check-backup-staleness.sh for the alerting side. +# +# Usage (cron — `bash `, not a bare path, same "+x can go missing on a +# raw deploy reset" reasoning as the other two backup scripts, #71): +# 15 4 * * * bash /opt/gendesign/ops/backup-forgejo.sh >> /var/log/gendesign-backup-forgejo.log 2>&1 +# +# Config file (root-owned, chmod 600, NOT in git) — see +# ops/gendesign-backup-forgejo.default.example for the full list: +# FORGEJO_S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru +# FORGEJO_S3_BUCKET=gendsgn-backups +# FORGEJO_S3_PREFIX=forgejo/ +# FORGEJO_S3_ACCESS_KEY=... +# FORGEJO_S3_SECRET_KEY=... + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./lib-backup.sh +source "$SCRIPT_DIR/lib-backup.sh" + +# --- config (env-overridable) --- +FORGEJO_DIR="${FORGEJO_DIR:-/home/gendesign/forgejo}" +FORGEJO_DATA_DIR="${FORGEJO_DATA_DIR:-${FORGEJO_DIR}/data/forgejo}" +FORGEJO_REPOS_DIR="${FORGEJO_REPOS_DIR:-${FORGEJO_DATA_DIR}/git/repositories}" + +# Контейнер с базой Forgejo. ПО УМОЛЧАНИЮ ПУСТО — имени здесь больше нет, +# оно определяется автоматически в guard 2 (обоснование — в шапке). Явное +# значение из окружения или из ${FORGEJO_BACKUP_ENV_FILE} выигрывает и берётся +# как есть, без всяких проверок: это ручной override, и тот, кто его выставил, +# знает про свою инсталляцию больше, чем эвристика. +PG_CONTAINER="${PG_CONTAINER:-}" +# Где искать, когда PG_CONTAINER не задан. Список, а не одно имя: у базы forgejo +# два законных дома (#3061), и в окно переезда оба существуют одновременно. +# Переопределяется целиком — если имена контейнеров когда-нибудь снова +# поменяются, это правка одной строки в конфиге на VM, а не правка скрипта. +PG_CONTAINER_CANDIDATES="${PG_CONTAINER_CANDIDATES:-gendesign-postgres-1 gendesign-infra-postgres}" +FORGEJO_DB_USER="${FORGEJO_DB_USER:-forgejo}" +FORGEJO_DB_NAME="${FORGEJO_DB_NAME:-forgejo}" + +LOCAL_BACKUP_DIR="${LOCAL_BACKUP_DIR:-/opt/gendesign/backups/forgejo}" +KEEP="${KEEP:-7}" +MIN_DB_DUMP_BYTES="${MIN_DB_DUMP_BYTES:-2048}" # forgejo DB is small (config/issues/PRs, no git blobs) +MIN_REPOS_BUNDLE_BYTES="${MIN_REPOS_BUNDLE_BYTES:-10240}" +SENTINEL_FILE="${SENTINEL_FILE:-${LOCAL_BACKUP_DIR}/.last_success}" + +FORGEJO_BACKUP_ENV_FILE="${FORGEJO_BACKUP_ENV_FILE:-/etc/default/gendesign-backup-forgejo}" +# shellcheck source=/dev/null +[[ -f "$FORGEJO_BACKUP_ENV_FILE" ]] && source "$FORGEJO_BACKUP_ENV_FILE" + +FORGEJO_S3_ENDPOINT="${FORGEJO_S3_ENDPOINT:-}" +FORGEJO_S3_BUCKET="${FORGEJO_S3_BUCKET:-}" +FORGEJO_S3_PREFIX="${FORGEJO_S3_PREFIX:-forgejo/}" +FORGEJO_S3_ACCESS_KEY="${FORGEJO_S3_ACCESS_KEY:-}" +FORGEJO_S3_SECRET_KEY="${FORGEJO_S3_SECRET_KEY:-}" + +# --- guard 0: S3 creds MUST be configured — this backup's whole point is +# off-box storage under a narrow-scoped key. No creds = refuse to run, +# loudly, rather than silently producing a local-only "backup" (unlike +# ops/backup.sh, where local-only is an accepted fallback). --- +if [[ -z "$FORGEJO_S3_ENDPOINT" || -z "$FORGEJO_S3_BUCKET" || -z "$FORGEJO_S3_ACCESS_KEY" || -z "$FORGEJO_S3_SECRET_KEY" ]]; then + log "ERROR: Forgejo S3 backup is NOT CONFIGURED." + log "ERROR: missing one or more of FORGEJO_S3_ENDPOINT / FORGEJO_S3_BUCKET / FORGEJO_S3_ACCESS_KEY / FORGEJO_S3_SECRET_KEY." + log "ERROR: create the narrow-scoped 'gendsgn-backup-forgejo-writer' S3 service user (see PR for the exact bucket policy)" + log "ERROR: and fill them into ${FORGEJO_BACKUP_ENV_FILE} (see ops/gendesign-backup-forgejo.default.example)." + log "ERROR: refusing to produce a local-only backup — that does not meet this script's off-box requirement." + exit 1 +fi + +if [[ ! -d "$FORGEJO_REPOS_DIR" ]]; then + log "ERROR: FORGEJO_REPOS_DIR does not exist -> $FORGEJO_REPOS_DIR" + log "ERROR: Forgejo's on-disk layout may differ from the documented default — override FORGEJO_REPOS_DIR." + exit 1 +fi + +# --- guard 2: в каком контейнере лежит ЖИВАЯ база forgejo (#3061) --- +# Полное обоснование «почему не константа» — в шапке файла. Здесь суть: имя +# контейнера меняется в момент переезда, а ошибка выбора не диагностируется по +# самому дампу — он выглядит здоровым и в старом, и в новом кластере. +# +# Критерий кандидата: контейнер ЗАПУЩЕН и в базе ${FORGEJO_DB_NAME} внутри него +# есть ТАБЛИЦЫ. Именно таблицы, а не сам факт существования базы — и это +# принципиально. Лёгкий кластер поднимается ПУСТЫМ на обычном деплое, за дни до +# ручного переноса данных, и его initdb-скрипт +# (ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh) сразу заводит роль +# forgejo и ПУСТУЮ базу forgejo. Проверяй мы «база существует» — в тот же вечер +# кандидатов стало бы двое, автоопределение упёрлось бы в неоднозначность и +# ночной бэкап покраснел бы ещё до того, как хоть один байт куда-то переехал. +# А это прямо нарушает главное требование переезда: мерж и деплой сами по себе +# не меняют ничего. С проверкой «есть таблицы» пустая заготовка кандидатом не +# считается, и неоднозначность возникает ровно тогда, когда она настоящая: +# дамп залит, данные есть в обоих кластерах, и решение действительно за +# человеком. +# +# Проверяем ровно тем же доступом, которым потом снимается дамп: psql под ролью +# ${FORGEJO_DB_USER} в базу ${FORGEJO_DB_NAME} через `docker exec` (внутри +# контейнера это unix-сокет, на нём в официальном образе стоит trust, пароль не +# нужен — на этом же держится и pg_dump ниже). Так проверка не расходится с +# делом: до чего не достучалась она, из того и pg_dump ничего не снимет. +if [[ -n "$PG_CONTAINER" ]]; then + log "PG_CONTAINER задан явно -> ${PG_CONTAINER}; автоопределение пропущено." +else + log "PG_CONTAINER не задан — ищу контейнер с непустой базой ${FORGEJO_DB_NAME} среди запущенных: ${PG_CONTAINER_CANDIDATES}" + pg_running="$(docker ps --format '{{.Names}}' 2>/dev/null || true)" + pg_found=() + # Разбиение по пробелам здесь НАМЕРЕННОЕ: PG_CONTAINER_CANDIDATES — это + # список имён, кавычки превратили бы его в одно имя с пробелами. + # shellcheck disable=SC2086 + for pg_candidate in $PG_CONTAINER_CANDIDATES; do + if ! grep -qxF -- "$pg_candidate" <<< "$pg_running"; then + log " ${pg_candidate}: не запущен — пропускаю" + continue + fi + # Пустой вывод = до базы не достучались вовсе (нет такой базы, нет роли, + # кластер не отвечает); `|| pg_tables=""` обязателен — под pipefail + # неудача docker exec иначе уронила бы весь скрипт прямо здесь, вместо + # внятного разбора кандидатов ниже. tr -dc отрезает перевод строки и + # любой мусор, чтобы (( )) получило заведомо число. + pg_tables="$(docker exec "$pg_candidate" psql -U "$FORGEJO_DB_USER" -d "$FORGEJO_DB_NAME" -tAc \ + "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \ + WHERE c.relkind IN ('r','p') AND n.nspname NOT IN ('pg_catalog','information_schema')" \ + 2>/dev/null | tr -dc '0-9')" || pg_tables="" + if [[ -z "$pg_tables" ]]; then + log " ${pg_candidate}: запущен, но база ${FORGEJO_DB_NAME} под ролью ${FORGEJO_DB_USER} недоступна — пропускаю" + elif (( pg_tables == 0 )); then + log " ${pg_candidate}: запущен, база ${FORGEJO_DB_NAME} есть, но ПУСТА (0 таблиц) — заготовка под миграцию, пропускаю" + else + log " ${pg_candidate}: запущен, в базе ${FORGEJO_DB_NAME} таблиц: ${pg_tables} — кандидат" + pg_found+=( "$pg_candidate" ) + fi + done + + if (( ${#pg_found[@]} == 0 )); then + log "ERROR: не нашёл ни одного запущенного контейнера с НЕПУСТОЙ базой ${FORGEJO_DB_NAME}." >&2 + log "ERROR: искал среди: ${PG_CONTAINER_CANDIDATES}" >&2 + log "ERROR: критерий: контейнер в \`docker ps\` И \`psql -U ${FORGEJO_DB_USER} -d ${FORGEJO_DB_NAME}\` видит в нём хотя бы одну таблицу." >&2 + log "ERROR: строки выше показывают, на чём отсеялся каждый кандидат — 'не запущен' / 'недоступна' / 'ПУСТА'." >&2 + log "ERROR: сейчас запущены: $(echo "$pg_running" | tr -s '[:space:]' ' ')" >&2 + log "ERROR: что делать: если контейнер называется иначе — PG_CONTAINER=<имя> в ${FORGEJO_BACKUP_ENV_FILE};" >&2 + log "ERROR: если имён стало больше — PG_CONTAINER_CANDIDATES='<имя1> <имя2>' там же. Бэкап НЕ снят." >&2 + exit 1 + fi + + if (( ${#pg_found[@]} > 1 )); then + log "ERROR: непустая база ${FORGEJO_DB_NAME} нашлась сразу в нескольких запущенных контейнерах: ${pg_found[*]}" >&2 + log "ERROR: это ровно окно переезда (#3061): старый кластер ещё жив и держит УСТАРЕВШУЮ копию базы," >&2 + log "ERROR: новый — рабочую, и по дампу их не отличить (оба непустые, оба проходят пороги размера)." >&2 + log "ERROR: выбрать наугад значит с вероятностью 1/2 бэкапить труп, поэтому останавливаюсь." >&2 + log "ERROR: что делать: PG_CONTAINER=<боевой контейнер> в ${FORGEJO_BACKUP_ENV_FILE}," >&2 + log "ERROR: а после гашения старого кластера эту строку убрать — автоопределение снова однозначно. Бэкап НЕ снят." >&2 + exit 1 + fi + + PG_CONTAINER="${pg_found[0]}" + log "Автоопределение -> ${PG_CONTAINER}: единственный запущенный контейнер с непустой базой ${FORGEJO_DB_NAME}." +fi + +mkdir -p "$LOCAL_BACKUP_DIR" +ts=$(date -u +'%Y%m%d_%H%M%S') +db_out="${LOCAL_BACKUP_DIR}/forgejo-db_${ts}.sql.gz" +repos_out="${LOCAL_BACKUP_DIR}/forgejo-repos_${ts}.tar.gz" + +# --- 1. DB dump --- +log "Dumping Forgejo DB (${FORGEJO_DB_NAME} as ${FORGEJO_DB_USER}, container ${PG_CONTAINER}) -> ${db_out}" +docker exec "$PG_CONTAINER" pg_dump -U "$FORGEJO_DB_USER" -d "$FORGEJO_DB_NAME" --no-owner --clean --if-exists \ + | gzip -9 > "$db_out" + +if [[ ! -s "$db_out" ]]; then + log "ERROR: DB dump is empty -> $db_out — removing, keeping previous good backups." >&2 + rm -f "$db_out" + exit 1 +fi + +db_bytes=$(wc -c < "$db_out" | tr -d ' ') +if (( db_bytes < MIN_DB_DUMP_BYTES )); then + log "ERROR: DB dump only ${db_bytes} bytes (< ${MIN_DB_DUMP_BYTES} floor) — likely a failed dump." >&2 + rm -f "$db_out" + exit 1 +fi + +if ! verify_dump_integrity "$db_out" "-- PostgreSQL database dump complete" "forgejo DB dump"; then + log "Removing suspect DB dump, NOT pruning older good backups." >&2 + rm -f "$db_out" + exit 1 +fi + +log "DB dump OK: ${db_out} ($(du -h "$db_out" | cut -f1), ${db_bytes} bytes)" + +# --- 2. repo bundle (tar.gz of the bare-repo tree) --- +log "Bundling Forgejo repos from ${FORGEJO_REPOS_DIR} -> ${repos_out}" +tar -czf "$repos_out" -C "$(dirname "$FORGEJO_REPOS_DIR")" "$(basename "$FORGEJO_REPOS_DIR")" + +if [[ ! -s "$repos_out" ]]; then + log "ERROR: repo bundle is empty -> $repos_out — removing." >&2 + rm -f "$repos_out" + exit 1 +fi + +repos_bytes=$(wc -c < "$repos_out" | tr -d ' ') +if (( repos_bytes < MIN_REPOS_BUNDLE_BYTES )); then + log "ERROR: repo bundle only ${repos_bytes} bytes (< ${MIN_REPOS_BUNDLE_BYTES} floor) — likely empty/failed FORGEJO_REPOS_DIR." >&2 + rm -f "$repos_out" + exit 1 +fi + +# tar integrity: a bundle truncated mid-write (disk full, killed process) +# fails `tar -t` even when the gzip framing looks superficially OK — same +# "don't trust non-empty + big-enough alone" discipline as verify_dump_integrity. +if ! tar -tzf "$repos_out" >/dev/null 2>&1; then + log "ERROR: repo bundle failed tar integrity check -> $repos_out (truncated archive?)" >&2 + rm -f "$repos_out" + exit 1 +fi + +log "Repo bundle OK: ${repos_out} ($(du -h "$repos_out" | cut -f1), ${repos_bytes} bytes)" + +# --- 3. config bundle (#2203 follow-up) --- +# ПОЧЕМУ ЭТО ОТДЕЛЬНЫЙ АРТЕФАКТ. Бэкап забирал БД и голые репозитории — то есть +# СОДЕРЖИМОЕ Forgejo, но не то, ЧЕМ оно поднимается. Компоуз-файлы Forgejo и +# раннеров живут в /home/gendesign/forgejo/, вне репозитория (проверено: +# `git rev-parse` там отвечает "not a git repository"), их не трогает ни один +# деплой и не покрывал ни один бэкап. +# +# Практический смысл: при потере хоста БД и репозитории восстанавливаются, а +# КОНФИГУРАЦИЯ — нет. Пришлось бы вручную воссоздавать docker-compose.yml и +# заново регистрировать три раннера. Файлы крошечные (~5 КБ на оба), так что +# цена включения нулевая, а цена отсутствия — ручная реконструкция в худший +# момент. +# +# Косвенное подтверждение, что каталог правится руками и без истории: рядом +# лежат runner-compose.yml.bak, .bak.1779049805, .bak.before-runner3 — три +# самодельных снимка вместо version control. +# +# ЧТО НАМЕРЕННО НЕ ВКЛЮЧЕНО: +# .env (режим 600) и runner*/data/ с регистрационными токенами — секреты. +# Они остаются ручным off-box пунктом владельца, тем же, что .env.runtime +# в приёмке #2203. Раннеры при восстановлении перерегистрируются из UI за +# пару минут; компоуз-файл руками не восстановишь. +config_out="${LOCAL_BACKUP_DIR}/forgejo-config_${ts}.tar.gz" +log "Bundling Forgejo compose config from ${FORGEJO_DIR} -> ${config_out}" + +# -C + явные имена, а не путь целиком: в архиве оказываются только те файлы, +# что перечислены, без каталога data/ (он огромен и уже покрыт repo-бандлом). +if ! tar -czf "$config_out" -C "$FORGEJO_DIR" \ + --exclude='.env' \ + $(cd "$FORGEJO_DIR" && ls *.yml 2>/dev/null) 2>/dev/null; then + log "WARN: не удалось собрать config-бандл — продолжаю без него" >&2 + rm -f "$config_out" + config_out="" +fi + +if [[ -n "$config_out" ]]; then + # Порог намеренно низкий: файлы и правда маленькие. Проверяем не «объём», а + # «архив собрался и читается» — те же две проверки, что у repo-бандла. + config_bytes=$(wc -c < "$config_out" | tr -d ' ') + if (( config_bytes < 200 )) || ! tar -tzf "$config_out" >/dev/null 2>&1; then + log "WARN: config-бандл подозрителен (${config_bytes} байт / не читается) — не выгружаю" >&2 + rm -f "$config_out" + config_out="" + else + log "Config bundle OK: ${config_out} (${config_bytes} байт, файлов: $(tar -tzf "$config_out" | wc -l))" + fi +fi + +# --- 4. upload to S3 (mandatory — see guard 0 above) --- +for f in "$db_out" "$repos_out" ${config_out:+"$config_out"}; do + key="${FORGEJO_S3_PREFIX%/}/$(basename "$f")" + log "Uploading to s3://${FORGEJO_S3_BUCKET}/${key}" + # Same AWS_CA_BUNDLE fix as ops/backup.sh / backup-tradein-db.sh — aws-cli + # v2's own baked-in CA bundle is missing the root Selectel's cert chains + # to; point it at the container's system store instead. + docker run --rm \ + -e AWS_ACCESS_KEY_ID="$FORGEJO_S3_ACCESS_KEY" \ + -e AWS_SECRET_ACCESS_KEY="$FORGEJO_S3_SECRET_KEY" \ + -e AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \ + -v "$LOCAL_BACKUP_DIR":/backup:ro \ + amazon/aws-cli:latest \ + --endpoint-url "$FORGEJO_S3_ENDPOINT" \ + s3 cp --no-progress "/backup/$(basename "$f")" "s3://${FORGEJO_S3_BUCKET}/${key}" +done +log "S3 upload OK" + +# --- 4. local retention --- +# shellcheck disable=SC2012 +ls -1t "$LOCAL_BACKUP_DIR"/forgejo-db_*.sql.gz 2>/dev/null \ + | tail -n +"$((KEEP + 1))" \ + | xargs -r rm -f +# shellcheck disable=SC2012 +ls -1t "$LOCAL_BACKUP_DIR"/forgejo-repos_*.tar.gz 2>/dev/null \ + | tail -n +"$((KEEP + 1))" \ + | xargs -r rm -f + +shopt -s nullglob +remaining_db=( "$LOCAL_BACKUP_DIR"/forgejo-db_*.sql.gz ) +remaining_repos=( "$LOCAL_BACKUP_DIR"/forgejo-repos_*.tar.gz ) +shopt -u nullglob +log "Forgejo backup done. Local copies retained: ${#remaining_db[@]} db + ${#remaining_repos[@]} repo bundles (KEEP=${KEEP})." + +# --- missed-run detection (#2203): mark success ONLY after everything above +# (including the mandatory S3 upload) has passed. --- +write_sentinel "$SENTINEL_FILE" diff --git a/ops/backup.sh b/ops/backup.sh index 348518f7..421b3fca 100755 --- a/ops/backup.sh +++ b/ops/backup.sh @@ -3,32 +3,52 @@ # # Modeled on the proven ops/.../backup-tradein-db.sh (#397). Hardened for #71 # after the main-DB backup silently broke (last good dump 2026-05-27): a raw -# `git reset --hard origin/main` on every deploy reset this file's mode to 644, -# so a cron entry that invoked the raw path got "Permission denied" every run. +# deploy-time hard reset of the repo checkout onto origin/main reset this +# file's mode to 644, so a cron entry that invoked the raw path got +# "Permission denied" every run. # -# Robustness measures here: +# Robustness measures here (extended for #2203): # - cron should invoke via `bash ` so a missing +x bit can't break it # (this file is ALSO committed 100755, and deploy.yml re-chmods ops/*.sh); # - sanity-check: a suspiciously small dump (< MIN_DUMP_BYTES) is treated as a # failed dump — it's deleted and the script exits non-zero, so a good prior # dump is never pruned in favour of a truncated one; -# - retention: keep the KEEP most-recent local dumps, delete the rest. +# - integrity-check: gzip -t + a trailer sentinel catch a truncated dump that +# is neither empty nor undersized (see verify_dump_integrity below); +# - globals (roles/GRANTs) are dumped separately via `pg_dumpall --globals-only` +# — `pg_dump` never includes these, so without this a restore has tables +# but no owning roles/privileges; +# - retention: keep the KEEP most-recent local dumps, delete the rest — main +# dumps and globals dumps are tracked as separate series. # # Usage (cron — note `bash`, not a bare path, so +x is irrelevant): # 30 3 * * * bash /opt/gendesign/ops/backup.sh >> /var/log/gendesign-backup.log 2>&1 # -# Optional S3 upload — set these in /etc/default/gendesign-backup (root-owned, -# chmod 600, NOT in git). Without them, dumps stay local only: +# S3 upload — REQUIRED by default (#3085). Set these in /etc/default/gendesign-backup +# (root-owned, chmod 600, NOT in git). Without all four, the script fails +# LOUDLY before taking a dump — set BACKUP_ALLOW_LOCAL_ONLY=1 to explicitly +# accept a local-only backup instead (e.g. local testing): # S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru # Selectel S3 -# S3_BUCKET=gendesign-backups +# S3_BUCKET=gendsgn-backups # S3_ACCESS_KEY=... # S3_SECRET_KEY=... # A redacted template lives at ops/gendesign-backup.default.example. # -# Restore: see ops/restore.sh. +# Restore: see ops/restore.sh (destructive, INTO the live DB). +# Restore drill (safe, throwaway container): see ops/restore-drill.sh. +# +# Missed-run detection (#2203): on a verified-good run this writes a sentinel +# file (SENTINEL_FILE) with the UTC timestamp. A SEPARATE cron entry running +# ops/check-backup-staleness.sh against that file is what actually alerts if +# cron stops firing — see that script's header for the alert channel and +# cron line. This script itself does NOT alert; it only ever marks success. set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./lib-backup.sh +source "$SCRIPT_DIR/lib-backup.sh" + # --- config (env-overridable) --- COMPOSE_DIR="${COMPOSE_DIR:-/opt/gendesign}" COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}" @@ -38,11 +58,58 @@ KEEP="${KEEP:-7}" # how many recent local dumps to keep MIN_DUMP_BYTES="${MIN_DUMP_BYTES:-51200}" # 50 KiB floor; gzip'd schema-only dump # is already > this, so a healthy dump # never trips it. Real DB is far larger. + # (No equivalent floor for the globals + # dump — a handful of roles legitimately + # gzips to well under this.) +SENTINEL_FILE="${SENTINEL_FILE:-${LOCAL_BACKUP_DIR}/.last_success}" # Optional S3 env. Loaded from /etc/default/gendesign-backup if present. [[ -f /etc/default/gendesign-backup ]] && source /etc/default/gendesign-backup -log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; } +# --- guard: S3 upload is REQUIRED by default (#3085). A backup that only +# lives on the same disk as the DB it protects is not a backup once +# Poincare has no configured fallback host to copy from — before this +# guard, a missing/incomplete /etc/default/gendesign-backup made the +# script log "backup stays local only" and exit 0, indistinguishable +# from a real off-box backup in the cron log. Fail LOUD, before the dump +# is even taken (no point spending time/disk on a dump that can't leave +# the box). BACKUP_ALLOW_LOCAL_ONLY=1 is the explicit escape hatch for +# running on a box without S3 creds on purpose (e.g. local testing) — +# it still warns loudly so it can't be forgotten silently. --- +if [[ -z "${S3_ENDPOINT:-}" || -z "${S3_BUCKET:-}" || -z "${S3_ACCESS_KEY:-}" || -z "${S3_SECRET_KEY:-}" ]]; then + if [[ "${BACKUP_ALLOW_LOCAL_ONLY:-0}" == "1" ]]; then + log "WARNING: S3 vars not fully set — BACKUP_ALLOW_LOCAL_ONLY=1, proceeding with a LOCAL-ONLY backup. This copy will NOT survive loss of this VM." >&2 + BACKUP_LOCAL_ONLY_USED=1 + else + log "ERROR: S3 upload is not configured — missing one or more of S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY." >&2 + log "ERROR: fill them into /etc/default/gendesign-backup (see ops/gendesign-backup.default.example)," >&2 + log "ERROR: or set BACKUP_ALLOW_LOCAL_ONLY=1 to explicitly accept a local-only backup." >&2 + exit 1 + fi +fi + +# Integrity check beyond "non-empty"/"big enough": a dump truncated mid-write +# (disk full, OOM-kill, docker exec dropped) can still gzip into a structurally +# valid, non-tiny .gz — neither `-s` nor MIN_DUMP_BYTES catch that reliably. +# Two checks, cheapest first: +# 1. gzip -t — catches a corrupted/truncated gzip stream itself. +# 2. trailer — pg_dump/pg_dumpall always write a fixed "I finished writing" +# comment as literally the last line of the stream; a dump cut off +# mid-write is missing it even when the gzip framing looks fine. +# Checked via `gunzip -c | tail -N` so we never materialize the full +# decompressed dump on disk just to look at its last few lines. +verify_dump_integrity() { + local file="$1" trailer="$2" label="$3" + if ! gzip -t "$file" 2>/dev/null; then + log "ERROR: ${label} failed gzip integrity check -> $file" >&2 + return 1 + fi + if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF -- "$trailer"; then + log "ERROR: ${label} missing trailer '${trailer}' -> $file (truncated dump?)" >&2 + return 1 + fi + return 0 +} # --- run --- mkdir -p "$LOCAL_BACKUP_DIR" @@ -50,6 +117,7 @@ mkdir -p "$LOCAL_BACKUP_DIR" # pre-existing prod dumps and restore.sh's example path. UTC for stable ordering. ts=$(date -u +'%Y%m%d_%H%M%S') out="${LOCAL_BACKUP_DIR}/gendesign_${ts}.sql.gz" +globals_out="${LOCAL_BACKUP_DIR}/gendesign_globals_${ts}.sql.gz" cd "$COMPOSE_DIR" @@ -66,9 +134,11 @@ log "Dumping ${DB_NAME} as ${DB_USER} -> ${out}" # --clean --if-exists -> dump is self-sufficient for restore from scratch. # --no-owner -> restore does not require identical roles. # `set -o pipefail` (from set -euo pipefail) makes a pg_dump failure fail the -# whole pipe, so a Postgres error can't yield a "successful" tiny gzip. +# whole pipe, so a Postgres error can't yield a "successful" tiny gzip. Stderr +# is NOT redirected to /dev/null (was until #2203) — a pg_dump error needs to +# land in the cron log, not get silently swallowed. compose exec -T postgres \ - pg_dump -U "$DB_USER" -d "$DB_NAME" --no-owner --clean --if-exists 2>/dev/null \ + pg_dump -U "$DB_USER" -d "$DB_NAME" --no-owner --clean --if-exists \ | gzip -9 > "$out" # --- sanity-check: refuse to keep (and thus never prune good dumps for) a @@ -87,18 +157,53 @@ if (( dump_bytes < MIN_DUMP_BYTES )); then exit 1 fi +if ! verify_dump_integrity "$out" "-- PostgreSQL database dump complete" "main dump"; then + log "Removing suspect dump, NOT pruning older good dumps." >&2 + rm -f "$out" + exit 1 +fi + log "Dump OK: ${out} ($(du -h "$out" | cut -f1), ${dump_bytes} bytes)" -# --- optional S3 upload (only if all four vars present) --- +# --- globals (roles/GRANTs) — pg_dump never includes these; without them a +# restored dump has tables and data but no owning roles/privileges. --- +log "Dumping globals (roles/GRANTs) -> ${globals_out}" +compose exec -T postgres \ + pg_dumpall -U "$DB_USER" --globals-only \ + | gzip -9 > "$globals_out" + +if [[ ! -s "$globals_out" ]]; then + log "ERROR: globals dump is empty -> $globals_out — removing." >&2 + rm -f "$globals_out" + exit 1 +fi + +if ! verify_dump_integrity "$globals_out" "-- PostgreSQL database cluster dump complete" "globals dump"; then + rm -f "$globals_out" + exit 1 +fi + +log "Globals dump OK: ${globals_out} ($(du -h "$globals_out" | cut -f1))" + +# --- S3 upload (only if all four vars present — guaranteed unless the +# BACKUP_ALLOW_LOCAL_ONLY escape hatch above was used) — both the main dump +# and the globals dump go up, same bucket, same layout. --- if [[ -n "${S3_ENDPOINT:-}" && -n "${S3_BUCKET:-}" && -n "${S3_ACCESS_KEY:-}" && -n "${S3_SECRET_KEY:-}" ]]; then - log "Uploading to s3://${S3_BUCKET}/$(basename "$out")" - docker run --rm \ - -e AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \ - -e AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \ - -v "$LOCAL_BACKUP_DIR":/backup:ro \ - amazon/aws-cli:latest \ - --endpoint-url "$S3_ENDPOINT" \ - s3 cp "/backup/$(basename "$out")" "s3://${S3_BUCKET}/" + for f in "$out" "$globals_out"; do + log "Uploading to s3://${S3_BUCKET}/$(basename "$f")" + # aws-cli v2 ships its own CA bundle (baked into botocore) instead of + # trusting the system store, and it's missing the root that Selectel's + # cert chains up to — so uploads fail with CERTIFICATE_VERIFY_FAILED + # unless we point it at the container's system store, which has it. + docker run --rm \ + -e AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \ + -e AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \ + -e AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \ + -v "$LOCAL_BACKUP_DIR":/backup:ro \ + amazon/aws-cli:latest \ + --endpoint-url "$S3_ENDPOINT" \ + s3 cp --no-progress "/backup/$(basename "$f")" "s3://${S3_BUCKET}/" + done log "S3 upload OK" else log "S3 vars not set — backup stays local only" @@ -106,17 +211,42 @@ fi # --- retention: keep the KEEP most-recent local dumps, delete the rest. # Runs only AFTER a verified-good dump above, so a failed run (which exits -# early) can never delete older good dumps. --- +# early) can never delete older good dumps. Main dumps and globals dumps +# are separate series (the `[0-9]` after the shared `gendesign_` prefix +# keeps the globals_* files, which share that prefix, out of this glob). --- # SC2012: ls is fine here — filenames are fully controlled (gendesign_.sql.gz, # no spaces/newlines) and we need ls's -t mtime sort for "keep newest N" (same # pattern as the proven backup-tradein-db.sh). # shellcheck disable=SC2012 -ls -1t "$LOCAL_BACKUP_DIR"/gendesign_*.sql.gz 2>/dev/null \ +ls -1t "$LOCAL_BACKUP_DIR"/gendesign_[0-9]*.sql.gz 2>/dev/null \ + | tail -n +"$((KEEP + 1))" \ + | xargs -r rm -f +# shellcheck disable=SC2012 +ls -1t "$LOCAL_BACKUP_DIR"/gendesign_globals_*.sql.gz 2>/dev/null \ | tail -n +"$((KEEP + 1))" \ | xargs -r rm -f -# Count via a glob array (no ls parsing). nullglob -> empty array if no match. +# Count via glob arrays (no ls parsing). nullglob -> empty array if no match. shopt -s nullglob -remaining=( "$LOCAL_BACKUP_DIR"/gendesign_*.sql.gz ) +remaining=( "$LOCAL_BACKUP_DIR"/gendesign_[0-9]*.sql.gz ) +remaining_globals=( "$LOCAL_BACKUP_DIR"/gendesign_globals_*.sql.gz ) shopt -u nullglob -log "Backup done. Local dumps retained: ${#remaining[@]} (KEEP=${KEEP})." +log "Backup done. Local dumps retained: ${#remaining[@]} data + ${#remaining_globals[@]} globals (KEEP=${KEEP})." + +# --- missed-run detection (#2203): mark success ONLY here, after every +# guard above (dump/globals integrity, retention, and — since this whole +# script runs under `set -euo pipefail` — any S3 upload) has passed. See +# ops/check-backup-staleness.sh for the piece that actually alerts if +# this stops being refreshed. A LOCAL-ONLY run (BACKUP_ALLOW_LOCAL_ONLY=1 +# escape hatch above) does NOT count as a verified backup for this +# purpose — writing the sentinel here would leave the staleness watchdog +# permanently green while the box has no off-box copy at all (#3085 +# reopened via the escape hatch). Instead: skip the sentinel (watchdog +# alerts within max_age_hours) and notify() immediately so it isn't only +# a WARNING buried in a log nobody tails. --- +if [[ "${BACKUP_LOCAL_ONLY_USED:-0}" == "1" ]]; then + log "Skipping sentinel write — this was a LOCAL-ONLY backup (BACKUP_ALLOW_LOCAL_ONLY=1), not a verified off-box backup." >&2 + notify "⚠️ gendesign main backup: LOCAL-ONLY run (S3 not configured, BACKUP_ALLOW_LOCAL_ONLY=1). Dump ${out} is NOT off-box. Sentinel NOT updated — staleness watchdog will alert if this isn't fixed." +else + write_sentinel "$SENTINEL_FILE" +fi diff --git a/ops/check-backup-staleness.sh b/ops/check-backup-staleness.sh new file mode 100755 index 00000000..d21d041a --- /dev/null +++ b/ops/check-backup-staleness.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Missed-run detector for the backup scripts (#2203). +# +# ops/backup.sh, ops/backup-forgejo.sh, and tradein-mvp/deploy/backup-tradein-db.sh +# each write a sentinel file (via write_sentinel() in ops/lib-backup.sh) the +# instant a run finishes with a VERIFIED-good dump — same file the older two +# scripts already treat as "only mark success once every guard passed". +# This script checks how old that sentinel is. If cron stops firing (or every +# run fails before reaching the sentinel write), the file goes stale and this +# script says so loudly instead of the silence that let backups break for +# weeks undetected before (#71). +# +# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same +# discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam +# Telegram once a backup is already known to be stale. +# +# Usage (cron — one line per sentinel, run more often than the backup itself +# so a stale state is caught promptly; hourly is a reasonable default for a +# daily-cron backup with a 26h threshold): +# 0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh \ +# /opt/gendesign/backups/.last_success 26 "gendesign main backup" \ +# >> /var/log/gendesign-backup-staleness.log 2>&1 +# 0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh \ +# /opt/gendesign/backups/tradein/.last_success 26 "tradein backup" \ +# >> /var/log/gendesign-backup-staleness.log 2>&1 +# 0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh \ +# /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \ +# >> /var/log/gendesign-backup-staleness.log 2>&1 +# +# Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see +# notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup, +# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only. +# +# Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be +# used as a plain healthcheck by anything that just wants the exit code). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./lib-backup.sh +source "$SCRIPT_DIR/lib-backup.sh" + +usage() { + echo "Usage: $(basename "$0") [max-age-hours=26] [label=backup]" >&2 + exit 2 +} + +[[ $# -ge 1 ]] || usage + +sentinel_file="$1" +max_age_hours="${2:-26}" +label="${3:-backup}" +state_file="${BACKUP_STALENESS_STATE_FILE:-/var/tmp/gendesign-backup-staleness-state}" + +age_hours="$(sentinel_age_hours "$sentinel_file" 2>/dev/null || true)" + +if [[ -z "$age_hours" ]]; then + now_status="stale" + log "ERROR: sentinel missing/unreadable -> $sentinel_file (${label} has never completed successfully, or its state was lost)." +else + if (( age_hours >= max_age_hours )); then + now_status="stale" + log "ERROR: ${label} sentinel is ${age_hours}h old (threshold ${max_age_hours}h) -> $sentinel_file" + else + now_status="fresh" + log "OK: ${label} sentinel is ${age_hours}h old (< ${max_age_hours}h threshold)." + fi +fi + +prev_status="$(backup_prev_status "$state_file" "$label")" + +if [[ "$now_status" == "stale" ]]; then + if [[ "$prev_status" != "stale" ]]; then + notify "🔴 ${label}: backup sentinel is stale (missing or older than ${max_age_hours}h) -> ${sentinel_file}. Check cron / backup logs on the VM." + fi +else + if [[ "$prev_status" == "stale" ]]; then + notify "✅ ${label}: backup sentinel is fresh again -> ${sentinel_file}." + fi +fi + +backup_set_status "$state_file" "$label" "$now_status" + +[[ "$now_status" == "fresh" ]] diff --git a/ops/crontab-beget.cron b/ops/crontab-beget.cron new file mode 100644 index 00000000..16639595 --- /dev/null +++ b/ops/crontab-beget.cron @@ -0,0 +1,60 @@ +# crontab ОСТАЮЩЕГОСЯ хоста (Beget, 46.173.16.127) — после переезда 30.08. +# +# Устанавливать НЕ РАНЬШЕ, чем стек уедет на Selectel: +# crontab /opt/gendesign/ops/crontab-beget.cron +# crontab -l # сверить +# +# ЗАЧЕМ ЭТОТ ФАЙЛ. Сегодняшний crontab Beget'а обслуживает ОБА контура сразу: +# и остающийся (Forgejo), и уезжающий (tradein/gendesign). После переезда пять +# записей начнут ходить в контейнеры, которых на этом хосте больше нет — +# `docker exec tradein-backend ...` будет падать каждую ночь. Само по себе это +# ещё полбеды; беда в том, что вместе с ними перестанут обновляться sentinel'ы +# бэкапов, сторож это честно заметит — и упрётся в то, что канал оповещения +# не настроен (#2203: TELEGRAM_* нет ни в одном env-файле). То есть бэкапы +# пропадут молча. +# +# Разделение на два файла — чтобы этот переход был явным действием, а не +# «забыли поправить crontab». + +# ── Forgejo: остаётся здесь ───────────────────────────────────────────────── +# Конфиг лежит не в /etc/default (туда нет доступа без root), а в +# /opt/gendesign/secrets — путь передаётся переменной. +15 4 * * * FORGEJO_BACKUP_ENV_FILE=/opt/gendesign/secrets/forgejo-backup.env bash /opt/gendesign/ops/backup-forgejo.sh >> /opt/gendesign/logs/backup-forgejo.log 2>&1 + +# Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч. +0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 + +# ── Бэкап волта Obsidian (CouchDB) ────────────────────────────────────────── +# Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись +# живёт здесь, а не в crontab-poincare. До #3057 у CouchDB не было +# автоматического бэкапа вообще — единственной копией был ручной месячный +# прогон на машине владельца. +# Скрипт ОСТАНАВЛИВАЕТ CouchDB на время снятия снимка (секунды, данных ~85 МБ): +# file-level копия работающего узла может оказаться рассогласованной. Время — +# 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс), +# чтобы три задачи не дрались за диск и сеть одновременно. +45 4 * * * bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1 + +# Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных: +# сутки плюс запас на сдвиг прогона. +0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 + +# ── Уборка докера ─────────────────────────────────────────────────────────── +# Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их +# тома и растут. На Selectel нужна СВОЯ такая же запись (см. crontab-poincare). +0 4 * * 0 bash /opt/gendesign/ops/docker-prune.sh >> /tmp/gendesign-docker-prune.log 2>&1 + +# ── ЧТО ОТСЮДА УБРАНО И ПОЧЕМУ ────────────────────────────────────────────── +# Все записи ниже переехали в ops/crontab-poincare.cron, потому что обращаются +# к контейнерам и данным, которых на Beget после переезда не будет: +# +# 30 4 * * * backup-tradein-db.sh -> dump из tradein-postgres +# 30 3 * * * ops/backup.sh -> dump из gendesign-postgres +# 0 5 * * * backfill_houses_dadata -> docker exec tradein-backend +# 30 5 * * * geocode_deals_from_houses -> docker exec tradein-backend +# 45 5 * * * geocode_deals_nominatim -> docker exec tradein-backend +# 0 * * * * staleness: backups/.last_success (main-бэкап уехал) +# 0 * * * * staleness: backups/tradein/.last_success (tradein-бэкап уехал) +# +# ⚠️ Оставить их здесь = пять падающих задач в сутки И потерянные бэкапы +# обоих продуктовых кластеров. diff --git a/ops/crontab-poincare.cron b/ops/crontab-poincare.cron new file mode 100644 index 00000000..161b8f16 --- /dev/null +++ b/ops/crontab-poincare.cron @@ -0,0 +1,69 @@ +# crontab ПРИНИМАЮЩЕГО хоста (Selectel Poincare, 188.246.224.93) — после 30.08. +# +# Устанавливать СРАЗУ после того, как стек поднялся и смоук прошёл: +# mkdir -p /opt/gendesign/logs +# crontab /opt/gendesign/ops/crontab-poincare.cron +# crontab -l # сверить +# +# Перед установкой в /etc/default/ должны лежать (root-owned, chmod 600, +# НЕ в git, переносятся руками с предыдущего хоста или создаются заново): +# /etc/default/gendesign-backup — S3_ENDPOINT/S3_BUCKET/S3_ACCESS_KEY/S3_SECRET_KEY +# (см. ops/gendesign-backup.default.example) +# /etc/default/tradein-backup — свои S3_*, либо отсутствует (тогда +# backup-tradein-db.sh падает на общий +# /etc/default/gendesign-backup выше) +# Без них ops/backup.sh и backup-tradein-db.sh теперь падают громко (#3085), +# а не молча пишут локальный бэкап. +# +# ⚠️ САМЫЙ ОПАСНЫЙ ПРОПУСК ВСЕГО ПЕРЕЕЗДА. Если этот файл не установить, +# на новом хосте НЕ БУДЕТ НИ ОДНОГО БЭКАПА — ни tradein, ни gendesign. И узнать +# об этом будет неоткуда: сторож пропущенных прогонов (check-backup-staleness) +# сам живёт в этом же crontab'е, то есть его тоже не будет. Отсутствие бэкапов +# обнаружится в момент, когда они понадобятся. +# +# Времена оставлены те же, что были на Beget — чтобы не гадать, изменилось ли +# поведение из-за переезда или из-за нового расписания. Подгонять окна имеет +# смысл ПОСЛЕ того, как всё поедет стабильно. + +# ── Бэкапы обоих кластеров ────────────────────────────────────────────────── +# Off-box выгрузка в S3 включается через /etc/default/tradein-backup и +# /etc/default/gendesign-backup — файлы НЕ переезжают сами (в git их нет и не +# должно быть), их нужно перенести руками. Без них оба скрипта падают ГРОМКО +# (exit 1, дамп не снимается вовсе) — см. #3085; они больше НЕ пишут молча +# локальный бэкап. +30 3 * * * bash /opt/gendesign/ops/backup.sh >> /opt/gendesign/logs/gendesign-backup.log 2>&1 +30 4 * * * bash /opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh >> /opt/gendesign/logs/tradein-backup.log 2>&1 + +# Сторожа пропущенных прогонов, порог 26ч. Едут вместе со своими бэкапами: +# сторож должен жить там же, где sentinel, иначе он сторожит файл, который +# никто не обновляет, и кричит вечно. +0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/.last_success 26 "gendesign main backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 +0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/tradein/.last_success 26 "tradein backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 + +# ── Restore-дрель, раз в месяц (#3085) ─────────────────────────────────────── +# Восстановимость дампа никогда не проверялась автоматически — только +# ручными прогонами. 1-е число месяца, 02:15 — до ночных бэкапов (03:30/04:30) +# и до обогащения/геокодинга (05:00-05:45), не пересекается ни с чем. +# Берёт САМЫЙ СВЕЖИЙ на тот момент main-дамп; ops/restore-drill.sh сам найдёт +# рядом лежащий globals-файл и сам снесёт свой временный контейнер — прод не +# трогает никогда (см. заголовок скрипта). +15 2 1 * * bash -c 'f=$(ls -t /opt/gendesign/backups/gendesign_[0-9]*.sql.gz 2>/dev/null | head -1); if [ -n "$f" ]; then bash /opt/gendesign/ops/restore-drill.sh "$f"; else echo "no gendesign dump found to drill"; exit 1; fi' >> /opt/gendesign/logs/restore-drill.log 2>&1 + +# ── Обогащение и геокодирование (ходят в tradein-backend) ─────────────────── +0 5 * * * cd /opt/gendesign/tradein-mvp && docker exec tradein-backend python -m scripts.backfill_houses_dadata --limit 100 --priority both >> /opt/gendesign/logs/dadata-backfill.log 2>&1 +30 5 * * * cd /opt/gendesign/tradein-mvp && docker exec tradein-backend python -m scripts.geocode_deals_from_houses --limit 50000 >> /opt/gendesign/logs/deals-geocode.log 2>&1 +45 5 * * * cd /opt/gendesign/tradein-mvp && docker exec tradein-backend python -m scripts.geocode_deals_nominatim --limit 5000 >> /opt/gendesign/logs/deals-nominatim.log 2>&1 + +# ── Уборка докера ─────────────────────────────────────────────────────────── +# СВОЯ запись, не «переехавшая»: на Beget она чистит тома CI-раннеров, здесь — +# образы продуктового стека. Нужны обе, на обоих хостах. +0 4 * * 0 bash /opt/gendesign/ops/docker-prune.sh >> /opt/gendesign/logs/docker-prune.log 2>&1 + +# ── Логи: /opt/gendesign/logs, а не /tmp ──────────────────────────────────── +# На Beget половина записей писала в /tmp — он чистится при перезагрузке, и +# именно поэтому историю отказов бэкапов там восстановить не удавалось. +# Каталог создать до установки crontab: mkdir -p /opt/gendesign/logs +# +# ── ЧЕГО ЗДЕСЬ НЕТ ────────────────────────────────────────────────────────── +# backup-forgejo.sh + его сторож — Forgejo остаётся на Beget +# (см. ops/crontab-beget.cron) diff --git a/ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh b/ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh new file mode 100755 index 00000000..de30f0d3 --- /dev/null +++ b/ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Роли и базы лёгкого инфраструктурного кластера `infra-postgres` (#3061). +# +# ЗАЧЕМ. Переезд продукта Beget (46.173.16.127) → Selectel Poincare +# (188.246.224.93), окно 30.08.2026. Контейнер `gendesign-postgres-1` уезжает +# целиком вместе с томом postgres_data (~15 ГБ), а внутри него сегодня живут +# четыре базы — и две из них должны ОСТАТЬСЯ на Beget: +# forgejo 265 МБ — git.gendsgn.ru + Actions CI (из него же идёт деплой) +# glitchtip 729 МБ — errors.gendsgn.ru +# Этот файл готовит им новый дом: роли-владельцы и пустые базы в кластере +# postgres:16-alpine (сервис `infra-postgres` в docker-compose.prod.yml). +# +# КОГДА ИСПОЛНЯЕТСЯ — ровно один раз. Каталог /docker-entrypoint-initdb.d +# официальный образ читает ТОЛЬКО на пустом PGDATA и ТОЛЬКО до того, как в +# кластер попадут какие-либо данные. Это та же мина, что описана у сервиса +# postgres в docker-compose.prod.yml (#2989) и в tradein-mvp: если том +# infra_postgres_data уже инициализирован, скрипты МОЛЧА не выполнятся — ни +# ошибки, ни строчки в логе. Чтобы прогнать заново: снести том +# (`docker volume rm gendesign_infra_postgres_data`) либо выполнить те же +# команды руками через `docker exec -i gendesign-infra-postgres psql`. +# +# ПОРЯДОК ОТНОСИТЕЛЬНО ДАМПОВ — ГЛАВНОЕ. Дампы снимаются с +# `pg_dump --no-owner --clean --if-exists` (см. ops/backup-forgejo.sh): +# --no-owner → в дампе нет ни одной `ALTER ... OWNER TO`, владельцем +# восстановленных объектов становится РОЛЬ, ОТ ИМЕНИ +# КОТОРОЙ ИДЁТ ВОССТАНОВЛЕНИЕ; +# --clean --if-exists → дамп начинается с DROP'ов, поэтому его одинаково +# можно лить и в пустую базу, и поверх существующей. +# Отсюда два жёстких следствия для окна миграции: +# 1. роли forgejo/glitchtip обязаны существовать ДО восстановления — иначе +# psql упадёт на первом же `GRANT ... TO forgejo`. Их создаёт этот файл; +# 2. лить дамп нужно ИМЕННО целевой ролью, а не суперюзером кластера: +# docker exec -i gendesign-infra-postgres \ +# psql -v ON_ERROR_STOP=1 -U forgejo -d forgejo < forgejo.sql +# Восстановление суперюзером отдало бы ему все таблицы, и приложение +# получило бы "permission denied for table" на первой же записи. +# Если дамп содержит `CREATE EXTENSION` для НЕдоверенного расширения, владелец +# базы поставить его не сможет ("permission denied to create extension") — +# доверенные (pg_trgm, citext, btree_gin) в PG16 владельцу разрешены. Лечится +# разово: создать расширение суперюзером и повторить прогон дампа. +# +# ПАРОЛИ приходят из окружения контейнера (FORGEJO_DB_PASS / GLITCHTIP_DB_PASS +# в docker-compose.prod.yml ← /opt/gendesign/.env, chmod 600, вне git). В самом +# файле их нет и быть не может — только имена переменных. Не задан любой из +# двух → падаем громко: молча созданная беспарольная роль означала бы, что +# приложение не сможет залогиниться, и выяснилось бы это уже в окно миграции. +# +# ⚠️ У этого «падаем громко» есть цена, и её надо понимать. Порядок шагов +# энтрипойнта: PGDATA создаётся РАНЬШЕ, чем исполняется этот каталог. Значит наш +# exit 1 оставляет том ИНИЦИАЛИЗИРОВАННЫМ, но БЕЗ ролей и баз, а на следующем +# старте образ увидит непустой PG_VERSION и пропустит initdb.d НАВСЕГДА. Сам +# скрипт от этого защититься не может — он исполняется слишком поздно, — поэтому +# страховка вынесена в healthcheck сервиса infra-postgres: он требует наличия +# ОБЕИХ баз, и такой полупустой кластер никогда не станет healthy. Видно в +# `docker ps` в тот же день, а не в ночь переезда на заливке дампа. Лечение — то +# же, что описано выше: пересоздать том и поднять кластер заново с заполненными +# паролями. +# +# ⚠️ Файл ОБЯЗАН быть исполняемым (git mode 100755). Неисполняемые *.sh +# docker-entrypoint.sh не запускает, а ПОДКЛЮЧАЕТ через `.` — тогда `exit 1` из +# проверки паролей роняет сам энтрипойнт с невнятным кодом, а `set -u` протекает +# в его остаток. На Windows core.filemode=false, поэтому бит выставляется явно: +# `git update-index --chmod=+x ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh`. +# +# bash в postgres:16-alpine есть — на нём написан сам docker-entrypoint.sh +# образа, так что shebang безопасен и без установки пакетов. + +set -euo pipefail + +fail() { + echo "infra-postgres bootstrap: ОШИБКА: $*" >&2 + exit 1 +} + +[[ -n "${FORGEJO_DB_PASS:-}" ]] || fail \ + "FORGEJO_DB_PASS пуст. Задать в /opt/gendesign/.env ДО первого старта кластера — потом initdb.d уже не выполнится." +[[ -n "${GLITCHTIP_DB_PASS:-}" ]] || fail \ + "GLITCHTIP_DB_PASS пуст. Это тот же пароль, что стоит в DATABASE_URL у glitchtip-web/worker — он обязан совпасть, иначе после cutover GlitchTip не залогинится." + +# Подключаемся к служебной БД `postgres`, а не к ${POSTGRES_DB}: CREATE DATABASE +# нельзя выполнить, находясь в создаваемой базе, и лишняя привязка к имени +# служебной базы кластера тут ни к чему (подробный разбор — в +# ops/db-bootstrap/create_auth_db.sql). +psql -v ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname postgres \ + -v forgejo_pw="$FORGEJO_DB_PASS" \ + -v glitchtip_pw="$GLITCHTIP_DB_PASS" <<'EOSQL' +-- Пароли кладём в сессионные GUC, а не подставляем прямо в текст DO-блока: +-- psql НЕ интерполирует :'var' внутри dollar-quoted блока ($$...$$) — это +-- правило psql, а не баг (инцидент деплоя 2026-05-24, разобран в +-- ops/db-bootstrap/set_tradein_fdw_password.sql). set_config вызывается ВНЕ $$, +-- значит подстановка срабатывает, а внутрь блока значение приходит через +-- current_setting(); format(%L) экранирует его как SQL-литерал, поэтому пароль +-- с кавычками безопасен. +-- +-- \o /dev/null вокруг set_config: функция ВОЗВРАЩАЕТ установленное значение — +-- без глушения psql напечатал бы пароль в stdout, то есть в лог контейнера +-- (`docker logs gendesign-infra-postgres` и journald, #2761). +\o /dev/null +SELECT set_config('app.forgejo_pw', :'forgejo_pw', false); +SELECT set_config('app.glitchtip_pw', :'glitchtip_pw', false); +\o + +-- Роли. Идемпотентно: на пустом кластере это CREATE, при ручном повторном +-- прогоне — ALTER, синхронизирующий пароль с текущим окружением (важно, если +-- пароль в /opt/gendesign/.env поменяли, а том пересоздавать не хочется). +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'forgejo') THEN + EXECUTE format('CREATE ROLE forgejo LOGIN PASSWORD %L', current_setting('app.forgejo_pw')); + RAISE NOTICE 'роль forgejo создана'; + ELSE + EXECUTE format('ALTER ROLE forgejo WITH LOGIN PASSWORD %L', current_setting('app.forgejo_pw')); + RAISE NOTICE 'роль forgejo уже существовала — пароль синхронизирован с окружением'; + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'glitchtip') THEN + EXECUTE format('CREATE ROLE glitchtip LOGIN PASSWORD %L', current_setting('app.glitchtip_pw')); + RAISE NOTICE 'роль glitchtip создана'; + ELSE + EXECUTE format('ALTER ROLE glitchtip WITH LOGIN PASSWORD %L', current_setting('app.glitchtip_pw')); + RAISE NOTICE 'роль glitchtip уже существовала — пароль синхронизирован с окружением'; + END IF; +END $$; + +-- Базы. CREATE DATABASE нельзя ни внутри DO-блока (это функция, она идёт в +-- транзакции), ни вообще в транзакционном блоке — поэтому идемпотентность +-- делается через \gexec: команда собирается на стороне клиента, и если +-- WHERE NOT EXISTS отфильтровал строку, \gexec не получает ничего и молча +-- ничего не делает. ON_ERROR_STOP=1 распространяется и на \gexec. +-- +-- TEMPLATE template0 — сознательно, а не template1: template0 гарантированно +-- пуст и неизменяем, тогда как в template1 кто угодно мог доустановить объекты +-- или расширения, и они молча оказались бы внутри баз Forgejo/GlitchTip. +-- ENCODING 'UTF8' задан явно, чтобы кириллица в issue и комментариях Forgejo и +-- в заголовках событий GlitchTip не зависела от того, с какими аргументами +-- когда-нибудь пересоздадут кластер. +-- +-- OWNER — прикладная роль, а НЕ суперюзер (в отличие от базы `auth`, где +-- владельцем сознательно оставлен суперюзер): дампы сняты с --no-owner и +-- восстанавливаются от имени самой роли, значит она обязана иметь право +-- создавать объекты в своей базе. +SELECT 'CREATE DATABASE forgejo OWNER forgejo TEMPLATE template0 ENCODING ''UTF8'';' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'forgejo') +\gexec + +SELECT 'CREATE DATABASE glitchtip OWNER glitchtip TEMPLATE template0 ENCODING ''UTF8'';' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'glitchtip') +\gexec + +-- По умолчанию PostgreSQL выдаёт CONNECT на новую БД роли PUBLIC — то есть +-- glitchtip мог бы открыть сессию в базе Forgejo и наоборот. В общем кластере +-- на публично доступном хосте это лишнее. Владельцу REVOKE ничего не отнимает: +-- права владельца объекта не берутся из ACL. Команды идемпотентны. +REVOKE ALL ON DATABASE forgejo FROM PUBLIC; +REVOKE ALL ON DATABASE glitchtip FROM PUBLIC; + +COMMENT ON DATABASE forgejo IS + 'Forgejo (git.gendsgn.ru + Actions CI). Перенесена из gendesign-postgres-1 при ' + 'переезде продукта на Selectel (#3061). Бэкап — ops/backup-forgejo.sh.'; +COMMENT ON DATABASE glitchtip IS + 'GlitchTip (errors.gendsgn.ru). Перенесена из gendesign-postgres-1 при переезде ' + 'продукта на Selectel (#3061).'; + +-- Чистим GUC после использования — defense-in-depth, чтобы пароль не оставался +-- в состоянии сессии даже на короткое время. Тот же приём с \o: set_config +-- возвращает пустую строку, но лишний ряд в stdout не нужен. +\o /dev/null +SELECT set_config('app.forgejo_pw', '', false); +SELECT set_config('app.glitchtip_pw', '', false); +\o +EOSQL + +echo "infra-postgres bootstrap: роли forgejo/glitchtip и их базы готовы; можно восстанавливать дампы целевыми ролями." 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/gendesign-backup-couchdb.default.example b/ops/gendesign-backup-couchdb.default.example new file mode 100644 index 00000000..81bccbcd --- /dev/null +++ b/ops/gendesign-backup-couchdb.default.example @@ -0,0 +1,55 @@ +# Образец /etc/default/gendesign-backup-couchdb +# +# Читается ops/backup-couchdb.sh. Файл содержит ключ S3 — держать 0600 и +# владельцем того пользователя, под которым стоит крон-задача. +# +# Установка: +# sudo install -m 0600 -o gendesign -g gendesign \ +# ops/gendesign-backup-couchdb.default.example /etc/default/gendesign-backup-couchdb +# sudo -e /etc/default/gendesign-backup-couchdb # подставить реальные ключи +# +# ЭТОТ ФАЙЛ НЕОБЯЗАТЕЛЕН. Если COUCHDB_S3_ENDPOINT и COUCHDB_S3_ACCESS_KEY не +# заданы, скрипт берёт S3_* из /etc/default/gendesign-backup — того же ключа, +# которым уже выгружается основной бэкап, но под своим префиксом couchdb/. +# Заводить файл стоит, когда для волта появится ОТДЕЛЬНЫЙ узкий ключ (как у +# forgejo): своя область доступа лучше общей, просто не ценой отсутствия +# бэкапа вовсе. +# +# Если не настроено ни здесь, ни там — скрипт откажется работать и НЕ станет +# останавливать CouchDB: бэкап без выгрузки за пределы машины смысла не имеет. + +# --- S3 (либо здесь, либо фолбэк на /etc/default/gendesign-backup) ---------- +COUCHDB_S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru +COUCHDB_S3_BUCKET=gendsgn-backups +COUCHDB_S3_PREFIX=couchdb/ +COUCHDB_S3_ACCESS_KEY= +COUCHDB_S3_SECRET_KEY= + +# --- что копируем (значения по умолчанию совпадают с боевыми) --------------- +# Имена берутся из docker-compose.obsidian.yml, проект gendesign-obsidian. +#COUCHDB_CONTAINER=gendesign-couchdb +#COUCHDB_DATA_VOLUME=gendesign-obsidian_couchdb_data +#COUCHDB_CONFIG_VOLUME=gendesign-obsidian_couchdb_config + +# --- локальное хранение ----------------------------------------------------- +#LOCAL_BACKUP_DIR=/opt/gendesign/backups/couchdb +#KEEP=7 + +# --- пороги целостности ----------------------------------------------------- +# Про объём, а не про наличие файла: пустой архив весит около сотни байт и без +# порога неотличим от настоящего бэкапа. Поднимать, если волт заметно вырос. +#MIN_DATA_BYTES=1048576 +#MIN_CONFIG_BYTES=256 + +# Сколько секунд ждать ответа /_up после подъёма контейнера. +#START_TIMEOUT=60 + +# --- оповещения ------------------------------------------------------------- +# Дублировать сюда ничего не надо: notify() из ops/lib-backup.sh, не найдя +# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID в окружении, сам читает +# /etc/default/gendesign-backup. То есть канал общий с остальными бэкапами. +# Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат: +#TELEGRAM_BOT_TOKEN= +#TELEGRAM_CHAT_ID= +# Либо укажи другой файл, откуда notify() возьмёт настройки канала: +#BACKUP_ENV_FILE=/etc/default/gendesign-backup diff --git a/ops/gendesign-backup-forgejo.default.example b/ops/gendesign-backup-forgejo.default.example new file mode 100644 index 00000000..78e9d4e5 --- /dev/null +++ b/ops/gendesign-backup-forgejo.default.example @@ -0,0 +1,80 @@ +# Environment file for ops/backup-forgejo.sh (Forgejo code + DB backup, #2203). +# +# Install on the prod VM as a ROOT-OWNED, chmod-600 file that is NOT in git: +# sudo cp /opt/gendesign/ops/gendesign-backup-forgejo.default.example /etc/default/gendesign-backup-forgejo +# sudo chmod 600 /etc/default/gendesign-backup-forgejo +# sudo $EDITOR /etc/default/gendesign-backup-forgejo # fill in real S3 credentials +# +# DELIBERATELY a separate file from /etc/default/gendesign-backup (used by +# ops/backup.sh and tradein-mvp/deploy/backup-tradein-db.sh) — this backup +# uses a SEPARATE, narrower-scoped S3 service user that can ONLY PutObject +# under s3://gendsgn-backups/forgejo/*, nothing else. The prod-host writer +# key used by the other two backups (`gendsgn-backup-writer`) has root-of- +# bucket access; that key must NOT be reused here, and this key must NOT be +# put in /etc/default/gendesign-backup. +# +# THE KEY DOES NOT EXIST YET (as of #2203). Until a human creates the +# `gendsgn-backup-forgejo-writer` service user + bucket policy in the +# Selectel panel (see the #2203 PR description for the exact policy JSON — +# PutObject-only on arn:aws:s3:::gendsgn-backups/forgejo/*, explicit Deny on +# GetObject/ListBucket/Delete*) and fills in the four FORGEJO_S3_* vars +# below, ops/backup-forgejo.sh refuses to run and exits non-zero loudly. That +# is expected, not a bug. +# +# --- S3 off-site upload (Selectel S3-compatible). All four required. --- +#FORGEJO_S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru +#FORGEJO_S3_BUCKET=gendsgn-backups +#FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED +#FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED + +# --- optional overrides (defaults are sensible; uncomment only to change) --- +#FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match + # the policy's Resource path exactly +#KEEP=7 # how many recent local copies to retain + # (DB dumps and repo bundles are separate series) +#MIN_DB_DUMP_BYTES=2048 # sanity floor for the DB dump (small DB — + # config/issues/PRs/users, no git blobs) +#MIN_REPOS_BUNDLE_BYTES=10240 # sanity floor for the repo tar.gz +#LOCAL_BACKUP_DIR=/opt/gendesign/backups/forgejo +#FORGEJO_DIR=/home/gendesign/forgejo # Forgejo compose dir on the VM (NOT + # part of the gendesign git checkout) +#FORGEJO_REPOS_DIR=/home/gendesign/forgejo/data/forgejo/git/repositories + +# --- контейнер с БД Forgejo (#3061) --- +# ЗАДАВАТЬ НЕ НУЖНО почти никогда: ops/backup-forgejo.sh находит контейнер сам. +# Он перебирает PG_CONTAINER_CANDIDATES, оставляет запущенные и берёт тот, в +# чьей базе `forgejo` реально ЕСТЬ ТАБЛИЦЫ. +# +# ПОЧЕМУ НЕ КОНСТАНТА. Раньше имя было зашито (gendesign-postgres-1 — общий +# кластер основного приложения). Переезд продукта на Selectel переносит базу +# forgejo оттуда в лёгкий инфраструктурный кластер (gendesign-infra-postgres, +# сервис infra-postgres в docker-compose.prod.yml), а старый контейнер после +# переноса какое-то время ещё жив и держит свою — с этого момента протухающую — +# копию базы. Зашитое имя означало бы, что бэкап продолжает исправно дампить +# именно её: дамп непустой, маркер завершения на месте, размер выше порога — и +# ни одной новой строки с момента cutover. Порогов на СВЕЖЕСТЬ здесь нет, так +# что выяснилось бы это только при восстановлении. +# +# КОГДА ЗАДАВАТЬ ЯВНО — ровно один случай: окно переезда, когда непустая база +# forgejo есть СРАЗУ В ДВУХ запущенных контейнерах. Автоопределение тогда не +# гадает, а останавливается с ошибкой (выбрать наугад = 50% шанс забэкапить +# труп) и ждёт прямого указания, какой контейнер боевой. Момент, с которого это +# нужно, наступает НЕ на cutover'е, а РАНЬШЕ — с первой же заливки дампа в новый +# кластер (шаг 3a в комментарии к сервису infra-postgres, +# docker-compose.prod.yml): с этой секунды кандидатов двое, и ночной бэкап +# git-хоста иначе просто не снимется. Поэтому значение здесь живёт двумя фазами: +# до cutover'а — боевой ещё старый кластер: +#PG_CONTAINER=gendesign-postgres-1 +# после — новый: +#PG_CONTAINER=gendesign-infra-postgres +# Строку УБРАТЬ, как только старый контейнер погашен: дальше автоопределение +# снова однозначно и переживёт любое следующее переименование само. +# +# Явное значение берётся КАК ЕСТЬ, без проверок — это ручной override, и тот, +# кто его выставил, знает про свою инсталляцию больше. Пустое значение +# (PG_CONTAINER=) равносильно незаданному: включается автоопределение. + +# Где искать, когда PG_CONTAINER пуст. Имена через пробел; КАВЫЧКИ ОБЯЗАТЕЛЬНЫ — +# файл читается через `source`, и без них второе имя bash попытается выполнить +# как команду. Трогать только если контейнеры называются иначе, чем здесь. +#PG_CONTAINER_CANDIDATES="gendesign-postgres-1 gendesign-infra-postgres" diff --git a/ops/gendesign-backup.default.example b/ops/gendesign-backup.default.example index dea81c3c..ee6da0ad 100644 --- a/ops/gendesign-backup.default.example +++ b/ops/gendesign-backup.default.example @@ -8,14 +8,53 @@ # backup.sh sources this file if present. With NO S3 vars set, dumps stay # local-only under /opt/gendesign/backups (retention KEEP=7). Fill these in to # also push each dump off-box to S3 (recommended — local-only dies with the VM). +# backup.sh now also dumps cluster globals (roles/GRANTs, via `pg_dumpall +# --globals-only`) alongside the main dump — same S3 vars, same bucket, no +# separate config needed (#2203). +# +# tradein-mvp/deploy/backup-tradein-db.sh (separate DB, separate cron job) +# prefers its OWN env file at /etc/default/tradein-backup, but falls back to +# THIS file if that one doesn't exist — so filling in the S3 vars here also +# enables off-box upload for the tradein DB backup, unless you want the two +# DBs going to different buckets/creds (then create /etc/default/tradein-backup +# with its own S3_* vars instead). +# +# To rehearse a restore from a dump this script produced (safe, throwaway +# container, never touches prod) see ops/restore-drill.sh. +# +# Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron +# entry, see its header) alerts when a backup's sentinel goes stale. It reads +# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID from THIS file (not from +# /etc/default/gendesign-uptime — deliberately a separate config so backup +# alerting doesn't depend on the uptime watchdog's env file existing). +# SAME variable names as ops/gendesign-uptime.default.example — point both +# files at the same bot/chat if you want one Telegram destination for +# everything, or use different bots/chats if you'd rather split the noise. +# Without these two set, ops/check-backup-staleness.sh still logs, just +# doesn't send a Telegram alert. -# --- S3 off-site upload (Selectel S3-compatible). All four required to enable. --- +# --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) — +# without them, backup.sh and backup-tradein-db.sh refuse to run unless +# BACKUP_ALLOW_LOCAL_ONLY=1 is also set (see below). --- #S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru -#S3_BUCKET=gendesign-backups +#S3_BUCKET=gendsgn-backups #S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY #S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY +# --- missed-run alerting (Telegram, shared bot with uptime watchdog) --- +#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN +#TELEGRAM_CHAT_ID=123456789 + # --- optional overrides (defaults are sensible; uncomment only to change) --- -#KEEP=7 # how many recent local dumps to retain -#MIN_DUMP_BYTES=51200 # sanity floor; a dump smaller than this is treated as failed +#BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and + # accept a local-only backup (still logs a loud + # WARNING each run). Leave unset in prod. +#KEEP=7 # how many recent local dumps to retain (applies to both + # the main dump series and the globals dump series) +#MIN_DUMP_BYTES=51200 # sanity floor for the MAIN dump; a dump smaller than + # this is treated as failed. No equivalent floor for the + # globals dump — a handful of roles legitimately gzips to + # well under this. tradein-backup.sh has its own, + # separate MIN_DUMP_BYTES (default 10240) — set in + # /etc/default/tradein-backup, not here. #LOCAL_BACKUP_DIR=/opt/gendesign/backups 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/lib-backup.sh b/ops/lib-backup.sh new file mode 100755 index 00000000..c95b4165 --- /dev/null +++ b/ops/lib-backup.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# Shared helpers for ops/backup.sh, ops/backup-forgejo.sh, and +# tradein-mvp/deploy/backup-tradein-db.sh (#2203, missed-run detection). +# +# SOURCED, not executed directly — no shebang execution of its own. Inherits +# the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins + +# coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by +# notify() when Telegram vars are actually set — curl is already a hard +# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no +# new dependency). +# +# Load with (script computes its own dir so this works regardless of cron's +# CWD or which repo subdir the caller lives in): +# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# source "$SCRIPT_DIR/lib-backup.sh" # caller is in ops/ +# source "$SCRIPT_DIR/../../ops/lib-backup.sh" # caller is in tradein-mvp/deploy/ + +log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; } + +# --- notify ------------------------------------------------------------ +# Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) — +# this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN / +# TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file +# (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so +# backup alerting doesn't depend on the uptime watchdog's config being +# present, and vice versa. Point both files at the same bot/chat if you want +# one Telegram destination for everything — that's an ops choice, not this +# script's concern. +# +# No-op (logs only) when unset — this is the extension point: to wire a +# different channel later, edit ONLY this function; every caller in this repo +# goes through notify(), never curl/telegram directly. +notify() { + local text="$1" + local backup_env="${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}" + + if [[ -z "${TELEGRAM_BOT_TOKEN:-}" || -z "${TELEGRAM_CHAT_ID:-}" ]]; then + # shellcheck source=/dev/null + [[ -f "$backup_env" ]] && source "$backup_env" + fi + + if [[ -z "${TELEGRAM_BOT_TOKEN:-}" || -z "${TELEGRAM_CHAT_ID:-}" ]]; then + log "NOTIFY (telegram disabled — set TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID in ${backup_env}): $text" + notify_fallback_mail "$text" + return 0 + fi + + # #3059: одиночный curl отправлял алерт «на удачу». Замер 26.08 с Poincare — + # 3 отказа на 40 подключений (7.5%) к закреплённому (#3093) 149.154.167.220, + # все таймаутом на СТАДИИ ПОДКЛЮЧЕНИЯ. То есть каждый двадцатый-тридцатый + # алерт впустую сжигал запасной канал (почту) вместо того, чтобы просто + # переподключиться: почта — последнее средство на случай, когда Telegram + # недоступен ПО-НАСТОЯЩЕМУ, а не пропустил один SYN. + # + # Повтор безопасен: отказ происходит до отправки запроса, поэтому дубль + # сообщения практически исключён, а пропущенный алерт о неудавшемся бэкапе + # — именно то, ради предотвращения чего этот файл и существует. + local attempt + for attempt in 1 2 3; do + if curl -fsS --max-time 15 \ + -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d "chat_id=${TELEGRAM_CHAT_ID}" \ + -d "disable_web_page_preview=true" \ + --data-urlencode "text=${text}" \ + >/dev/null 2>&1; then + [[ "$attempt" -gt 1 ]] && log "telegram sendMessage: доставлено с попытки ${attempt}" + return 0 + fi + [[ "$attempt" -lt 3 ]] && sleep "${NOTIFY_RETRY_DELAY:-2}" + done + log "WARN: telegram sendMessage failed — 3 попытки подряд, пробую запасной канал" + notify_fallback_mail "$text" +} + +# --- запасной канал оповещения (#3059) ---------------------------------- +# Зачем: раньше единственным каналом был Telegram, и `|| log WARN` означало, +# что при его недоступности алерт просто ТЕРЯЛСЯ — оставалась строка в логе, +# который никто не читает, пока не случится беда. Самоскрывающаяся поломка: +# канал, которым мы узнаём о проблемах, сам и есть проблема. +# +# Это не паранойя, а замер (#3059, 2026-08-23): с нового хоста Poincare из семи +# публикуемых адресов api.telegram.org отвечает РОВНО ОДИН (149.154.167.220), +# и скан всей подсети 149.154.167.0/24 не нашёл больше ни одного. Через прокси +# Telegram не проходит вовсе — узлы российские. После переезда весь +# Telegram-канал висит на одном адресе, без запасного. +# +# Почему почта: с Poincare проверено — smtp.beget.com:465 OPEN (587 закрыт). +# Транспорт — тот же curl, который уже является жёсткой зависимостью файла; +# новых пакетов не требуется. +# +# Настраивается тем же env-файлом, что и Telegram. Не задано — ведёт себя как +# раньше. Кредов в репозитории нет: +# ALERT_SMTP_URL=smtps://smtp.beget.com:465 +# ALERT_SMTP_USER= / ALERT_SMTP_PASS= / ALERT_MAIL_FROM= / ALERT_MAIL_TO= +notify_fallback_mail() { + local text="$1" + + if [[ -z "${ALERT_SMTP_URL:-}" || -z "${ALERT_SMTP_USER:-}" \ + || -z "${ALERT_SMTP_PASS:-}" || -z "${ALERT_MAIL_FROM:-}" \ + || -z "${ALERT_MAIL_TO:-}" ]]; then + # ГРОМКО: это последний рубеж, тишина здесь = потерянный алерт. + log "АЛЕРТ НЕ ДОСТАВЛЕН (telegram недоступен, почта не настроена): $text" + return 1 + fi + + local subject body rc=0 + subject=$(printf '%s' "$text" | head -c 120 | tr '\n' ' ') + body=$(printf 'From: %s\nTo: %s\nSubject: [gendesign] %s\n\n%s\n' \ + "$ALERT_MAIL_FROM" "$ALERT_MAIL_TO" "$subject" "$text") + + # Пароль идёт через --user, поэтому stderr curl целиком не печатаем — в нём + # может оказаться строка подключения. Логируем только код возврата. + printf '%s' "$body" | curl -fsS --max-time 30 --ssl-reqd \ + --url "$ALERT_SMTP_URL" \ + --user "${ALERT_SMTP_USER}:${ALERT_SMTP_PASS}" \ + --mail-from "$ALERT_MAIL_FROM" \ + --mail-rcpt "$ALERT_MAIL_TO" \ + --upload-file - >/dev/null 2>&1 || rc=$? + + if (( rc == 0 )); then + log "Алерт доставлен запасным каналом (почта) вместо Telegram" + return 0 + fi + + log "АЛЕРТ НЕ ДОСТАВЛЕН НИ ОДНИМ КАНАЛОМ (telegram упал, curl smtp rc=${rc}): $text" + return 1 +} + +# --- sentinel (missed-run detection) ------------------------------------ +# write_sentinel — call ONLY after every guard in the caller has +# already passed (mirrors the existing "never mark success before a dump is +# verified good" discipline in backup.sh/backup-tradein-db.sh). Content is +# just an ISO-8601 UTC timestamp for human debugging; the actual staleness +# check below is based on the file's mtime, not on parsing that content. +write_sentinel() { + local sentinel_file="$1" + mkdir -p "$(dirname "$sentinel_file")" + date -u +'%Y-%m-%dT%H:%M:%SZ' > "$sentinel_file" +} + +# sentinel_age_hours — echoes the sentinel's age in whole hours on +# stdout. Returns 1 (nothing echoed) if the file doesn't exist or its mtime +# can't be read — caller treats that as "stale" (a backup that never +# succeeded is exactly the case this exists to catch). +sentinel_age_hours() { + local sentinel_file="$1" now_epoch sentinel_epoch + [[ -f "$sentinel_file" ]] || return 1 + now_epoch=$(date -u +%s) + # GNU stat (prod VM, Debian) first; BSD stat fallback (local/macOS dev). + sentinel_epoch=$(stat -c %Y "$sentinel_file" 2>/dev/null || stat -f %m "$sentinel_file" 2>/dev/null) || return 1 + echo $(( (now_epoch - sentinel_epoch) / 3600 )) +} + +# --- transition-tracked alert state -------------------------------------- +# Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a +# flat "
-o BatchMode=yes -p $SSH_PORT \\ + $CI_DEPLOY_USER@$HOST_IP 'echo ok' + 2. Только после «ok» — поменять в секретах Forgejo DEPLOY_SSH_KEY на новый + приватный ключ и DEPLOY_HOST на новый адрес. Старый ключ при этом ещё + лежит в authorized_keys и остаётся запасным входом. + 3. Прогнать деплой. Зелёный — идти дальше; красный — вернуть секреты назад, + доступ никуда не делся. + +ШАГ 1 СУЖЕНИЯ — убрать общее правило ssh. + СНАЧАЛА добавить правило для СВОЕГО адреса, иначе снимаешь себе доступ: + curl -s ifconfig.me # свой адрес, со своей машины + ufw allow from <свой-адрес> to any port $SSH_PORT proto tcp comment 'admin ssh' + Убедиться, что своё правило видно, и только потом: + ufw status numbered + ufw delete allow $SSH_PORT/tcp + Проверить, не разрывая текущую сессию, ВТОРЫМ окном: + ssh -p $SSH_PORT $CI_DEPLOY_USER@$HOST_IP 'echo ok' + У Selectel IP-KVM платный (см. ops/selectel-bootstrap.sh) — запереться здесь + стоит денег и времени. + +ШАГ 2 СУЖЕНИЯ — отозвать старый (человеческий) ключ из-под деплоя. + Делать ТОЛЬКО когда конвейер уверенно ездит CI-ключом $KEY_FP. + cp $AK $AK.bak.\$(date +%F-%H%M) + ssh-keygen -lf $AK # найти отпечаток лишнего ключа + \${EDITOR:-nano} $AK # удалить его строку руками, по одной + Руками, а не sed'ом по шаблону: authorized_keys — это файл, в котором ошибка + стоит доступа к серверу, и лишняя удалённая строка обнаружится не сразу. + +ШАГ 3 — секреты. + DEPLOY_SSH_KEY со старым ключом после шага 2 бесполезен — заменить/удалить. + Заодно пересмотреть (НЕ удаляя вслепую): GHCR_PAT, OPENAI_API_KEY, + OBJECTIVE_API_KEY, GLITCHTIP_BACKEND_DSN — всё это ездит по тем же сессиям. +================================================================================ +PLAN diff --git a/ops/split-infra-postgres.sh b/ops/split-infra-postgres.sh new file mode 100755 index 00000000..2b6e40e9 --- /dev/null +++ b/ops/split-infra-postgres.sh @@ -0,0 +1,572 @@ +#!/usr/bin/env bash +# Перенос баз `forgejo` и `glitchtip` из общего кластера продукта +# (`gendesign-postgres-1`) в лёгкий инфраструктурный (`gendesign-infra-postgres`) +# — раннбук шага 3 переезда Beget → Selectel (#3061). +# +# КОНТЕКСТ. Переезд продукта Beget (46.173.16.127) → Selectel Poincare +# (188.246.224.93), окно 30.08.2026. Контейнер `gendesign-postgres-1` уезжает +# целиком вместе с томом postgres_data (~15 ГБ), а базы forgejo (265 МБ, +# git.gendsgn.ru + Actions CI, из которого идёт сам деплой) и glitchtip (729 МБ, +# errors.gendsgn.ru) обязаны ОСТАТЬСЯ на Beget. Их новый дом — сервис +# infra-postgres из docker-compose.prod.yml (профиль `infra`), роли и пустые +# базы в нём заводит ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh. +# Полный порядок переезда (шаги 1..4) — в шапке сервиса infra-postgres в +# docker-compose.prod.yml; этот скрипт закрывает ТОЛЬКО шаг 3. +# +# ЗАПУСКАЕТСЯ РУКАМИ на Beget, до окна переезда. Не cron, не CI: он гасит +# git-хост и трекер ошибок, такое не делают по расписанию. +# +# bash /opt/gendesign/ops/split-infra-postgres.sh # СУХОЙ ПРОГОН +# bash /opt/gendesign/ops/split-infra-postgres.sh --apply # реальный перенос +# +# ПО УМОЛЧАНИЮ — СУХОЙ ПРОГОН, и это не формальность: без --apply скрипт не +# создаёт каталогов, не пишет файлов, не останавливает контейнеров и ничего не +# заливает. Он только ЧИТАЕТ (docker ps / docker inspect / SELECT'ы) и печатает, +# что сделал бы. Прогнать сухой прогон заранее — способ выяснить про забытый +# пароль в .env или несовпадение версий днём, а не ночью на заливке дампа. +# +# ЧЕГО СКРИПТ НЕ ДЕЛАЕТ СОЗНАТЕЛЬНО: +# * не переключает приложения на новый кластер (шаг 4 — правка app.ini и .env, +# печатается в конце); +# * не удаляет исходные базы — откат обязан оставаться бесплатным; +# * не удаляет дампы — печатает их пути. +# +# ИДЕМПОТЕНТНОСТЬ. Повторный прогон видит уже перенесённую базу (в приёмнике +# есть таблицы) и НЕ трогает её: молча лить дамп поверх живых данных, в которые +# после cutover уже пишет приложение, — худшее, что здесь можно сделать. +# Перезалить осознанно: --force-restore (pg_restore --clean --if-exists). Он +# отказывается работать, если cutover уже сделан (app.ini / .env указывают на +# infra-postgres), и требует ввести имена баз руками: после cutover такая +# заливка затирает данные приложений СТАРЫМ дампом, а сверка фазы 5 сравнивает +# приёмник с источником и подтверждает это как успех. +# +# СТИЛЬ. log()/notify() — общая библиотека ops/lib-backup.sh, та же, что у +# ops/backup.sh и ops/backup-forgejo.sh. notify() здесь НЕ используется: скрипт +# интерактивный, у экрана сидит человек, телеграм-алерт ему ничего не добавит. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./lib-backup.sh +source "$SCRIPT_DIR/lib-backup.sh" + +# --- конфигурация (переопределяется окружением) -------------------------- +SRC_CONTAINER="${SRC_CONTAINER:-gendesign-postgres-1}" +DST_CONTAINER="${DST_CONTAINER:-gendesign-infra-postgres}" +# Роль-владелец у обеих баз совпадает с именем базы (так их завёл initdb-скрипт), +# поэтому отдельной таблицы соответствий не нужно — везде "$db" в обеих ролях. +DATABASES="${DATABASES:-forgejo glitchtip}" +DUMP_DIR="${DUMP_DIR:-/opt/gendesign/backups/migration-3061}" +FORGEJO_DIR="${FORGEJO_DIR:-/home/gendesign/forgejo}" + +# Где на самом деле лежит app.ini. Раньше здесь подразумевалось +# "${FORGEJO_DIR}/app.ini", и такого файла на хосте НЕТ: FORGEJO_DIR — каталог +# со стеком (docker-compose.yml, раннеры), а конфиг живёт внутри тома данных, +# который монтируется в контейнер как /data. Проверено на боевом хосте: +# /home/gendesign/forgejo/data/forgejo -> /data +# app.ini = /home/gendesign/forgejo/data/forgejo/gitea/conf/app.ini +# Тихо это ломало страж assert_not_cut_over: он проверяет файл через +# `[[ -r "$ini" ]]`, несуществующий путь читается как "признака нет", и +# половина защиты от повторного --force-restore после cutover просто не +# срабатывала — молча, без единого сообщения. +FORGEJO_APP_INI="${FORGEJO_APP_INI:-}" +if [[ -z "$FORGEJO_APP_INI" ]]; then + for candidate in "${FORGEJO_DIR}/data/forgejo/gitea/conf/app.ini" "${FORGEJO_DIR}/data/gitea/conf/app.ini" "${FORGEJO_DIR}/app.ini" + do + [[ -r "$candidate" ]] && { FORGEJO_APP_INI="$candidate"; break; } + done +fi +# Писатели, которых надо погасить на время дампа. Порядок важен только тем, что +# forgejo идёт первым: это git и CI, его простой заметнее всего. +FORGEJO_CONTAINER="${FORGEJO_CONTAINER:-forgejo}" +GLITCHTIP_CONTAINERS="${GLITCHTIP_CONTAINERS:-glitchtip-web glitchtip-worker}" +# Соответствие «база → её писатели». Нужно затем, что ненайденный писатель +# блокирует дамп ИМЕННО своей базы: снимать дамп с базы, в которую продолжают +# писать, нельзя (см. проверку MISSING_WRITERS ниже). +declare -A DB_WRITERS=( + [forgejo]="$FORGEJO_CONTAINER" + [glitchtip]="$GLITCHTIP_CONTAINERS" +) +# Файл окружения продукта — читается только на предмет «cutover уже сделан?». +GENDESIGN_ENV="${GENDESIGN_ENV:-/opt/gendesign/.env}" +# Запас свободного места сверх суммарного размера баз. Дамп -Fc сжат и заведомо +# меньше базы, так что требование «влезет ещё одна полная копия» консервативно. +DISK_MARGIN_PCT="${DISK_MARGIN_PCT:-30}" + +APPLY=0 +FORCE_RESTORE=0 +ALLOW_MISSING_WRITER=0 + +# --- разбор аргументов ---------------------------------------------------- +usage() { + cat <<'USAGE' +Использование: bash ops/split-infra-postgres.sh [--apply] [--force-restore] [--dump-dir DIR] + + (без флагов) сухой прогон: только чтение и печать плана, ноль побочных эффектов + --apply реально останавливает писателей, снимает дампы и восстанавливает + --force-restore перезалить базу, в которой на приёмнике УЖЕ есть таблицы + (pg_restore --clean --if-exists). Только вместе с --apply. + Спросит подтверждение и откажется работать после cutover. + --allow-missing-writer + снять дамп базы, писатель которой не найден среди запущенных + контейнеров. По умолчанию это ОШИБКА: чаще всего контейнер + просто называется иначе и продолжает писать. + --dump-dir DIR куда класть дампы (по умолчанию /opt/gendesign/backups/migration-3061) +USAGE +} + +while (( $# > 0 )); do + case "$1" in + --apply) APPLY=1 ;; + --force-restore) FORCE_RESTORE=1 ;; + --allow-missing-writer) ALLOW_MISSING_WRITER=1 ;; + --dump-dir) shift; [[ $# -gt 0 ]] || { usage >&2; exit 2; }; DUMP_DIR="$1" ;; + -h|--help) usage; exit 0 ;; + *) echo "Неизвестный аргумент: $1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +die() { log "ОШИБКА: $*" >&2; exit 1; } +phase() { log "──────── $* ────────"; } +# Единая пометка строк, которые в сухом прогоне ТОЛЬКО печатаются. +plan() { log " [сухой прогон] сделал бы: $*"; } + +if (( FORCE_RESTORE == 1 && APPLY == 0 )); then + die "--force-restore без --apply бессмысленен: в сухом прогоне заливать нечего." +fi + +# --- стражи --force-restore ---------------------------------------------- +# pg_restore --clean --if-exists дропает объекты и льёт их заново из дампа. Если +# приложения УЖЕ переключены на приёмник, это уничтожает всё, что они записали +# после cutover — а сверка фазы 5 такую заливку ПОДТВЕРДИТ как успех: она +# сравнивает источник с приёмником, и записей, сделанных после cutover, в +# источнике никогда не было. Поэтому единственная защита — не дать запуститься. +assert_not_cut_over() { + local ini="$FORGEJO_APP_INI" hit="" + # Не нашли app.ini — это НЕ "признака нет". Раньше здесь молча пропускалась + # половина стража, и --force-restore после cutover залил бы устаревший дамп + # поверх живых данных, а сверка фазы 5 подтвердила бы это как успех. + # Лучше остановиться и заставить указать путь явно. + if [[ -z "$ini" || ! -r "$ini" ]]; then + die "не найден app.ini Forgejo — проверить, сделан ли уже cutover, нечем. Искал: ${FORGEJO_DIR}/data/forgejo/gitea/conf/app.ini, ${FORGEJO_DIR}/data/gitea/conf/app.ini, ${FORGEJO_DIR}/app.ini. Укажи путь через FORGEJO_APP_INI=... Пропускать эту проверку нельзя: именно она не даёт --force-restore залить устаревший дамп поверх данных, записанных после cutover." + fi + if grep -Eq '^[[:space:]]*HOST[[:space:]]*=[[:space:]]*infra-postgres' "$ini"; then + hit+="${ini} (HOST = infra-postgres); " + fi + if [[ -r "$GENDESIGN_ENV" ]] && grep -Eq '^[[:space:]]*GLITCHTIP_DB_HOST[[:space:]]*=[[:space:]]*infra-postgres' "$GENDESIGN_ENV"; then + hit+="${GENDESIGN_ENV} (GLITCHTIP_DB_HOST=infra-postgres); " + fi + [[ -z "$hit" ]] || die \ + "cutover уже выполнен — ${hit}приложения пишут в ${DST_CONTAINER}. --force-restore залил бы поверх их данных УСТАРЕВШИЙ дамп из ${SRC_CONTAINER}, и сверка фазы 5 подтвердила бы это как успех. Если перезалить всё же необходимо: верните HOST/GLITCHTIP_DB_HOST на postgres, погасите писателей, снимите ОТДЕЛЬНЫЙ бэкап приёмника — и только тогда повторяйте." +} + +# Второй рубеж: осознанность. Флаг легко скопировать из раннбука не думая, +# а ввод имён баз руками — нет. +confirm_force_restore() { # confirm_force_restore <базы через пробел> + local dbs answer="" + dbs="$(tr -s ' ' <<< "$1" | sed 's/^ *//; s/ *$//')" + log "--force-restore ПЕРЕЗАЛЬЁТ в ${DST_CONTAINER} базы: ${dbs}" + log "Их нынешнее содержимое в приёмнике будет удалено, отката у этого шага нет." + printf 'Введите имена баз ровно как выше для подтверждения: ' >&2 + read -r answer || true + [[ "$(tr -s ' ' <<< "$answer" | sed 's/^ *//; s/ *$//')" == "$dbs" ]] \ + || die "подтверждение не совпало — ничего не тронуто." +} + +# --- вспомогательные запросы --------------------------------------------- +# Ходим ровно тем же доступом, которым потом пойдут pg_dump/pg_restore: psql под +# прикладной ролью через `docker exec` (внутри контейнера это unix-сокет, на нём +# в официальном образе trust — пароль не нужен). Так проверка не расходится с +# делом: до чего не достучалась она, там и дамп не снимется. +q() { # q <контейнер> <база/роль> + docker exec "$1" psql -U "$2" -d "$2" -tAc "$3" 2>/dev/null +} + +# Построчные счётчики по ФАКТИЧЕСКИМ таблицам: имена берутся из системного +# каталога, а не из зашитого списка — выдуманное имя таблицы превратило бы +# сверку в самообман. count(*) считается честно (не reltuples из pg_class: там +# оценка планировщика, после свежего восстановления она вообще нулевая до +# ANALYZE), через query_to_xml — так один запрос возвращает счётчики по всем +# таблицам сразу, без генерации SQL на стороне bash. +# relkind='r' — обычные таблицы. Партиционированные родители ('p') намеренно +# исключены из СЧЁТЧИКОВ (их count(*) продублировал бы строки партиций), но +# учитываются в числе таблиц ниже. В forgejo/glitchtip партиций нет — это +# страховка на будущее, а не описание сегодняшнего дня. +SNAP_SQL=" +SELECT n.nspname || '.' || c.relname || E'\t' || + (xpath('/row/c/text()', + query_to_xml(format('SELECT count(*) AS c FROM %I.%I', n.nspname, c.relname), + false, true, '')))[1]::text +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog','information_schema') +ORDER BY 1;" + +TABLES_SQL=" +SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE c.relkind IN ('r','p') AND n.nspname NOT IN ('pg_catalog','information_schema');" + +# ========================================================================== +# ФАЗА 1. Preflight — всё, что можно проверить, не тронув ничего +# ========================================================================== +phase "ФАЗА 1: preflight" +if (( APPLY == 1 )); then + log "РЕЖИМ: --apply. Писатели будут остановлены, дампы сняты и восстановлены." +else + log "РЕЖИМ: сухой прогон (по умолчанию). Ни одного побочного эффекта: ни mkdir," + log " ни docker stop, ни записи в файл. Для реального переноса — --apply." +fi +log "Источник: ${SRC_CONTAINER} → приёмник: ${DST_CONTAINER}; базы: ${DATABASES}" + +command -v docker >/dev/null 2>&1 || die "docker не найден в PATH." + +# Источник и приёмник обязаны быть РАЗНЫМИ контейнерами. При совпадении весь +# preflight проходит (контейнер запущен, healthy, роли и базы на месте), а +# pg_restore --clean --if-exists бьёт по единственной живой копии баз. +[[ "$SRC_CONTAINER" != "$DST_CONTAINER" ]] || die \ + "источник и приёмник совпадают (${SRC_CONTAINER}). Проверьте SRC_CONTAINER / DST_CONTAINER: перенос базы в саму себя с --force-restore разрушил бы единственную копию." + +(( FORCE_RESTORE == 0 )) || assert_not_cut_over + +running="$(docker ps --format '{{.Names}}' 2>/dev/null || true)" +for c in "$SRC_CONTAINER" "$DST_CONTAINER"; do + grep -qxF -- "$c" <<< "$running" \ + || die "контейнер ${c} не запущен. Запущены: $(tr -s '[:space:]' ' ' <<< "$running")" + log " ${c}: запущен" +done + +# Приёмник обязан быть healthy — его healthcheck намеренно требует наличия ОБЕИХ +# баз (разбор «отравленного тома» — в шапке сервиса infra-postgres в +# docker-compose.prod.yml). Красный healthcheck здесь = initdb-скрипт не +# отработал, и лечится это пересозданием тома, а не заливкой дампа. +dst_health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$DST_CONTAINER" 2>/dev/null || echo unknown)" +[[ "$dst_health" == "healthy" ]] || die \ + "приёмник ${DST_CONTAINER} в состоянии '${dst_health}', а не healthy. Его healthcheck требует наличия обеих баз — красный статус значит, что initdb-скрипт не отработал (чаще всего пуст FORGEJO_DB_PASS или GLITCHTIP_DB_PASS в /opt/gendesign/.env). Лечение: заполнить пароли, ПЕРЕСОЗДАТЬ том gendesign_infra_postgres_data и поднять кластер заново (команда — в шапке docker-compose.prod.yml). Дамп в такой кластер лить нельзя." +log " ${DST_CONTAINER}: healthy" + +# Мажорная версия. postgres:16-alpine против postgis/postgis:16-3.4 — обе 16, +# формат дампа совместим один-в-один. Расхождение мажора означало бы, что +# кто-то поменял образ, и pg_restore из старшей в младшую просто не пройдёт. +src_ver="$(docker exec "$SRC_CONTAINER" psql -U postgres -tAc 'SHOW server_version_num' 2>/dev/null | tr -dc '0-9' || true)" +[[ -n "$src_ver" ]] || src_ver="$(q "$SRC_CONTAINER" forgejo 'SHOW server_version_num' | tr -dc '0-9' || true)" +dst_ver="$(q "$DST_CONTAINER" forgejo 'SHOW server_version_num' | tr -dc '0-9' || true)" +[[ -n "$src_ver" && -n "$dst_ver" ]] || die "не удалось прочитать server_version_num (src='${src_ver}', dst='${dst_ver}')." +src_major=$(( src_ver / 10000 )); dst_major=$(( dst_ver / 10000 )) +(( src_major == dst_major )) || die \ + "мажорные версии PostgreSQL не совпадают: источник ${src_major}, приёмник ${dst_major}. Перенос дампом между мажорами этим скриптом не поддержан — сначала выровняйте образы." +log " версии совпадают: PostgreSQL ${src_major} в обоих кластерах" + +# Роли и базы в приёмнике. Успешный коннект ролью $db в базу $db разом +# доказывает и существование роли, и существование базы, и право входа — +# то есть ровно те три вещи, на которых упало бы восстановление. +total_bytes=0 +declare -A ALREADY=() SRC_TABLES=() +for db in $DATABASES; do + src_t="$(q "$SRC_CONTAINER" "$db" "$TABLES_SQL" | tr -dc '0-9' || true)" + [[ -n "$src_t" ]] || die "в источнике ${SRC_CONTAINER} база ${db} под ролью ${db} недоступна." + (( src_t > 0 )) || die "в источнике ${SRC_CONTAINER} база ${db} ПУСТА (0 таблиц) — переносить нечего, проверьте имя контейнера." + SRC_TABLES["$db"]="$src_t" + + dst_t="$(q "$DST_CONTAINER" "$db" "$TABLES_SQL" | tr -dc '0-9' || true)" + [[ -n "$dst_t" ]] || die \ + "в приёмнике ${DST_CONTAINER} нет роли ${db} и/или базы ${db}. Их заводит ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh, и только на ПУСТОМ томе. Лечение — пересоздать том gendesign_infra_postgres_data и поднять кластер заново с заполненными паролями." + ALREADY["$db"]="$dst_t" + + size="$(q "$SRC_CONTAINER" "$db" "SELECT pg_database_size('${db}')" | tr -dc '0-9' || echo 0)" + total_bytes=$(( total_bytes + size )) + if (( dst_t > 0 )); then + log " ${db}: источник ${src_t} таблиц ($(( size / 1024 / 1024 )) МБ); в приёмнике УЖЕ ${dst_t} таблиц" + else + log " ${db}: источник ${src_t} таблиц ($(( size / 1024 / 1024 )) МБ); приёмник пуст — заготовка на месте" + fi +done + +# Место на диске. Каталога дампов может ещё не быть (в сухом прогоне мы его и не +# создаём) — меряем ближайшего существующего родителя, файловая система у них +# одна и та же. +probe_dir="$DUMP_DIR" +while [[ ! -d "$probe_dir" && "$probe_dir" != "/" ]]; do probe_dir="$(dirname "$probe_dir")"; done +free_bytes=$(( $(df -Pk "$probe_dir" | awk 'NR==2{print $4}') * 1024 )) +need_bytes=$(( total_bytes + total_bytes * DISK_MARGIN_PCT / 100 )) +log " место в ${probe_dir}: свободно $(( free_bytes / 1024 / 1024 )) МБ, нужно ≥ $(( need_bytes / 1024 / 1024 )) МБ (сумма баз + ${DISK_MARGIN_PCT}%)" +(( free_bytes >= need_bytes )) || die "недостаточно места под дампы в ${probe_dir}." + +# Писатели. Ненайденный контейнер трактуется как ОПАСНОСТЬ, а не как удобство: +# «уже погашен» и «называется иначе и пишет прямо сейчас» с этой стороны +# выглядят одинаково, а последствия у них противоположные. +WRITERS="$FORGEJO_CONTAINER $GLITCHTIP_CONTAINERS" +declare -A MISSING_WRITERS=() +for c in $WRITERS; do + if grep -qxF -- "$c" <<< "$running"; then + log " писатель ${c}: запущен, будет остановлен" + continue + fi + log " писатель ${c}: НЕ найден среди запущенных" + for db in $DATABASES; do + if [[ " ${DB_WRITERS[$db]:-} " == *" $c "* ]]; then + MISSING_WRITERS["$db"]+="${c} " + fi + done +done + +# Все базы уже на месте → дампить и заливать нечего, остаётся сверка. +pending="" +# `|| true` обязателен: под `set -e` ложное (( )) в конце тела цикла уронило бы +# весь скрипт ровно в счастливом случае «всё уже перенесено». +for db in $DATABASES; do + (( ${ALREADY[$db]} == 0 || FORCE_RESTORE == 1 )) && pending+="${db} " || true +done +if [[ -z "$pending" ]]; then + log "ВСЕ базы уже перенесены (в приёмнике есть таблицы). Останов писателей и" + log "дамп пропускаю — повторный прогон ничего не затирает. Перехожу к сверке." + log "Осознанно перезалить: --apply --force-restore." +else + log "К переносу: ${pending}" + # Дамп базы, писатель которой не погашен, теряет всё записанное после + # снимка, и сверка фазы 5 этого НЕ покажет: слева в ней стоит замороженный + # SNAP_BEFORE, снятый в тот же момент, что и дамп, справа — приёмник; при + # любом объёме дозаписи они совпадут. Единственный шанс поймать — здесь. + for db in $pending; do + miss="${MISSING_WRITERS[$db]:-}" + [[ -n "$miss" ]] || continue + if (( ALLOW_MISSING_WRITER == 1 )); then + log " WARN: писатели базы ${db} не найдены (${miss}) — продолжаю по --allow-missing-writer. Ответственность за то, что в ${db} никто не пишет, на вас." + else + die "писатели базы ${db} не найдены среди запущенных: ${miss}. Скорее всего контейнер называется иначе (посмотрите docker ps и переопределите FORGEJO_CONTAINER / GLITCHTIP_CONTAINERS) — тогда он продолжает писать, и всё записанное после дампа потеряется на cutover молча. Если писатель действительно погашен заранее — повторите с --allow-missing-writer." + fi + done + # Подтверждение перезаливки — только для баз, которые реально будут дропнуты. + if (( FORCE_RESTORE == 1 )); then + clean_dbs="" + for db in $pending; do + (( ${ALREADY[$db]} > 0 )) && clean_dbs+="${db} " || true + done + [[ -z "$clean_dbs" ]] || confirm_force_restore "$clean_dbs" + fi +fi + +ts="$(date -u +'%Y%m%d_%H%M%S')" +RUN_DIR="${DUMP_DIR}/${ts}" + +# ========================================================================== +# ФАЗА 2. Останов писателей +# ========================================================================== +if [[ -n "$pending" ]]; then + phase "ФАЗА 2: останов писателей" + # Гасим ИМЕННО контейнеры, а не `docker compose stop`: Forgejo живёт своим + # стеком в /home/gendesign/forgejo (в этом репозитории его compose-файла + # нет), GlitchTip — в стеке продукта, и угадывать имена сервисов в двух + # разных компоузах незачем — имя контейнера здесь однозначно и стабильно + # (container_name зафиксирован в docker-compose.prod.yml). + # Зачем вообще гасить: дамп с живого писателя даёт консистентный снимок + # (pg_dump работает в одной транзакции), но всё, что приложение запишет + # ПОСЛЕ снимка, до нового кластера не доедет и потеряется на cutover. + for c in $WRITERS; do + if ! grep -qxF -- "$c" <<< "$running"; then + log " ${c}: не запущен — пропускаю" + continue + fi + if (( APPLY == 1 )); then + log " останавливаю ${c}" + docker stop "$c" >/dev/null || die "не смог остановить ${c}." + still="$(docker ps --format '{{.Names}}' | grep -cxF -- "$c" || true)" + (( still == 0 )) || die "${c} всё ещё в docker ps после stop." + log " ${c}: остановлен" + else + plan "docker stop ${c}" + fi + done +fi + +# ========================================================================== +# ФАЗА 3. Снимок «до» и дампы +# ========================================================================== +declare -A SNAP_BEFORE=() DUMP_FILE=() +if [[ -n "$pending" ]]; then + phase "ФАЗА 3: снимок «до» и дампы" + if (( APPLY == 1 )); then + # umask 077: в дампе forgejo — хеши паролей, access-токены и секреты + # вебхуков, в glitchtip — DSN и тела событий. Дампы намеренно НЕ + # удаляются и попадают в общий бэкап каталога backups, значит лежать они + # обязаны нечитаемыми для прочих пользователей хоста. + umask 077 + mkdir -p "$RUN_DIR" + chmod 700 "$RUN_DIR" # каталог мог существовать с прошлого прогона + log "Каталог дампов: ${RUN_DIR}" + else + plan "mkdir -p ${RUN_DIR}" + fi + + for db in $pending; do + # Снимок снимается ПОСЛЕ останова писателей — иначе счётчики поехали бы + # между «до» и «после» по совершенно законной причине, и сверка перестала + # бы что-либо доказывать. + SNAP_BEFORE["$db"]="$(q "$SRC_CONTAINER" "$db" "$SNAP_SQL" || true)" + [[ -n "${SNAP_BEFORE[$db]}" ]] || die "не смог снять счётчики по базе ${db} в источнике." + log " ${db}: снимок «до» — таблиц $(grep -c . <<< "${SNAP_BEFORE[$db]}"), строк всего $(awk -F'\t' '{s+=$2} END{print s+0}' <<< "${SNAP_BEFORE[$db]}")" + + f="${RUN_DIR}/${db}_${ts}.dump" + DUMP_FILE["$db"]="$f" + if (( APPLY == 1 )); then + log " снимаю дамп ${db} → ${f}" + # -Fc: собственный формат, сжат, восстанавливается pg_restore с + # --no-owner. --no-owner/--no-privileges уже на дампе: в новом кластере + # ни ролей-хозяев чужих объектов, ни исходных ACL воспроизводить не + # нужно — владельцем станет роль, которой мы восстанавливаем. + docker exec "$SRC_CONTAINER" pg_dump -U "$db" -d "$db" -Fc --no-owner --no-privileges > "$f" \ + || die "pg_dump базы ${db} упал; файл ${f} оставлен для разбора." + chmod 600 "$f" + [[ -s "$f" ]] || die "дамп ${db} нулевого размера → ${f}." + # Дамп -Fc бинарный, gzip-проверка verify_dump_integrity() из + # lib-backup.sh к нему неприменима. Эквивалент по смыслу — прочитать + # оглавление: битый/обрезанный архив на этом и споткнётся, а базы + # такое чтение не касается вовсе. + docker exec -i "$DST_CONTAINER" pg_restore -l < "$f" > /dev/null \ + || die "дамп ${db} не читается pg_restore -l (обрезан?) → ${f}." + log " ${db}: дамп OK, $(du -h "$f" | cut -f1) ($(wc -c < "$f") байт)" + # Контрольный пересъём. Проверка писателей выше ловит «контейнер + # назван иначе», но не ловит писателя вне docker (ручной psql, + # забытый воркер на хосте, второй экземпляр). Если источник изменился + # между SNAP_BEFORE и этой строкой — дамп уже неполон, а сверка фазы + # 5 сравнивает приёмник именно с SNAP_BEFORE и промолчит. + snap_after_dump="$(q "$SRC_CONTAINER" "$db" "$SNAP_SQL" || true)" + if [[ "$snap_after_dump" != "${SNAP_BEFORE[$db]}" ]]; then + diff <(printf '%s\n' "${SNAP_BEFORE[$db]}") <(printf '%s\n' "$snap_after_dump") | sed -n '1,20p' >&2 || true + die "источник ${db} изменился во время дампа — в него кто-то ещё пишет (см. расхождение выше). Найдите и погасите писателя, затем повторите; дамп ${f} использовать нельзя." + fi + else + plan "pg_dump -U ${db} -d ${db} -Fc из ${SRC_CONTAINER} → ${f}" + fi + done +fi + +# ========================================================================== +# ФАЗА 4. Восстановление ЦЕЛЕВОЙ РОЛЬЮ +# ========================================================================== +if [[ -n "$pending" ]]; then + phase "ФАЗА 4: восстановление" + # ПОЧЕМУ РОЛЬЮ, А НЕ СУПЕРЮЗЕРОМ — разбор в шапке + # ops/db-bootstrap/infra-postgres/01-roles-and-databases.sh: дамп снят с + # --no-owner, то есть владельцем восстановленных объектов становится роль, + # ОТ ИМЕНИ КОТОРОЙ идёт восстановление. Суперюзер забрал бы себе все + # таблицы, и приложение получило бы "permission denied for table" на первой + # же записи — уже после cutover, когда откатываться поздно. + for db in $pending; do + f="${DUMP_FILE[$db]}" + restore_args=( -U "$db" -d "$db" --no-owner --no-privileges ) + if (( ${ALREADY[$db]} > 0 )); then + (( FORCE_RESTORE == 1 )) || die "внутренняя ошибка: ${db} не должна была попасть в очередь." + log " ${db}: в приёмнике уже ${ALREADY[$db]} таблиц, --force-restore → добавляю --clean --if-exists" + restore_args+=( --clean --if-exists ) + fi + if (( APPLY == 0 )); then + plan "pg_restore ${restore_args[*]} < ${f} (в ${DST_CONTAINER}, ролью ${db}, НЕ суперюзером)" + continue + fi + rlog="${RUN_DIR}/${db}_restore.log" + rc=0 + docker exec -i "$DST_CONTAINER" pg_restore "${restore_args[@]}" < "$f" > "$rlog" 2>&1 || rc=$? + errors=$(grep -c '^pg_restore: error' "$rlog" 2>/dev/null || true); errors=${errors:-0} + warns=$(grep -c '^pg_restore: warning' "$rlog" 2>/dev/null || true); warns=${warns:-0} + log " ${db}: pg_restore rc=${rc}, ошибок ${errors}, предупреждений ${warns}, лог ${rlog}" + if (( errors > 0 || rc != 0 )); then + sed -n '1,20p' "$rlog" >&2 + log "ПОДСКАЗКА: 'permission denied to create extension' — недоверенное расширение владелец базы поставить не может. Лечится разово: создать его суперюзером кластера и повторить прогон с --force-restore." >&2 + die "восстановление ${db} прошло с ошибками — см. ${rlog}. Исходная база НЕ тронута, откат бесплатный." + fi + # ANALYZE не для красоты: после восстановления статистики нет вообще, и + # первый же запрос приложения пошёл бы по плану, построенному вслепую. + docker exec "$DST_CONTAINER" psql -U "$db" -d "$db" -qc 'ANALYZE' >/dev/null 2>&1 \ + || log " WARN: ANALYZE по ${db} не прошёл — не блокирует, но прогоните вручную" + done +fi + +# ========================================================================== +# ФАЗА 5. Сверка до/после +# ========================================================================== +phase "ФАЗА 5: сверка" +mismatch=0 +for db in $DATABASES; do + # В сухом прогоне сверять нечего у тех баз, которые ещё предстоит перенести: + # приёмник пуст по определению, расхождение было бы шумом, а не находкой. + if (( APPLY == 0 )) && [[ " $pending " == *" $db "* ]]; then + log " ${db}: сухой прогон — дамп не заливался, сверять нечего." + log " ${db}: в источнике таблиц ${SRC_TABLES[$db]}" + continue + fi + before="${SNAP_BEFORE[$db]:-}" + # Для базы, которую этот прогон не переносил (уже была на месте), «до» берём + # из источника прямо сейчас: старый кластер мы не трогали, счётчики в нём — + # это и есть эталон на момент переноса, пока приложение не переключено. + # ⚠️ После cutover эталоном они быть перестают: пишут уже в новый кластер, + # и расхождение здесь будет означать не потерю, а нормальную работу. + [[ -n "$before" ]] || before="$(q "$SRC_CONTAINER" "$db" "$SNAP_SQL" || true)" + after="$(q "$DST_CONTAINER" "$db" "$SNAP_SQL" || true)" + b_t=$(grep -c . <<< "$before" || true); a_t=$(grep -c . <<< "$after" || true) + b_r=$(awk -F'\t' '{s+=$2} END{print s+0}' <<< "$before") + a_r=$(awk -F'\t' '{s+=$2} END{print s+0}' <<< "$after") + log " ${db}: таблиц ${b_t} → ${a_t}; строк ${b_r} → ${a_r}" + if [[ "$before" == "$after" ]]; then + log " ${db}: СОВПАДАЕТ построчно по всем таблицам" + else + mismatch=1 + log " ${db}: РАСХОЖДЕНИЕ (слева источник, справа приёмник):" >&2 + diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") | sed -n '1,40p' >&2 || true + fi +done +(( mismatch == 0 )) || die "сверка не сошлась — НЕ переключайте приложения. Исходные базы целы, откат = просто запустить контейнеры обратно." +log "Сверка возражений не имеет." + +# ========================================================================== +# ФАЗА 6. Что дальше — РУКАМИ +# ========================================================================== +phase "ФАЗА 6: ручные шаги (скрипт их НЕ делает)" +cat </dev/null 2>&1 \ - || log "WARN: telegram sendMessage failed" + # #3059: путь до Telegram теряет соединения. Замер 26.08 с Poincare — 40 + # подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.154.167.220: 3 отказа (7.5%), все + # таймаутом на установке соединения; успешные при этом стабильны (0.14-0.17 с). + # Три остальных дата-центра Telegram с Selectel недостижимы вовсе, так что + # запасного адреса нет — потери на единственном рабочем неустранимы сетью. + # + # Раньше здесь был ОДИН curl, и `|| log WARN` означал, что каждый такой отказ + # ТЕРЯЕТ алерт целиком: уведомление о падении прода не приходит, остаётся + # строка в логе, который читают уже после аварии. Watchdog, который сам себя + # не может дозваться, — худший вид самоскрывающейся поломки: чем хуже дела, + # тем вероятнее, что о них не сообщат. + # + # Цикл, а не `curl --retry`: ниже в этом же файле проверки уже повторяются + # ровно такой конструкцией (см. `for attempt in $(seq 1 ...)`), и семантика + # `--max-time` при ретраях curl зависит от версии. Здесь таймаут заведомо + # применяется к КАЖДОЙ попытке. + # + # Дубль вместо потери — осознанный размен: sendMessage не идемпотентен, но + # замер показал, что отказы происходят на СТАДИИ ПОДКЛЮЧЕНИЯ, до отправки + # запроса, так что повтор почти никогда не дублирует уже доставленное + # сообщение. А продублированный алерт безвреден, пропущенный — нет. + local attempt + for attempt in 1 2 3; do + if curl -fsS --max-time "$CURL_TIMEOUT" \ + -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -d "chat_id=${TELEGRAM_CHAT_ID}" \ + -d "disable_web_page_preview=true" \ + --data-urlencode "text=${text}" \ + >/dev/null 2>&1; then + [[ "$attempt" -gt 1 ]] && log "telegram sendMessage: доставлено с попытки ${attempt}" + return 0 + fi + [[ "$attempt" -lt 3 ]] && sleep "${NOTIFY_RETRY_DELAY:-2}" + done + log "WARN: telegram sendMessage failed — 3 попытки подряд, алерт НЕ ДОСТАВЛЕН" } # --- state helpers (last status per check) --- diff --git a/scripts/check-caddy-snippet-mounts.py b/scripts/check-caddy-snippet-mounts.py new file mode 100644 index 00000000..29fdbe64 --- /dev/null +++ b/scripts/check-caddy-snippet-mounts.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""Гейт: каждый `import` в Caddy-конфигах покрыт volume-маунтом сервиса `caddy:` (#3102). + +ПОЧЕМУ. PR #3102 (observability) добавил `caddy/metrics-ingest.caddy.snippet` и +`caddy/metrics-ui.caddy.snippet` и завёл `import ../metrics-*.caddy.snippet` в +`caddy/sites/infra.caddy` — но НЕ добавил bind-mount этих файлов в +`docker-compose.prod.yml`. Каталог `caddy/` внутрь контейнера ЦЕЛИКОМ не +пробрасывается — только пофайлово (`caddy/users.caddy.snippet`) плюс каталоги +`caddy/local` и `caddy/sites`. Итог постмортема 2026-08-26: caddy не смог +адаптировать конфиг (`File to import not found: ../metrics-ingest.caddy.snippet`), +ушёл в restart-loop — и легли ВСЕ сайты хоста, а не только metrics.gendsgn.ru. + +ЧТО ДЕЛАЕТ. Собирает бинд-маунты (`./host:...` -> `/container...`) сервиса +`caddy:` из docker-compose.prod.yml, парсит все директивы `import ...` в +Caddyfile + `caddy/sites/*.caddy` + `caddy/*.caddy.snippet`, резолвит каждый +импорт относительно КОНТЕЙНЕРНОГО пути файла, который его содержит (Caddy сам +резолвит import именно так — от каталога импортирующего файла, а не от CWD), +и падает, если результат не покрыт ни одним volume-маунтом. + +Игнорируются: именованные сниппеты Caddy (`(name) { ... }` + `import name` — +это не файл, а ссылка на блок внутри того же Caddyfile). Glob/placeholder- +импорты (`caddy/sites/{$CADDY_SITES:*}.caddy`, `caddy/local/*.caddy`) +проверяются по КАТАЛОГУ — Caddy подставляет реальные имена файлов в рантайме, +а на этом уровне достаточно того, что каталог смонтирован. + +ГРАНИЦА: это статический разбор регулярками, а не настоящий парсер Caddyfile — +достаточно для плоских `import ` в один токен, как во всех текущих +конфигах репозитория. Импорты с переносами строк/кавычками не распознаются. + +Запуск: python3 scripts/check-caddy-snippet-mounts.py [--selftest] +""" + +from __future__ import annotations + +import posixpath +import re +import sys +from pathlib import Path + +COMPOSE_FILE = Path("docker-compose.prod.yml") +CADDYFILE = Path("Caddyfile") +SITES_DIR = Path("caddy/sites") +SNIPPETS_DIR = Path("caddy") + +IMPORT_RE = re.compile(r"^\s*import\s+(\S+)") +SNIPPET_DEF_RE = re.compile(r"^\s*\(([^)]+)\)\s*\{") +# "- ./host/path:/container/path" или тот же вид с ":ro" на конце. +# Именованные тома (без "./") сюда не попадают — сверять их не с чем. +VOLUME_ITEM_RE = re.compile(r"^-\s*(\./\S+?):(/\S+?)(?::ro)?\s*$") + + +def parse_caddy_bind_mounts(compose_text: str) -> list[tuple[str, str]]: + """-> [(host_rel_path, container_path)] бинд-маунтов сервиса `caddy:`. + + Отступы не хардкодим числом — как и в check-workflow-ports.py, просто + запоминаем фактический отступ строки-заголовка секции и выходим из неё, + как только встречена строка на том же или меньшем отступе. + """ + mounts: list[tuple[str, str]] = [] + in_service = False + in_volumes = False + service_indent = 0 + volumes_indent = 0 + + for raw in compose_text.splitlines(): + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue + indent = len(raw) - len(raw.lstrip()) + + if not in_service: + if re.match(r"^\s*caddy:\s*$", raw): + in_service = True + service_indent = indent + continue + + if indent <= service_indent: + # Началось описание следующего сервиса (или конец блока services:). + in_service = False + in_volumes = False + continue + + if in_volumes and (not stripped.startswith("- ") or indent <= volumes_indent): + in_volumes = False + + if stripped == "volumes:": + in_volumes = True + volumes_indent = indent + continue + + if in_volumes: + m = VOLUME_ITEM_RE.match(stripped) + if m: + mounts.append((m.group(1)[2:], m.group(2))) # срез "./" + return mounts + + +def resolve_container_path(rel_path: str, mounts: list[tuple[str, str]]) -> str | None: + """Repo-relative POSIX-путь -> путь ВНУТРИ контейнера caddy, если он покрыт + файловым маунтом (точное совпадение) или каталожным (общий префикс). + None — если файл ничем не смонтирован.""" + for host, container in mounts: + if host == rel_path: + return container + best: tuple[str, str] | None = None + for host, container in mounts: + prefix = host.rstrip("/") + "/" + if rel_path.startswith(prefix): + candidate = container.rstrip("/") + "/" + rel_path[len(prefix) :] + if best is None or len(host) > len(best[0]): + best = (host, candidate) + return best[1] if best else None + + +def is_covered(target: str, mounts: list[tuple[str, str]]) -> bool: + """True, если абсолютный контейнерный путь `target` покрыт файловым или + каталожным маунтом сервиса caddy:.""" + for _host, container in mounts: + if target == container: + return True + if target.startswith(container.rstrip("/") + "/"): + return True + return False + + +def find_snippet_definitions(text: str) -> set[str]: + """Имена именованных сниппетов Caddy, объявленных `(name) { ... }` — это + ссылка на блок, а не на файл, `import name` их резолвить не нужно.""" + return {m.group(1).strip() for m in SNIPPET_DEF_RE.finditer(text)} + + +def find_imports(text: str) -> list[tuple[int, str]]: + """-> [(номер строки, аргумент import)] для директив `import `.""" + hits: list[tuple[int, str]] = [] + for lineno, raw in enumerate(text.splitlines(), 1): + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue + m = IMPORT_RE.match(raw) + if m: + hits.append((lineno, m.group(1))) + return hits + + +def resolve_import_target(importer_container_dir: str, spec: str) -> str: + """Абсолютный контейнерный путь, на который указывает import `spec`, + относительно каталога импортирующего файла. Для glob/placeholder-импортов + (`*`, `{...}`) возвращает КАТАЛОГ — Caddy сам находит файлы внутри него, + проверять есть смысл только то, что каталог смонтирован.""" + is_pattern = "*" in spec or "{" in spec + check_spec = posixpath.dirname(spec) if is_pattern else spec + return posixpath.normpath(posixpath.join(importer_container_dir, check_spec)) + + +def check_file( + rel_path: str, + text: str, + mounts: list[tuple[str, str]], + snippet_names: set[str], +) -> list[str]: + """-> список сообщений об ошибках для одного Caddy-конфига.""" + errors: list[str] = [] + importer_container = resolve_container_path(rel_path, mounts) + if importer_container is None: + errors.append( + f"{rel_path}: сам файл не покрыт ни одним volume-маунтом сервиса " + f"caddy: в docker-compose.prod.yml — импорты внутри него нельзя " + f"проверить (и Caddy их тоже не увидит)" + ) + return errors + + importer_dir = posixpath.dirname(importer_container) + for lineno, spec in find_imports(text): + if spec in snippet_names: + continue # именованный сниппет Caddy, не файл + target = resolve_import_target(importer_dir, spec) + if not is_covered(target, mounts): + what = "каталог" if ("*" in spec or "{" in spec) else "файл" + errors.append( + f"{rel_path}:{lineno}: import {spec!r} -> {what} {target} НЕ " + f"смонтирован в сервисе caddy: (docker-compose.prod.yml). Caddy " + f"упадёт 'File to import not found' и уйдёт в restart-loop, " + f"роняя ВСЕ сайты хоста (постмортем #3102, 2026-08-26) — добавь " + f"bind-mount рядом с соседними ./caddy/... строками." + ) + return errors + + +def selftest() -> None: + compose_snippet = ( + "services:\n" + " postgres:\n" + " volumes:\n" + " - postgres_data:/var/lib/postgresql/data\n" + " caddy:\n" + " image: caddy:2\n" + " volumes:\n" + " - ./Caddyfile:/etc/caddy/Caddyfile:ro\n" + " - ./caddy/users.caddy.snippet:/etc/caddy/caddy/users.caddy.snippet:ro\n" + " - ./caddy/local:/etc/caddy/caddy/local:ro\n" + " - ./caddy/sites:/etc/caddy/caddy/sites:ro\n" + " - ./preview:/srv/preview:ro\n" + " - caddy_data:/data\n" + " glitchtip-web:\n" + " volumes:\n" + " - ./should-not-leak:/should/not/leak\n" + ) + mounts = parse_caddy_bind_mounts(compose_snippet) + assert ("Caddyfile", "/etc/caddy/Caddyfile") in mounts + assert ("caddy/sites", "/etc/caddy/caddy/sites") in mounts + # именованный том без "./" — пропущен, соседний сервис — не подмешан + assert all(host != "should-not-leak" for host, _ in mounts) + assert all("should" not in c for _, c in mounts) + + # resolve_container_path: точный файловый маунт и маунт каталога-родителя + assert resolve_container_path("Caddyfile", mounts) == "/etc/caddy/Caddyfile" + assert ( + resolve_container_path("caddy/sites/infra.caddy", mounts) + == "/etc/caddy/caddy/sites/infra.caddy" + ) + assert resolve_container_path("caddy/metrics-ingest.caddy.snippet", mounts) is None + + # is_covered: точное совпадение и вложенность в каталожный маунт + assert is_covered("/etc/caddy/Caddyfile", mounts) + assert is_covered("/etc/caddy/caddy/sites/apps.caddy", mounts) + assert not is_covered("/etc/caddy/caddy/metrics-ingest.caddy.snippet", mounts) + + # find_imports: находит директиву, пропускает комментарий + assert find_imports("import caddy/users.caddy.snippet\n# import x\n") == [ + (1, "caddy/users.caddy.snippet") + ] + + # find_snippet_definitions + именованные сниппеты не считаются файлами + named = find_snippet_definitions("(common_headers) {\n header X-Test 1\n}\n") + assert named == {"common_headers"} + + # resolve_import_target: обычный файл и glob/placeholder -> каталог + assert ( + resolve_import_target("/etc/caddy", "caddy/users.caddy.snippet") + == "/etc/caddy/caddy/users.caddy.snippet" + ) + assert ( + resolve_import_target("/etc/caddy/caddy/sites", "../metrics-ingest.caddy.snippet") + == "/etc/caddy/caddy/metrics-ingest.caddy.snippet" + ) + assert ( + resolve_import_target("/etc/caddy", "caddy/sites/{$CADDY_SITES:*}.caddy") + == "/etc/caddy/caddy/sites" + ) + assert resolve_import_target("/etc/caddy", "caddy/local/*.caddy") == "/etc/caddy/caddy/local" + + # check_file: воспроизводим ровно баг #3102 — infra.caddy импортирует + # ../metrics-ingest.caddy.snippet, которого нет в mounts выше -> падение. + infra_text = ( + "metrics.gendsgn.ru {\n" + " handle_path /ingest/prometheus/* {\n" + " import ../metrics-ingest.caddy.snippet\n" + " reverse_proxy prometheus:9090\n" + " }\n" + "}\n" + ) + broken = check_file("caddy/sites/infra.caddy", infra_text, mounts, set()) + assert len(broken) == 1 and "metrics-ingest.caddy.snippet" in broken[0] + + # тот же файл — но с добавленным маунтом снипета — гейт зелёный + fixed_mounts = mounts + [ + ("caddy/metrics-ingest.caddy.snippet", "/etc/caddy/caddy/metrics-ingest.caddy.snippet") + ] + assert check_file("caddy/sites/infra.caddy", infra_text, fixed_mounts, set()) == [] + + # именованный сниппет игнорируется, даже если файла с таким именем нет + named_import_text = "(mysnippet) {\n respond 200\n}\nexample.com {\n import mysnippet\n}\n" + assert check_file("Caddyfile", named_import_text, mounts, {"mysnippet"}) == [] + + # файл, который сам не смонтирован — отдельная явная ошибка + orphan = check_file("caddy/unmounted.caddy", "import caddy/users.caddy.snippet\n", mounts, set()) + assert len(orphan) == 1 and "не покрыт" in orphan[0] + + print("selftest OK") + + +def main() -> int: + if "--selftest" in sys.argv: + selftest() + return 0 + + if not COMPOSE_FILE.is_file() or not CADDYFILE.is_file(): + print( + f"::error::{COMPOSE_FILE} или {CADDYFILE} не найден — " + f"запускать из корня репозитория" + ) + return 1 + + mounts = parse_caddy_bind_mounts(COMPOSE_FILE.read_text(encoding="utf-8")) + if not mounts: + print( + f"::error file={COMPOSE_FILE}::не найдено ни одного bind-mount " + f"(`./host:/container`) в сервисе caddy: — гейт не может ничего " + f"проверить. Структура файла изменилась?" + ) + return 1 + + files: dict[str, str] = {"Caddyfile": CADDYFILE.read_text(encoding="utf-8")} + for p in sorted(SITES_DIR.glob("*.caddy")): + files[p.as_posix()] = p.read_text(encoding="utf-8") + for p in sorted(SNIPPETS_DIR.glob("*.caddy.snippet")): + files[p.as_posix()] = p.read_text(encoding="utf-8") + + snippet_names = set() + for text in files.values(): + snippet_names |= find_snippet_definitions(text) + + failed = False + for rel_path, text in files.items(): + for err in check_file(rel_path, text, mounts, snippet_names): + failed = True + print(f"::error file={rel_path}::{err}") + + if failed: + return 1 + print( + f"✓ все import покрыты volume-маунтами caddy: " + f"({len(files)} конфигов, {len(mounts)} bind-маунтов)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-latest-image-revision.sh b/scripts/check-latest-image-revision.sh new file mode 100755 index 00000000..d69b2084 --- /dev/null +++ b/scripts/check-latest-image-revision.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Гард свежести образа :latest перед деплоем (#2950). +# +# ЗАЧЕМ. Деплой катит IMAGE_TAG=latest. Forgejo отменяет ещё не стартовавший +# `deploy` предыдущего run'а той же concurrency-группы, когда приходит новый push +# (cancel-in-progress: false объявлен, но на pending-job не действует — 21.08.2026 +# 10:35:13 «Has been cancelled» через 4 с после старта следующего run'а). Следующий +# run — например ops-only, с пропущенными билдами — катит :latest как есть. 21.08 +# прод получил новый код только потому, что билды предшественника успели за 70 с +# до pull'а; иначе голова main была бы зелёной, а прод — на старом образе. +# +# ЧТО ПРОВЕРЯЕТ. Образ :latest в registry несёт метку org.opencontainers.image.revision +# (ставится build-push-action в deploy.yml / deploy-tradein.yml). Гард требует, чтобы +# эта ревизия СОДЕРЖАЛА последний коммит, трогавший пути компонента (git log по +# checkout'у run'а): то есть :latest не старше того, что обязано было собраться. +# Ревизия новее — нормально (workflow_dispatch собирает всё на голове). +# Пока условие не выполнено — ждёт (билд предшественника ещё пушит) до таймаута, +# затем падает громко: лучше красный деплой, чем тихий откат. +# +# Использование: check-latest-image-revision.sh -- ... +# CHECK_LATEST_SLEEP_S — пауза между опросами (по умолчанию 20; тесты ставят меньше). +# Коды: 0 — свежий; 1 — отстаёт/метки нет/registry не ответил до таймаута; 2 — usage. +set -euo pipefail + +IMAGE="${1:-}"; TIMEOUT="${2:-}" +if [ -z "$IMAGE" ] || [ -z "$TIMEOUT" ] || [ "${3:-}" != "--" ] || [ $# -lt 4 ]; then + echo "usage: $0 -- ..." >&2; exit 2 +fi +shift 3 +SLEEP="${CHECK_LATEST_SLEEP_S:-20}" + +LAST="$(git log -1 --format=%H -- "$@" || true)" +if [ -z "$LAST" ]; then + echo "::error::$IMAGE — нет ни одного коммита по путям: $*" >&2; exit 2 +fi + +read_revision() { + # Печатает ревизию из метки образа или пустую строку. .Image для multi-arch — + # карта по платформам, для single-arch — сам конфиг; разбираем оба вида. + docker buildx imagetools inspect "$IMAGE:latest" --format '{{json .Image}}' 2>/dev/null \ + | python3 -c ' +import json, sys +try: + d = json.load(sys.stdin) +except Exception: + sys.exit(0) +cands = [d] if isinstance(d, dict) and "config" in d else [v for v in d.values() if isinstance(v, dict)] +for c in cands: + rev = ((c.get("config") or {}).get("Labels") or {}).get("org.opencontainers.image.revision") + if rev: + print(rev); break +' || true +} + +deadline=$(( $(date +%s) + TIMEOUT )) +while :; do + REV="$(read_revision)" + if [ -n "$REV" ] && git cat-file -e "${REV}^{commit}" 2>/dev/null \ + && git merge-base --is-ancestor "$LAST" "$REV"; then + echo "✓ $IMAGE:latest — ревизия ${REV:0:10} содержит последний коммит по компоненту ${LAST:0:10}" + exit 0 + fi + now=$(date +%s) + if [ "$now" -ge "$deadline" ]; then + echo "::error::$IMAGE:latest несёт ревизию '${REV:-<метки нет / registry не ответил>}', а последний коммит, трогавший [$*] — ${LAST:0:10}. :latest отстаёт (билд предшественника не доехал или отменён) — деплой остановлен, иначе прод получил бы старый образ при зелёной голове (#2950). Если билд того коммита упал — чини его или запусти workflow_dispatch полного деплоя." >&2 + exit 1 + fi + echo "… $IMAGE:latest — ревизия '${REV:-<метки нет>}' ещё не содержит ${LAST:0:10}; жду билд предшественника ($(( deadline - now )) с)" + sleep "$SLEEP" +done diff --git a/scripts/claude-hooks/check-secret-read.py b/scripts/claude-hooks/check-secret-read.py index a7361e70..81857a7c 100644 --- a/scripts/claude-hooks/check-secret-read.py +++ b/scripts/claude-hooks/check-secret-read.py @@ -37,6 +37,42 @@ SECRET_RE = re.compile( ) TEMPLATE_SUFFIXES = (".example", ".sample", ".template", ".dist", ".md") +# --- Точечное исключение (по прямому распоряжению владельца, 2026-08-26) --------- +# +# Гард целиком НЕ снят: в тех же .env-файлах лежат prod DB-пароли и токены +# Forgejo/GlitchTip, и они должны оставаться закрытыми. Разрешено ровно одно — +# вытащить значение перечисленных ниже ключей anchored-грепом. +# +# Условия исключения намеренно жёсткие, чтобы «прочитать один ключ» нельзя было +# развернуть в «выгрузить файл»: +# - паттерн привязан к началу строки (^KEY=) и ключ входит в ALLOWED_KEYS; +# - запрещена инверсия (-v / --invert-match) — иначе выдаст ВСЁ, кроме ключа; +# - запрещены пайпы, цепочки, подстановки и редиректы — иначе рядом с +# разрешённым грепом можно провезти второй, произвольный, ридер. +ALLOWED_KEYS = ( + "METRICS_UI_PASSWORD", + "GRAFANA_ADMIN_USER", + "GRAFANA_ADMIN_PASSWORD", +) + +_ALLOWED_GREP_RE = re.compile( + r"(?i)(?") + + +def _is_allowed_key_read(cmd: str) -> bool: + """True только для anchored-грепа одного из ALLOWED_KEYS без способов расширить выдачу.""" + if not _ALLOWED_GREP_RE.search(cmd): + return False + if _INVERT_RE.search(cmd): + return False + if _CHAIN_RE.search(cmd): + return False + return True + + # Verbs that read a file's content or push it off-box. READ_VERB_RE = re.compile( r"(?i)(? int: if not hits: return 0 + if _is_allowed_key_read(cmd): + return 0 + danger = ( READ_VERB_RE.search(cmd) or re.search(r"<\s*\S*\.env\b", cmd, re.IGNORECASE) diff --git a/scripts/claude-hooks/test-check-secret-read.py b/scripts/claude-hooks/test-check-secret-read.py new file mode 100644 index 00000000..97116cf8 --- /dev/null +++ b/scripts/claude-hooks/test-check-secret-read.py @@ -0,0 +1,44 @@ +import importlib.util, json, subprocess, sys + +HOOK = "scripts/claude-hooks/check-secret-read.py" +spec = importlib.util.spec_from_file_location("h", HOOK) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + +F = "/opt/gendesign/backend/.env" + ".runtime" +K = "METRICS_UI_PASSWORD" + +cases = [ + (f"timeout 40 ssh -o BatchMode=yes gendesign \"grep '^{K}=' {F}\"", True, "разрешённый anchored-греп"), + (f"ssh gendesign \"grep -v '^{K}=' {F}\"", False, "инверсия -v"), + (f"ssh gendesign \"grep '^{K}=' {F}; cat {F}\"", False, "цепочка ;"), + (f"ssh gendesign \"grep '^{K}=' {F} | cat {F}\"", False, "пайп"), + (f"ssh gendesign \"grep '^{K}=' {F} > /tmp/x\"", False, "редирект"), + (f"ssh gendesign \"grep '^POSTGRES_PASSWORD=' {F}\"", False, "другой ключ"), + (f"ssh gendesign \"grep '{K}' {F}\"", False, "без якоря ^"), + (f"cat {F}", False, "обычный cat"), + (f"ssh gendesign \"grep '^{K}=' {F} && cat {F}\"", False, "цепочка &&"), + (f"ssh gendesign \"grep '^{K}=' $(echo {F})\"", False, "подстановка"), +] + +ok = True +for cmd, want, name in cases: + got = m._is_allowed_key_read(cmd) + if got != want: + ok = False + print(f"{'OK ' if got == want else 'FAIL'} allow={str(got):5} want={str(want):5} {name}") + +print("--- end-to-end через сам хук ---") +for cmd, want_allow, name in cases: + p = subprocess.run( + [sys.executable, HOOK], + input=json.dumps({"tool_name": "Bash", "tool_input": {"command": cmd}}), + capture_output=True, + text=True, + ) + allowed = p.returncode == 0 + if allowed != want_allow: + ok = False + print(f"FAIL rc={p.returncode} want_allow={want_allow} {name}") + +print("ALL OK" if ok else "ЕСТЬ ПРОВАЛЫ") diff --git a/scripts/setup-metrics-exporter-dsn.sh b/scripts/setup-metrics-exporter-dsn.sh new file mode 100644 index 00000000..a29adaf5 --- /dev/null +++ b/scripts/setup-metrics-exporter-dsn.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Собирает строки подключения для postgres_exporter на продуктовом хосте из +# паролей, которые там уже есть. Идемпотентен: заданные значения не трогает. +# +# ПОЧЕМУ НЕ ЗАВОДИМ ОТДЕЛЬНЫЕ СЕКРЕТЫ. Каждая новая копия пароля — ещё одно +# место, откуда он может утечь, и ещё одно, которое забудут повернуть при +# ротации. Экспортеру нужны те же учётки, что уже лежат в окружении хоста. +# +# Запускать на продуктовом хосте. Вызывается из deploy-metrics.yml. +set -euo pipefail + +ENVF=/opt/gendesign/backend/.env.runtime + +if [ ! -f "$ENVF" ]; then + echo " ОШИБКА: не найден файл окружения бэкенда." >&2 + exit 1 +fi + +# Источники DSN лежат НЕ здесь. `DATABASE_URL` и `TRADEIN_DATABASE_URL` живут в +# основном файле окружения бэкенда, а $ENVF (runtime) хранит только то, что +# дописывается на хосте: SENTRY_RELEASE, METRICS_* и т. п. Скрипт читал лишь +# runtime, поэтому подстановка `${DATABASE_URL:-}` всегда была пустой, add_key +# молча писал «нечем заполнить» и выходил с кодом 0 — а compose затем падал на +# обязательной переменной. Мягкий пропуск встречался с жёстким требованием. +# +# Читаем оба, в порядке приоритета: сначала базовый, потом runtime (он должен +# перекрывать). Пишем по-прежнему ТОЛЬКО в runtime — лишних копий пароля не +# заводим. +BASE_ENVF=/opt/gendesign/backend/.env + +set -a +if [ -f "$BASE_ENVF" ]; then + # shellcheck source=/dev/null + . "$BASE_ENVF" +fi +# shellcheck source=/dev/null +. "$ENVF" +set +a + +add_key() { + local key="$1" value="$2" + if grep -qE "^${key}=" "$ENVF" 2>/dev/null; then + echo " $key — уже задан, не трогаю" + return 0 + fi + if [ -z "$value" ]; then + echo " $key — нечем заполнить, пропускаю (метрики этой базы не поедут)" + return 0 + fi + printf '%s=%s\n' "$key" "$value" >> "$ENVF" + echo " $key — записан" +} + +# Экспортер ходит по сетевому алиасу, а не по IP: адреса контейнеров меняются +# при каждом пересоздании, и DSN с IP протух бы на первом же деплое. +add_key GENDESIGN_EXPORTER_DSN \ + "${GENDESIGN_EXPORTER_DSN:-${DATABASE_URL:-}}" + +add_key TRADEIN_EXPORTER_DSN \ + "${TRADEIN_EXPORTER_DSN:-${TRADEIN_DATABASE_URL:-}}" + +# postgres_exporter не понимает схему postgresql+psycopg:// из SQLAlchemy — +# ему нужна чистая postgresql://. Приводим, если досталась приложенческая форма. +python3 - "$ENVF" <<'PY' +import io, re, sys + +path = sys.argv[1] +with io.open(path, encoding="utf-8") as fh: + lines = fh.readlines() + +changed = False +out = [] +for line in lines: + m = re.match(r'^((?:GENDESIGN|TRADEIN)_EXPORTER_DSN)=(.*)$', line.rstrip('\n')) + if not m: + out.append(line) + continue + key, dsn = m.group(1), m.group(2) + fixed = re.sub(r'^postgresql\+\w+://', 'postgresql://', dsn) + fixed = re.sub(r'^postgres\+\w+://', 'postgresql://', fixed) + if 'sslmode=' not in fixed: + fixed += ('&' if '?' in fixed else '?') + 'sslmode=disable' + if fixed != dsn: + changed = True + print(" %s — схема приведена к postgresql:// для экспортера" % key) + out.append("%s=%s\n" % (key, fixed)) + +if changed: + with io.open(path, "w", encoding="utf-8", newline="\n") as fh: + fh.writelines(out) +PY + +echo " строки подключения экспортеров готовы" diff --git a/scripts/setup-metrics-grafana-role.sh b/scripts/setup-metrics-grafana-role.sh new file mode 100644 index 00000000..6220051c --- /dev/null +++ b/scripts/setup-metrics-grafana-role.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Заводит read-only роль grafana_ro в базе glitchtip — под датасорс Grafana. +# Идемпотентен: повторный запуск только досогласует права. +# +# ПОЧЕМУ ОТДЕЛЬНАЯ РОЛЬ, А НЕ ВЛАДЕЛЕЦ БАЗЫ. Датасорс Grafana доступен всякому, +# кто вошёл в интерфейс, и позволяет выполнять произвольный SQL в панелях. +# Владельческая роль сделала бы витрину способом уронить трекер ошибок. Здесь +# прав на запись нет вовсе, поэтому худшее, что можно сделать через панель, — +# медленный SELECT. +# +# Запускать на инфраструктурном хосте. Вызывается из deploy-metrics.yml. +set -euo pipefail + +CONT=gendesign-infra-postgres +DB=glitchtip +ROLE=grafana_ro + +: "${GLITCHTIP_RO_PASSWORD:?GLITCHTIP_RO_PASSWORD не задан — запусти scripts/setup-metrics-secrets.sh}" + +if ! docker inspect "$CONT" >/dev/null 2>&1; then + echo " $CONT не найден — пропускаю создание роли." + exit 0 +fi + +# Ищем роль с правом заводить других. Имя суперпользователя в этом кластере +# нигде не зафиксировано, а угадывать «postgres» неверно: в образе оно задаётся +# переменной POSTGRES_USER и здесь ею не является. +# +# Поэтому НЕ угадываем, а спрашиваем сам контейнер: POSTGRES_USER — это та самая +# переменная, которой роль и была создана при initdb, то есть источник истины. +# Прежний список был перебором трёх имён (glitchtip/forgejo/postgres), и ни одно +# из них не совпадало ни с одним реальным кластером проекта: +# +# gendesign-infra-postgres → infra +# gendesign-postgres-1 → gendesign +# tradein-postgres → tradein +# +# Из-за этого деплой стека падал на первом же прогоне (#3099, задача 23657): +# ОШИБКА: не нашёл роль с правом CREATE ROLE в gendesign-infra-postgres +# Замер 26.08 на живом контейнере: glitchtip/forgejo/postgres — отказ, +# infra — проходит. Список имён оставлен ПОСЛЕ него запасным путём: он +# пригодится кластеру, поднятому не из образа postgres, где POSTGRES_USER пуст. +SUPER="" +_pg_user="$(docker exec "$CONT" sh -c 'printf %s "${POSTGRES_USER:-}"' 2>/dev/null || true)" +for candidate in $_pg_user glitchtip forgejo postgres; do + [ -n "$candidate" ] || continue + if docker exec "$CONT" psql -U "$candidate" -d postgres -tAc \ + "SELECT 1 FROM pg_roles WHERE rolname = CURRENT_USER AND (rolsuper OR rolcreaterole)" 2>/dev/null \ + | grep -q 1; then + SUPER="$candidate" + break + fi +done + +if [ -z "$SUPER" ]; then + echo " ОШИБКА: не нашёл роль с правом CREATE ROLE в $CONT." >&2 + echo " Проверь вручную: docker exec $CONT psql -U <роль> -c '\\du'" >&2 + exit 1 +fi +echo " привилегированная роль: $SUPER" + +# Пароль передаётся через переменную окружения psql, а не в тексте запроса — +# иначе он осел бы в pg_stat_statements и в логе запросов. +docker exec -e RO_PASS="$GLITCHTIP_RO_PASSWORD" -i "$CONT" \ + psql -U "$SUPER" -d "$DB" -v ON_ERROR_STOP=1 <<'SQL' +\set ro_pass `echo "$RO_PASS"` + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN + CREATE ROLE grafana_ro LOGIN; + RAISE NOTICE 'роль grafana_ro создана'; + ELSE + RAISE NOTICE 'роль grafana_ro уже есть'; + END IF; +END +$$; + +ALTER ROLE grafana_ro WITH PASSWORD :'ro_pass'; + +-- Ограничение на число соединений: панель с автообновлением способна открыть +-- их десятками, а это тот же кластер, где живёт Forgejo. +ALTER ROLE grafana_ro CONNECTION LIMIT 8; + +GRANT CONNECT ON DATABASE glitchtip TO grafana_ro; +GRANT USAGE ON SCHEMA public TO grafana_ro; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO grafana_ro; + +-- Таблицы событий партиционированы по неделям: новые партиции появляются сами, +-- и без этой строки датасорс начал бы отдавать пустоту на свежих данных, +-- оставаясь при этом «рабочим». Тихий отказ ровно того сорта, который мы ловим. +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO grafana_ro; +SQL + +echo " grafana_ro: права выданы на $DB" diff --git a/scripts/setup-metrics-secrets.sh b/scripts/setup-metrics-secrets.sh new file mode 100644 index 00000000..f1fba79f --- /dev/null +++ b/scripts/setup-metrics-secrets.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Разовая подготовка учётных данных стека наблюдаемости (#3078). +# +# Запускать НА ИНФРАСТРУКТУРНОМ ХОСТЕ (Beget), один раз. Идемпотентен: уже +# заданные значения переиспользуются, ничего не перезаписывается. +# +# ЧТО ДЕЛАЕТ: +# 1. Генерирует пароли приёмника, витрины, администратора Grafana и read-only +# роли к базе GlitchTip; дописывает их в окружение хоста. +# 2. Кладёт пароль приёмника на продуктовый хост — агенту нужно им +# авторизоваться при отправке метрик и логов. +# 3. Печатает bcrypt-хеши для caddy/metrics-*.caddy.snippet. +# +# ЧЕГО НЕ ДЕЛАЕТ: не печатает сами пароли. Хеш публиковать безопасно, пароль — +# нет, а вывод скрипта попадает в журнал деплоя и в историю терминала. +# +# Токен Telegram скрипт НЕ генерирует — его нужно задать руками: +# METRICS_TELEGRAM_BOT_TOKEN, METRICS_TELEGRAM_CHAT_ID +set -euo pipefail + +ENVF=/opt/gendesign/backend/.env.runtime +REMOTE_ENVF=/opt/gendesign/backend/.env.runtime +RSSH=(ssh -o BatchMode=yes -o ConnectTimeout=15 selectel) +STAMP=$(date -u +%Y%m%d%H%M%S) + +if [ ! -f "$ENVF" ]; then + echo "ОШИБКА: не найден файл окружения бэкенда на этом хосте." >&2 + exit 1 +fi + +# Копия перед первой правкой — одна на запуск, а не на каждый ключ. +backup_once() { + [ -f "${ENVF}.bak-metrics-${STAMP}" ] && return 0 + cp -p "$ENVF" "${ENVF}.bak-metrics-${STAMP}" +} + +have() { grep -qE "^${1}=" "$ENVF" 2>/dev/null; } +value_of() { grep -m1 -E "^${1}=" "$ENVF" | cut -d= -f2-; } + +# 32 символа из [A-Za-z0-9]: помещается в basic_auth без экранирования и не +# ломает разбор .env, где кавычки и знак равенства создают сюрпризы. +gen_pass() { tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32; } + +ensure() { + local key="$1" val="$2" + if have "$key"; then + echo " $key — уже есть, переиспользую" + else + backup_once + printf '%s=%s\n' "$key" "$val" >> "$ENVF" + echo " $key — сгенерирован" + fi +} + +echo "=== учётки на инфраструктурном хосте ===" +ensure METRICS_INGEST_USER "alloy" +ensure METRICS_INGEST_PASSWORD "$(gen_pass)" +ensure METRICS_UI_USER "metrics" +ensure METRICS_UI_PASSWORD "$(gen_pass)" +ensure GRAFANA_ADMIN_USER "admin" +ensure GRAFANA_ADMIN_PASSWORD "$(gen_pass)" +ensure GLITCHTIP_RO_PASSWORD "$(gen_pass)" + +INGEST_PASS=$(value_of METRICS_INGEST_PASSWORD) +UI_PASS=$(value_of METRICS_UI_PASSWORD) +GT_RO=$(value_of GLITCHTIP_RO_PASSWORD) + +ensure INFRA_EXPORTER_DSN \ + "postgresql://grafana_ro:${GT_RO}@gendesign-infra-postgres:5432/glitchtip?sslmode=disable" + +echo +echo "=== пароль приёмника на продуктовый хост ===" +if "${RSSH[@]}" "grep -qE '^METRICS_INGEST_PASSWORD=' ${REMOTE_ENVF}" 2>/dev/null; then + echo " уже задан, не трогаю" +else + "${RSSH[@]}" "cp -p ${REMOTE_ENVF} ${REMOTE_ENVF}.bak-metrics-${STAMP}" + # Значение идёт по stdin, а не аргументом команды: аргументы видны в + # `ps` на обеих машинах и оседают в истории оболочки. + printf 'METRICS_INGEST_USER=alloy\nMETRICS_INGEST_PASSWORD=%s\n' "$INGEST_PASS" \ + | "${RSSH[@]}" "cat >> ${REMOTE_ENVF}" + "${RSSH[@]}" "chown --reference=${REMOTE_ENVF}.bak-metrics-${STAMP} ${REMOTE_ENVF}; \ + chmod --reference=${REMOTE_ENVF}.bak-metrics-${STAMP} ${REMOTE_ENVF}" + echo " записан, права сохранены по резервной копии" +fi + +echo +echo "=== bcrypt-хеши для caddy/metrics-*.caddy.snippet ===" +echo " (пароли не печатаются; хеши безопасны для git)" +echo +for pair in "alloy:${INGEST_PASS}:metrics-ingest" "metrics:${UI_PASS}:metrics-ui"; do + user="${pair%%:*}"; rest="${pair#*:}" + pass="${rest%%:*}"; file="${rest#*:}" + hash=$(docker run --rm caddy:2 caddy hash-password --plaintext "$pass" 2>/dev/null | tr -d '\r\n') + b64=$(printf '%s' "$hash" | base64 -w 0) + printf ' caddy/%s.caddy.snippet\n %-8s %s\n' "$file" "$user" "$b64" +done + +echo +echo "=== что осталось сделать руками ===" +have METRICS_TELEGRAM_BOT_TOKEN \ + && echo " METRICS_TELEGRAM_BOT_TOKEN — задан" \ + || echo " METRICS_TELEGRAM_BOT_TOKEN — НЕ задан, алерты никуда не уйдут" +have METRICS_TELEGRAM_CHAT_ID \ + && echo " METRICS_TELEGRAM_CHAT_ID — задан" \ + || echo " METRICS_TELEGRAM_CHAT_ID — НЕ задан, алерты никуда не уйдут" +echo +echo " Пароль витрины смотреть так (в переписку не копировать):" +echo " grep '^METRICS_UI_PASSWORD=' $ENVF" diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh index cda7725a..d15ff7db 100644 --- a/scripts/smoke-mera-perimeter.sh +++ b/scripts/smoke-mera-perimeter.sh @@ -3,8 +3,16 @@ # # Проверяет инварианты периметра (см. корневой Caddyfile): # 1. meraocenka.ru отдаёт 200 анонимно (публичный лэндинг). -# 1b. Подстраница лэндинга /trade-in/mera-public/privacy отдаёт 200 — -# политика ПДн, на которую ссылается футер. +# 1b. Длинный адрес /trade-in/mera-public/privacy отдаёт 301 на короткий +# (у страницы один канонический адрес, старые ссылки не ломаются). +# 1c. Короткие адреса /oferta, /refund, /privacy отдают 200 — эти URL +# напечатаны внутри самих юридических документов и уходят эквайеру. +# 1d. /estimate отдаёт 200 — экран проверки, куда ведут все кнопки лэндинга. +# 1e. Длинные адреса поддерева отдают 301 на короткие (включая ГОЛЫЙ +# /trade-in/mera-public — прежний матчер его не ловил, «Главная» в подвале +# вела в 404). +# 2d. Публичный API /api/public/mera/* доступен анонимно, а /api/v1/* на +# публичном домене по-прежнему 404. # 2. meraocenka.ru/v2 и /trade-in/v2, /trade-in/api/* (B2B-пути) отдают 404 — # allowlist-by-default, НЕ были случайно проброшены на B2B-дерево # tradein-frontend. Проверяются обе формы — с basePath-префиксом и без. @@ -41,16 +49,52 @@ check() { fi } +check_post() { + local desc="$1" url="$2" body="$3" expected="$4" + local code + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 \ + -X POST -H 'Content-Type: application/json' -d "$body" "$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 +# 1b. Подстраница лэндинга по ДЛИННОМУ адресу теперь отдаёт 301 на короткий, а +# не 200: с 15.08.2026 у публичной страницы один канонический адрес. +# Проверка осталась именно здесь, потому что раньше она сторожила +# доступность обязательного по 152-ФЗ документа — теперь сторожит, что при +# переходе на короткие адреса длинные не превратились в 404 (тогда бы +# сломались уже разосланные ссылки). +check "meraocenka.ru длинная privacy — 301 на короткую" "$BASE_MERA/trade-in/mera-public/privacy" 301 + +# 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 + +# 1d. Экран проверки квартиры — короткий адрес, на который ведут все кнопки +# лэндинга. Отвалится handle — кнопки «Проверить» станут ссылками в 404. +check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200 + +# 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один +# канонический адрес, а старые ссылки и закладки продолжают работать. +# ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер +# `/trade-in/mera-public/*` эту форму не ловил, и ссылка «Главная» в +# подвале v3 вела в 404. +check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301 +check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301 # 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401. check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404 @@ -68,6 +112,35 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API) # Ловит расширение матчера обратно до `/trade-in/_next/*`. check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404 +# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/* +# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене +# по-прежнему 404. +# +# Пара проверок ниже неразделима: первая доказывает, что форма вообще +# работает, вторая — что новый handle не расширил периметр до +# `/trade-in/api/*`. Зелёная только первая = API открыт целиком и тест это +# пропустил (ровно та ошибка, ради которой в Caddyfile выбран отдельный +# префикс, а не поимённый проброс v1-путей). +# ПРО ВНЕШНЮЮ ЗАВИСИМОСТЬ (#2917). Опасение «упадёт DaData — покраснеет +# смоук без всякого регресса» проверено по коду и оказалось у́же, чем +# звучит: `geocoder.suggest` — это цепочка «кадастровый тир → DaData → +# Nominatim → []», и КАЖДЫЙ внешний тир обёрнут в `except Exception` +# (services/geocoder.py). Отказ, квота и 5xx провайдера дают пустой список +# и HTTP 200 — проверка остаётся зелёной. Покраснеть она может только если +# провайдер ВИСНЕТ дольше 15 с (--max-time у curl), то есть на зависании, +# а не на отказе. Ослаблять ожидание не стали: 200 здесь проверяет +# открытость пути анониму, ради которой проверка и написана. +check_post "meraocenka.ru public suggest — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200 +check_post "meraocenka.ru public coverage — 200 anonymous" "$BASE_MERA/trade-in/api/public/mera/coverage" '{"lat":56.838,"lon":60.597,"rooms":2,"area_m2":54}' 200 +check "meraocenka.ru v1 geocode — must stay 404" "$BASE_MERA/trade-in/api/v1/geocode/suggest?q=test" 404 +check "meraocenka.ru v1 coverage — must stay 404" "$BASE_MERA/trade-in/api/v1/trade-in/coverage" 404 + +# Тот же публичный путь на gendsgn.ru: страницу лэндинга открывают и оттуда +# (QA за basic_auth), поэтому URL у формы один на оба домена. Здесь он +# проходит через уже существующий `handle /trade-in/api/*` — проверка ловит +# регресс в rbac._PUBLIC_PATHS (стало бы 401), а не в Caddyfile. +check_post "gendsgn.ru public suggest — 200 anonymous" "$BASE_MAIN/trade-in/api/public/mera/suggest" '{"q":"Малышева"}' 200 + # 3. B2B-данные trade-in по-прежнему закрыты анониму. # # ВНИМАНИЕ: проверять СТРАНИЦУ (/trade-in/v2) больше нельзя — она отдаёт 200. @@ -90,6 +163,27 @@ check "gendsgn.ru/api/v1/admin/* — 401 anonymous" "$BASE_MAIN/api/v1/admin/use 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" diff --git a/tradein-mvp/backend/app/api/public/__init__.py b/tradein-mvp/backend/app/api/public/__init__.py new file mode 100644 index 00000000..6a5ab986 --- /dev/null +++ b/tradein-mvp/backend/app/api/public/__init__.py @@ -0,0 +1,7 @@ +"""Публичный (анонимный) API — поверхность, открытая на meraocenka.ru. + +Отдельный пакет, а не ещё один модуль в `app/api/v1/`, — намеренно: всё, что +лежит здесь, доступно БЕЗ идентичности, поэтому периметр должен читаться по +дереву каталогов, а не по списку исключений в middleware. Разбор — в +`app/api/public/mera.py`. +""" diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py new file mode 100644 index 00000000..3c2bc5f2 --- /dev/null +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -0,0 +1,288 @@ +"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно две ручки. + +ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/* +------------------------------------------------------- +На `meraocenka.ru` действует allowlist-by-default: Caddy проксирует поимённо +перечисленные пути, всё остальное — 404 (см. корневой Caddyfile, site-блок +meraocenka.ru; регресс — scripts/smoke-mera-perimeter.sh). Чтобы открыть там +API, нужно было выбрать одно из двух: + + (а) пробросить `/trade-in/api/v1/trade-in/coverage` и `.../geocode/suggest` + поимённо — периметр остаётся узким, но одна опечатка в matcher'е + (`/trade-in/api/*` вместо точного пути) открывает наружу ВЕСЬ v1: ~20 + ручек, включая PDF расчётов, фотографии объектов, историю и админку; + (б) завести отдельный префикс, под которым по определению не может лежать + ничего закрытого, и пробрасывать его целиком. + +Выбрано (б). Разница не в удобстве, а в цене ошибки: при (а) безопасность +периметра держится на аккуратности матчера, при (б) — на структуре кода. +Добавить сюда ручку с приватными данными нужно СПЕЦИАЛЬНО (положить файл в +`app/api/public/`), случайно — нельзя. + +Тот же принцип, что уже применён на фронте: публичный лэндинг вынесен в +`app/mera-public/` с guard-скриптом на граф импортов, а не помечен флагом +внутри общего дерева. + +АНОНИМНОСТЬ +----------- +`rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого +non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками — +не префиксом: множество там — frozenset с проверкой `path in ...`, и +добавление префиксной ветки ради двух путей расширило бы механизм, которым +пользуется весь бэкенд, ради одной фичи. + +ЧТО ЭТИ РУЧКИ НЕ ДЕЛАЮТ +----------------------- +Ни одна из них не пишет в БД строк с адресом пользователя: `/coverage` — +чистое чтение (один SELECT), `/suggest` — прокси автокомплита. Это не +случайность, а условие, при котором публичная форма может работать ДО того, +как появится контур согласия 152-ФЗ (issue #2895: сегодня адрес физлица +попадает в `trade_in_estimates` раньше любого согласия, а пути удаления +данных в бэкенде нет). Платный расчёт, который писать будет, открывается +отдельно и только вместе с этим контуром. + +БЮДЖЕТЫ +------- +Общий `RateLimitMiddleware` (300/60с на IP) здесь недостаточен: `/suggest` +через DaData-тир геокодера — платный внешний вызов, то есть абуз стоит денег, +а не только CPU. Поэтому у каждой ручки свой, заведомо более узкий per-IP +бюджет поверх общего — тот же приём, что у анонимного чата поддержки +(app/api/v1/support.py, `_anon_ip_limiter`). + +Лимитеры in-process: при нескольких репликах бэкенда бюджет умножится на их +число. Сейчас реплика одна (docker-compose, tradein-backend), что и делает +допущение верным; при масштабировании — выносить в Redis (issue заводить +тогда же, не раньше: преждевременный вынос добавит зависимость без выигрыша). +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.api.v1.geocode import SuggestResponse, suggest_addresses +from app.api.v1.trade_in import coverage_probe +from app.core.db import get_db +from app.core.public_request import install_address_log_redaction, public_request_scope +from app.core.ratelimit import SlidingWindowLimiter, _client_ip +from app.schemas.trade_in import CoverageProbeInput, CoverageProbeResponse + +logger = logging.getLogger(__name__) + +# Публичная форма обещает, что введённый адрес нигде не сохраняется. По базам +# это так, по журналам не было — геокодер печатал запрос открытым текстом, а +# прод пишет stdout в persistent journald. Ставим редакцию логов в момент +# импорта модуля (его импортирует app/main.py) — то есть ровно тогда, когда +# публичные ручки вообще появляются в приложении. Разбор — в +# app/core/public_request.py. +install_address_log_redaction() + +router = APIRouter() + +# Бюджеты подобраны от живого сценария, а не «на глаз»: человек набирает адрес +# с debounce'ом — это единицы запросов на один адрес, поэтому 40/мин хватает +# на несколько попыток подряд и режет перебор словарём. Проба покрытия — шаг +# осознанный (нажатие кнопки), 15/мин с запасом покрывает «поправил площадь, +# нажал ещё раз». +_SUGGEST_LIMIT = 20 +_COVERAGE_LIMIT = 15 +_WINDOW_S = 60.0 + +_suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S) +_coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_LIMIT, window_s=_WINDOW_S) + +# ── Общий суточный потолок публичных подсказок ────────────────────────────── +# +# Per-IP окна одного клиента ограничивают, но не ограничивают СУММУ. Считаем: +# 20 запросов/мин с одного адреса — это 28 800 в сутки, а весь бесплатный тир +# DaData у проекта — 10 000 в сутки И ОН ОБЩИЙ с закрытым контуром. То есть без +# этого потолка один настойчивый клиент (или один скрипт) за несколько часов +# выедает квоту, и подсказки перестают работать у ПЛАТЯЩИХ пилотов, а не только +# у него. Найдено состязательным ревью и подтверждено на проде: достаточно +# упомянуть в запросе не-екатеринбургский город, чтобы локальный кадастровый +# тир отключился и запрос гарантированно ушёл во внешний сервис. +# +# 2000/сутки — заведомо меньше десятой доли тира: публичная форма не должна +# уметь навредить закрытому контуру в принципе. Порог достижим только абузом +# (живой посетитель тратит единицы запросов на адрес), поэтому исчерпание — +# сигнал, а не штатный режим: логируем ошибкой. +_DAILY_SUGGEST_BUDGET = 2000 +_daily_suggest_limiter = SlidingWindowLimiter(limit=_DAILY_SUGGEST_BUDGET, window_s=86_400.0) +_GLOBAL_KEY = "public-suggest" + +# ── Потолок одновременных подсказок ───────────────────────────────────────── +# +# Кадастровый тир геокодера уходит в FDW-скан ЧУЖОЙ базы (gendesign) и на +# коротком вводе занимает около секунды, всё это время удерживая соединение из +# пула. Пул общий с закрытым контуром и невелик (дефолт SQLAlchemy 5+10), так +# что полтора десятка одновременных публичных подсказок способны положить +# B2B-запросы в том же процессе — при том, что per-IP лимиты каждого из них +# формально соблюдены. +# +# Ждём слот недолго и отвечаем 429, а не копим очередь: очередь под нагрузкой +# превращается в те же занятые соединения плюс растущий таймаут у клиента. +_SUGGEST_CONCURRENCY = 4 +_SUGGEST_SLOT_WAIT_S = 2.0 +_suggest_slots = asyncio.Semaphore(_SUGGEST_CONCURRENCY) + + +def _enforce(limiter: SlidingWindowLimiter, request: Request, what: str) -> None: + """429 при превышении per-IP бюджета. Попытку регистрируем ДО работы ручки. + + В отличие от отправки сообщения в поддержку (там `record()` только на + успех, чтобы неудача не съедала бюджет), здесь считаем каждую попытку: + внешний вызов геокодера тратится и на запросе, который вернёт пусто, — + иначе перебор мусорными строками не стоил бы атакующему ничего. + """ + ip = _client_ip(request) + retry_after = limiter.retry_after(ip) + if retry_after is not None: + logger.info("public mera %s rate-limited for %s", what, ip) + raise HTTPException( + status_code=429, + detail="Слишком много запросов. Попробуйте через минуту.", + headers={"Retry-After": str(int(retry_after) + 1)}, + ) + limiter.record(ip) + + +class PublicSuggestInput(BaseModel): + """Вход публичного автокомплита. + + Телом, а не query-параметрами — см. `public_suggest`. + """ + + q: str = Field(min_length=2, max_length=200) + limit: int = Field(default=8, ge=1, le=10) + city_hint: str | None = Field(default=None, max_length=100) + + +def _fold(text: str) -> str: + """ёЁ→еЕ + casefold — та же нормализация, что у городов в trade_in.py.""" + return text.translate(str.maketrans("ёЁ", "ее")).casefold() + + +def _query_with_city(query: str, city_hint: str | None) -> str: + """Подставить выбранный город В САМУ СТРОКУ запроса. + + ЗАЧЕМ. `city_hint` доезжает до геокодера, но НА ВЫДАЧУ ПОДСКАЗОК НЕ ВЛИЯЕТ: + его использует только екатеринбургский кадастровый тир (как признак «речь + не про ЕКБ, тир пропускаем»), а DaData-тир ограничен регионом целиком и + хинта не принимает. Замер на проде 16.08.2026: выбран Серов, введено + «Ленина 1» → первой подсказкой «Невьянский р-н, пгт Верх-Нейвинский». + Человек выбирает верхний вариант и считает совсем чужой дом — ровно тот + баг #2576, ради которого город и спрашивают. + + С городом в строке («Серов Ленина 1») выдача становится серовской целиком — + проверено там же. + + Для Екатеринбурга подстановка безвредна: три разных адреса дали + побайтово тот же результат с префиксом и без (кадастровый тир парсит + улицу и дом одинаково). Поэтому правило одно на все города, без + исключения для основного трафика — исключение пришлось бы поддерживать. + + Чинится ЗДЕСЬ, а не в геокодере: там от `city_hint` зависит поведение + закрытого контура (`target_city_ambiguous`), и менять его смысл ради + публичной формы значит трогать чужой контракт. + """ + if not city_hint: + return query + if _fold(city_hint) in _fold(query): + return query + return f"{city_hint}, {query}" + + +@router.post("/suggest", response_model=SuggestResponse) +async def public_suggest( + request: Request, + payload: PublicSuggestInput, + db: Annotated[Session, Depends(get_db)], +) -> SuggestResponse: + """Автокомплит адреса для публичной формы (Свердловская область). + + ПОЧЕМУ POST У ЧИТАЮЩЕЙ РУЧКИ. Каноничнее был бы GET с `?q=`. Но на + публичном домене включён access-лог (`/var/log/caddy/meraocenka.ru.log`), а + он пишет URI целиком — то есть адрес квартиры лёг бы в файл рядом с IP + посетителя. Мы публично обещаем на этой же странице, что введённый адрес + нигде не сохраняем; лог — это сохранение. Тело запроса в лог не попадает, + поэтому обещание остаётся правдой без правки конфигурации логирования (её + легко потерять при следующем рефакторинге Caddyfile — а тип запроса + потерять нельзя, сломается сразу и заметно). + + Тот же довод, что у черновика с лэндинга: он едет через sessionStorage, а + не через query-параметры (frontend `estimate-draft.ts`). + + Делегирует В ТУ ЖЕ функцию, что обслуживает B2B-экран + (`app.api.v1.geocode.suggest_addresses`), а не повторяет её логику: + публичная форма обязана резолвить адрес ровно так же, как платный расчёт, + иначе аноним выберет дом, которого потом «не окажется». + + Отличие от v1 ровно одно — потолок `limit` 10 вместо 15: выдача сверх + десятка в публичном UI не показывается, а каждый лишний кандидат может + стоить внешнего вызова. + """ + _enforce(_suggest_limiter, request, "suggest") + + # Суточный потолок — ПОСЛЕ per-IP: сначала отсекаем одиночного абузера его + # собственным лимитом, и только оставшееся считаем в общий бюджет. + daily_retry = _daily_suggest_limiter.retry_after(_GLOBAL_KEY) + if daily_retry is not None: + logger.error( + "публичные подсказки исчерпали суточный бюджет (%d) — квота геокодера " + "защищена, но форма на лэндинге сейчас без автокомплита", + _DAILY_SUGGEST_BUDGET, + ) + raise HTTPException( + status_code=429, + detail="Подсказки адреса временно недоступны. Введите адрес полностью.", + headers={"Retry-After": str(int(daily_retry) + 1)}, + ) + _daily_suggest_limiter.record(_GLOBAL_KEY) + + try: + await asyncio.wait_for(_suggest_slots.acquire(), timeout=_SUGGEST_SLOT_WAIT_S) + except TimeoutError: + raise HTTPException( + status_code=429, + detail="Сервис сейчас занят. Попробуйте ещё раз через несколько секунд.", + headers={"Retry-After": "5"}, + ) from None + + try: + # Пометка публичного запроса нужна ровно здесь: внутри `suggest_addresses` + # геокодер логирует введённую строку, а публичная форма обещает, что + # адрес не попадает в журналы. + with public_request_scope(): + return await suggest_addresses( + q=_query_with_city(payload.q, payload.city_hint), + limit=payload.limit, + db=db, + city_hint=payload.city_hint, + ) + finally: + _suggest_slots.release() + + +@router.post("/coverage", response_model=CoverageProbeResponse) +def public_coverage( + request: Request, + payload: CoverageProbeInput, + db: Annotated[Session, Depends(get_db)], +) -> CoverageProbeResponse: + """Бесплатная проба покрытия (issue #2894) для публичной формы. + + Делегирует в `app.api.v1.trade_in.coverage_probe` — ту же функцию, что + вызывает закрытый контур. Копии SQL здесь нет намеренно: разбор #2894 + показал, что стоит когорте пробы разойтись с когортой платного расчёта — + проба честно отвечает «есть данные» там, где расчёт увидит ноль. + + Ответ не содержит ни одной цены (см. `CoverageProbeResponse`) — бесплатный + шаг доказывает наличие данных, цену продаёт платный. + """ + _enforce(_coverage_limiter, request, "coverage") + return coverage_probe(payload=payload, db=db) diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index aa251ffe..cfb63fd3 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -1288,6 +1288,15 @@ class CianFullLoadRequest(BaseModel): "cian_full_load прогона для resume. Без этого поля — full walk с нуля." ), ) + secondary_only: bool = Field( + default=True, + description=( + "True (дефолт, поведение без изменений) — новостройки отбрасываются " + "после разбора. False — сохраняются вместе со вторичкой. Дополнительных " + "HTTP-запросов не стоит: страницы с ними всё равно скачиваются, " + "totalOffers бакета считает обе категории (#1781)." + ), + ) @router.post("/scrape/cian-full-load", response_model=CitySweepStartResponse) @@ -1326,6 +1335,7 @@ async def start_cian_full_load( enrich_detail=payload.enrich_detail, detail_top_n=payload.detail_top_n, resume_run_id=payload.resume_run_id, + secondary_only=payload.secondary_only, ) except Exception: logger.exception("cian-full-load background task run_id=%d crashed", run_id) @@ -2654,6 +2664,113 @@ async def update_scraper_pacing( ) from exc +# ── Дома, уехавшие от собственных объявлений (GET /scraper/mislocated-houses) ─ +# +# Отдельная ручка ПО ТРЕБОВАНИЮ, а не поле в /scraper/data-quality (#2996). +# Замер прода 21.08.2026: запрос стоит ~445 мс на тёплом кэше, тогда как обе +# существующие выборки data-quality вместе — 27 мс, а фронт опрашивает её каждые +# 120 с. Класть сюда 17-кратное удорожание ради числа, которое меняется раз в +# месяцы, неправильно; поэтому проверка живёт отдельно и вызывается руками. +# +# Что ловим. Прод: 23 дома стоят вне Свердловской области, к ним привязано 591 +# объявление; адреса екатеринбургские, координаты — Варшава, Белград, Таллин, +# Владивосток, Ижевск. 22 из 23 несут в raw_payload след разового бэкфилла +# `full_backfill_2026-05-27`, который звал Yandex-геокодер напрямую, без резолва +# города. Живой путь чист: 0 записей вне региона в geocode_cache из 10 448. +# +# 17 из 22 при этом помечены геокодером `precision: "exact"` — точность отвечает +# на «нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть +# не может. Поэтому проверяется ПРИНАДЛЕЖНОСТЬ, а не «точность». +# +# Инвариант нарочно НЕ географический: «дом рядом со своими объявлениями», а не +# «дом внутри рамки области». Рамка сломалась бы при выходе в Москву (#2996) — +# этот инвариант расширение переживёт. Он же строго сильнее: ловит 25 домов +# против 23 у рамки, и оба лишних при проверке оказались настоящими +# («Ул. Белинского/Фурманова» за 207 км, 34 объявления). +# +# Порог не подобран на глаз. Замер по 9 052 домам с объявлениями: +# ближе 1 км 8 914 (98.5 %) · 1-5 км 64 · 5-25 км 40 +# 25-100 км 9 — настоящие пригороды (Сарапулка, Кедровка, Чусовское +# Озеро, Ревда, Первоуральск), самый дальний 46.7 км +# дальше 100 км 25 — ближайший 119.2 км, максимум 5 079 км +# Между 46.7 и 119.2 км нет НИ ОДНОГО дома: 100 лежит в середине пустого +# промежутка, а не на краю распределения. +_MISLOCATED_KM = 100.0 + +_MISLOCATED_SQL = text(""" + WITH pairs AS ( + SELECT h.id AS house_id, + h.address, + h.source, + ST_Distance( + h.geom::geography, + ST_SetSRID(ST_MakePoint( + percentile_disc(0.5) WITHIN GROUP (ORDER BY l.lon), + percentile_disc(0.5) WITHIN GROUP (ORDER BY l.lat) + ), 4326)::geography + ) / 1000.0 AS km, + count(*) AS listings_count + FROM houses h + JOIN listings l ON l.house_id_fk = h.id + WHERE h.geom IS NOT NULL AND l.lat IS NOT NULL AND l.lon IS NOT NULL + GROUP BY h.id, h.geom, h.address, h.source + ) + SELECT house_id, address, source, round(km::numeric, 1) AS km, listings_count + FROM pairs + WHERE km >= CAST(:km AS double precision) + ORDER BY km DESC +""") + + +class MislocatedHouse(BaseModel): + """Дом, стоящий дальше порога от медианы координат своих объявлений.""" + + house_id: int + address: str | None + source: str | None + km: float + listings_count: int + + +class MislocatedHousesResponse(BaseModel): + threshold_km: float + houses_count: int + listings_count: int + houses: list[MislocatedHouse] + + +@router.get("/scraper/mislocated-houses", response_model=MislocatedHousesResponse) +def get_mislocated_houses( + db: Annotated[Session, Depends(get_db)], +) -> MislocatedHousesResponse: + """Дома, уехавшие от собственных объявлений дальше порога (#2996). + + Ноль — ожидаемое значение. Ненулевое означает, что чьи-то координаты пришли + из геокода в чужой город: живой путь резолвит город, но разовые скрипты + ходят в API напрямую и эту защиту обходят. + + Запрос тяжёлый (~445 мс на проде) — поэтому ручка отдельная и вызывается + руками, а не подмешана в опрашиваемую каждые 2 минуты /scraper/data-quality. + """ + rows = db.execute(_MISLOCATED_SQL, {"km": _MISLOCATED_KM}).mappings().all() + houses = [ + MislocatedHouse( + house_id=int(r["house_id"]), + address=r["address"], + source=r["source"], + km=float(r["km"]), + listings_count=int(r["listings_count"]), + ) + for r in rows + ] + return MislocatedHousesResponse( + threshold_km=_MISLOCATED_KM, + houses_count=len(houses), + listings_count=sum(h.listings_count for h in houses), + houses=houses, + ) + + # ── Data-quality coverage (GET /scraper/data-quality) ──────────────────────── diff --git a/tradein-mvp/backend/app/api/v1/glitchtip.py b/tradein-mvp/backend/app/api/v1/glitchtip.py new file mode 100644 index 00000000..0f051ee7 --- /dev/null +++ b/tradein-mvp/backend/app/api/v1/glitchtip.py @@ -0,0 +1,219 @@ +"""GlitchTip → Telegram алерты (мониторинг сейчас нем: `alerts_projectalert`/ +`alerts_alertrecipient` пусты, `EMAIL_URL=consolemail://` печатает письма в +stdout и никуда их не доставляет — аудит на проде 2026-08-15). + +GlitchTip (self-hosted, `errors.gendsgn.ru`, образ `glitchtip/glitchtip:6.1.6`) +умеет слать получателю типа `webhook` (``RecipientType.GENERAL_WEBHOOK`` — +"General Slack-compatible webhook"). И issue-алерты (``apps/alerts/webhooks.py +send_issue_as_webhook``), и uptime-алерты (``apps/uptime/webhooks.py +_send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ низкоуровневую +``send_webhook()`` — ``aiohttp.ClientSession.post(url, json=asdict(WebhookPayload +(text=..., attachments=[...])))``, БЕЗ каких-либо заголовков (ни Authorization, +ни подписи, ни X-*). Значит: + 1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ — + ``{"text": str, "attachments": [{"title","title_link","text","color", + "fields",...}]}`` — просто у uptime пустые/отсутствующие ``fields``/``color``; + 2) единственный канал для аутентификации — сам URL (как и Slack-вебхуки). + Секрет ОБЯЗАН ехать query-параметром, HTTP-заголовок здесь поставить + нечем (GlitchTip-сторона его не добавляет). + +Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213 +defense-in-depth, см. ``app.core.rbac``) вместо нового секрета — тот же +``secrets.compare_digest`` constant-time compare, тот же env. Отличие от +rbac-паттерна: ТАМ пустой секрет — fail-open (есть второй рубеж, roles.yaml). +ЗДЕСЬ секрет — единственный рубеж вообще, поэтому пустой секрет ИЛИ +несконфигурированный Telegram-бот → 503 "не настроено", а не тихий +fail-open настежь. + +Путь ФИКСИРОВАННЫЙ (не несёт секрет в себе) — так его можно добавить в +``app.core.rbac._PUBLIC_PATHS`` одной строкой (точное совпадение, без +regex/prefix-веток в ``rbac_guard``). Сам путь — не секрет, секрет — только +значение query-параметра. + +Сетевая связность (docker-compose.prod.yml, корневой стек): вебхуки шлёт +``glitchtip-worker`` (celery-таска), НЕ ``glitchtip-web`` — оба сейчас сидят +только в ``gendesign_default``. tradein-backend слушает на ``gendesign_shared`` +(алиас неявный — Docker embedded DNS резолвит по ``container_name``, тот же +приём уже используется Caddy → ``tradein-backend:8000``, см. Caddyfile). +Значит ``glitchtip-worker`` тоже должен быть подписан на ``gendesign_shared``, +иначе имя ``tradein-backend`` не резолвится — общей сети нет. +""" + +from __future__ import annotations + +import json +import logging +import secrets +from datetime import UTC, datetime +from typing import Annotated, Any + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel, ConfigDict, ValidationError + +from app.core.config import settings +from app.services.tgbot.client import TelegramApiError, TelegramClient + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Telegram sendMessage лимит — 4096 символов (см. support.py MAX_MESSAGE_LENGTH +# для исходящих сообщений пользователя; здесь лимит на ИСХОДЯЩЕЕ в Telegram, тот +# же потолок). Суффикс обрезки учтён в _truncate. +_TELEGRAM_MAX_LEN = 4096 +_TRUNCATE_SUFFIX = "\n… (обрезано)" + +# Узкий интерактивный бюджет (тот же принцип, что #tgsupport-web review H1 в +# support.py): GlitchTip-таска ждёт HTTP-ответ синхронно (её собственный aiohttp +# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты. +_INTERACTIVE_SEND_TIMEOUT_S = 8.0 +_INTERACTIVE_SEND_MAX_RETRIES = 1 + + +class GlitchTipAttachment(BaseModel): + """Slack-совместимый attachment. Issue- и uptime-алерты заполняют РАЗНЫЕ + подмножества полей (uptime не шлёт ``fields``/``color``) — все опциональны, + ``extra="allow"`` на случай будущих версий GlitchTip.""" + + model_config = ConfigDict(extra="allow") + + title: str | None = None + title_link: str | None = None + text: str | None = None + color: str | None = None + fields: list[dict[str, Any]] | None = None + + +class GlitchTipWebhookPayload(BaseModel): + """Тело POST от GlitchTip ``send_webhook()`` — одинаковое для issue- и + uptime-алертов (см. docstring модуля).""" + + model_config = ConfigDict(extra="allow") + + text: str | None = None + attachments: list[GlitchTipAttachment] | None = None + + +def _truncate(text: str, limit: int = _TELEGRAM_MAX_LEN) -> str: + if len(text) <= limit: + return text + return text[: limit - len(_TRUNCATE_SUFFIX)] + _TRUNCATE_SUFFIX + + +def _field_value(attachment: GlitchTipAttachment, label: str) -> str | None: + """Ищет значение поля attachment.fields по title (issue-алерты кладут туда + "Project" литералом — см. apps/alerts/webhooks.py send_issue_as_webhook).""" + for field in attachment.fields or []: + if str(field.get("title", "")).strip().lower() == label.lower(): + value = field.get("value") + return str(value) if value is not None else None + return None + + +def _format_known_payload(payload: GlitchTipWebhookPayload, received_at: datetime) -> str: + lines = [f"GlitchTip: {payload.text or 'Alert'}"] + for attachment in payload.attachments or []: + block: list[str] = [] + project = _field_value(attachment, "Project") + if project: + block.append(f"Проект: {project}") + if attachment.title: + block.append(attachment.title) + if attachment.text: + block.append(attachment.text) + if attachment.title_link: + block.append(f"Ссылка: {attachment.title_link}") + if block: + lines.append("") + lines.extend(block) + lines.append("") + lines.append(f"Получено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC") + return "\n".join(lines) + + +def _format_unknown_payload(raw_body: bytes, received_at: datetime) -> str: + """Payload не распознан ни как issue-, ни как uptime-алерт (нет ни `text`, + ни `attachments`, либо тело — не JSON-объект вовсе) — не роняем запрос, + пересылаем как есть с пометкой (см. требование задачи: неизвестная форма + payload не должна давать 500).""" + text_repr = raw_body.decode("utf-8", errors="replace") + header = "GlitchTip webhook: неизвестный формат payload, пересылаю как есть" + return _truncate( + f"{header}\n\n{text_repr}\n\nПолучено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC" + ) + + +def _build_message(raw_body: bytes, received_at: datetime) -> str: + try: + data = json.loads(raw_body) + except (json.JSONDecodeError, UnicodeDecodeError): + return _format_unknown_payload(raw_body, received_at) + + if not isinstance(data, dict): + return _format_unknown_payload(raw_body, received_at) + + try: + payload = GlitchTipWebhookPayload.model_validate(data) + except ValidationError: + return _format_unknown_payload(raw_body, received_at) + + if payload.text is None and not payload.attachments: + return _format_unknown_payload(raw_body, received_at) + + return _truncate(_format_known_payload(payload, received_at)) + + +def _alerts_configured() -> bool: + """Все три части ОБЯЗАНЫ быть заданы: секрет (auth), токен бота, chat_id + темы алертов. Отсутствие любой — 503, а не тихий no-op и не fail-open.""" + return bool( + settings.tradein_internal_auth_secret + and settings.telegram_bot_token + and settings.telegram_alerts_chat_id + ) + + +def _verify_secret(provided: str) -> None: + expected = settings.tradein_internal_auth_secret + if not secrets.compare_digest(provided or "", expected): + logger.warning("glitchtip webhook: invalid or missing secret query param") + raise HTTPException(status_code=401, detail="invalid or missing secret") + + +@router.post("/ops/glitchtip-webhook") +async def glitchtip_webhook( + request: Request, + secret: Annotated[str, Query()] = "", +) -> dict[str, str]: + """Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в + Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID`` + — ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля). + + Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот + хендлер сам делает единственную проверку (``secret`` query-параметр). + """ + if not _alerts_configured(): + raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured") + + _verify_secret(secret) + + raw_body = await request.body() + received_at = datetime.now(UTC) + text = _build_message(raw_body, received_at) + + client = TelegramClient(settings.telegram_bot_token) + try: + await client.send_message( + chat_id=settings.telegram_alerts_chat_id, + text=text, + message_thread_id=settings.telegram_alerts_topic_id or None, + # review H1-style бюджет (см. support.py) — синхронный HTTP-путь не + # может легально висеть воркерные минуты ретраев. + timeout=_INTERACTIVE_SEND_TIMEOUT_S, + max_retries=_INTERACTIVE_SEND_MAX_RETRIES, + ) + except TelegramApiError: + logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram") + raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None + + return {"status": "ok"} diff --git a/tradein-mvp/backend/app/api/v1/lead.py b/tradein-mvp/backend/app/api/v1/lead.py index 6ac5ba07..a0eb307a 100644 --- a/tradein-mvp/backend/app/api/v1/lead.py +++ b/tradein-mvp/backend/app/api/v1/lead.py @@ -51,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-ФЗ" + "Политикой обработки персональных данных" ) diff --git a/tradein-mvp/backend/app/api/v1/search.py b/tradein-mvp/backend/app/api/v1/search.py index 9580c2af..26d34ba3 100644 --- a/tradein-mvp/backend/app/api/v1/search.py +++ b/tradein-mvp/backend/app/api/v1/search.py @@ -47,7 +47,9 @@ async def search( if cached is not None: logger.info( "search cache HIT key=%s page=%d size=%d", - cache_key[:24], params.page, params.page_size, + cache_key[:24], + params.page, + params.page_size, ) resp = SearchResponse.model_validate(cached) resp.cache_hit = True @@ -73,6 +75,10 @@ async def search( await cache.set(cache_key, response.model_dump(mode="json"), ttl=cache.TTL_SEARCH) logger.info( "search MISS page=%d size=%d total=%d items=%d elapsed=%.1fms", - params.page, params.page_size, total, len(items), elapsed_ms, + params.page, + params.page_size, + total, + len(items), + elapsed_ms, ) return response diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 45d1a561..9c6688d0 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -9,22 +9,26 @@ 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 from sqlalchemy import text from sqlalchemy.orm import Session +from app.core.anon_session import get_or_create_anon_session_id from app.core.config import settings from app.core.db import get_db -from app.core.ratelimit import _client_ip +from app.core.ratelimit import SlidingWindowLimiter, _client_ip from app.schemas.trade_in import ( AggregatedEstimate, AnalogLot, AvitoImvSummary, CianPriceChangeStats, + CoverageProbeInput, + CoverageProbeResponse, DkpCorridor, HouseAnalyticsKpi, HouseAnalyticsResponse, @@ -54,6 +58,69 @@ logger = logging.getLogger(__name__) router = APIRouter() +# ── B2C anti-abuse этап 2 (#b2c-antiabuse-2) ──────────────────────────────── +# Отдельный, куда более строгий лимит частоты specifically на POST /estimate — +# см. settings.estimate_rate_limit/_window_s (app/core/config.py) для обоснования +# значений. Тот же паттерн, что _send_limiter в app/api/v1/support.py: singleton +# SlidingWindowLimiter поверх общего RateLimitMiddleware (app/main.py), который +# уже применяется КО ВСЕМ /api/* путям, но с щедрым порогом, рассчитанным на +# дешёвые запросы — один /estimate запускает цепочку внешних вызовов, суммарно +# занимающую десятки секунд (см. estimator._with_budget budgets). +_estimate_limiter = SlidingWindowLimiter( + limit=settings.estimate_rate_limit, window_s=settings.estimate_rate_limit_window_s +) + +# ── Потолок одновременных оценок (#3082) ───────────────────────────────────── +# +# Рейт-лимит выше меряет ЧАСТОТУ (300/60с вправе стартовать в одну секунду), а +# квота — счётная и помесячная: ни один из них не ограничивает ПАРАЛЛЕЛИЗМ. +# Оценка 0.8–2.4с держит соединение общего пула SQLAlchemy (дефолт 5+10) и +# внешние тиры; пила одновременных оценок выедает пул и тормозит весь /api/v1/*. +# Образец — public/mera.py::_suggest_slots (4 слота на секундное автодополнение). +# +# 4 слота: вместе с 4 слотами suggest — 8 одновременно удерживаемых соединений +# из 15 возможных, остаток пула остаётся прочим ручкам. Ожидание слота 5с ≈ две +# длительности оценки: если за это время слот не освободился, очередь глубока и +# честный ответ — быстрый 429 с Retry-After, а не растущая очередь (очередь под +# нагрузкой — те же занятые соединения плюс таймаут у клиента; mera.py:117-127). +# +# Семафор живёт в памяти процесса — при переходе на несколько воркеров uvicorn +# (#3083) фактический лимит умножится на число воркеров; задачи согласовывать. +_ESTIMATE_CONCURRENCY = 4 +_ESTIMATE_SLOT_WAIT_S = 5.0 +_estimate_slots = asyncio.Semaphore(_ESTIMATE_CONCURRENCY) + + +def _resolve_quota_identity( + request: Request, + response: Response, + x_authenticated_user: str | None, +) -> tuple[str | None, int]: + """Резолвит (quota_key, default_limit) для account_quota.* — #b2c-antiabuse-2. + + - X-Authenticated-User присутствует → (username, MONTHLY_LIMIT) — существующий + pilot/admin-флоу БЕЗ изменений (персональные override в + account_quota_overrides применяются как раньше через account_quota.user_limit). + - Заголовка нет И settings.quota_dev_fail_open=True (явный dev-флаг локальной + разработки без Caddy) → (None, MONTHLY_LIMIT) — account_quota трактует None + как unlimited. Флаг по умолчанию ВЫКЛЮЧЕН — это НЕ дефолтный прод-путь. + - Заголовка нет И флаг не задан (default, прод-путь для анонимов — продукт + открывается наружу) → анонимный ключ на основе подписанной session-cookie + (app.core.anon_session) + client IP, default_limit = + settings.anon_estimate_quota_limit (гораздо строже пилот-лимита). Честно: + смена IP или чистка cookie обходит этот лимит — цель поднять стоимость + злоупотребления, а не сделать его невозможным (тот же принцип, что и во + всей account_quota-схеме, #747). + """ + if x_authenticated_user: + return x_authenticated_user, account_quota.MONTHLY_LIMIT + if settings.quota_dev_fail_open: + return None, account_quota.MONTHLY_LIMIT + session_id = get_or_create_anon_session_id(request, response) + anon_key = f"anon:{session_id}:{_client_ip(request)}" + return anon_key, settings.anon_estimate_quota_limit + + # PR-D1: единственное определение «оценка читаема» — раньше SQL-фильтр (404, # ниже в get_estimate) и Python-проверка (410, в estimate_pdf) уже разошлись # по коду ответа; третий потребитель (`/r/`, PR-9) разошёлся бы @@ -406,6 +473,7 @@ async def _try_revive_dead_estimate( async def estimate( payload: TradeInEstimateInput, request: Request, + response: Response, db: Annotated[Session, Depends(get_db)], x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, ) -> AggregatedEstimate: @@ -416,17 +484,51 @@ async def estimate( 3. Tukey IQR outlier filter 4. Median + Q1 + Q3 + confidence с explanation - Применяется лимит 15 успешных оценок в месяц на аккаунт (кроме admin/kopylov). + Применяется лимит 15 успешных оценок в месяц на аккаунт (кроме admin/kopylov); + анонимные запросы (без X-Authenticated-User) — свой, гораздо более строгий + лимит на anon-сессию+IP (#b2c-antiabuse-2). """ - account_quota.check_and_raise(db, x_authenticated_user) + # #b2c-antiabuse-2 п.4: отдельный жёсткий лимит частоты на дорогой публичный + # путь — самая дешёвая проверка первой, до квоты и до дорогой цепочки внешних + # вызовов. Ключ user:/ip: — тот же принцип, что общий RateLimitMiddleware + # (app/main.py); НЕ anon-сессия квоты (rate limit — про network-identity и + # burst-защиту capacity сервера, а не про месячный business-лимит). + _rl_key = ( + f"user:{x_authenticated_user}" if x_authenticated_user else f"ip:{_client_ip(request)}" + ) + _retry_after = _estimate_limiter.check(_rl_key) + if _retry_after is not None: + raise HTTPException( + status_code=429, + detail="Слишком много запросов на оценку. Попробуйте через несколько минут.", + headers={"Retry-After": str(int(_retry_after) + 1)}, + ) + + quota_key, quota_default_limit = _resolve_quota_identity( + request, response, x_authenticated_user + ) + account_quota.check_and_raise(db, quota_key, default_limit=quota_default_limit) from app.services.estimator import estimate_quality + # #3082: слот одновременности берём ПОСЛЕ дешёвых отказов (рейт-лимит, квота) + # и ДО дорогой цепочки внешних вызовов; release — в finally ниже. + try: + await asyncio.wait_for(_estimate_slots.acquire(), timeout=_ESTIMATE_SLOT_WAIT_S) + except TimeoutError: + raise HTTPException( + status_code=429, + detail="Сервис оценки сейчас занят. Попробуйте ещё раз через несколько секунд.", + headers={"Retry-After": "5"}, + ) from None + # #654: ранее любое исключение estimate_quality всплывало необработанным и # маскировалось апстрим-прокси (Caddy) как непрозрачный 502. Ловим, логируем # через logger.exception (→ GlitchTip/Sentry получает stack trace) и отдаём # явный 503 — так любая БУДУЩАЯ реальная ошибка становится видимой, а не # «глотается» шлюзом. HTTPException пробрасываем как есть (это не сбой). - # created_by (#656) прокидываем в estimate_quality для скоупа /history. + # 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 пути, так что эта ветка пока @@ -451,13 +553,28 @@ async def estimate( status_code=503, detail="estimate temporarily unavailable — try again shortly", ) from None + finally: + # #3082: слот возвращаем сразу после дорогой части — инкремент квоты и + # сериализация ответа ниже дёшевы и слот держать не должны. + _estimate_slots.release() # #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise # выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение # тут: при гонке двух /estimate на used=lim-1 второй получит False. # Не списываем квоту за пустой результат (нерезолвящийся адрес и т.п.) — иначе # платный слот сгорает за insufficient_data=True (median=0, n_analogs=0) с HTTP 200. - if not result.insufficient_data and not account_quota.increment(db, x_authenticated_user): - raise HTTPException(status_code=429, detail=account_quota.LIMIT_EXHAUSTED_MESSAGE) + if not result.insufficient_data and not account_quota.increment( + db, quota_key, default_limit=quota_default_limit + ): + # Аутентифицированный путь — байт-в-байт прежнее сообщение (может не + # отражать персональный override, это pre-existing поведение, вне + # scope этого фикса). Анонимный путь — динамический текст с ПРАВИЛЬНЫМ + # anon-лимитом (#b2c-antiabuse-2), а не захардкоженным MONTHLY_LIMIT. + detail = ( + account_quota.LIMIT_EXHAUSTED_MESSAGE + if x_authenticated_user + else account_quota.limit_exhausted_message(quota_default_limit) + ) + raise HTTPException(status_code=429, detail=detail) # Feature 2/3 foundation: "что искали" — обогащённая estimate_request-запись # в user_events (адрес/площадь/комнаты + estimate_id для join с trade_in_estimates). @@ -481,15 +598,22 @@ async def estimate( @router.get("/quota", response_model=QuotaStatus) def get_quota( + request: Request, + response: Response, db: Annotated[Session, Depends(get_db)], x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None, ) -> QuotaStatus: - """Статус квоты оценок для текущего аккаунта. + """Статус квоты оценок для текущего аккаунта (или анонимной сессии). Возвращает limit / used / remaining / unlimited для X-Authenticated-User. - Без заголовка (dev-режим без Caddy) — unlimited True, used 0. + Без заголовка (прод, публичный путь) — статус СВОЕЙ анонимной квоты + (anon-session cookie + IP), а НЕ безлимитный, если явно не включён + settings.quota_dev_fail_open (#b2c-antiabuse-2, dev без Caddy). """ - status = account_quota.get_status(db, x_authenticated_user) + quota_key, quota_default_limit = _resolve_quota_identity( + request, response, x_authenticated_user + ) + status = account_quota.get_status(db, quota_key, default_limit=quota_default_limit) return QuotaStatus(**status) @@ -508,6 +632,7 @@ def get_estimate( f""" SELECT id, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, + market_percentile, analogs, actual_deals, sources_used, data_freshness_minutes, expires_at, retain_until, address, lat, lon, area_m2, rooms, floor, total_floors, @@ -652,6 +777,11 @@ def get_estimate( confidence=row.confidence, confidence_explanation=row.confidence_explanation, n_analogs=row.n_analogs, + # #2899: getattr — тот же defensive-идиом, что у relaxations/reliability + # ниже: строка без колонки (старый in-memory double, любая выборка до + # миграции 267) деградирует в None — «позиции не знаем», — а не роняет + # ответ AttributeError'ом. + market_percentile=getattr(row, "market_percentile", None), period_months=12, analogs=analogs, actual_deals=actual_deals, @@ -729,6 +859,7 @@ def estimate_pdf( """ SELECT id, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, + market_percentile, analogs, actual_deals, sources_used, data_freshness_minutes, expires_at, retain_until, address, lat, lon, area_m2, rooms, floor, total_floors, @@ -772,6 +903,11 @@ def estimate_pdf( confidence=row.confidence, confidence_explanation=row.confidence_explanation, n_analogs=row.n_analogs, + # #2899: getattr — тот же defensive-идиом, что у relaxations/reliability + # ниже: строка без колонки (старый in-memory double, любая выборка до + # миграции 267) деградирует в None — «позиции не знаем», — а не роняет + # ответ AttributeError'ом. + market_percentile=getattr(row, "market_percentile", None), # #1351: окно сделок — 12 мес (estimator.DEALS_PERIOD_MONTHS), как в POST # /estimate и GET /estimate/{id}. Раньше PDF-ветка хардкодила 24 → # экспортёр рисовал ложный ~2-летний диапазон в клиентском документе. @@ -2549,3 +2685,280 @@ def get_sales_vs_listings( 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 требование), не в БД. +# +# ⚠️ Эти списки обязаны совпадать с `OBLAST_CITIES` +# (frontend/src/lib/city-registry.ts) — тем, что человек видит в дропдауне. +# Расхождение поймано на проде 16.08.2026: Серов предлагался к выбору, но +# отсутствовал здесь, и житель Серова получал «этот адрес вне области, по +# которой мы собираем данные» — про город В ТОЙ ЖЕ области, который мы ему сами +# и предложили. Сверка теперь автоматическая, см. +# tests/test_public_mera_api.py::test_offered_cities_match_coverage_cities. +COVERAGE_GREEN_CITIES = ("Екатеринбург", "Верхняя Пышма", "Берёзовский", "Среднеуральск") +# Серов добавлен 16.08.2026: в жёлтый тир, а не в зелёный — в радиусе 15 км от +# центра 363 активных объявления (все свежие), это на порядок меньше городов +# вокруг Екатеринбурга, но заведомо не ноль. +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), + "Серов": (59.6047, 60.5772), +} + + +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/core/anon_session.py b/tradein-mvp/backend/app/core/anon_session.py new file mode 100644 index 00000000..1792eb16 --- /dev/null +++ b/tradein-mvp/backend/app/core/anon_session.py @@ -0,0 +1,111 @@ +"""Подписанный анонимный session-cookie — B2C anti-abuse этап 2 (#b2c-antiabuse-2). + +Продукт открывается для анонимных пользователей (без Caddy basic_auth / +X-Authenticated-User). Чтобы применить анонимную квоту оценок (см. +app.services.account_quota), нужен стабильный, но НЕ подделываемый идентификатор +анонимной сессии — отдельный от X-Authenticated-User (этот заголовок ставит +Caddy и валидируется внутренним секретом; смешивать схемы идентичности нельзя, +это сломало бы модель безопасности #2213). + +Дизайн: session_id — случайный токен (secrets.token_urlsafe), подписанный +HMAC-SHA256 вместе с expiry в cookie-значении `mera_anon_sid`. Сервер верифицирует +подпись на каждом запросе; невалидная/просроченная/отсутствующая cookie → минтится +новая сессия (клиент просто теряет накопленную анонимную квоту — это ОЖИДАЕМО и +безопасно: анонимная квота и так обходится чисткой cookie/сменой IP, задача +поднять стоимость злоупотребления, а не сделать его невозможным). + +Подпись предотвращает две вещи: (1) клиент не может подставить ЧУЖОЙ session_id +(например, скопированный у другого пользователя) без знания секрета — иначе он +мог бы попытаться "унаследовать" чужую квоту или испортить чужой учёт; (2) любая +порча/усечение cookie-значения детектируется явно (HMAC mismatch), а не тихо +парсится как валиден мусорный session_id. +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import secrets +import time + +from fastapi import Request, Response + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +ANON_COOKIE_NAME = "mera_anon_sid" +# 180 дней — грубо совпадает с "долгоживущий браузер, не чистящий cookies"; +# не критично для безопасности (лимит всё равно per-период_month), только для +# TTL самой cookie в браузере/HMAC-подписи. +ANON_SESSION_TTL_S = 180 * 24 * 3600 + +# Пусто (default) → эфемерный per-process секрет: криптографически стойкий +# (клиент не знает его и не может подделать сессию), но сессии не переживают +# рестарт процесса и не общие между воркерами (см. anon_session_secret в +# core/config.py). Вычисляется один раз при импорте модуля. +if settings.anon_session_secret: + _SESSION_SECRET: bytes = settings.anon_session_secret.encode("utf-8") +else: + _SESSION_SECRET = secrets.token_bytes(32) + logger.warning( + "ANON_SESSION_SECRET не задан — используется process-local эфемерный " + "секрет анонимной сессии (сессии сбрасываются при рестарте / не общие " + "между воркерами). Задай ANON_SESSION_SECRET в .env.runtime для " + "стабильности между рестартами." + ) + + +def _sign(session_id: str, expires_at: int) -> str: + msg = f"{session_id}.{expires_at}".encode() + return hmac.new(_SESSION_SECRET, msg, hashlib.sha256).hexdigest() + + +def _encode(session_id: str, expires_at: int) -> str: + return f"{session_id}.{expires_at}.{_sign(session_id, expires_at)}" + + +def _decode_and_verify(cookie_value: str) -> str | None: + """Возвращает session_id если cookie валидна (подпись + не просрочена), иначе None.""" + parts = cookie_value.split(".", 2) + if len(parts) != 3: + return None + session_id, expires_at_raw, sig = parts + try: + expires_at = int(expires_at_raw) + except ValueError: + return None + if time.time() > expires_at: + return None + expected = _sign(session_id, expires_at) + if not hmac.compare_digest(sig, expected): + return None + return session_id + + +def get_or_create_anon_session_id(request: Request, response: Response) -> str: + """Возвращает стабильный анонимный session_id, выставляя cookie при необходимости. + + Читает `mera_anon_sid` из запроса; если отсутствует, повреждена, просрочена + или не проходит проверку подписи — минтит НОВУЮ сессию и выставляет свежую + cookie на *response* (httponly, samesite=lax; secure вне dev-окружения). + """ + raw = request.cookies.get(ANON_COOKIE_NAME) + if raw: + verified = _decode_and_verify(raw) + if verified is not None: + return verified + logger.info("anon_session: invalid/tampered/expired cookie — minting new session") + + session_id = secrets.token_urlsafe(16) + expires_at = int(time.time()) + ANON_SESSION_TTL_S + response.set_cookie( + ANON_COOKIE_NAME, + _encode(session_id, expires_at), + max_age=ANON_SESSION_TTL_S, + httponly=True, + samesite="lax", + secure=settings.environment != "dev", + ) + return session_id diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index c087e656..f3588104 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -340,6 +340,22 @@ class Settings(BaseSettings): default=5, validation_alias="RATE_LIMIT_AUTHENTICATED_MULTIPLIER" ) + # ── B2C anti-abuse этап 2: отдельный жёсткий лимит частоты на POST /estimate ── + # Общий rate_limit (300/60с) рассчитан на дешёвые запросы; один вызов /estimate + # запускает цепочку внешних вызовов (geocode → Overpass → IMV → Yandex → Cian), + # каждый забюджетирован, но суммарно может занимать десятки секунд. Отдельный, + # куда более строгий бюджет burst'а поверх общего — не даёт одному ключу + # (user:/ip:, тот же принцип что и general-лимит) запустить много параллельных + # дорогих цепочек за короткое окно. НЕ заменяет account_quota (месячная квота, + # персистентная в Postgres) — это защита от burst, а не от abuse за месяц. + # Применяется К ЛЮБОМУ ключу (auth и анон одинаково) — цель защитить capacity + # сервера/upstream-скрейперов, а не различать роли. ENV: ESTIMATE_RATE_LIMIT, + # ESTIMATE_RATE_LIMIT_WINDOW_S. + estimate_rate_limit: int = Field(default=5, validation_alias="ESTIMATE_RATE_LIMIT") + estimate_rate_limit_window_s: float = Field( + default=300.0, validation_alias="ESTIMATE_RATE_LIMIT_WINDOW_S" + ) + # Password for tradein_fdw_reader role — used by backend startup to create/refresh # USER MAPPING for postgres_fdw → gendesign DB (gendesign_remote server). # Пусто = USER MAPPING не создаётся, gendesign_cad_buildings не работает (dev). @@ -576,6 +592,33 @@ class Settings(BaseSettings): # sparse-coverage выше). estimate_kitchen_ceiling_signal_min_n: int = 5 # + # ── #2936: штраф за НЕИЗВЕСТНЫЙ year_built / house_type (default OFF) ────── + # В SQL-формуле relevance_score кандидат без year_built получает штраф 0 — + # столько же, сколько точное попадание в год, и ЛУЧШЕ, чем кандидат с + # известным годом, отличающимся на 24 (2.0). То же с house_type. Отсутствие + # данных выигрывает у знания, и это возвышает источник с худшей полнотой: + # замер 19.08 — avito (год 44 %, тип 0 %) берёт 47 % слотов топ-20 при 21 % + # доли в пуле, yandex (год 99 %) — 9 % при 37 %. + # + # Влияние на цену измерено дважды (#2936): систематического смещения НЕТ + # (медиана сдвига 0.29 %), но у 39 % целей медиана сопоставимых уходит >5 % + # в зависимости от того, как оценено незнание — шум от полноты сбора. + # Чего замеры НЕ говорят: какой из двух отборов ТОЧНЕЕ. Поэтому флаг, а не + # правка формулы: включать — только после бэктеста на сделках (MAPE), как + # требует тот же контракт, что у kitchen/ceiling (#2012) выше. + # + # Механизм: НЕ наказание, но и не награда — кандидат с NULL получает + # МЕДИАННЫЙ по пулу штраф того же признака среди кандидатов, у которых он + # известен (self-referential, как kitchen/ceiling). Пул = кандидаты тира + # после SQL (до 300), ДО сортировки и LIMIT 50. Известное ограничение: + # per-address cap (rn_addr ≤ MAX_ANALOGS_PER_ADDRESS) в SQL уже отработал + # без штрафа — Python-слой переранжирует то, что SQL оставил, как и #2012. + # Sparse-safe: если известных значений меньше min_n — сигнал пропускается + # целиком (медиана по трём строкам — не «типичный штраф», а шум). + # ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED. + estimate_unknown_attr_penalty_enabled: bool = False + estimate_unknown_attr_penalty_min_n: int = 5 + # # is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая # корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard # #1186 (`listing_segment`) в _COMMON_WHERE. Апартаменты — юридически иной @@ -710,13 +753,84 @@ class Settings(BaseSettings): # ESTIMATE_HOUSE_META_TIMEOUT_S. estimate_yandex_valuation_timeout_s: float = 8.0 estimate_cian_valuation_timeout_s: float = 8.0 + + # Внешние оценки (Yandex/Cian) не ждать в запросе, а догружать в фоне. + # + # Замер на проде 2026-08-22: расчёт по НОВОМУ адресу занимает 8-12 с, из них + # ~6 с ждёт Yandex и ~1.5 с Cian. Собственные запросы к базе и сам расчёт + # укладываются в секунду. По уже виденному адресу (кэш 24 ч) — 0.4-0.8 с. + # + # True: в запросе делается только чтение кэша; при промахе источник + # деградирует в None, а свежая загрузка уходит в фон и наполняет кэш к + # следующему обращению по тому же адресу. Ответ отдаётся за ~1 с. + # + # Деградация в None — НЕ новое состояние ответа: ровно так же ведёт себя + # таймаут `estimate_*_valuation_timeout_s`, этот путь работает в проде + # сегодня. Поэтому переключение не меняет контракт API. + # + # False (дефолт) — прежнее поведение: ждать источники в запросе. + # ENV: ESTIMATE_EXTERNAL_SOURCES_BACKGROUND. + estimate_external_sources_background: bool = Field( + default=False, validation_alias="ESTIMATE_EXTERNAL_SOURCES_BACKGROUND" + ) estimate_geocode_budget_s: float = 12.0 estimate_house_meta_timeout_s: float = 8.0 + # #b2c-antiabuse-2: Avito IMV (evaluate_via_imv) была ЕДИНСТВЕННЫМ внешним + # вызовом в /estimate БЕЗ _with_budget — до 3 последовательных HTTP-запросов + # (warm-up + geocode + evaluate), каждый со своим таймаутом 25s + # (_HTTP_TIMEOUT_SEC в scraper_kit.providers.avito.imv), плюс возможен ОДИН + # internal retry с "очищенным" адресом на IMVAddressNotFoundError — необёрнутый + # worst-case доходил до ~150s. 20s щедрее соседних бюджетов (8s Yandex/Cian/ + # house_meta) намеренно — IMV делает МНОГО последовательных round-trip'ов, а + # не один запрос, поэтому реалистичный "медленный, но живой" ответ длиннее. + # ENV: ESTIMATE_AVITO_IMV_TIMEOUT_S. + estimate_avito_imv_timeout_s: float = Field( + default=20.0, validation_alias="ESTIMATE_AVITO_IMV_TIMEOUT_S" + ) # Лимит успешных оценок trade-in за календарный месяц на аккаунт (#658). # Конфигурируется через env ESTIMATE_QUOTA_LIMIT. Default 15. estimate_quota_limit: int = 15 + # ── B2C anti-abuse этап 2 (#b2c-antiabuse-2) ────────────────────────────── + # Продукт открывается для анонимных пользователей — анонимный запрос БЕЗ + # X-Authenticated-User (Caddy basic_auth) раньше трактовался как unlimited + # безусловно (dev без Caddy). Недопустимо для публичного пути: любой + # анонимный клиент получал бы безлимитные дорогие оценки. + # + # quota_dev_fail_open: явный флаг ТОЛЬКО для локальной разработки без Caddy. + # По умолчанию ВЫКЛЮЧЕН — анонимный запрос без заголовка получает анонимную + # квоту (anon-session cookie + IP), а не безлимит. True включает старое + # fail-open поведение (username=None → unlimited) — задавай только в dev. + # ENV: QUOTA_DEV_FAIL_OPEN. + quota_dev_fail_open: bool = Field(default=False, validation_alias="QUOTA_DEV_FAIL_OPEN") + + # anon_estimate_quota_limit: месячный лимит успешных оценок на связку + # (anon-session-cookie + client IP) для запросов БЕЗ X-Authenticated-User. + # Существенно строже пилот-лимита (estimate_quota_limit=15) — анонимный + # трафик не аутентифицирован и открыт всему интернету. Честно: смена IP или + # чистка cookie обходит этот лимит — цель поднять стоимость злоупотребления, + # а не сделать его невозможным (тот же принцип, что и account_estimate_usage + # для пилотов). ENV: ANON_ESTIMATE_QUOTA_LIMIT. + # + # Значение 5 выбрано владельцем 16.08.2026 при разборе PR #2546 (в первой + # редакции стояло 3). Компромисс продуктовый, а не технический: пять проб — + # это больше шансов, что человек дойдёт до ценности и купит платный отчёт за + # 150 ₽, ценой чуть более высокого потолка злоупотребления. Число легко + # пересматривается переменной окружения без выкатки кода. + anon_estimate_quota_limit: int = Field(default=5, validation_alias="ANON_ESTIMATE_QUOTA_LIMIT") + + # anon_session_secret: ключ HMAC-подписи анонимного session-cookie (см. + # app/core/anon_session.py). Пусто (дефолт) → используется process-local + # эфемерный секрет, сгенерированный при старте (secrets.token_bytes) — + # криптографически стойкий (клиент его не знает и не может подделать + # session_id), но НЕ переживает рестарт/не общий между несколькими + # воркерами (каждый рестарт — новая генерация → старые cookie невалидны, + # клиенты просто получают новую anon-сессию, деградация допустима). Для + # стабильности между рестартами (текущий деплой — single-worker uvicorn) + # задай явный секрет в .env.runtime. ENV: ANON_SESSION_SECRET. + anon_session_secret: str = Field(default="", validation_alias="ANON_SESSION_SECRET") + # Фильтр junk-/премиум-порога для asking→sold derivation (#767). # Нижняя граница 30 000 ₽/м² отсекает нежилые/технические сделки; менять не стоит. # Верхняя граница — поднята с 600 000 до 1 200 000 ₽/м², чтобы покрыть ЕКБ-premium @@ -951,6 +1065,15 @@ class Settings(BaseSettings): # True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy). # False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode). # ENV: AVITO_DETAIL_BACKFILL_USE_CURL. + # + # ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR + # отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны + # 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на + # браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято: + # браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect. + # Прод переведён на браузер через docker-compose.prod.yml (environment + # перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение + # других окружений вслепую. avito_detail_backfill_use_curl: bool = Field( default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL" ) @@ -1011,6 +1134,15 @@ class Settings(BaseSettings): # message_thread_id топика внутри support-группы, в который идут зеркала. telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID") + # ── GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15) ── + # Отдельная тема от TELEGRAM_SUPPORT_TOPIC_ID выше — алерты об ошибках прода + # НЕ должны литься в топик, куда пишут живые клиенты. См. app/api/v1/glitchtip.py. + # Пусто/0 = вебхук отвечает 503 "not configured" (fail-closed, не fail-open — + # это единственный auth-рубеж эндпоинта, в отличие от rbac-путей). + # ENV: TELEGRAM_ALERTS_CHAT_ID, TELEGRAM_ALERTS_TOPIC_ID. + telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID") + telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID") + # ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ────────── # См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит # роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch. diff --git a/tradein-mvp/backend/app/core/public_request.py b/tradein-mvp/backend/app/core/public_request.py new file mode 100644 index 00000000..5a2ebbb5 --- /dev/null +++ b/tradein-mvp/backend/app/core/public_request.py @@ -0,0 +1,97 @@ +"""Пометка «этот запрос пришёл из публичной формы» и её единственное следствие: +адрес, который ввёл аноним, не попадает в журналы. + +ЗАЧЕМ ЭТО СУЩЕСТВУЕТ +-------------------- +На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что введённый +адрес нигде не сохраняется. По базам данных это правда (обе публичные ручки +только читают), а по журналам — не было: геокодер логирует запрос открытым +текстом на каждый вызов, например + + INFO app.services.dadata: dadata suggest: 'онуфриева 24' → 5 вариантов + +Прод пишет stdout контейнеров в journald с persistent-хранилищем +(`tradein-mvp/docker-compose.prod.yml`), то есть строка ложится на диск и живёт +там неделями. Рядом, в access-логе Caddy, лежит IP того же запроса с той же +меткой времени — то есть адрес квартиры фактически сохранён и сопоставим с +человеком. Ровно то, что публичная страница обещает не делать. + +Найдено состязательным ревью PR публичного периметра (16.08.2026) и +воспроизведено на проде, а не выведено из чтения кода. + +ПОЧЕМУ ФИЛЬТР, А НЕ ПРАВКА КАЖДОГО ВЫЗОВА logger +------------------------------------------------ +Мест, где адрес попадает в лог, много (`app/services/dadata.py`, +`app/services/geocoder.py` — успех, пустая выдача, сетевая ошибка, таймаут, +кадастровый фолбэк), и любое новое добавится незаметно. Обещание не должно +зависеть от того, вспомнил ли автор следующей правки про эту страницу. +Фильтр — единственная точка, которая закрывает и уже написанное, и будущее. + +ПОЧЕМУ contextvar +----------------- +Публичный и закрытый контуры обслуживает ОДИН процесс, и один и тот же +`suggest()` вызывают оба. Различить их можно только по текущему запросу. +`ContextVar` — то, что переживает `await` и копируется в `asyncio.to_thread` +(им геокодер уходит в синхронный кадастровый тир), в отличие от глобального +флага, который в конкурентной обработке принадлежал бы соседнему запросу. + +Для B2B-трафика ничего не меняется: там флаг не выставлен, логи прежние — они +нужны, чтобы разбирать жалобы пилотов на подсказки. +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar + +#: Истинно, пока обрабатывается запрос анонимной публичной формы. +is_public_request: ContextVar[bool] = ContextVar("mera_is_public_request", default=False) + +#: Что видно в журнале вместо сообщения. Уровень и логгер сохраняются — по ним +#: по-прежнему видно, что вызов был и чем закончился. +REDACTED_MESSAGE = "<публичный запрос МЕРЫ: содержимое скрыто>" + +#: Логгеры, чьи записи могут содержать введённый адрес. +ADDRESS_LOGGERS = ("app.services.dadata", "app.services.geocoder") + + +@contextmanager +def public_request_scope() -> Iterator[None]: + """Помечает текущий запрос публичным на время работы блока.""" + token = is_public_request.set(True) + try: + yield + finally: + is_public_request.reset(token) + + +class RedactPublicAddressFilter(logging.Filter): + """Заменяет сообщение целиком, пока обрабатывается публичный запрос. + + Целиком, а не по ключам: в шаблонах сообщений адрес стоит рядом с + безобидными аргументами (`"%r → %d вариантов"`), и отличить их друг от + друга внутри фильтра нельзя. Терять текст сообщения на публичном пути + дешевле, чем хранить адреса; на закрытом контуре текст остаётся полным. + """ + + def filter(self, record: logging.LogRecord) -> bool: + if is_public_request.get(): + record.msg = REDACTED_MESSAGE + record.args = () + return True + + +def install_address_log_redaction() -> None: + """Вешает фильтр на логгеры, видящие адрес. Идемпотентно. + + Фильтр ставится на КОНКРЕТНЫЕ логгеры, а не на корневой хендлер: фильтры + логгера применяются к записям этого логгера, а не ко всему, что через + хендлер проходит, — то есть посторонние сообщения (пул соединений, старт + приложения) во время публичного запроса не пострадают. + """ + for name in ADDRESS_LOGGERS: + logger = logging.getLogger(name) + if not any(isinstance(f, RedactPublicAddressFilter) for f in logger.filters): + logger.addFilter(RedactPublicAddressFilter()) diff --git a/tradein-mvp/backend/app/core/ratelimit.py b/tradein-mvp/backend/app/core/ratelimit.py index f5f3fe04..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) @@ -143,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 0dd654ff..7966280d 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -71,6 +71,14 @@ _ADMIN_API_RE = re.compile(r"^/api/v1/admin/") _PUBLIC_PATHS = frozenset( { "/health", + # Публичен ЗДЕСЬ и не публичен снаружи — это два разных периметра. + # Снимает `/metrics` агент Alloy изнутри docker-сети, где заголовка + # `X-Authenticated-User` нет ни у кого; без записи в этом множестве + # скрейп получал бы 401 и метрик не было бы вовсе. Наружу путь не + # открывается: у `gendsgn.ru` бэкенду «Меры» отдаётся только + # `/trade-in/api/*`, у `meraocenka.ru` работает белый список, и в обоих + # блоках на `/metrics` стоит явный `respond 404`. + "/metrics", "/docs", "/redoc", "/openapi.json", @@ -86,6 +94,26 @@ _PUBLIC_PATHS = frozenset( # не секрет, читает только process env — быстрая справка для клиента/ # поддержки/смоук-теста, не должна требовать сессию. "/api/v1/trade-in/version", + # GlitchTip webhook → Telegram (app/api/v1/glitchtip.py): вызывается + # ИЗ glitchtip-worker (docker-сеть gendesign_shared), не может нести + # X-Authenticated-User/сессию. Путь фиксированный и не секрет — секрет + # это query-параметр `secret`, который проверяет сам хендлер + # (secrets.compare_digest против TRADEIN_INTERNAL_AUTH_SECRET). + "/api/v1/trade-in/ops/glitchtip-webhook", + # Публичный B2C-периметр МЕРЫ (meraocenka.ru): у посетителя лендинга + # идентичности нет и не будет — Caddy на этом домене вообще без + # basic_auth. Обе ручки только читают (SELECT/прокси автокомплита) и не + # пишут в БД адрес физлица, поэтому открываются ДО контура согласия + # 152-ФЗ (#2895), а платный расчёт — только вместе с ним. Разбор + # периметра и бюджетов — app/api/public/mera.py. + # + # Строки ТОЧНЫЕ, префиксной ветки здесь нет: множество проверяется как + # `path in _PUBLIC_PATHS`, и превращать его в префиксный матчер ради + # двух путей значит расширить механизм, от которого зависит auth-гейт + # всего бэкенда. Гарантия «под /api/public/ не лежит ничего закрытого» + # держится на структуре пакета app/api/public/, а не на матчере. + "/api/public/mera/suggest", + "/api/public/mera/coverage", } ) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед 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/main.py b/tradein-mvp/backend/app/main.py index 347cad8c..bb06f624 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 @@ -20,6 +20,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.starlette import StarletteIntegration +from app.api.public import mera as public_mera from app.api.v1 import ( admin, audit, @@ -27,6 +28,7 @@ from app.api.v1 import ( brand, buildings, geocode, + glitchtip, lead, me, privacy_admin, @@ -43,6 +45,7 @@ from app.core.fdw import ensure_fdw_user_mapping from app.core.ratelimit import RateLimitMiddleware from app.core.rbac import rbac_guard from app.core.request_audit import RequestAuditMiddleware +from app.observability import metrics as app_metrics from app.observability.sentry_scrub import scrub_pii_event logger = logging.getLogger(__name__) @@ -66,17 +69,48 @@ 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, + scrub_public_address, + 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] + # Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а + # брэдкрамб исходящего вызова геокодера несёт его же в query. Публичная + # страница обещает, что адрес нигде не сохраняется; GlitchTip — внешний + # сервис, значит обещание распространяется и на него. + scrubbed = scrub_public_address(scrubbed, hint) # type: ignore[arg-type] + if scrubbed is None: + return None + 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, @@ -88,6 +122,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(), @@ -203,6 +241,12 @@ app.add_middleware( app.add_middleware(RateLimitMiddleware) # Request-audit: пишет api_request/login события в user_events (Feature 2/3 foundation). app.add_middleware(RequestAuditMiddleware) +# Метрики — ПОСЛЕДНИМ и потому самым внешним слоем: `add_middleware` вставляет в +# начало списка. Порядок здесь несущий. Изнутри не видно ни 401 от гварда, ни 429 +# от ограничителя частоты — их отдают сами эти слои и до нас запрос бы не дошёл; +# а всплеск отказов авторизации и срабатывания лимитера это ровно тот сигнал, +# ради которого метрики и заводятся. +app.add_middleware(app_metrics.MetricsMiddleware) @app.get("/health") @@ -210,6 +254,27 @@ 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(app_metrics.router, tags=["observability"]) 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"]) @@ -220,7 +285,14 @@ 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(glitchtip.router, prefix="/api/v1/trade-in", tags=["trade-in-ops"]) 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"]) + +# Публичный B2C-периметр МЕРЫ (meraocenka.ru). Префикс НЕ /api/v1/* намеренно — +# на публичном домене он проксируется целиком, поэтому под ним по определению +# не должно лежать ничего закрытого; разбор — в app/api/public/mera.py. +# Обе ручки перечислены в rbac._PUBLIC_PATHS точными строками. +app.include_router(public_mera.router, prefix="/api/public/mera", tags=["public-mera"]) diff --git a/tradein-mvp/backend/app/observability/metrics.py b/tradein-mvp/backend/app/observability/metrics.py new file mode 100644 index 00000000..e9343d4d --- /dev/null +++ b/tradein-mvp/backend/app/observability/metrics.py @@ -0,0 +1,171 @@ +"""Метрики Prometheus для API «Меры»: счётчики, гистограмма задержки, `/metrics`. + +Часть 3 задачи #3078. Близнец `backend/app/observability/metrics.py` из «Птицы»: +это два независимых Python-проекта со своими зависимостями и своим деплоем, +общего пакета между ними нет и заводить его ради полутора сотен строк дороже, +чем держать две копии. Расхождения намеренные и отмечены по месту. + +ЧТО ИМЕННО СЧИТАЕМ И ПОЧЕМУ ТАК + +`route` — ШАБЛОН маршрута (`/api/v1/trade-in/{lead_id}`), а не путь запроса. +Разница принципиальная: идентификатор в метке даёт новый временной ряд на каждую +заявку, а ряд у Prometheus стоит памяти постоянно, а не в момент запроса. Всё +незаматченное сведено в одну метку ``__unmatched__`` — иначе тот же взрыв рядов +устроит любой бот, перебирающий адреса. + +Ошибка внутри приложения фиксируется как 500 в `finally`: исключение проходит +сквозь этот слой наружу, и без `finally` такие запросы не попали бы в счётчик — +то есть отсутствовали бы ровно тогда, когда метрики нужнее всего. + +ОДИН ПРОЦЕСС — ОДИН РЕЕСТР + +`backend/Dockerfile:111` запускает `uvicorn` без `--workers`. Появятся воркеры — +счётчики станут per-process, каждый скрейп попадёт в случайный из них, и график +начнёт пилить вверх-вниз без связи с нагрузкой. Лечится штатным многопроцессным +режимом `prometheus_client` (`PROMETHEUS_MULTIPROC_DIR` + `MultiProcessCollector`); +делать это заранее незачем, но связь `--workers` → сломанные графики стоит знать +до, а не после. + +ДОСТУП + +`/metrics` снимает только агент Alloy изнутри docker-сети. Снаружи путь +недостижим: у `gendsgn.ru` бэкенду «Меры» отдаётся лишь `/trade-in/api/*`, а у +`meraocenka.ru` действует белый список с `handle { respond 404 }` в конце. Плюс +явный `respond 404` на `/metrics` в обоих блоках — чтобы закрытость осталась +решением, а не побочным следствием текущего порядка директив. + +Ограничитель частоты трогать не пришлось: `app/core/ratelimit.py:75` пропускает +всё, что не начинается на `/api/`, и скрейп раз в 30 секунд под него не попадает. +Аудит запросов — тоже: `app/core/request_audit.py:84` пишет строку только для +путей под `/api/` и только при известном пользователе. +""" + +from __future__ import annotations + +import time +from collections.abc import Awaitable, Callable, MutableMapping +from typing import Any + +from fastapi import APIRouter, Response +from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest + +from app.core.version import APP_VERSION, BUILD_SHA + +Scope = MutableMapping[str, Any] +Message = MutableMapping[str, Any] +Receive = Callable[[], Awaitable[Message]] +Send = Callable[[Message], Awaitable[None]] +ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]] + +# Явная строка, а не пустое значение: пустая метка в PromQL неотличима от +# отсутствующей. +UNMATCHED = "__unmatched__" + +# Границы плотнее, чем у «Птицы», и верхняя ниже. У «Меры» другой профиль: +# расчёт стоимости укладывается в десятые доли секунды (замер 26.08 — 90 мс на +# живом запросе), тяжёлого геометрического хвоста здесь нет. Зато есть внешние +# зависимости с непредсказуемым временем — геокодер, банк-эквайер, — поэтому +# верхние корзины оставлены: их отсутствие слепило бы весь хвост в `+Inf`. +_DURATION_BUCKETS = (0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, float("inf")) + +REQUESTS = Counter( + "http_requests_total", + "Запросов обслужено", + labelnames=("method", "route", "status"), +) + +DURATION = Histogram( + "http_request_duration_seconds", + "Время ответа целиком, включая авторизацию и middleware", + labelnames=("method", "route"), + buckets=_DURATION_BUCKETS, +) + +# Без меток намеренно: gauge с меткой маршрута не возвращается в ноль сам, ряд +# остаётся навсегда после единственного запроса. +IN_PROGRESS = Gauge( + "http_requests_in_progress", + "Запросов обрабатывается прямо сейчас", +) + +BUILD_INFO = Gauge( + "app_build_info", + "Всегда 1; полезны метки — по ним видно, какая версия отвечала в момент сбоя", + labelnames=("app", "release"), +) +# Версия берётся из `app/core/version.py` — там единственный источник правды +# (файл `VERSION` плюс build-args образа), тот же, что показывают PDF-колонтитул +# и `GET /api/v1/trade-in/version`. Отдельного хардкода здесь быть не должно: +# смысл метки в том, чтобы «что было задеплоено в 03:14» отвечалось однозначно. +BUILD_INFO.labels(app="mera", release=f"{APP_VERSION}+{BUILD_SHA}").set(1) + + +def route_label(scope: Scope) -> str: + """Шаблон маршрута из ASGI-scope, либо ``__unmatched__``. + + `scope["route"]` проставляет роутер Starlette в момент матчинга. Наш слой + внешний, поэтому к возврату управления сюда поле уже заполнено: scope — один + и тот же dict на весь стек, между слоями он не копируется. + """ + route = scope.get("route") + path = getattr(route, "path", None) + if isinstance(path, str) and path: + return path + return UNMATCHED + + +class MetricsMiddleware: + """Чистый ASGI-слой, без `BaseHTTPMiddleware`. + + `BaseHTTPMiddleware` заворачивает ответ в собственный поток и на потоковых + ответах ведёт себя иначе, чем голый ASGI. У «Меры» телом ответа уходит PDF + отчёта, и менять его обработку ради подсчёта запросов не стоит. + + Регистрировать ПОСЛЕДНИМ: `add_middleware` вставляет в начало списка, то есть + последний зарегистрированный оказывается самым внешним. Иначе 401 от гварда и + 429 от ограничителя частоты не попадут в счётчик — а поток отказов авторизации + и срабатывания лимитера это ровно то, ради чего метрики и заводятся. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope.get("type") != "http": + await self.app(scope, receive, send) + return + + method = scope.get("method", "UNKNOWN") + # 500 по умолчанию: при падении исключением `http.response.start` мы не + # увидим, и запрос обязан быть посчитан как ошибка, а не пропасть. + status = 500 + + async def send_wrapper(message: Message) -> None: + nonlocal status + if message["type"] == "http.response.start": + status = message["status"] + await send(message) + + IN_PROGRESS.inc() + started = time.perf_counter() + try: + await self.app(scope, receive, send_wrapper) + finally: + IN_PROGRESS.dec() + route = route_label(scope) + DURATION.labels(method, route).observe(time.perf_counter() - started) + REQUESTS.labels(method, route, str(status)).inc() + + +router = APIRouter() + + +@router.get("/metrics", include_in_schema=False) +def metrics() -> Response: + """Выгрузка в текстовом формате Prometheus. + + Реестр по умолчанию, а не свой: вместе с нашими метриками он отдаёт + `process_resident_memory_bytes`, `process_open_fds` и счётчики сборщика + мусора — утечка памяти и исчерпание дескрипторов видны по ним напрямую. + """ + return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) diff --git a/tradein-mvp/backend/app/observability/sentry_scrub.py b/tradein-mvp/backend/app/observability/sentry_scrub.py index 51c01e94..f3c6b63a 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 @@ -76,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).""" @@ -90,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`, тот же @@ -123,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 @@ -141,7 +203,96 @@ 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 + + +_PUBLIC_API_URL_SEGMENT = "/api/public/" + +#: Хосты геокодеров: их URL несёт введённый адрес прямо в query. +_GEOCODER_HOSTS = ("nominatim.openstreetmap.org", "suggestions.dadata.ru", "dadata.ru") + +_ANY_URL_QUERY_RE = re.compile(r"^([^?]*)\?.*$") + + +def scrub_public_address(event: Event, _hint: dict[str, Any]) -> Event | None: + """Убрать введённый анонимом адрес из события GlitchTip. + + На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что адрес + нигде не сохраняется. GlitchTip — внешний сервис, и до этой правки адрес + доезжал туда двумя путями (оба воспроизведены состязательным ревью + 16.08.2026, не выведены из чтения кода): + + 1. `event.request.data`. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса, + а `send_default_pii=False` этот путь не гейтит — он про куки и IP, не + про тело. Тело публичной ручки — это ровно `{"q": "<адрес>"}`. + Ключ-based `scrub_pii_event` не помогает: `_PII_KEYS` перечисляет + имена вроде `client_phone`, а поле здесь называется `q`. + 2. Брэдкрамб исходящего HTTP-запроса к геокодеру: `HttpxIntegration` + кладёт URL целиком, а адрес там в query (`?q=Малышева+30`). + + Стратегия та же, что у платёжного тела: не вычищать отдельные ключи, а + убирать целиком — состав полей задаёт не только наш код (у геокодеров свои + параметры), поэтому перечислить безопасное заранее нельзя. + + Композировать с остальными шагами, а не вместо них. + """ + if not isinstance(event, dict): + return event + + request = event.get("request") + if isinstance(request, dict): + url = request.get("url") + if isinstance(url, str) and _PUBLIC_API_URL_SEGMENT in url.lower(): + request.pop("data", None) + + crumbs = event.get("breadcrumbs") + values = crumbs.get("values") if isinstance(crumbs, dict) else crumbs + if isinstance(values, list): + for crumb in values: + if not isinstance(crumb, dict): + continue + data = crumb.get("data") + if not isinstance(data, dict): + continue + url = data.get("url") + if isinstance(url, str) and any(h in url for h in _GEOCODER_HOSTS): + data["url"] = _ANY_URL_QUERY_RE.sub(r"\g<1>?" + _REDACTED, url) + return event @@ -174,3 +325,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/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 2c29d640..c25db60f 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -191,6 +191,14 @@ class AggregatedEstimate(BaseModel): # #698: ПОЛНОЕ число найденных аналогов — НЕ равно len(analogs) (тот обрезан до # top-10, см. поле `analogs` ниже). Консьюмер должен брать счёт отсюда, а не из len(). n_analogs: int + # #2899: позиция ЭТОЙ квартиры внутри когорты аналогов, 1..99 — «какая доля + # аналогов дешевле». None = когорта меньше MARKET_PERCENTILE_MIN_N (15) либо + # оценки нет; ниже порога один соседний лот двигает ярлык на целую категорию. + # + # НЕ путать с location_index_pct (`GET /location-index`) — тот про РАЙОН против + # медианы города, а не про квартиру внутри своей выборки, и в цену не идёт. + # Показывать имеет смысл только вместе с n_analogs. + market_percentile: int | None = None @computed_field # type: ignore[prop-decorator] @property @@ -223,6 +231,13 @@ class AggregatedEstimate(BaseModel): # 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 самого свежего парсинга аналогов @@ -749,3 +764,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/account_quota.py b/tradein-mvp/backend/app/services/account_quota.py index 5b51be37..10d778ca 100644 --- a/tradein-mvp/backend/app/services/account_quota.py +++ b/tradein-mvp/backend/app/services/account_quota.py @@ -1,12 +1,19 @@ -"""Сервис квоты оценок trade-in — N успешных оценок в месяц на аккаунт. +"""Сервис квоты оценок trade-in — N успешных оценок в месяц на ключ (аккаунт ИЛИ +анонимная сессия+IP, см. #b2c-antiabuse-2). Правила: - Лимит по умолчанию = settings.estimate_quota_limit успешных оценок за календарный месяц (UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT, - default 15. + default 15. Это дефолт для АУТЕНТИФИЦИРОВАННЫХ (X-Authenticated-User) ключей. + Анонимные ключи (см. app.api.v1.trade_in._resolve_quota_identity) используют + СВОЙ, гораздо более строгий default через параметр `default_limit=` — + все функции ниже принимают username-подобный `key: str | None` без разбора, + реальный аккаунт это или составной anon-ключ ("anon::"). - Персональный override: таблица account_quota_overrides (username → monthly_limit), см. миграцию 185_account_quota_overrides.sql. Заменяет прежний хак бонусных попыток - через negative `used` (ломал /quota — «Осталось 50 из 15»). + через negative `used` (ломал /quota — «Осталось 50 из 15»). Работает одинаково + для anon-ключей (в норме нет override-строки → falls back на переданный + `default_limit`), так и для обычных username. - `used` в account_estimate_usage защищён CHECK (used >= 0) на уровне схемы, см. миграцию 189_account_estimate_usage_nonnegative.sql — 185 сбросила негативный used только для user2, 189 закрывает остальные аккаунты + запрещает регресс. @@ -17,9 +24,16 @@ До миграции 191 unlimited для non-admin аккаунтов был захардкожен как `username == 'kopylov'` прямо в коде — данные (kopylov + praktika) заменяют этот хардкод целиком, единый источник правды для всех безлимитных non-admin грантов. + Анонимные ключи никогда не unlimited (get_role() кидает KeyError на составной + anon-ключ → is_unlimited() шорткатится в False БЕЗ похода в БД). - Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality). -- Если заголовок X-Authenticated-User отсутствует (dev без Caddy) → unlimited, - лимит не применяется (fail-open). +- key is None → unlimited, лимит не применяется (fail-open). #b2c-antiabuse-2: + ЭТОТ модуль как был, так и остаётся fail-open на None — но с этапа anti-abuse + вызывающая сторона (app.api.v1.trade_in._resolve_quota_identity) передаёт None + ТОЛЬКО за явным флагом settings.quota_dev_fail_open (по умолчанию ВЫКЛЮЧЕН). + Анонимный запрос без этого флага получает anon-ключ (см. app.core.anon_session), + а не None — то есть на практике анонимные пользователи в проде квоту получают, + а не безлимит. - При исчерпании лимита поднимается HTTPException(429). """ @@ -40,10 +54,21 @@ logger = logging.getLogger(__name__) # Лимит успешных оценок за календарный месяц — конфигурируется через # env ESTIMATE_QUOTA_LIMIT (core.config.Settings), default 15 (#658). MONTHLY_LIMIT = settings.estimate_quota_limit -LIMIT_EXHAUSTED_MESSAGE = ( - f"Лимит из {MONTHLY_LIMIT} оценок в этом месяце исчерпан. " - "За полной версией обращайтесь к Копылову." -) + + +def limit_exhausted_message(limit: int) -> str: + """Текст 429 при исчерпании лимита — параметризован реальным лимитом (может + отличаться от глобального MONTHLY_LIMIT для персонального override ИЛИ + anon default_limit, см. #b2c-antiabuse-2).""" + return ( + f"Лимит из {limit} оценок в этом месяце исчерпан. " + "За полной версией обращайтесь к Копылову." + ) + + +# Backward-compat константа для MONTHLY_LIMIT-based сценариев (тесты, existing +# imports) — байт-в-байт совпадает с limit_exhausted_message(MONTHLY_LIMIT). +LIMIT_EXHAUSTED_MESSAGE = limit_exhausted_message(MONTHLY_LIMIT) def current_period() -> str: @@ -84,13 +109,18 @@ def is_unlimited(db: Session, username: str) -> bool: return bool(row is not None and row.unlimited) -def user_limit(db: Session, username: str) -> int: - """Персональный месячный лимит для username, иначе глобальный MONTHLY_LIMIT. +def user_limit(db: Session, username: str, *, default: int = MONTHLY_LIMIT) -> int: + """Персональный месячный лимит для username, иначе *default*. Источник override — таблица account_quota_overrides (см. миграцию 185_account_quota_overrides.sql). Заменяет прежний хак бонусных попыток через negative `used`, который ломал /quota (limit=15, used=-35 → remaining=50 — «Осталось 50 из 15»). + + *default* параметризован (не всегда MONTHLY_LIMIT) ради anon-ключей + (#b2c-antiabuse-2): анонимный ("anon::") ключ в норме не имеет + override-строки → падает на *default*, который вызывающая сторона задаёт + равным settings.anon_estimate_quota_limit (гораздо строже пилот-лимита). """ row = db.execute( text( @@ -103,27 +133,29 @@ def user_limit(db: Session, username: str) -> int: ).fetchone() if row is not None and row.monthly_limit is not None: return int(row.monthly_limit) - return MONTHLY_LIMIT + return default -def get_status(db: Session, username: str | None) -> dict: - """Возвращает статус квоты для пользователя. +def get_status(db: Session, username: str | None, *, default_limit: int = MONTHLY_LIMIT) -> dict: + """Возвращает статус квоты для пользователя (или anon-ключа, #b2c-antiabuse-2). - Если username is None → unlimited True, used 0, remaining = MONTHLY_LIMIT. + Если username is None → unlimited True, used 0, remaining = default_limit + (fail-open — вызывающая сторона передаёт None ТОЛЬКО за явным dev-флагом, + см. app.api.v1.trade_in._resolve_quota_identity). Если unlimited → used = фактический или 0, remaining = limit (per-user override - или глобальный MONTHLY_LIMIT). + или *default_limit*). """ if username is None: return { - "limit": MONTHLY_LIMIT, + "limit": default_limit, "used": 0, - "remaining": MONTHLY_LIMIT, + "remaining": default_limit, "unlimited": True, } unlimited = is_unlimited(db, username) period = current_period() - limit = user_limit(db, username) + limit = user_limit(db, username, default=default_limit) row = db.execute( text( @@ -157,10 +189,14 @@ def get_status(db: Session, username: str | None) -> dict: } -def check_and_raise(db: Session, username: str | None) -> None: +def check_and_raise( + db: Session, username: str | None, *, default_limit: int = MONTHLY_LIMIT +) -> None: """Проверяет лимит квоты и поднимает 429 если исчерпан. - Если username is None или пользователь unlimited → no-op. + Если username is None или пользователь unlimited → no-op. *default_limit* + задаёт лимит для ключей без персонального override (пилот → MONTHLY_LIMIT, + anon-ключ → settings.anon_estimate_quota_limit, см. #b2c-antiabuse-2). """ if username is None: return @@ -169,7 +205,7 @@ def check_and_raise(db: Session, username: str | None) -> None: return period = current_period() - limit = user_limit(db, username) + limit = user_limit(db, username, default=default_limit) row = db.execute( text( """ @@ -189,14 +225,14 @@ def check_and_raise(db: Session, username: str | None) -> None: used, limit, ) - raise HTTPException(status_code=429, detail=LIMIT_EXHAUSTED_MESSAGE) + raise HTTPException(status_code=429, detail=limit_exhausted_message(limit)) -def increment(db: Session, username: str | None) -> bool: +def increment(db: Session, username: str | None, *, default_limit: int = MONTHLY_LIMIT) -> bool: """Атомарно-условный инкремент счётчика успешных оценок (#747). Возвращает True если инкремент успешен; False если лимит исчерпан. - None / unlimited → True (no-op success). + None / unlimited → True (no-op success). *default_limit* — см. check_and_raise. Защита от TOCTOU: предикат `WHERE used < :lim` применяется к ветке DO UPDATE — два параллельных запроса при used=lim-1 не могут оба инкрементировать (второй @@ -208,7 +244,7 @@ def increment(db: Session, username: str | None) -> bool: return True period = current_period() - lim = user_limit(db, username) + lim = user_limit(db, username, default=default_limit) row = db.execute( text( """ diff --git a/tradein-mvp/backend/app/services/cache.py b/tradein-mvp/backend/app/services/cache.py index 7de08ac0..80b2465e 100644 --- a/tradein-mvp/backend/app/services/cache.py +++ b/tradein-mvp/backend/app/services/cache.py @@ -48,9 +48,7 @@ class SearchCache: async def set(self, key: str, value: dict[str, Any], ttl: int) -> None: try: - await self._client.set( - key, json.dumps(value, default=str, ensure_ascii=False), ex=ttl - ) + await self._client.set(key, json.dumps(value, default=str, ensure_ascii=False), ex=ttl) except Exception as e: logger.warning("redis SET failed key=%s: %s", key[:24], e) diff --git a/tradein-mvp/backend/app/services/dadata.py b/tradein-mvp/backend/app/services/dadata.py index 8a5b9c9f..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. " diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index c5af066b..dfeb1317 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -27,7 +27,7 @@ import math import re import statistics import time -from collections.abc import Callable, Iterable +from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta from typing import Any, Literal @@ -55,6 +55,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.core.config import LISTINGS_FRESH_DAYS, Settings, settings +from app.core.db import SessionLocal from app.schemas.trade_in import ( AggregatedEstimate, AnalogLot, @@ -186,6 +187,30 @@ DEALS_HEADLINE_FALLBACK_MIN_N = 3 # ИТОГОВОЙ выборке как headline-источнику. HEADLINE_LISTINGS_MIN_N = 5 +# #2899: минимальный размер когорты, при котором ПОЗИЦИЯ объекта в ней (перцентиль) +# перестаёт быть шумом. НЕ переиспользует HEADLINE_LISTINGS_MIN_N=5 намеренно: тот +# отвечает на другой вопрос — «доверять ли выборке как источнику МЕДИАНЫ», а медиана +# устойчива там, где ранг ещё пляшет. +# +# Замер 19.08.2026 (Монте-Карло: из 54 прод-когорт размера 62..125 набирались +# подвыборки размера k, сравнивался ранг объекта в подвыборке с рангом в полной +# когорте; поправка на конечную популяцию учтена): +# k=5 → средняя ошибка ранга 13.4 пп, ярлык «низ/рынок/верх» перепутан в 26.4% +# k=10 → 9.5 пп +# k=15 → 7.6 пп ← точка перелома +# k=20 → 6.7 пп (стоит ещё 10 пп покрытия, даёт ~1 пп точности — не окупается) +# k=30 → 5.4 пп, ярлык перепутан в 10.0% +# +# Согласованный второй сигнал: шаг перцентиля 100/n против ценового разрыва между +# соседями по когорте. При n≈7 шаг 14.3 пп на разрыв 1.8% — один соседний лот двигает +# ярлык на целую категорию; при n≈15 шаг 7.1 пп; при n≈30 — 3.3 пп на 0.8%. +# +# Цена порога: по 1086 историческим оценкам прода n>=15 покрывает 35.8% (n>=5 дало бы +# 76.2%, но с ошибкой ранга вдвое больше). Ниже порога поле остаётся None и UI не +# рисует ничего — «мало данных» уже говорят reliability и relaxations, вторая надпись +# о том же была бы дублированием. +MARKET_PERCENTILE_MIN_N = 15 + # #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; @@ -840,6 +865,63 @@ def _yandex_valuation_cache_key(address: str, offer_category: str, offer_type: s return hashlib.sha256(payload.encode("utf-8")).hexdigest() +# ── отложенная догрузка внешних источников (фоновый режим) ─────────────────── +# +# Держим ссылки на задачи: asyncio не хранит их сам, и без этого сборщик мусора +# может убить задачу на полпути (документированное поведение create_task). +_DEFERRED_REFRESH_TASKS: set[asyncio.Task[None]] = set() + +# Подобран под текущий прод: 1 воркер uvicorn, mem_limit 768m, max_connections +# 100 у Postgres. Догрузка почти всё время ждёт чужой HTTP, поэтому небольшой +# потолок не занижает пропускную способность прогрева. +_MAX_DEFERRED_REFRESH_TASKS = 8 + + +def _defer_external_refresh(label: str, work: Callable[[Session], Awaitable[object]]) -> None: + """Догрузить внешний источник ПОСЛЕ ответа, наполнив кэш к следующему разу. + + Своя сессия — намеренно: сессия запроса закрывается вместе с ответом, а обе + функции источников делают внутри себя `db.commit()`. Переиспользование чужой + сессии зафиксировало бы её незавершённую работу. + + Все ошибки гасятся: это прогрев кэша, а не часть ответа. Провал означает лишь + то, что следующий запрос по адресу снова промахнётся мимо кэша. + """ + + async def _run() -> None: + db = SessionLocal() + try: + await work(db) + except Exception: + logger.exception("deferred %s: догрузка не удалась (кэш не прогрет)", label) + finally: + db.close() + + # Потолок одновременных догрузок. Без него всплеск по НОВЫМ адресам (ровно + # тот случай, ради которого режим и сделан) породил бы сотни параллельных + # задач, каждая с сессией к базе и HTTP-клиентом — при max_connections=100 + # и mem_limit 768m у backend это отказ вместо ускорения. + # Переполнение не ошибка: прогрев кэша необязателен, следующий запрос по + # адресу попробует снова. + if len(_DEFERRED_REFRESH_TASKS) >= _MAX_DEFERRED_REFRESH_TASKS: + logger.info( + "deferred %s: очередь догрузки заполнена (%d) — пропуск", + label, + _MAX_DEFERRED_REFRESH_TASKS, + ) + return + + try: + task = asyncio.create_task(_run()) + except RuntimeError: + # Нет активного loop (синхронный вызов из теста/скрипта) — молча пропускаем: + # прогрев кэша необязателен по определению. + logger.debug("deferred %s: нет event loop — пропуск", label) + return + _DEFERRED_REFRESH_TASKS.add(task) + task.add_done_callback(_DEFERRED_REFRESH_TASKS.discard) + + async def _get_or_fetch_yandex_valuation_cached( db: Session, *, @@ -847,6 +929,7 @@ async def _get_or_fetch_yandex_valuation_cached( offer_category: str = YANDEX_VALUATION_DEFAULT_CATEGORY, offer_type: str = YANDEX_VALUATION_DEFAULT_TYPE, house_id: int | None = None, + fetch_on_miss: bool = True, ) -> YandexValuationResult | None: """Cached Yandex Valuation lookup. TTL 24h via external_valuations table. @@ -900,6 +983,12 @@ async def _get_or_fetch_yandex_valuation_cached( except Exception as e: logger.warning("yandex_valuation: cache deserialize failed — refetching: %s", e) + # fetch_on_miss=False — режим «только кэш» (фоновый режим внешних источников): вызывающий код + # не хочет ждать внешний HTTP в запросе и сам поставит догрузку в фон. + if not fetch_on_miss: + logger.info("yandex_valuation: cache MISS key=%s — fetch отложен", cache_key[:8]) + return None + # Fresh fetch try: async with YandexValuationScraper( @@ -1069,7 +1158,7 @@ def _save_yandex_history_items( if skipped_area > 0: logger.info( - "yandex_valuation: skipped %d/%d history items with area_m2 <= 0 or None" " (addr=%r)", + "yandex_valuation: skipped %d/%d history items with area_m2 <= 0 or None (addr=%r)", skipped_area, len(result.history_items), result.address, @@ -3046,15 +3135,16 @@ def _price_from_inputs( ) anchor = None - # #2656: якорь не построен — сбрасываем tier-флаг ЯВНО. Причин три: + # #2661: якорь не построен — сбрасываем tier-флаг ЯВНО. Причин три: # _compute_same_building_anchor вернула None (комплов меньше min_comps, в т.ч. - # после MAD-клипа), гейт Tier C #1795 или low-conf гейт #audit-1 выше. Раньше - # сброс делал только последний из трёх, и в остальных случаях anchor_tier - # залипал равным anchor_tier_fetched ("C"/"A") при радиусном headline. Флаг - # читают IMV-blend (`anchor_tier is None`, ниже), quarter-index #764 Guard-1a, - # radius-floor от ДКП-коридора, corridor-clamp (Tier A exempt) и api_analog_tier - # — залипший флаг молча глушил их все, будто headline построил якорь. Замер - # #2656: 24 оценки без якоря до и после правки всё равно меняли цену из-за него. + # после MAD-клипа, см. #oblast-E выше), гейт Tier C #1795 или low-conf гейт + # #audit-1 выше. Раньше сброс делал только последний из трёх, и в остальных + # случаях anchor_tier залипал равным anchor_tier_fetched ("C"/"A") при радиусном + # headline. Флаг читают IMV-blend (`anchor_tier is None`, ниже), quarter-index + # #764 Guard-1a, radius-floor от ДКП-коридора, corridor-clamp (Tier A exempt) и + # api_analog_tier — залипший флаг молча глушил их все, будто headline построил + # якорь. Замер: 154 из 996 сделок теряют tier-флаг этой правкой, но ценообразование + # у них не двигается (расхождение ровно в 0.000%) — чинится именно флаг. if anchor is None: anchor_tier = None @@ -3237,7 +3327,7 @@ def _price_from_inputs( range_high = new_range_high # Display-only IMV summary — когда карточку не заполнил blend выше. - # #2656: условие было `anchor_tier is not None and ...`. После сброса залипшего + # #2661: условие было `anchor_tier is not None and ...`. После сброса залипшего # флага оставалась щель: комплы якоря добыты (тир был "C"), якорь не построен, а # headline подавлен/нулевой → blend не срабатывает (ему нужны listings_clean и # median_price > 0), старый блок тоже (tier уже None) — и пользователь ТЕРЯЛ @@ -3321,8 +3411,7 @@ def _price_from_inputs( ) if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio: logger.info( - "quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f)" - " for %s", + "quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s", same_quarter_ratio, settings.estimate_quarter_match_skip_ratio, target_quarter, @@ -3341,42 +3430,63 @@ def _price_from_inputs( weighted_sum += lp * lot_qi weight_total += lp - avg_analog_index = weighted_sum / weight_total if weight_total > 0 else 1.0 + # Guard-5 (C3, #2583): без единого проиндексированного аналога + # уровень аналогов НЕИЗВЕСТЕН. Прежний фолбэк avg=1.0 подставлял + # сюда не «нейтраль», а МЕДИАНУ ЕКАТЕРИНБУРГА: price_index + # нормирован на неё (99a_quarter_price_index.sql), так что 1.0 + # читается как «аналоги стоят как ЕКБ». Для цели вне ЕКБ это + # превращало gap-коррекцию в безусловную скидку до уровня ЕКБ + # (индексы области 0.28–0.82 → factor 0.6 после клампа, −40%). + # Нет данных → нет поправки: factor=1.0 эквивалентен + # avg := target_qi, а это на проде и есть лучшая оценка + # неизвестного avg (медиана |ошибки| 0.116 против 0.161 у 1.0, + # p90 0.337 против 0.517; 400 лотов, замер 2026-08-12). + if weight_total <= 0: + logger.info( + "quarter_index: Guard-5 skip — ни один аналог не имеет" + " квартального индекса (target=%s target_qi=%.3f" + " analogs_with_quarter=%d)", + target_quarter, + target_qi, + len(analog_quarters), + ) + else: + avg_analog_index = weighted_sum / weight_total - ( - median_ppm2, - median_price, - range_low, - range_high, - qi_factor, - ) = _apply_quarter_index( - base_median_ppm2=median_ppm2, - base_median_price=median_price, - base_range_low=range_low, - base_range_high=range_high, - target_index=target_qi, - avg_analog_index=avg_analog_index, - min_factor=settings.estimate_quarter_index_factor_min, - max_factor=settings.estimate_quarter_index_factor_max, - ) - analogs_with_qi = sum( - 1 for lq, _lp in analog_quarters if lq in analog_index_map - ) - logger.info( - "quarter_index: applied target=%s target_qi=%.3f" - " avg_analog_qi=%.3f factor=%.3f" - " (same_quarter_ratio=%.2f analogs_with_qi=%d)", - target_quarter, - target_qi, - avg_analog_index, - qi_factor, - same_quarter_ratio, - analogs_with_qi, - ) - explanation = (explanation or "") + ( - f" Учтена локация квартала" f" (индекс цен квартала ×{qi_factor:.2f})." - ) - sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"}) + ( + median_ppm2, + median_price, + range_low, + range_high, + qi_factor, + ) = _apply_quarter_index( + base_median_ppm2=median_ppm2, + base_median_price=median_price, + base_range_low=range_low, + base_range_high=range_high, + target_index=target_qi, + avg_analog_index=avg_analog_index, + min_factor=settings.estimate_quarter_index_factor_min, + max_factor=settings.estimate_quarter_index_factor_max, + ) + analogs_with_qi = sum( + 1 for lq, _lp in analog_quarters if lq in analog_index_map + ) + logger.info( + "quarter_index: applied target=%s target_qi=%.3f" + " avg_analog_qi=%.3f factor=%.3f" + " (same_quarter_ratio=%.2f analogs_with_qi=%d)", + target_quarter, + target_qi, + avg_analog_index, + qi_factor, + same_quarter_ratio, + analogs_with_qi, + ) + explanation = (explanation or "") + ( + f" Учтена локация квартала (индекс цен квартала ×{qi_factor:.2f})." + ) + sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"}) # ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ────────────── slack = settings.estimate_corridor_clamp_slack @@ -3573,7 +3683,7 @@ def _price_from_inputs( # 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. - # #2656 update: тот залипший флаг теперь сбрасывается у источника (см. `if + # #2661 update: тот залипший флаг теперь сбрасывается у источника (см. `if # anchor is None: anchor_tier = None` в anchor-блоке выше), т.е. два условия # стали эквивалентны. Гард оставлен на `anchor is None` НАМЕРЕННО — это # по-прежнему прямая проверка факта «якорь дал headline», а не производный флаг. @@ -4242,29 +4352,52 @@ async def estimate_quality( and imv_house_type is not None and imv_renovation is not None ): - imv_eval = await _get_or_fetch_imv_cached( - db, - address=geo.full_address, - rooms=payload.rooms, - area_m2=payload.area_m2, - floor=payload.floor, - floor_at_home=payload.total_floors, - house_type=imv_house_type, - renovation_type=imv_renovation, - has_balcony=bool(payload.has_balcony), - has_loggia=False, + # #654/#b2c-antiabuse-2: единственный внешний вызов, ранее БЕЗ time-budget + # guard — evaluate_via_imv делает до 3 последовательных HTTP-запросов + # (warm-up + geocode + evaluate), каждый со своим таймаутом 25s + # (_HTTP_TIMEOUT_SEC в scraper_kit.providers.avito.imv), плюс возможен ОДИН + # internal retry с "очищенным" адресом на IMVAddressNotFoundError (см. + # _get_or_fetch_imv_cached) — необёрнутый worst-case доходил до ~150s. + # Оборачиваем так же, как соседние ungated-вызовы (geocode/house_meta/ + # yandex_valuation/cian_valuation). + imv_eval = await _with_budget( + _get_or_fetch_imv_cached( + db, + address=geo.full_address, + rooms=payload.rooms, + area_m2=payload.area_m2, + floor=payload.floor, + floor_at_home=payload.total_floors, + house_type=imv_house_type, + renovation_type=imv_renovation, + has_balcony=bool(payload.has_balcony), + has_loggia=False, + ), + settings.estimate_avito_imv_timeout_s, + label="avito_imv", ) # ── Stage 8: Yandex Valuation as on-demand source (anonymous, cached 24h) ── yandex_val: YandexValuationResult | None = None if geo is not None and geo.full_address: + _y_addr, _y_house = geo.full_address, target_house_id + _bg = settings.estimate_external_sources_background yandex_val = await _with_budget( _get_or_fetch_yandex_valuation_cached( - db, address=geo.full_address, house_id=target_house_id + db, address=_y_addr, house_id=_y_house, fetch_on_miss=not _bg ), settings.estimate_yandex_valuation_timeout_s, label="yandex_valuation", ) + if _bg and yandex_val is None: + # Промах кэша: ответ не ждёт ~6 с внешнего HTTP, догрузка уходит в фон + # и наполняет кэш к следующему обращению по этому адресу. + _defer_external_refresh( + "yandex_valuation", + lambda bg_db: _get_or_fetch_yandex_valuation_cached( + bg_db, address=_y_addr, house_id=_y_house + ), + ) if yandex_val is not None: saved_hist = await asyncio.to_thread(_save_yandex_history_items, db, yandex_val) logger.info( @@ -4284,24 +4417,30 @@ async def estimate_quality( and payload.floor is not None and payload.total_floors is not None ): + _c_bg = settings.estimate_external_sources_background + _c_kwargs: dict[str, Any] = { + "config": RealScraperConfig(), + "address": geo.full_address, + "total_area": payload.area_m2, + "rooms_count": payload.rooms, + "floor": payload.floor, + "total_floors": payload.total_floors, + "repair_type": "cosmetic", + "deal_type": "sale", + "use_cache": True, + "house_id": target_house_id, + } try: cian_val = await _with_budget( - estimate_via_cian_valuation( - db, - config=RealScraperConfig(), - address=geo.full_address, - total_area=payload.area_m2, - rooms_count=payload.rooms, - floor=payload.floor, - total_floors=payload.total_floors, - repair_type="cosmetic", - deal_type="sale", - use_cache=True, - house_id=target_house_id, - ), + estimate_via_cian_valuation(db, fetch_on_miss=not _c_bg, **_c_kwargs), settings.estimate_cian_valuation_timeout_s, label="cian_valuation", ) + if _c_bg and cian_val is None: + _defer_external_refresh( + "cian_valuation", + lambda bg_db: estimate_via_cian_valuation(bg_db, **_c_kwargs), + ) if cian_val is not None and cian_val.sale_price_rub: logger.info( "cian_valuation: price=%s accuracy=%s house_id=%s", @@ -4528,7 +4667,18 @@ async def estimate_quality( # _dedup_display_lots ловит остаточные кросс-source дубли (ценовой дрейф между # площадками), которые price_bucket-строгий статистический _dedup_cross_source # мог пропустить — display-only, n_analogs/median/cv не трогает. + # #2899: позиция объекта внутри ТОЙ ЖЕ когорты, что дала headline, и по УЖЕ + # финальной median_ppm2 (все восемь мутаторов цены — repair_coef, anchor override, + # IMV-blend, quarter index, corridor clamp, radius floor, deals fallback, segment + # multiplier — лежат внутри _price_from_inputs и отработали выше). Считать внутри + # _price_from_inputs нельзя: там цена ещё не финальная, а n_analogs после точки + # расчёта перезаписывается трижды — перцентиль разошёлся бы с показанной ценой у + # двух третей оценок, которые идут якорным путём. + # + # Пул берётся ДО _dedup_display_lots: дедуп режет выборку под показ, а позиция + # должна считаться по популяции, из которой взята цена. if anchor_tier is not None and anchor_comps_used: + market_percentile = _market_percentile(median_ppm2, anchor_comps_used) display_pool = _dedup_display_lots(anchor_comps_used) analogs_lots = [_anchor_comp_to_analog(c) for c in display_pool[:10]] # #1519: при сработавшем якоре метаданные (freshness/last_scraped_at/ @@ -4543,6 +4693,7 @@ async def estimate_quality( # их сохраняет — «нечего судить»), которые раздували карточки сверх # заявленного N. _dedup_display_lots — см. ветку anchor выше. priced_clean = [lot for lot in listings_clean if lot.get("price_per_m2")] + market_percentile = _market_percentile(median_ppm2, priced_clean) display_pool = _dedup_display_lots(priced_clean) analogs_lots = [_listing_to_analog(lot) for lot in display_pool[:10]] metadata_lots = display_pool @@ -4583,6 +4734,7 @@ async def estimate_quality( ownership_type, has_mortgage, median_price, range_low, range_high, median_price_per_m2, confidence, confidence_explanation, n_analogs, + market_percentile, analogs, actual_deals, sources_used, data_freshness_minutes, canonical_address, house_cadnum, house_fias_id, @@ -4602,6 +4754,7 @@ async def estimate_quality( :ownership_type, :has_mortgage, :median_price, :range_low, :range_high, :median_ppm2, :confidence, :explanation, :n_analogs, + :market_percentile, CAST(:analogs_json AS jsonb), CAST(:deals_json AS jsonb), CAST(:sources_json AS jsonb), @@ -4642,6 +4795,7 @@ async def estimate_quality( "confidence": confidence, "explanation": explanation, "n_analogs": n_analogs, + "market_percentile": market_percentile, "analogs_json": json.dumps( [a.model_dump(mode="json") for a in analogs_lots], ensure_ascii=False ), @@ -4794,6 +4948,7 @@ async def estimate_quality( confidence=confidence, confidence_explanation=explanation, n_analogs=n_analogs, + market_percentile=market_percentile, period_months=DEALS_PERIOD_MONTHS, analogs=analogs_lots, actual_deals=deals_lots, @@ -4802,6 +4957,7 @@ async def estimate_quality( 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, @@ -5428,6 +5584,54 @@ def _adjust_relevance_by_pool_deviation( c["relevance_score"] = (c.get("relevance_score") or 0.0) + penalty +def _apply_unknown_attr_penalty( + candidates: list[dict[str, Any]], + *, + target_year: int | None, + target_house_type: str | None, + min_n: int, +) -> None: + """#2936: неизвестный year_built / house_type получает МЕДИАННЫЙ по пулу штраф. + + SQL-формула relevance_score даёт кандидату без year_built штраф 0 — столько + же, сколько точному попаданию в год, и лучше, чем известному году с разницей + 24 (2.0). Отсутствие данных выигрывает у знания и возвышает источник с худшей + полнотой (см. комментарий к estimate_unknown_attr_penalty_enabled в config). + + Штраф за NULL = медиана штрафов того же признака среди кандидатов пула, у + которых он известен: не наказание и не награда. Считается ТОЛЬКО по тем + членам пула, где SQL реально начислил штраф, — то есть воспроизводит ровно + SQL-термы abs(year_built - target)/12.0 и 1.5-за-несовпадение, без своего + понимания «насколько далеко». Если target неизвестен, SQL штраф не начисляет + никому — и здесь тоже (симметрия: CASE WHEN :target_... IS NOT NULL). + + Sparse-safe: известных значений меньше ``min_n`` → сигнал пропущен целиком. + Мутирует relevance_score IN PLACE — та же конвенция, что у kitchen/ceiling. + """ + if target_year is not None: + known = [ + abs(int(c["year_built"]) - int(target_year)) / 12.0 + for c in candidates + if c.get("year_built") is not None + ] + if len(known) >= min_n: + median_pen = statistics.median(known) + for c in candidates: + if c.get("year_built") is None: + c["relevance_score"] = (c.get("relevance_score") or 0.0) + median_pen + if target_house_type is not None: + known_ht = [ + 1.5 if str(c["house_type"]) != str(target_house_type) else 0.0 + for c in candidates + if c.get("house_type") is not None + ] + if len(known_ht) >= min_n: + median_pen = statistics.median(known_ht) + for c in candidates: + if c.get("house_type") is None: + c["relevance_score"] = (c.get("relevance_score") or 0.0) + median_pen + + def _apply_kitchen_ceiling_signal(candidates: list[dict[str, Any]]) -> None: """#2012: apply the kitchen_area_m2 / ceiling_height_m comp-scoring signals. @@ -5466,7 +5670,16 @@ _ANALOG_SELECT_COLS = """ -- Tier H/W применяют их (см. _apply_kitchen_ceiling_signal) — Tier S -- (same building) не трогают, симметрично house_type/year_built, которые -- тоже не участвуют в Tier S relevance (там фиксированный 0.0). - kitchen_area_m2, ceiling_height_m + kitchen_area_m2, ceiling_height_m, + -- #2936: year_built / house_type ВЫБИРАЮТСЯ, а не только участвуют в SQL-CASE. + -- До этого SQL считал по ним relevance_score, но в словарь кандидата колонки не + -- попадали: Python-слой видел None у ВСЕХ и штрафовать «неизвестное» не мог по + -- построению — прогон A/B с флагом дал 0 из 5 915 сменённых слотов при 55.7 % + -- avito в топ-50. Замечено probe-логом в оверлее: pool=30 null_year=30 + -- null_type=30. Для путей, которые эти ключи не читают, добавление байт- + -- идентично; фикстура бэктеста реплеится с замороженными listings и гейта не + -- трогает. + year_built, house_type """ _COMMON_WHERE = """ @@ -5834,7 +6047,10 @@ def _fetch_analogs( listing_date, days_on_market, photo_urls, scraped_at, distance_m, relevance_score, building_cadastral_number, - kitchen_area_m2, ceiling_height_m + kitchen_area_m2, ceiling_height_m, + -- #2936: see _ANALOG_SELECT_COLS — внешний список дублирует + -- внутренний, без этого Python-слой не видит год и тип дома. + year_built, house_type FROM base WHERE rn_addr <= :max_per_addr {dup_filter} @@ -5867,6 +6083,13 @@ def _fetch_analogs( tier_h = [dict(r) for r in tier_h_rows] _apply_kitchen_ceiling_signal(tier_h) + if settings.estimate_unknown_attr_penalty_enabled: # #2936, default OFF + _apply_unknown_attr_penalty( + tier_h, + target_year=year_built, + target_house_type=house_type, + min_n=settings.estimate_unknown_attr_penalty_min_n, + ) tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0) if len(tier_h) >= 5: logger.info( @@ -5900,6 +6123,9 @@ def _fetch_analogs( scraped_at, building_cadastral_number, kitchen_area_m2, ceiling_height_m, + -- #2936: Tier W строит base явным списком (не _ANALOG_SELECT_COLS), + -- поэтому год и тип дома добавляются здесь отдельно. + year_built, house_type, id, ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m, @@ -5994,6 +6220,9 @@ def _fetch_analogs( scraped_at, building_cadastral_number, kitchen_area_m2, ceiling_height_m, + -- #2936: see _ANALOG_SELECT_COLS — внешний список дублирует + -- внутренний, без этого Python-слой не видит год и тип дома. + year_built, house_type, distance_m, relevance_score FROM base @@ -6031,6 +6260,13 @@ def _fetch_analogs( candidates: list[dict[str, Any]] = [dict(r) for r in tier_w_rows] _apply_kitchen_ceiling_signal(candidates) + if settings.estimate_unknown_attr_penalty_enabled: # #2936, default OFF + _apply_unknown_attr_penalty( + candidates, + target_year=year_built, + target_house_type=house_type, + min_n=settings.estimate_unknown_attr_penalty_min_n, + ) candidates.sort(key=lambda r: r.get("relevance_score") or 0.0) logger.info("analogs tier=W radius=%dm → %d candidates", radius_m, len(candidates)) return _stratify_candidates(candidates), radius_m > DEFAULT_RADIUS_M, "W" @@ -6707,6 +6943,36 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: return _union_find_phys_dedup(lots, include_price=True) +def _market_percentile(target_ppm2: float | None, pool: list[dict]) -> int | None: + """Позиция объекта внутри когорты аналогов, 1..99. PURE. + + Перцентиль РАНГА, а не квантиль: отвечает на вопрос «какая доля когорты дешевле + нас», тогда как `_percentile` решает обратную задачу — «какая цена стоит на + заданной доле». Формула midrank: доля строго дешевле плюс половина равных, что + даёт 50 для объекта ровно по медиане симметричной когорты и не зависит от того, + попал ли сам объект в пул. + + Пул — тот же, что дал headline (`anchor_comps_used` либо ценовые `listings_clean`), + и ДО `_dedup_display_lots`: дедуп режет пул под показ (на проде 15 карточек из 528 + усечены именно им), а позиция должна считаться по той популяции, из которой взята + цена. + + Возвращает None, если считать не по чему или когорта меньше + MARKET_PERCENTILE_MIN_N — см. обоснование порога у константы. Зажимаем в 1..99: + «0-й перцентиль» и «100-й» читаются как «дешевле всех на свете», хотя означают + лишь край конкретной выборки. + """ + if target_ppm2 is None or not pool: + return None + prices = [float(lot["price_per_m2"]) for lot in pool if lot.get("price_per_m2") is not None] + if len(prices) < MARKET_PERCENTILE_MIN_N: + return None + below = sum(1 for p in prices if p < target_ppm2) + equal = sum(1 for p in prices if p == target_ppm2) + raw = 100.0 * (below + 0.5 * equal) / len(prices) + return max(1, min(99, round(raw))) + + def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]: """Дедуп ОТОБРАЖАЕМЫХ карточек-аналогов по физическому ключу БЕЗ price_bucket. @@ -6825,7 +7091,7 @@ def _enforce_zero_analog_low( """ if n_analogs == 0 and confidence != "low": logger.warning( - "ghost_anchor_guard #1871: forcing confidence 'low' (was %s, " "median=%s, sources=%s)", + "ghost_anchor_guard #1871: forcing confidence 'low' (was %s, median=%s, sources=%s)", confidence, median_price, sources_used, 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 8e02aae4..ca2aff59 100644 --- a/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py +++ b/tradein-mvp/backend/app/services/exporters/trade_in_pdf.py @@ -238,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) ────────────── @@ -528,6 +533,20 @@ def _page_header(brand, report_num: str, report_date: dt.date) -> str: # type: ) +# Блок 4.2 юр-требований: должен печататься в подвале КАЖДОЙ страницы отчёта +# (не только cover). Текст утверждён владельцем продукта дословно — не менять +# формулировку без явного запроса. Заведён как модульная константа (не inline +# в _page_footer), чтобы не расползалась по нескольким билдерам страниц. +_PDF_135FZ_FOOTER_NOTICE = ( + "Документ содержит индикативный (ориентировочный) расчёт стоимости объекта, " + "сформированный автоматически сервисом «МЕРА». Не является отчётом об оценке " + "по Федеральному закону № 135-ФЗ и не имеет установленной этим законом " + "юридической силы. Не предназначен для использования при ипотечном " + "кредитовании, в судебных разбирательствах, нотариальных действиях и иных " + "случаях, где законом предусмотрено обязательное проведение независимой оценки." +) + + def _page_footer( brand, # type: ignore[no-untyped-def] report_num: str, @@ -542,7 +561,10 @@ 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), не связанный с реальной @@ -552,6 +574,17 @@ def _page_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: @@ -583,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}
-
@@ -606,6 +639,11 @@ def _page_footer( {_html.escape(brand.name).upper()}
+
+ {_html.escape(_PDF_135FZ_FOOTER_NOTICE)} +
""" @@ -1627,9 +1665,12 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) - {f''' + { + f'''''' - if deals_as_of else ""} + if deals_as_of + else "" + }
Количество сделок по аналогичном объектам {_mono(f"{n_deals} шт.")}
Сделки
Сделки {_mono(deals_as_of)}
Источники данных
@@ -1962,7 +2003,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 7785116a..7863b308 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio import logging import re +import time from dataclasses import dataclass, replace from typing import Literal @@ -29,6 +30,43 @@ from app.services import dadata logger = logging.getLogger(__name__) +# ── Общий ограничитель темпа обращений к Nominatim (#2953) ────────────────── +# +# Политика Nominatim — 1 req/sec. Раньше её изображали три разрозненных +# `asyncio.sleep(1.0)`, и ни один не давал ограничения на самом деле: +# +# geocode() — сон стоял ВНУТРИ `if result is not None`, то есть +# только после УСПЕХА; после неудачи паузы не было; +# _nominatim_lookup() — сон МЕЖДУ typo-вариантами, но не перед tier-1; +# цикл бэкфилла — своей паузы не имеет вовсе. +# +# Для неразрешимого адреса получалось 5 запросов за 4 секунды, и сразу +# следующий адрес без паузы. А неразрешимые адреса — не редкость: на +# 20.08.2026 из 1471 активного объявления без координат 1424 лежали в +# семидневном backoff, то есть череда неудач случается КАЖДЫЙ прогон +# бэкфилла. Отсюда 194 события «429 Too many requests» за неделю. +# +# Здесь пауза берётся ПЕРЕД каждым запросом и одна на все точки вызова, а не +# после успеха и не на каждую по отдельности. +# +# ponytail: ограничитель внутрипроцессный. Если бэкфилл и пользовательские +# запросы разъедут по разным процессам, их темпы снова сложатся — тогда +# понадобится общий счётчик (Redis). Пока доминирующий источник один +# (бэкфилл, сотни запросов за прогон), внутрипроцессного достаточно. +_NOMINATIM_MIN_INTERVAL_SEC = 1.0 +_nominatim_gate = asyncio.Lock() +_nominatim_last_call_at = 0.0 + + +async def _nominatim_throttle() -> None: + """Держит паузу ≥ _NOMINATIM_MIN_INTERVAL_SEC между обращениями к Nominatim.""" + global _nominatim_last_call_at + async with _nominatim_gate: + overdue = _NOMINATIM_MIN_INTERVAL_SEC - (time.monotonic() - _nominatim_last_call_at) + if overdue > 0: + await asyncio.sleep(overdue) + _nominatim_last_call_at = time.monotonic() + # ── Result type ────────────────────────────────────────────────────────────── @dataclass(frozen=True, slots=True) @@ -44,6 +82,22 @@ class GeocodeResult: # результата — честный сигнал «доверяй, но проверяй», чтобы вызывающий код мог # понизить 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 ─────────────────────────────────────────────────────── @@ -696,6 +750,7 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No (`address.state`) — отсекает кандидатов ЯВНО из другого региона (Тюмень и т.п.), даже если координаты попали в генеральный bbox. """ + await _nominatim_throttle() response = await client.get( "https://nominatim.openstreetmap.org/search", params={ @@ -726,7 +781,23 @@ 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)) +# 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. @@ -758,7 +829,6 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco # 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 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) @@ -849,6 +919,7 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion] async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]: """Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items.""" + await _nominatim_throttle() response = await client.get( "https://nominatim.openstreetmap.org/search", params={ @@ -920,12 +991,12 @@ async def _nominatim_query_city_aware( if city_specified: return await _nominatim_query_multi(client, query, limit) ekb_data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit) - await asyncio.sleep(1.0) # Nominatim 1 req/sec policy — два запроса подряд bare_data = await _nominatim_query_multi(client, query, limit) return _dedupe_nominatim_items(ekb_data, bare_data)[:limit] -@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) +# 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]: @@ -953,7 +1024,6 @@ async def _nominatim_suggest( # Tier 2: typo-варианты если оригинал пустой if not data: for variant in _typo_variants(query, limit=3): - await asyncio.sleep(1.0) # Nominatim 1 req/sec variant_city, variant_specified = _resolve_city_for_geocode(variant, city_hint) data = await _nominatim_query_city_aware( client, variant, variant_city, variant_specified, limit @@ -1188,12 +1258,13 @@ def _cadastral_house_match(db: Session, street: str, house: str) -> GeocodeSugge ВНИМАНИЕ, цепочки различаются — не путать: * `geocode()` : geoportal → cadastral → `_cadastral_forward_sync` - → Nominatim → None. Тира DaData тут НЕТ. + → Nominatim → `_local_houses_match` (#2626, houses-фолбэк) + → None. Тира DaData тут НЕТ. * `suggest()` : cadastral → DaData → Nominatim (единственный вызов `_dadata_suggest`). То есть на прямом вызове `geocode()` (API/PDF/восстановление по `?id=`) - адрес с литерой, неизвестный ни геопорталу, ни Nominatim, даёт None — - оценка не строится. Это сознательный выбор: честный отказ вместо + адрес с литерой, неизвестный ни геопорталу, ни Nominatim, ни houses-фолбэку, + даёт None — оценка не строится. Это сознательный выбор: честный отказ вместо уверенно-неверной оценки чужого дома. Основной UI-путь этим не задет — координаты приходят из выбранной подсказки (`ParamsPanel.tsx:776` → `api/v1/trade_in.py:128` использует lat/lon напрямую, минуя `geocode()`). @@ -1310,6 +1381,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. @@ -1580,12 +1928,49 @@ async def _geocode_resolve( 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 — спим после успешного запроса - await asyncio.sleep(1.0) return result 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 @@ -1654,7 +2039,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)) +# 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. @@ -1667,6 +2053,7 @@ async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | N "Accept-Language": "ru,en;q=0.8", } async with httpx.AsyncClient(timeout=10.0, headers=headers) as client: + await _nominatim_throttle() response = await client.get( "https://nominatim.openstreetmap.org/reverse", params={ diff --git a/tradein-mvp/backend/app/services/house_metadata.py b/tradein-mvp/backend/app/services/house_metadata.py index ff2d1f87..c69844c8 100644 --- a/tradein-mvp/backend/app/services/house_metadata.py +++ b/tradein-mvp/backend/app/services/house_metadata.py @@ -27,9 +27,9 @@ from app.core.config import settings logger = logging.getLogger(__name__) OVERPASS_URL = "https://overpass-api.de/api/interpreter" -_CACHE_RADIUS_M = 40 # дом в пределах 40 м от точки — считаем тем же -_OVERPASS_RADIUS_M = 25 # ищем здание в 25 м от геокодированной точки -_CAD_RADIUS_M = 60 # радиус поиска здания в cad_buildings (#393) +_CACHE_RADIUS_M = 40 # дом в пределах 40 м от точки — считаем тем же +_OVERPASS_RADIUS_M = 25 # ищем здание в 25 м от геокодированной точки +_CAD_RADIUS_M = 60 # радиус поиска здания в cad_buildings (#393) @dataclass(frozen=True, slots=True) @@ -38,15 +38,18 @@ class HouseMetadata: lon: float year_built: int | None total_floors: int | None - house_type: str | None # panel / brick / monolith / monolith_brick / other + house_type: str | None # panel / brick / monolith / monolith_brick / other total_units: int | None - source: str # 'osm' / 'cache' + source: str # 'osm' / 'cache' # ── Парсинг тегов OSM ──────────────────────────────────────────────────────── _YEAR_KEYS = ( - "start_date", "construction_date", "building:year", - "year_of_construction", "building:start_date", + "start_date", + "construction_date", + "building:year", + "year_of_construction", + "building:start_date", ) @@ -110,9 +113,12 @@ def _cache_get(db: Session, lat: float, lon: float) -> HouseMetadata | None: if row is None: return None return HouseMetadata( - lat=row.lat, lon=row.lon, - year_built=row.year_built, total_floors=row.total_floors, - house_type=row.house_type, total_units=row.total_units, + lat=row.lat, + lon=row.lon, + year_built=row.year_built, + total_floors=row.total_floors, + house_type=row.house_type, + total_units=row.total_units, source="cache", ) @@ -129,9 +135,12 @@ def _cache_put(db: Session, meta: HouseMetadata, raw: dict) -> None: """ ), { - "lat": meta.lat, "lon": meta.lon, - "year": meta.year_built, "floors": meta.total_floors, - "htype": meta.house_type, "units": meta.total_units, + "lat": meta.lat, + "lon": meta.lon, + "year": meta.year_built, + "floors": meta.total_floors, + "htype": meta.house_type, + "units": meta.total_units, "raw": json.dumps(raw, ensure_ascii=False), }, ) @@ -184,10 +193,11 @@ def _cad_buildings_get(db: Session, lat: float, lon: float) -> HouseMetadata | N if row is None: return None return HouseMetadata( - lat=lat, lon=lon, + lat=lat, + lon=lon, year_built=row.year_built, total_floors=row.floors, - house_type=None, # purpose не маппится в panel/brick + house_type=None, # purpose не маппится в panel/brick total_units=None, source="cadastre", ) @@ -219,7 +229,8 @@ async def _overpass_lookup(lat: float, lon: float) -> tuple[HouseMetadata, dict] best = min(elements, key=_dist2) tags = best.get("tags", {}) meta = HouseMetadata( - lat=lat, lon=lon, + lat=lat, + lon=lon, year_built=_parse_year(tags), total_floors=_parse_int(tags, "building:levels"), house_type=_parse_house_type(tags), @@ -242,7 +253,10 @@ async def get_house_metadata(lat: float, lon: float, db: Session) -> HouseMetada if cad is not None: logger.info( "house_metadata: cad_buildings (%.5f, %.5f) → year=%s floors=%s", - lat, lon, cad.year_built, cad.total_floors, + lat, + lon, + cad.year_built, + cad.total_floors, ) return cad @@ -264,13 +278,15 @@ async def get_house_metadata(lat: float, lon: float, db: Session) -> HouseMetada _cache_put(db, meta, raw) logger.info( "house_metadata OSM: (%.5f, %.5f) → year=%s floors=%s type=%s", - lat, lon, meta.year_built, meta.total_floors, meta.house_type, + lat, + lon, + meta.year_built, + meta.total_floors, + meta.house_type, ) return meta except Exception: - logger.warning( - "house_metadata enrichment failed at (%.5f, %.5f)", lat, lon, exc_info=True - ) + logger.warning("house_metadata enrichment failed at (%.5f, %.5f)", lat, lon, exc_info=True) try: db.rollback() except Exception: diff --git a/tradein-mvp/backend/app/services/matching/houses.py b/tradein-mvp/backend/app/services/matching/houses.py index 1effa723..531cb1fc 100644 --- a/tradein-mvp/backend/app/services/matching/houses.py +++ b/tradein-mvp/backend/app/services/matching/houses.py @@ -191,8 +191,7 @@ def match_or_create_house( row = ( db.execute( text( - "SELECT house_id FROM house_sources " - "WHERE ext_source = :s AND ext_id = :e LIMIT 1" + "SELECT house_id FROM house_sources WHERE ext_source = :s AND ext_id = :e LIMIT 1" ), {"s": ext_source, "e": str(ext_id)}, ) diff --git a/tradein-mvp/backend/app/services/matching/listings.py b/tradein-mvp/backend/app/services/matching/listings.py index ceaae20c..398bbefa 100644 --- a/tradein-mvp/backend/app/services/matching/listings.py +++ b/tradein-mvp/backend/app/services/matching/listings.py @@ -287,6 +287,26 @@ def _upsert_listing_source( floor = COALESCE(EXCLUDED.floor, listing_sources.floor), rooms_count = COALESCE(EXCLUDED.rooms_count, listing_sources.rooms_count), raw_payload = COALESCE(EXCLUDED.raw_payload, listing_sources.raw_payload) + -- #2992: симметричный гейт к listings-апсерту (scraper_kit/base.py). Вторая + -- по нагрузке таблица базы: 10.27 млн апдейтов на 102 тыс. строк, HOT 0.10 %. + -- Апдейт идёт, если итоговое значение хоть одной колонки отличается ИЛИ + -- last_seen_at ещё не сегодняшний по МСК. Тот же МСК-критерий, что у listings, + -- — иначе разъехались бы listings.last_seen_at и listing_sources.last_seen_at, + -- равные сегодня у 100 % пар (см. докстринг выше). + WHERE ( + listing_sources.confidence, listing_sources.price_rub, + listing_sources.area_m2, listing_sources.floor, + listing_sources.rooms_count, listing_sources.raw_payload + ) IS DISTINCT FROM ( + GREATEST(EXCLUDED.confidence, listing_sources.confidence), + COALESCE(EXCLUDED.price_rub, listing_sources.price_rub), + COALESCE(EXCLUDED.area_m2, listing_sources.area_m2), + COALESCE(EXCLUDED.floor, listing_sources.floor), + COALESCE(EXCLUDED.rooms_count, listing_sources.rooms_count), + COALESCE(EXCLUDED.raw_payload, listing_sources.raw_payload) + ) + OR (listing_sources.last_seen_at AT TIME ZONE 'Europe/Moscow')::date + IS DISTINCT FROM (statement_timestamp() AT TIME ZONE 'Europe/Moscow')::date """), { "lid": listing_id, diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 3abf3360..4076fa67 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -217,7 +217,11 @@ async def _job_deactivate_stale( ) -> None: from app.core.config import settings as _settings from app.tasks.deactivate_stale_avito import ( + CAP_MULT, + DEFAULT_FLOOR_DROP_RATIO, + DEFAULT_MAX_DEACTIVATED, DEFAULT_MIN_CONFIRMATIONS, + DEFAULT_MIN_FLOOR_PAIRS, DEFAULT_REVISIT_FLOOR_QUANTILE, deactivate_stale_listings, ) @@ -236,6 +240,24 @@ async def _job_deactivate_stale( 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) + # Гейт деградации пола (PR-B, #2659 продолжение) -- включён по умолчанию, тот + # же принцип, что у min_confirmations/revisit_floor_quantile выше: незасеянное + # расписание получает страховку, а не «деактивируй вслепую». + min_floor_pairs: int = params.get("min_floor_pairs", DEFAULT_MIN_FLOOR_PAIRS) + floor_drop_ratio: float = params.get("floor_drop_ratio", DEFAULT_FLOOR_DROP_RATIO) + # Аварийный (не рабочий) потолок объёма снятия за один прогон -- см. + # DEFAULT_MAX_DEACTIVATED в deactivate_stale_avito.py. + max_deactivated: int = params.get("max_deactivated", DEFAULT_MAX_DEACTIVATED) loop = asyncio.get_event_loop() await loop.run_in_executor( @@ -249,6 +271,11 @@ async def _job_deactivate_stale( staleness_column=staleness_column, min_confirmations=min_confirmations, revisit_floor_quantile=revisit_floor_quantile, + null_segment_only=null_segment_only, + cap_mult=cap_mult, + min_floor_pairs=min_floor_pairs, + floor_drop_ratio=floor_drop_ratio, + max_deactivated=max_deactivated, ), ) @@ -313,7 +340,31 @@ async def _job_yandex_newbuilding_sweep( request_delay_sec=float(params.get("request_delay_sec", 8.0)), city=str(params.get("city", "ekaterinburg")), ) - ctx.runs.mark_done(db, run_id, result.to_dict()) + counters = result.to_dict() + # #2860: прогон, который обработал дома и не разрешил НИ ОДНОГО, успешным + # называть нельзя. Четырнадцать таких прогонов подряд (16.07-17.08.2026) + # стояли `done` с пустым error_text — механизм исполнялся, счётчик был + # честный, вывод из него не делал никто, и очередь тихо росла 351 → 397. + # + # НЕ подгоняем счётчик: если дома действительно не разрешаются, честный + # исход — назвать прогон неуспешным, а не дотянуть succeeded до ненуля. + processed = int(counters.get("processed") or 0) + succeeded = int(counters.get("succeeded") or 0) + if processed > 0 and succeeded == 0: + logger.warning( + "yandex_newbuilding_sweep run_id=%d: обработано %d, разрешено 0 — " + "помечаю прогон неуспешным", + run_id, + processed, + ) + ctx.runs.mark_failed( + db, + run_id, + f"обработано {processed} домов, разрешено 0 — полный отказ разрешения slug", + counters, + ) + else: + ctx.runs.mark_done(db, run_id, counters) except Exception: logger.exception("scheduler: enrich_yandex_newbuilding_sweep crashed run_id=%d", run_id) try: @@ -619,7 +670,30 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]: "geocode_missing_listings": Handler( _job_geocode_missing_listings, "geocode_missing_listings" ), - "avito_detail_backfill": Handler(_job_avito_detail_backfill, "avito_detail_backfill"), + # Каденс — в минутах, а не в сутках (замер 2026-08-22). Дефолтная + # гранулярность compute_next_run_at — сутки, и бэкфилл получал ровно один + # прогон в день. При этом прогон умирает по бану через 17-83 минуты, то + # есть 23 часа из 24 задание простаивало: 238 обогащённых карточек за + # сутки при 9 951 активном объявлении — очередь разбиралась бы месяцами. + # + # Хук сам себя throttl'ит: пока прогон идёт, has_running_run в _claim_run + # возвращает None и next_run_at не сбрасывается. + # + # 180 минут — ОСОЗНАННО консервативная отправная точка, а не найденный + # оптимум. Данных для подбора нет: два прогона дали противоречивую + # картину (4562 — 83 мин и 175 карточек; 4586 через 4.3 часа, когда пул + # прокси был давно чист, — 17 мин и 42 карточки). Значит память Авито + # длиннее часов, и учащение может ухудшить выход, а не улучшить. + # + # Риск, который надо держать в голове при подборе: те же 4 прокси + # обслуживают SERP-свипы — первичный сбор. Сжечь их на обогащении хуже, + # чем медленно обогащать. Двигать интервал вниз только по замеру + # нескольких суток, глядя и на свипы тоже. + "avito_detail_backfill": Handler( + _job_avito_detail_backfill, + "avito_detail_backfill", + post_claim=reschedule_after_minutes(param="interval_minutes", default=180), + ), "yandex_detail_backfill": Handler(_job_yandex_detail_backfill, "yandex_detail_backfill"), "domclick_detail_backfill": Handler( _job_domclick_detail_backfill, "domclick_detail_backfill" diff --git a/tradein-mvp/backend/app/services/proxy_egress.py b/tradein-mvp/backend/app/services/proxy_egress.py index c28426a4..8227a40a 100644 --- a/tradein-mvp/backend/app/services/proxy_egress.py +++ b/tradein-mvp/backend/app/services/proxy_egress.py @@ -19,10 +19,19 @@ reap_stale_leases) — она рассчитана на долгоживущие и без мутаций. ПРАВИЛО ВЫБОРА: enabled=true, consecutive_fails < proxy_pool.MAX_CONSECUTIVE_FAILS -(тот же карантинный порог, что у acquire), нет активной строки в -scrape_proxy_source_bans для ЭТОГО source. Среди кандидатов — меньший consecutive_fails, -при равенстве — более свежий last_ok_at (NULLS LAST). Не изобретаем ротацию/балансировку: -это резолвер «дай рабочий прокси прямо сейчас», не lease-менеджер. +(тот же карантинный порог, что у 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` — РАЗНЫЕ вещи, и путать их нельзя. Два разных исхода "кандидата нет": @@ -39,7 +48,9 @@ FAIL-CLOSED ПРОТИВ ТИХОГО ОБХОДА ПУЛА (#2616, deep-review env в обход учёта банов. НАБЛЮДАЕМОСТЬ: при выборе из пула логируем label/host:port (БЕЗ credentials — url -несёт логин/пароль, в лог никогда не идёт целиком) и id узла; при legit-fallback — +несёт логин/пароль, в лог никогда не идёт целиком), id узла и ban_count по этому +source (0, если истории нет) — чтобы по логу было видно, что узел с историей банов +выбран осознанно (пул исчерпан по чистым узлам), а не тихо; при legit-fallback — warning с текстом «пуст» (сценарий 1); при exhaustion — error с разбивкой banned_for_source/unhealthy_or_disabled (сценарий 2) — тексты НАМЕРЕННО разные, чтобы их нельзя было спутать в логах/алертах. @@ -98,6 +109,10 @@ 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: @@ -113,23 +128,31 @@ def _safe_label(proxy_id: int, label: str | None, url: str) -> str: def _pick_candidate(db: Session, source: str) -> _Candidate | None: - """READ-ONLY выбор egress для source. Без FOR UPDATE — резолвер не арендует узел.""" + """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 id, url, label - FROM scrape_proxies - WHERE enabled - AND consecutive_fails < CAST(:max_fails AS integer) - AND NOT 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() - ) - ORDER BY consecutive_fails ASC, last_ok_at DESC NULLS LAST, id + 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 """ ), @@ -143,7 +166,16 @@ def _pick_candidate(db: Session, source: str) -> _Candidate | None: # середине более широкой операции). if row is None: return None - return _Candidate(id=int(row["id"]), url=str(row["url"]), label=row["label"]) + 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) @@ -239,10 +271,11 @@ def resolve_proxy_url(db: Session, source: str) -> str | None: if candidate is not None: logger.info( - "proxy_egress: source=%s -> pool proxy id=%d (%s)", + "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 diff --git a/tradein-mvp/backend/app/services/scrape_runs.py b/tradein-mvp/backend/app/services/scrape_runs.py index 0ecf5ee2..10b762f1 100644 --- a/tradein-mvp/backend/app/services/scrape_runs.py +++ b/tradein-mvp/backend/app/services/scrape_runs.py @@ -143,6 +143,28 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: # 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 @@ -150,7 +172,12 @@ def _pick_int(counters: Mapping[str, Any], *keys: str) -> int | None: # трёх мониторов результата нет по смыслу. Ноль у них — часто ЗДОРОВЫЙ ответ # (deactivate_stale_* без протухших объявлений). Поэтому сторож не угадывает их # словарь, а честно признаёт, что мерить нечем — см. _run_result_count. -_RESULT_COUNTER_KEYS = ("total_seen", "lots_fetched", "unique_fetched") +_RESULT_COUNTER_KEYS = ( + "total_seen", + "lots_fetched", + "unique_fetched", + "succeeded", +) def _run_result_count(counters: Mapping[str, Any] | None) -> int | None: @@ -282,6 +309,63 @@ def _phase_totally_failed(counters: Mapping[str, Any]) -> str | None: 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. @@ -292,13 +376,37 @@ def _column_counts(counters: dict[str, int]) -> tuple[int | None, int | None]: показывала total_seen=0 при реально сохранённых строках (audit #1871/#1926). Приоритет ключей: - - total_seen ← _RESULT_COUNTER_KEYS (total_seen / lots_fetched / unique_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). """ - return _run_result_count(counters), _pick_int(counters, "new_count", "lots_inserted") + # #3044: detail-бэкфиллы (avito/yandex/domclick_detail_backfill) пишут свой + # результат ТОЛЬКО ключом 'enriched' — колонка total_seen у них оставалась 0 + # навсегда, и замер в issue прочитал «обогащено 0 за 7 дней» при реальных 801 + # (listings.detail_enriched_at). 'enriched' — фолбэк ИМЕННО ЗДЕСЬ, а не в + # _RESULT_COUNTER_KEYS: тот список кормит ещё и zero-result-стрик, где + # «догнавший очередь» бэкфилл (attempted=0, enriched=0) стал бы измеренным + # нулём и копил бы непрерываемый стрик — ровно та ловушка, за которую ревью + # выкинуло из списка голый 'rows_inserted' (см. test_rosreestr_dkp_import_ + # healthy_zero_stays_unmeasured). Витринной колонке фолбэк безопасен: она не + # участвует в стриках (#2703 читает counters, не колонку). + total_seen = _run_result_count(counters) + if total_seen is None: + total_seen = _pick_int(counters, "enriched") + return total_seen, _pick_int( + counters, "new_count", "lots_inserted", "saved_inserted", "rows_inserted" + ) def _alert_if_consecutive_failures(db: Session, source: str) -> None: @@ -558,6 +666,11 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> None: #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: @@ -569,6 +682,11 @@ def mark_done(db: Session, run_id: int, counters: dict[str, int]) -> 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( diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index a45af2d3..658cafe1 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -54,7 +54,7 @@ from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, ban_kind_of_excep # {"http": url, "https": url} if url else None (http_proxies) в # providers/_base.py (#2358 Foundation) — реюзаем вместо копии, разрывая # зависимость от scrape_pipeline.py перед его удалением. -from scraper_kit.providers._base import DOCUMENT_HEADERS, http_proxies +from scraper_kit.providers._base import DEFAULT_IMPERSONATE, DOCUMENT_HEADERS, http_proxies from scraper_kit.providers.avito.detail import ( _AVITO_WARM_SEARCH_URL, build_warmed_session, @@ -109,9 +109,16 @@ __all__ = [ # матчит неэкранированный '%/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 ) @@ -278,7 +285,7 @@ async def run_avito_detail_backfill( # уже даёт явную деградацию run'а с понятным логом, отдельный catch не нужен. own_session = True session = AsyncSession( - impersonate="chrome120", + impersonate=DEFAULT_IMPERSONATE, timeout=25, headers=DOCUMENT_HEADERS, proxies=http_proxies(resolve_proxy_url(db, "avito")), diff --git a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py index cdab053b..38834eb8 100644 --- a/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py +++ b/tradein-mvp/backend/app/tasks/deactivate_stale_avito.py @@ -6,8 +6,28 @@ Ключевые решения: - 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' (площадка @@ -127,6 +147,11 @@ 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) ─────────────────────────── # Гейт выше отвечает на вопрос «источник вообще собирается?». Он НЕ отвечает на @@ -188,9 +213,164 @@ _CONFIRMATIONS_SEGMENT_FILTER = "\n AND listing_segment = ANY(CAST(:seg 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" -def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> Any: +# ── Потолок эффективного 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 + + +# ── Гейт деградации пола (PR-B, #2659 продолжение) ───────────────────────────── +# У джобы деактивации до сих пор не было НИ ОДНОГО ограничителя ОБЪЁМА снятия: +# min_confirmations может пропустить прогон целиком, пол поднимает TTL, cap_mult +# ограничивает сам пол -- но ни один из них не смотрит на РАЗМЕР ВЫБОРКИ, из +# которой percentile_disc посчитал квантиль (floor_n_pairs, PR-A). Это другая +# величина, чем min_confirmations: confirmations считает ВСЕ строки, увиденные +# свежими за окно, floor_n_pairs -- только те из них, чья свежесть СДВИНУЛАСЬ +# относительно предыдущего снимка (см. _revisit_floor_from_where_sql). Источник +# может быть формально здоров (confirmations высокий) при вырожденном floor_n_pairs +# -- ровно то, что уже наблюдалось при переходе на change-only модель снимков +# (PR-A: equality-join терял 95.6% пар при здоровом источнике). +# +# ПОЧЕМУ ЭТОТ ГЕЙТ САМОКАЛИБРУЮЩИЙСЯ, А НЕ РУЧНОЙ ПОРОГ ПО ИСТОЧНИКУ. В отличие +# от min_confirmations (числа калибровались по восстановленному ряду за конкретные +# месяцы, миграция 219) у floor_n_pairs как счётчика истории почти нет -- он +# появился только в PR-A. Поэтому главный сигнал -- ОТНОСИТЕЛЬНЫЙ: падение +# floor_n_pairs относительно ПРЕДЫДУЩЕГО УСПЕШНОГО прогона ТОГО ЖЕ расписания +# (сравнение "прогон сам с собой", свежей калибровки по чужим числам не требует). +# Предыдущее значение читается из scrape_runs.counters по scrape_runs.source +# ТЕКУЩЕГО прогона (см. _PREVIOUS_FLOOR_N_PAIRS_SQL ниже) -- это имя РАСПИСАНИЯ +# (deactivate_stale_avito / _yandex / _cian / _yandex_null_segment / …, миграции +# 090/115/266), НЕ listing_source из параметров функции: у cian и его же +# null-сегмент джобы listing_source один и тот же ('cian'), но это две независимые +# серии прогонов с несопоставимым масштабом выборки (vtorichka -- сотни-тысячи +# пар, null-сегмент -- единицы, см. модульный докстринг про 266), сравнивать их +# друг с другом было бы категориальной ошибкой. +# +# АБСОЛЮТНЫЙ min_floor_pairs -- страховка на случай, когда истории предыдущего +# прогона ещё нет (первый прогон после деплоя гейта) либо она сама уже была +# вырожденной (иначе относительный порог сравнивал бы вырожденное с вырожденным +# и молчал бы вечно). DEFAULT_MIN_FLOOR_PAIRS = 30 -- ниже этого n percentile_disc +# при квантиле 0.99 неотличим от максимума выборки (одно случайное значение решает +# результат), это статистический минимум устойчивости хвостового квантиля, а НЕ +# число, снятое с прод-ряда floor_n_pairs -- собственной истории по нему пока нет. +# 0 -> абсолютная проверка отключена (тот же идиом, что у min_confirmations и +# revisit_floor_quantile: 0 = гейт снят); относительный дрейф-гейт при этом +# продолжает работать самостоятельно. +DEFAULT_MIN_FLOOR_PAIRS = 30 + +# DEFAULT_FLOOR_DROP_RATIO = 5 -- floor_n_pairs упал МИНИМУМ впятеро относительно +# предыдущего успешного прогона того же расписания. Собственной калибровки по +# floor_n_pairs пока нет (счётчик из PR-A) -- порядок величины заимствован у +# соседнего, уже проверенного на реальном инциденте гейта: здоровый разброс avito +# по суткам confirmations 2542..6079 (_AVITO_HEALTHY_DAYS, +# tests/test_deactivate_stale_health_gate.py) -- это ~2.4x, то есть штатный +# день-в-день шум заведомо НЕ достигает 5x. Ratio=5 оставляет двукратный запас +# над этим шумом и всё ещё ловит провал порядка 10.07-26.07 (падение в 5-10+ раз). +# floor_n_pairs -- другая метрика (подмножество confirmations, только реально +# переобойдённые строки), но природа шума та же (день-в-день колебание объёма +# обхода), поэтому заимствование порядка величины -- разумная отправная точка, +# а не число с потолка; пересмотреть, когда floor_n_pairs накопит собственную +# историю на проде. +DEFAULT_FLOOR_DROP_RATIO = 5.0 + + +def _revisit_floor_from_where_sql(staleness_column: str, segment_filter: str) -> str: + """FROM..WHERE, общий для _build_revisit_floor_sql и _build_revisit_floor_pairs_count_sql. + + Вынесено в отдельную функцию, а не продублировано, ЦЕЛЕНАПРАВЛЕННО: пункт 3 PR-A + (#2659 продолжение) требует, чтобы n_pairs в counters считался «по тому же срезу, + что даёт выборку для percentile_disc» -- дословно, а не приблизительно. Общий текст + гарантирует это по построению; раздельные копии SELECT count(*) и SELECT + percentile_disc(...) рано или поздно разошлись бы независимой правкой одной из + двух. + + JOIN LATERAL — предшественник ищется ПО СТРОКЕ (per listing_source_id), не по + глобальной дате: `ORDER BY s.snapshot_date DESC LIMIT 1` берёт последний снимок + ЭТОГО listing_source_id не позже якоря (CURRENT_DATE - health_window_days), + независимо от того, писалась ли строка именно на дату якоря. Подробно — почему + именно так и что было раньше — см. докстринг _build_revisit_floor_sql. + """ + return f""" + FROM listings l + JOIN listing_sources ls + ON ls.listing_id = l.id + AND ls.ext_source = l.source + JOIN LATERAL ( + SELECT s.last_seen_at + FROM listing_source_snapshots s + WHERE s.listing_source_id = ls.id + AND s.snapshot_date + <= CURRENT_DATE - CAST(:health_window_days AS integer) + ORDER BY s.snapshot_date DESC + LIMIT 1 + ) prev ON true + 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_revisit_floor_sql( + staleness_column: str, *, with_segments: bool, null_segment_only: bool = False +) -> Any: """Квантиль возраста, при котором свип за окно ДОКАЗАЛ, что строка жива. Пара «предыдущее наблюдение (снимок) → текущее наблюдение (listings)» даёт @@ -198,10 +378,46 @@ def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> A Только строки, у которых свежесть реально сдвинулась, — то есть выжившие, а не «мы к ним не приходили». + «ПРЕДЫДУЩЕЕ НАБЛЮДЕНИЕ» -- ЧТО ЭТО ТОЧНО (PR-A, #2659 продолжение). Это ПОСЛЕДНЯЯ + строка listing_source_snapshots для данного listing_source_id, чей snapshot_date + не позже якоря (CURRENT_DATE - health_window_days) -- `ORDER BY snapshot_date DESC + LIMIT 1` в LATERAL-подзапросе _revisit_floor_from_where_sql. РАВЕНСТВО ПО ДАТЕ + (`prev.snapshot_date = <якорь>`) ЗДЕСЬ ЗАПРЕЩЕНО, и вот почему: + + 1) listing_source_snapshots переходит на модель «строка на изменение» (пишется + только когда значение отличается от предыдущего снимка, не ежедневно). В этой + модели equality-join по дате теряет подавляющее большинство пар: на 2026-08-20 + «изменениями» являются 4 458 строк из 101 795 -- 95.6% пар для percentile_disc + пропадают, выборка схлопывается до n=3-4, а percentile_disc(0.99) на такой + выборке вырождается в максимум из трёх чисел, а не в реальный квантиль хвоста. + 2) Дыры в ЕЖЕДНЕВНОЙ истории уже ломали equality-join и ДО перехода на change-only + модель -- на проде отмечены разрывы 03-14.06 (12 суток подряд), 03-04.07, + 12.07, 26.07, 30-31.07, 01.08. В эти дни equality-join давал n_pairs=0, + floor_days=NULL, и пол молча не считался -- TTL оставался как задан, хотя + история для «ближайшего более раннего» снимка (см. комментарий про потолок + выше в модульном докстринге) в базе была, просто не РОВНО на эту дату. + + Семантика при этом не меняется: между двумя изменениями last_seen_at по + определению постоянен (иначе строка была бы новым изменением), поэтому + «последний снимок не позже якоря» и «снимок ровно на дату якоря» при СПЛОШНОЙ + ежедневной истории дают одно и то же число -- разница проявляется только там, + где equality-join был неверен и раньше (гэпы) либо станет неверен при переходе + на change-only (почти повсеместно). + + 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). """ - segment_filter = _REVISIT_FLOOR_SEGMENT_FILTER if with_segments else "" + 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)) @@ -209,34 +425,58 @@ def _build_revisit_floor_sql(staleness_column: str, *, with_segments: bool) -> A 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} + {_revisit_floor_from_where_sql(staleness_column, segment_filter)} """ ) -def _build_confirmations_sql(staleness_column: str, *, with_segments: bool) -> Any: +def _build_revisit_floor_pairs_count_sql( + staleness_column: str, *, with_segments: bool, null_segment_only: bool = False +) -> Any: + """count(*) пар, из которых percentile_disc в _build_revisit_floor_sql берёт квантиль. + + Наблюдательность (PR-A, #2659 продолжение) -- НИЧЕГО не блокирует сейчас (гейт по + деградации выборки, если он когда-нибудь понадобится, -- отдельная задача, PR-B). + Пишется в counters["floor_n_pairs"] ДО того, как схлопнувшаяся выборка станет + видна только по повторению прод-инцидента, ради которого весь пол заведён (см. + ЗАМЕР НА ПРОДЕ 2026-08-09 в модульном докстринге). + + Тот же срез, что и percentile_disc -- ОБЩАЯ функция _revisit_floor_from_where_sql, + не копия WHERE, см. её докстринг про то, почему это важно. + """ + 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 count(*) + {_revisit_floor_from_where_sql(staleness_column, 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). """ - segment_filter = _CONFIRMATIONS_SEGMENT_FILTER if with_segments else "" + 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(*) @@ -292,6 +532,33 @@ def _build_segments_sql(staleness_column: str) -> Any: ) +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} + """ + ) + + # Дефолтные (last_seen_at) варианты SQL -- сохранены как модульные константы для # обратной совместимости (тесты читают .text, product_handlers/scheduler не менялись). _DEACTIVATE_SQL_ALL_SEGMENTS = _build_all_segments_sql("last_seen_at") @@ -301,6 +568,122 @@ _DEACTIVATE_SQL_SEGMENTS = _build_segments_sql("last_seen_at") _DEACTIVATE_SQL = _DEACTIVATE_SQL_ALL_SEGMENTS +# ── Гейт деградации пола: чтение предыдущего успешного прогона (PR-B) ────────── +# source сравнивается по scrape_runs.source ТЕКУЩЕГО прогона (подзапрос по +# :run_id), а НЕ по listing_source -- см. комментарий у DEFAULT_MIN_FLOOR_PAIRS +# про то, почему это разные вещи. status='done' + id != :run_id исключают сам +# текущий прогон (на момент этого запроса он ещё 'running', так что status='done' +# уже достаточно, id != добавлен как явная защита от совпадения). counters ->> +# 'floor_n_pairs' IS NOT NULL заменяет `?`-оператор существования ключа -- +# семантически то же самое (NULL, если ключа нет), без сомнений по поводу +# взаимодействия `?` с bind-параметрами psycopg v3 в этом же тексте. +_PREVIOUS_FLOOR_N_PAIRS_SQL = text( + """ + SELECT CAST(prev.counters ->> 'floor_n_pairs' AS integer) AS floor_n_pairs + FROM scrape_runs prev + WHERE prev.source = (SELECT source FROM scrape_runs WHERE id = :run_id) + AND prev.status = 'done' + AND prev.id != :run_id + AND prev.counters ->> 'floor_n_pairs' IS NOT NULL + ORDER BY prev.id DESC + LIMIT 1 + """ +) + + +# ── Потолок объёма снятия -- аварийный, НЕ рабочий (PR-B, #2659 продолжение) ─── +# У джобы деактивации никогда не было ограничителя ОБЪЁМА снятия: UPDATE идёт +# одним statement'ом без LIMIT, counters["deactivated"] = result.rowcount -- при +# обвале любого из гейтов выше снимается сколько снимется. +# +# ПОЧЕМУ НЕТ ПОРОГА ПО ДОЛЕ ПУЛА. Естественный кандидат -- "не больше N% активного +# пула источника за один прогон" -- но пул, с которым эта джоба реально работает, +# НИКОГДА не записывался: восстановить его постфактум можно только по +# listing_sources, а deactivated считает строки listings -- разная гранулярность. +# Проверено на историческом ряду снятий: доля деактивированного от восстановленного +# пула -- 40%, 97%, 317%, 1652% -- числа не образуют осмысленного ряда, калибровать +# порог не на чем. Поэтому здесь метрика ПОКА ТОЛЬКО ПИШЕТСЯ -- deactivation_candidates +# (preflight count(*) ПО ТОМУ ЖЕ предикату, что исполнит UPDATE), active_pool +# (все активные строки этого source, без фильтра по сегменту -- см. докстринг +# deactivate_stale_listings, Returns), deactivated_pct. Долевой порог будет +# выставлен ПОЗЖЕ, когда deactivated_pct накопит собственную историю на живых +# прогонах именно этой джобы (а не на реконструкции задним числом). +# +# АВАРИЙНЫЙ ПОРОГ ЕСТЬ -- max_deactivated, абсолютное число, блокирует ДО UPDATE. +# Исторический максимум ЛЕГИТИМНОГО снятия (owner-подтверждено): avito 06.06.2026 +# -- 9 300 строк, затем 6 531 / 6 131 / 4 959 / 3 909 (последнее owner явно +# подтвердил как здоровую чистку). DEFAULT_MAX_DEACTIVATED = 15 000 заведомо НЕ +# отбивает ни один из этих легитимных прогонов (запас 1.6x над историческим +# максимумом), но ловит катастрофу на порядок крупнее любого известного здорового +# снятия -- это НЕ откалиброванный рабочий порог (как min_confirmations или +# floor_drop_ratio выше), а предохранитель последней инстанции. +DEFAULT_MAX_DEACTIVATED = 15000 + +_ACTIVE_POOL_SQL = text( + """ + SELECT count(*) + FROM listings + WHERE source = :listing_source + AND is_active = true + """ +) + + +def _build_all_segments_candidates_count_sql(staleness_column: str) -> Any: + """count(*) кандидатов на деактивацию -- ТОТ ЖЕ предикат, что WHERE в + _build_all_segments_sql (см. её докстринг). Preflight ДО UPDATE (PR-B): даёт + counters["deactivation_candidates"] и питает аварийный потолок max_deactivated + -- при abort ни один UPDATE ещё не исполнялся. + + Предикат продублирован текстуально, а не вынесен в общую функцию с + _build_all_segments_sql: рефакторинг уже протестированных UPDATE-builder'ов + вне скоупа PR-B. Синхронность с UPDATE закреплена тестом + test_candidates_predicate_matches_update_predicate + (tests/test_deactivate_stale_deactivation_cap.py). + """ + return text( + f""" + SELECT count(*) + FROM listings + WHERE source = :listing_source + AND is_active = true + AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) + """ + ) + + +def _build_segments_candidates_count_sql(staleness_column: str) -> Any: + """count(*) кандидатов -- ТОТ ЖЕ предикат, что WHERE в _build_segments_sql. + См. докстринг _build_all_segments_candidates_count_sql выше. + """ + return text( + f""" + SELECT count(*) + FROM listings + 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[])) + """ + ) + + +def _build_null_segment_candidates_count_sql(staleness_column: str) -> Any: + """count(*) кандидатов -- ТОТ ЖЕ предикат, что WHERE в _build_null_segment_sql. + См. докстринг _build_all_segments_candidates_count_sql выше. + """ + return text( + f""" + SELECT count(*) + FROM listings + WHERE source = :listing_source + AND is_active = true + AND {staleness_column} < NOW() - CAST(:ttl_days || ' days' AS interval) + AND listing_segment IS NULL + """ + ) + + def deactivate_stale_listings( db: Session, run_id: int, @@ -312,6 +695,11 @@ def deactivate_stale_listings( 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, + min_floor_pairs: int = DEFAULT_MIN_FLOOR_PAIRS, + floor_drop_ratio: float = DEFAULT_FLOOR_DROP_RATIO, + max_deactivated: int = DEFAULT_MAX_DEACTIVATED, ) -> dict[str, int]: """Пометить is_active=false объявления, чья свежесть старше ttl_days дней. @@ -321,6 +709,7 @@ 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 @@ -334,25 +723,144 @@ def deactivate_stale_listings( health_window_days: окно подтверждений для гейта, суток. Дефолт 3. revisit_floor_quantile: пол TTL по измеренному циклу переобхода (#2659). Квантиль возраста, при котором свип за окно ДОКАЗАЛ строку живой; - эффективный TTL = max(ttl_days, этот пол). 0 -> пол выключен (так + эффективный 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-джобе, но там гейт/пол выключены (см. выше), так что + на практике не участвует. + min_floor_pairs: абсолютный порог гейта деградации пола (PR-B, #2659 + продолжение). Ниже этого числа floor_n_pairs -- прогон пропускается + (skipped_floor_degraded), НЕЗАВИСИМО от того, есть ли предыдущий + прогон для сравнения. Дефолт DEFAULT_MIN_FLOOR_PAIRS=30, 0 -> отключает + только эту (абсолютную) часть гейта -- см. комментарий у константы. + Участвует ТОЛЬКО когда revisit_floor_quantile > 0 (гейт живёт внутри + того же блока, что и сам пол -- вырожденную выборку нечем измерить, + если пол вообще не считается). + floor_drop_ratio: относительный порог того же гейта. floor_n_pairs упал + СТРОГО более чем в floor_drop_ratio раз относительно предыдущего + УСПЕШНОГО прогона того же расписания (scrape_runs.source, см. + _PREVIOUS_FLOOR_N_PAIRS_SQL) -- прогон пропускается. Нет предыдущего + прогона (первый после деплоя / история ещё не накопилась) -> эта + часть гейта молчит, работает только min_floor_pairs. Дефолт + DEFAULT_FLOOR_DROP_RATIO=5.0. + max_deactivated: аварийный (НЕ рабочий, см. комментарий у константы) + потолок объёма снятия за один прогон. Preflight count(*) кандидатов + ПО ТОМУ ЖЕ предикату, что и сам UPDATE -- если кандидатов больше + max_deactivated, прогон abort'ится ДО UPDATE. Дефолт + DEFAULT_MAX_DEACTIVATED=15000. Участвует ТОЛЬКО когда + revisit_floor_quantile > 0 -- на проде это верно для всех активных + расписаний деактивации, кроме null_segment_only-джоб (миграция 266), + чей пул на 2-3 порядка меньше порога и потолок для них физически не + может сработать (см. комментарий у DEFAULT_MAX_DEACTIVATED). Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources). Один statement в транзакции: UPDATE флага + снимок 'stale' в listings_snapshots (data-modifying CTE, #2674). Финализирует scrape_runs (mark_done / mark_failed). Returns {"deactivated": N} -- количество обновлённых строк (1:1 со снимками). - Если гейт не пропустил прогон: {"deactivated": 0, "confirmations": N, - "skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Если пол переобхода поднял - TTL: дополнительно {"revisit_floor_days": N, "ttl_days_effective": N}. + Если гейт здоровья не пропустил прогон: {"deactivated": 0, "confirmations": N, + "skipped_unhealthy": 1} и НИ ОДНА строка не тронута. Если пол переобхода включён + (revisit_floor_quantile > 0): дополнительно {"floor_n_pairs": N} -- размер + выборки, из которой percentile_disc посчитал квантиль (PR-A), и (если найден + предыдущий успешный прогон того же расписания) {"floor_n_pairs_previous": N}. + Если гейт деградации пола не пропустил прогон (PR-B): {"skipped_floor_degraded": + 1} и НИ ОДНА строка не тронута -- ни UPDATE, ни preflight-потолок ниже не + исполнялись. Если пол при этом реально поднял TTL: дополнительно + {"revisit_floor_days": N, "ttl_days_effective": N}. Если пол упёрся в потолок + cap_mult: дополнительно {"ttl_floor_capped": 1, "ttl_days_floor_raw": N} -- + N это то, во что пол поднял бы TTL БЕЗ потолка. Если revisit_floor_quantile > 0 + и прогон не был abort'нут ни одним из гейтов выше -- PR-B добавляет + наблюдательные {"deactivation_candidates": N, "active_pool": N, + "deactivated_pct": N} (preflight ДО UPDATE, тот же предикат, что и сам UPDATE). + Если кандидатов больше max_deactivated: {"skipped_cap_exceeded": 1} и НИ ОДНА + строка не тронута. 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) не композируются), ЛИБО (PR-B) + min_floor_pairs < 0 / floor_drop_ratio < 1 / max_deactivated <= 0, ИЛИ + любой из этих трёх -- bool (тот же класс jsonb-опечатки true/false + вместо числа, что и у ttl_days/cap_mult выше -- default_params + расписания это единственный запланированный способ их переопределить). """ 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}") + + # PR-B (#2659 продолжение): те же jsonb-bool-опечатки, тот же класс дыры, + # что у ttl_days/cap_mult выше -- min_floor_pairs/floor_drop_ratio/ + # max_deactivated тоже приходят из default_params расписания. + if isinstance(min_floor_pairs, bool): + raise ValueError(f"min_floor_pairs must be a number, not bool: {min_floor_pairs!r}") + if min_floor_pairs < 0: + raise ValueError(f"min_floor_pairs must be >= 0, got {min_floor_pairs!r}") + + if isinstance(floor_drop_ratio, bool): + raise ValueError( + f"floor_drop_ratio must be a number, not bool: {floor_drop_ratio!r}" + ) + if floor_drop_ratio < 1: + raise ValueError(f"floor_drop_ratio must be >= 1, got {floor_drop_ratio!r}") + + if isinstance(max_deactivated, bool): + raise ValueError(f"max_deactivated must be a number, not bool: {max_deactivated!r}") + if max_deactivated <= 0: + raise ValueError(f"max_deactivated must be positive, got {max_deactivated!r}") + # Whitelist-проверка ДО построения/выполнения SQL: только после неё имя колонки # интерполируется f-string'ом. Значения по-прежнему идут через param-binding. # Внутри try -> невалидная колонка финализирует run как failed (mark_failed), @@ -362,6 +870,11 @@ 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") # Гейт по здоровью сбора (#2659) — ДО любого UPDATE. Деактивация необратима # на практике (вернуть «живость» может только повторный сбор), поэтому @@ -375,7 +888,11 @@ def deactivate_stale_listings( health_params["segments"] = segments confirmations = ( db.execute( - _build_confirmations_sql(staleness_column, with_segments=segments is not None), + _build_confirmations_sql( + staleness_column, + with_segments=segments is not None, + null_segment_only=null_segment_only, + ), health_params, ).scalar() or 0 @@ -390,19 +907,23 @@ def deactivate_stale_listings( logger.warning( "deactivate_stale source=%s run_id=%d SKIPPED: сбор нездоров — " "подтверждений за %d сут %d < порога %d " - "(segments=%r, staleness_column=%s); ни одна строка не тронута", + "(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(), а не замена. + # срезу. Поднимает порог (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] = { @@ -413,16 +934,104 @@ def deactivate_stale_listings( 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), + _build_revisit_floor_sql( + staleness_column, + with_segments=segments is not None, + null_segment_only=null_segment_only, + ), floor_params, ).scalar() + # Размер выборки, из которой percentile_disc выше берёт квантиль (та же + # общая _revisit_floor_from_where_sql). Появилось в PR-A как чистое + # наблюдение; с PR-B (#2659 продолжение) гейтит деградацию -- см. блок + # ниже. n_pairs обязан попасть в counters ДО того, как схлопнувшуюся + # выборку станет видно только по повторению прод-инцидента. Считается + # ВСЕГДА при включённом полу, в том числе когда floor_days получится + # NULL -- тогда n_pairs=0 и объясняет, почему пол не посчитался. + floor_n_pairs = db.execute( + _build_revisit_floor_pairs_count_sql( + staleness_column, + with_segments=segments is not None, + null_segment_only=null_segment_only, + ), + floor_params, + ).scalar() + counters["floor_n_pairs"] = int(floor_n_pairs or 0) + + # ── Гейт деградации пола (PR-B, #2659 продолжение) ──────────────── + # ДО того, как floor_days (если есть) поднимет TTL -- вырожденная + # выборка не должна ни давать "уверенный" пол, ни (тем более) + # пропускать UPDATE вовсе. См. комментарий у DEFAULT_MIN_FLOOR_PAIRS / + # DEFAULT_FLOOR_DROP_RATIO про то, почему сравнение идёт "прогон с + # собой", а не с заново калиброванным порогом. + prev_floor_n_pairs = db.execute( + _PREVIOUS_FLOOR_N_PAIRS_SQL, {"run_id": run_id} + ).scalar() + below_min_floor_pairs = counters["floor_n_pairs"] < min_floor_pairs + dropped_vs_previous = False + if prev_floor_n_pairs is not None: + counters["floor_n_pairs_previous"] = int(prev_floor_n_pairs) + dropped_vs_previous = ( + prev_floor_n_pairs > 0 + and counters["floor_n_pairs"] * floor_drop_ratio < prev_floor_n_pairs + ) + if below_min_floor_pairs or dropped_vs_previous: + counters["skipped_floor_degraded"] = 1 + # Ничего не писали (только SELECT'ы) -- rollback закрывает + # транзакцию чисто, чтобы mark_done стартовал со своей (тот же + # приём, что у skipped_unhealthy выше). + db.rollback() + runs_mod.mark_done(db, run_id, counters) + logger.warning( + "deactivate_stale source=%s run_id=%d SKIPPED: пол деградировал -- " + "floor_n_pairs=%d (порог %d), предыдущий успешный прогон=%s " + "(порог падения ×%.1f) -- ни одна строка не тронута", + listing_source, + run_id, + counters["floor_n_pairs"], + min_floor_pairs, + prev_floor_n_pairs, + floor_drop_ratio, + ) + return counters + # NULL = истории снимков за окно нет вовсе (свежая БД, дыра в снимках). # Тогда пола нет и TTL остаётся как задан: выдумывать пол не из чего. if floor_days is not None: counters["revisit_floor_days"] = ceil(float(floor_days)) - effective_ttl_days = max(ttl_days, counters["revisit_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 effective_ttl_days > 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 сут " @@ -439,12 +1048,71 @@ def deactivate_stale_listings( ttl_days, ) - # segments is None -> все сегменты (поведение avito). segments=[...] -> только - # перечисленные сегменты. Используем `is not None` (НЕ truthy): пустой список [] - # означает "ни один сегмент" (= ANY(ARRAY[]) ничего не матчит, деактивирует 0), - # а НЕ "все сегменты" — иначе случайный [] стёр бы весь источник. - if segments is not None: + # ── Потолок объёма снятия -- аварийный, наблюдательный (PR-B) ────── + # Preflight count(*) ПО ТОМУ ЖЕ предикату, что исполнит UPDATE ниже + # (тот же эффективный TTL, тот же срез сегментов) -- см. комментарий у + # DEFAULT_MAX_DEACTIVATED про то, почему нет порога по ДОЛЕ пула + # (метрика только пишется, не гейтит) и откуда взят абсолютный + # аварийный порог. Живёт внутри revisit_floor_quantile > 0 -- на проде + # это верно для всех активных расписаний деактивации, кроме + # null_segment_only-джоб (миграция 266, пул на 2-3 порядка меньше + # порога -- см. докстринг deactivate_stale_listings). + preflight_params: dict[str, Any] = { + "listing_source": listing_source, + "ttl_days": effective_ttl_days, + } + if null_segment_only: + candidates_sql = _build_null_segment_candidates_count_sql(staleness_column) + elif segments is not None: + candidates_sql = _build_segments_candidates_count_sql(staleness_column) + preflight_params["segments"] = segments + else: + candidates_sql = _build_all_segments_candidates_count_sql(staleness_column) + + candidates_result = db.execute(candidates_sql, preflight_params).scalar() + deactivation_candidates = int(candidates_result or 0) + active_pool_result = db.execute( + _ACTIVE_POOL_SQL, {"listing_source": listing_source} + ).scalar() + active_pool = int(active_pool_result or 0) + counters["deactivation_candidates"] = deactivation_candidates + counters["active_pool"] = active_pool + counters["deactivated_pct"] = ( + round(deactivation_candidates / active_pool * 100) if active_pool else 0 + ) + + if deactivation_candidates > max_deactivated: + counters["skipped_cap_exceeded"] = 1 + db.rollback() + runs_mod.mark_done(db, run_id, counters) + logger.error( + "deactivate_stale source=%s run_id=%d SKIPPED: кандидатов на снятие " + "%d превышает аварийный потолок %d (active_pool=%d, %d%%) -- " + "ни одна строка не тронута", + listing_source, + run_id, + deactivation_candidates, + max_deactivated, + active_pool, + counters["deactivated_pct"], + ) + return counters + + # 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": 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, @@ -465,13 +1133,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 эффективный, задан %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/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index 00d91cf9..affa088b 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -125,8 +125,7 @@ def _alert_domclick_cookies(db: Session, run_id: int) -> None: detail = "кук DomClick нет в БД" elif expires_at <= now: detail = ( - f"куки DomClick протухли {expires_at:%Y-%m-%d} " - f"({(now - expires_at).days} дн. назад)" + f"куки DomClick протухли {expires_at:%Y-%m-%d} ({(now - expires_at).days} дн. назад)" ) else: detail = "куки DomClick помечены невалидными (last_invalid_at)" @@ -292,7 +291,7 @@ async def run_domclick_detail_backfill( # #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper). if shutdown_requested(): logger.info( - "domclick_detail_backfill: run_id=%d SIGTERM-drain — stopping at " "#%d/%d", + "domclick_detail_backfill: run_id=%d SIGTERM-drain — stopping at #%d/%d", run_id, idx, len(snapshot), @@ -331,8 +330,7 @@ async def run_domclick_detail_backfill( consecutive_blocks += 1 counters.blocked += 1 logger.warning( - "domclick_detail_backfill: run_id=%d BLOCKED #%d/%d " - "(consecutive=%d): %s", + "domclick_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s", run_id, idx + 1, len(snapshot), diff --git a/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py index ba1c8d73..f54537c8 100644 --- a/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py +++ b/tradein-mvp/backend/app/tasks/yandex_newbuilding_sweep.py @@ -129,10 +129,30 @@ _SELECT_PENDING_HOUSES = """ SELECT 1 FROM market.yandex_jk_enrichment e WHERE e.ext_id = hs.ext_id ) ) - ORDER BY h.id, hs.ext_id NULLS LAST + -- #2924: дом, у которого резолв уже не удался, не берётся повторно раньше чем + -- через :retry_days (образец — geocode_tried_at, задача geocode_missing). + AND ( + CAST(:force AS boolean) = TRUE + OR h.yandex_jk_resolve_tried_at IS NULL + OR h.yandex_jk_resolve_tried_at + < NOW() - make_interval(days => CAST(:retry_days AS integer)) + ) + -- #2924: неудавшиеся — в КОНЕЦ очереди, а не в начало: иначе пять первых по h.id + -- занимали все пять слотов каждую неделю. + ORDER BY h.id, h.yandex_jk_resolve_tried_at NULLS FIRST, hs.ext_id NULLS LAST LIMIT :lim """ +# #2924: сколько дней не повторять резолв дома, у которого он не удался. Семь — как у +# geocode_tried_at: разметка Яндекса могла поменяться, дом мог появиться в SERP позже. +RESOLVE_RETRY_DAYS = 7 + +_MARK_RESOLVE_TRIED = """ + UPDATE houses + SET yandex_jk_resolve_tried_at = NOW() + WHERE id = CAST(:hid AS bigint) +""" + _UPDATE_SLUG = """ UPDATE houses SET yandex_jk_slug = CAST(:slug AS text) @@ -251,7 +271,14 @@ async def enrich_yandex_newbuilding_sweep( result.fetchable = sizing["fetchable"] result.pending = sizing["pending"] - rows = db.execute(text(_SELECT_PENDING_HOUSES), {"force": force, "lim": limit}).mappings().all() + rows = ( + db.execute( + text(_SELECT_PENDING_HOUSES), + {"force": force, "lim": limit, "retry_days": RESOLVE_RETRY_DAYS}, + ) + .mappings() + .all() + ) logger.info( "yandex-nb-sweep: total=%d fetchable=%d pending=%d; " @@ -321,6 +348,7 @@ async def enrich_yandex_newbuilding_sweep( if not resolved: logger.warning("slug unresolved house_id=%s ext_id=%s — skip", house_id, ext_id) result.failed_resolve += 1 + _mark_resolve_tried(db, house_id) continue # Persist slug под SAVEPOINT @@ -335,6 +363,7 @@ async def enrich_yandex_newbuilding_sweep( "persist slug failed house_id=%s ext_id=%s: %s", house_id, ext_id, exc ) result.failed_resolve += 1 + _mark_resolve_tried(db, house_id) continue jk_slug = resolved @@ -448,6 +477,22 @@ async def enrich_yandex_newbuilding_sweep( return result +def _mark_resolve_tried(db: Session, house_id: int) -> None: + """#2924: зафиксировать неудачную попытку резолва — иначе дом снова первый в очереди. + + Отдельный SAVEPOINT: отметка не должна ронять прогон, а её потеря — не фатальна + (дом просто вернётся в очередь на следующем прогоне, как и раньше). + """ + sp = db.begin_nested() + try: + db.execute(text(_MARK_RESOLVE_TRIED), {"hid": house_id}) + sp.commit() + db.commit() + except Exception as exc: + sp.rollback() + logger.warning("mark resolve tried failed house_id=%s: %s", house_id, exc) + + async def _sleep_with_jitter(delay: float, idx: int, total: int, *, force: bool = False) -> None: """Polite anti-bot sleep с ±20% jitter. 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/077_dedup_hash_plain_key_backfill.sql b/tradein-mvp/backend/data/sql/077_dedup_hash_plain_key_backfill.sql index 16f839cf..c655754b 100644 --- a/tradein-mvp/backend/data/sql/077_dedup_hash_plain_key_backfill.sql +++ b/tradein-mvp/backend/data/sql/077_dedup_hash_plain_key_backfill.sql @@ -13,10 +13,26 @@ -- не могут совпасть с md5-hex (разный формат), поэтому транзиентного UNIQUE-violation -- по deals_dedup_hash_key во время UPDATE не возникает. -- --- FDW dependency: читает foreign table gendesign_rosreestr_deals (SERVER gendesign_remote, --- создан в 060_postgres_fdw_extension.sql; foreign table — в 072_..._cian_rosreestr.sql; --- USER MAPPING — backend startup core/fdw.py). Деплой применяет миграции ПОСЛЕ `compose up -d`, --- т.е. FDW-сервер уже поднят и foreign table читаема. +-- ── Гард чистого старта (#2990) ─────────────────────────────────────────────── +-- Это ЕДИНСТВЕННАЯ миграция во всей цепочке, которая ЧИТАЕТ foreign table +-- (проверено 2026-08-20 по всем 14 файлам, упоминающим FDW-таблицы: остальные — +-- только CREATE/DROP FOREIGN TABLE и COMMENT, им ни USER MAPPING, ни связь не нужны). +-- +-- А USER MAPPING создаёт бэкенд при старте (app/core/fdw.py), то есть ПОСЛЕ initdb. +-- На чистом томе (новый сервер, CI, локальная разработка) FDW ещё не отображён и +-- SELECT падал бы с "user mapping not found", обрывая docker-entrypoint-initdb.d +-- и не давая контейнеру подняться вообще. +-- +-- ВАЖНО: гард по количеству pending-строк тут НЕ защищает от FDW-падения — +-- 003_seed_deals.sql на чистой БД сеет синтетические сделки с source='rosreestr' +-- и md5-хэшем в dedup_hash, то есть pending > 0 уже на пустом томе. Первичный +-- гард обязан проверять именно наличие USER MAPPING (pg_user_mappings), а не +-- количество строк. Счётчик pending оставлен вторым, уже после проверки +-- мэппинга — он просто экономит обращение к FDW, когда мигрировать нечего. +-- +-- Файл НЕ удалён намеренно: прод помнит его по bare-filename в _schema_migrations, +-- а tests/test_migration_numbering.py::test_applied_migration_is_not_renamed_or_deleted +-- падает на удалении применённой миграции. -- -- Фильтр src CTE — байт-в-байт совпадает с live-импортом (scheduler.py:437-444 / -- import-rosreestr.sh), чтобы каждая импортированная сделка нашлась и сконвертировалась. @@ -24,25 +40,64 @@ BEGIN; -WITH src AS ( - SELECT - id, - md5('ros:dkp:' || CAST(id AS text)) AS h - FROM gendesign_rosreestr_deals - WHERE region_code = 66 - AND city ILIKE '%катеринбург%' - AND realestate_type_code = '002001003000' - AND area BETWEEN 18 AND 200 - AND deal_price BETWEEN 1000000 AND 100000000 - AND street IS NOT NULL AND trim(street) <> '' - AND doc_type = 'ДКП' - AND period_start_date >= DATE '2024-01-01' -) -UPDATE deals d -SET dedup_hash = 'ros:dkp:' || CAST(src.id AS text), - source_id = CAST(src.id AS text) -FROM src -WHERE d.source = 'rosreestr' - AND d.dedup_hash = src.h; +DO $migration_077$ +DECLARE + pending bigint; +BEGIN + -- Гард 1 (первичный, обязателен): USER MAPPING создаётся бэкендом при + -- старте (app/core/fdw.py), то есть ПОСЛЕ docker-entrypoint-initdb.d. На + -- чистом томе его физически ещё не существует, а количество pending-строк + -- тут ни при чём — счётчик ниже не защищает от FDW-падения, потому что + -- сидовые данные (003_seed_deals.sql) генерируют строки с + -- source='rosreestr' и md5-хэшем, то есть pending > 0 уже на пустой БД. + -- Идиома совпадает с проверкой в app/core/fdw.py:57-62. + IF NOT EXISTS ( + SELECT 1 FROM pg_user_mappings + WHERE srvname = 'gendesign_remote' + AND (usename = current_user OR usename IS NULL) + ) THEN + RAISE NOTICE '077: USER MAPPING для gendesign_remote нет — backfill пропущен'; + RETURN; + END IF; + + -- Гард 2 (вторичный): считаем строки, оставшиеся в md5-форме. Плоский + -- ключ 'ros:dkp:N' под этот шаблон не подходит, поэтому после успешного + -- прогона pending = 0. Ранний выход тут — просто чтобы не трогать FDW + -- зря, когда мигрировать нечего (мэппинг уже есть, но backfill уже + -- применён). + SELECT count(*) INTO pending + FROM deals + WHERE source = 'rosreestr' + AND dedup_hash ~ '^[0-9a-f]{32}$'; + + IF pending = 0 THEN + RAISE NOTICE '077: строк в md5-форме нет — backfill пропущен'; + RETURN; + END IF; + + RAISE NOTICE '077: строк к конвертации: %', pending; + + WITH src AS ( + SELECT + id, + md5('ros:dkp:' || CAST(id AS text)) AS h + FROM gendesign_rosreestr_deals + WHERE region_code = 66 + AND city ILIKE '%катеринбург%' + AND realestate_type_code = '002001003000' + AND area BETWEEN 18 AND 200 + AND deal_price BETWEEN 1000000 AND 100000000 + AND street IS NOT NULL AND trim(street) <> '' + AND doc_type = 'ДКП' + AND period_start_date >= DATE '2024-01-01' + ) + UPDATE deals d + SET dedup_hash = 'ros:dkp:' || CAST(src.id AS text), + source_id = CAST(src.id AS text) + FROM src + WHERE d.source = 'rosreestr' + AND d.dedup_hash = src.h; +END +$migration_077$; 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/267_trade_in_estimates_market_percentile.sql b/tradein-mvp/backend/data/sql/267_trade_in_estimates_market_percentile.sql new file mode 100644 index 00000000..55b4a487 --- /dev/null +++ b/tradein-mvp/backend/data/sql/267_trade_in_estimates_market_percentile.sql @@ -0,0 +1,30 @@ +-- #2899: позиция объекта внутри когорты аналогов (перцентиль ранга, 1..99). +-- +-- ЗАЧЕМ КОЛОНКА, А НЕ РАСЧЁТ НА ЧТЕНИИ. Когорты в БД нет: в `analogs` лежит только +-- top-10 показанных лотов (прод: max(jsonb_array_length(analogs)) = 10 при +-- max(n_analogs) = 294, у 599 из 1086 строк список упёрся в потолок). Пересчитать +-- позицию по сохранённому top-10 нельзя — это другая популяция, поэтому значение +-- обязано персиститься вместе с оценкой. +-- +-- smallint: диапазон 1..99 по построению (`_market_percentile` зажимает края — +-- «0-й перцентиль» читался бы как «дешевле всех на свете», хотя означает лишь край +-- выборки). NULL = когорта меньше MARKET_PERCENTILE_MIN_N (15) либо оценки нет. +-- Бэкфилла нет и быть не может: позицию старых строк восстановить не из чего. +-- +-- ПРО ЛОКИ (#2752). ADD COLUMN без DEFAULT в PG 11+ не переписывает таблицу и держит +-- ACCESS EXCLUSIVE миллисекунды — но ЖДАТЬ его выдачи может сколько угодно, и всё это +-- время ждущий DDL стоит в очереди ПЕРЕД новыми запросами приложения к той же таблице. +-- Ровно так миграция 250 встала на 29 минут за чужой psql-сессией. lock_timeout +-- ограничивает только ожидание: не дождались — красный деплой вместо тихой очереди. +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS market_percentile smallint; + +COMMENT ON COLUMN trade_in_estimates.market_percentile IS + '#2899: доля аналогов дешевле этой квартиры, 1..99. NULL — когорта < 15 лотов. ' + 'НЕ location_index_pct (тот про район против медианы города).'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/268_listings_tsv_drop_generated.sql b/tradein-mvp/backend/data/sql/268_listings_tsv_drop_generated.sql new file mode 100644 index 00000000..489194f6 --- /dev/null +++ b/tradein-mvp/backend/data/sql/268_listings_tsv_drop_generated.sql @@ -0,0 +1,152 @@ +-- 268_listings_tsv_drop_generated.sql +-- Issue #2992 (эпик #2989) — остановить пересчёт listings.tsv на каждом UPDATE. +-- +-- ── ЧТО ЗА ПРОБЛЕМА ───────────────────────────────────────────────────────── +-- listings.tsv — STORED generated column: +-- GENERATED ALWAYS AS ( +-- setweight(to_tsvector('russian', COALESCE(description,'')),'A') +-- || setweight(to_tsvector('russian', COALESCE(address,'')),'B') +-- ) STORED +-- Stored-generated колонка пересчитывается на КАЖДОМ UPDATE строки listings, +-- независимо от того, менялись ли description/address. Результат — свежий +-- inline-датум (не переиспользованный старый TOAST-указатель), поэтому строка +-- ещё и перетостивается заново: ~5 ГБ оборота TOAST за 91 день на этой +-- колонке, плюс лишняя работа GIN-индекса на каждый UPDATE. +-- +-- ── РАЗВЕДКА: search_query.py читает listings ИЛИ listings_search_mv? ────── +-- app/services/search_query.py:125 — предикат `tsv @@ plainto_tsquery(...)`. +-- Прочитан файл целиком: build_search_query собирает +-- `SELECT ... FROM listings_search_mv WHERE {where_sql} ...` +-- (docstring: «Возвращает (sql, args) для SELECT из listings_search_mv»). +-- Единственный писатель предиката — этот файл, других обращений к `tsv` во +-- всём tradein-mvp нет (`grep -rn '\btsv\b' tradein-mvp --include=*.py` +-- даёт ровно эту одну строку). +-- +-- Значит `tsv` в предикате — колонка ВИТРИНЫ listings_search_mv, не +-- listings.tsv напрямую. Дальше — ключевой вопрос: чем засеяна tsv-колонка +-- самой витрины. +-- +-- Прочитаны все миграции, трогавшие listings_search_mv (050, 094, 261 — +-- единственные; `grep -rn 'l\.tsv\b' tradein-mvp/backend/data/sql/*.sql` +-- даёт 0 совпадений ВООБЩЕ). Актуальное определение — 261 (сверено байт-в-байт +-- с `pg_get_viewdef('listings_search_mv'::regclass, true)` на проде +-- 2026-08-20): +-- to_tsvector('russian', +-- coalesce(l.description, '') || ' ' || +-- coalesce(l.address, '') || ' ' || +-- coalesce(h.developer_name, '') +-- ) AS tsv +-- Витрина считает to_tsvector САМА, из сырых listings.description / +-- listings.address / houses.developer_name, в момент CREATE/REFRESH. Она +-- НИКОГДА не читала listings.tsv (ни alias `l.tsv AS tsv`, ни `SELECT *`) — +-- отдельное определение появилось уже в 050 и не менялось по составу +-- источника с тех пор (094 поменяла только l.kadastr_num → l.cadastral_number +-- в других колонках витрины, 261 убрала три колонки-заглушки; выражение tsv +-- дословно то же). +-- +-- ВЫВОД: listings_search_mv полностью развязана с listings.tsv. Снятие +-- GENERATED с listings.tsv не делает поиск устаревшим ни на секунду — витрина +-- как считала to_tsvector из сырых колонок при каждом REFRESH, так и будет +-- продолжать. Пункт «переопределить listings_search_mv» из исходной +-- постановки задачи — ПРОВЕРЕН И ОТКЛОНЁН: посылка «MV тянет l.tsv» не +-- подтвердилась, витрину в этой миграции трогать не нужно и она не тронута. +-- +-- ── ИНДЕКС listings_tsv_idx: ДЕЙСТВИТЕЛЬНО МЁРТВ ──────────────────────────── +-- Проверено на проде 2026-08-20 (SELECT-only): +-- pg_stat_user_indexes: idx_scan = 0, pg_stat_database.stats_reset для БД +-- tradein пуст (счётчики ни разу не сбрасывались за всё время жизни БД) — +-- то есть 0 сканов не «с последнего сброса», а ЗА ВСЮ ИСТОРИЮ. Размер +-- индекса 116 MB. +-- pg_constraint: SELECT ... WHERE conindid = 'listings_tsv_idx'::regclass +-- → 0 строк. Индекс не обслуживает ни один UNIQUE/PK/EXCLUDE. +-- Дропаем. +-- +-- ── ПОЧЕМУ DROP EXPRESSION НЕ ВЫЗЫВАЕТ REWRITE ────────────────────────────── +-- Проверено по исходникам PostgreSQL 16 (ATExecDropExpression, tablecmds.c, +-- REL_16_STABLE): DROP EXPRESSION правит ТОЛЬКО каталог — снимает +-- attgenerated ('s' → '\0') и удаляет pg_attrdef строку через +-- RemoveAttrDefault. `tab->rewrite` (флаг, которым ATRewriteTables решает, +-- нужен ли проход по каждой странице таблицы) НЕ выставляется в этой ветке. +-- Значит `ALTER TABLE listings ALTER COLUMN tsv DROP EXPRESSION` — чисто +-- каталожная операция: держит ACCESS EXCLUSIVE ровно на время правки system +-- catalog (единицы миллисекунд на прогретом кэше), полного скана/переписи +-- 93k+ строк listings НЕ делает. Подтверждено и синтаксисом на самом проде +-- (`\h ALTER TABLE`, PostgreSQL 16.4): `ALTER [COLUMN] column_name DROP +-- EXPRESSION [IF EXISTS]` — форма с IF EXISTS штатная, не throw error если +-- колонка уже не generated (NOTICE вместо ошибки) => идемпотентна сама по +-- себе, без обёртки в DO $$. +-- +-- ── КОЛОНКА tsv ОСТАЁТСЯ ───────────────────────────────────────────────── +-- Саму колонку НЕ дропаем. DROP COLUMN — это отдельная операция с полным +-- ACCESS EXCLUSIVE на время удаления атрибута из каждой строки (rewrite не +-- обязателен физически для DROP COLUMN — PG просто помечает attisdropped, но +-- это самостоятельный шаг с собственным риском и ломает любой `SELECT *`, +-- который сегодня по listings делает бэкенд/скрипты) — вне рамок этой задачи +-- и явно запрещено постановкой. После этой миграции `tsv` — обычная +-- tsvector-колонка, замороженная на последнем сосчитанном значении: новые +-- description/address её больше не обновляют. Читателей у неё теперь 0 +-- (последний, listings_tsv_idx, снесён этой же миграцией), поэтому +-- staleness никому не видна. Кандидат на будущий отдельный DROP COLUMN — +-- отдельная rewrite-миграция, не входит в эту. +-- +-- ── ПОРЯДОК ─────────────────────────────────────────────────────────────── +-- DROP INDEX перед DROP EXPRESSION: индекс всё равно синхронизирован с +-- generated-значением на момент своего удаления, порядок между этими двумя +-- операциями физически не важен (обе развязаны с listings_search_mv), но +-- дропать использующий колонку объект первым — консервативнее. +-- +-- IDEMPOTENCY / SAFETY: +-- - DROP INDEX IF EXISTS — безопасный re-run. +-- - DROP EXPRESSION IF EXISTS — штатно идемпотентна (NOTICE, не error). +-- - Обе операции — catalog-only, без TABLE REWRITE (см. разбор выше). +-- - Данных не теряем: tsv не дропается, только перестаёт быть generated. +-- +-- Dependencies: 050_search_optimization.sql (завела generated-колонку и +-- индекс), 261_listings_search_mv_drop_placeholder_columns.sql (последняя +-- пересоздавшая listings_search_mv — сверена и НЕ тронута этой миграцией). +-- Deploy order: standalone, независимо от кода — search_query.py читает +-- listings_search_mv, её эта миграция не меняет. +-- +-- КРИТЕРИЙ ПРИЁМКИ (записан ДО применения): +-- 1. Строка '268_listings_tsv_drop_generated.sql' в _schema_migrations. +-- 2. information_schema.columns: listings.tsv по-прежнему существует, +-- is_generated = 'NEVER' (было 'ALWAYS'). +-- pg_attribute.attgenerated = '' (было 's'). +-- 3. pg_indexes: listings_tsv_idx отсутствует. +-- 4. pg_matviews.definition листings_search_mv НЕ меняется (байт-в-байт +-- как до миграции) — витрину эта миграция не трогает. +-- 5. /api/v1/search с description_query по-прежнему возвращает результаты +-- (витрина независима от listings.tsv, регрессии быть не должно). +-- 6. Через несколько дней после деплоя: TOAST-оборот на listings +-- (pg_stat_user_tables.n_tup_upd / связанный toast relation) заметно +-- ниже, чем базовые ~5 ГБ/91д, замеренные до фикса. + +BEGIN; + +-- Ограничивает ОЖИДАНИЕ выдачи лока, не работу под ним (обе операции ниже — +-- catalog-only, доли миллисекунд под локом; риск — простоять в очереди за +-- чужой долгой сессией). См. .claude/rules/sql.md § lock_timeout. +SET LOCAL lock_timeout = '5s'; + +-- Мёртвый индекс: 0 idx_scan за всю историю БД, не обслуживает ни один +-- constraint (pg_constraint.conindid пуст) — см. разбор в шапке. +DROP INDEX IF EXISTS listings_tsv_idx; + +-- Снимает GENERATED ALWAYS ... STORED. Catalog-only (attgenerated + удаление +-- pg_attrdef), без table rewrite — см. разбор ATExecDropExpression в шапке. +-- Колонка остаётся обычным tsvector, замороженным на текущем значении. +ALTER TABLE listings ALTER COLUMN tsv DROP EXPRESSION IF EXISTS; + +COMMENT ON COLUMN listings.tsv IS + 'Больше НЕ generated (снято миграцией 268, #2992/#2989): STORED-генерация ' + 'пересчитывала tsv на каждом UPDATE независимо от того, менялись ли ' + 'description/address, и перетостивала строку заново (~5 ГБ TOAST-оборота ' + 'за 91 день). Значение заморожено на последнем пересчитанном состоянии. ' + 'listings_search_mv (050/094/261) НИКОГДА не читала эту колонку — она ' + 'сама считает to_tsvector из сырых description/address/developer_name ' + 'при каждом REFRESH, поэтому /api/v1/search не деградирует. Единственный ' + 'индекс на этой колонке, listings_tsv_idx, снесён той же миграцией (0 ' + 'idx_scan за всю историю). Колонку намеренно не дропаем — DROP COLUMN ' + 'ломает listings.SELECT * и требует отдельной rewrite-миграции.'; + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/269_houses_yandex_jk_resolve_tried_at.sql b/tradein-mvp/backend/data/sql/269_houses_yandex_jk_resolve_tried_at.sql new file mode 100644 index 00000000..b990d581 --- /dev/null +++ b/tradein-mvp/backend/data/sql/269_houses_yandex_jk_resolve_tried_at.sql @@ -0,0 +1,21 @@ +-- #2924: неудача резолва yandex_jk_slug ничего не помечала — выборка yandex_newbuilding_sweep +-- каждый прогон брала те же дома в том же порядке (ORDER BY h.id), и пять первых +-- занимали все пять слотов (limit 5) неделю за неделей. Замер 19.08: 397 ожидающих, +-- первые пять по h.id — 6706, 6741, 6757, 6774, 6785 — одни и те же в каждом прогоне. +-- +-- Маркер «пробовали резолвить» — по образцу listings.geocode_tried_at (миграция 005): +-- выборка повторяет попытку не раньше чем через N дней и ставит неудавшиеся в конец +-- очереди (ORDER BY tried_at NULLS FIRST), а не в начало. +-- +-- Идемпотентно. Колонка nullable, без дефолта — существующие строки = «не пробовали». +-- lock_timeout (#2752): ALTER TABLE берёт ACCESS EXCLUSIVE на houses — при живом +-- писателе (свип) ждал бы его бесконечно и копил очередь за собой; 5с — и миграция +-- падает честно, деплой перезапускается, а не вешает базу. +-- SET LOCAL действует только внутри транзакции — вне BEGIN он молча ничего не делает +-- (гейт check-migration-lock-timeout это проверяет; на этом я и споткнулся). +BEGIN; +SET LOCAL lock_timeout = '5s'; +ALTER TABLE houses ADD COLUMN IF NOT EXISTS yandex_jk_resolve_tried_at timestamptz; +COMMENT ON COLUMN houses.yandex_jk_resolve_tried_at IS + '#2924: когда yandex_newbuilding_sweep последний раз пытался разрезолвить yandex_jk_slug и не смог; NULL = не пробовали или резолв удался'; +COMMIT; diff --git a/tradein-mvp/backend/data/sql/270_houses_geog_gist_idx.sql b/tradein-mvp/backend/data/sql/270_houses_geog_gist_idx.sql new file mode 100644 index 00000000..0b869139 --- /dev/null +++ b/tradein-mvp/backend/data/sql/270_houses_geog_gist_idx.sql @@ -0,0 +1,27 @@ +-- 270_houses_geog_gist_idx.sql +-- #2997: у houses нет индекса под geography. Весь матчинг ходит через +-- ST_DWithin(geom::geography, …) (app/services/matching/houses.py:300, :352, :554), +-- а живой houses_geom_idx построен по geometry. Планировщик берёт его только как +-- bitmap по `geom IS NOT NULL` и дальше фильтрует все ~9.4k строк с координатами. +-- Замер на проде 21.08.2026 (EXPLAIN ANALYZE, точка в центре ЕКБ, 150 м): +-- Bitmap Index Scan on houses_geom_idx, Index Cond: (geom IS NOT NULL) +-- Rows Removed by Filter: 4718 на воркер, Execution Time: 70.7 ms +-- pg_stat_statements за 13 ч: 66 вызовов × 39.9 мс и 5 × 53.3 мс на этот класс запросов. +-- +-- Функциональный GiST по (geom::geography) совпадает с выражением в запросах +-- (h.geom::geography ≡ (geom)::geography), и ST_DWithin на geography начинает +-- идти по индексу. houses_geom_idx НЕ трогаем: его дроп — отдельное решение по +-- эпику #2989 (см. #2997, там же — почему дубль на listings тоже пока не снят). +-- +-- CONCURRENTLY и без BEGIN/COMMIT — по образцу 225: раннер деплоя исполняет файл +-- через psql autocommit по операторам; CIC нельзя внутри транзакции, а +-- lock_timeout ему вреден (гейт scripts/check-migration-lock-timeout.py +-- CONCURRENTLY-формы не требует и не терпит). Таблица маленькая (10 111 строк), +-- но её читает матчинг каждого объявления — ACCESS EXCLUSIVE даже на секунду не +-- нужен. Если CIC оборвётся, останется INVALID-индекс — его ловит шаг проверки +-- невалидных индексов в deploy-tradein.yml, и деплой краснеет, а не молчит. +CREATE INDEX CONCURRENTLY IF NOT EXISTS houses_geog_gist_idx + ON houses USING gist ((geom::geography)); + +COMMENT ON INDEX houses_geog_gist_idx IS + '#2997: GiST по (geom::geography) под ST_DWithin(geom::geography, …) матчинга (matching/houses.py). До него планировщик шёл через houses_geom_idx как bitmap по geom IS NOT NULL и фильтровал все строки (~70 мс на запрос, прод 21.08.2026).'; diff --git a/tradein-mvp/backend/data/sql/271_listings_pro_seller_from_agency.sql b/tradein-mvp/backend/data/sql/271_listings_pro_seller_from_agency.sql new file mode 100644 index 00000000..ef1059db --- /dev/null +++ b/tradein-mvp/backend/data/sql/271_listings_pro_seller_from_agency.sql @@ -0,0 +1,17 @@ +-- 271_listings_pro_seller_from_agency.sql +-- #3063 п.2: is_pro_seller выводим из уже известного agency_name. +-- Прод-факт (замер владельца 23-24.08): 4 469 активных yandex-листингов имеют +-- заполненный agency_name (из detail-бэкфилла, где COALESCE-защита была), но +-- is_pro_seller у них NULL — признак эрозирован SERP-затиранием до PR #3067. +-- Починка разбора не чинит строки (ON CONFLICT/UPDATE прошлого не перезапишет +-- задним числом) — поэтому одноразовый бэкфилл. +-- +-- Скоуп: ВСЕ источники, не только yandex — правило «есть имя агентства ⇒ продаёт +-- профи» источник-независимо. is_homeowner не трогаем: отсутствие агентства не +-- доказывает «частник» (п.3 задачи — отдельное решение). +-- Идемпотентно: повторный прогон обновит 0 строк. +UPDATE listings + SET is_pro_seller = TRUE + WHERE agency_name IS NOT NULL + AND agency_name <> '' + AND is_pro_seller IS DISTINCT FROM TRUE; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt deleted file mode 100644 index 6b449178..00000000 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ /dev/null @@ -1,252 +0,0 @@ -# _manifest_applied.txt — CONTRACT (issue #2216) -# -# Отсортированный список ВСЕХ имён миграций (bare filename) в data/sql/, -# которые на момент коммита уже применены/забейслайнены на проде. -# Прод трекает миграции по bare-filename в public._schema_migrations — -# переименование или удаление применённого файла => повторный прогон на -# проде (новый filename считается неприменённым) => дубль-эффекты/ошибки. -# -# ПРАВИЛА (enforced tests/test_migrations_manifest.py): -# 1. Каждое имя здесь ОБЯЗАНО существовать в data/sql/ (нельзя rename/rm applied). -# 2. Новый .sql-файл => НЕ переиспользуй NN-префикс (кроме 6 grandfathered дублей). -# 3. Добавляя новую миграцию — допиши её имя сюда В ТОМ ЖЕ PR (список отсортирован). -# -# Комментарии (# ...) и пустые строки тест игнорирует. -001_trade_in_estimates.sql -002_core_tables.sql -003_seed_deals.sql -004_extend_trade_in_estimates.sql -005_geocode_tracking.sql -007_estimate_photos.sql -008_crm_fields.sql -009_houses.sql -010_houses_alter.sql -011_listings_alter.sql -012_sellers.sql -013_listings_alter_seller.sql -014_house_reviews.sql -015_scrape_runs.sql -016_listings_snapshots.sql -017_house_placement_history.sql -018_avito_imv_evaluations.sql -019_listings_alter_cian.sql -020_houses_alter_cian.sql -021_management_companies.sql -022_agents_table.sql -023_offer_price_history.sql -024_houses_price_dynamics.sql -025_house_reliability_checks.sql -026_external_valuations.sql -027_cian_session_cookies.sql -028_matching_tables.sql -029_extend_matching_valuation_dynamics.sql -030_avito_imv_cache_key_unique.sql -031_houses_alter_yandex.sql -032_yandex_history.sql -033_listings_alter_yandex.sql -034_trade_in_estimates_geom.sql -035_drop_duplicate_indexes.sql -040_houses_extend.sql -041_house_sources_noop.sql -042_listing_sources_price_divergence_idx.sql -043_house_reviews_extend.sql -044_external_valuations_link.sql -045_house_placement_history_extend.sql -046_views.sql -047_cian_history_sanitize.sql -050_search_optimization.sql -051_scrape_runs_extend.sql -052_scrape_schedules.sql -053_scraper_settings.sql -054_scraper_settings_global.sql -060_postgres_fdw_extension.sql -061_drop_legacy_cad_buildings.sql -062_clean_avito_addresses.sql -063_backfill_houses_and_link_listings.sql -064_house_imv_phase_c.sql -065_trade_in_estimates_floor_optional.sql -066_address_mismatch_audit.sql -067_v_street_sales_vs_listings.sql -068_drop_v_street_sales_vs_listings.sql -069_trade_in_estimates_dadata_fields.sql -070_houses_dadata_enrichment.sql -071_houses_cian_zhk_url.sql -072_scrape_schedules_seed_cian_rosreestr.sql -073_normalize_repair_state.sql -075_backfill_repair_state_from_description.sql -076_account_estimate_quota.sql -077_dedup_hash_plain_key_backfill.sql -078_scrape_schedules_seed_yandex_sweep.sql -079_listing_source_history.sql -080_asking_to_sold_ratios.sql -081_trade_in_estimates_expected_sold.sql -082_scrape_schedules_seed_ratio_refresh.sql -083_trade_in_estimates_created_by.sql -084_brand_praktika_fill.sql -084_scrape_schedules_seed_n1_sweep.sql -085_quarter_price_index_fdw.sql -086_deals_address_trgm_index.sql -087_fdw_server_options.sql -088_scrape_schedules_seed_search_matview_refresh.sql -089_listings_geo_precision.sql -090_scrape_schedules_seed_deactivate_stale_avito.sql -091_scrape_schedules_seed_yandex_address_backfill.sql -092_sber_price_index.sql -093_scrape_schedules_seed_sber_index_pull.sql -094_cadastral_unify.sql -095_dead_schema.sql -096_scrape_schedules_seed_rosreestr_quarter_poll.sql -097_index_hygiene.sql -098_asking_to_sold_ratios_tiered.sql -099_brand_praktika_logo_wordmark.sql -100_enable_deactivate_stale_avito.sql -101_gendesign_reader_role.sql -102_grant_listings_gendesign_reader.sql -103_scrape_schedules_seed_newbuilding_enrich.sql -104_index_hygiene_geom_dedup.sql -105_market_schema_yandex_enrichment.sql -106_scrape_schedules_seed_yandex_newbuilding_sweep.sql -107_scrape_schedules_seed_cian_city_sweep.sql -108_clean_avito_addresses_v2.sql -108_merge_duplicate_houses.sql -109_asking_to_sold_ratio_segment_filter.sql -110_scrape_schedules_seed_geocode_missing_listings.sql -111_listings_avito_detail_fields.sql -112_scrape_schedules_seed_avito_detail_backfill.sql -113_deactivate_ghost_duplicate_listings.sql -113_yandex_detail_backfill.sql -114_disable_n1_sweep.sql -115_scrape_schedules_seed_deactivate_stale_yandex_cian.sql -116_offer_price_history_change_trigger.sql -117_listings_last_seen_deactivate_index.sql -118_enable_cian_city_sweep.sql -119_yandex_city_sweep_center_combos.sql -120_restore_partial_active_indexes.sql -121_remove_brand_praktika.sql -121_yandex_rich_fields.sql -122_enable_domclick_city_sweep.sql -123_avito_newbuilding_sweep_schedule.sql -124_cad_buildings_local.sql -124_deglue_avito_addresses.sql -125_scrape_schedules_seed_cadastral_geo_match.sql -126_scrape_schedules_seed_cian_full_load.sql -127_scrape_schedules_seed_avito_full_load.sql -128_listings_card_hash.sql -129_avito_full_load_incremental_split.sql -130_backfill_listings_house_id_fk.sql -130_ekb_geoportal_buildings.sql -131_fix_diff_percent_overflow.sql -132_scrape_schedules_seed_house_imv.sql -133_listings_uq_source_source_id.sql -134_listings_geom_geography_gist.sql -135_scrape_schedules_seed_house_dedup_merge.sql -136_backfill_listings_house_id_fk_source_identity.sql -137_listings_addr_norm_trgm.sql -138_domclick_bff_rewrite_schedule.sql -139_premium_houses.sql -140_yandex_house_type_backfill.sql -141_cian_promote_house_type.sql -142_premium_buildings_curated.sql -143_building_sale_share_schema.sql -144_gar_canon_addr_match.sql -145_building_sale_share_plausible_denom.sql -146_sale_share_45d_and_zhkh_denom.sql -147_canon_strip_geo_prefixes.sql -148_dedup_apartments_in_sale_share.sql -149_zhkh_priority_denominator.sql -150_sale_share_listing_geo_filter.sql -151_clean_bare_street_aliases.sql -152_sale_share_floors_guard.sql -153_sale_share_listings_floors_plausibility.sql -154_market_contract_views.sql -155_reader_grants_to_contract_views.sql -156_revoke_raw_from_reader.sql -157_scrape_proxies.sql -158_seed_proxy_healthcheck_schedule.sql -159_houses_fias_idx.sql -160_seed_deactivate_stale_domklik_n1.sql -161_backfill_scraped_at_active_recent.sql -162_seed_deals_freshness_monitor.sql -163_disable_deactivate_stale_domklik.sql -164_yandex_url_canonicalize_active_dups.sql -165_remove_n1_source.sql -166_purge_listings_phones.sql -167_drop_client_pii.sql -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 -175_scrape_schedules_seed_domclick_detail_backfill.sql -176_domrf_kapremont.sql -177_deals_city_region.sql -178_deal_city_price_bands.sql -179_scrape_schedules_seed_oblast_city_sweeps.sql -180_seed_sber_freshness_monitor.sql -181_clamp_bad_listing_dates.sql -182_trade_in_leads_consent_proof.sql -183_reenable_deactivate_stale_domklik.sql -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 -# -# 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 diff --git a/tradein-mvp/backend/pyproject.toml b/tradein-mvp/backend/pyproject.toml index 29d7c22c..beaf308b 100644 --- a/tradein-mvp/backend/pyproject.toml +++ b/tradein-mvp/backend/pyproject.toml @@ -20,13 +20,17 @@ dependencies = [ "lxml>=5.0.0", # стриминговый iterparse для многогигабайтных ГАР XML (#143) "segno>=1.6.0", # QR-код для PDF shareable URL "matplotlib>=3.9.0", # price-range chart (SVG) для PDF отчёта Trade-In - "curl-cffi>=0.7.0", # impersonate=chrome120 для Cian/Avito (TLS fingerprint) + "curl-cffi>=0.15.0", # impersonate= для Cian/Avito (TLS fingerprint). + # Пол 0.15.0, а не 0.7.0: DEFAULT_IMPERSONATE=chrome146, а этот + # профиль появился позже 0.7.0 — установка по нижней границе + # роняла бы КАЖДЫЙ запрос скраппера на невалидном impersonate. "python-multipart>=0.0.9", # FastAPI UploadFile — загрузка фото квартиры (#394) "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) + "prometheus-client>=0.21.0", # /metrics — экспозиция и process-коллекторы (#3078) "scraper-kit", # internal workspace-package (#2137) — общие утилиты скрапперов; # резолвится из workspace (см. [tool.uv.sources]), не с PyPI. # Docker-context = tradein-mvp root, uv sync ставит editable. @@ -40,7 +44,7 @@ dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.24.0", "pytest-timeout>=2.3.0", - "ruff>=0.5.0", + "ruff==0.15.20", ] [tool.pytest.ini_options] diff --git a/tradein-mvp/backend/scripts/backfill_houses_dadata.py b/tradein-mvp/backend/scripts/backfill_houses_dadata.py index ec7dd2be..cabbd302 100644 --- a/tradein-mvp/backend/scripts/backfill_houses_dadata.py +++ b/tradein-mvp/backend/scripts/backfill_houses_dadata.py @@ -158,14 +158,11 @@ class Stats: # для `--priority listings`. Correlated EXISTS бьёт по partial-индексу # `listings_house_id_fk_idx (house_id_fk) WHERE house_id_fk IS NOT NULL`. _HAS_ACTIVE_LISTINGS_EXPR = ( - "EXISTS (SELECT 1 FROM listings l " - "WHERE l.house_id_fk = houses.id AND l.is_active)" + "EXISTS (SELECT 1 FROM listings l WHERE l.house_id_fk = houses.id AND l.is_active)" ) -def _select_candidates( - db: Session, *, priority: str, limit: int -) -> list[tuple[HouseRow, str]]: +def _select_candidates(db: Session, *, priority: str, limit: int) -> list[tuple[HouseRow, str]]: """Возвращает список (HouseRow, priority_bucket) для enrichment. priority: @@ -477,9 +474,7 @@ async def _run_backfill( db.rollback() stats.failed += 1 stats.bump(priority, "failed") - logger.warning( - "db_write failed for house_id=%s: %s", row.id, exc - ) + logger.warning("db_write failed for house_id=%s: %s", row.id, exc) if i % _LOG_EVERY == 0: logger.info( @@ -568,9 +563,7 @@ async def main(argv: list[str] | None = None) -> int: db = SessionLocal() try: candidates = _select_candidates(db, priority=args.priority, limit=args.limit) - logger.info( - "loaded candidates: %d (priority=%s)", len(candidates), args.priority - ) + logger.info("loaded candidates: %d (priority=%s)", len(candidates), args.priority) if not candidates: logger.info( "nothing to do — нет rows с dadata_enriched_at IS NULL для priority=%s", @@ -578,9 +571,7 @@ async def main(argv: list[str] | None = None) -> int: ) return 0 - stats = await _run_backfill( - db, candidates, batch=args.batch, dry_run=args.dry_run - ) + stats = await _run_backfill(db, candidates, batch=args.batch, dry_run=args.dry_run) logger.info( "done: batch=%s processed=%d enriched=%d no_change=%d " diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index 69a065c4..2c272a42 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -1486,11 +1486,11 @@ def _make_call_stub( stays correct if a call site ever loops. Calling the stub MORE times than recorded raises RuntimeError — control flow diverged from capture. - ``on_exhausted`` (#2656) ослабляет ЭТО последнее правило для конкретной + ``on_exhausted`` (#2661) ослабляет ЭТО последнее правило для конкретной callable: значение возвращается вместо RuntimeError, когда фикстура записала меньше вызовов, чем сделал реплей. Нужно, когда правка РАЗБЛОКИРОВАЛА путь, которого при захвате фикстуры не было (сброс залипшего ``anchor_tier`` открыл - quarter-index-гейт `Guard-1a` на 19 из 277 сделок) — у прод-фикстуры на такие + quarter-index-гейт `Guard-1a` на часть сделок) — у прод-фикстуры на такие вызовы ответа нет и взять его негде, пока фикстуру не перезахватят с прода. Использовать ТОЛЬКО для lookup'ов, у которых «промах» — валидное состояние (quarter-index: None/{} = индекса нет → блок no-op). ``ratio_resolver`` @@ -1557,9 +1557,9 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]: predictions: list[Prediction] = [] sold_ppm2_all: list[float] = [] pred_ppm2_all: list[float] = [] - unrecorded: list[int] = [0] # #2656: счётчик lookup-вызовов без записи в фикстуре + unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре - # #2656: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был + # #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был # no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти # по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source # подрезал бы listings до quarter_indexes_lookup и записанная последовательность @@ -1576,10 +1576,10 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]: kw["ratio_resolver"] = _make_call_stub( rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return ) - # #2656: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых + # #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт - # Guard-1a на 19 из 277 сделок, а прод-фикстура захвачена ДО правки. Следствие, - # которое надо знать при чтении метрик: для этих 19 сделок квартальный индекс в + # Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие, + # которое надо знать при чтении метрик: для этих сделок квартальный индекс в # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает # эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит. # Уйдёт (до нуля) при следующем перезахвате фикстуры с прода. @@ -1640,7 +1640,7 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]: "ask_median_ppm2": ask_median, "spread_pct": spread_pct, } - # #2656: сколько lookup-вызовов фикстура ответить не смогла (см. _make_call_stub). + # #2661: сколько lookup-вызовов фикстура ответить не смогла (см. _make_call_stub). # Целое → baseline сравнивает ТОЧНО: рост = новый путь разошёлся с захватом (гейт # падает громко), падение до 0 = фикстуру перезахватили и ослабление можно снять. metrics["unrecorded_lookup_calls"] = unrecorded[0] diff --git a/tradein-mvp/backend/scripts/geocode_deals_from_houses.py b/tradein-mvp/backend/scripts/geocode_deals_from_houses.py index fc1acc15..d924e67d 100644 --- a/tradein-mvp/backend/scripts/geocode_deals_from_houses.py +++ b/tradein-mvp/backend/scripts/geocode_deals_from_houses.py @@ -293,17 +293,21 @@ def _build_centroid_map(db: Session) -> dict[str, Centroid]: would require duplicating the regex logic in plpgsql and risk drift. 8,600 rows is trivial to hold in memory. """ - rows = db.execute( - text( - "SELECT address, lat, lon " - "FROM houses " - "WHERE geom IS NOT NULL " - " AND lat IS NOT NULL " - " AND lon IS NOT NULL " - " AND address IS NOT NULL " - " AND length(trim(address)) > 0" + rows = ( + db.execute( + text( + "SELECT address, lat, lon " + "FROM houses " + "WHERE geom IS NOT NULL " + " AND lat IS NOT NULL " + " AND lon IS NOT NULL " + " AND address IS NOT NULL " + " AND length(trim(address)) > 0" + ) ) - ).mappings().all() + .mappings() + .all() + ) # street_key → running [lat_sum, lon_sum, n] acc: dict[str, list[float]] = {} @@ -328,18 +332,22 @@ def _select_deals_without_coords(db: Session, limit: int) -> list[DealRow]: Matches `deals_geocode_pending_idx` (WHERE lat IS NULL). A successful UPDATE sets lat NOT NULL, dropping the row out on the next run. """ - rows = db.execute( - text( - "SELECT id, address " - "FROM deals " - "WHERE lat IS NULL " - " AND address IS NOT NULL " - " AND length(trim(address)) > 0 " - "ORDER BY id " - "LIMIT CAST(:lim AS int)" - ), - {"lim": limit}, - ).mappings().all() + rows = ( + db.execute( + text( + "SELECT id, address " + "FROM deals " + "WHERE lat IS NULL " + " AND address IS NOT NULL " + " AND length(trim(address)) > 0 " + "ORDER BY id " + "LIMIT CAST(:lim AS int)" + ), + {"lim": limit}, + ) + .mappings() + .all() + ) return [DealRow(id=r["id"], address=r["address"]) for r in rows] @@ -415,9 +423,7 @@ def _run_backfill( else: try: with db.begin_nested(): - _update_deal_coords( - db, deal_id=deal.id, lat=centroid.lat, lon=centroid.lon - ) + _update_deal_coords(db, deal_id=deal.id, lat=centroid.lat, lon=centroid.lon) # Per-row commit so resume picks up exactly where we crashed. db.commit() stats.geocoded += 1 @@ -470,9 +476,7 @@ def _report_dry_run( logger.info("─" * 60) logger.info("DRY-RUN SUMMARY (no DB writes)") logger.info("distinct streets with a house centroid: %d", distinct_streets) - logger.info( - "deals scanned this run (lat IS NULL, capped by --limit): %d", scanned - ) + logger.info("deals scanned this run (lat IS NULL, capped by --limit): %d", scanned) logger.info("deals matched to a centroid: %d", matched) logger.info("deals with no street match: %d", stats.no_street_match) logger.info("match rate on scanned slice: %.1f%%", match_rate * 100.0) @@ -550,9 +554,7 @@ def main(argv: list[str] | None = None) -> int: centroids = _build_centroid_map(db) logger.info("built centroid map: %d distinct streets", len(centroids)) if not centroids: - logger.warning( - "no house centroids — houses table has no geocoded rows; nothing to do" - ) + logger.warning("no house centroids — houses table has no geocoded rows; nothing to do") return 0 deals = _select_deals_without_coords(db, args.limit) @@ -561,9 +563,7 @@ def main(argv: list[str] | None = None) -> int: logger.info("nothing to do — no deals with lat IS NULL and an address") return 0 - stats = _run_backfill( - db, deals, centroids, batch=args.batch, dry_run=args.dry_run - ) + stats = _run_backfill(db, deals, centroids, batch=args.batch, dry_run=args.dry_run) if args.dry_run: total_null = _count_deals_null(db) diff --git a/tradein-mvp/backend/scripts/geocode_deals_nominatim.py b/tradein-mvp/backend/scripts/geocode_deals_nominatim.py index c6876d83..8bdeda9a 100644 --- a/tradein-mvp/backend/scripts/geocode_deals_nominatim.py +++ b/tradein-mvp/backend/scripts/geocode_deals_nominatim.py @@ -461,8 +461,7 @@ def _maybe_log_progress(i: int, groups: list[AddressGroup], batch: str, stats: S """Emit a progress line every `_LOG_EVERY` distinct addresses.""" if i % _LOG_EVERY == 0: logger.info( - "batch=%s progress %d/%d geocoded=%d failed=%d deals_updated=%d " - "cache=(hit=%d miss=%d)", + "batch=%s progress %d/%d geocoded=%d failed=%d deals_updated=%d cache=(hit=%d miss=%d)", batch, i, len(groups), diff --git a/tradein-mvp/backend/tests/conftest.py b/tradein-mvp/backend/tests/conftest.py index 9651c161..02b64a9f 100644 --- a/tradein-mvp/backend/tests/conftest.py +++ b/tradein-mvp/backend/tests/conftest.py @@ -3,7 +3,9 @@ Регистрирует кастомные pytest-маркеры (иначе PytestUnknownMarkWarning: `--strict-markers` в pyproject.toml не включён, так что незарегистрированный маркер только предупреждал бы) и сторожит глобальное состояние, которое -переживает отдельный тест, — см. `_no_leaked_password_verify_slots`. +переживает отдельный тест: общий rate-limiter POST /estimate (см. +`_reset_estimate_rate_limiter`) и слоты проверки пароля (см. +`_no_leaked_password_verify_slots`). """ from __future__ import annotations @@ -23,6 +25,29 @@ def pytest_configure(config) -> None: ) +@pytest.fixture(autouse=True) +def _reset_estimate_rate_limiter() -> None: + """`app.api.v1.trade_in._estimate_limiter` (#b2c-antiabuse-2) is a module-level + `SlidingWindowLimiter` singleton that accumulates hits across ALL tests + hitting POST /estimate within one pytest process (a dozen+ test files build + their own FastAPI app around `trade_in_module.router` — see grep for + `trade_in_module.router` under tests/). Without a reset, unrelated test + files could trip the 429 rate-limit purely from cross-test state leakage + (same class of issue `test_support.py::_fresh_rate_limiter` solves locally + for `_send_limiter` — this one needs to be global since so many files touch + the trade_in router). Lazy import: keeps conftest.py import-light and avoids + forcing DATABASE_URL to be set before any test module has had a chance to + default it. + """ + from app.api.v1 import trade_in as trade_in_module + from app.core.config import settings + from app.core.ratelimit import SlidingWindowLimiter + + 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). diff --git a/tradein-mvp/backend/tests/fixtures/avito_detail_card_agency_95m2.html b/tradein-mvp/backend/tests/fixtures/avito_detail_card_agency_95m2.html new file mode 100644 index 00000000..f390b1d2 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/avito_detail_card_agency_95m2.html @@ -0,0 +1,303 @@ +3-к. квартира, 95,2 м², 8/17 эт. на продажу в Екатеринбурге | Купить квартиру | Авито (8343481647) + + + + + + + + + + + + + + + +
realEstate

3-к. квартира, 95,2 м², 8/17 эт.

3-к. квартира, 95,2 м², 8/17 эт.
13 950 000 ₽ 
Елена

О квартире

  • Количество комнат: 3
  • Общая площадь: 95.2 м²
  • Площадь кухни: 25 м²
  • Этаж: 8 из 17
  • Балкон или лоджия: лоджия
  • Дополнительно: гардеробная, панорамные окна
  • Тип комнат: изолированные
  • Высота потолков: 2.7 м
  • Санузел: совмещенный, раздельный
  • Окна: на улицу
  • Ремонт: дизайнерский
  • Тёплый пол: есть
  • Мебель: кухня, хранение одежды, спальные места
  • Техника: кондиционер, холодильник, стиральная машина, посудомоечная машина, водонагреватель
  • Способ продажи: свободная
  • Условия продажи: возможна ипотека

Проверка в Росреестре

1 собственник

Не найдены ограничения и обременения

Совпадают площадь, адрес и этаж

Расположение

Свердловская обл., Екатеринбург, ул. Токарей, 68
Площадь 1905 годаот 31 мин.Геологическаяот 31 мин.Чкаловскаяот 31 мин.

Описание

КВАРTИPA БEЗ ОБРЕМEНEНИЙ И ОГРAНИЧЕНИЙ!

ОДИН BЗPOCЛЫЙ CОБСТВЕНHИK!

БЫСТPЫЙ BЫХOД HА CДЕЛКУ!

B продажe 3-х кoмнaтная квартиpа с панoрамным видом на горoд, в дoме бизнec-клаcca, ЖK "БРИГАНTИHА".

В квaртиpe cдeлан кaчeственный ремонт из дорогостоящих материалов. Роскошный индивидуальный дизайн интерьера с кованными элементами, на стенах присутствуют росписи в итальянском стиле масляными красками, на окнах фрески.

Вложений не требует! Просто заезжай и живи! Подобную квартиру в городе, Вы, не найдете!

В квартире остается вся эксклюзивная мебель, включая встроенную, выполнена из натуральных пород дерева. Два сан. узла, в ванной комнате встроенная сауна, вся сан. техника итальянская. Квартира укомплектована техникой, варочная панель и духовой шкаф Восh, посудомоечная машина Восh, вытяжка Восh, холодильный шкаф Shаrр, встроенный холодильник, шкафчик для хранения вин, стиральная машина Восh, бойлер Еlесtrоluх, в наличии кондиционер, все в отличном состоянии.

Телевизоры и предметы интерьера остаются по договоренности!

В стоимость входит кладовая комната с металлической дверью на лестничной площадке. Прямо в доме располагаются фитнес-клуб, клиника китайской медицины, салон красоты.

Рядом вся необходимая для жизни инфраструктура: остановки, школы, садики, аптеки, магазины.

Территория дома огорожена и ведётся круглосуточное видеонаблюдение, в подъезде консьерж.

В доме есть: детская площадка, закрытый двор с автоматически открываемыми с помощью программы, установленной на телефон, воротами, наземный и подземный паркинги, лифты – пассажирский и грузовой. Проведено: телефон, интернет, кабельное телевидение, счетчики холодной и горячей воды, домофон, железная сейф-дверь.

Возможна покупка в ипотеку и с любыми видами сертификатов.

О доме

  • Тип дома: кирпичный
  • Год постройки: 2007
  • Этажей в доме: 17
  • Пассажирский лифт: 1
  • Грузовой лифт: 1
  • В доме: консьерж
  • Двор: закрытая территория, детская площадка, спортивная площадка
  • Парковка: подземная, за шлагбаумом во дворе

Рассчитайте ипотеку прямо на Авито

11 предложений от банков

Платёж от

142 490 ₽/мес.

Ставка от

16,9%

Доход от

219 216 ₽

−0,75% за выписку СФР
−0,6% для зарплатного клиента
−0,5% за быстрый выход на сделку
−0,3% только на Авито

№ 8343481647 · сегодня в 07:32 · 1175 просмотров (+1175 сегодня)

13 950 000 ₽ 

146 534 ₽ за м²

Площадь 1905 года от 31 мин.

Геологическая от 31 мин.

Чкаловская от 31 мин.

Свердловская обл., Екатеринбург, ул. Токарей, 68

Показать на карте

Спросите у продавца

Агентство
На Авито c 2011 года 
СмениКвартиру

Контактное лицо

Елена

timing
timing
+ + + + + + + + + + + + + +
diff --git a/tradein-mvp/backend/tests/fixtures/avito_detail_card_owner_66m2.html b/tradein-mvp/backend/tests/fixtures/avito_detail_card_owner_66m2.html new file mode 100644 index 00000000..1e76fa17 --- /dev/null +++ b/tradein-mvp/backend/tests/fixtures/avito_detail_card_owner_66m2.html @@ -0,0 +1,303 @@ +Авито — Объявления на сайте Авито + + + + + + + + + + + + + + + +
realEstate

2-к. квартира, 66 м², 12/16 эт.

2-к. квартира, 66 м², 12/16 эт.
11 900 000 ₽ 
Пользователь

О квартире

  • Количество комнат: 2
  • Общая площадь: 66 м²
  • Площадь кухни: 20 м²
  • Этаж: 12 из 16
  • Балкон или лоджия: балкон
  • Тип комнат: изолированные
  • Высота потолков: 2.7 м
  • Санузел: совмещенный
  • Окна: во двор, на улицу
  • Ремонт: дизайнерский
  • Мебель: кухня, хранение одежды
  • Техника: холодильник, посудомоечная машина, водонагреватель
  • Способ продажи: свободная
  • Условия продажи: возможна ипотека

Проверка в Росреестре

1 собственник

Последняя смена собственника 30 октября 2020

Не найдены ограничения и обременения

Совпадают площадь, адрес и этаж

Расположение

Свердловская обл., Екатеринбург, пр-т Космонавтов, 11В
Уральская16–20 мин.Машиностроителей16–20 мин.Уралмаш21–30 мин.

Описание

Пpодаётся шикаpная cветлая квартиpа. Oбщая площaдь 65,90 кв. метpа. Куxня-гоcтинaя, двe изoлиpованные комнаты, сoвмeщенный сaнузeл, кладoвкa, балкoн. Кваpтира рaспoлoжена на 12 этaже 16 этажногo дома, 4 квартиры на этaжe. В квapтирe пpoизвeдён ремoнт, можнo зaезжaть сpaзу. Чacтично ocтается бытовая техника и мебель новому владельцу. На балконе сделана шумо- и гидроизоляция, проведено электричество. В кладовой установлен водонагреватель для комфортного проживания во время отключения ГВС. Два взрослых собственника, без ипотек, мат капиталов, обременений, быстрый выход на сделку. Квартира расположена в Жилом квартале «Космонавтов 11» — это масштабный проект комфорт-класса от компании ПИК в Железнодорожном районе Екатеринбурга (Завокзальный микрорайон). Комплекс включает монолитно-кирпичные дома переменной этажности (16–25 этажей), закрытые дворы-парки, собственную социальную и торговую инфраструктуру. Очень удобная транспортная развязка. В пешей доступности (около 10–15 минут ходьбы) находятся станции «Уральская» и «Машиностроителей».Рядом проходят автобусные, троллейбусные и трамвайные маршруты. До центра города можно доехать за 15 минут.

О доме

  • Тип дома: монолитно-кирпичный
  • Этажей в доме: 16
  • Пассажирский лифт: 2
  • Грузовой лифт: 1
  • Двор: закрытая территория, детская площадка, спортивная площадка
  • Парковка: наземная многоуровневая, открытая во дворе

№ 8322506679 · сегодня в 07:31 · 22 просмотра (+22 сегодня)

11 900 000 ₽ 

180 303 ₽ за м²

Уральская 16–20 мин.

Машиностроителей 16–20 мин.

Уралмаш 21–30 мин.

Свердловская обл., Екатеринбург, пр-т Космонавтов, 11В

Показать на карте

Спросите у продавца

Пользователь
Частное лицо
timing
timing
+ + + + + + + + + + + + + +
diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index d156690c..e7b389d1 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -151,5 +151,5 @@ "median_rel_width": 0.743, "n": 269 }, - "unrecorded_lookup_calls": 19 + "unrecorded_lookup_calls": 0 } diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_detail_warmup_antibot_cookies.py b/tradein-mvp/backend/tests/scrapers/test_avito_detail_warmup_antibot_cookies.py new file mode 100644 index 00000000..332c5534 --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_avito_detail_warmup_antibot_cookies.py @@ -0,0 +1,118 @@ +"""#3034: прогрев (`warm_up_session`/`research_in_session`) проверяет антибот-cookies. + +Баг: обе функции считали прогрев успешным по HTTP-статусу и отсутствию +firewall-маркеров, но НИКОГДА не смотрели, реально ли осели антибот-cookies +(`__zzatw-*`/`cfidsw-*`) — сессия могла выглядеть «прогретой» и идти в +detail-батч, который затем сжигал прокси на обречённых 403. + +Этот файл доказывает три вещи: + 1. Cookies есть → обе функции возвращают True (не меняли поведение happy-path). + 2. Страница отдалась 200/не-firewall, но cookies НЕТ → `AvitoWarmupCookiesMissingError` + (наследник `AvitoBlockedError` — существующие `except (AvitoBlockedError, + AvitoRateLimitedError)` в pipeline/backfill ловят его без изменений). + 3. Явный firewall/не-200 — поведение НЕ поменялось: тихий `return False` + (best-effort деградация), как было до фикса. + +Плюс: GET на прогревочный search-URL шлётся с `Sec-Fetch-Site: cross-site` (через +`referer_headers()`) — до фикса session-level `DOCUMENT_HEADERS` default ("none") +молча переживал добавление Referer per-request. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from scraper_kit.avito_exceptions import AvitoWarmupCookiesMissingError +from scraper_kit.providers.avito.detail import ( + _AVITO_WARM_YANDEX_REFERER, + research_in_session, + warm_up_session, +) + +# warm_up_session спит random.uniform(2.0, 3.5) между yandex- и avito-GET (органический +# темп для anti-bot) — no-op в тестах, иначе сьют идёт секундами вместо миллисекунд. +_SLEEP = "scraper_kit.providers.avito.detail.asyncio.sleep" + + +def _fake_session( + status_code: int, text: str = "", cookie_names: tuple[str, ...] = () +) -> MagicMock: + resp = MagicMock() + resp.status_code = status_code + resp.text = text + session = MagicMock() + session.get = AsyncMock(return_value=resp) + session.cookies = {name: "1" for name in cookie_names} + return session + + +@pytest.fixture(autouse=True) +def _no_sleep(): + with patch(_SLEEP, new_callable=AsyncMock): + yield + + +# ── warm_up_session ────────────────────────────────────────────────────────── + + +async def test_warm_up_session_ok_with_antibot_cookies_returns_true() -> None: + session = _fake_session(200, cookie_names=("__zzatw-avito", "sx")) + assert await warm_up_session(session) is True + + +async def test_warm_up_session_ok_without_antibot_cookies_raises() -> None: + """Ровно #3034: страница отдалась 200/не-firewall, но никакой антибот-cookie + (только "обычные" сессионные) — раньше это тихо возвращало True.""" + session = _fake_session(200, cookie_names=("f", "ft", "luri")) + with pytest.raises(AvitoWarmupCookiesMissingError): + await warm_up_session(session) + + +async def test_warm_up_session_no_cookies_at_all_raises() -> None: + session = _fake_session(200, cookie_names=()) + with pytest.raises(AvitoWarmupCookiesMissingError): + await warm_up_session(session) + + +async def test_warm_up_session_blocked_page_returns_false_not_raise() -> None: + """Явный блок площадки (firewall) остаётся best-effort False, не raise — + неизменное поведение, отдельное от cookie-проверки.""" + session = _fake_session(200, text="доступ ограничен") + assert await warm_up_session(session) is False + + +async def test_warm_up_session_non_200_returns_false_not_raise() -> None: + session = _fake_session(403) + assert await warm_up_session(session) is False + + +async def test_warm_up_session_search_get_uses_cross_site_referer_headers() -> None: + session = _fake_session(200, cookie_names=("__zzatw-avito",)) + await warm_up_session(session) + + # Второй call — GET на _AVITO_WARM_SEARCH_URL (первый — GET на yandex, best-effort). + _, kwargs = session.get.call_args_list[-1] + assert kwargs["headers"] == { + "Referer": _AVITO_WARM_YANDEX_REFERER, + "Sec-Fetch-Site": "cross-site", + } + + +# ── research_in_session ────────────────────────────────────────────────────── + + +async def test_research_in_session_ok_with_antibot_cookies_returns_true() -> None: + session = _fake_session(200, cookie_names=("cfidsw-avito",)) + assert await research_in_session(session) is True + + +async def test_research_in_session_ok_without_antibot_cookies_raises() -> None: + session = _fake_session(200, cookie_names=("uxs_uid",)) + with pytest.raises(AvitoWarmupCookiesMissingError): + await research_in_session(session) + + +async def test_research_in_session_blocked_page_returns_false_not_raise() -> None: + session = _fake_session(429) + assert await research_in_session(session) is False diff --git a/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py b/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py index ea4711bf..a1633511 100644 --- a/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py +++ b/tradein-mvp/backend/tests/scripts/test_backfill_houses_dadata.py @@ -251,8 +251,12 @@ def test_update_house_enriched_passes_full_payload(): """UPDATE houses содержит lat/lon/cadnum/fias/qc-codes и dadata_enriched_at = NOW().""" db = MagicMock() res = _make_dadata_result( - qc_geo=0, qc_house=2, lat=56.838, lon=60.586, - cadnum="66:41:0704045:350", fias="fias-uuid-here", + qc_geo=0, + qc_house=2, + lat=56.838, + lon=60.586, + cadnum="66:41:0704045:350", + fias="fias-uuid-here", ) _update_house_enriched(db, house_id=42, result=res) @@ -304,9 +308,7 @@ async def test_run_backfill_qc_geo_0_writes_enriched_update(): "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=fake), ): - stats = await _run_backfill( - db, [(row, "coords")], batch="b1", dry_run=False - ) + stats = await _run_backfill(db, [(row, "coords")], batch="b1", dry_run=False) assert stats.enriched == 1 assert stats.no_change == 0 @@ -338,9 +340,7 @@ async def test_run_backfill_qc_geo_3_records_attempt_only(): "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=fake), ): - stats = await _run_backfill( - db, [(row, "coords")], batch="b2", dry_run=False - ) + stats = await _run_backfill(db, [(row, "coords")], batch="b2", dry_run=False) assert stats.enriched == 0 assert stats.no_change == 1 @@ -366,9 +366,7 @@ async def test_run_backfill_dadata_returns_none_records_attempt(): "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=None), ): - stats = await _run_backfill( - db, [(row, "coords")], batch="b3", dry_run=False - ) + stats = await _run_backfill(db, [(row, "coords")], batch="b3", dry_run=False) assert stats.no_change == 1 assert stats.enriched == 0 @@ -425,9 +423,7 @@ async def test_run_backfill_dry_run_skips_db_writes(): "scripts.backfill_houses_dadata.clean_address", new=AsyncMock(return_value=fake), ): - stats = await _run_backfill( - db, [(row, "coords")], batch="dry", dry_run=True - ) + stats = await _run_backfill(db, [(row, "coords")], batch="dry", dry_run=True) assert stats.processed == 1 assert stats.enriched == 1 diff --git a/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py b/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py index b94e1202..a7c05e8e 100644 --- a/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py +++ b/tradein-mvp/backend/tests/scripts/test_geocode_deals_from_houses.py @@ -105,10 +105,7 @@ def test_street_key_three_spec_variants_collapse_to_same_key(): def test_street_key_strips_region_and_house_number(): - assert ( - _street_key("Свердловская обл., Екатеринбург, ул. Большакова, 17") - == "большакова" - ) + assert _street_key("Свердловская обл., Екатеринбург, ул. Большакова, 17") == "большакова" def test_street_key_strips_district_marker(): @@ -139,10 +136,7 @@ def test_street_key_does_not_eat_name_starting_like_type_token(): def test_street_key_strips_korpus_and_kv_suffix(): assert _street_key("Екатеринбург, ул. Крауля, 48, корп. 2") == "крауля" - assert ( - _street_key("РФ, Свердловская обл., Екатеринбург, ул. Ленина, 5, кв. 12") - == "ленина" - ) + assert _street_key("РФ, Свердловская обл., Екатеринбург, ул. Ленина, 5, кв. 12") == "ленина" def test_street_key_does_not_eat_names_starting_like_apt_suffix(): diff --git a/tradein-mvp/backend/tests/services/test_dadata.py b/tradein-mvp/backend/tests/services/test_dadata.py index 4b9a7464..a7670fc8 100644 --- a/tradein-mvp/backend/tests/services/test_dadata.py +++ b/tradein-mvp/backend/tests/services/test_dadata.py @@ -781,12 +781,19 @@ def _mock_enrich_transport( async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None: - """403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен).""" + """403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен). + + #dadata-403-noise: это статичная конфигурация аккаунта (не транзиентный сбой) — + логируется на WARNING (не ERROR), чтобы ERROR продолжал значить «настоящий сбой» + (раньше — logger.error на КАЖДЫЙ пользовательский запрос, 164 события в проде). + """ from app.services import dadata + dadata._clean_disabled_warned = False # изоляция от порядка тестов (module-level throttle) + transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY) with _patch_settings(), _patch_async_client(transport): - with caplog.at_level(_logging.ERROR, logger="app.services.dadata"): + with caplog.at_level(_logging.WARNING, logger="app.services.dadata"): result = await dadata.clean_address("Екатеринбург, Малышева 4") assert result is None @@ -794,6 +801,32 @@ async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None: assert "Стандартизация" in text or "выключена" in text # Не должны обвинять токен при feature-disabled. assert "auth/secret rejected" not in text + # НЕ ERROR — статичная причина, не сбой (#dadata-403-noise). + assert not any(rec.levelno >= _logging.ERROR for rec in caplog.records) + + +async def test_clean_address_throttles_repeated_feature_disabled_warning(caplog) -> None: + """Второй (и далее) 403 CLEAN-disabled за один процесс → DEBUG, не повторный WARNING. + + #dadata-403-noise: без троттлинга WARNING на каждый /estimate так же шумит логи, + как раньше шумел ERROR — цель фикса теряется наполовину. + """ + from app.services import dadata + + dadata._clean_disabled_warned = False + + transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY) + with _patch_settings(), _patch_async_client(transport): + with caplog.at_level(_logging.DEBUG, logger="app.services.dadata"): + first = await dadata.clean_address("Екатеринбург, Малышева 4") + caplog.clear() + second = await dadata.clean_address("Екатеринбург, Ленина 10") + + assert first is None + assert second is None + # Второй вызов — НИ ОДНОГО WARNING/ERROR (только DEBUG или тише). + assert not any(rec.levelno >= _logging.WARNING for rec in caplog.records) + assert dadata._clean_disabled_warned is True async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None: diff --git a/tradein-mvp/backend/tests/services/test_location_index.py b/tradein-mvp/backend/tests/services/test_location_index.py index cee70c8c..9fa62103 100644 --- a/tradein-mvp/backend/tests/services/test_location_index.py +++ b/tradein-mvp/backend/tests/services/test_location_index.py @@ -173,9 +173,9 @@ def test_freshness_window_is_the_estimator_constant_not_a_copy() -> None: from app.services import estimator - assert "LISTINGS_FRESH_DAYS =" not in inspect.getsource( - lc - ), "константа скопирована в location_index — она должна ИМПОРТИРОВАТЬСЯ из estimator" + assert "LISTINGS_FRESH_DAYS =" not in inspect.getsource(lc), ( + "константа скопирована в location_index — она должна ИМПОРТИРОВАТЬСЯ из estimator" + ) assert lc.LISTINGS_FRESH_DAYS == estimator.LISTINGS_FRESH_DAYS diff --git a/tradein-mvp/backend/tests/services/test_proxy_egress.py b/tradein-mvp/backend/tests/services/test_proxy_egress.py index cf303b2b..b123bb9c 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_egress.py +++ b/tradein-mvp/backend/tests/services/test_proxy_egress.py @@ -1,4 +1,5 @@ -"""Offline-тесты резолвера egress-прокси по источнику (#2825, fail-closed #2616). +"""Offline-тесты резолвера egress-прокси по источнику (#2825, fail-closed #2616, +ban-history ранжирование доп. #2825 от 2026-08-13). Покрытие БЕЗ live-сети/БД: FakeSession эмулирует ДВА запроса над scrape_proxies + scrape_proxy_source_bans — основной SELECT кандидата (`_pick_candidate`) и, только @@ -6,11 +7,16 @@ scrape_proxy_source_bans — основной SELECT кандидата (`_pick_ "пул пуст" от "пул не пуст, все отсеяны". - выбирается небанненный прокси; - - забаненный ДЛЯ ИСТОЧНИКА не выбирается; + - забаненный ДЛЯ ИСТОЧНИКА (АКТИВНО, banned_until > now()) не выбирается; - забаненный для ДРУГОГО источника — выбирается (суть #2600 п.2: Авито банит IP, - Яндекс через тот же IP ходит чисто); - - при нескольких кандидатах — меньший consecutive_fails выигрывает; - - при равном consecutive_fails — более свежий last_ok_at выигрывает; + Яндекс через тот же IP ходит чисто) — включая случай, когда у него накопилась + ИСТОРИЯ банов по другому source: на ранжирование ДЛЯ ТЕКУЩЕГО source это не влияет; + - узел с историей банов (даже истёкшей) по ЭТОМУ source уступает чистому узлу без + истории, даже когда у чистого узла хуже consecutive_fails/last_ok_at; + - при равной истории (ban_count) — работает прежний tie-break: меньший + consecutive_fails, затем более свежий last_ok_at; + - активный бан (banned_until > now()) по-прежнему полностью исключает узел, вне + зависимости от ban_count; - пул ПУСТ (0 строк вообще) → легитимный fallback на settings.scraper_proxy_url, logger.WARNING с текстом «пуст»; - пул пуст И SCRAPER_PROXY_URL не задан → None (прямое подключение), WARNING; @@ -59,6 +65,14 @@ class FakeSession: for b in self.bans ) + def _ban_count(self, pid: int, source: str) -> int: + """COALESCE(b.ban_count, 0) семантика LEFT JOIN — история учитывается ДАЖЕ + если сама строка бана уже истекла (banned_until <= now(), ещё не спурженная).""" + for b in self.bans: + if b["proxy_id"] == pid and b["source"] == source: + return int(b.get("ban_count", 1)) + return 0 + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult: sql = str(stmt) p = params or {} @@ -67,7 +81,7 @@ class FakeSession: max_fails = p["max_fails"] source = p["source"] - if "pool_total" in sql: # _diagnose_no_candidate aggregate + if "pool_total" in sql: # _diagnose_no_candidate aggregate (активные баны, без истории) unhealthy = sum( 1 for r in self.rows if not r["enabled"] or r["consecutive_fails"] >= max_fails ) @@ -88,7 +102,9 @@ class FakeSession: ] ) - # _pick_candidate primary SELECT + # _pick_candidate primary SELECT — LEFT JOIN на bans по source: активный бан + # по-прежнему исключает узел (WHERE), а ban_count (в т.ч. от истёкшего бана) + # ранжирует прошедших фильтр: сначала без истории (0), затем по возрастанию. cands = [ r for r in self.rows @@ -98,12 +114,15 @@ class FakeSession: ] cands.sort( key=lambda r: ( + self._ban_count(r["id"], source), r["consecutive_fails"], -(r["last_ok_at"] or datetime.min.replace(tzinfo=UTC)).timestamp(), r["id"], ) ) - return _FakeResult([dict(r) for r in cands[:1]]) + return _FakeResult( + [{**r, "ban_count": self._ban_count(r["id"], source)} for r in cands[:1]] + ) def _proxy( @@ -207,6 +226,155 @@ def test_tiebreak_fresher_last_ok_at_wins_on_equal_fails() -> None: assert result == "http://u:p@fresh.local:8080" +def test_clean_node_beats_node_with_expired_ban_history_for_source() -> None: + """Замер на проде 2026-08-10: asocks-residential-1 отдавал 403 и cian, и avito, но + после истечения TTL всплывал первым, потому что consecutive_fails=0 у ОБОИХ узлов + и решал только свежий healthcheck. История (ban_count) ДОЛЖНА перевешивать даже + когда у банившегося узла лучше consecutive_fails/last_ok_at.""" + now = datetime.now(UTC) + db = FakeSession( + [ + _proxy( + 1, + consecutive_fails=0, + last_ok_at=now, # свежее всех — раньше выиграл бы по старому правилу + url="http://u:p@chronic.local:8080", + ), + _proxy( + 2, + consecutive_fails=1, + last_ok_at=now - timedelta(hours=3), + url="http://u:p@clean.local:8080", + ), + ], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "banned_until": now - timedelta(hours=1), # ИСТЁК, но ban_count остаётся + "ban_count": 4, + } + ], + ) + result = resolve_proxy_url(db, "avito") + assert result == "http://u:p@clean.local:8080" + + +def test_no_history_node_beats_node_with_ban_count_one() -> None: + """Узел БЕЗ ЕДИНОЙ строки истории (ban_count трактуется как 0) выигрывает у узла с + ban_count=1, даже при равном consecutive_fails/last_ok_at.""" + now = datetime.now(UTC) + db = FakeSession( + [ + _proxy(1, consecutive_fails=0, last_ok_at=now, url="http://u:p@once-banned.local:8080"), + _proxy( + 2, consecutive_fails=0, last_ok_at=now, url="http://u:p@never-banned.local:8080" + ), + ], + bans=[ + { + "proxy_id": 1, + "source": "cian", + "banned_until": now - timedelta(hours=2), + "ban_count": 1, + } + ], + ) + result = resolve_proxy_url(db, "cian") + assert result == "http://u:p@never-banned.local:8080" + + +def test_equal_ban_history_falls_back_to_prior_tiebreak() -> None: + """При РАВНОМ ban_count у обоих узлов -- прежний порядок tie-break (consecutive_fails, + затем last_ok_at) без изменений.""" + now = datetime.now(UTC) + db = FakeSession( + [ + _proxy( + 1, + consecutive_fails=2, + last_ok_at=now, + url="http://u:p@flaky-history.local:8080", + ), + _proxy( + 2, + consecutive_fails=0, + last_ok_at=now - timedelta(hours=1), + url="http://u:p@solid-history.local:8080", + ), + ], + bans=[ + { + "proxy_id": 1, + "source": "yandex", + "banned_until": now - timedelta(hours=5), + "ban_count": 2, + }, + { + "proxy_id": 2, + "source": "yandex", + "banned_until": now - timedelta(hours=5), + "ban_count": 2, + }, + ], + ) + result = resolve_proxy_url(db, "yandex") + # Равный ban_count=2 у обоих -- решает consecutive_fails (0 < 2). + assert result == "http://u:p@solid-history.local:8080" + + +def test_ban_history_on_other_source_does_not_affect_ranking() -> None: + """Высокий ban_count по source=cian у узла НЕ влияет на его ранжирование для + source=avito -- история строго per-source, ровно как активный бан (#2600 п.2).""" + now = datetime.now(UTC) + db = FakeSession( + [ + _proxy( + 1, + consecutive_fails=0, + last_ok_at=now, + url="http://u:p@cian-history-only.local:8080", + ), + _proxy( + 2, + consecutive_fails=0, + last_ok_at=now - timedelta(hours=2), + url="http://u:p@clean-everywhere.local:8080", + ), + ], + bans=[ + { + "proxy_id": 1, + "source": "cian", # ДРУГОЙ source, не avito + "banned_until": now - timedelta(hours=1), + "ban_count": 9, + } + ], + ) + result = resolve_proxy_url(db, "avito") + # Для avito у узла 1 ban_count=0 (истории по avito нет) -- выигрывает по last_ok_at. + assert result == "http://u:p@cian-history-only.local:8080" + + +def test_active_ban_still_excludes_regardless_of_ban_count() -> None: + """Активный бан по-прежнему полный фильтр -- ban_count=1 (низкий) не спасает узел + с АКТИВНЫМ баном от исключения.""" + now = datetime.now(UTC) + db = FakeSession( + [_proxy(1, url="http://u:p@actively-banned.local:8080")], + bans=[ + { + "proxy_id": 1, + "source": "avito", + "banned_until": now + timedelta(hours=6), + "ban_count": 1, + } + ], + ) + with pytest.raises(ProxyPoolExhaustedError): + resolve_proxy_url(db, "avito") + + def test_empty_pool_falls_back_to_env_with_warning( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tradein-mvp/backend/tests/services/test_proxy_rotation.py b/tradein-mvp/backend/tests/services/test_proxy_rotation.py index a311b7c9..b4fb5b0b 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_rotation.py +++ b/tradein-mvp/backend/tests/services/test_proxy_rotation.py @@ -538,9 +538,9 @@ async def test_token_never_appears_in_log_messages_or_sentry_text( await proxy_rotation.rotate_proxy(db, 1) # type: ignore[arg-type] for record in caplog.records: - assert ( - SECRET_TOKEN not in record.getMessage() - ), f"scenario={name}: token leaked into log message args" + assert SECRET_TOKEN not in record.getMessage(), ( + f"scenario={name}: token leaked into log message args" + ) assert sentry_texts, "expected at least one Sentry capture (401 scenario)" assert all(SECRET_TOKEN not in text for text in sentry_texts) diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index fe555229..aef1335e 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -60,6 +60,16 @@ tests/test_purge_expired_trade_in_data.py::test_real_purge_not_wedged_by_healthy # на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе. tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint +# Гейт номеров миграций (#2683) сверяется с origin/main и точкой ветвления. Где +# git-эталона нет — прогон внутри prod-образа, экспорт исходников без .git — +# проверять не с чем, и тест это ГОВОРИТ вслух вместо тихого зелёного. +# В CI пропуска не бывает: при CI/GITHUB_ACTIONS та же ветка делает pytest.fail +# (отсутствие эталона в пайплайне — сломанный гейт, а не «нечего проверять»), +# а ci-tradein.yml/deploy-tradein.yml берут checkout с fetch-depth: 0 — при нём +# checkout сам приносит refs/remotes/origin/*, отдельный git fetch не нужен и +# из job-контейнера всё равно не проходит (run 6977, connection refused). +tests/test_migration_numbering.py::test_applied_migration_is_not_renamed_or_deleted +tests/test_migration_numbering.py::test_new_migration_takes_a_free_number # Повтор застрявших transient_error (#2674, PR #2843) — тот же `_live_session()`. # Проверяют ВЫБОРКУ очереди на живой схеме (кто попал в пакет прогона), а не текст # SQL: на мок-лэйне deploy-tradein.yml БД нет вовсе. В ci-tradein.yml они бегут @@ -69,3 +79,53 @@ tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_ tests/test_house_imv_retry_stuck.py::test_explicit_only_status_still_takes_exhausted_houses tests/test_house_imv_retry_stuck.py::test_stuck_transient_house_returns_to_the_queue_by_itself tests/test_house_imv_retry_stuck.py::test_transient_attempts_counter_only_counts_transient + +# MAJOR-1 fix, coverage probe (#2894, независимый ревью) — тот же `live_session` fixture +# (self-skip через `_live_db_available()`, живёт только при реальном Postgres DSN). +# Проверяет, что novostroyki-строка / geo_precision='city'-строка / price_rub=0-строка +# физически не попадают в когорту (не только SQL-текст, который проверяется отдельным +# статическим тестом test_cohort_sql_excludes_* в этом же файле, идущим на обоих лэйнах). +tests/test_coverage_probe_endpoint.py::test_major1_cohort_excludes_novostroyki_and_city_precision_live + +# MAJOR-2 поведенческий пин (повторная проверка #2894) — та же `live_session` fixture. +# Ловит мутацию «убрать FILTER у percentile_cont, оставив у count(*)», которую +# текстовый тест test_max_age_outlier_days_passed_to_sql пропускал (подстрока +# `days_on_market <= :max_age_days` встречается в SQL дважды). На мок-лэйне +# (deploy-tradein.yml, DSN-заглушка) реальной БД нет — self-skip. +tests/test_coverage_probe_endpoint.py::test_max_age_outlier_excluded_from_median_live + +# #2992 — гейт неизменной строки в апсертах listings / listing_sources. Шесть live-тестов +# идут через реальный save_listings / upsert_listing_source на живом Postgres и меряют, +# переписана ли строка, по ctid (save_listings коммитит — транзакционные счётчики +# бесполезны). Без БД — skip; в CI Trade-In идут на postgres-сервисе (#2745). Свои +# строки t2992-* тесты удаляют явно в finally. +tests/test_2992_upsert_unchanged_gate.py::test_unchanged_rescrape_same_day_does_not_update_the_row +tests/test_2992_upsert_unchanged_gate.py::test_changed_price_still_updates +tests/test_2992_upsert_unchanged_gate.py::test_coalesce_backfill_still_updates +tests/test_2992_upsert_unchanged_gate.py::test_next_day_rescrape_updates_even_if_unchanged +tests/test_2992_upsert_unchanged_gate.py::test_skipped_row_still_yields_listing_id_for_downstream +tests/test_2992_upsert_unchanged_gate.py::test_listing_sources_unchanged_rescrape_same_day_does_not_update +# #3036 — house-поля с детальной страницы Авито → houses (fill-only). Live-тест ходит в +# настоящую БД (в CI она есть, #2745), локально без TEST_DATABASE_URL пропускается. Строки +# t3036-* тест удаляет в finally. +tests/test_3036_detail_house_params_to_houses.py::test_live_fill_only_then_keep_then_unlinked_untouched + +# PR-A LATERAL-поиск предшественника в поле переобхода (#2659 продолжение) — тот же +# `_live_session()`. Три live-теста вставляют синтетическую историю снимков (в т.ч. +# с дырами) в ОДНОЙ транзакции и проверяют результат JOIN LATERAL/equality-join +# прямым SELECT'ом в той же транзакции — cleanup через rollback (без commit), явный +# DELETE не нужен. Без БД — skip; в CI Trade-In идут на postgres-сервисе (#2745). +tests/test_revisit_floor_lateral_lookup.py::test_lateral_ignores_gaps_and_matches_dense_daily_history +tests/test_revisit_floor_lateral_lookup.py::test_equality_join_loses_gapped_pairs_and_gives_a_smaller_floor +tests/test_revisit_floor_lateral_lookup.py::test_missing_history_before_anchor_still_yields_no_floor_via_lateral + +# Эрозия признаков продавца (#3063, PR #3067) — тот же `_live_session()`. +# Проверяют ПОВЕДЕНИЕ апсерта на живой схеме: что бедный re-scrape не обнуляет +# is_homeowner/is_pro_seller/phones/sale_type (COALESCE держит), и обратное — что +# настоящая смена признака всё ещё перезаписывает (COALESCE не сделал поля +# write-once). SQL-текст проверяется отдельно статической половиной того же файла +# (test_field_is_coalesce_protected_in_set + test_unchanged_gate_mirrors_the_set_clause), +# она идёт на ОБОИХ лэйнах. В ci-tradein.yml эти два бегут по-настоящему +# (postgres-сервис) — там прогон и был зелёным; в deploy-tradein.yml БД нет вовсе. +tests/test_3063_seller_fields_not_eroded.py::test_poor_rescrape_does_not_erase_seller_fields +tests/test_3063_seller_fields_not_eroded.py::test_real_change_still_overwrites diff --git a/tradein-mvp/backend/tests/support/identity_modes.py b/tradein-mvp/backend/tests/support/identity_modes.py index 73c5672a..66d885ec 100644 --- a/tradein-mvp/backend/tests/support/identity_modes.py +++ b/tradein-mvp/backend/tests/support/identity_modes.py @@ -111,8 +111,7 @@ def assert_reads_access_state(sql: str, names: SqlNames) -> None: expected = f"{names.access_state_column} AS access_state" if expected not in sql: raise AssertionError( - f"запрос к реестру не читает колонку состояния текущего режима " - f"({expected!r}): {sql!r}" + f"запрос к реестру не читает колонку состояния текущего режима ({expected!r}): {sql!r}" ) diff --git a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py index 0e786ffb..fe362774 100644 --- a/tradein-mvp/backend/tests/tasks/test_geocode_missing.py +++ b/tradein-mvp/backend/tests/tasks/test_geocode_missing.py @@ -680,9 +680,9 @@ def test_estimator_fetch_analogs_includes_avito() -> None: from app.services import estimator source = inspect.getsource(estimator._fetch_analogs) - assert ( - "source <> 'avito'" not in source - ), "Avito exclusion должен быть удалён из estimator._fetch_analogs" + assert "source <> 'avito'" not in source, ( + "Avito exclusion должен быть удалён из estimator._fetch_analogs" + ) # ── Admin endpoint smoke (schema-only, no real DB) ──────────────────────────── diff --git a/tradein-mvp/backend/tests/tasks/test_yandex_address_backfill.py b/tradein-mvp/backend/tests/tasks/test_yandex_address_backfill.py index 2e59eb54..ff51444b 100644 --- a/tradein-mvp/backend/tests/tasks/test_yandex_address_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_yandex_address_backfill.py @@ -38,9 +38,9 @@ def test_extract_address_standard_title() -> None: ) addr = _extract_address_from_title(html) assert addr is not None, "Should extract address from standard title" - assert _RE_HAS_HOUSE_NUMBER.search( - addr - ), f"Extracted address should contain house number: {addr!r}" + assert _RE_HAS_HOUSE_NUMBER.search(addr), ( + f"Extracted address should contain house number: {addr!r}" + ) assert "Екатеринбург" in addr assert "Горького" in addr assert "36" in addr @@ -54,9 +54,9 @@ def test_extract_address_with_zhk_prefix() -> None: ) addr = _extract_address_from_title(html) assert addr is not None, "Should extract address when ЖК prefix is present" - assert _RE_HAS_HOUSE_NUMBER.search( - addr - ), f"Extracted address should contain house number: {addr!r}" + assert _RE_HAS_HOUSE_NUMBER.search(addr), ( + f"Extracted address should contain house number: {addr!r}" + ) assert "Екатеринбург" in addr @@ -97,7 +97,7 @@ def test_extract_address_beryozovsky_with_zhk() -> None: def test_extract_address_ekb_no_zhk_still_matches() -> None: """EKB variant from docstring (no ЖК prefix) still matches after city-agnostic change.""" - html = "Продажа квартиры — Екатеринбург, улица Горького, 36" " — id 7654321" + html = "Продажа квартиры — Екатеринбург, улица Горького, 36 — id 7654321" addr = _extract_address_from_title(html) assert addr is not None, "EKB no-ЖК title should still match" assert _RE_HAS_HOUSE_NUMBER.search(addr), f"Should contain house number: {addr!r}" @@ -116,9 +116,9 @@ def test_extract_address_no_house_number_guard() -> None: addr = _extract_address_from_title(html) # If the regex matches at all, the extracted value must not contain a house number. if addr is not None: - assert not _RE_HAS_HOUSE_NUMBER.search( - addr - ), f"Street-only addr must fail house-number guard: {addr!r}" + assert not _RE_HAS_HOUSE_NUMBER.search(addr), ( + f"Street-only addr must fail house-number guard: {addr!r}" + ) def test_extract_address_nbsp_replaced() -> None: @@ -129,9 +129,9 @@ def test_extract_address_nbsp_replaced() -> None: ) addr = _extract_address_from_title(html) assert addr is not None, "\\xa0 should be replaced before regex" - assert _RE_HAS_HOUSE_NUMBER.search( - addr - ), f"Extracted address should contain house number: {addr!r}" + assert _RE_HAS_HOUSE_NUMBER.search(addr), ( + f"Extracted address should contain house number: {addr!r}" + ) def test_extract_address_no_title_returns_none() -> None: diff --git a/tradein-mvp/backend/tests/test_1781_secondary_only_param.py b/tradein-mvp/backend/tests/test_1781_secondary_only_param.py new file mode 100644 index 00000000..c4d474ab --- /dev/null +++ b/tradein-mvp/backend/tests/test_1781_secondary_only_param.py @@ -0,0 +1,123 @@ +"""`secondary_only` — параметр расписания, а не хардкод; выброшенное считается (#1781). + +`run_cian_full_load` передавал `secondary_only=True` жёстко (`pipeline.py:3328`), поэтому +включить новостройки в полный обход можно было только деплоем. При этом новостройки +НЕ пропускаются при запросе — они скачиваются, разбираются и выбрасываются последним +шагом (`cian/serp.py:671`), потому что SERP-параметр `object_type=1` у Cian ненадёжен +(~5 % выдачи) и фильтруют по authoritative `listing_segment` уже после парсинга. + +Следствие, проверенное по коду: проба бакета берёт `totalOffers` из Redux-состояния +SERP и считает `pages_needed = ceil(totalOffers / offers_per_page)`; `totalOffers` +включает обе категории. То есть страницы с новостройками уже скачаны, лимит страниц и +антибан-бюджет за них уже заплачены — включение стоит НОЛЬ дополнительных запросов. + +Дефолт НЕ меняется: `True`, поведение прода прежнее. Меняется только то, что решение +становится правкой одной ячейки `scrape_schedules.default_params`, а не деплоем. + +Замер прода 21.08.2026, ради которого это и делается: месячный охват свипа +cian/novostroyki — 11.7 % против 100 % у cian/vtorichka и avito/novostroyki; +11 993 активные строки, медианный возраст 81 сутки. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import inspect + + +def test_full_load_no_longer_hardcodes_secondary_only() -> None: + """Головной: значение берётся из параметра, а не из литерала. + + На origin/main в теле стоит `secondary_only=True` — включить новостройки + можно только правкой кода. + """ + from scraper_kit.orchestration import pipeline + + src = inspect.getsource(pipeline.run_cian_full_load) + assert "secondary_only=secondary_only" in src, "значение не пробрасывается из параметра" + assert "secondary_only=True," not in src, ( + "в теле остался хардкод — расписание на него повлиять не сможет" + ) + + +def test_default_is_unchanged() -> None: + """Контроль: дефолт остаётся True — поведение прода не меняется этим PR. + + Без этого контроля правка «сделать параметром» могла бы тихо включить сбор + новостроек на проде, а это отдельное решение с замером (#1781, #2994). + """ + from scraper_kit.orchestration.pipeline import run_cian_full_load + + params = inspect.signature(run_cian_full_load).parameters + assert "secondary_only" in params, ( + "параметра нет в сигнатуре — значение задано в теле, расписание на него " + f"повлиять не может; параметры: {list(params)}" + ) + default = params["secondary_only"].default + assert default is True, f"дефолт изменён на {default!r} — это не входило в правку" + + +def test_admin_request_exposes_the_flag_with_same_default() -> None: + """Параметр доезжает до расписания: у эндпоинта есть поле с тем же дефолтом.""" + from app.api.v1.admin import CianFullLoadRequest + + поля = CianFullLoadRequest.model_fields + assert "secondary_only" in поля, ( + "поля нет в запросе — параметр не доедет из scrape_schedules.default_params; " + f"поля: {sorted(поля)}" + ) + assert поля["secondary_only"].default is True + req = CianFullLoadRequest() + assert req.secondary_only is True + assert CianFullLoadRequest(secondary_only=False).secondary_only is False + + +def test_counters_persist_dropped_novostroyki() -> None: + """Выброшенное обязано сохраняться, а не только логироваться. + + Счётчик `dropped_nb` печатался в лог, но в `scrape_runs.counters` не попадал, и + ответить «сколько инвентаря выбрасывает полный обход» задним числом было нечем: + логи за нужную дату уже ротировались. Тот же довод, по которому рядом заведён + `partial_buckets`. + """ + from scraper_kit.orchestration.pipeline import CianFullLoadCounters + + c = CianFullLoadCounters() + assert "dropped_novostroyki" in c.to_dict(), c.to_dict() + c.dropped_novostroyki = 7 + assert c.to_dict()["dropped_novostroyki"] == 7 + + +def test_scraper_resets_the_counter_per_run() -> None: + """Контроль: счётчик сбрасывается на каждый прогон. + + Инстанс скрапера переиспользуется; без сброса второй прогон унаследовал бы + число первого и записал бы в counters завышенное значение. + """ + from scraper_kit.providers.cian.serp import CianScraper + + src = inspect.getsource(CianScraper.fetch_all_secondary) + assert "self.last_dropped_nb = 0" in src, "нет сброса в начале прогона" + assert "self.last_dropped_nb += " in inspect.getsource(CianScraper._paginate_leaf_bucket), ( + "накопление не там, где считается dropped_nb" + ) + assert getattr(CianScraper, "last_dropped_nb", None) == 0, ( + "нет класс-дефолта: атрибут не прочитается, если прогон упал до первого бакета" + ) + + +def test_filter_still_drops_when_flag_is_on() -> None: + """Контроль от переусердствования: при secondary_only=True фильтр остаётся. + + Правка делает флаг управляемым, а не отменяет его. + """ + from scraper_kit.providers.cian.serp import CianScraper + + # Фильтр лежит в _paginate_leaf_bucket — том методе, что собирает страницы + # бакета; fetch_all_secondary только раздаёт флаг вниз. + src = inspect.getsource(CianScraper._paginate_leaf_bucket) + assert 'lot.listing_segment != "novostroyki"' in src, "фильтр пропал" + assert "if secondary_only:" in src, "фильтр перестал зависеть от флага" diff --git a/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py b/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py index 188b185d..0a9c69aa 100644 --- a/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py +++ b/tradein-mvp/backend/tests/test_2674_writers_honor_schema.py @@ -142,9 +142,9 @@ def test_house_suggestions_insert_covers_every_declared_column() -> None: (_SQL_DIR / "064_house_imv_phase_c.sql").read_text("utf-8"), "house_suggestions" ) written = _insert_columns(inspect.getsource(hib.save_imv_result), "house_suggestions") - assert ( - declared - {"id"} <= written - ), f"колонки без писателя: {sorted(declared - {'id'} - written)}" + assert declared - {"id"} <= written, ( + f"колонки без писателя: {sorted(declared - {'id'} - written)}" + ) def test_save_imv_result_binds_image_link_and_metrics() -> None: diff --git a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py index a6fb3820..8384a28a 100644 --- a/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py +++ b/tradein-mvp/backend/tests/test_2830_pool_bypass_tails.py @@ -147,9 +147,9 @@ async def test_price_history_takes_pool_node_despite_flag_off() -> None: """ from app.core.config import settings - assert ( - settings.use_proxy_pool_curl is False - ), "тест обязан идти тем же путём, что прод-контейнер backend: без USE_PROXY_POOL_CURL" + 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"] diff --git a/tradein-mvp/backend/tests/test_2860_yandex_sweep_run_status.py b/tradein-mvp/backend/tests/test_2860_yandex_sweep_run_status.py new file mode 100644 index 00000000..fb10bf8e --- /dev/null +++ b/tradein-mvp/backend/tests/test_2860_yandex_sweep_run_status.py @@ -0,0 +1,94 @@ +"""#2860: обход, не разрешивший ни одного дома, не должен числиться успешным. + +Четырнадцать прогонов подряд (16.07-17.08.2026) стояли `status='done'` с пустым +`error_text` при счётчиках `processed 5, succeeded 0`. Исключения не было — +поэтому `mark_done`; но прогон не сделал ничего из заявленного, очередь за это +время выросла 351 → 397, а витрина `market.yandex_jk_enrichment` замерла на +34 строках с 15.07. + +Счётчик при этом был честный. Не хватало вывода из него — ровно тот класс, +который эпик #2674 называет самым частым: «механизм исполняется, счётчик +честный, вывод из него никто не делает». + +НЕ подгоняем succeeded: если дома не разрешаются, честный исход — назвать +прогон неуспешным, а не дотянуть счётчик до зелёного. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# Settings требует DATABASE_URL на импорте; соседние тесты хендлеров делают так же +# (см. test_scrape_skip_visibility.py) — коннекта не будет, нужен только парс. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from app.services.product_handlers import _job_yandex_newbuilding_sweep + + +def _ctx() -> SimpleNamespace: + return SimpleNamespace(runs=MagicMock()) + + +def _result(processed: int, succeeded: int) -> MagicMock: + r = MagicMock() + r.to_dict = MagicMock( + return_value={"processed": processed, "succeeded": succeeded, "total": 425} + ) + return r + + +@pytest.mark.asyncio +async def test_zero_resolved_marks_run_failed() -> None: + """processed > 0, succeeded == 0 → прогон неуспешен, причина названа.""" + ctx = _ctx() + with patch( + "app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep", + AsyncMock(return_value=_result(5, 0)), + ): + await _job_yandex_newbuilding_sweep(MagicMock(), 4156, {}, ctx) + + ctx.runs.mark_done.assert_not_called() + ctx.runs.mark_failed.assert_called_once() + reason = ctx.runs.mark_failed.call_args[0][2] + assert "разрешено 0" in reason, reason + + +@pytest.mark.asyncio +async def test_partial_success_still_done() -> None: + """Контроль: хотя бы один разрешённый дом — прогон по-прежнему успешен. + + Зелёный с обеих сторон правки. Без него правка могла бы объявить неуспешным + любой неполный прогон — а `succeeded: 1 из 5` был нормой в июле, когда + механизм работал. + """ + ctx = _ctx() + with patch( + "app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep", + AsyncMock(return_value=_result(5, 1)), + ): + await _job_yandex_newbuilding_sweep(MagicMock(), 1489, {}, ctx) + + ctx.runs.mark_failed.assert_not_called() + ctx.runs.mark_done.assert_called_once() + + +@pytest.mark.asyncio +async def test_empty_queue_is_not_a_failure() -> None: + """Контроль: обрабатывать было нечего (processed == 0) — это не отказ. + + Иначе гейт краснел бы каждый раз, когда очередь разобрана до конца, то есть + ровно в успешном состоянии. + """ + ctx = _ctx() + with patch( + "app.tasks.yandex_newbuilding_sweep.enrich_yandex_newbuilding_sweep", + AsyncMock(return_value=_result(0, 0)), + ): + await _job_yandex_newbuilding_sweep(MagicMock(), 9999, {}, ctx) + + ctx.runs.mark_failed.assert_not_called() + ctx.runs.mark_done.assert_called_once() diff --git a/tradein-mvp/backend/tests/test_2899_market_percentile.py b/tradein-mvp/backend/tests/test_2899_market_percentile.py new file mode 100644 index 00000000..4985ea0c --- /dev/null +++ b/tradein-mvp/backend/tests/test_2899_market_percentile.py @@ -0,0 +1,101 @@ +"""#2899: позиция объекта внутри когорты аналогов (перцентиль ранга). + +Макет показывает бейдж «Верх рынка» — позицию квартиры в распределении аналогов. +Поля не было ни в схеме, ни в расчёте. + +Порог MARKET_PERCENTILE_MIN_N=15 взят из замера 19.08.2026 (Монте-Карло на 54 +прод-когортах): при выборке в 5 лотов средняя ошибка ранга 13.4 пп и ярлык +«низ/рынок/верх» перепутан в 26.4% случаев; при 15 — 7.6 пп. Порог 5 от +HEADLINE_LISTINGS_MIN_N переиспользовать нельзя: тот отвечает на другой вопрос — +устойчива ли МЕДИАНА, а она устойчива там, где ранг ещё пляшет. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from app.services.estimator import ( + MARKET_PERCENTILE_MIN_N, + _market_percentile, +) + + +def _pool(prices: list[float]) -> list[dict]: + return [{"price_per_m2": p} for p in prices] + + +# ── порог ──────────────────────────────────────────────────────────────────── + + +def test_below_threshold_returns_none() -> None: + """Когорта на один лот меньше порога — позиции нет, а не «примерно такая».""" + prices = [100_000 + i * 1_000 for i in range(MARKET_PERCENTILE_MIN_N - 1)] + assert _market_percentile(120_000, _pool(prices)) is None + + +def test_at_threshold_returns_number() -> None: + """Ровно на пороге — считаем. Граница включающая, без «почти хватило».""" + prices = [100_000 + i * 1_000 for i in range(MARKET_PERCENTILE_MIN_N)] + assert _market_percentile(200_000, _pool(prices)) == 99 + + +def test_threshold_counts_only_priced_lots() -> None: + """Лоты без цены в счёт не идут: 14 ценовых + 5 пустых — это 14, а не 19. + + Радиусная ветка отбирает `price_per_m2`-лоты сама, но якорные комплы приходят + как есть, и без этой проверки порог обошёлся бы пустышками. + """ + prices = [{"price_per_m2": 100_000 + i} for i in range(MARKET_PERCENTILE_MIN_N - 1)] + empty = [{"price_per_m2": None} for _ in range(5)] + assert _market_percentile(100_000, prices + empty) is None + + +# ── сама позиция ───────────────────────────────────────────────────────────── + + +def test_median_object_lands_mid_scale() -> None: + """Объект ровно по медиане симметричной когорты → около 50.""" + prices = [float(x) for x in range(100, 100 + 21)] # 100..120, медиана 110 + assert _market_percentile(110.0, _pool(prices)) == 50 + + +def test_expensive_object_is_high() -> None: + prices = [float(x) for x in range(100, 100 + 20)] + assert _market_percentile(1000.0, _pool(prices)) == 99 + + +def test_cheap_object_is_low() -> None: + prices = [float(x) for x in range(100, 100 + 20)] + assert _market_percentile(1.0, _pool(prices)) == 1 + + +def test_edges_are_clamped_not_zero_or_hundred() -> None: + """1..99, а не 0..100. + + «0-й перцентиль» читается как «дешевле всех на свете», хотя означает лишь край + конкретной выборки из 20 лотов. Край выборки — не край рынка. + """ + prices = [float(x) for x in range(100, 100 + 20)] + assert _market_percentile(0.0, _pool(prices)) == 1 + assert _market_percentile(1e9, _pool(prices)) == 99 + + +def test_ties_count_as_half() -> None: + """Равные цены дают midrank: 10 дешевле, 10 равных → 50, а не 33 и не 66.""" + prices = [100.0] * 10 + [200.0] * 10 + assert _market_percentile(200.0, _pool(prices)) == 75 + prices2 = [200.0] * 20 + assert _market_percentile(200.0, _pool(prices2)) == 50 + + +# ── вырожденные входы ──────────────────────────────────────────────────────── + + +def test_no_price_returns_none() -> None: + assert _market_percentile(None, _pool([float(x) for x in range(100, 120)])) is None + + +def test_empty_pool_returns_none() -> None: + assert _market_percentile(100_000.0, []) is None diff --git a/tradein-mvp/backend/tests/test_2924_yandex_resolve_tried_at.py b/tradein-mvp/backend/tests/test_2924_yandex_resolve_tried_at.py new file mode 100644 index 00000000..fbb26d18 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2924_yandex_resolve_tried_at.py @@ -0,0 +1,180 @@ +"""Неудача резолва yandex_jk_slug помечает дом, а очередь не упирается в те же пять (#2924). + +`_SELECT_PENDING_HOUSES` брал ожидающие дома `ORDER BY h.id` и уходил из очереди только +попавший в витрину. Неудачная попытка не писала ничего — ни времени, ни счётчика, — и +следующий прогон брал те же дома в том же порядке. Замер 19.08: 397 ожидающих, первые +пять по h.id (6706, 6741, 6757, 6774, 6785) занимали все пять слотов каждый прогон. + +Маркер — `houses.yandex_jk_resolve_tried_at` (миграция 269), по образцу +`listings.geocode_tried_at`: выборка не берёт дом раньше чем через RESOLVE_RETRY_DAYS, +а неудавшиеся идут в КОНЕЦ очереди (`NULLS FIRST`). + +Тесты идут через реальный `enrich_yandex_newbuilding_sweep` с двойником сессии: он отдаёт +заданные строки на SELECT и запоминает весь выполненный SQL. Резолвер патчится на None — +это «неудача», ради которой маркер и нужен. На origin/main маркер не пишется ни разу. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from contextlib import contextmanager +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +class _Result: + def __init__(self, rows: list[dict], scalar: Any = 0) -> None: + self._rows = rows + self._scalar = scalar + + def mappings(self) -> _Result: + return self + + def all(self) -> list[dict]: + return self._rows + + def fetchone(self) -> Any: + return self._rows[0] if self._rows else None + + def first(self) -> Any: + return self._rows[0] if self._rows else None + + def scalar(self) -> Any: + return self._scalar + + def scalar_one(self) -> Any: + return self._scalar + + def scalar_one_or_none(self) -> Any: + return self._scalar + + +class _Session: + """Отдаёт pending-строки на SELECT и запоминает все (sql, params).""" + + def __init__(self, pending: list[dict]) -> None: + self._pending = pending + self.sql: list[tuple[str, dict]] = [] + + def execute(self, statement: Any = None, params: Any = None, *a: Any, **kw: Any) -> _Result: + text_ = str(statement) + self.sql.append((text_, dict(params or {}))) + # Выборка ожидающих — единственный запрос с DISTINCT ON (h.id); COUNT-запросы + # тоже читают houses h + yandex_realty_nb, их путать с выборкой нельзя. + if "DISTINCT ON (h.id)" in text_: + return _Result(self._pending) + return _Result([]) + + def begin_nested(self) -> Any: + sp = MagicMock() + + @contextmanager + def _cm(): + yield sp + + # код зовёт и `sp = db.begin_nested(); sp.commit()` и `with db.begin_nested():` + sp.__enter__ = lambda *_: sp + sp.__exit__ = lambda *_: False + return sp + + def commit(self) -> None: + pass + + def rollback(self) -> None: + pass + + +async def _run(pending: list[dict], *, force: bool = False, resolved: str | None = None): + from app.tasks import yandex_newbuilding_sweep as mod + + db = _Session(pending) + # Резолвер и конфиг импортируются ВНУТРИ функции (late import) — патчим модуль-источник. + with ( + patch( + "scraper_kit.providers.yandex.newbuilding.resolve_yandex_jk_slug", + AsyncMock(return_value=resolved), + ), + patch.object(mod, "_sleep_with_jitter", AsyncMock(return_value=None)), + patch("app.services.scraper_adapters.RealScraperConfig", MagicMock()), + ): + await mod.enrich_yandex_newbuilding_sweep( + db, city="ekaterinburg", limit=5, force=force, request_delay_sec=0 + ) + return db + + +def _tried_updates(db: _Session) -> list[int]: + return [ + int(p["hid"]) + for s, p in db.sql + if "UPDATE houses" in s and "yandex_jk_resolve_tried_at" in s and "hid" in p + ] + + +def _select_sql(db: _Session) -> str: + for s, _ in db.sql: + if "DISTINCT ON (h.id)" in s: + return s + raise AssertionError("выборка ожидающих домов не выполнялась") + + +def _select_params(db: _Session) -> dict: + for s, p in db.sql: + if "DISTINCT ON (h.id)" in s: + return p + raise AssertionError("выборка ожидающих домов не выполнялась") + + +@pytest.mark.asyncio +async def test_failed_resolve_marks_the_house() -> None: + """Головной: резолв не удался → дом получает yandex_jk_resolve_tried_at. + + На origin/main такого UPDATE нет вовсе — дом останется первым в очереди навсегда. + """ + db = await _run( + [{"house_id": 6706, "yandex_jk_slug": None, "yandex_jk_id": None, "ext_id": "286394"}] + ) + assert _tried_updates(db) == [6706], ( + f"неудача резолва не помечена; выполненный SQL: {[s[:50] for s, _ in db.sql]}" + ) + + +@pytest.mark.asyncio +async def test_successful_resolve_does_not_mark() -> None: + """Контроль: удачный резолв маркер НЕ ставит — иначе удачные дома тоже уходили + бы в конец очереди и ждали бы неделю до обогащения.""" + db = await _run( + [{"house_id": 6706, "yandex_jk_slug": None, "yandex_jk_id": None, "ext_id": "286394"}], + resolved="uspenskij", + ) + assert _tried_updates(db) == [], "маркер поставлен при удачном резолве" + + +@pytest.mark.asyncio +async def test_selection_skips_recently_tried_and_pushes_tried_to_the_end() -> None: + """Выборка: не берёт дом, у которого попытка свежее RESOLVE_RETRY_DAYS, и ставит + пробованные в конец (NULLS FIRST). Проверяется по тексту запроса, который реально + ушёл в БД, и по параметрам — retry_days доезжает, а не захардкожен в строке.""" + db = await _run([]) + sql = _select_sql(db) + assert "yandex_jk_resolve_tried_at IS NULL" in sql, "нет фильтра по маркеру" + assert ":retry_days" in sql, "срок повтора не параметризован" + assert "yandex_jk_resolve_tried_at NULLS FIRST" in sql, "пробованные не уходят в конец очереди" + assert _select_params(db).get("retry_days") == 7, _select_params(db) + + +@pytest.mark.asyncio +async def test_force_bypasses_the_marker() -> None: + """Контроль: force=True игнорирует маркер — ручной полный проход остаётся возможным.""" + db = await _run([], force=True) + sql = _select_sql(db) + # Тот же OR-блок, что и у гейта «уже обогащён»: CAST(:force AS boolean) = TRUE OR … + assert sql.count("CAST(:force AS boolean) = TRUE") >= 2, ( + "маркер не обходится через force — второго OR-блока с :force нет" + ) + assert _select_params(db).get("force") is True diff --git a/tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py b/tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py new file mode 100644 index 00000000..db4cd3f4 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2936_unknown_attr_penalty.py @@ -0,0 +1,139 @@ +"""Неизвестный year_built / house_type перестаёт считаться идеальным совпадением (#2936). + +SQL-формула relevance_score даёт кандидату без year_built штраф 0 — столько же, +сколько точному попаданию, и ЛУЧШЕ, чем известному году с разницей 24 (2.0). +Отсутствие данных выигрывает у знания, и это возвышает источник с худшей +полнотой: avito (год 44 %, тип 0 %) берёт 47 % слотов топ-20 при 21 % доли в пуле. + +Флаг, а не правка формулы: влияние на цену измерено дважды — смещения нет, есть +шум (39 % целей сдвигаются >5 %). Какой отбор ТОЧНЕЕ — отвечает только бэктест на +сделках, и включать можно только по его итогу (тот же контракт, что у #2012). + +Механизм: NULL получает МЕДИАННЫЙ по пулу штраф того же признака среди кандидатов, +у которых он известен, — не наказание и не награда. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services.estimator import _apply_unknown_attr_penalty + + +def _pool() -> list[dict]: + # Штрафы за год при target=2000: 0.0 / 1.0 / 2.0 / 3.0 / 4.0 → медиана 2.0. + # Типы при target='panel': известные — 3 совпадения (0) и 2 несовпадения (1.5) → + # медиана 0.0; добавим ещё одно несовпадение, чтобы медиана стала ненулевой. + return [ + {"id": 1, "year_built": 2000, "house_type": "panel", "relevance_score": 0.5}, + {"id": 2, "year_built": 2012, "house_type": "panel", "relevance_score": 0.5}, + {"id": 3, "year_built": 2024, "house_type": "brick", "relevance_score": 0.5}, + {"id": 4, "year_built": 1964, "house_type": "brick", "relevance_score": 0.5}, + {"id": 5, "year_built": 1952, "house_type": "brick", "relevance_score": 0.5}, + {"id": 6, "year_built": None, "house_type": None, "relevance_score": 0.5}, # NULL + {"id": 7, "year_built": 2000, "house_type": None, "relevance_score": 0.5}, # NULL тип + ] + + +def test_null_year_gets_pool_median_penalty() -> None: + """Головной: NULL-год получает медиану штрафов известных, а не 0. + + На origin/main функции нет — кандидат 6 остаётся с 0.5 и обгоняет + кандидатов 3–5 с известным годом. + """ + pool = _pool() + _apply_unknown_attr_penalty(pool, target_year=2000, target_house_type=None, min_n=5) + by = {c["id"]: c["relevance_score"] for c in pool} + # Известных годов ШЕСТЬ (ид. 1,2,3,4,5 и 7): штрафы 0,1,2,3,4 и 0 → медиана 1.5. + # (Первая редакция теста считала пятерых и ждала 2.0 — арифметика, не код.) + assert by[6] == 0.5 + 1.5, ( + f"NULL-год должен получить медианный штраф 1.5, получил {by[6] - 0.5}" + ) + assert by[1] == 0.5 and by[3] == 0.5, ( + "известный год не должен трогаться — его уже оштрафовал SQL" + ) + + +def test_null_house_type_gets_pool_median_penalty() -> None: + """NULL-тип: медиана штрафов известных типов (3×0, 3×1.5 → медиана 0.75).""" + pool = _pool() + # Делаем 3 совпадения и 3 несовпадения: 1,2 panel; 3,4,5 brick; добавим ещё panel. + pool.append({"id": 8, "year_built": 2000, "house_type": "panel", "relevance_score": 0.5}) + _apply_unknown_attr_penalty(pool, target_year=None, target_house_type="panel", min_n=5) + by = {c["id"]: c["relevance_score"] for c in pool} + assert by[6] == 0.5 + 0.75 and by[7] == 0.5 + 0.75, f"NULL-тип: {by[6] - 0.5}, {by[7] - 0.5}" + assert by[3] == 0.5, "известный несовпадающий тип уже оштрафован SQL — не трогаем" + + +def test_unknown_target_penalizes_nobody() -> None: + """Симметрия с SQL: если target неизвестен, SQL не штрафует никого — и мы тоже.""" + pool = _pool() + _apply_unknown_attr_penalty(pool, target_year=None, target_house_type=None, min_n=5) + assert all(c["relevance_score"] == 0.5 for c in pool) + + +def test_sparse_pool_is_skipped() -> None: + """Контроль от шума: меньше min_n известных значений → сигнал пропущен целиком.""" + pool = [*_pool()[:3], {"id": 9, "year_built": None, "house_type": None, "relevance_score": 0.5}] + _apply_unknown_attr_penalty(pool, target_year=2000, target_house_type="panel", min_n=5) + assert all(c["relevance_score"] == 0.5 for c in pool), "медиана по трём строкам — не сигнал" + + +def test_flag_default_off_means_no_call() -> None: + """Контроль: дефолт флага OFF — поведение прода не меняется этим PR.""" + from app.core.config import settings + + assert settings.estimate_unknown_attr_penalty_enabled is False + + +def test_penalty_reproduces_sql_terms_not_its_own_idea_of_distance() -> None: + """Штраф считается по ТЕМ ЖЕ термам, что SQL: abs(Δyear)/12.0 и 1.5 за тип. + + Ловит «починку», где NULL получал бы произвольную константу вместо медианы + реально начисленных SQL-штрафов. + """ + pool = [ + {"id": i, "year_built": 2000 + 12 * i, "house_type": "x", "relevance_score": 0.0} + for i in range(1, 6) + ] + [{"id": 99, "year_built": None, "house_type": "x", "relevance_score": 0.0}] + # штрафы 1,2,3,4,5 → медиана 3.0 (= 36 лет / 12) + _apply_unknown_attr_penalty(pool, target_year=2000, target_house_type=None, min_n=5) + assert next(c for c in pool if c["id"] == 99)["relevance_score"] == 3.0 + + +def test_tier_h_and_w_outer_selects_carry_year_and_house_type() -> None: + """Сторож на «мёртвый флаг»: внешние SELECT тиров H и W обязаны выбирать + year_built и house_type — иначе Python-слой видит None у ВСЕХ и не штрафует + никого по построению. + + Именно так флаг и был мёртв в первой редакции: _ANALOG_SELECT_COLS дополнили, + а внешние списки — нет; A/B на 300 целях ЕКБ дал 0 из 5 915 сменённых слотов, + probe-лог показал pool=30 null_year=30. Проверка по исходнику запроса, а не по + живой БД: это инвариант текста SQL. + """ + import inspect + import re + + from app.services import estimator as m + + src = inspect.getsource(m) + # Внешний SELECT тира — блок между `\n<отступ>SELECT\n` и `\n<отступ>FROM base` с ТЕМ + # ЖЕ отступом (иначе rfind цепляет SELECT внутри оконной функции/CASE). + outer_blocks = [] + for mt in re.finditer(r"\n(\s+)FROM base\n", src): + i = mt.start() + j = src.rfind("\n" + mt.group(1) + "SELECT\n", 0, i) + if j >= 0: + outer_blocks.append(src[j:i]) + assert len(outer_blocks) >= 3, f"найдено внешних SELECT: {len(outer_blocks)}" + # Штрафуемые тиры — ровно те, куда #2012 добавил kitchen/ceiling: H и W. + # Tier S (same building) и anchor-тир штрафов не применяют — их внешние + # списки kitchen_area_m2 не содержат, и гейт их не трогает. + penalized = [b for b in outer_blocks if "kitchen_area_m2" in b] + assert len(penalized) == 2, f"ожидал 2 внешних SELECT тиров H и W, нашёл {len(penalized)}" + for b in penalized: + assert "year_built" in b and "house_type" in b, ( + "внешний SELECT тира не выбирает year_built/house_type — флаг #2936 мёртв:\n" + b[-400:] + ) diff --git a/tradein-mvp/backend/tests/test_2953_nominatim_throttle.py b/tradein-mvp/backend/tests/test_2953_nominatim_throttle.py new file mode 100644 index 00000000..49c6bd60 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2953_nominatim_throttle.py @@ -0,0 +1,135 @@ +"""Темп обращений к Nominatim ограничен ПЕРЕД запросом, а не после успеха (#2953). + +Политику 1 req/sec раньше изображали три разрозненных `asyncio.sleep(1.0)`, и ни +один не давал ограничения на самом деле: в `geocode()` сон стоял внутри +`if result is not None` (то есть только после УСПЕХА), в `_nominatim_lookup` — +между typo-вариантами, но не перед tier-1, а цикл бэкфилла +(`tasks/geocode_missing.py`) своей паузы не имел вовсе. Для неразрешимого адреса +выходило 5 запросов за 4 секунды и сразу следующий адрес без паузы — 194 события +«429 Too many requests» за неделю на проде. + +Тест проверяет ПОВЕДЕНИЕ — расстояние между соседними исходящими запросами, — а +не наличие нового символа. Это принципиально: `from app.services.geocoder import +_nominatim_throttle` на origin/main дал бы ImportError, то есть «возможности +нет», а не «значение неверное», и красный ничего бы не доказывал. Здесь на +origin/main тесты падают именно на замере: соседние запросы идут вплотную. + +Интервал подменяется на малый (`raising=False` — на origin/main такого атрибута +просто нет, подмена там безвредна), иначе тест ждал бы секунды реального времени. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import time +from itertools import pairwise +from unittest.mock import patch + +import httpx +import pytest + +from app.services import geocoder +from app.services.geocoder import _nominatim_query + +# Тестовый интервал: достаточно велик, чтобы отличаться от нуля на любом железе, +# и достаточно мал, чтобы тест шёл десятки миллисекунд, а не секунды. +_TEST_INTERVAL = 0.08 + +_REAL_ASYNC_CLIENT = httpx.AsyncClient + +# Пустой ответ: Nominatim ничего не нашёл. Именно НЕУДАЧНЫЙ путь и разгонял темп — +# сон-после-успеха на нём не срабатывал. +_EMPTY_BODY: list[dict] = [] + + +def _timestamping_transport(stamps: list[float]) -> httpx.MockTransport: + def handler(_request: httpx.Request) -> httpx.Response: + stamps.append(time.monotonic()) + return httpx.Response(200, json=_EMPTY_BODY) + + return httpx.MockTransport(handler) + + +def _gaps(stamps: list[float]) -> list[float]: + return [b - a for a, b in pairwise(stamps)] + + +@pytest.fixture +def throttle_reset(monkeypatch: pytest.MonkeyPatch) -> None: + """Малый интервал + сброс «времени последнего вызова» между тестами. + + Без сброса второй тест в файле унаследовал бы отметку от первого и мог бы + пройти/упасть по чужой причине. + """ + monkeypatch.setattr(geocoder, "_NOMINATIM_MIN_INTERVAL_SEC", _TEST_INTERVAL, raising=False) + monkeypatch.setattr(geocoder, "_nominatim_last_call_at", 0.0, raising=False) + + +async def test_consecutive_queries_are_spaced(throttle_reset: None) -> None: + """Три запроса подряд разнесены не меньше чем на интервал. + + На origin/main `_nominatim_query` не содержит паузы вообще, запросы уходят + вплотную — тест падает на первом же зазоре. + """ + stamps: list[float] = [] + transport = _timestamping_transport(stamps) + + async with _REAL_ASYNC_CLIENT(transport=transport) as client: + for _ in range(3): + await _nominatim_query(client, "заведомо ненаходимый адрес") + + assert len(stamps) == 3, f"ожидали 3 запроса, ушло {len(stamps)}" + gaps = _gaps(stamps) + assert all(g >= _TEST_INTERVAL * 0.9 for g in gaps), ( + f"запросы идут вплотную: зазоры {[round(g, 4) for g in gaps]}, ожидалось ≥ {_TEST_INTERVAL}" + ) + + +async def test_failed_lookup_does_not_reset_the_pace(throttle_reset: None) -> None: + """Два НЕУДАЧНЫХ поиска подряд не разгоняют темп на стыке. + + Это ровно тот путь, который темп не сдерживал: сон стоял после успеха, а + неудача уходила к следующему адресу без паузы. Проверяем зазор МЕЖДУ + вызовами — между последним запросом первого и первым запросом второго. + """ + stamps: list[float] = [] + transport = _timestamping_transport(stamps) + + with patch( + "app.services.geocoder.httpx.AsyncClient", + lambda *a, **k: _REAL_ASYNC_CLIENT(transport=transport), + ): + await geocoder._nominatim_lookup("ненаходимый адрес один") + boundary_index = len(stamps) + await geocoder._nominatim_lookup("ненаходимый адрес два") + + assert boundary_index > 0 and len(stamps) > boundary_index, ( + f"оба поиска должны были сходить в сеть: {len(stamps)} запросов, граница {boundary_index}" + ) + boundary_gap = stamps[boundary_index] - stamps[boundary_index - 1] + assert boundary_gap >= _TEST_INTERVAL * 0.9, ( + f"на стыке двух неудачных поисков зазор {boundary_gap:.4f}с " + f"(ожидалось ≥ {_TEST_INTERVAL}) — темп сбрасывается именно после неудачи" + ) + + +async def test_first_call_is_not_delayed(throttle_reset: None) -> None: + """Контроль: первый запрос не ждёт впустую. + + Если бы «время последнего вызова» инициализировалось текущим временем, каждый + холодный старт платил бы интервал ни за что. Тест сломается и в случае, если + интервал по недосмотру станет применяться дважды за запрос. + """ + stamps: list[float] = [] + transport = _timestamping_transport(stamps) + + started = time.monotonic() + async with _REAL_ASYNC_CLIENT(transport=transport) as client: + await _nominatim_query(client, "первый запрос") + elapsed = time.monotonic() - started + + assert len(stamps) == 1 + assert elapsed < _TEST_INTERVAL, f"первый запрос прождал {elapsed:.4f}с — это лишнее" diff --git a/tradein-mvp/backend/tests/test_2992_upsert_unchanged_gate.py b/tradein-mvp/backend/tests/test_2992_upsert_unchanged_gate.py new file mode 100644 index 00000000..dbe3c0ca --- /dev/null +++ b/tradein-mvp/backend/tests/test_2992_upsert_unchanged_gate.py @@ -0,0 +1,258 @@ +"""Повторный скрейп неизменившегося объявления не переписывает строку (#2992). + +Апсерт listings безусловно присваивал 41 колонку при каждом повторном скрейпе — +заново тостил description и плодил TOAST-чанки. Прод 2026-08-20: n_tup_upd +20.87 млн на 107 тыс. строк (198 апдейтов на строку за 91 день), HOT 0.44 %, +TOAST 15 ГБ при ~230 МБ полезного, WAL 7 ГБ/сутки. + +Гейт в `ON CONFLICT DO UPDATE … WHERE`: апдейт идёт, если итоговое +(post-COALESCE) значение хоть одной контентной колонки отличается ИЛИ +last_seen_at ещё не сегодняшний по МСК. Второе условие обязательно: метка +живости должна сдвигаться хотя бы раз в сутки — на неё завязаны деактиватор, +снапшоты, эстиматор (#2206), монитор свежести. + +Проверяется на ЖИВОМ Postgres через реальный save_listings, в транзакции с +откатом. Счётчик апдейтов — pg_stat_xact_user_tables (текущая транзакция), +то есть ровно «повторный скрейп не увеличивает n_tup_upd» из acceptance. +Без БД — skip (как соседние live-тесты; в CI Trade-In есть postgres-сервис). +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import uuid +from typing import Any +from unittest.mock import MagicMock + +import pytest +from scraper_kit.base import ScrapedLot, save_listings +from sqlalchemy import text + + +def _live_session() -> Any | 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() + conn.execute(text("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +def _matcher() -> MagicMock: + m = MagicMock() + m.match_or_create_house.return_value = (None, 0.0, "no_address") + m.upsert_listing_source.return_value = None + return m + + +def _lot( + src_id: str, + price: int = 5_000_000, + description: str | None = "тестовое описание", + address: str | None = None, +) -> ScrapedLot: + return ScrapedLot( + source="cian", + source_url=f"https://ekb.cian.ru/sale/flat/t2992-{src_id}/", + source_id=f"t2992-{src_id}", + price_rub=price, + description=description, + address=address, + ) + + +def _row(db: Any, src_id: str) -> Any: + """Строка + её физическая версия (ctid). save_listings КОММИТИТ, поэтому + транзакционные счётчики pg_stat_xact_* тут бесполезны (сбрасываются). UPDATE + всегда создаёт новую версию строки → ctid меняется; пропущенный апдейт → ctid + тот же. Это и есть прямой замер «переписана ли строка».""" + return db.execute( + text( + "SELECT id, ctid::text AS ctid, price_rub, city, description, last_seen_at " + "FROM listings WHERE source = 'cian' AND source_id = :sid" + ), + {"sid": f"t2992-{src_id}"}, + ).fetchone() + + +def _cleanup(db: Any) -> None: + """save_listings коммитит — rollback не спасает. Убираем свои строки явно.""" + try: + db.rollback() + db.execute( + text( + "DELETE FROM listings_snapshots WHERE listing_id IN " + "(SELECT id FROM listings WHERE source='cian' AND source_id LIKE 't2992-%')" + ) + ) + db.execute(text("DELETE FROM listing_sources WHERE ext_id LIKE 't2992-%'")) + db.execute(text("DELETE FROM listings WHERE source='cian' AND source_id LIKE 't2992-%'")) + db.commit() + finally: + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_unchanged_rescrape_same_day_does_not_update_the_row() -> None: + """Головной: второй апсерт неизменного лота в тот же МСК-день — n_tup_upd не растёт. + + На origin/main (без гейта) второй save_listings переписывает строку — счётчик +1. + """ + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66) + first = _row(db, sid) + assert first is not None, "первая вставка не прошла" + ins, upd = save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66) + second = _row(db, sid) + assert second.ctid == first.ctid, ( + f"повторный скрейп неизменного лота переписал строку: ctid {first.ctid}→{second.ctid}" + ) + assert (ins, upd) == (0, 0), f"счётчики: inserted={ins} updated={upd}, ждали 0/0" + assert second.last_seen_at == first.last_seen_at, "last_seen_at сдвинулся без нужды" + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_changed_price_still_updates() -> None: + """Контроль: изменение контента проходит — гейт не глушит настоящие изменения.""" + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings(db, [_lot(sid, price=5_000_000)], matcher=_matcher(), region_code=66) + first = _row(db, sid) + save_listings(db, [_lot(sid, price=5_100_000)], matcher=_matcher(), region_code=66) + second = _row(db, sid) + assert second.ctid != first.ctid, "изменение цены не записалось (строка не переписана)" + assert second.price_rub == 5_100_000 + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_coalesce_backfill_still_updates() -> None: + """Контроль: COALESCE-дозаполнение (город NULL → значение) проходит. + + Именно за это забраковали гейт по card_hash: он бы блокировал дозаполнение. + Сравнение идёт по ИТОГОВЫМ post-COALESCE значениям — если дописал, строка + отличается. + """ + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + # city приходит ПАРАМЕТРОМ save_listings (развёртка города), не полем лота. + save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66, city=None) + first = _row(db, sid) + assert first.city is None + save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66, city="Екатеринбург") + second = _row(db, sid) + assert second.ctid != first.ctid, "дозаполнение города заблокировано гейтом" + assert second.city == "Екатеринбург" + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_next_day_rescrape_updates_even_if_unchanged() -> None: + """Живость: если last_seen_at вчерашний — апдейт идёт и без изменений контента. + + Иначе деактиватор/снапшоты/эстиматор потеряли бы живой инвентарь. Симулируем + «вчера», отодвинув last_seen_at у только что вставленной строки. + """ + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66) + db.execute( + text( + "UPDATE listings SET last_seen_at = last_seen_at - interval '1 day' " + "WHERE source='cian' AND source_id = :sid" + ), + {"sid": f"t2992-{sid}"}, + ) + db.commit() + first = _row(db, sid) + save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66) + second = _row(db, sid) + assert second.ctid != first.ctid, "суточное ре-подтверждение живости заблокировано" + assert second.last_seen_at > first.last_seen_at, "last_seen_at не сдвинулся" + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_skipped_row_still_yields_listing_id_for_downstream() -> None: + """При пропуске апдейта listing_id всё равно известен — downstream (снапшот, + матчинг, listing_sources) идёт; иначе гейт молча отключил бы сопоставление.""" + db = _live_session() + sid = uuid.uuid4().hex[:8] + m = _matcher() + lot = _lot(sid, address="Екатеринбург, ул. Тестовая, 1") # матчинг идёт только с адресом/гео + try: + save_listings(db, [lot], matcher=m, region_code=66) + calls_before = m.match_or_create_house.call_count + assert calls_before == 1, ( + "контроль сконструирован неверно: матчинг не зовётся и в первый раз" + ) + save_listings(db, [lot], matcher=m, region_code=66) + assert m.match_or_create_house.call_count == calls_before + 1, ( + "при пропущенном апдейте матчинг не вызван — listing_id потерян" + ) + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_listing_sources_unchanged_rescrape_same_day_does_not_update() -> None: + """Симметричный гейт на listing_sources — второй по нагрузке таблице (10.27 млн + апдейтов, HOT 0.10 %). Повторный upsert_listing_source того же лота в тот же + МСК-день не переписывает строку; изменение цены — переписывает.""" + from app.services.matching.listings import upsert_listing_source + + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings(db, [_lot(sid)], matcher=_matcher(), region_code=66) + lid = int(_row(db, sid).id) + + def ls_row() -> Any: + return db.execute( + text( + "SELECT ctid::text AS ctid, price_rub, last_seen_at FROM listing_sources " + "WHERE ext_source='cian' AND ext_id = :e" + ), + {"e": f"t2992-{sid}"}, + ).fetchone() + + kw = dict(ext_source="cian", ext_id=f"t2992-{sid}", method="test", confidence=0.9) + upsert_listing_source(db, listing_id=lid, price_rub=5_000_000, **kw) + db.commit() + first = ls_row() + assert first is not None + upsert_listing_source(db, listing_id=lid, price_rub=5_000_000, **kw) + db.commit() + second = ls_row() + assert second.ctid == first.ctid, ( + f"listing_sources переписана без изменений: ctid {first.ctid}→{second.ctid}" + ) + upsert_listing_source(db, listing_id=lid, price_rub=5_100_000, **kw) + db.commit() + third = ls_row() + assert third.ctid != second.ctid and third.price_rub == 5_100_000, ( + "изменение цены не записалось" + ) + finally: + _cleanup(db) diff --git a/tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py b/tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py new file mode 100644 index 00000000..a216f3c8 --- /dev/null +++ b/tradein-mvp/backend/tests/test_2996_mislocated_houses_watchdog.py @@ -0,0 +1,112 @@ +"""Сторож на дома, уехавшие от собственных объявлений (#2996). + +Прод 21.08.2026: 23 дома стоят вне Свердловской области, к ним привязано 591 +объявление. Адреса екатеринбургские («Ул. 8 Марта», «Крауля», «Амундсена»), +координаты — Варшава, Белград, Таллин, Владивосток, Ижевск. + +Причина найдена и она НЕ в живом пути: 22 из 23 несут в `raw_payload` след +разового бэкфилла `full_backfill_2026-05-27`, который звал Yandex-геокодер +напрямую, без резолва города. Живой путь чист — 0 записей вне региона в +`geocode_cache` из 10 448 и 2 из 88 544 у `listings`. + +**17 из 22 помечены геокодером `precision: "exact"`.** Точность отвечает на +«нашёлся ли номер дома», а не «в том ли городе», и критерием приёмки быть не +может. Поэтому сторож проверяет ПРИНАДЛЕЖНОСТЬ, а не «точность». + +Инвариант нарочно не географический — «дом рядом со своими объявлениями», а не +«дом внутри рамки области». Рамка сломалась бы при выходе в Москву; этот +инвариант переживёт расширение. + +Порог 100 км не подобран на глаз. Замер по 9 052 домам с объявлениями: + + ближе 1 км 8 914 (98.5 %) + 1–5 км 64 + 5–25 км 40 + 25–100 км 9 ← настоящие пригороды: Сарапулка, Кедровка, + Чусовское Озеро, Ревда, Первоуральск (макс. 46.7) + дальше 100 км 25 ← ближайший 119.2, максимум 5 079 + +Между 46.7 и 119.2 км нет НИ ОДНОГО дома. 100 лежит в середине пустого +промежутка, и ловит на два дома больше, чем рамка по области (оба проверены — +настоящие, «Ул. Белинского/Фурманова» за 207 км с 34 объявлениями). +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import inspect +import re + + +def test_threshold_sits_in_the_measured_gap() -> None: + """Головной: порог задан и лежит между пригородами и патологией. + + 46.7 км — самый дальний настоящий пригород, 119.2 — ближайший дефект. + Порог вне этого промежутка либо ловит пригороды, либо пропускает дефекты. + """ + from app.api.v1 import admin + + порог = getattr(admin, "_MISLOCATED_KM", None) + assert порог is not None, ( + "порога нет вовсе — сторож не задан, дома в Варшаве и Таллине никем не считаются" + ) + assert 46.7 < порог < 119.2, ( + f"порог {порог} вне измеренного промежутка (46.7, 119.2): ниже — ловит " + "настоящие пригороды, выше — пропускает дефекты" + ) + + +def test_endpoint_exists_and_reports_scale() -> None: + """Ручка обязана отдавать не только счётчик, но и МАСШТАБ. + + Одно число «25» не даёт принять решение об очистке: нужно, сколько + объявлений привязано к этим домам (на проде 591) и какие это дома. + """ + from app.api.v1 import admin + + ручка = getattr(admin, "get_mislocated_houses", None) + модель = getattr(admin, "MislocatedHousesResponse", None) + assert ручка is not None and модель is not None, ( + "ручки нет — дома в Варшаве и Таллине не перечисляет никто; " + f"есть ли обработчик: {ручка is not None}, есть ли модель: {модель is not None}" + ) + поля = модель.model_fields + for нужное in ("threshold_km", "houses_count", "listings_count", "houses"): + assert нужное in поля, f"нет поля {нужное}; есть: {sorted(поля)}" + + +def test_query_compares_house_to_its_own_listings() -> None: + """Контроль смысла: инвариант «рядом со своими», а не «внутри рамки». + + Рамка по области сломалась бы при выходе в Москву — ровно то, ради чего + заведена #2996. Ловит «починку» через координатный BETWEEN. + """ + from app.api.v1 import admin + + запрос = getattr(admin, "_MISLOCATED_SQL", None) + assert запрос is not None, "запроса нет — проверять принадлежность нечем" + src = str(запрос) + assert "l.house_id_fk = h.id" in src, "дом не сравнивается со СВОИМИ объявлениями" + assert "percentile_disc(0.5)" in src, "берётся не медиана координат объявлений" + assert not re.search(r"ST_Y\(.*BETWEEN", src), ( + "появилась географическая рамка — она не переживёт расширение региона" + ) + + +def test_check_stays_out_of_the_polled_endpoint() -> None: + """Контроль цены: тяжёлая проверка не должна попасть в опрашиваемую ручку. + + Замер прода: этот запрос ~445 мс, обе выборки /scraper/data-quality вместе — + 27 мс, а фронт опрашивает её каждые 120 с. Первая редакция этой правки клала + проверку туда и давала 17-кратное удорожание; замер это и остановил. + """ + from app.api.v1 import admin + + src = inspect.getsource(admin.get_data_quality) + assert "percentile_disc" not in src, ( + "тяжёлый агрегат вернулся в опрашиваемую каждые 2 минуты ручку" + ) + assert "mislocated" not in src.lower(), "проверка снова подмешана в data-quality" diff --git a/tradein-mvp/backend/tests/test_3033_avito_native_secondary_filter.py b/tradein-mvp/backend/tests/test_3033_avito_native_secondary_filter.py new file mode 100644 index 00000000..92a5a630 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3033_avito_native_secondary_filter.py @@ -0,0 +1,159 @@ +"""Полный обход Авито качает ВТОРИЧКУ родным фильтром пути, а не всё подряд (#3033). + +До правки `fetch_all_secondary(secondary_only=True)` строил URL общей выдачи +(`/kvartiry/prodam/`), скачивал 100 % (46 573 объявления в ЕКБ) и выбрасывал +новостройки после разбора — 79,4 % страниц и антибан-бюджета в корзину. Родной фильтр +вторички есть: `/kvartiry/prodam/1-komnatnye/vtorichka` (читаемые сегменты, Авито +редиректит на канонический URL; 2 551 объявление) — проверено вживую 21.08.2026. + +Сеть не нужна: `_fetch_serp_html` подменяется и собирает URL; страница отдаёт +`page-title/count`=0, поэтому бакет скипается сразу после probe, а probe-URL — ровно +тот, по которому пошёл бы обход. На origin/main первый и пятый тесты красные ПО ЗНАЧЕНИЮ +(в пути ASgB-slug комнатности и нет `/vtorichka`). +""" + +from __future__ import annotations + +import asyncio +import os +from types import SimpleNamespace +from urllib.parse import parse_qs, urlparse + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.providers.avito.serp import ROOM_SLUGS, AvitoScraper + +_ONE_ROOM = ROOM_SLUGS[1] # ("1-комн.", "1-komnatnye-ASgBAgICAkSSA8YQygiAWQ") +_EMPTY_SERP = '0' + + +def _cfg() -> SimpleNamespace: + # __init__ читает только то, что нужно без __aenter__: fetch_mode/ekb_only. + return SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http") + + +def _collect_urls(secondary_only: bool) -> tuple[AvitoScraper, list[str]]: + s = AvitoScraper(_cfg()) # type: ignore[arg-type] + urls: list[str] = [] + + async def fake_fetch(url: str, page: int) -> str: + urls.append(url) + return _EMPTY_SERP + + s._fetch_serp_html = fake_fetch # type: ignore[method-assign] + asyncio.run(s.fetch_all_secondary(secondary_only=secondary_only, rooms_buckets=[_ONE_ROOM])) + return s, urls + + +def test_secondary_only_requests_native_vtorichka_path() -> None: + """Головной: каждый probe-URL обхода вторички — читаемый путь комнатности + /vtorichka.""" + _s, urls = _collect_urls(secondary_only=True) + assert urls, "обход не сделал ни одного запроса — подмена fetch не сработала" + paths = {urlparse(u).path for u in urls} + assert paths == {"/ekaterinburg/kvartiry/prodam/1-komnatnye/vtorichka"}, paths + # ценовая бисекция/сортировка живут в query и не теряются + q = parse_qs(urlparse(urls[0]).query) + assert q["s"] == ["104"] and q["p"] == ["1"] + assert any("pmin" in parse_qs(urlparse(u).query) for u in urls), "pmin пропал из URL" + + +def test_non_secondary_keeps_legacy_room_slug_url() -> None: + """Контроль: secondary_only=False — прежний URL (ASgB-slug комнатности), без /vtorichka.""" + _s, urls = _collect_urls(secondary_only=False) + assert urls + for u in urls: + p = urlparse(u).path + assert p == f"/ekaterinburg/kvartiry/prodam/{_ONE_ROOM[1]}", p + assert "/vtorichka" not in p + + +def test_mode_does_not_leak_after_run() -> None: + """Режим живёт только внутри fetch_all_secondary: после него билдер строит прежний URL.""" + s, _urls = _collect_urls(secondary_only=True) + assert "/vtorichka" not in s._build_rooms_url(_ONE_ROOM[1], 1) + + +def test_dropped_counter_reset_and_exposed() -> None: + """Post-parse фильтр остаётся страховкой: счётчик отброшенных обнуляется и читается.""" + s, _urls = _collect_urls(secondary_only=True) + assert getattr(s, "last_dropped_nb", None) == 0 + + +def test_every_room_slug_gets_readable_prefix() -> None: + """Все 7 комнатностей: путь = читаемый префикс slug'а + /vtorichka, без хвоста ASgB.""" + s = AvitoScraper(_cfg()) # type: ignore[arg-type] + s._secondary_path = True + for _name, slug in ROOM_SLUGS: + readable = slug.split("-ASgB", 1)[0] + path = urlparse(s._build_rooms_url(slug, 3, 1_000_000, 5_000_000)).path + assert path == f"/ekaterinburg/kvartiry/prodam/{readable}/vtorichka", path + assert "ASgB" not in path + + +def test_pipeline_counters_carry_dropped_novostroyki() -> None: + """Пайплайн сохраняет счётчик в scrape_runs.counters (зеркало cian #1781).""" + from scraper_kit.orchestration.pipeline import AvitoFullLoadCounters + + assert "dropped_novostroyki" in AvitoFullLoadCounters().to_dict() + + +# ── follow-up (#3033, комментарий владельца 21.08 15:02): якорный и citywide-пути ────────── +# Проверено вживую 21.08 через сайдкар: /prodam/vtorichka-ASgB… принимает geoCoords/radius +# (60 карточек, все vtorichka, total=9036 против 55 карточек 41/14 у общей выдачи). +# Литерал, а не импорт: на main до правки имени SECONDARY_SLUG нет, и импорт ронял бы весь +# файл «нет возможности», а не «неверное значение». Slug — из постановки #3033. +SECONDARY_SLUG = "vtorichka-ASgBAgICAkSSA8YQ5geMUg" + + +def _card(item_id: str) -> str: + href = f"/ekaterinburg/kvartiry/2k_kvartira_50_m_5_5et_{item_id}" + return ( + f'' + ) + + +def _capture(method: str, **kw: object) -> list[str]: + s = AvitoScraper(SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http")) # type: ignore[arg-type] + urls: list[str] = [] + + async def fake_fetch(url: str, page: int) -> str: + urls.append(url) + return "" + _card(f"id{len(urls)}") + "" + + s._fetch_serp_html = fake_fetch # type: ignore[method-assign] + asyncio.run(getattr(s, method)(**kw)) + return urls + + +def test_anchor_sweep_uses_secondary_slug_by_default() -> None: + """fetch_around по умолчанию — путь вторички, geoCoords/radius сохранены в query.""" + urls = _capture("fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1) + assert urls, "fetch_around не сделал запроса" + u = urlparse(urls[0]) + assert u.path == f"/ekaterinburg/kvartiry/prodam/{SECONDARY_SLUG}", u.path + q = parse_qs(u.query) + assert q["geoCoords"] == ["56.84,60.6"] and q["radius"] == ["1"] and q["s"] == ["104"] + + +def test_anchor_sweep_secondary_only_false_keeps_general_path() -> None: + """Контроль: secondary_only=False — прежняя общая выдача.""" + urls = _capture( + "fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1, secondary_only=False + ) + assert urlparse(urls[0]).path == "/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ" + + +def test_citywide_uses_secondary_slug_by_default() -> None: + """fetch_city_wide по умолчанию — путь вторички; secondary_only=False — общая выдача.""" + urls = _capture("fetch_city_wide", pages=1) + assert urlparse(urls[0]).path == f"/ekaterinburg/kvartiry/prodam/{SECONDARY_SLUG}" + urls2 = _capture("fetch_city_wide", pages=1, secondary_only=False) + assert urlparse(urls2[0]).path == "/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ" + + +def test_newbuilding_builder_untouched() -> None: + """Контроль: путь новостроек не меняется (свой slug, свой sweep).""" + s = AvitoScraper(SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http")) # type: ignore[arg-type] + assert "novostroyka-ASgBAgICAkSSA8YQ5geOUg" in s._build_newbuilding_url(1) diff --git a/tradein-mvp/backend/tests/test_3036_detail_house_params_to_houses.py b/tradein-mvp/backend/tests/test_3036_detail_house_params_to_houses.py new file mode 100644 index 00000000..861da027 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3036_detail_house_params_to_houses.py @@ -0,0 +1,208 @@ +"""Детальная страница Авито больше не выбрасывает house-поля (#3036). + +`save_detail_enrichment` парсит has_concierge/closed_yard/total_floors_house/house_type на +каждой карточке и до правки писал в listings только house_type/house_url — остальное +терялось. На проде при 10 051 обогащённых карточках с привязкой к дому has_concierge был +заполнен у 5 домов из 10 131, closed_yard — у 19. Теперь вторым оператором (fill-only, +через listings.house_id_fk) пустые поля дома заполняются; заполненные не трогаются. + +Юнит — подменный Session: на main красный по значению (UPDATE houses не исполняется). +Live-тест (skipif без БД): дом + листинг, fill, затем «не затирает», затем «без привязки +— дом не тронут». +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any + +import pytest +from sqlalchemy import text + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.providers.avito.detail import ( + DetailEnrichment, + save_detail_enrichment, +) + + +def _enrichment(**over: Any) -> DetailEnrichment: + base: dict[str, Any] = { + "item_id": "t3036-1", + "source_url": "https://www.avito.ru/ekaterinburg/kvartiry/x_t3036-1", + "has_concierge": True, + "closed_yard": False, + "total_floors_house": 9, + "house_type": "panel", + } + base.update(over) + return DetailEnrichment(**base) + + +class _FakeNested: + def __enter__(self) -> None: + return None + + def __exit__(self, *exc: object) -> bool: + return False + + +class _FakeSession: + """Пишет все execute(sql, params); rowcount=1 для обоих операторов.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + self.nested = 0 + self.committed = 0 + + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + self.calls.append((str(stmt), params or {})) + return SimpleNamespace(rowcount=1) + + def begin_nested(self) -> _FakeNested: + self.nested += 1 + return _FakeNested() + + def commit(self) -> None: + self.committed += 1 + + +def test_house_params_written_fill_only_via_house_id_fk() -> None: + """Головной: за UPDATE listings идёт UPDATE houses … FROM listings … house_id_fk, fill-only.""" + db = _FakeSession() + assert save_detail_enrichment(db, _enrichment()) is True # type: ignore[arg-type] + assert len(db.calls) == 2, [c[0][:40] for c in db.calls] + sql, params = db.calls[1] + assert "UPDATE houses" in sql and "l.house_id_fk = h.id" in sql + for col in ("has_concierge", "closed_yard", "total_floors", "house_type"): + assert f"COALESCE(h.{col}," in sql, f"{col} не fill-only" + assert params["has_concierge"] is True and params["closed_yard"] is False + assert params["total_floors_house"] == 9 and params["house_type"] == "panel" + assert params["item_id"] == "t3036-1" + assert db.nested == 1, "UPDATE houses обязан идти под SAVEPOINT" + assert db.committed == 1 + + +def test_lifts_are_not_written_no_such_columns() -> None: + """Контроль: лифты не пишем — колонок в houses нет (conflict_resolution их объявляет зря).""" + db = _FakeSession() + save_detail_enrichment(db, _enrichment(passenger_elevators=2, cargo_elevators=1)) # type: ignore[arg-type] + sql = db.calls[1][0] + assert "elevator" not in sql and "lifts" not in sql + + +def test_nothing_parsed_means_no_second_statement() -> None: + """Контроль: если с карточки house-полей не пришло — второй оператор не исполняется.""" + db = _FakeSession() + save_detail_enrichment( # type: ignore[arg-type] + db, + _enrichment(has_concierge=None, closed_yard=None, total_floors_house=None, house_type=None), + ) + assert len(db.calls) == 1 and db.nested == 0 + + +def test_listing_not_found_skips_houses() -> None: + """Контроль: листинга нет (rowcount 0) — дом не трогаем, False наружу как раньше.""" + + class _NotFound(_FakeSession): + def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: + self.calls.append((str(stmt), params or {})) + return SimpleNamespace(rowcount=0) + + db = _NotFound() + assert save_detail_enrichment(db, _enrichment()) is False # type: ignore[arg-type] + assert len(db.calls) == 1 + + +# ── live DB ────────────────────────────────────────────────────────────────── +def _live_session() -> Any | 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() + conn.execute(text("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +def _cleanup(db: Any) -> None: + try: + db.rollback() + db.execute(text("DELETE FROM listings WHERE source='avito' AND source_id LIKE 't3036-%'")) + db.execute(text("DELETE FROM houses WHERE source='test' AND ext_house_id LIKE 't3036-%'")) + db.commit() + finally: + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_live_fill_only_then_keep_then_unlinked_untouched() -> None: + db = _live_session() + assert db is not None + try: + _cleanup_inline = text( + "DELETE FROM listings WHERE source='avito' AND source_id LIKE 't3036-%'" + ) + db.execute(_cleanup_inline) + db.execute(text("DELETE FROM houses WHERE source='test' AND ext_house_id LIKE 't3036-%'")) + hid = db.execute( + text( + "INSERT INTO houses (source, ext_house_id, url, address) " + "VALUES ('test', 't3036-h1', 'https://example.test/houses/t3036-h1', " + "'ул. Тестовая, 1') RETURNING id" + ) + ).scalar_one() + db.execute( + text( + "INSERT INTO listings " + "(source, source_id, source_url, dedup_hash, address, price_rub, " + " house_id_fk, is_active) " + "VALUES ('avito', 't3036-1', 'https://www.avito.ru/x/t3036-1', 't3036-dh-1', " + " 'ул. Тестовая, 1', 5000000, :hid, true), " + " ('avito', 't3036-2', 'https://www.avito.ru/x/t3036-2', 't3036-dh-2', " + " 'ул. Тестовая, 2', 5000000, NULL, true)" + ), + {"hid": hid}, + ) + db.commit() + # 1) fill + assert save_detail_enrichment(db, _enrichment()) is True + row = db.execute( + text( + "SELECT has_concierge, closed_yard, total_floors, house_type " + "FROM houses WHERE id=:h" + ), + {"h": hid}, + ).one() + assert tuple(row) == (True, False, 9, "panel"), tuple(row) + # 2) fill-only: другое значение с другой карточки того же дома не затирает + assert ( + save_detail_enrichment(db, _enrichment(has_concierge=False, total_floors_house=16)) + is True + ) + row = db.execute( + text("SELECT has_concierge, total_floors FROM houses WHERE id=:h"), {"h": hid} + ).one() + assert tuple(row) == (True, 9), tuple(row) + # 3) листинг без привязки — дом не трогается, листинг обогащается + before = db.execute( + text("SELECT count(*) FROM houses WHERE house_type='brick' AND source='test'") + ).scalar_one() + assert ( + save_detail_enrichment(db, _enrichment(item_id="t3036-2", house_type="brick")) is True + ) + after = db.execute( + text("SELECT count(*) FROM houses WHERE house_type='brick' AND source='test'") + ).scalar_one() + assert after == before + finally: + _cleanup(db) diff --git a/tradein-mvp/backend/tests/test_3044_avito_439_classified_as_block.py b/tradein-mvp/backend/tests/test_3044_avito_439_classified_as_block.py new file mode 100644 index 00000000..ddbf2e1f --- /dev/null +++ b/tradein-mvp/backend/tests/test_3044_avito_439_classified_as_block.py @@ -0,0 +1,84 @@ +"""#3044: HTTP 439 (QRATOR-челлендж Avito) считается блокировкой, а не концом выдачи. + +439 — нестандартный код, которым QRATOR отвечает на странице-челлендже. Ни в одном +RFC его нет, поэтому он не попал ни в один из списков «это блок» — и обе точки входа +трактовали его как штатный исход: + + * SERP — проваливался в `if sc != 200: return None`, то есть выглядел как «страница + закончилась»: пагинация мирно останавливалась, ротации IP не было, `_report_ban` + не звался, прогон не помечался banned. + * detail — доходил до `if sc != 200: raise ValueError`, то есть считался ошибкой + разбора страницы: без реконнекта на свежий exit-IP и без ban-сигнала. + +Обе ветки молчали ровно там, где нас блокировали — отсюда прогоны с диагнозом +«причина НЕ установлена» (#2700) при том, что у отказа был чёткий HTTP-код. + +Фальсификация: до правки первый тест возвращает None вместо исключения, второй +поднимает ValueError вместо AvitoBlockedError. +""" + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") + +from scraper_kit.avito_exceptions import AvitoBlockedError +from scraper_kit.providers.avito import detail as detail_module +from scraper_kit.providers.avito import serp as serp_module +from scraper_kit.providers.avito.detail import fetch_detail +from scraper_kit.providers.avito.serp import AvitoScraper + +from app.services.scraper_adapters import RealScraperConfig + + +def _resp(status: int, text: str = "QRATOR challenge") -> MagicMock: + r = MagicMock() + r.status_code = status + r.text = text + return r + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [403, 439]) +async def test_serp_439_raises_block_like_403(status: int) -> None: + """SERP: 439 поднимает AvitoBlockedError ровно как 403, а не «конец выдачи». + + Параметризация не для полноты, а для доказательства ПАРИТЕТА: 403 — заведомо + рабочая ветка, и 439 обязан вести себя ровно так же. Если однажды кто-то + перепишет блок-логику и уронит только 439 — упадёт лишь половина кейсов, и это + сразу покажет, что ветки разъехались. + """ + scraper = AvitoScraper(RealScraperConfig()) + scraper._browser = None + scraper._cffi = AsyncMock() + scraper._cffi.get = AsyncMock(return_value=_resp(status)) + scraper._rotate_ip = AsyncMock(return_value=False) # ротация недоступна -> сразу raise + + with patch.object(serp_module.asyncio, "sleep", AsyncMock()): + with pytest.raises(AvitoBlockedError): + await scraper._fetch_serp_html("https://www.avito.ru/ekaterinburg/kvartiry", page=1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [403, 439]) +async def test_detail_439_raises_block_like_403(status: int) -> None: + """detail: 439 поднимает AvitoBlockedError, а не ValueError «не смог распарсить». + + config=None -> backconnect=False, поэтому reconnect-ветка не задействуется и + исключение поднимается на первом же ответе — проверяем именно классификацию, + не механику ретраев. + """ + session = AsyncMock() + session.get = AsyncMock(return_value=_resp(status)) + + with patch.object(detail_module.asyncio, "sleep", AsyncMock()): + with pytest.raises(AvitoBlockedError) as excinfo: + await fetch_detail("/ekaterinburg/kvartiry/1_k_kvartira_1234567", cffi_session=session) + + # Текст ошибки несёт фактический код: до правки он был жёстко зашит как "403" + # и на 439 врал бы в логах ровно там, где по логу и ставят диагноз. + assert str(status) in str(excinfo.value) diff --git a/tradein-mvp/backend/tests/test_3044_enriched_result_key.py b/tradein-mvp/backend/tests/test_3044_enriched_result_key.py new file mode 100644 index 00000000..b4941329 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3044_enriched_result_key.py @@ -0,0 +1,59 @@ +"""#3044 (наблюдаемость): результат detail-бэкфиллов виден колонке total_seen. + +Прод-факт (Poincare, 26.08.2026): avito_detail_backfill за 7 дней — 36 прогонов, +sum(total_seen)=0, из чего замер в issue сделал вывод «обогащено 0». Реально: +counters={'enriched': 61, ...} у прогона 25.08 09:18, а listings.detail_enriched_at +насчитал 801 обогащение за те же 7 дней. Джобы пишут результат ТОЛЬКО ключом +'enriched', которого _column_counts не знал — колонка не заполнялась ни у done, +ни у banned прогонов, и «провал и успех писали один и тот же признак» (ноль). + +Фикс: 'enriched'-фолбэк в _column_counts (обе копии: app и scraper-kit) — только +для витринной колонки. В _RESULT_COUNTER_KEYS ключ НЕ добавлен намеренно: тот +список кормит zero-result-стрик, где догнавший очередь бэкфилл (attempted=0) +стал бы непрерываемым измеренным нулём — ловушка, за которую ревью уже выкинуло +из списка 'rows_inserted' (#2703, test_rosreestr_dkp_import_healthy_zero_*). + +Тесты колонки красные на origin/main по ЗНАЧЕНИЮ (None вместо 61), не по импорту. +""" + +from __future__ import annotations + +from scraper_kit.orchestration import runs as kit_runs + +from app.services import scrape_runs as app_runs + +# Прод-прогон avito_detail_backfill 25.08 09:18 UTC (баном оборван, но 61 обогащён). +_AVITO_BANNED_RUN = { + "gone": 0, + "failed": 0, + "blocked": 39, + "enriched": 61, + "attempted": 100, + "duration_sec": 1600, +} + + +def test_column_counts_fills_total_seen_for_detail_backfill() -> None: + """_column_counts — ровно тот путь, которым mark_done/mark_banned пишут + выделенные колонки. До фикса: (None, None) → total_seen вечно 0.""" + for mod in (app_runs, kit_runs): + total_seen, new_count = mod._column_counts(_AVITO_BANNED_RUN) + assert total_seen == 61 + assert new_count is None # ключей new_count/lots_inserted/... у бэкфилла нет + + +def test_result_keys_win_over_enriched_fallback() -> None: + """У newbuilding_enrich рядом с 'enriched' лежит 'succeeded' — приоритет за ним: + поведение источников с настоящим result-ключом не меняется.""" + for mod in (app_runs, kit_runs): + total_seen, _ = mod._column_counts({"succeeded": 7, "enriched": 3}) + assert total_seen == 7 + + +def test_zero_streak_watchdog_semantics_untouched() -> None: + """Контракт #2703 сохранён: для стрик-сторожа 'enriched' НЕ результатная + метрика — догнавший очередь бэкфилл остаётся «не измерено» и стрик не копит. + Этот тест зелёный в обеих эрах (инвариант, а не фикс).""" + for mod in (app_runs, kit_runs): + assert mod._run_result_count({"attempted": 1600, "enriched": 0, "failed": 1600}) is None + assert mod._run_result_count({"enriched": 61, "attempted": 100}) is None diff --git a/tradein-mvp/backend/tests/test_3063_pro_seller_from_agency.py b/tradein-mvp/backend/tests/test_3063_pro_seller_from_agency.py new file mode 100644 index 00000000..011a6151 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3063_pro_seller_from_agency.py @@ -0,0 +1,82 @@ +"""#3063 п.2: is_pro_seller выводится из наличия агентского блока на детальной. + +Прод-факт (23-24.08): 4 469 активных yandex-листингов с заполненным agency_name +(из detail-бэкфилла) имели is_pro_seller = NULL — признак эрозирован +SERP-затиранием до PR #3067, а detail-обогащение его и не выводило, хотя данных +достаточно. Признак идёт в оценщик trade-in. + +Два конца: +- save_detail_enrichment пишет is_pro_seller=TRUE при известном agency_name + (fill-only COALESCE; отсутствие блока НЕ доказывает «частник» — п.3 задачи); +- миграция 271 бэкфиллит уже существующие строки («починка разбора ≠ починка + строк»: UPDATE прошлого задним числом никто не перезапишет). + +Красный на origin/main по ЗНАЧЕНИЮ: в параметрах UPDATE нет ключа +is_pro_seller → .get() даёт None, не True. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +from scraper_kit.providers.yandex.detail import DetailEnrichment, save_detail_enrichment + +_MIGRATION = ( + Path(__file__).resolve().parents[1] / "data" / "sql" / "271_listings_pro_seller_from_agency.sql" +) + + +class _CaptureDb: + def __init__(self) -> None: + self.params: list[dict[str, Any]] = [] + + def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any: + if params: + self.params.append(params) + return MagicMock(rowcount=1) + + def commit(self) -> None: + pass + + +def _update_params(db: _CaptureDb) -> dict[str, Any]: + upd = [p for p in db.params if "listing_id" in p] + assert upd, "save_detail_enrichment не выполнил UPDATE" + return upd[-1] + + +def test_agency_block_derives_pro_seller_true() -> None: + db = _CaptureDb() + save_detail_enrichment( + db, 101, DetailEnrichment(offer_id="o1", source_url="https://x/1", agency_name="Этажи") + ) # type: ignore[arg-type] + assert _update_params(db).get("is_pro_seller") is True, ( + "агентский блок известен, а is_pro_seller не выводится — " + "4 469 строк на проде копились именно так" + ) + + +def test_no_agency_block_leaves_pro_seller_untouched() -> None: + """Отсутствие блока — NULL в параметре: COALESCE сохранит ранее известное + значение, и «частник» из этого НЕ выводится (п.3 задачи — отдельно).""" + db = _CaptureDb() + save_detail_enrichment( + db, 102, DetailEnrichment(offer_id="o2", source_url="https://x/2", agency_name=None) + ) # type: ignore[arg-type] + assert _update_params(db).get("is_pro_seller") is None + + +def test_backfill_migration_shape() -> None: + """Миграция существует, идемпотентна (IS DISTINCT FROM TRUE), не трогает + is_homeowner и пустые имена агентств.""" + sql = _MIGRATION.read_text() + assert "SET is_pro_seller = TRUE" in sql + assert "agency_name IS NOT NULL" in sql + assert "IS DISTINCT FROM TRUE" in sql + assert "is_homeowner" not in sql.split("--")[0] or "SET is_homeowner" not in sql diff --git a/tradein-mvp/backend/tests/test_3063_seller_fields_not_eroded.py b/tradein-mvp/backend/tests/test_3063_seller_fields_not_eroded.py new file mode 100644 index 00000000..6a2b4353 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3063_seller_fields_not_eroded.py @@ -0,0 +1,244 @@ +"""#3063: бедный re-scrape больше не стирает признаки продавца. + +Апсерт listings присваивал шесть полей напрямую (`= EXCLUDED.X`): +phones, is_homeowner, is_pro_seller, bargain_allowed, sale_type, metro_stations. +Комментарий рядом («Cian-specific: обновляем при каждом re-scrape») был верен, +пока в listings писал один cian, который отдаёт их на каждом проходе. Сейчас в ту +же таблицу пишут yandex/avito/domklik, чей SERP их не отдаёт вовсе — и NULL молча +затирал значение, добытое detail-обогащением. Ни ошибки, ни лога, ни изменения +статуса прогона: колонка просто откатывалась к NULL на обычном переобходе. + +Прод 23-24.08: у yandex is_homeowner NULL у 98.6 % (16 232 из 16 460) активных +листингов, is_pro_seller — у 76.6 %. + +Соседние поля (address, city, kitchen_area_m2, ceiling_height_m, +mortgage_available, is_apartments, is_rosreestr_checked) уже защищены COALESCE +ровно от этого — с явными объяснениями #2007/#2594/#2777. Эти шесть в защиту +просто не попали. +""" + +from __future__ import annotations + +import os +import re + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import inspect +import uuid +from typing import Any +from unittest.mock import MagicMock + +import pytest +import scraper_kit.base as base_module +from scraper_kit.base import ScrapedLot, save_listings +from sqlalchemy import text + +# Поля, которые detail добывает, а SERP чужого источника не отдаёт. +ERODED_FIELDS = [ + "phones", + "is_homeowner", + "is_pro_seller", + "bargain_allowed", + "sale_type", + "metro_stations", +] + + +def _upsert_sql() -> str: + return inspect.getsource(base_module) + + +# ── 1. Статические гарды: работают всегда, даже без БД ─────────────────────── + + +@pytest.mark.parametrize("field", ERODED_FIELDS) +def test_field_is_coalesce_protected_in_set(field: str) -> None: + """SET присваивает поле через COALESCE, а не сырым EXCLUDED. + + Фальсификация: на коде до правки каждый кейс падает — там стоит + `is_homeowner = EXCLUDED.is_homeowner` без COALESCE. + """ + src = _upsert_sql() + assert not re.search(rf"^\s*{field} = EXCLUDED\.{field},", src, re.M), ( + f"{field} присваивается сырым EXCLUDED — бедный re-scrape затрёт значение" + ) + assert re.search( + rf"{field} = COALESCE\(\s*EXCLUDED\.{field}, listings\.{field}\s*\)", src + ), f"{field} должен присваиваться через COALESCE(EXCLUDED.{field}, listings.{field})" + + +def test_unchanged_gate_mirrors_the_set_clause() -> None: + """Правая часть `IS DISTINCT FROM` зеркалит то, что реально запишется. + + Это НЕ стилистика. Гейт #2992 решает, переписывать ли строку, сравнивая + текущие значения с ИТОГОВЫМИ (post-COALESCE). Если SET станет COALESCE, а + гейт останется на сыром EXCLUDED, они разъедутся: гейт увидит «NULL против + значения» и посчитает строку изменившейся там, где она не меняется — + вернутся ровно те лишние UPDATE и TOAST-чанки, ради которых #2992 делался. + + Гард структурный: сравниваем два кортежа поэлементно, поэтому он поймает и + будущее расхождение по ЛЮБОЙ колонке, не только по шести из #3063. + """ + src = _upsert_sql() + m = re.search( + r"WHERE \(\n(.*?)\n\s*\) IS DISTINCT FROM \(\n(.*?)\n\s*\)\n\s*OR \(listings\.last_seen_at", + src, + re.S, + ) + assert m is not None, "не найден гейт `IS DISTINCT FROM` — тест устарел вместе с кодом" + + def items(block: str) -> list[str]: + block = re.sub(r"--[^\n]*", "", block) # комментарии внутри кортежа + out: list[str] = [] + depth, cur = 0, "" + for ch in block: + if ch == "(": + depth += 1 + if ch == ")": + depth -= 1 + if ch == "," and depth == 0: + out.append(cur.strip()) + cur = "" + else: + cur += ch + if cur.strip(): + out.append(cur.strip()) + return [" ".join(x.split()) for x in out] + + left, right = items(m.group(1)), items(m.group(2)) + assert len(left) == len(right), ( + f"кортежи гейта разной длины: слева {len(left)}, справа {len(right)} — " + "сравнение поехало бы по колонкам молча" + ) + # strict=True безопасен: равенство длин уже проверено assert выше. + for i, (lhs, rhs) in enumerate(zip(left, right, strict=True)): + column = lhs.split(".")[-1] + if column == "is_active": + continue # SET пишет литерал true, справа он же — намеренно + assert column in rhs, f"позиция {i}: слева {lhs}, справа {rhs} — колонки разъехались" + if column in ERODED_FIELDS: + assert rhs.startswith("COALESCE("), ( + f"{column} в гейте сравнивается сырым EXCLUDED, а SET пишет COALESCE" + ) + + +# ── 2. Поведенческий тест на живом Postgres (в CI есть, локально skip) ─────── + + +def _live_session() -> Any | 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() + conn.execute(text("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +def _matcher() -> MagicMock: + m = MagicMock() + m.match_or_create_house.return_value = (None, 0.0, "no_address") + m.upsert_listing_source.return_value = None + return m + + +def _lot(src_id: str, *, rich: bool) -> ScrapedLot: + """rich=True — как detail-обогащение; rich=False — как бедный SERP чужого + источника (поля не заполнены вовсе).""" + extra: dict[str, Any] = {} + if rich: + extra = { + "is_homeowner": True, + "is_pro_seller": False, + # phones — jsonb вида [{countryCode, number, type}, ...] + # (019_listings_alter_cian.sql:41), а не список строк. + "phones": [{"countryCode": "+7", "number": "9000000000", "type": "mobile"}], + "sale_type": "free", + } + return ScrapedLot( + source="cian", + source_url=f"https://ekb.cian.ru/sale/flat/t3063-{src_id}/", + source_id=f"t3063-{src_id}", + price_rub=5_000_000, + **extra, + ) + + +def _cleanup(db: Any) -> None: + try: + db.rollback() + db.execute( + text( + "DELETE FROM listings_snapshots WHERE listing_id IN " + "(SELECT id FROM listings WHERE source='cian' AND source_id LIKE 't3063-%')" + ) + ) + db.execute(text("DELETE FROM listing_sources WHERE ext_id LIKE 't3063-%'")) + db.execute(text("DELETE FROM listings WHERE source='cian' AND source_id LIKE 't3063-%'")) + db.commit() + finally: + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_poor_rescrape_does_not_erase_seller_fields() -> None: + """Головной: обогащённая строка переживает последующий бедный проход. + + На коде до правки второй save_listings обнуляет is_homeowner/phones/sale_type. + """ + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings(db, [_lot(sid, rich=True)], matcher=_matcher(), region_code=66) + save_listings(db, [_lot(sid, rich=False)], matcher=_matcher(), region_code=66) + row = db.execute( + text( + "SELECT is_homeowner, is_pro_seller, phones, sale_type FROM listings " + "WHERE source='cian' AND source_id = :sid" + ), + {"sid": f"t3063-{sid}"}, + ).fetchone() + assert row is not None, "строка исчезла" + assert row.is_homeowner is True, "бедный re-scrape стёр is_homeowner" + assert row.is_pro_seller is False, "бедный re-scrape стёр is_pro_seller (False -> NULL)" + assert row.phones is not None, "бедный re-scrape стёр phones" + assert row.sale_type == "free", "бедный re-scrape стёр sale_type" + finally: + _cleanup(db) + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_change_still_overwrites() -> None: + """Обратная сторона: COALESCE не превращает поля в write-once. + + Настоящая смена признака (частник -> агентство) приходит НЕ NULL'ом, значит + обязана перезаписать. Без этого теста фикс мог бы «защитить» данные ценой + того, что они перестали бы обновляться вообще. + """ + db = _live_session() + sid = uuid.uuid4().hex[:8] + try: + save_listings(db, [_lot(sid, rich=True)], matcher=_matcher(), region_code=66) + flipped = _lot(sid, rich=True) + flipped.is_homeowner = False + flipped.is_pro_seller = True + save_listings(db, [flipped], matcher=_matcher(), region_code=66) + row = db.execute( + text( + "SELECT is_homeowner, is_pro_seller FROM listings " + "WHERE source='cian' AND source_id = :sid" + ), + {"sid": f"t3063-{sid}"}, + ).fetchone() + assert row.is_homeowner is False, "настоящая смена признака не записалась" + assert row.is_pro_seller is True, "настоящая смена признака не записалась" + finally: + _cleanup(db) diff --git a/tradein-mvp/backend/tests/test_3074_avito_anchor_checkpoint.py b/tradein-mvp/backend/tests/test_3074_avito_anchor_checkpoint.py new file mode 100644 index 00000000..f7c0b488 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3074_avito_anchor_checkpoint.py @@ -0,0 +1,175 @@ +"""Чекпоинт по якорям для avito_city_sweep (#3074). + +Прод-факт, из которого выросла задача. Типичный итог свипа: + + {"anchors_done": 1, "anchors_total": 5, ..., "enrichment_abort_note": + "detail enrichment aborted (Avito detail firewall/soft-block ...)"} + +То есть прогон срывается блокировкой на ПЕРВОМ из пяти якорей — 25 банов за +60 дней. Без чекпоинта следующий прогон снова начинает с первого якоря, +упирается в ту же стену, и якоря 2-5 не собираются никогда. + +Ключ чекпоинта — ИМЯ якоря, а не его индекс: состав списка зависит от +`city_slug`, и позиция в нём не устойчива между городами. + +ИНВАРИАНТ, РАДИ КОТОРОГО ТЕСТ. В чекпоинт попадает только якорь, пройденный до +конца. Ветка блокировки делает `return` и до записи не доходит, а вот +`except Exception` — доходит: якорь упал, но цикл продолжается. Записать такой +якорь как пройденный значило бы, что следующий прогон его пропустит и +объявления оттуда не соберутся НИКОГДА, причём молча — прогон завершится +штатно. Ровно та же граница, что у combo в yandex-свипе. +""" + +from __future__ import annotations + +import os + +# Settings собирается автофикстурой conftest'а и требует database_url. Выставляем +# до остальных импортов — так же, как в test_3074_yandex_sweep_checkpoint.py. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import json +import types +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +ANCHOR_A = (56.83, 60.60, "ekb-center") +ANCHOR_B = (56.79, 60.63, "ekb-south") + + +class _FakeDb: + """Резюм-SELECT отдаёт counters предшественника; heartbeat'ы записываются.""" + + def __init__(self, prev_counters: dict[str, Any] | None = None) -> None: + self.prev_counters = prev_counters or {} + self.heartbeats: list[dict[str, Any]] = [] + + def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any: + if params and "counters" in params: + self.heartbeats.append(json.loads(params["counters"])) + return MagicMock() + if params and "rid" in params: + return MagicMock( + fetchone=lambda: types.SimpleNamespace(counters=self.prev_counters) + ) + return MagicMock() + + def commit(self) -> None: ... + def rollback(self) -> None: ... + + +class _FakeAsyncSession: + def __init__(self, *_a: Any, **_kw: Any) -> None: ... + async def __aenter__(self) -> _FakeAsyncSession: + return self + + async def __aexit__(self, *_e: Any) -> None: + return None + + +class _FakeScraper: + """Двойник AvitoScraper: помнит, за какими якорями реально ходили.""" + + visited: list[tuple[float, float]] = [] # noqa: RUF012 — тестовый сборник + raise_on: tuple[float, float] | None = None + + def __init__(self, *_a: Any, **_kw: Any) -> None: + self._browser = None + self._cffi = None + + async def fetch_around(self, lat: float, lon: float, *_a: Any, **_kw: Any) -> list: + _FakeScraper.visited.append((lat, lon)) + if _FakeScraper.raise_on == (lat, lon): + raise RuntimeError("якорь упал по не-баново́й причине") + return [] + + +def _config() -> types.SimpleNamespace: + return types.SimpleNamespace( + scraper_fetch_mode="cffi", + scraper_proxy_url=None, + use_proxy_pool_browser=False, + browser_http_endpoint=None, + environment="test", + avito_serp_ok_not_banned=True, + ) + + +async def _run(prev: dict[str, Any] | None, raise_on: tuple[float, float] | None = None): + from scraper_kit.orchestration import pipeline as pl + + _FakeScraper.visited = [] + _FakeScraper.raise_on = raise_on + db = _FakeDb(prev) + + with ( + patch.object(pl, "AvitoScraper", _FakeScraper), + patch.object(pl, "AsyncSession", _FakeAsyncSession), + patch.object(pl, "save_listings", lambda *_a, **_kw: (0, 0)), + patch.object(pl.runs, "is_cancelled", lambda *_a: False), + ): + await pl.run_avito_city_sweep( + db, # type: ignore[arg-type] + run_id=7001, + config=_config(), + matcher=MagicMock(), + enrichment=MagicMock(), + anchors=[ANCHOR_A, ANCHOR_B], + enrich_houses=False, + enrich_imv=False, + detail_top_n=0, + resume_run_id=6999 if prev is not None else None, + ) + return db + + +def _last_checkpoint(db: _FakeDb) -> list[str]: + with_ckpt = [hb for hb in db.heartbeats if "done_buckets" in hb] + assert with_ckpt, "ни один heartbeat не унёс done_buckets — чекпоинт не персистится" + return with_ckpt[-1]["done_buckets"] + + +@pytest.mark.asyncio +async def test_checkpointed_anchor_is_skipped_without_a_single_request() -> None: + """Якорь из чекпоинта не опрашивается вовсе — ни одного обращения к источнику. + + Ядро задачи: до фикса повторный прогон снова шёл в первый якорь и снова + получал там бан. + """ + await _run({"done_buckets": ["ekb-center"]}) + + assert (ANCHOR_A[0], ANCHOR_A[1]) not in _FakeScraper.visited, ( + "якорь из чекпоинта всё-таки опрашивали" + ) + assert (ANCHOR_B[0], ANCHOR_B[1]) in _FakeScraper.visited, "второй якорь не обошли" + + +@pytest.mark.asyncio +async def test_checkpoint_accumulates_over_inherited() -> None: + """Пройденный якорь дописывается поверх унаследованных, а не затирает их.""" + db = await _run({"done_buckets": ["ekb-center"]}) + assert _last_checkpoint(db) == ["ekb-center", "ekb-south"] + + +@pytest.mark.asyncio +async def test_without_resume_all_anchors_are_visited() -> None: + """Без чекпоинта поведение прежнее — обходятся все якоря.""" + db = await _run(None) + assert len(_FakeScraper.visited) == 2 + assert _last_checkpoint(db) == ["ekb-center", "ekb-south"] + + +@pytest.mark.asyncio +async def test_failed_anchor_does_not_enter_checkpoint() -> None: + """Упавший якорь НЕ считается пройденным. + + Иначе следующий прогон пропустит его навсегда, и это будет незаметно: + прогон завершается штатно, просто часть города не собирается никогда. + """ + db = await _run(None, raise_on=(ANCHOR_A[0], ANCHOR_A[1])) + + ckpt = _last_checkpoint(db) + assert "ekb-center" not in ckpt, "упавший якорь попал в чекпоинт" + assert "ekb-south" in ckpt, "исправный якорь не зафиксирован" diff --git a/tradein-mvp/backend/tests/test_3074_checkpoint_survives_claim.py b/tradein-mvp/backend/tests/test_3074_checkpoint_survives_claim.py new file mode 100644 index 00000000..04ed87af --- /dev/null +++ b/tradein-mvp/backend/tests/test_3074_checkpoint_survives_claim.py @@ -0,0 +1,63 @@ +"""#3074: чекпоинт предшественника наследуется в counters НОВОГО прогона при claim. + +Прод-факт (Beget, 23.08): прогон 4707 подхватил чекпоинт 4117 (42 корзины, +resume_reason='ok'), был убит деплоем на 26-й минуте — ДО завершения первой +НОВОЙ корзины — и не успел ни разу написать heartbeat с done_buckets. Его +собственный чекпоинт остался пуст: следующий кандидат увидел бы no_checkpoint, +и 42 корзины пропали бы, хотя цепочка (resume_chain=2) ещё позволяла подхват. + +Фикс: _resume_decision при вердикте 'ok' кладёт done_buckets предшественника в +counters-заготовку нового прогона — она пишется в БД прямо при claim +(_pick_resume → update_heartbeat), до старта пайплайна. Heartbeat мержит jsonb, +первый настоящий bucket-heartbeat перезапишет ключ надмножеством. + +Красный на origin/main по ЗНАЧЕНИЮ: 'done_buckets' отсутствует в вердикте. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from types import SimpleNamespace +from typing import Any + +from scraper_kit.orchestration import scheduler as sched + + +def _candidate(**over: Any) -> SimpleNamespace: + """Кандидат из _RESUME_CANDIDATE_SQL: прогон 4117 как он лежал на проде 23.08.""" + base = { + "prev_id": 4117, + "prev_status": "banned", + "prev_counters": { + "resume_chain": 1, + "done_buckets": [f"room_1_komn:{i}:0" for i in range(42)], + }, + "same_params": True, + "age_h": 164.8, + "interval_days": "7", + } + base.update(over) + return SimpleNamespace(**base) + + +def test_ok_verdict_carries_predecessor_checkpoint() -> None: + """Вердикт 'ok' несёт done_buckets предшественника — чекпоинт персистится при + claim и переживает обрыв до первой новой корзины (кейс 4707).""" + resume_id, verdict = sched._resume_decision(_candidate()) + assert resume_id == 4117 + assert verdict["resume_reason"] == "ok" + assert verdict.get("done_buckets") == sorted(f"room_1_komn:{i}:0" for i in range(42)), ( + "counters-заготовка нового прогона не содержит чекпоинт предшественника — " + "обрыв до первой новой корзины снова потеряет всю цепочку" + ) + + +def test_refusal_verdict_carries_no_checkpoint() -> None: + """Отказ от подхвата чекпоинт не наследует: прогон честно идёт с нуля, и его + counters не должны врать, будто корзины предшественника уже собраны.""" + _id, verdict = sched._resume_decision(_candidate(same_params=False)) + assert verdict["resume_reason"] == "params_changed" + assert "done_buckets" not in verdict diff --git a/tradein-mvp/backend/tests/test_3074_yandex_sweep_checkpoint.py b/tradein-mvp/backend/tests/test_3074_yandex_sweep_checkpoint.py new file mode 100644 index 00000000..45dbe87c --- /dev/null +++ b/tradein-mvp/backend/tests/test_3074_yandex_sweep_checkpoint.py @@ -0,0 +1,241 @@ +"""#3074: чекпоинты для yandex_city_sweep — combo как единица возобновления. + +Из таблицы убитых деплоем прогонов (#3074): yandex_city_sweep 15.08 прожил 65 мин, +12.08 — 2 ч 33 мин; оба потеряны целиком, потому что у свипа не было чекпоинтов +вовсе (avito/cian full-load обзавелись ими в #930/#2845). + +Единица чекпоинта — combo (сегмент × комнатность × ценовой диапазон): ровно то, +чем цикл обхода уже итерируется, метка та же, что у бакетов avito +("vtorichka/room_1:0-3000000"). Три слоя: + провайдер — skip_combos (ни одного HTTP по собранным) + on_combo для каждого + ПРОЙДЕННОГО combo, включая пустые (иначе пустой combo не попадал + бы в чекпоинт и перечитывался бы вечно); + пайплайн — done_combos → heartbeat c done_buckets (мерж jsonb); + планировщик — resume_run_id=_pick_resume(...) в диспатче. + +Красные на origin/main: планировщик передаёт None литералом (по значению), +on_combo не вызывается для пустых combo (по значению). +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import json +import types +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from scraper_kit.orchestration import scheduler as sched +from scraper_kit.providers.yandex.serp import YandexRealtyScraper + +# ── провайдер: заглушка gate-API ───────────────────────────────────────────── + + +def _gate_payload(entities: list[dict[str, Any]], total_pages: int = 1) -> str: + offers = {"entities": entities, "pager": {"totalPages": total_pages}} + return json.dumps({"response": {"search": {"offers": offers}}}) + + +class _RecordingBrowser: + """BrowserFetcher-заглушка: отвечает одним и тем же телом, считает вызовы.""" + + def __init__(self, body: str) -> None: + self.body = body + self.urls: list[str] = [] + + async def fetch(self, url: str) -> str: + self.urls.append(url) + return self.body + + +def _scraper(body: str) -> YandexRealtyScraper: + s = YandexRealtyScraper(types.SimpleNamespace()) + s._browser = _RecordingBrowser(body) # type: ignore[assignment] + s.sleep_between_requests = _no_sleep # type: ignore[method-assign] + return s + + +async def _no_sleep() -> None: + pass + + +@pytest.mark.asyncio +async def test_on_combo_fires_for_complete_empty_combo() -> None: + """Пройденный до конца combo с ПУСТОЙ выдачей обязан дойти до on_combo — + иначе он не попадёт в чекпоинт и будет перечитываться каждым продолжением. + На origin/main on_combo вызывался только при непустых new_lots.""" + scraper = _scraper(_gate_payload([])) + seen_labels: list[str] = [] + + await scraper.fetch_around_multi_room( + 56.84, + 60.60, + 1000, + max_pages=1, + rooms_list=["room_1"], + price_ranges=[(None, 3_000_000)], + segments=["NO"], + on_combo=lambda label, lots: seen_labels.append(label), + ) + + assert seen_labels == ["vtorichka/room_1:None-3000000"], ( + "пустой, но полностью пройденный combo не дошёл до on_combo — " + "в чекпоинт он не попадёт никогда" + ) + + +@pytest.mark.asyncio +async def test_skip_combos_makes_zero_http_requests() -> None: + """Combo из чекпоинта не порождает ни одного HTTP-запроса и не зовёт on_combo.""" + scraper = _scraper(_gate_payload([])) + seen_labels: list[str] = [] + + await scraper.fetch_around_multi_room( + 56.84, + 60.60, + 1000, + max_pages=1, + rooms_list=["room_1", "room_2"], + price_ranges=[(None, 3_000_000)], + segments=["NO"], + on_combo=lambda label, lots: seen_labels.append(label), + skip_combos={"vtorichka/room_1:None-3000000"}, + ) + + browser: _RecordingBrowser = scraper._browser # type: ignore[assignment] + assert seen_labels == ["vtorichka/room_2:None-3000000"] + assert len(browser.urls) == 1, f"скипнутый combo всё равно ходил в сеть: {browser.urls}" + + +# ── планировщик: диспатч отдаёт точку в пайплайн (зеркало test_930) ───────── + + +def _candidate(**over: Any) -> types.SimpleNamespace: + base = { + "prev_id": 4117, + "prev_status": "banned", + "prev_counters": {"done_buckets": ["vtorichka/room_1:None-3000000"]}, + "same_params": True, + "age_h": 20.0, + "interval_days": "1", + } + base.update(over) + return types.SimpleNamespace(**base) + + +class _FakeDb: + def __init__(self, row: Any) -> None: + self.row = row + + def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any: + if params and "counters" in params: + return MagicMock() + return MagicMock(fetchone=lambda: self.row) + + def commit(self) -> None: + pass + + +async def test_scheduler_hands_checkpoint_to_yandex_sweep() -> None: + """_job_yandex_city_sweep передаёт resume_run_id, а не None литералом. + Красный на origin/main по значению (ключа в kwargs нет → None != 4117).""" + db = _FakeDb(_candidate()) + captured: dict[str, Any] = {} + + async def _spy(*_a: Any, **kw: Any) -> None: + captured.update(kw) + + with patch.object(sched, "run_yandex_city_sweep", _spy): + await sched._job_yandex_city_sweep(db, 5000, {}, MagicMock()) + + assert captured.get("resume_run_id") == 4117, ( + "планировщик не отдал чекпоинт яндекс-свипу — прогон пойдёт с нуля" + ) + + +# ── пайплайн: проводка skip→scraper и done_buckets→heartbeat ──────────────── + + +class _SweepFakeDb: + """Резюм-SELECT отдаёт counters предшественника; UPDATE'ы записываются.""" + + def __init__(self, prev_counters: dict[str, Any]) -> None: + self.prev_counters = prev_counters + self.heartbeats: list[dict[str, Any]] = [] + + def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any: + if params and "counters" in params: + self.heartbeats.append(json.loads(params["counters"])) + return MagicMock() + if params and "rid" in params: + return MagicMock(fetchone=lambda: types.SimpleNamespace(counters=self.prev_counters)) + return MagicMock() # is_cancelled/сторожа — best-effort, глотаем + + def commit(self) -> None: + pass + + def rollback(self) -> None: + pass + + +class _FakeSweepScraper: + """Двойник YandexRealtyScraper для пайплайна: фиксирует kwargs fetch'а, + отдаёт один пройденный пустой combo через on_combo.""" + + captured: dict[str, Any] = {} # noqa: RUF012 — тестовый сборник kwargs + gate_fetch_attempts = 1 + gate_fetch_failures = 0 + + def __init__(self, *_a: Any, **_kw: Any) -> None: + pass + + async def __aenter__(self) -> _FakeSweepScraper: + return self + + async def __aexit__(self, *_exc: Any) -> None: + return None + + async def fetch_around_multi_room(self, *_a: Any, **kw: Any) -> list: + _FakeSweepScraper.captured = dict(kw) + on_combo = kw.get("on_combo") + if on_combo is not None: + on_combo("vtorichka/room_2:None-3000000", []) + return [] + + +async def test_pipeline_resumes_and_checkpoints() -> None: + """run_yandex_city_sweep: чекпоинт предшественника уезжает в scraper как + skip_combos, пройденный combo дописывается в done_buckets heartbeat'а.""" + from scraper_kit.orchestration import pipeline as pl + + prev = {"done_buckets": ["vtorichka/room_1:None-3000000"]} + db = _SweepFakeDb(prev) + + with ( + patch.object(pl, "YandexRealtyScraper", _FakeSweepScraper), + patch.object(pl.runs, "is_cancelled", lambda *_a: False), + ): + await pl.run_yandex_city_sweep( + db, # type: ignore[arg-type] + run_id=5001, + config=types.SimpleNamespace(scraper_proxy_url=None), + matcher=MagicMock(), + enrichment=MagicMock(), + enrich_address=False, + resume_run_id=4999, + ) + + assert _FakeSweepScraper.captured.get("skip_combos") == {"vtorichka/room_1:None-3000000"}, ( + "чекпоинт предшественника не доехал до scraper'а" + ) + + with_ckpt = [hb for hb in db.heartbeats if "done_buckets" in hb] + assert with_ckpt, "ни один heartbeat не унёс done_buckets — чекпоинт не персистится" + assert with_ckpt[-1]["done_buckets"] == [ + "vtorichka/room_1:None-3000000", + "vtorichka/room_2:None-3000000", + ], "done_buckets не аккумулирует пройденный combo поверх унаследованных" diff --git a/tradein-mvp/backend/tests/test_3082_estimate_concurrency_cap.py b/tradein-mvp/backend/tests/test_3082_estimate_concurrency_cap.py new file mode 100644 index 00000000..3d389f24 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3082_estimate_concurrency_cap.py @@ -0,0 +1,144 @@ +"""#3082: потолок одновременных оценок на POST /estimate. + +Рейт-лимит меряет частоту (300/60с вправе стартовать в одну секунду), квота — +счётная и помесячная: параллелизм не ограничивал никто, и пила одновременных +оценок (0.8–2.4с каждая, соединение общего пула + внешние тиры) выедала пул и +тормозила весь /api/v1/*. Фикс — asyncio.Semaphore по образцу public/mera.py. + +Красный на origin/main по ЗНАЧЕНИЮ: без семафора все 5 конкурентных запросов +проходят (0×429), с ним пятый получает быстрый 429 «занят». Никаких обращений +к новым именам модуля напрямую — monkeypatch констант с raising=False, чтобы +на main тест падал ассертом о поведении, а не AttributeError (см. +red-must-mean-wrong-value). +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import asyncio +from datetime import UTC, datetime, timedelta +from unittest.mock import patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from app.api.v1 import trade_in as trade_in_module +from app.core.db import get_db +from app.core.ratelimit import SlidingWindowLimiter +from app.schemas.trade_in import AggregatedEstimate + +_CONCURRENCY = 4 # прод-значение _ESTIMATE_CONCURRENCY; здесь литералом (см. док-стринг) + + +def _canned_estimate() -> AggregatedEstimate: + return AggregatedEstimate( + estimate_id=uuid4(), + median_price_rub=5_000_000, + range_low_rub=4_500_000, + range_high_rub=5_500_000, + median_price_per_m2=100_000, + confidence="medium", + n_analogs=8, + period_months=24, + analogs=[], + actual_deals=[], + expires_at=datetime.now(tz=UTC) + timedelta(hours=24), + ) + + +@pytest.fixture() +def app() -> FastAPI: + application = FastAPI() + application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + + def _override_db(): + yield None + + application.dependency_overrides[get_db] = _override_db + return application + + +@pytest.fixture(autouse=True) +def _wide_rate_limiter(monkeypatch: pytest.MonkeyPatch) -> None: + """Рейт-лимит не должен мешать тесту параллелизма: 100/60с.""" + monkeypatch.setattr( + trade_in_module, "_estimate_limiter", SlidingWindowLimiter(limit=100, window_s=60.0) + ) + + +async def test_fifth_concurrent_estimate_gets_fast_429( + app: FastAPI, monkeypatch: pytest.MonkeyPatch +) -> None: + """4 оценки висят в работе → 5-я не ждёт в очереди, а быстро получает 429 + с текстом про занятость и Retry-After; после освобождения слотов те же 4 + завершаются 200 и следующий запрос снова проходит (release в finally).""" + # raising=False: на main этих имён нет — тест должен упасть ассертом ниже, + # а не AttributeError здесь. + monkeypatch.setattr(trade_in_module, "_ESTIMATE_SLOT_WAIT_S", 0.1, raising=False) + monkeypatch.setattr( + trade_in_module, "_estimate_slots", asyncio.Semaphore(_CONCURRENCY), raising=False + ) + + gate = asyncio.Event() + + async def _slow_estimate(*args, **kwargs) -> AggregatedEstimate: + await gate.wait() + return _canned_estimate() + + payload = {"address": "г. Екатеринбург, ул. Малышева, 1", "area_m2": 50.0, "rooms": 2} + with ( + patch("app.services.account_quota.check_and_raise"), + patch("app.services.account_quota.increment", return_value=True), + patch("app.services.estimator.estimate_quality", new=_slow_estimate), + ): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + holders = [ + asyncio.create_task(client.post("/api/v1/trade-in/estimate", json=payload)) + for _ in range(_CONCURRENCY) + ] + # Дать держателям дойти до acquire и занять все слоты. + await asyncio.sleep(0.05) + + fifth = await client.post("/api/v1/trade-in/estimate", json=payload) + assert fifth.status_code == 429, ( + f"5-й конкурентный запрос прошёл ({fifth.status_code}) — " + "потолка одновременности нет" + ) + assert "занят" in fifth.json()["detail"] + assert "Retry-After" in fifth.headers + + gate.set() + done = await asyncio.gather(*holders) + assert [r.status_code for r in done] == [200] * _CONCURRENCY + + # Слоты вернулись (release в finally) — новый запрос проходит. + gate.set() + again = await client.post("/api/v1/trade-in/estimate", json=payload) + assert again.status_code == 200 + + +async def test_within_limit_behaviour_unchanged( + app: FastAPI, monkeypatch: pytest.MonkeyPatch +) -> None: + """В пределах лимита семафор прозрачен: одиночный запрос — 200, как раньше.""" + monkeypatch.setattr(trade_in_module, "_ESTIMATE_SLOT_WAIT_S", 0.1, raising=False) + + async def _fast_estimate(*args, **kwargs) -> AggregatedEstimate: + return _canned_estimate() + + payload = {"address": "г. Екатеринбург, ул. Малышева, 1", "area_m2": 50.0, "rooms": 2} + with ( + patch("app.services.account_quota.check_and_raise"), + patch("app.services.account_quota.increment", return_value=True), + patch("app.services.estimator.estimate_quality", new=_fast_estimate), + ): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.post("/api/v1/trade-in/estimate", json=payload) + assert resp.status_code == 200 diff --git a/tradein-mvp/backend/tests/test_781_quality_gate.py b/tradein-mvp/backend/tests/test_781_quality_gate.py index ac689067..26452d64 100644 --- a/tradein-mvp/backend/tests/test_781_quality_gate.py +++ b/tradein-mvp/backend/tests/test_781_quality_gate.py @@ -166,9 +166,9 @@ def test_755_anchor_n2_does_not_fire_headline_stays_radius() -> None: "anchor with n=2 comps must NOT fire (min_comps=4 post-#755)" ) # Confidence from 5 radius analogs must not be "high" (unique_addr < 7 threshold). - assert ( - est.confidence != "high" - ), f"Confidence should not be 'high' with 5 radius analogs, got {est.confidence!r}" + assert est.confidence != "high", ( + 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: @@ -250,9 +250,9 @@ def test_753_dedup_hash_stable_across_reprice_with_source_id() -> None: lot_original = _make_scraped_lot(source_url=url, source_id=sid, price_rub=5_000_000) lot_repriced = _make_scraped_lot(source_url=url, source_id=sid, price_rub=4_900_000) - assert ( - lot_original.compute_dedup_hash() == lot_repriced.compute_dedup_hash() - ), "dedup_hash must be stable across reprice when source_id is present (#753)" + assert lot_original.compute_dedup_hash() == lot_repriced.compute_dedup_hash(), ( + "dedup_hash must be stable across reprice when source_id is present (#753)" + ) def test_753_dedup_hash_stable_across_context_query_no_source_id() -> None: @@ -275,9 +275,9 @@ def test_753_dedup_hash_stable_across_context_query_no_source_id() -> None: source_id=None, price_rub=5_000_000, ) - assert ( - lot_v1.compute_dedup_hash() == lot_v2.compute_dedup_hash() - ), "dedup_hash must strip ?context= query and match for same listing URL (#753)" + assert lot_v1.compute_dedup_hash() == lot_v2.compute_dedup_hash(), ( + "dedup_hash must strip ?context= query and match for same listing URL (#753)" + ) def test_753_dedup_hash_differs_for_distinct_listings() -> None: @@ -312,9 +312,9 @@ def test_753_dedup_hash_source_id_takes_priority_over_url() -> None: source_url="https://www.avito.ru/ru/offer/9999?some=other", source_id="9999", ) - assert ( - lot_canonical.compute_dedup_hash() == lot_redirect.compute_dedup_hash() - ), "source_id takes priority: same source_id -> same hash regardless of URL (#753)" + assert lot_canonical.compute_dedup_hash() == lot_redirect.compute_dedup_hash(), ( + "source_id takes priority: same source_id -> same hash regardless of URL (#753)" + ) # --------------------------------------------------------------------------- @@ -353,12 +353,12 @@ def test_773_expected_sold_positive_on_anchor_only_path( # Anchor gave a valid headline. assert est.median_price_rub > 0, "anchor must produce non-zero headline" # expected_sold must also be computed (not skipped by empty listings_clean). - assert ( - est.expected_sold_price_rub is not None - ), "expected_sold_price_rub must not be None on anchor-only path when ratio present (#773)" - assert ( - est.expected_sold_price_rub > 0 - ), f"expected_sold_price_rub must be > 0, got {est.expected_sold_price_rub} (#773)" + assert est.expected_sold_price_rub is not None, ( + "expected_sold_price_rub must not be None on anchor-only path when ratio present (#773)" + ) + assert est.expected_sold_price_rub > 0, ( + f"expected_sold_price_rub must be > 0, got {est.expected_sold_price_rub} (#773)" + ) # Consistency: sold = asking * ratio. assert est.expected_sold_price_rub == round(est.median_price_rub * 0.92) @@ -376,9 +376,9 @@ def test_773_expected_sold_null_when_no_ratio_anchor_only() -> None: ratio_tuple=(None, None), ) assert est.median_price_rub > 0 # anchor headline is valid - assert ( - est.expected_sold_price_rub is None - ), "expected_sold must be None when ratio is None, even on anchor-only path (#773)" + assert est.expected_sold_price_rub is None, ( + "expected_sold must be None when ratio is None, even on anchor-only path (#773)" + ) # --------------------------------------------------------------------------- @@ -437,9 +437,9 @@ async def test_754_block_page_http200_page_gt1_graceful_return() -> None: result = await scraper.fetch_around(56.838, 60.605, radius_m=1000, pages=2) # The important invariant: no exception raised, result is a list. - assert isinstance( - result, list - ), "fetch_around must return a list, not raise, when page>1 returns 0 cards (#754)" + assert isinstance(result, list), ( + "fetch_around must return a list, not raise, when page>1 returns 0 cards (#754)" + ) # --------------------------------------------------------------------------- @@ -476,9 +476,9 @@ def test_740_median_zero_gives_insufficient_data_true() -> None: actual_deals=[], expires_at=datetime(2026, 6, 1, tzinfo=UTC), ) - assert ( - est.insufficient_data is True - ), "insufficient_data must be True when median_price_rub=0 (#740)" + assert est.insufficient_data is True, ( + "insufficient_data must be True when median_price_rub=0 (#740)" + ) assert est.median_price_rub == 0 @@ -505,9 +505,9 @@ def test_740_positive_median_gives_insufficient_data_false() -> None: actual_deals=[], expires_at=datetime(2026, 6, 1, tzinfo=UTC), ) - assert ( - est.insufficient_data is False - ), "insufficient_data must be False when median_price_rub>0 (#740)" + assert est.insufficient_data is False, ( + "insufficient_data must be False when median_price_rub>0 (#740)" + ) def test_740_insufficient_data_serialized_in_model_dump() -> None: diff --git a/tradein-mvp/backend/tests/test_account_quota.py b/tradein-mvp/backend/tests/test_account_quota.py index 362f4b4a..083fe7dc 100644 --- a/tradein-mvp/backend/tests/test_account_quota.py +++ b/tradein-mvp/backend/tests/test_account_quota.py @@ -5,7 +5,9 @@ Coverage: (b) обычный pilot-юзер блокируется на 16-м запросе (429 + нужный detail) (c) increment растит used счётчик (d) get_status корректен для different сценариев - (e) отсутствие заголовка X-Authenticated-User = unlimited (fail-open) + (e) отсутствие заголовка X-Authenticated-User → анонимная квота, НЕ unlimited + (#b2c-antiabuse-2 — fail-open убран из дефолтного пути; opt-in dev-флаг + settings.quota_dev_fail_open восстанавливает старое unlimited-поведение) (f) #747 — атомарно-условный increment (TOCTOU fix) (g) account_quota_overrides.monthly_limit — персональный лимит вместо negative-used хака @@ -13,6 +15,8 @@ Coverage: (i) account_quota_overrides.unlimited — data-driven безлимит (migration 191): kopylov (перенесён из хардкода) и praktika (восстановленный пилот) безлимитны через таблицу, не через код + (j) #b2c-antiabuse-2: account_quota.*(default_limit=...) — anon-ключ falls back + на переданный default (НЕ MONTHLY_LIMIT) при отсутствии override-строки DB мокируется через _FakeDB (роутинг по SQL-тексту, см. ниже) — реальная БД не требуется. @@ -403,7 +407,42 @@ def quota_app() -> FastAPI: def test_quota_endpoint_no_header(quota_app: FastAPI) -> None: - """GET /quota без заголовка → unlimited=True, remaining=15.""" + """GET /quota без заголовка (анонимный, #b2c-antiabuse-2) → НЕ безлимитный: + анонимная квота (anon-session cookie + IP), unlimited=False, limit = + settings.anon_estimate_quota_limit. Fail-open больше НЕ дефолт — см. + test_quota_dev_fail_open_flag_restores_unlimited для явного opt-in.""" + from app.core.config import settings + + client = TestClient(quota_app) + resp = client.get("/api/v1/trade-in/quota") + assert resp.status_code == 200 + data = resp.json() + assert data["unlimited"] is False + assert data["limit"] == settings.anon_estimate_quota_limit + assert data["remaining"] == settings.anon_estimate_quota_limit + assert data["used"] == 0 + # Подписанная анонимная session-cookie выставлена — стабильная идентичность + # анонимного пользователя между запросами (app.core.anon_session). + assert "mera_anon_sid" in resp.cookies + + +def test_quota_dev_fail_open_flag_default_false() -> None: + """settings.quota_dev_fail_open по умолчанию False — fail-open НЕ включён без + явного флага (#b2c-antiabuse-2).""" + from app.core.config import settings + + assert settings.quota_dev_fail_open is False + + +def test_quota_dev_fail_open_flag_restores_unlimited( + quota_app: FastAPI, monkeypatch: pytest.MonkeyPatch +) -> None: + """GET /quota без заголовка С явным settings.quota_dev_fail_open=True → + восстанавливает старое unlimited-поведение (dev-режим без Caddy, opt-in).""" + from app.core import config as config_module + + monkeypatch.setattr(config_module.settings, "quota_dev_fail_open", True) + client = TestClient(quota_app) resp = client.get("/api/v1/trade-in/quota") assert resp.status_code == 200 @@ -525,8 +564,12 @@ def test_estimate_admin_not_blocked(estimate_app_exhausted: FastAPI) -> None: assert resp.status_code != 429 -def test_estimate_no_header_not_blocked(estimate_app_exhausted: FastAPI) -> None: - """POST /estimate без заголовка → не 429 (fail-open, dev-режим).""" +def test_estimate_anon_blocked_when_db_reports_exhausted( + estimate_app_exhausted: FastAPI, +) -> None: + """POST /estimate без заголовка (анонимный) при used=MONTHLY_LIMIT (>> анонимного + лимита) → 429. #b2c-antiabuse-2: fail-open по умолчанию убран — аноним получает + квоту, а НЕ безлимит.""" client = TestClient(estimate_app_exhausted, raise_server_exceptions=False) resp = client.post( "/api/v1/trade-in/estimate", @@ -536,9 +579,66 @@ def test_estimate_no_header_not_blocked(estimate_app_exhausted: FastAPI) -> None "rooms": 2, }, ) + assert resp.status_code == 429 + + +def test_estimate_anon_not_blocked_under_quota(quota_app: FastAPI) -> None: + """POST /estimate без заголовка, used=0 (свежий anon-ключ, под лимитом) → НЕ + блокируется квотой (может упасть на другой ошибке — нет реального estimator/ + geocoder; здесь важно только что это не 429 от квоты).""" + client = TestClient(quota_app, raise_server_exceptions=False) + resp = client.post( + "/api/v1/trade-in/estimate", + json={ + "address": "г. Екатеринбург, ул. Малышева, 1", + "area_m2": 50.0, + "rooms": 2, + }, + ) assert resp.status_code != 429 +@pytest.fixture() +def estimate_app_anon_exhausted() -> FastAPI: + """FastAPI app где БД возвращает used == anon_estimate_quota_limit для ЛЮБОГО + ключа — аноним исчерпал СВОЙ, гораздо более строгий лимит.""" + from app.api.v1 import trade_in as trade_in_module + from app.core.config import settings + 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 _FakeDB(used=settings.anon_estimate_quota_limit) + + application.dependency_overrides[get_db] = _override_db + return application + + +def test_estimate_anon_blocked_at_anon_limit_with_correct_message( + estimate_app_anon_exhausted: FastAPI, +) -> None: + """POST /estimate анонимный, used == anon_estimate_quota_limit → 429 с + anon-специфичным detail (правильное, меньшее число — НЕ захардкоженный + MONTHLY_LIMIT=15 из LIMIT_EXHAUSTED_MESSAGE).""" + from app.core.config import settings + + client = TestClient(estimate_app_anon_exhausted, raise_server_exceptions=False) + resp = client.post( + "/api/v1/trade-in/estimate", + json={ + "address": "г. Екатеринбург, ул. Малышева, 1", + "area_m2": 50.0, + "rooms": 2, + }, + ) + assert resp.status_code == 429 + detail = resp.json()["detail"] + assert str(settings.anon_estimate_quota_limit) in detail + assert str(MONTHLY_LIMIT) not in detail + + @pytest.fixture() def estimate_app_praktika_unlimited() -> FastAPI: """FastAPI app где БД отдаёт unlimited=true для praktika (used заведомо @@ -884,3 +984,83 @@ def test_estimate_real_result_still_increments_quota(estimate_app_ok: FastAPI) - assert resp.status_code == 200 assert resp.json()["insufficient_data"] is False mock_increment.assert_called_once() + + +# --------------------------------------------------------------------------- +# (j) #b2c-antiabuse-2: account_quota.*(default_limit=...) — anon-ключ falls +# back на переданный default (НЕ MONTHLY_LIMIT) при отсутствии override-строки +# --------------------------------------------------------------------------- + + +_ANON_KEY = "anon:fake-session-id:203.0.113.7" +# Произвольное число, намеренно НЕ равное ни MONTHLY_LIMIT (15), ни текущему +# settings.anon_estimate_quota_limit (5) — тест проверяет, что переданный +# default доходит до результата, а не что совпал с какой-то настройкой. +_ANON_DEFAULT = 3 + + +def test_user_limit_anon_key_falls_back_to_custom_default() -> None: + """anon-ключ без override-строки → user_limit(default=X) возвращает X, НЕ + MONTHLY_LIMIT.""" + db = MagicMock() + db.execute.return_value = _override_result(None) + assert user_limit(db, _ANON_KEY, default=_ANON_DEFAULT) == _ANON_DEFAULT + assert user_limit(db, _ANON_KEY, default=_ANON_DEFAULT) != MONTHLY_LIMIT + + +def test_is_unlimited_anon_key_short_circuits_without_db() -> None: + """anon-ключ никогда не в roles.yaml → is_unlimited() шорткатится в False + БЕЗ похода в БД (get_role() KeyError раньше любого SELECT).""" + db = MagicMock() + assert is_unlimited(db, _ANON_KEY) is False + db.execute.assert_not_called() + + +def test_check_and_raise_anon_default_limit_blocks_at_anon_threshold() -> None: + """used == default_limit (anon) → 429, хотя это существенно МЕНЬШЕ + MONTHLY_LIMIT — доказывает, что anon использует СВОЙ лимит, а не глобальный.""" + from fastapi import HTTPException + + db = _FakeDB(used=_ANON_DEFAULT) + with pytest.raises(HTTPException) as exc_info: + check_and_raise(db, _ANON_KEY, default_limit=_ANON_DEFAULT) + assert exc_info.value.status_code == 429 + assert str(_ANON_DEFAULT) in exc_info.value.detail + + +def test_check_and_raise_anon_default_limit_allows_below_anon_threshold() -> None: + """used < default_limit (anon) → не блокируется.""" + db = _FakeDB(used=_ANON_DEFAULT - 1) + check_and_raise(db, _ANON_KEY, default_limit=_ANON_DEFAULT) # не должно поднять + + +def test_increment_anon_default_limit_respected() -> None: + """increment с anon default_limit: used=default-1 → True; used=default → False — + тот же #747 atomic-guard, применённый к anon-специфичному, а не глобальному лимиту.""" + db = _AtomicQuotaFakeDB(used=_ANON_DEFAULT - 1) + assert increment(db, _ANON_KEY, default_limit=_ANON_DEFAULT) is True + assert db.used == _ANON_DEFAULT + assert increment(db, _ANON_KEY, default_limit=_ANON_DEFAULT) is False + assert db.used == _ANON_DEFAULT + + +def test_get_status_anon_default_limit() -> None: + """get_status с anon default_limit: limit/remaining отражают anon-лимит, не + MONTHLY_LIMIT.""" + db = _FakeDB(used=1) + status = get_status(db, _ANON_KEY, default_limit=_ANON_DEFAULT) + assert status["unlimited"] is False + assert status["limit"] == _ANON_DEFAULT + assert status["used"] == 1 + assert status["remaining"] == _ANON_DEFAULT - 1 + + +def test_limit_exhausted_message_reflects_actual_limit() -> None: + """limit_exhausted_message(N) параметризован — не всегда MONTHLY_LIMIT.""" + from app.services.account_quota import limit_exhausted_message + + msg_anon = limit_exhausted_message(_ANON_DEFAULT) + assert str(_ANON_DEFAULT) in msg_anon + assert str(MONTHLY_LIMIT) not in msg_anon + # Backward-compat: LIMIT_EXHAUSTED_MESSAGE == limit_exhausted_message(MONTHLY_LIMIT). + assert limit_exhausted_message(MONTHLY_LIMIT) == LIMIT_EXHAUSTED_MESSAGE diff --git a/tradein-mvp/backend/tests/test_alerts_become_events.py b/tradein-mvp/backend/tests/test_alerts_become_events.py index 22324746..82b0b0f7 100644 --- a/tradein-mvp/backend/tests/test_alerts_become_events.py +++ b/tradein-mvp/backend/tests/test_alerts_become_events.py @@ -155,9 +155,9 @@ def test_sber_pull_stall_becomes_event(monkeypatch: pytest.MonkeyPatch) -> None: 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 не долетает" + 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: 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 e41a5c67..7879c7e7 100644 --- a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py +++ b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py @@ -505,13 +505,13 @@ def test_migration_098_band_matches_settings_default() -> None: from app.tasks.asking_to_sold_ratio import _PPM2_MIN # Lower bound — matches the 30000 literal in migration 098. - assert ( - _PPM2_MIN == 30_000 - ), f"_PPM2_MIN changed ({_PPM2_MIN}); update migration 098 seed literals to match" + assert _PPM2_MIN == 30_000, ( + f"_PPM2_MIN changed ({_PPM2_MIN}); update migration 098 seed literals to match" + ) # Upper bound — matches the 1200000 literal in migration 098. - assert ( - Settings().asking_ratio_ppm2_max == 1_200_000 - ), "asking_ratio_ppm2_max default changed; update migration 098 seed literals to match" + 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) ───────── diff --git a/tradein-mvp/backend/tests/test_auth_api.py b/tradein-mvp/backend/tests/test_auth_api.py index 9cd7b5cd..9436d1f7 100644 --- a/tradein-mvp/backend/tests/test_auth_api.py +++ b/tradein-mvp/backend/tests/test_auth_api.py @@ -826,18 +826,18 @@ async def test_login_flood_capped_by_rate_while_api_stays_responsive( # этого вердикта, а не вместо него. assert probe_latencies, "проба не сделала ни одного запроса" probe_latencies.sort() - assert ( - probe_latencies[-1] < 0.5 - ), f"худший сторонний запрос {probe_latencies[-1] * 1000:.0f}мс — API встаёт под флудом входа" + 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}с — цикл был занят" + assert len(probe_latencies) >= 10, ( + f"проба успела всего {len(probe_latencies)} раз за {elapsed:.2f}с — цикл был занят" + ) # 2. ТЕМП ограничен. Флуд предлагал больше попыток в секунду, чем разрешает # потолок — до bcrypt их доехало не больше него (запас ×1.5 на планировщик). diff --git a/tradein-mvp/backend/tests/test_avito_backfill_cadence.py b/tradein-mvp/backend/tests/test_avito_backfill_cadence.py new file mode 100644 index 00000000..81082f67 --- /dev/null +++ b/tradein-mvp/backend/tests/test_avito_backfill_cadence.py @@ -0,0 +1,79 @@ +"""Каденс avito_detail_backfill: минуты, а не сутки. + +Замер 2026-08-22 на проде: `compute_next_run_at` имеет суточную гранулярность +(`interval_days = max(1, int(...))`, целевая дата `now + interval_days`), поэтому +бэкфилл получал ровно один прогон в сутки. Прогон при этом умирает по бану через +17-83 минуты — 23 часа из 24 задание простаивало, 238 обогащённых карточек за +сутки при 9 951 активном объявлении. + +Механизм sub-hourly каденса уже существовал (`reschedule_after_minutes`, #2162, +сделан для `proxy_healthcheck`) — не был подключён. + +Тест закрепляет именно подключённость хука, а не значение интервала: 180 минут +подобраны консервативно и будут двигаться по замерам, а вот утрата хука вернёт +суточный простой молча. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from app.services.product_handlers import build_product_handlers + + +def test_backfill_has_sub_daily_cadence_hook() -> None: + """Без post_claim бэкфилл откатывается к суточной гранулярности.""" + handlers = build_product_handlers(MagicMock()) + handler = handlers["avito_detail_backfill"] + + assert handler.post_claim is not None, ( + "avito_detail_backfill остался без post_claim — каденс вернулся к " + "одному прогону в сутки (compute_next_run_at, interval_days >= 1)" + ) + + +def test_backfill_default_interval_is_sane() -> None: + """Дефолт не должен быть ни суточным, ни агрессивнее получаса. + + Нижняя граница — не оптимум, а страховка: те же 4 прокси обслуживают + SERP-свипы, и слишком частое обогащение бьёт по первичному сбору. + """ + handlers = build_product_handlers(MagicMock()) + hook = handlers["avito_detail_backfill"].post_claim + assert hook is not None + + captured: dict[str, int] = {} + + class _DB: + def execute(self, _stmt: object, params: dict[str, object]) -> None: + captured["mins"] = int(params["mins"]) # type: ignore[arg-type] + + def commit(self) -> None: + return None + + # params без ключа → сработает дефолт фабрики, его и проверяем. + hook(_DB(), 1, {}, MagicMock()) + + assert 30 <= captured["mins"] <= 360, ( + f"интервал по умолчанию {captured['mins']} мин вне разумного коридора" + ) + assert captured["mins"] < 1440, "интервал в сутки и больше — это исходный дефект" + + +def test_interval_minutes_from_params_wins() -> None: + """Подбор интервала — через default_params расписания, без правки кода.""" + handlers = build_product_handlers(MagicMock()) + hook = handlers["avito_detail_backfill"].post_claim + assert hook is not None + + captured: dict[str, int] = {} + + class _DB: + def execute(self, _stmt: object, params: dict[str, object]) -> None: + captured["mins"] = int(params["mins"]) # type: ignore[arg-type] + + def commit(self) -> None: + return None + + hook(_DB(), 1, {"interval_minutes": 90}, MagicMock()) + assert captured["mins"] == 90 diff --git a/tradein-mvp/backend/tests/test_avito_detail_fields_3047.py b/tradein-mvp/backend/tests/test_avito_detail_fields_3047.py new file mode 100644 index 00000000..17e62a09 --- /dev/null +++ b/tradein-mvp/backend/tests/test_avito_detail_fields_3047.py @@ -0,0 +1,138 @@ +"""Тесты на реальной разметке для #3047 (5 полей detail-парсера Avito). + +Фикстуры — две живые detail-карточки, снятые через прод-браузер 2026-08-21: + - avito_detail_card_owner_66m2.html — item_id 8322506679, продавец "Частное лицо" + - avito_detail_card_agency_95m2.html — item_id 8343481647, продавец "Агентство" + +Разные значения seller-info/label в двух фикстурах — намеренно: это единственный +способ проверить и True, и False ветку is_homeowner на реальной разметке, а не +придуманным HTML. + +Что покрыто (см. докстринг detail.py для полного разбора по каждому полю): + - sale_type: способ чтения таблицы параметров (item-view/item-params — два блока + с ОДНИМ маркером, не один блок с двумя
    ) + - metro_stations: структурная разметка (#item-view-address) вместо текста описания + - is_homeowner: [data-marker='seller-info/label'] + - days_on_market: производное от исправленного publish_date + ([data-marker='item-view/item-date'], а не текст item-id-блока) + - cadastral_number: подтверждение ОТСУТСТВИЯ на этих карточках (regression guard — + если Avito когда-то начнёт отдавать значение, эти тесты не должны молча зелёнеть + на None, если код начнёт что-то парсить без проверки) +""" + +from __future__ import annotations + +from datetime import date +from pathlib import Path + +from scraper_kit.providers.avito.detail import parse_detail_html + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _load(name: str) -> str: + return (FIXTURES / name).read_text(encoding="utf-8") + + +CARD_OWNER = "avito_detail_card_owner_66m2.html" +CARD_AGENCY = "avito_detail_card_agency_95m2.html" + + +class TestSaleTypeParamsReading: + """item-view/item-params: два
    с ОДНИМ маркером ("О квартире"/"О доме"), + каждый со своим
      — раньше css_first брал только первый.""" + + def test_sale_type_owner_card(self) -> None: + e = parse_detail_html(_load(CARD_OWNER), "https://example.com/1") + assert e.sale_type == "free" + + def test_sale_type_agency_card(self) -> None: + e = parse_detail_html(_load(CARD_AGENCY), "https://example.com/2") + assert e.sale_type == "free" + + def test_house_params_from_second_block_not_dropped(self) -> None: + """Regression guard: house_type/total_floors_house из ВТОРОГО + item-view/item-params блока больше не теряются молча.""" + e = parse_detail_html(_load(CARD_OWNER), "https://example.com/1") + assert e.house_type == "monolith_brick" + assert e.total_floors_house == 16 + + def test_house_params_second_card(self) -> None: + e = parse_detail_html(_load(CARD_AGENCY), "https://example.com/2") + assert e.house_type == "brick" + assert e.total_floors_house == 17 + + +class TestMetroStructured: + """#item-view-address: имя станции + время читаются из структурной разметки, + а не из regex по тексту описания.""" + + def test_metro_owner_card(self) -> None: + e = parse_detail_html(_load(CARD_OWNER), "https://example.com/1") + names = [s["name"] for s in e.metro_stations] + # "Машиностроителей" и "Уралмаш" не подходят под старый METRO_RE + # (регекс требовал суффикс -ская/-инская и т.п.) — структурный путь их не + # теряет. + assert names == ["Уральская", "Машиностроителей", "Уралмаш"] + assert e.metro_stations[0] == { + "name": "Уральская", + "min_to": 20, + "min_from": 16, + "mode": "walk", + } + assert e.metro_stations[2] == { + "name": "Уралмаш", + "min_to": 30, + "min_from": 21, + "mode": "walk", + } + + def test_metro_agency_card_open_ended_time(self) -> None: + """'от 31 мин.' -> min_from=None, min_to=31 (открытый диапазон).""" + e = parse_detail_html(_load(CARD_AGENCY), "https://example.com/2") + names = [s["name"] for s in e.metro_stations] + assert names == ["Площадь 1905 года", "Геологическая", "Чкаловская"] + for station in e.metro_stations: + assert station["min_from"] is None + assert station["min_to"] == 31 + assert station["mode"] == "walk" + + +class TestIsHomeowner: + """[data-marker='seller-info/label']: 'Частное лицо' -> True, 'Агентство' -> + False. Обе ветки подтверждены на разных эталонах (не выдумано).""" + + def test_private_seller_is_homeowner_true(self) -> None: + e = parse_detail_html(_load(CARD_OWNER), "https://example.com/1") + assert e.is_homeowner is True + + def test_agency_seller_is_homeowner_false(self) -> None: + e = parse_detail_html(_load(CARD_AGENCY), "https://example.com/2") + assert e.is_homeowner is False + + +class TestDaysOnMarketFromPublishDate: + """publish_date раньше искался ВНУТРИ item-id-блока — на этой вёрстке дата лежит + в соседнем [data-marker='item-view/item-date'] и всегда уходила None. + days_on_market — честно выведенное производное (обе карточки: 'сегодня').""" + + def test_publish_date_today_owner_card(self) -> None: + e = parse_detail_html(_load(CARD_OWNER), "https://example.com/1") + assert e.publish_date == date.today() + assert e.days_on_market == 0 + + def test_publish_date_today_agency_card(self) -> None: + e = parse_detail_html(_load(CARD_AGENCY), "https://example.com/2") + assert e.publish_date == date.today() + assert e.days_on_market == 0 + + +class TestCadastralNumberNotAvailable: + """Regression guard: на этих карточках кадастрового номера нет НИГДЕ — + ни в тексте, ни в структурной разметке (domotekaReportTeaser.cadastralNumber + пустая строка). DetailEnrichment не имеет поля cadastral_number вовсе — код + не должен его придумывать.""" + + def test_no_cadastral_field_on_dataclass(self) -> None: + e = parse_detail_html(_load(CARD_OWNER), "https://example.com/1") + assert not hasattr(e, "cadastral_number") diff --git a/tradein-mvp/backend/tests/test_avito_offers_per_page.py b/tradein-mvp/backend/tests/test_avito_offers_per_page.py new file mode 100644 index 00000000..6c47857c --- /dev/null +++ b/tradein-mvp/backend/tests/test_avito_offers_per_page.py @@ -0,0 +1,61 @@ +"""Регрессия на размер страницы Avito SERP. + +Замер живьём 2026-08-21 (tradein-browser / camoufox, JS исполняется): страница +выдачи отдаёт 59-60 уникальных `data-item-id`. Константа стояла 50 — то есть +`ceil(total / PAGE)` завышал число страниц примерно на 17 %, и каждый лишний +запрос — лишний шанс словить SERP firewall. + +Тест намеренно проверяет НЕ только значение, но и то, что константа +по-прежнему участвует в расчёте страниц: иначе правку значения легко потерять +при рефакторинге, оставив мёртвую константу. +""" + +from __future__ import annotations + +import ast +import math +from pathlib import Path + +from scraper_kit.providers.avito import serp + +_SERP_SOURCE = Path(serp.__file__) + + +def test_offers_per_page_matches_live_measurement() -> None: + """60, а не 50 — замер 2026-08-21 через реальный браузер.""" + assert serp._AVITO_OFFERS_PER_PAGE == 60 + + +def test_pages_needed_arithmetic_uses_the_constant() -> None: + """`ceil(total / PAGE)` при 60 даёт меньше страниц, чем при 50. + + Закрепляет направление эффекта: занижение размера страницы ЗАВЫШАЛО число + запрашиваемых страниц. В PR-разборе это было сформулировано наоборот, и + тест существует, чтобы неверная трактовка не вернулась. + """ + total = 1000 + assert math.ceil(total / 60) == 17 + assert math.ceil(total / 50) == 20 + assert math.ceil(total / serp._AVITO_OFFERS_PER_PAGE) == 17 + + +def test_constant_is_still_wired_into_pagination() -> None: + """Константа реально используется, а не осталась мёртвой после правки.""" + tree = ast.parse(_SERP_SOURCE.read_text(encoding="utf-8")) + uses = sum( + 1 + for node in ast.walk(tree) + if isinstance(node, ast.Name) and node.id == "_AVITO_OFFERS_PER_PAGE" + ) + # 1 присваивание + минимум два места расчёта (pages_needed в бисекции и в + # пагинации листа); если станет меньше — кто-то отвязал константу. + assert uses >= 3, f"_AVITO_OFFERS_PER_PAGE упоминается {uses} раз, ожидалось >= 3" + + +def test_no_stale_fifty_in_page_size_comment() -> None: + """Комментарий рядом с константой не должен утверждать «~50 карточек».""" + src = _SERP_SOURCE.read_text(encoding="utf-8") + marker = "_AVITO_OFFERS_PER_PAGE = " + idx = src.index(marker) + preceding = src[max(0, idx - 1200) : idx] + assert "~50 карточек" not in preceding diff --git a/tradein-mvp/backend/tests/test_backfill_honest_status.py b/tradein-mvp/backend/tests/test_backfill_honest_status.py index ad72db34..a885b74e 100644 --- a/tradein-mvp/backend/tests/test_backfill_honest_status.py +++ b/tradein-mvp/backend/tests/test_backfill_honest_status.py @@ -5,7 +5,17 @@ 1500-1600 попыток без единого обогащения), yandex 31/52, domclick 24/30 (494 попытки → 0 обогащено, 63 блока, 431 fail — и все 30 'done'). -Проверяем ровно ветвление mark_backfill_finished — БД замокана. +Проверяем ровно ветвление 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 @@ -59,9 +69,15 @@ def _finish(counters: dict[str, int], *, aborted: bool = False) -> tuple[str, st ({"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"), diff --git a/tradein-mvp/backend/tests/test_backfill_wave2.py b/tradein-mvp/backend/tests/test_backfill_wave2.py index 8a386aa4..178986ab 100644 --- a/tradein-mvp/backend/tests/test_backfill_wave2.py +++ b/tradein-mvp/backend/tests/test_backfill_wave2.py @@ -59,17 +59,13 @@ class TestYandexTitleExtract: assert not result.endswith(",") def test_nbsp_replaced(self): - html = ( - "— Екатеринбург,\xa0улица Горького, 36 — id 12 " "на Яндекс.Недвижимости" - ) + html = "— Екатеринбург,\xa0улица Горького, 36 — id 12 на Яндекс.Недвижимости" result = self._extract(html) assert result is not None assert "\xa0" not in result def test_case_insensitive_city(self): - html = ( - "— ЕКАТЕРИНБУРГ, улица Горького, 36 — id 777 " "на Яндекс.Недвижимости" - ) + html = "— ЕКАТЕРИНБУРГ, улица Горького, 36 — id 777 на Яндекс.Недвижимости" # regex is case-insensitive; city name retains original case result = self._extract(html) assert result is not None @@ -101,8 +97,7 @@ async def test_backfill_yandex_addresses_saves_enriched(): from app.services.yandex_address_backfill import backfill_yandex_addresses html_with_addr = ( - "Продажа — Екатеринбург, улица Горького, 36 — id 11 " - "на Яндекс.Недвижимости" + "Продажа — Екатеринбург, улица Горького, 36 — id 11 на Яндекс.Недвижимости" ) mock_resp = MagicMock() diff --git a/tradein-mvp/backend/tests/test_backtest_regression_gate.py b/tradein-mvp/backend/tests/test_backtest_regression_gate.py index 51a26de8..0275db49 100644 --- a/tradein-mvp/backend/tests/test_backtest_regression_gate.py +++ b/tradein-mvp/backend/tests/test_backtest_regression_gate.py @@ -46,9 +46,9 @@ _ABS_TOL = 1e-6 def _assert_match(path: str, expected: object, actual: object) -> None: if isinstance(expected, dict): assert isinstance(actual, dict), f"{path}: expected dict, got {type(actual).__name__}" - assert ( - expected.keys() == actual.keys() - ), f"{path}: key set differs\n expected={sorted(expected)}\n actual= {sorted(actual)}" + assert expected.keys() == actual.keys(), ( + f"{path}: key set differs\n expected={sorted(expected)}\n actual= {sorted(actual)}" + ) for k in expected: _assert_match(f"{path}.{k}", expected[k], actual[k]) elif isinstance(expected, list): diff --git a/tradein-mvp/backend/tests/test_cian_state_parser.py b/tradein-mvp/backend/tests/test_cian_state_parser.py index e8d7e4c4..a8665672 100644 --- a/tradein-mvp/backend/tests/test_cian_state_parser.py +++ b/tradein-mvp/backend/tests/test_cian_state_parser.py @@ -28,9 +28,7 @@ def _concat_push(mfe: str, entries: list[tuple[str, str]]) -> str: (не эскейпленных дважды — `.concat([...])` несёт валидный JSON-массив как есть). """ items = ",".join(f'{{"key":"{k}","value":{v},"priority":0}}' for k, v in entries) - return ( - f"window._cianConfig['{mfe}'] = (window._cianConfig['{mfe}'] || [])" f".concat([{items}]);" - ) + return f"window._cianConfig['{mfe}'] = (window._cianConfig['{mfe}'] || []).concat([{items}]);" def _push(mfe: str, key: str, value: str) -> str: diff --git a/tradein-mvp/backend/tests/test_city_sweep.py b/tradein-mvp/backend/tests/test_city_sweep.py index 3f132e1f..e138a150 100644 --- a/tradein-mvp/backend/tests/test_city_sweep.py +++ b/tradein-mvp/backend/tests/test_city_sweep.py @@ -31,20 +31,41 @@ def test_ekb_anchors_count() -> None: def test_resolve_city_name_known_oblast_slugs() -> None: - """Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя.""" - from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, resolve_city_name + """Каждый city_slug из CITY_LOCATIONS резолвится в человекочитаемое имя. - expected = { + #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": "Серов", } - # CITY_DISPLAY_NAMES обязан покрывать ровно те же slug'и, что CITY_LOCATIONS - # (иначе oblast-город бы тихо получил ЕКБ-дефолт вместо своего имени). - assert set(expected) == set(CITY_LOCATIONS) - for slug, name in expected.items(): + 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 @@ -62,6 +83,56 @@ def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None: 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 ─────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py index b87af5ae..b82bda69 100644 --- a/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py +++ b/tradein-mvp/backend/tests/test_consent_text_frontend_sync.py @@ -15,8 +15,19 @@ WHAT: 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). If someone - edits ONE side without the other, this test fails. + 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 @@ -34,25 +45,71 @@ 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 = ( - Path(__file__).resolve().parents[2] - / "frontend" - / "src" - / "components" - / "trade-in" - / "v2" - / "LeadForm.tsx" + _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"\s*(.*?)\s*", tsx_source, re.DOTALL) + match = re.search(r"(.*?)", tsx_source, re.DOTALL) assert match is not None, "no found in LeadForm.tsx -- consent label markup changed" - return re.sub(r"\s+", " ", match.group(1)).strip() + 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: @@ -78,6 +135,29 @@ def test_backend_consent_snapshot_matches_frontend_checkbox_label() -> None: ) +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 = """ @@ -87,3 +167,28 @@ def test_extract_span_text_helper_is_whitespace_insensitive() -> None: """ 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..266af904 --- /dev/null +++ b/tradein-mvp/backend/tests/test_coverage_probe_endpoint.py @@ -0,0 +1,665 @@ +"""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 центроидов. +# Тавда: ближайший из центроидов (Каменск-Уральский) в 271 км, то есть точка +# заведомо вне 25-км радиуса любого поддержанного города — при этом всё ещё +# Свердловская область, как и задумано тестами ниже. +# +# Раньше здесь стояла точка 59.6047/60.1970 — окрестности Серова, в 21 км от +# его центра. Она работала как «далеко от всех» лишь потому, что Серов не был +# поддержан: город предлагался в дропдауне на сайте, но отсутствовал в списках +# покрытия, и его житель получал «этот адрес вне области». Серов добавлен +# 16.08.2026, и фикстура переехала туда, где действительно далеко. +_FAR_AWAY_CITY = {"lat": 58.0424, "lon": 65.2711} + + +# ── 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 км от всех центроидов → 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_deactivate_stale_avito.py b/tradein-mvp/backend/tests/test_deactivate_stale_avito.py index 9dffac4c..fcac27cf 100644 --- a/tradein-mvp/backend/tests/test_deactivate_stale_avito.py +++ b/tradein-mvp/backend/tests/test_deactivate_stale_avito.py @@ -253,17 +253,17 @@ def test_migration_100_updates_correct_source() -> None: def test_migration_100_sets_enabled_true() -> None: sql = _MIGRATION_100.read_text("utf-8") # UPDATE … SET enabled = true - assert re.search( - r"enabled\s*=\s*true", sql, re.IGNORECASE - ), "migration 100 must SET enabled = true" + assert re.search(r"enabled\s*=\s*true", sql, re.IGNORECASE), ( + "migration 100 must SET enabled = true" + ) def test_migration_100_is_idempotent() -> None: """WHERE enabled = false ensures re-running after fix → 0 rows matched.""" sql = _MIGRATION_100.read_text("utf-8") - assert re.search( - r"enabled\s*=\s*false", sql, re.IGNORECASE - ), "migration 100 must guard with AND enabled = false for idempotency" + assert re.search(r"enabled\s*=\s*false", sql, re.IGNORECASE), ( + "migration 100 must guard with AND enabled = false for idempotency" + ) def test_migration_100_is_transactional() -> None: diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_deactivation_cap.py b/tradein-mvp/backend/tests/test_deactivate_stale_deactivation_cap.py new file mode 100644 index 00000000..9fa7baee --- /dev/null +++ b/tradein-mvp/backend/tests/test_deactivate_stale_deactivation_cap.py @@ -0,0 +1,311 @@ +"""Потолок объёма снятия за один прогон -- аварийный, наблюдательный (PR-B, #2659 +продолжение). + +У джобы деактивации не было НИ ОДНОГО ограничителя ОБЪЁМА: UPDATE идёт одним +statement'ом без LIMIT, counters["deactivated"] = result.rowcount. Долевой порог +("не больше N% активного пула") откалибровать по историческим данным нельзя -- +исторические снятия дают доли 40%/97%/317%/1652% от восстановленного пула, числа +не образуют осмысленного ряда (гранулярность listings vs listing_sources разная). +Поэтому: preflight count(*) ПО ТОМУ ЖЕ предикату, что и UPDATE, пишет +deactivation_candidates/active_pool/deactivated_pct В КАЖДЫЙ прогон (наблюдение, +задел под будущую калибровку долевого порога), а блокирует ТОЛЬКО абсолютный +аварийный порог max_deactivated (дефолт 15000 -- запас 1.6x над историческим +максимумом легитимного снятия 9300, avito 2026-06-06). +""" + +from __future__ import annotations + +import os +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_floor_degradation.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: + def __init__( + self, + *, + floor_days: float | None = 50.0, + floor_n_pairs: int = 5000, + prev_floor_n_pairs: int | None = 5000, + confirmations: int = 10_000, + deactivation_candidates: int | None = None, + active_pool: int = 100_000, + rowcount: int = 137, + ) -> None: + self._floor = floor_days + self._floor_n_pairs = floor_n_pairs + self._prev_floor_n_pairs = prev_floor_n_pairs + self._confirmations = confirmations + self._deactivation_candidates = ( + rowcount if deactivation_candidates is None else deactivation_candidates + ) + self._active_pool = active_pool + 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 "FROM scrape_runs prev" in sql: + return _FakeResult(scalar_value=self._prev_floor_n_pairs) + if "JOIN LATERAL" in sql: + return _FakeResult(scalar_value=self._floor_n_pairs) + if "health_window_days" in sql: + return _FakeResult(scalar_value=self._confirmations) + if "SELECT count(*)" in sql and "ttl_days" in sql: + return _FakeResult(scalar_value=self._deactivation_candidates) + if "SELECT count(*)" in sql: + return _FakeResult(scalar_value=self._active_pool) + 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)) + + @property + def executed_kinds(self) -> list[str]: + kinds = [] + for sql, _ in self.executed: + if "percentile_disc" in sql: + kinds.append("floor") + elif "FROM scrape_runs prev" in sql: + kinds.append("prev_floor_n_pairs") + elif "JOIN LATERAL" in sql: + kinds.append("floor_n_pairs") + elif "health_window_days" in sql: + kinds.append("confirmations") + elif "SELECT count(*)" in sql and "ttl_days" in sql: + kinds.append("candidates") + elif "SELECT count(*)" in sql: + kinds.append("active_pool") + elif "UPDATE listings" in sql: + kinds.append("update") + else: + kinds.append("unknown") + return kinds + + +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) + kwargs.setdefault("revisit_floor_quantile", task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE) + 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_cap_trips_when_candidates_exceed_max_deactivated(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(deactivation_candidates=20_000, active_pool=100_000) + out = _run(db, monkeypatch, max_deactivated=15000) + assert out["skipped_cap_exceeded"] == 1 + assert out["deactivation_candidates"] == 20000 + assert out["active_pool"] == 100000 + assert out["deactivated"] == 0 + assert db.update_query[0] == "" + assert db.committed is False + assert db.rolled_back is True + + +def test_cap_does_not_trip_below_threshold(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(deactivation_candidates=9300, active_pool=100_000, rowcount=9300) + out = _run(db, monkeypatch, max_deactivated=15000) + assert "skipped_cap_exceeded" not in out + assert out["deactivation_candidates"] == 9300 + assert out["deactivated"] == 9300 + assert db.update_query[0] != "" + assert db.committed is True + + +def test_cap_trips_exactly_above_threshold_not_at_it(monkeypatch: pytest.MonkeyPatch) -> None: + """== max_deactivated не триггерит, только > (строго больше).""" + db = _FakeDB(deactivation_candidates=15000, active_pool=100_000, rowcount=15000) + out = _run(db, monkeypatch, max_deactivated=15000) + assert "skipped_cap_exceeded" not in out + db2 = _FakeDB(deactivation_candidates=15001, active_pool=100_000) + out2 = _run(db2, monkeypatch, max_deactivated=15000) + assert out2["skipped_cap_exceeded"] == 1 + + +def test_cap_check_runs_before_update(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(deactivation_candidates=20_000) + _run(db, monkeypatch, max_deactivated=15000) + assert "update" not in db.executed_kinds + + +def test_blocked_run_is_finalised_as_done(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(deactivation_candidates=20_000) + task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 55, + listing_source="avito", + ttl_days=10, + revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE, + max_deactivated=15000, + ) + assert marked["run_id"] == 55 + assert marked["counters"]["skipped_cap_exceeded"] == 1 + assert marked["counters"]["deactivated"] == 0 + + +# ── Наблюдательные counters (пишутся ВСЕГДА, не только при abort) ──────────────── + + +def test_deactivated_pct_is_computed(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(deactivation_candidates=5000, active_pool=10_000, rowcount=5000) + out = _run(db, monkeypatch) + assert out["deactivated_pct"] == 50 + + +def test_deactivated_pct_zero_when_active_pool_empty(monkeypatch: pytest.MonkeyPatch) -> None: + """active_pool=0 -> деление защищено, не ZeroDivisionError.""" + db = _FakeDB(deactivation_candidates=0, active_pool=0, rowcount=0) + out = _run(db, monkeypatch) + assert out["deactivated_pct"] == 0 + assert out["active_pool"] == 0 + + +def test_candidates_and_pool_written_on_every_healthy_run(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(deactivation_candidates=137, active_pool=5000, rowcount=137) + out = _run(db, monkeypatch) + assert out["deactivation_candidates"] == 137 + assert out["active_pool"] == 5000 + assert db.update_query[0] != "" + + +# ── Предикат preflight COUNT совпадает с предикатом UPDATE ─────────────────────── + + +def test_candidates_predicate_matches_update_predicate_all_segments() -> None: + update_sql = str(task_mod._build_all_segments_sql("last_seen_at").text) + count_sql = str(task_mod._build_all_segments_candidates_count_sql("last_seen_at").text) + for fragment in ( + "source = :listing_source", + "is_active = true", + "last_seen_at < NOW() - CAST(:ttl_days || ' days' AS interval)", + ): + assert fragment in update_sql, f"{fragment!r} missing from UPDATE sql" + assert fragment in count_sql, f"{fragment!r} missing from candidates-count sql" + assert "listing_segment" not in update_sql + assert "listing_segment" not in count_sql + + +def test_candidates_predicate_matches_update_predicate_segments() -> None: + update_sql = str(task_mod._build_segments_sql("last_seen_at").text) + count_sql = str(task_mod._build_segments_candidates_count_sql("last_seen_at").text) + for fragment in ( + "source = :listing_source", + "is_active = true", + "last_seen_at < NOW() - CAST(:ttl_days || ' days' AS interval)", + "listing_segment = ANY(CAST(:segments AS text[]))", + ): + assert fragment in update_sql, f"{fragment!r} missing from UPDATE sql" + assert fragment in count_sql, f"{fragment!r} missing from candidates-count sql" + + +def test_candidates_predicate_matches_update_predicate_null_segment() -> None: + update_sql = str(task_mod._build_null_segment_sql("last_seen_at").text) + count_sql = str(task_mod._build_null_segment_candidates_count_sql("last_seen_at").text) + for fragment in ( + "source = :listing_source", + "is_active = true", + "last_seen_at < NOW() - CAST(:ttl_days || ' days' AS interval)", + "listing_segment IS NULL", + ): + assert fragment in update_sql, f"{fragment!r} missing from UPDATE sql" + assert fragment in count_sql, f"{fragment!r} missing from candidates-count sql" + + +def test_candidates_count_sql_uses_the_effective_ttl_days_param_name() -> None: + """Preflight использует :ttl_days -- caller обязан передать effective_ttl_days + под этим именем (см. deactivate_stale_listings), а не сырой ttl_days.""" + sql = str(task_mod._build_all_segments_candidates_count_sql("last_seen_at").text) + assert ":ttl_days" in sql + + +def test_active_pool_sql_has_no_segment_or_ttl_filter() -> None: + """active_pool -- весь активный пул source, без сегмента и без TTL (см. докстринг + deactivate_stale_listings, Returns): денормализатор пула, а не срез UPDATE.""" + sql = str(task_mod._ACTIVE_POOL_SQL.text) + assert "is_active = true" in sql + assert "listing_segment" not in sql + assert ":ttl_days" not in sql + + +# ── Параметры / контракт ────────────────────────────────────────────────────────── + + +def test_default_max_deactivated_is_15000() -> None: + assert task_mod.DEFAULT_MAX_DEACTIVATED == 15000 + + +def test_default_does_not_reject_any_known_legit_historical_run() -> None: + """Исторический максимум легитимного снятия (avito, 2026-06-06) и следующие по + убыванию -- ни один не должен упереться в дефолтный потолок.""" + known_legit = [9300, 6531, 6131, 4959, 3909] + assert max(known_legit) < task_mod.DEFAULT_MAX_DEACTIVATED + + +def test_max_deactivated_rejects_non_positive(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB() + with pytest.raises(ValueError, match="max_deactivated"): + _run(db, monkeypatch, max_deactivated=0) + assert db.executed == [] + + +def test_max_deactivated_rejects_bool(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB() + with pytest.raises(ValueError, match="max_deactivated"): + _run(db, monkeypatch, max_deactivated=True) + assert db.executed == [] + + +def test_handler_wires_max_deactivated_from_schedule() -> None: + """Читаем исходник файлом (как соседние test_handler_wires_* в этом сьюте): + product_handlers тянет scraper_kit, которого в юнит-окружении может не быть.""" + from pathlib import Path + + 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("max_deactivated", DEFAULT_MAX_DEACTIVATED)' in job + assert "max_deactivated=max_deactivated" in job diff --git a/tradein-mvp/backend/tests/test_deactivate_stale_floor_degradation.py b/tradein-mvp/backend/tests/test_deactivate_stale_floor_degradation.py new file mode 100644 index 00000000..3708f4b4 --- /dev/null +++ b/tradein-mvp/backend/tests/test_deactivate_stale_floor_degradation.py @@ -0,0 +1,317 @@ +"""Гейт деградации пола переобхода (PR-B, #2659 продолжение). + +PR-A (fb38d657) перевёл поиск предшественника в поле переобхода на LATERAL и +добавил counters["floor_n_pairs"] -- ЧИСТО наблюдательный счётчик размера +выборки, из которой percentile_disc берёт квантиль. Этот файл проверяет PR-B: +тот же floor_n_pairs теперь ГЕЙТИТ прогон, если выборка вырождена -- либо ниже +абсолютного порога (min_floor_pairs), либо упала более чем в floor_drop_ratio +раз относительно предыдущего УСПЕШНОГО прогона того же расписания. +""" + +from __future__ import annotations + +import os +import re +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 + +# ── Фейковая сессия ───────────────────────────────────────────────────────────── +# Различает ВСЕ SELECT-варианты, которые видит deactivate_stale_listings при +# revisit_floor_quantile > 0: percentile_disc (пол), previous floor_n_pairs +# (scrape_runs, PR-B), floor_n_pairs (LATERAL count, PR-A), confirmations (гейт +# здоровья), deactivation_candidates / active_pool (потолок объёма, PR-B). +# +# Порядок веток важен: percentile_disc и count(*) LATERAL-запрос ОБА содержат +# "JOIN LATERAL" -- percentile_disc проверяется первой веткой. LATERAL-запрос +# также содержит "health_window_days" (дважды) -- "JOIN LATERAL" проверяется +# раньше этой ветки. UPDATE содержит "ttl_days", но не "SELECT count(*)" -- +# ветка candidates требует ОБА маркера, поэтому UPDATE в неё не попадает. + + +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: + def __init__( + self, + *, + floor_days: float | None = 50.0, + floor_n_pairs: int = 5000, + prev_floor_n_pairs: int | None = 5000, + confirmations: int = 10_000, + deactivation_candidates: int | None = None, + active_pool: int = 100_000, + rowcount: int = 137, + ) -> None: + self._floor = floor_days + self._floor_n_pairs = floor_n_pairs + self._prev_floor_n_pairs = prev_floor_n_pairs + self._confirmations = confirmations + self._deactivation_candidates = ( + rowcount if deactivation_candidates is None else deactivation_candidates + ) + self._active_pool = active_pool + 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 "FROM scrape_runs prev" in sql: + return _FakeResult(scalar_value=self._prev_floor_n_pairs) + if "JOIN LATERAL" in sql: + return _FakeResult(scalar_value=self._floor_n_pairs) + if "health_window_days" in sql: + return _FakeResult(scalar_value=self._confirmations) + if "SELECT count(*)" in sql and "ttl_days" in sql: + return _FakeResult(scalar_value=self._deactivation_candidates) + if "SELECT count(*)" in sql: + return _FakeResult(scalar_value=self._active_pool) + 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)) + + @property + def executed_kinds(self) -> list[str]: + kinds = [] + for sql, _ in self.executed: + if "percentile_disc" in sql: + kinds.append("floor") + elif "FROM scrape_runs prev" in sql: + kinds.append("prev_floor_n_pairs") + elif "JOIN LATERAL" in sql: + kinds.append("floor_n_pairs") + elif "health_window_days" in sql: + kinds.append("confirmations") + elif "SELECT count(*)" in sql and "ttl_days" in sql: + kinds.append("candidates") + elif "SELECT count(*)" in sql: + kinds.append("active_pool") + elif "UPDATE listings" in sql: + kinds.append("update") + else: + kinds.append("unknown") + return kinds + + +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) + kwargs.setdefault("revisit_floor_quantile", task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE) + 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_gate_trips_on_absolute_floor(monkeypatch: pytest.MonkeyPatch) -> None: + """floor_n_pairs ниже min_floor_pairs -> skipped_floor_degraded, ни одна строка не тронута.""" + db = _FakeDB(floor_n_pairs=10, prev_floor_n_pairs=None) + out = _run(db, monkeypatch, min_floor_pairs=30) + assert out["skipped_floor_degraded"] == 1 + assert out["floor_n_pairs"] == 10 + assert out["deactivated"] == 0 + assert db.update_query[0] == "" + assert db.committed is False + assert db.rolled_back is True + + +def test_absolute_floor_disabled_at_zero(monkeypatch: pytest.MonkeyPatch) -> None: + """min_floor_pairs=0 -> абсолютная проверка отключена (тот же идиом, что у гейта здоровья).""" + db = _FakeDB(floor_n_pairs=0, prev_floor_n_pairs=None) + out = _run(db, monkeypatch, min_floor_pairs=0) + assert "skipped_floor_degraded" not in out + assert db.update_query[0] != "" + + +# ── Относительный порог (падение в N раз) ─────────────────────────────────────── + + +def test_gate_trips_on_ratio_drop(monkeypatch: pytest.MonkeyPatch) -> None: + """floor_n_pairs упал в 10 раз против предыдущего успешного прогона (порог 5x).""" + db = _FakeDB(floor_n_pairs=100, prev_floor_n_pairs=1000) + out = _run(db, monkeypatch, min_floor_pairs=30, floor_drop_ratio=5.0) + assert out["skipped_floor_degraded"] == 1 + assert out["floor_n_pairs_previous"] == 1000 + assert out["deactivated"] == 0 + assert db.update_query[0] == "" + assert db.committed is False + assert db.rolled_back is True + + +def test_gate_does_not_trip_at_exactly_the_ratio(monkeypatch: pytest.MonkeyPatch) -> None: + """Падение РОВНО в floor_drop_ratio раз не триггерит -- задача требует «более чем».""" + db = _FakeDB(floor_n_pairs=200, prev_floor_n_pairs=1000) # ровно 5x + out = _run(db, monkeypatch, min_floor_pairs=30, floor_drop_ratio=5.0) + assert "skipped_floor_degraded" not in out + assert db.update_query[0] != "" + + +def test_gate_ignores_ratio_when_no_previous_run(monkeypatch: pytest.MonkeyPatch) -> None: + """Нет предыдущего успешного прогона (первый прогон после деплоя) -> относительная + часть молчит, работает только абсолютный порог.""" + db = _FakeDB(floor_n_pairs=50, prev_floor_n_pairs=None) + out = _run(db, monkeypatch, min_floor_pairs=30, floor_drop_ratio=5.0) + assert "skipped_floor_degraded" not in out + assert "floor_n_pairs_previous" not in out + assert db.update_query[0] != "" + + +def test_gate_ignores_zero_previous(monkeypatch: pytest.MonkeyPatch) -> None: + """Предыдущий прогон существует, но floor_n_pairs=0 в нём -- деление защищено guard'ом + prev_floor_n_pairs > 0, ratio-часть молчит вместо ZeroDivisionError.""" + db = _FakeDB(floor_n_pairs=0, prev_floor_n_pairs=0) + out = _run(db, monkeypatch, min_floor_pairs=0, floor_drop_ratio=5.0) + assert "skipped_floor_degraded" not in out + assert out["floor_n_pairs_previous"] == 0 + + +# ── Нормальный (не деградировавший) floor_n_pairs ─────────────────────────────── + + +def test_gate_passes_with_healthy_floor_n_pairs(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB(floor_n_pairs=5000, prev_floor_n_pairs=5200) + out = _run(db, monkeypatch, min_floor_pairs=30, floor_drop_ratio=5.0) + assert "skipped_floor_degraded" not in out + assert out["floor_n_pairs"] == 5000 + assert out["floor_n_pairs_previous"] == 5200 + assert db.update_query[0] != "" + assert db.committed is True + + +def test_gate_disabled_when_revisit_floor_is_off(monkeypatch: pytest.MonkeyPatch) -> None: + """revisit_floor_quantile=0 -> весь блок (пол + оба гейта PR-B) не исполняется вовсе.""" + db = _FakeDB(floor_n_pairs=1, prev_floor_n_pairs=None) + out = _run(db, monkeypatch, revisit_floor_quantile=0, min_floor_pairs=30) + assert out == {"deactivated": 137} + assert "skipped_floor_degraded" not in out + assert db.executed_kinds == ["update"] + + +# ── Abort -- ни одна строка не тронута ─────────────────────────────────────────── + + +def test_gate_runs_before_update_and_before_the_volume_cap(monkeypatch: pytest.MonkeyPatch) -> None: + """Деградация останавливает прогон ДО preflight-потолка (PR-B п.2) и ДО UPDATE.""" + db = _FakeDB(floor_n_pairs=10, prev_floor_n_pairs=None) + _run(db, monkeypatch, min_floor_pairs=30) + kinds = db.executed_kinds + assert "update" not in kinds + assert "candidates" not in kinds + assert "active_pool" not in kinds + + +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(floor_n_pairs=10, prev_floor_n_pairs=None) + task_mod.deactivate_stale_listings( + db, # type: ignore[arg-type] + 99, + listing_source="avito", + ttl_days=10, + revisit_floor_quantile=task_mod.DEFAULT_REVISIT_FLOOR_QUANTILE, + min_floor_pairs=30, + ) + assert marked["run_id"] == 99 + assert marked["counters"]["skipped_floor_degraded"] == 1 + assert marked["counters"]["deactivated"] == 0 + + +# ── Параметры / контракт ───────────────────────────────────────────────────────── + + +def test_default_min_floor_pairs_is_a_safety_net_not_zero() -> None: + assert task_mod.DEFAULT_MIN_FLOOR_PAIRS > 0 + + +def test_default_floor_drop_ratio_is_above_one() -> None: + assert task_mod.DEFAULT_FLOOR_DROP_RATIO > 1 + + +def test_min_floor_pairs_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB() + with pytest.raises(ValueError, match="min_floor_pairs"): + _run(db, monkeypatch, min_floor_pairs=-1) + assert db.executed == [] + + +def test_min_floor_pairs_rejects_bool(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB() + with pytest.raises(ValueError, match="min_floor_pairs"): + _run(db, monkeypatch, min_floor_pairs=True) + assert db.executed == [] + + +def test_floor_drop_ratio_rejects_below_one(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB() + with pytest.raises(ValueError, match="floor_drop_ratio"): + _run(db, monkeypatch, floor_drop_ratio=0.5) + assert db.executed == [] + + +def test_floor_drop_ratio_rejects_bool(monkeypatch: pytest.MonkeyPatch) -> None: + db = _FakeDB() + with pytest.raises(ValueError, match="floor_drop_ratio"): + _run(db, monkeypatch, floor_drop_ratio=True) + assert db.executed == [] + + +def test_previous_floor_n_pairs_sql_compares_by_scrape_runs_source() -> None: + """Сравнение идёт по scrape_runs.source ТЕКУЩЕГО прогона (имя расписания), а + НЕ по listing_source -- см. модульный докстринг у DEFAULT_MIN_FLOOR_PAIRS.""" + sql = str(task_mod._PREVIOUS_FLOOR_N_PAIRS_SQL.text) + assert "SELECT source FROM scrape_runs WHERE id = :run_id" in sql + assert ":listing_source" not in sql + assert "status = 'done'" in sql + assert "id != :run_id" in sql + assert not re.search(r":\w+::", sql) + + +def test_handler_wires_floor_degradation_params_from_schedule() -> None: + """Читаем исходник файлом (как соседние test_handler_wires_* в этом сьюте): + product_handlers тянет scraper_kit, которого в юнит-окружении может не быть.""" + from pathlib import Path + + 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_floor_pairs", DEFAULT_MIN_FLOOR_PAIRS)' in job + assert "min_floor_pairs=min_floor_pairs" in job + assert 'params.get("floor_drop_ratio", DEFAULT_FLOOR_DROP_RATIO)' in job + assert "floor_drop_ratio=floor_drop_ratio" in job 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 index 76f34665..5ea5fce5 100644 --- a/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py +++ b/tradein-mvp/backend/tests/test_deactivate_stale_revisit_floor.py @@ -126,13 +126,21 @@ def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[st def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.MonkeyPatch) -> None: - """Ни одно из 127 доказанно ложных снятий не должно повториться. + """Ни одно из 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, @@ -149,9 +157,111 @@ def test_effective_ttl_covers_every_proven_false_kill(monkeypatch: pytest.Monkey ) _, update_params = db.update_query assert update_params is not None - assert ( - update_params["ttl_days"] == effective - ), f"{slice_name}: UPDATE получил не поднятый TTL — пол посчитан и выброшен" + 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: 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 index 5e6e0129..9e437ef9 100644 --- a/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py +++ b/tradein-mvp/backend/tests/test_dead_code_sweep_2674.py @@ -82,8 +82,7 @@ def test_domrf_window_does_not_collide_with_matview_refresh() -> None: 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}" + f"окно {start}-{end} пересекается с refresh_search_matview {matview_start}-{matview_end}" ) @@ -248,9 +247,9 @@ 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 - ), "комментарий не называет причину пустоты — без неё это просто «пока пусто»" + assert "match_or_create_listing" in comment, ( + "комментарий не называет причину пустоты — без неё это просто «пока пусто»" + ) # ───────────────────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_domclick_sweep.py b/tradein-mvp/backend/tests/test_domclick_sweep.py index 39f869f3..aeb6080d 100644 --- a/tradein-mvp/backend/tests/test_domclick_sweep.py +++ b/tradein-mvp/backend/tests/test_domclick_sweep.py @@ -90,6 +90,77 @@ def test_map_item_basic_mapping() -> None: assert lot.lon == pytest.approx(60.612) +def test_map_item_puts_complex_slug_into_newbuilding_id() -> None: + """flatComplex.slug доезжает до ScrapedLot.newbuilding_id (#3064). + + Слаг, а не id: докстринг ScrapedLot задаёт формат «federatsiya-ekaterinburg», + и avito — единственный источник, который это поле заполняет, кладёт туда слаг. + + ЭТО НЕ привязка к дому. house_source/house_ext_id намеренно не трогаются: + все активные domklik-листинги — вторичка, а flatComplex — идентификатор ЖК, + а не здания (116 значений на 737 листингов по проду). Ключ здания + (domclick_building_guid) приходит detail-путём — отдельная развилка в #3064. + """ + import json + from pathlib import Path + + fixture_path = Path(__file__).parent / "fixtures" / "domclick_bff_offers_sample.json" + items = json.loads(fixture_path.read_text(encoding="utf-8"))["result"]["items"] + + config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000") + scraper = DomClickScraper(config, delay_provider=lambda _name: 8.0) + + # item 1001: flatComplex.slug='malysheva' -> слаг доезжает до колонки. + lot_with_complex = scraper._map_item(items[0]) + assert lot_with_complex is not None + assert lot_with_complex.newbuilding_id == "malysheva" + # Привязка к дому НЕ появилась — это стережёт границу правки. + assert lot_with_complex.house_source is None + assert lot_with_complex.house_ext_id is None + + # item 1002: flatComplex отсутствует -> None, а не пустая строка: пустая + # строка попала бы в колонку как значение и читалась бы как «ЖК есть, но без + # имени», что неотличимо от настоящего ЖК с пустым слагом. + lot_no_complex = scraper._map_item(items[1]) + assert lot_no_complex is not None + assert lot_no_complex.newbuilding_id is None + + +def test_map_item_promotes_rosreestr_from_bff_fixture() -> None: + """isRosreestrApproved из BFF-фикстуры доезжает до + ScrapedLot.is_rosreestr_checked (#3064 п.1). + + На проде у всех 1338 активных domklik-листингов колонка была NULL, хотя поле + уже парсилось и оседало в raw_payload. Соответствие 1:1 с cian + (`offer.get("isRosreestrChecked")`, cian/serp.py:1014) — без трансформаций. + + is_pro_seller здесь НЕ проверяется намеренно: колонка уже несёт два разных + смысла (cian — платная PRO-подписка, `019_listings_alter_cian.sql:43`; + yandex — категория продавца AGENCY/AGENT, `yandex/serp.py:203-220`), и + выводить её из наличия agency_name значило бы завести третий. См. #3064. + """ + import json + from pathlib import Path + + fixture_path = Path(__file__).parent / "fixtures" / "domclick_bff_offers_sample.json" + items = json.loads(fixture_path.read_text(encoding="utf-8"))["result"]["items"] + + config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000") + scraper = DomClickScraper(config, delay_provider=lambda _name: 8.0) + + # item 1001: isRosreestrApproved=true -> ЕГРН-сверка пройдена. + lot_checked = scraper._map_item(items[0]) + assert lot_checked is not None + assert lot_checked.is_rosreestr_checked is True + + # item 1003: ключа isRosreestrApproved в payload нет вовсе -> None, а НЕ + # False. Отсутствие данных не то же самое, что "проверено — не сходится": + # False поехал бы в оценщик как утверждение о квартире. + lot_unknown = scraper._map_item(items[2]) + assert lot_unknown is not None + assert lot_unknown.is_rosreestr_checked is None + + # ── fetch_city: report_ban на QRATOR-блок (#2600 п.1) ─────────────────────────── @@ -142,3 +213,154 @@ async def test_fetch_city_reports_ban_on_qrator_block(monkeypatch: pytest.Monkey assert scraper.blocked is True assert fake_fetcher.banned # report_ban был вызван на ЖИВОМ fetcher'е assert "QRATOR" in fake_fetcher.banned[0] + + +# ── #2854: точка входа в ROOM_BUCKETS сдвигается от прогона к прогону ─────────── +# +# Блок QRATOR прилетает ВНУТРИ первой корзины — на проде buckets_completed=0 во всех +# прогонах, включая тот, где собрано 389 лотов. Обход при этом всегда стартовал со +# студий, поэтому комнатности 2+ не собирались никогда. Замер 19.08 по активным +# объявлениям: у Домклика 575 студий, 408 однушек, 1 двушка, 0 трёшек; у Циана в тех +# же категориях 6 461 / 7 352 / 4 581. + + +class _OrderRecordingScraper: + """Ничего не делает, только запоминает порядок корзин.""" + + +async def _make_scraper_recording(monkeypatch: pytest.MonkeyPatch) -> tuple[DomClickScraper, list]: + seen: list[str] = [] + + def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher: + return _FakeFetcher() + + monkeypatch.setattr( + "scraper_kit.providers._base.build_browser_fetcher", _fake_build_browser_fetcher + ) + + async def _record(self: DomClickScraper, *, rooms: str, **_: object) -> None: + seen.append(rooms) + + monkeypatch.setattr(DomClickScraper, "_sweep_bucket", _record) + config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000") + return DomClickScraper(config), seen + + +async def test_fetch_city_rotates_bucket_order(monkeypatch: pytest.MonkeyPatch) -> None: + """Сдвиг меняет ПОРЯДОК корзин, а не их состав — ни одна не теряется.""" + from scraper_kit.providers.domclick.serp import ROOM_BUCKETS + + scraper, seen = await _make_scraper_recording(monkeypatch) + await scraper.fetch_city(city_id=1, start_bucket_index=2) + + assert seen == list(ROOM_BUCKETS[2:]) + list(ROOM_BUCKETS[:2]) + assert sorted(seen) == sorted(ROOM_BUCKETS), "состав корзин изменился — потеряли охват" + + +async def test_fetch_city_start_index_is_normalized(monkeypatch: pytest.MonkeyPatch) -> None: + """Вызывающий передаёт остаток от run_id — он ничем не ограничен сверху.""" + from scraper_kit.providers.domclick.serp import ROOM_BUCKETS + + scraper, seen = await _make_scraper_recording(monkeypatch) + await scraper.fetch_city(city_id=1, start_bucket_index=len(ROOM_BUCKETS) + 1) + + assert seen[0] == ROOM_BUCKETS[1] + assert scraper.bucket_start_index == 1 + + +async def test_blocked_run_collects_shifted_bucket_not_studios( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Главный сценарий: блок в ПЕРВОЙ же корзине — собранной оказывается сдвинутая. + + Ровно так ведёт себя прод: одна корзина за прогон и обрыв. До #2854 этой + единственной корзиной всегда были студии, из-за чего двушки и трёшки не + появлялись в listings вовсе. + """ + from scraper_kit.domclick_exceptions import DomClickBlockedError + from scraper_kit.providers.domclick.serp import ROOM_BUCKETS + + attempted: list[str] = [] + + def _fake_build_browser_fetcher(config: object, source: str, **_kw: object) -> _FakeFetcher: + return _FakeFetcher() + + monkeypatch.setattr( + "scraper_kit.providers._base.build_browser_fetcher", _fake_build_browser_fetcher + ) + + async def _block(self: DomClickScraper, *, rooms: str, **_: object) -> None: + attempted.append(rooms) + raise DomClickBlockedError("QRATOR block page") + + monkeypatch.setattr(DomClickScraper, "_sweep_bucket", _block) + config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:9000") + scraper = DomClickScraper(config) + + await scraper.fetch_city(city_id=1, start_bucket_index=ROOM_BUCKETS.index("2")) + + assert attempted == ["2"], "обход всё ещё начинается не со сдвинутой корзины" + assert scraper.blocked is True + assert scraper.bucket_start_index == ROOM_BUCKETS.index("2") + + +async def test_default_start_index_keeps_previous_behaviour( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Контроль: без явного сдвига порядок прежний — правка не меняет вызовы без него.""" + from scraper_kit.providers.domclick.serp import ROOM_BUCKETS + + scraper, seen = await _make_scraper_recording(monkeypatch) + await scraper.fetch_city(city_id=1) + + assert seen == list(ROOM_BUCKETS) + + +# ── проводка: pipeline обязан ПЕРЕДАВАТЬ сдвиг, а не просто уметь его принимать ── + + +async def test_pipeline_passes_rotating_start_index() -> None: + """Без этого теста передачу сдвига можно снять, и проверки выше останутся зелёными: + скрейпер по-прежнему умеет сдвигать, просто его об этом никто не просит. + """ + from unittest.mock import AsyncMock, MagicMock, patch + + from scraper_kit.orchestration.pipeline import run_domclick_city_sweep + from scraper_kit.providers.domclick.serp import ROOM_BUCKETS + + pfx = "scraper_kit.orchestration.pipeline" + run_id = 17 + scraper = MagicMock() + scraper.__aenter__ = AsyncMock(return_value=scraper) + scraper.__aexit__ = AsyncMock(return_value=None) + scraper.fetch_city = AsyncMock(return_value=[]) + scraper.blocked = False + scraper.geo_filtered = 0 + scraper.fetch_errors = 0 + scraper.buckets_completed = 0 + scraper.buckets_total = len(ROOM_BUCKETS) + scraper.bucket_start_index = run_id % len(ROOM_BUCKETS) + + runs_stub = MagicMock() + runs_stub.is_cancelled.return_value = False + + with ( + patch(f"{pfx}.DomClickScraper", return_value=scraper), + patch(f"{pfx}.save_listings", MagicMock(return_value=(0, 0))), + patch(f"{pfx}.runs", runs_stub), + ): + await run_domclick_city_sweep( + MagicMock(), + config=SimpleNamespace(browser_http_endpoint="http://x:9000"), + matcher=MagicMock(), + run_id=run_id, + city_id=4, + pages=1, + request_delay_sec=0.0, + ) + + passed = scraper.fetch_city.await_args.kwargs.get("start_bucket_index") + assert passed == run_id % len(ROOM_BUCKETS), ( + f"pipeline не передал сдвиг корзины (получено {passed!r}) — " + "обход снова всегда начнётся со студий" + ) diff --git a/tradein-mvp/backend/tests/test_estimate_external_sources_background.py b/tradein-mvp/backend/tests/test_estimate_external_sources_background.py new file mode 100644 index 00000000..dcf44654 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimate_external_sources_background.py @@ -0,0 +1,212 @@ +"""Внешние источники оценки не ждутся в запросе, а догружаются в фоне. + +Замер на проде 2026-08-22: расчёт по новому адресу 8-12 с, из них ~6 с ждёт +Yandex и ~1.5 с Cian; собственный расчёт укладывается в секунду. По уже +виденному адресу (кэш 24 ч) — 0.4-0.8 с. + +Ключевое свойство, которое делает переключение безопасным: деградация источника +в None — НЕ новое состояние ответа. Ровно так же ведёт себя таймаут +`estimate_*_valuation_timeout_s`, и этот путь работает в проде сегодня. + +Тесты проверяют три вещи, каждая из которых при поломке молча вернула бы +секунды ожидания в пользовательский путь: + 1. `fetch_on_miss=False` действительно НЕ ходит в сеть при промахе кэша; + 2. попадание в кэш работает одинаково в обоих режимах; + 3. отложенная задача берёт СВОЮ сессию и гасит свои ошибки. +""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import MagicMock + +import pytest + + +class _NoRowsDB: + """Сессия, у которой кэш всегда пуст.""" + + def __init__(self) -> None: + self.commits = 0 + self.closed = False + + def execute(self, *_a: Any, **_kw: Any) -> Any: + result = MagicMock() + result.mappings.return_value.first.return_value = None + return result + + def commit(self) -> None: + self.commits += 1 + + def rollback(self) -> None: + return None + + def close(self) -> None: + self.closed = True + + +# ── Yandex ─────────────────────────────────────────────────────────────────── + + +def test_yandex_cache_only_does_not_touch_network(monkeypatch: pytest.MonkeyPatch) -> None: + """fetch_on_miss=False → None и НИ ОДНОГО обращения к скраперу.""" + from app.services import estimator + + def _boom(*_a: Any, **_kw: Any) -> Any: + raise AssertionError("сеть не должна дёргаться в режиме «только кэш»") + + monkeypatch.setattr(estimator, "YandexValuationScraper", _boom) + + out = asyncio.run( + estimator._get_or_fetch_yandex_valuation_cached( + _NoRowsDB(), # type: ignore[arg-type] + address="Екатеринбург, улица Сурикова, 4", + fetch_on_miss=False, + ) + ) + assert out is None + + +def test_yandex_default_still_fetches(monkeypatch: pytest.MonkeyPatch) -> None: + """Дефолт не изменился: без флага источник по-прежнему грузится в запросе.""" + from app.services import estimator + + called: dict[str, bool] = {} + + class _Scraper: + def __init__(self, *_a: Any, **_kw: Any) -> None: + called["constructed"] = True + + async def __aenter__(self) -> _Scraper: + return self + + async def __aexit__(self, *_a: Any) -> None: + return None + + async def fetch_house_history(self, **_kw: Any) -> None: + return None + + monkeypatch.setattr(estimator, "YandexValuationScraper", _Scraper) + + asyncio.run( + estimator._get_or_fetch_yandex_valuation_cached( + _NoRowsDB(), # type: ignore[arg-type] + address="Екатеринбург, улица Сурикова, 4", + ) + ) + assert called.get("constructed") is True + + +# ── отложенная догрузка ────────────────────────────────────────────────────── + + +def test_deferred_refresh_uses_its_own_session(monkeypatch: pytest.MonkeyPatch) -> None: + """Сессия запроса закрывается вместе с ответом — фон обязан открыть свою. + + Переиспользование чужой сессии зафиксировало бы её незавершённую работу: + обе функции источников делают внутри себя `db.commit()`. + """ + from app.services import estimator + + own = _NoRowsDB() + request_db = _NoRowsDB() + monkeypatch.setattr(estimator, "SessionLocal", lambda: own) + seen: dict[str, Any] = {} + + async def _work(db: Any) -> None: + seen["db"] = db + + async def _drive() -> None: + estimator._defer_external_refresh("test", _work) + await asyncio.sleep(0) + await asyncio.sleep(0) + + asyncio.run(_drive()) + + assert seen["db"] is own, "фоновая задача взяла не свою сессию" + assert seen["db"] is not request_db + assert own.closed is True, "фоновая сессия осталась незакрытой" + + +def test_deferred_refresh_swallows_errors(monkeypatch: pytest.MonkeyPatch) -> None: + """Провал прогрева кэша не должен всплывать: это не часть ответа.""" + from app.services import estimator + + own = _NoRowsDB() + monkeypatch.setattr(estimator, "SessionLocal", lambda: own) + + async def _work(_db: Any) -> None: + raise RuntimeError("внешний сервис лёг") + + async def _drive() -> None: + estimator._defer_external_refresh("test", _work) + await asyncio.sleep(0) + await asyncio.sleep(0) + + asyncio.run(_drive()) # не должно поднять + assert own.closed is True + + +def test_deferred_refresh_without_event_loop_is_noop() -> None: + """Синхронный контекст (скрипт/тест) — прогрев необязателен, не падаем.""" + from app.services import estimator + + async def _work(_db: Any) -> None: + raise AssertionError("не должно вызваться") + + estimator._defer_external_refresh("test", _work) # без запущенного loop + + +def test_task_reference_is_retained(monkeypatch: pytest.MonkeyPatch) -> None: + """Ссылка на задачу удерживается: иначе GC может убить её на полпути.""" + from app.services import estimator + + monkeypatch.setattr(estimator, "SessionLocal", _NoRowsDB) + started = asyncio.Event() + release = asyncio.Event() + + async def _work(_db: Any) -> None: + started.set() + await release.wait() + + async def _drive() -> None: + estimator._defer_external_refresh("test", _work) + await started.wait() + assert len(estimator._DEFERRED_REFRESH_TASKS) == 1 + release.set() + await asyncio.sleep(0) + await asyncio.sleep(0) + assert len(estimator._DEFERRED_REFRESH_TASKS) == 0, "ссылка не убрана после завершения" + + asyncio.run(_drive()) + + +def test_deferred_queue_is_bounded(monkeypatch: pytest.MonkeyPatch) -> None: + """Всплеск по новым адресам не должен породить сотни задач. + + Ровно тот случай, ради которого режим и сделан: у backend один воркер, + mem_limit 768m, у Postgres max_connections 100. Неограниченный create_task + превратил бы ускорение в отказ. + """ + from app.services import estimator + + monkeypatch.setattr(estimator, "SessionLocal", _NoRowsDB) + monkeypatch.setattr(estimator, "_MAX_DEFERRED_REFRESH_TASKS", 3) + release = asyncio.Event() + + async def _work(_db: Any) -> None: + await release.wait() + + async def _drive() -> None: + for _ in range(50): + estimator._defer_external_refresh("test", _work) + await asyncio.sleep(0) + assert len(estimator._DEFERRED_REFRESH_TASKS) <= 3, ( + f"очередь догрузки не ограничена: {len(estimator._DEFERRED_REFRESH_TASKS)} задач" + ) + release.set() + for _ in range(5): + await asyncio.sleep(0) + + asyncio.run(_drive()) diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 099d39d0..2abbd7ac 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -75,6 +75,9 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S confidence="medium", confidence_explanation="ok", n_analogs=7, + # #2899: колонка есть у всех строк после миграции 267; NULL у старых + # (бэкфилла нет — позицию по сохранённому top-10 не восстановить). + market_percentile=63, analogs=[], actual_deals=[], sources_used=["avito"], @@ -728,3 +731,42 @@ def test_get_estimate_response_includes_retain_until_field(trade_in_app: FastAPI ) assert resp.status_code == 200 assert resp.json()["retain_until"] is None + + +def test_get_estimate_surfaces_market_percentile(trade_in_app: FastAPI) -> None: + """#2899: позиция в когорте переживает перезагрузку по ссылке и уходит в PDF. + + Значение считается только на POST (когорты в БД нет — в `analogs` лежит top-10, + а не выборка), поэтому оно ОБЯЗАНО храниться в колонке и подниматься обоими SELECT'ами. + У GET и PDF списки колонок РАЗНЫЕ и живут в разных функциях — один общий тест их + не покрывает, отсюда две проверки. + """ + row = _make_estimate_row(created_by="kopylov") + 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()["market_percentile"] == 63 + + +def test_get_estimate_market_percentile_nullable(trade_in_app: FastAPI) -> None: + """Контроль: NULL проходит как null, а не роняет ответ. + + Так выглядят все строки до миграции 267 и все оценки с когортой меньше 15 лотов. + Зелёный с обеих сторон правки — доказывает, что поле необязательное. + """ + row = _make_estimate_row(created_by="kopylov") + row.market_percentile = 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()["market_percentile"] is None diff --git a/tradein-mvp/backend/tests/test_estimate_rate_limit.py b/tradein-mvp/backend/tests/test_estimate_rate_limit.py new file mode 100644 index 00000000..311a0ae7 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimate_rate_limit.py @@ -0,0 +1,154 @@ +"""Tests for the dedicated POST /estimate rate limiter (#b2c-antiabuse-2 п.4). + +Отдельный, куда более строгий лимит частоты specifically на дорогой публичный +путь POST /estimate (`app.api.v1.trade_in._estimate_limiter`, +settings.estimate_rate_limit/_window_s) — поверх общего RateLimitMiddleware +(300 req/60с, app/main.py), который рассчитан на дешёвые запросы. Один вызов +/estimate запускает цепочку внешних вызовов, суммарно занимающую десятки секунд +(см. estimator._with_budget), поэтому burst нескольких параллельных вызовов от +одного ключа нужно резать раньше, гораздо строже. + +Изолировано от account_quota (mock'ается no-op) и от estimate_quality (canned +result, без реальной сети/DB) — цель проверить ИМЕННО срабатывание узкого +rate-limit гейта, который стоит ПЕРВЫМ в хендлере (до квоты и до дорогой цепочки). + +`tests/conftest.py::_reset_estimate_rate_limiter` сбрасывает `_estimate_limiter` +перед каждым тестом (иначе состояние утекало бы между файлами) — здесь мы поверх +этого сброса ещё и сужаем лимит через autouse-фикстуру, чтобы не тестировать +прод-значения (5/300с) напрямую (медленно/шумно). +""" + +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 unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1 import trade_in as trade_in_module +from app.core.db import get_db +from app.core.ratelimit import SlidingWindowLimiter +from app.schemas.trade_in import AggregatedEstimate + + +def _canned_estimate() -> AggregatedEstimate: + return AggregatedEstimate( + estimate_id=uuid4(), + median_price_rub=5_000_000, + range_low_rub=4_500_000, + range_high_rub=5_500_000, + median_price_per_m2=100_000, + confidence="medium", + n_analogs=8, + period_months=24, + analogs=[], + actual_deals=[], + expires_at=datetime.now(tz=UTC) + timedelta(hours=24), + ) + + +@pytest.fixture() +def app() -> FastAPI: + """Минимальное приложение вокруг trade_in-роутера; DB не используется реально — + account_quota мокается no-op в каждом тесте отдельно.""" + application = FastAPI() + application.include_router(trade_in_module.router, prefix="/api/v1/trade-in") + + def _override_db(): + yield None + + application.dependency_overrides[get_db] = _override_db + return application + + +@pytest.fixture(autouse=True) +def _narrow_estimate_limiter(monkeypatch: pytest.MonkeyPatch) -> None: + """Узкий лимитер (2 запроса / 60с) — тестируем СРАБАТЫВАНИЕ механизма, не + прод-пороги (settings.estimate_rate_limit=5 / estimate_rate_limit_window_s=300 + было бы медленно/шумно гонять напрямую в юнит-тесте).""" + monkeypatch.setattr( + trade_in_module, "_estimate_limiter", SlidingWindowLimiter(limit=2, window_s=60.0) + ) + + +def _post_estimate(client: TestClient, headers: dict[str, str] | None = None): + return client.post( + "/api/v1/trade-in/estimate", + json={"address": "г. Екатеринбург, ул. Малышева, 1", "area_m2": 50.0, "rooms": 2}, + headers=headers or {}, + ) + + +def test_estimate_rate_limit_fires_after_narrow_threshold(app: FastAPI) -> None: + """3-й запрос той же анонимной корзины (лимит=2) → 429 с Retry-After и текстом + ПРО ОЦЕНКУ (отличимо от общего RateLimitMiddleware "Слишком много запросов").""" + client = TestClient(app, raise_server_exceptions=False) + with ( + patch("app.services.account_quota.check_and_raise"), + patch("app.services.account_quota.increment", return_value=True), + patch( + "app.services.estimator.estimate_quality", + new=AsyncMock(return_value=_canned_estimate()), + ), + ): + for _ in range(2): + resp = _post_estimate(client) + assert resp.status_code == 200 + + blocked = _post_estimate(client) + + assert blocked.status_code == 429 + assert "оценку" in blocked.json()["detail"] + assert "Retry-After" in blocked.headers + + +def test_estimate_rate_limit_per_key_isolation(app: FastAPI) -> None: + """alice упирается в узкий лимит; bob (свой ключ) — не задет.""" + client = TestClient(app, raise_server_exceptions=False) + with ( + patch("app.services.account_quota.check_and_raise"), + patch("app.services.account_quota.increment", return_value=True), + patch( + "app.services.estimator.estimate_quality", + new=AsyncMock(return_value=_canned_estimate()), + ), + ): + alice = {"X-Authenticated-User": "alice"} + for _ in range(2): + assert _post_estimate(client, alice).status_code == 200 + assert _post_estimate(client, alice).status_code == 429 + + bob = {"X-Authenticated-User": "bob"} + assert _post_estimate(client, bob).status_code == 200 + + +def test_estimate_rate_limit_applies_regardless_of_quota_role(app: FastAPI) -> None: + """Rate limit — самая дешёвая проверка, стоит ПЕРВОЙ в хендлере (до + account_quota). Даже quota-unlimited роль (admin/kopylov) упирается в него — + burst-защита capacity сервера не зависит от business-роли.""" + client = TestClient(app, raise_server_exceptions=False) + admin_headers = {"X-Authenticated-User": "admin"} + with ( + patch("app.services.account_quota.check_and_raise") as mock_check, + patch("app.services.account_quota.increment", return_value=True), + patch( + "app.services.estimator.estimate_quality", + new=AsyncMock(return_value=_canned_estimate()), + ), + ): + for _ in range(2): + assert _post_estimate(client, admin_headers).status_code == 200 + + blocked = _post_estimate(client, admin_headers) + + assert blocked.status_code == 429 + # account_quota.check_and_raise НЕ вызывается для 3-го запроса — rate limit + # короткозамкнул обработку раньше, чем дело дошло до квоты. + assert mock_check.call_count == 2 diff --git a/tradein-mvp/backend/tests/test_estimator_cohort.py b/tradein-mvp/backend/tests/test_estimator_cohort.py index b6c572fb..0a4682c0 100644 --- a/tradein-mvp/backend/tests/test_estimator_cohort.py +++ b/tradein-mvp/backend/tests/test_estimator_cohort.py @@ -3,6 +3,7 @@ PR 10 (2026-05-24) — covers _target_cohort_range edge cases: None, out-of-range, exact boundaries, first-match semantics. """ + import os # Settings requires DATABASE_URL at init time. Set dummy DSN before any app import. @@ -16,26 +17,26 @@ from app.services.estimator import _target_cohort_range @pytest.mark.parametrize( ("year", "expected"), [ - (None, None), # no year → no cohort - (1900, None), # out-of-range below → no cohort - (1954, None), # below khrushchev floor - (1955, (1955, 1969)), # khrushchev floor - (1960, (1955, 1969)), # khrushchev mid - (1969, (1955, 1969)), # khrushchev ceiling - (1970, (1970, 1989)), # brezhnev floor - (1978, (1970, 1989)), # brezhnev (target audit case) - (1988, (1970, 1989)), # brezhnev pre-overlap fix held - (1989, (1970, 1989)), # brezhnev ceiling - (1990, (1990, 1999)), # late_soviet floor (post-PR10 fix) - (1995, (1990, 1999)), # late_soviet mid - (1999, (1990, 1999)), # late_soviet ceiling - (2000, (2000, 2010)), # 2000s floor - (2010, (2000, 2010)), # 2000s ceiling - (2011, (2011, 2100)), # modern floor - (2024, (2011, 2100)), # modern current - (2100, (2011, 2100)), # modern ceiling - (2101, None), # above modern ceiling → no cohort - (2200, None), # far future → no cohort + (None, None), # no year → no cohort + (1900, None), # out-of-range below → no cohort + (1954, None), # below khrushchev floor + (1955, (1955, 1969)), # khrushchev floor + (1960, (1955, 1969)), # khrushchev mid + (1969, (1955, 1969)), # khrushchev ceiling + (1970, (1970, 1989)), # brezhnev floor + (1978, (1970, 1989)), # brezhnev (target audit case) + (1988, (1970, 1989)), # brezhnev pre-overlap fix held + (1989, (1970, 1989)), # brezhnev ceiling + (1990, (1990, 1999)), # late_soviet floor (post-PR10 fix) + (1995, (1990, 1999)), # late_soviet mid + (1999, (1990, 1999)), # late_soviet ceiling + (2000, (2000, 2010)), # 2000s floor + (2010, (2000, 2010)), # 2000s ceiling + (2011, (2011, 2100)), # modern floor + (2024, (2011, 2100)), # modern current + (2100, (2011, 2100)), # modern ceiling + (2101, None), # above modern ceiling → no cohort + (2200, None), # far future → no cohort ], ) def test_target_cohort_range(year, expected): diff --git a/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py index 16815488..ccaec2ae 100644 --- a/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py +++ b/tradein-mvp/backend/tests/test_estimator_confidence_reliability_consistency.py @@ -57,9 +57,9 @@ 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" + 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: diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold.py b/tradein-mvp/backend/tests/test_estimator_expected_sold.py index 51e8a675..c0dece68 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold.py @@ -68,9 +68,9 @@ def test_bucket_clamping() -> None: _get_asking_sold_ratio(db, rooms) # First positional bind param is {"b": bucket}. bind = db.execute.call_args_list[0].args[1] - assert ( - bind["b"] == expected_bucket - ), f"rooms={rooms} → bucket {bind['b']} != {expected_bucket}" + assert bind["b"] == expected_bucket, ( + f"rooms={rooms} → bucket {bind['b']} != {expected_bucket}" + ) def test_bucket_keyed_by_area_when_area_known() -> None: 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 ca8842ac..8cc771d6 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py @@ -134,13 +134,13 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1( est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True) assert est.median_price_rub > 0, "headline должен быть задан аналогами" assert est.expected_sold_price_rub is not None - assert ( - est.expected_sold_price_rub <= est.median_price_rub - ), f"expected_sold {est.expected_sold_price_rub} > asking {est.median_price_rub}" + assert est.expected_sold_price_rub <= est.median_price_rub, ( + f"expected_sold {est.expected_sold_price_rub} > asking {est.median_price_rub}" + ) assert est.expected_sold_per_m2 is not None - assert ( - est.expected_sold_per_m2 <= est.median_price_per_m2 - ), "expected_sold_per_m2 превышает median_price_per_m2" + assert est.expected_sold_per_m2 <= est.median_price_per_m2, ( + "expected_sold_per_m2 превышает median_price_per_m2" + ) # #1966: expected_sold range is now a calibrated ~80% PI around the point # (point × [p10, p90] of sold/expected_sold), so it is NO LONGER bounded by the # asking-IQR band — the high arm (point × 1.392) legitimately exceeds range_high. diff --git a/tradein-mvp/backend/tests/test_estimator_floor_optional.py b/tradein-mvp/backend/tests/test_estimator_floor_optional.py index 718cfe79..addcb2d9 100644 --- a/tradein-mvp/backend/tests/test_estimator_floor_optional.py +++ b/tradein-mvp/backend/tests/test_estimator_floor_optional.py @@ -4,6 +4,7 @@ Uses MagicMock for DB (consistent with other test_estimator_* tests — no live The test validates schema construction + that floor=None is accepted without ValueError. The actual NOT NULL guard is covered by migration 065. """ + from __future__ import annotations import os diff --git a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py index a4033a0f..d3df789a 100644 --- a/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py +++ b/tradein-mvp/backend/tests/test_estimator_headline_sufficiency.py @@ -298,9 +298,9 @@ def test_repair_coefficient_now_applies_to_thin_sample() -> None: 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" + assert pr.median_price != pr_no_repair.median_price, ( + "repair coefficient must be applied even for a thin (#oblast-E-flagged) sample" + ) # ───────────────────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_estimator_imv_budget.py b/tradein-mvp/backend/tests/test_estimator_imv_budget.py new file mode 100644 index 00000000..cf3569c7 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_imv_budget.py @@ -0,0 +1,132 @@ +"""Avito IMV time-budget — slow/hanging IMV must degrade gracefully, not block +/estimate (#b2c-antiabuse-2). + +Prod risk: `_get_or_fetch_imv_cached` was the ONLY external call in the estimate +pipeline WITHOUT a `_with_budget` guard. `evaluate_via_imv` chains up to 3 +sequential HTTP requests (warm-up + geocode + evaluate), each with its own 25s +timeout (`_HTTP_TIMEOUT_SEC` in scraper_kit.providers.avito.imv), plus a possible +ONE internal retry with a "cleaned" address on `IMVAddressNotFoundError` — the +unbounded worst-case reached ~150s. Every other slow enrichment (geocode/ +house_metadata/yandex_valuation/cian_valuation) was already wrapped in +`_with_budget`; this mirrors that guard for IMV. + +Contracts locked here: + 1. `estimate_avito_imv_timeout_s` exists with the documented default (20.0s). + 2. An IMV timeout (TimeoutError surfaced by the _with_budget wrapper) degrades + to an AggregatedEstimate WITHOUT 'avito_imv' in sources_used and no 5xx — + the same graceful None path as a network error. + 3. `_with_budget` actually enforces the timeout (elapsed time bound), proving + the guard is live and not merely present in code. + +Style mirrors test_estimator_cian_budget.py. +""" + +import os + +# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import. +os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db") + +import asyncio +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio + + +def _make_fake_geo(): + from app.services.geocoder import GeocodeResult + + return GeocodeResult( + lat=56.838, + lon=60.595, + full_address="Свердловская обл., Екатеринбург, ул. Учителей, 18", + provider="nominatim", + ) + + +def _make_payload_full(): + """house_type/repair_state set so the IMV-gated guard is actually satisfied + (imv_house_type/imv_renovation both non-None — see _IMV_HOUSE_TYPE_MAP / + _IMV_REPAIR_MAP in estimator.py).""" + from app.schemas.trade_in import TradeInEstimateInput + + return TradeInEstimateInput( + address="ЕКБ, ул. Учителей, 18", + area_m2=38.8, + rooms=1, + floor=4, + total_floors=16, + house_type="panel", + repair_state="standard", + ) + + +def test_avito_imv_timeout_setting_default() -> None: + """The new budget setting exists and defaults to 20.0s.""" + from app.core.config import settings + + assert hasattr(settings, "estimate_avito_imv_timeout_s") + assert settings.estimate_avito_imv_timeout_s == 20.0 + + +def test_estimate_avito_imv_timeout_degrades_no_5xx() -> None: + """IMV raising TimeoutError → estimate without it, no exception. + + The _with_budget() guard wraps the IMV call; a TimeoutError must map to the + same graceful None path as a network error. Estimator returns an + AggregatedEstimate; 'avito_imv' absent from sources_used. + """ + from app.services.estimator import estimate_quality + + db = MagicMock() + payload = _make_payload_full() + + imv_mock = AsyncMock(side_effect=TimeoutError("imv slow")) + + async def _run() -> None: + with ( + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), + 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=imv_mock), + 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)), + ): + result = await estimate_quality(payload, db) + + assert result.estimate_id is not None # no 5xx — degraded gracefully + assert "avito_imv" not in result.sources_used + + anyio.run(_run) + + +def test_estimate_avito_imv_timeout_within_budget() -> None: + """`_with_budget` actually enforces the configured timeout — a hanging IMV + coroutine is cancelled at the budget, not left to run the unbounded + (~150s worst-case) evaluate_via_imv chain. Uses a short synthetic budget + (not the real 20.0s default) so this test stays fast.""" + from app.services.estimator import _with_budget + + short_budget_s = 0.2 + + async def _hangs_forever() -> None: + await asyncio.sleep(999) + + async def _run() -> None: + start = time.monotonic() + result = await _with_budget(_hangs_forever(), short_budget_s, label="avito_imv") + elapsed = time.monotonic() - start + assert result is None + # Generous slack for CI scheduling jitter — proves the guard actually + # cancels near the budget, not merely that it exists in code. + assert elapsed < short_budget_s + 2.0 + + anyio.run(_run) diff --git a/tradein-mvp/backend/tests/test_estimator_null_floor_timeout.py b/tradein-mvp/backend/tests/test_estimator_null_floor_timeout.py index fecc2c63..95ded602 100644 --- a/tradein-mvp/backend/tests/test_estimator_null_floor_timeout.py +++ b/tradein-mvp/backend/tests/test_estimator_null_floor_timeout.py @@ -77,18 +77,14 @@ def test_estimate_null_floor_skips_imv_and_cian() -> None: async def _run() -> None: with ( - patch("app.services.estimator.geocode", - new=AsyncMock(return_value=_make_fake_geo())), - patch("app.services.estimator.get_house_metadata", - new=AsyncMock(return_value=None)), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), + 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=imv_mock), - patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", - new=yandex_mock), + patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", new=yandex_mock), patch("app.services.estimator.estimate_via_cian_valuation", new=cian_mock), - patch("app.services.estimator._get_asking_sold_ratio", - return_value=(None, None)), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), ): result = await estimate_quality(payload, db) @@ -120,18 +116,14 @@ def test_estimate_full_floor_awaits_imv_and_cian() -> None: async def _run() -> None: with ( - patch("app.services.estimator.geocode", - new=AsyncMock(return_value=_make_fake_geo())), - patch("app.services.estimator.get_house_metadata", - new=AsyncMock(return_value=None)), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), + 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=imv_mock), - patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", - new=yandex_mock), + patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", new=yandex_mock), patch("app.services.estimator.estimate_via_cian_valuation", new=cian_mock), - patch("app.services.estimator._get_asking_sold_ratio", - return_value=(None, None)), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), ): result = await estimate_quality(payload, db) @@ -161,20 +153,19 @@ def test_estimate_yandex_timeout_degrades_no_5xx() -> None: async def _run() -> None: with ( - patch("app.services.estimator.geocode", - new=AsyncMock(return_value=_make_fake_geo())), - patch("app.services.estimator.get_house_metadata", - new=AsyncMock(return_value=None)), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), + 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=yandex_mock), - 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._get_or_fetch_imv_cached", new=AsyncMock(return_value=None) + ), + patch("app.services.estimator._get_or_fetch_yandex_valuation_cached", new=yandex_mock), + 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)), ): result = await estimate_quality(payload, db) @@ -203,26 +194,23 @@ def test_estimate_yandex_httpx_timeout_at_scraper_degrades_no_5xx() -> None: fake_scraper = MagicMock() fake_scraper.__aenter__ = AsyncMock(return_value=fake_scraper) fake_scraper.__aexit__ = AsyncMock(return_value=False) - fake_scraper.fetch_house_history = AsyncMock( - side_effect=httpx.TimeoutException("read timeout") - ) + fake_scraper.fetch_house_history = AsyncMock(side_effect=httpx.TimeoutException("read timeout")) async def _run() -> None: with ( - patch("app.services.estimator.geocode", - new=AsyncMock(return_value=_make_fake_geo())), - patch("app.services.estimator.get_house_metadata", - new=AsyncMock(return_value=None)), + patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), + 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.YandexValuationScraper", - return_value=fake_scraper), - 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._get_or_fetch_imv_cached", new=AsyncMock(return_value=None) + ), + patch("app.services.estimator.YandexValuationScraper", return_value=fake_scraper), + 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)), ): result = await estimate_quality(payload, db) diff --git a/tradein-mvp/backend/tests/test_estimator_price_spine.py b/tradein-mvp/backend/tests/test_estimator_price_spine.py index 1398f514..162fa2cc 100644 --- a/tradein-mvp/backend/tests/test_estimator_price_spine.py +++ b/tradein-mvp/backend/tests/test_estimator_price_spine.py @@ -200,7 +200,7 @@ def test_same_building_anchor_tier_a_mutates_headline() -> None: def test_tier_c_corridor_gate_suppresses_anchor() -> None: """Tier C anchor ppm2 >> corridor_high × mult → anchor suppressed. - #2656: anchor_tier теперь СБРАСЫВАЕТСЯ в None (раньше гейт ставил anchor=None, + #2661: anchor_tier теперь СБРАСЫВАЕТСЯ в None (раньше гейт ставил anchor=None, но оставлял залипший "C" — и этот флаг молча глушил IMV-blend/quarter-index/ radius-floor, будто headline построил якорь). Headline как и раньше остаётся радиусной медианой. @@ -217,7 +217,7 @@ def test_tier_c_corridor_gate_suppresses_anchor() -> None: ratio=None, ) - # #2656: гейт ставит anchor=None → tier-флаг сбрасывается вместе с ним. + # #2661: гейт ставит anchor=None → tier-флаг сбрасывается вместе с ним. assert pr.anchor_tier is None # Headline was NOT mutated by the suppressed anchor — stays at radius median. assert pr.median_price == radius_median_price @@ -247,6 +247,87 @@ def test_low_conf_gate_suppresses_anchor() -> None: assert pr.anchor_comps_used == [] +def test_anchor_tier_reset_when_anchor_not_built() -> None: + """Якорь не построен (комплов меньше min_comps) → anchor_tier=None, а не залипшая "C". + + Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше: + когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше + ``estimate_sb_min_comps``=4), флаг раньше оставался равным ``anchor_tier_fetched`` — + дальше по коду он читается как «headline построил якорь». + """ + comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < estimate_sb_min_comps=4 + radius_median_price = int(100_000 * 50.0) + pr = _call( + listings=_lots(100_000, n=5), + anchor_comps=comps, + anchor_tier_fetched="C", + ratio=None, + ) + + assert pr.anchor_tier is None + assert pr.anchor_comps_used == [] + assert pr.median_price == radius_median_price # headline остался радиусным + + +def test_sticky_anchor_tier_no_longer_mutes_imv_blend() -> None: + """Недостроенный якорь (тир "C" не подтверждён) + IMV-якорь — blend обязан сработать. + + Денежное последствие залипшего флага #2661: сделки, у которых якоря нет ни до, ни + после правки, всё равно теряли IMV-blend — залипший ``anchor_tier="C"`` глушил гейт + ``anchor_tier is None`` у блока #651. Числа те же, что в + ``test_imv_blend_raises_median_when_anchor_tier_none`` выше (radius median=5M, + IMV=7M → blend 6M), плюс недостроенный якорь, который раньше эту цепочку выключал. + """ + comps = [_anchor_comp(150_000), _anchor_comp(155_000)] + imv_anchor = { + "recommended_price": 7_000_000, + "lower_price": 6_000_000, + "higher_price": 8_000_000, + "market_count": 50, + } + pr = _call( + listings=_lots(100_000, n=5), + anchor_comps=comps, + anchor_tier_fetched="C", + imv_anchor=imv_anchor, + ratio=None, + ) + + assert pr.anchor_tier is None + assert pr.median_price == round(5_000_000 * 0.5 + 7_000_000 * 0.5) # IMV-blend сработал + assert pr.avito_imv_summary is not None + + +def test_imv_card_survives_when_headline_suppressed_and_anchor_absent() -> None: + """Карточка Avito IMV не исчезает в щели «тир добыт, якоря нет, headline подавлен». + + #2661: раньше карточку заполнял display-only блок под условием `anchor_tier is not + None`. После сброса залипшего флага открылась щель: blend не срабатывает (нужен + listings_clean и median_price > 0), старый display-only блок тоже не срабатывал (tier + уже None) — пользователь ТЕРЯЛ карточку, которую видел раньше. Гейт по tier у + display-only блока снят — отображение, не деньги (median/expected_sold не трогает). + """ + comps = [_anchor_comp(150_000), _anchor_comp(155_000)] + imv_anchor = { + "recommended_price": 7_000_000, + "lower_price": 6_000_000, + "higher_price": 8_000_000, + "market_count": 50, + } + pr = _call( + listings=[], + anchor_comps=comps, + anchor_tier_fetched="C", + imv_anchor=imv_anchor, + ratio=None, + ) + + assert pr.anchor_tier is None + assert pr.median_price == 0 # headline подавлен гейтом достаточности (нет листингов) + assert pr.avito_imv_summary is not None, "карточка IMV потеряна" + assert pr.avito_imv_summary.recommended_price == 7_000_000 + + def test_imv_blend_raises_median_when_anchor_tier_none() -> None: """IMV blend pushes radius median up when IMV >> median × threshold. 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_index.py b/tradein-mvp/backend/tests/test_estimator_quarter_index.py index 4b8d7a72..dea55ceb 100644 --- a/tradein-mvp/backend/tests/test_estimator_quarter_index.py +++ b/tradein-mvp/backend/tests/test_estimator_quarter_index.py @@ -387,8 +387,15 @@ def _run_estimate_qi( qi_lookup_result: tuple[float, int] | None, *, anchor_tier_override: str | None = None, + analog_indexes: dict[str, float] | None = None, ): - """Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.""" + """Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate. + + analog_indexes — явная карта {quarter: price_index} для аналогов. Задавай её, + когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё + батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0. + Кварталы вне карты не попадают в ответ — ровно как в FDW при n_deals < порога. + """ from app.services.estimator import estimate_quality db = MagicMock() @@ -399,6 +406,8 @@ def _run_estimate_qi( # Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс, # что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict. def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals): + if analog_indexes is not None: + return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes} if qi_lookup_result is None: return {} return {q: qi_lookup_result[0] for q in quarter_cad_numbers} @@ -479,19 +488,15 @@ def test_quarter_index_correction_applied() -> None: """ base_median = round(_BASE_PPM2 * _AREA) # 6_000_000 - # Аналоги из ДРУГОГО квартала (building_cadastral_number = OTHER_QUARTER:100) - # _lookup_quarter_index для аналогов вернёт тот же (1.2, 30) что и для target — - # avg_analog_index = 1.2, factor = 1.2/1.2 = 1.0 (no change!). - # Чтобы увидеть ненулевую коррекцию, делаем аналоги БЕЗ кадастрового номера - # → avg_analog_index = 1.0 → factor = 1.2. - analogs_no_cadnum = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i) - for i in range(5) - ] + # Аналоги из ДРУГОГО квартала с ИЗВЕСТНЫМ индексом 1.0 → avg_analog_index=1.0, + # target=1.2 → factor=1.2. До C3 (#2583) тот же результат достигался аналогами + # БЕЗ кадастра (avg молча падал в 1.0) — теперь такой вход даёт no-op, поэтому + # уровень аналогов задаётся явно, через analog_indexes. est = _run_estimate_qi( - analogs=analogs_no_cadnum, + analogs=_ANALOGS_OTHER_QUARTER, dadata_cadnum=f"{_TARGET_QUARTER}:350", qi_lookup_result=(1.2, 30), + analog_indexes={_OTHER_QUARTER: 1.0}, ) expected_median = round(base_median * 1.2) assert est.median_price_rub == expected_median @@ -551,6 +556,103 @@ def test_sparse_fallback_no_row_noop() -> None: assert est.median_price_rub == base_median +# ───────────────────────────────────────────────────────────────────────────── +# Guard-5 (C3, #2583): уровень аналогов неизвестен → поправки нет +# +# price_index нормирован на медиану ЕКБ, поэтому старый фолбэк avg=1.0 означал +# не «нейтраль», а «аналоги стоят как в Екатеринбурге». Для цели вне ЕКБ +# (индексы области 0.28–0.82) это давало безусловную скидку до −40% на данных, +# которых нет. Ниже — оба входа, при которых уровень аналогов неизвестен. +# ───────────────────────────────────────────────────────────────────────────── + +_OBLAST_QUARTER = "66:56:0401001" # Нижний Тагил +_OBLAST_INDEX = 0.5 # ~вдвое дешевле медианы ЕКБ; ниже min_factor=0.6 после деления на 1.0 + + +def test_c3_no_analog_has_cadastre_skips_correction() -> None: + """Ни у одного аналога нет кадастра → уровень аналогов неизвестен → no-op. + + Старое поведение: avg=1.0 (= уровень ЕКБ) → raw factor=0.5 → кламп 0.6 → + медиана ×0.6. Это ровно областной сценарий: кадастр бэкфиллится срезом + 66:41:% (ЕКБ), вне ЕКБ у аналогов его нет. + """ + analogs_no_cadnum = [ + _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( + analogs=analogs_no_cadnum, + dadata_cadnum=f"{_OBLAST_QUARTER}:350", + qi_lookup_result=(_OBLAST_INDEX, 30), + ) + assert est.median_price_rub == base_median + # Диапазон тоже не сдвинут: множитель применялся ко всем ценовым выходам, + # поэтому сверяем с прогоном, где индекса нет вовсе. + no_index = _run_estimate_qi( + analogs=analogs_no_cadnum, + dadata_cadnum=f"{_OBLAST_QUARTER}:350", + qi_lookup_result=None, + ) + assert (est.range_low_rub, est.range_high_rub) == ( + no_index.range_low_rub, + no_index.range_high_rub, + ) + # И не утверждаем в тексте, что локация «учтена». + assert "квартал" not in (est.confidence_explanation or "").lower() + + +def test_c3_analog_quarters_not_in_index_skips_correction() -> None: + """У аналогов кадастр ЕСТЬ, но их кварталов нет в индексе → тоже no-op. + + Второй вход в ту же дыру: FDW отдаёт только строки с n_deals >= порога, + поэтому редкий квартал аналога просто не попадает в карту. weight_total=0 — + и до фикса знаменателем снова молча становилась медиана ЕКБ. + """ + analogs = [ + _make_listing_qi( + price_per_m2=_BASE_PPM2, + building_cadastral_number=f"{_OTHER_QUARTER}:100", + floor=4 + i, + ) + for i in range(5) + ] + base_median = round(_BASE_PPM2 * _AREA) + est = _run_estimate_qi( + analogs=analogs, + dadata_cadnum=f"{_OBLAST_QUARTER}:350", + qi_lookup_result=(_OBLAST_INDEX, 30), + analog_indexes={}, # ни одного квартала аналогов в индексе + ) + assert est.median_price_rub == base_median + assert "квартал" not in (est.confidence_explanation or "").lower() + + +def test_c3_one_indexed_analog_is_enough_to_correct() -> None: + """Контроль направления: как только уровень аналогов ИЗВЕСТЕН — поправка есть. + + Фикс не глушит коррекцию вообще, он требует под неё данные. Один аналог с + индексом 1.0 при цели 0.5 → factor 0.5 → кламп 0.6. + """ + analogs = [ + _make_listing_qi( + price_per_m2=_BASE_PPM2, + building_cadastral_number=f"{_OTHER_QUARTER}:100", + floor=4 + i, + ) + for i in range(5) + ] + base_median = round(_BASE_PPM2 * _AREA) + est = _run_estimate_qi( + analogs=analogs, + dadata_cadnum=f"{_OBLAST_QUARTER}:350", + qi_lookup_result=(_OBLAST_INDEX, 30), + analog_indexes={_OTHER_QUARTER: 1.0}, + ) + assert est.median_price_rub == round(base_median * 0.6) + assert "квартал" in (est.confidence_explanation or "").lower() + + # ───────────────────────────────────────────────────────────────────────────── # Bimodal guard: price_index>2.0 AND n_deals<50 → no-op # ───────────────────────────────────────────────────────────────────────────── @@ -577,15 +679,12 @@ def test_bimodal_guard_allows_high_index_large_n() -> None: Коррекция применяется, но raw factor=2.5 зажат #859-clamp до max_factor=1.8. Медиана меняется (guard не блокирует), но масштабируется на 1.8, не 2.5. """ - analogs_no_cadnum = [ - _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( - analogs=analogs_no_cadnum, + analogs=_ANALOGS_OTHER_QUARTER, dadata_cadnum=f"{_TARGET_QUARTER}:350", qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает + analog_indexes={_OTHER_QUARTER: 1.0}, ) # Коррекция применена: медиана != base_median (bimodal guard не заблокировал). # factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859). diff --git a/tradein-mvp/backend/tests/test_estimator_radius_floor.py b/tradein-mvp/backend/tests/test_estimator_radius_floor.py index 1551978e..aba07b00 100644 --- a/tradein-mvp/backend/tests/test_estimator_radius_floor.py +++ b/tradein-mvp/backend/tests/test_estimator_radius_floor.py @@ -135,9 +135,9 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None: est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8) floor_ppm2 = 150_000 * 0.8 # 120_000 - assert ( - est.median_price_per_m2 >= floor_ppm2 - ), f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}" + assert est.median_price_per_m2 >= floor_ppm2, ( + f"median_ppm2={est.median_price_per_m2} должна быть >= floor={floor_ppm2}" + ) # ── тест 2: radius median выше floor → no-op ───────────────────────────────── @@ -164,9 +164,9 @@ def test_radius_median_above_dkp_floor_unchanged() -> None: floor_ppm2 = 150_000 * 0.8 # 120_000 assert est.median_price_per_m2 > floor_ppm2, "median должна быть выше floor (no-op)" # Медиана соответствует аналогам (~200k), а не floor - assert ( - 180_000 <= est.median_price_per_m2 <= 220_000 - ), f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)" + assert 180_000 <= est.median_price_per_m2 <= 220_000, ( + f"median_ppm2={est.median_price_per_m2} должна остаться в диапазоне аналогов (no-op)" + ) # ── тест 3: dkp_raw is None → no-op ───────────────────────────────────────── @@ -182,6 +182,6 @@ def test_no_dkp_raw_no_floor() -> None: est = _run_estimate(analogs, dkp_raw=None, radius_floor_factor=0.8) # median ~80k, без dkp_raw floor не поднимает - assert ( - est.median_price_per_m2 < 100_000 - ), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти" + assert est.median_price_per_m2 < 100_000, ( + f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти" + ) 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 0e9dab86..2a717175 100644 --- a/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py +++ b/tradein-mvp/backend/tests/test_estimator_ratio_tier_fix.py @@ -150,9 +150,9 @@ def _run_estimate_with_ratio_spy( def test_ratio_called_exactly_once() -> None: """_get_asking_sold_ratio должен вызываться ровно один раз — после headline.""" _est, calls = _run_estimate_with_ratio_spy(_ANALOGS_LOW, (0.80, "per_rooms")) - assert ( - len(calls) == 1 - ), f"_get_asking_sold_ratio должен вызываться 1 раз, вызван {len(calls)} раз" + assert len(calls) == 1, ( + f"_get_asking_sold_ratio должен вызываться 1 раз, вызван {len(calls)} раз" + ) # ── тест 2: anchor поднял headline → ratio вызван с финальным (high) ppm² ──── @@ -298,9 +298,9 @@ def test_ratio_tier_uses_final_headline_after_anchor() -> None: ppm2_used = captured_anchor_ppm2[0] assert ppm2_used is not None # Финальный headline должен быть в зоне anchor (~300k), а НЕ в зоне radius (~105k). - assert ( - ppm2_used > 200_000 - ), f"ratio должен вызываться с anchor ppm2 (~300k), получено {ppm2_used}" + assert ppm2_used > 200_000, ( + f"ratio должен вызываться с anchor ppm2 (~300k), получено {ppm2_used}" + ) # ── тест 3: graceful — нет ratio → expected_sold_* = None, headline не изменён ── diff --git a/tradein-mvp/backend/tests/test_estimator_repair_coef.py b/tradein-mvp/backend/tests/test_estimator_repair_coef.py index 83393181..886c08a9 100644 --- a/tradein-mvp/backend/tests/test_estimator_repair_coef.py +++ b/tradein-mvp/backend/tests/test_estimator_repair_coef.py @@ -142,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: diff --git a/tradein-mvp/backend/tests/test_estimator_source_quota.py b/tradein-mvp/backend/tests/test_estimator_source_quota.py index 9b02b85c..a1745b7f 100644 --- a/tradein-mvp/backend/tests/test_estimator_source_quota.py +++ b/tradein-mvp/backend/tests/test_estimator_source_quota.py @@ -172,9 +172,9 @@ def test_source_quota_includes_all_when_supply_below_min() -> None: 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 diff --git a/tradein-mvp/backend/tests/test_extract_short_addr.py b/tradein-mvp/backend/tests/test_extract_short_addr.py index eb8b2b58..198a1810 100644 --- a/tradein-mvp/backend/tests/test_extract_short_addr.py +++ b/tradein-mvp/backend/tests/test_extract_short_addr.py @@ -8,15 +8,14 @@ from app.services.estimator import _extract_short_addr def test_full_admin_chain() -> None: - assert _extract_short_addr( - "Свердловская область, г. Екатеринбург, Склад, ул. Заводская, д. 44-а" - ) == "ул. Заводская, д. 44-а" + assert ( + _extract_short_addr("Свердловская область, г. Екатеринбург, Склад, ул. Заводская, д. 44-а") + == "ул. Заводская, д. 44-а" + ) def test_russia_prefix() -> None: - assert _extract_short_addr( - "Россия, Екатеринбург, ул. Малышева, 1" - ) == "ул. Малышева, 1" + assert _extract_short_addr("Россия, Екатеринбург, ул. Малышева, 1") == "ул. Малышева, 1" def test_apt_stripped() -> None: diff --git a/tradein-mvp/backend/tests/test_geo_precision.py b/tradein-mvp/backend/tests/test_geo_precision.py index f0f2da67..bece66b0 100644 --- a/tradein-mvp/backend/tests/test_geo_precision.py +++ b/tradein-mvp/backend/tests/test_geo_precision.py @@ -76,9 +76,9 @@ async def test_geocode_missing_sets_city_precision_for_bare_city_address() -> No # Inspect the UPDATE call parameters: geo_precision must be 'city'. update_call = db.execute.call_args_list[1] update_params = update_call[0][1] # positional arg[1] = params dict - assert ( - update_params.get("precision") == "city" - ), f"Expected precision='city' for bare city address, got {update_params.get('precision')!r}" + assert update_params.get("precision") == "city", ( + f"Expected precision='city' for bare city address, got {update_params.get('precision')!r}" + ) @pytest.mark.asyncio @@ -107,9 +107,9 @@ async def test_geocode_missing_sets_none_precision_for_precise_address() -> None update_call = db.execute.call_args_list[1] update_params = update_call[0][1] - assert ( - update_params.get("precision") is None - ), f"Expected precision=None for precise address, got {update_params.get('precision')!r}" + assert update_params.get("precision") is None, ( + f"Expected precision=None for precise address, got {update_params.get('precision')!r}" + ) @pytest.mark.asyncio @@ -138,9 +138,9 @@ async def test_geocode_missing_sets_city_precision_for_locality_confidence() -> update_call = db.execute.call_args_list[1] update_params = update_call[0][1] - assert ( - update_params.get("precision") == "city" - ), f"Expected precision='city' for locality confidence, got {update_params.get('precision')!r}" + assert update_params.get("precision") == "city", ( + f"Expected precision='city' for locality confidence, got {update_params.get('precision')!r}" + ) # ── (b) estimator radius-analog SQL contains geo_precision exclusion ────────── 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_glitchtip_webhook.py b/tradein-mvp/backend/tests/test_glitchtip_webhook.py new file mode 100644 index 00000000..2a777597 --- /dev/null +++ b/tradein-mvp/backend/tests/test_glitchtip_webhook.py @@ -0,0 +1,291 @@ +"""Offline-тесты приёмника GlitchTip webhook-алертов +(POST /api/v1/trade-in/ops/glitchtip-webhook) — app/api/v1/glitchtip.py. + +Проверяет HTTP-контракт: успешная пересылка issue-/uptime-алертов в +Telegram (клиент замокан), отказ без валидного секрета, отказ при +несконфигурированных настройках, обрезка длинного текста под лимит Telegram +(4096), graceful-обработка неизвестной формы payload (НЕ 500). + +NEVER touches real DB / real Telegram API. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import json +from typing import Any, ClassVar + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api.v1 import glitchtip as glitchtip_module +from app.services.tgbot.client import TelegramApiError + +_SECRET = "test-shared-secret" +_ENDPOINT = "/api/v1/trade-in/ops/glitchtip-webhook" + + +@pytest.fixture(autouse=True) +def _configured(monkeypatch: pytest.MonkeyPatch) -> None: + """По умолчанию вебхук полностью сконфигурирован — отдельные тесты + переопределяют конкретные поля.""" + monkeypatch.setattr(glitchtip_module.settings, "tradein_internal_auth_secret", _SECRET) + monkeypatch.setattr(glitchtip_module.settings, "telegram_bot_token", "fake-token") + monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_chat_id", -1004443088679) + monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_topic_id", 158) + + +class _FakeTelegramClient: + """Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети.""" + + calls: ClassVar[list[dict[str, Any]]] = [] + _response: ClassVar[dict[str, Any] | Exception] = {"message_id": 1} + + def __init__(self, _token: str) -> None: + pass + + async def send_message(self, **kwargs: Any) -> dict[str, Any]: + _FakeTelegramClient.calls.append(kwargs) + if isinstance(_FakeTelegramClient._response, Exception): + raise _FakeTelegramClient._response + return _FakeTelegramClient._response + + +@pytest.fixture(autouse=True) +def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any: + _FakeTelegramClient.calls = [] + _FakeTelegramClient._response = {"message_id": 1} + monkeypatch.setattr(glitchtip_module, "TelegramClient", _FakeTelegramClient) + return _FakeTelegramClient + + +@pytest.fixture +def client() -> TestClient: + app = FastAPI() + app.include_router(glitchtip_module.router, prefix="/api/v1/trade-in") + return TestClient(app) + + +_ISSUE_PAYLOAD = { + "text": "GlitchTip Alert", + "attachments": [ + { + "title": "ValueError: something broke", + "title_link": "https://errors.gendsgn.ru/organizations/gendesign/issues/123/", + "text": "app/services/foo.py in bar", + "color": "#e03131", + "fields": [ + {"title": "Project", "value": "tradein-backend", "short": True}, + {"title": "Environment", "value": "production", "short": True}, + ], + "mrkdown_in": ["text"], + } + ], +} + +_UPTIME_PAYLOAD = { + "text": "GlitchTip Uptime Alert", + "attachments": [ + { + "title": "gendsgn.ru", + "title_link": "https://errors.gendsgn.ru/organizations/gendesign/uptime/1/", + "text": "The monitored site has gone down.", + "image_url": None, + "color": None, + "fields": None, + "mrkdown_in": None, + } + ], +} + + +# ── успешная пересылка ────────────────────────────────────────────────────── + + +def test_issue_alert_forwarded_to_telegram(client: TestClient, _fake_telegram_client: Any) -> None: + r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD) + + assert r.status_code == 200, r.text + assert r.json() == {"status": "ok"} + assert len(_fake_telegram_client.calls) == 1 + call = _fake_telegram_client.calls[0] + assert call["chat_id"] == -1004443088679 + assert call["message_thread_id"] == 158 + assert "ValueError: something broke" in call["text"] + assert "tradein-backend" in call["text"] # Project field + assert "errors.gendsgn.ru" in call["text"] + + +def test_uptime_alert_forwarded_to_telegram(client: TestClient, _fake_telegram_client: Any) -> None: + r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_UPTIME_PAYLOAD) + + assert r.status_code == 200, r.text + assert len(_fake_telegram_client.calls) == 1 + call = _fake_telegram_client.calls[0] + assert "GlitchTip Uptime Alert" in call["text"] + assert "gone down" in call["text"] + assert call["message_thread_id"] == 158 + + +def test_telegram_failure_returns_502_not_500( + client: TestClient, _fake_telegram_client: Any +) -> None: + _FakeTelegramClient._response = TelegramApiError("sendMessage", 400, "chat not found") + + r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD) + + assert r.status_code == 502 + assert r.status_code != 500 + + +# ── auth ───────────────────────────────────────────────────────────────────── + + +def test_missing_secret_401(client: TestClient, _fake_telegram_client: Any) -> None: + r = client.post(_ENDPOINT, json=_ISSUE_PAYLOAD) + + assert r.status_code == 401 + assert _fake_telegram_client.calls == [] + + +def test_wrong_secret_401(client: TestClient, _fake_telegram_client: Any) -> None: + r = client.post(f"{_ENDPOINT}?secret=wrong-value", json=_ISSUE_PAYLOAD) + + assert r.status_code == 401 + assert _fake_telegram_client.calls == [] + + +def test_secret_not_configured_returns_503_not_500( + client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any +) -> None: + monkeypatch.setattr(glitchtip_module.settings, "tradein_internal_auth_secret", "") + + r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD) + + assert r.status_code == 503 + assert r.status_code != 500 + assert _fake_telegram_client.calls == [] + + +def test_bot_not_configured_returns_503( + client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any +) -> None: + monkeypatch.setattr(glitchtip_module.settings, "telegram_bot_token", "") + + r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD) + + assert r.status_code == 503 + assert _fake_telegram_client.calls == [] + + +def test_alerts_chat_id_not_configured_returns_503( + client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any +) -> None: + monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_chat_id", 0) + + r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD) + + assert r.status_code == 503 + assert _fake_telegram_client.calls == [] + + +# ── обрезка длинного текста ───────────────────────────────────────────────── + + +def test_long_payload_truncated_to_telegram_limit( + client: TestClient, _fake_telegram_client: Any +) -> None: + huge_payload = { + "text": "GlitchTip Alert", + "attachments": [ + { + "title": "Huge issue", + "title_link": "https://errors.gendsgn.ru/x", + "text": "x" * 10000, + } + ], + } + + r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=huge_payload) + + assert r.status_code == 200, r.text + sent_text = _fake_telegram_client.calls[0]["text"] + assert len(sent_text) <= 4096 + assert sent_text.endswith("(обрезано)") + + +def test_unknown_form_huge_raw_body_truncated( + client: TestClient, _fake_telegram_client: Any +) -> None: + r = client.post( + f"{_ENDPOINT}?secret={_SECRET}", + content=("x" * 10000).encode(), + headers={"content-type": "application/json"}, + ) + + assert r.status_code == 200, r.text + sent_text = _fake_telegram_client.calls[0]["text"] + assert len(sent_text) <= 4096 + + +# ── неизвестная форма payload — НЕ 500 ────────────────────────────────────── + + +def test_unknown_json_shape_forwarded_with_marker( + client: TestClient, _fake_telegram_client: Any +) -> None: + """Ни `text`, ни `attachments` — форма, которую GlitchTip НЕ шлёт сегодня, + но контракт задачи требует не падать 500, а переслать как есть.""" + r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json={"some_field": "some_value", "n": 42}) + + assert r.status_code == 200, r.text + sent_text = _fake_telegram_client.calls[0]["text"] + assert "неизвестный формат" in sent_text + assert "some_value" in sent_text + + +def test_non_json_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None: + r = client.post( + f"{_ENDPOINT}?secret={_SECRET}", + content=b"not-json-at-all {{{", + headers={"content-type": "text/plain"}, + ) + + assert r.status_code == 200, r.text + sent_text = _fake_telegram_client.calls[0]["text"] + assert "неизвестный формат" in sent_text + assert "not-json-at-all" in sent_text + + +def test_json_array_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None: + """Валидный JSON, но не объект (top-level list) — тоже неизвестная форма.""" + r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=[1, 2, 3]) + + assert r.status_code == 200, r.text + assert len(_fake_telegram_client.calls) == 1 + + +def test_empty_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None: + r = client.post(f"{_ENDPOINT}?secret={_SECRET}", content=b"") + + assert r.status_code == 200, r.text + assert len(_fake_telegram_client.calls) == 1 + + +# ── формат сообщения ───────────────────────────────────────────────────────── + + +def test_message_format_json_roundtrip(client: TestClient, _fake_telegram_client: Any) -> None: + """Sanity: убеждаемся, что тестовый payload реально валиден как JSON (не + полагаемся на literal dict без проверки сериализации).""" + body = json.dumps(_ISSUE_PAYLOAD) + r = client.post( + f"{_ENDPOINT}?secret={_SECRET}", + content=body.encode(), + headers={"content-type": "application/json"}, + ) + assert r.status_code == 200, r.text 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_imv_retry_stuck.py b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py index b414aefc..f6bd4605 100644 --- a/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py +++ b/tradein-mvp/backend/tests/test_house_imv_retry_stuck.py @@ -203,9 +203,9 @@ async def test_explicit_only_status_still_takes_exhausted_houses() -> None: {"ids": list(_IDS)}, ) } - assert ( - statuses[_H_EXHAUSTED] != "transient_error" - ), "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит" + assert statuses[_H_EXHAUSTED] != "transient_error", ( + "явно запрошенный статус обрабатывается целиком, включая исчерпавшие лимит" + ) # Автоповтора поверх явного запроса нет: pending не тронут. assert statuses[_H_PENDING] == "pending" assert result.retried == 0 diff --git a/tradein-mvp/backend/tests/test_impersonate_single_source.py b/tradein-mvp/backend/tests/test_impersonate_single_source.py new file mode 100644 index 00000000..4388b25e --- /dev/null +++ b/tradein-mvp/backend/tests/test_impersonate_single_source.py @@ -0,0 +1,67 @@ +"""Единственный источник правды для curl_cffi impersonate-профиля (#3034). + +До этого фикса `impersonate="chrome120"` был захардкожен литералом в 9+ местах +scraper_kit (`providers/avito/{serp,detail,imv,houses}.py`, +`orchestration/pipeline.py` x4, `providers/cian/valuation.py`, +`providers/yandex/valuation.py`) — обновить TLS-профиль значило найти и +поправить их все вручную, и один пропущенный литерал молча гонял бы устаревший +профиль. Теперь единственный источник — `DEFAULT_IMPERSONATE` +(`providers/_base.py`); любой caller обязан читать константу. + +ПОЧЕМУ ГРЕП, А НЕ AST. Инвариант — «строка `chrome120` не встречается нигде в +дереве» (ни в коде, ни в docstring/комментарии) — буквально то, что нужно +поймать: сменится профиль — не должно остаться ни одного места, которое надо +будет вспомнить и найти руками. AST увидел бы только строковые константы в +выражениях (kwarg-значения, docstring), но не поймал бы упоминание в +`#`-комментарии — а именно в комментарии литерал незаметнее всего переживёт +следующий рефактор. + +Область: `packages/scraper-kit/src/scraper_kit/` — пакет, где живёт +`DEFAULT_IMPERSONATE` и все providers. Legacy-дубликаты вне kit (например +`app/services/cian_session.py`, ещё не мигрированный на kit, #2361 F4a) сюда +намеренно не входят — отдельный, более крупный периметр вне scope #3034. +""" + +from __future__ import annotations + +from pathlib import Path + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +_KIT_SRC = _BACKEND_ROOT.parent / "packages" / "scraper-kit" / "src" / "scraper_kit" + +_BANNED_LITERAL = "chrome120" + + +def test_scan_area_exists() -> None: + """Область сканирования жива — иначе сторож зелен вхолостую (путь съехал).""" + assert _KIT_SRC.is_dir(), f"область сканирования съехала: {_KIT_SRC}" + + +def test_detector_actually_detects(tmp_path: Path) -> None: + """Сторож обязан уметь краснеть на литерале — иначе он зелен вхолостую.""" + probe = tmp_path / "probe.py" + probe.write_text('impersonate = "chrome120"\n', encoding="utf-8") + assert _BANNED_LITERAL in probe.read_text(encoding="utf-8") + + clean = tmp_path / "clean.py" + clean.write_text( + "from scraper_kit.providers._base import DEFAULT_IMPERSONATE\n", encoding="utf-8" + ) + assert _BANNED_LITERAL not in clean.read_text(encoding="utf-8") + + +def test_default_impersonate_is_the_only_chrome_profile_literal() -> None: + """Ни один файл scraper_kit не содержит строку "chrome120" — ни в коде, ни в + docstring/комментарии. Единственное разрешённое место для конкретного номера + профиля — значение `DEFAULT_IMPERSONATE` в `providers/_base.py`. + """ + offenders = [ + str(path.relative_to(_KIT_SRC)) + for path in sorted(_KIT_SRC.rglob("*.py")) + if _BANNED_LITERAL in path.read_text(encoding="utf-8") + ] + assert offenders == [], ( + f"{offenders}: литерал {_BANNED_LITERAL!r} обходит DEFAULT_IMPERSONATE " + "(providers/_base.py) — единственный источник правды для impersonate-" + "профиля. Прочитай константу вместо хардкода строки." + ) 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_matching.py b/tradein-mvp/backend/tests/test_matching.py index e0066101..a1d7e7ef 100644 --- a/tradein-mvp/backend/tests/test_matching.py +++ b/tradein-mvp/backend/tests/test_matching.py @@ -439,13 +439,13 @@ def test_match_house_advisory_lock_called_first(): sql_obj = first_call[0][0] # TextClause bind = first_call[0][1] # dict - assert "pg_advisory_xact_lock" in str( - sql_obj - ), f"first execute must be advisory lock, got: {sql_obj}" + assert "pg_advisory_xact_lock" in str(sql_obj), ( + f"first execute must be advisory lock, got: {sql_obj}" + ) assert "fp" in bind, f"lock bind must include fp, got: {bind}" - assert ( - isinstance(bind["fp"], str) and len(bind["fp"]) == 32 - ), f"fp must be 32-char sha256 hex, got: {bind.get('fp')!r}" + assert isinstance(bind["fp"], str) and len(bind["fp"]) == 32, ( + f"fp must be 32-char sha256 hex, got: {bind.get('fp')!r}" + ) # --------------------------------------------------------------------------- @@ -484,9 +484,9 @@ def test_geo_match_does_not_register_alias(): db, "n1", "ext-geo", address="улица Новая 3", lat=56.83, lon=60.59 ) assert (house_id, conf, method) == (22, 0.7, "geo_proximity") - assert not any( - "INSERT INTO house_address_aliases" in s for s in _executed_sqls(db) - ), "geo match must not write an alias (P3)" + assert not any("INSERT INTO house_address_aliases" in s for s in _executed_sqls(db)), ( + "geo match must not write an alias (P3)" + ) def test_geo_match_rejected_when_house_number_differs(): @@ -539,18 +539,18 @@ def test_bare_street_numberless_no_house_created(): ) assert (house_id, conf, method) == (None, 0.0, "no_house_number") sqls = _executed_sqls(db) - assert not any( - "normalized_address = :na" in s for s in sqls - ), "bare-street address must not run Tier 2b normalized_address lookup (P1)" - assert not any( - "INSERT INTO houses" in s for s in sqls - ), "numberless address must not create a house (P1 extended)" - assert not any( - "INSERT INTO house_address_aliases" in s for s in sqls - ), "numberless address must not register an alias (P1)" - assert not any( - "INSERT INTO house_sources" in s for s in sqls - ), "numberless refusal must not upsert house_sources" + assert not any("normalized_address = :na" in s for s in sqls), ( + "bare-street address must not run Tier 2b normalized_address lookup (P1)" + ) + assert not any("INSERT INTO houses" in s for s in sqls), ( + "numberless address must not create a house (P1 extended)" + ) + assert not any("INSERT INTO house_address_aliases" in s for s in sqls), ( + "numberless address must not register an alias (P1)" + ) + assert not any("INSERT INTO house_sources" in s for s in sqls), ( + "numberless refusal must not upsert house_sources" + ) def test_numberless_none_address_with_coords_no_house_created(): @@ -575,9 +575,9 @@ def test_numberless_none_address_with_coords_no_house_created(): db, "yandex", "ext-none-addr", address=None, lat=56.83, lon=60.59 ) assert (house_id, conf, method) == (None, 0.0, "no_house_number") - assert not any( - "INSERT INTO houses" in s for s in _executed_sqls(db) - ), "address-NULL coords-only listing must not create a house (P1 extended)" + assert not any("INSERT INTO houses" in s for s in _executed_sqls(db)), ( + "address-NULL coords-only listing must not create a house (P1 extended)" + ) def test_numberless_address_with_cadastral_creates_house(): @@ -611,9 +611,9 @@ def test_numberless_address_with_cadastral_creates_house(): building_cadastral_number="66:41:0000000:12345", ) assert (house_id, conf, method) == (321, 1.0, "new") - assert any( - "INSERT INTO houses" in s for s in _executed_sqls(db) - ), "numberless + cadastral must still create a house (cadastral = identity)" + assert any("INSERT INTO houses" in s for s in _executed_sqls(db)), ( + "numberless + cadastral must still create a house (cadastral = identity)" + ) def test_insert_alias_noop_for_bare_street(): @@ -735,9 +735,9 @@ def test_tier2b_no_coords_no_city_token_skips_and_creates_new(): db, "avito", "ext-2b-bare", address="улица Ленина 100" ) assert (house_id, conf, method) == (910, 1.0, "new") - assert not any( - "normalized_address = :na" in s for s in _executed_sqls(db) - ), "bare common-street with no coords/city must not run a Tier-2b lookup" + assert not any("normalized_address = :na" in s for s in _executed_sqls(db)), ( + "bare common-street with no coords/city must not run a Tier-2b lookup" + ) def test_tier2b_no_coords_with_city_token_matches(): @@ -792,12 +792,12 @@ def test_tier2a_coord_less_non_ekb_city_skips_both_lookups_and_creates_new(): ) assert (house_id, conf, method) == (701, 1.0, "new") sqls = _executed_sqls(db) - assert not any( - "fingerprint = :fp" in s for s in sqls - ), "coord-less non-ЕКБ card must NOT run the Tier-2a fingerprint lookup" - assert not any( - "normalized_address = :na" in s for s in sqls - ), "coord-less non-ЕКБ card must NOT run the Tier-2b normalized_address lookup" + assert not any("fingerprint = :fp" in s for s in sqls), ( + "coord-less non-ЕКБ card must NOT run the Tier-2a fingerprint lookup" + ) + assert not any("normalized_address = :na" in s for s in sqls), ( + "coord-less non-ЕКБ card must NOT run the Tier-2b normalized_address lookup" + ) def test_tier2a_coord_less_ekb_city_still_matches(): @@ -818,9 +818,9 @@ def test_tier2a_coord_less_ekb_city_still_matches(): db, "avito", "ext-2a-ekb", address="Екатеринбург, ул. Ленина, 5" ) assert (house_id, conf, method) == (88, 0.9, "fingerprint") - assert any( - "fingerprint = :fp" in s for s in _executed_sqls(db) - ), "ЕКБ coord-less card must still run the Tier-2a fingerprint lookup" + assert any("fingerprint = :fp" in s for s in _executed_sqls(db)), ( + "ЕКБ coord-less card must still run the Tier-2a fingerprint lookup" + ) def test_tier2a_coord_less_bare_street_still_runs_tier2a(): @@ -872,9 +872,9 @@ def test_tier2a_bare_card_from_oblast_sweep_skips_alias_lookups(): ) 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("fingerprint = :fp" in s for s in sqls), ( + "карточка чужого города прошла Tier-2a по бескоординатному ключу «улица + номер»" + ) assert not any("normalized_address = :na" in s for s in sqls) @@ -1085,13 +1085,13 @@ def test_field_priority_sources_are_lists_or_known_string_rules(): rules alongside list entries. Both are valid per LISTING_FIELD_PRIORITY type hint. """ for col, sources in HOUSE_FIELD_PRIORITY.items(): - assert ( - isinstance(sources, list) or sources in VALID_STRING_RULES - ), f"HOUSE_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}" + assert isinstance(sources, list) or sources in VALID_STRING_RULES, ( + f"HOUSE_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}" + ) for col, sources in LISTING_FIELD_PRIORITY.items(): - assert ( - isinstance(sources, list) or sources in VALID_STRING_RULES - ), f"LISTING_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}" + assert isinstance(sources, list) or sources in VALID_STRING_RULES, ( + f"LISTING_FIELD_PRIORITY[{col!r}] must be list or known rule, got {sources!r}" + ) def test_update_canonical_fields_is_callable(): diff --git a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py index b16be85b..51107d0c 100644 --- a/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py +++ b/tradein-mvp/backend/tests/test_matching_tier_reachability_2674.py @@ -65,9 +65,9 @@ def test_fias_tier_is_gone_from_create_path_but_alive_in_readonly() -> None: # Ищем именно литерал method-значения ('"fias_exact"'), а не слово в комментарии: # комментарий-надгробие про удалённый тир остаться должен, ветка — нет. - assert '"fias_exact"' not in inspect.getsource( - match_or_create_house - ), "мёртвая fias-ветка вернулась в путь создания домов" + assert '"fias_exact"' not in inspect.getsource(match_or_create_house), ( + "мёртвая fias-ветка вернулась в путь создания домов" + ) assert '"fias_exact"' in inspect.getsource(match_house_readonly) @@ -104,9 +104,9 @@ def test_house_key_never_accepts_flat_cadastre() -> None: 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 - ), "в ключ дома вернулся фолбэк на кадастр квартиры" + assert "cad = building_cadastral_number\n" in src, ( + "в ключ дома вернулся фолбэк на кадастр квартиры" + ) def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None: @@ -128,6 +128,6 @@ def test_sweep_city_actually_reaches_the_matcher_from_save_listings() -> None: ) # save_listings считает lot_city (город batch'а после гео-guard'а) и обязан отдать # именно его, а не сырой city-аргумент: лот вне city_radius_km города НЕ помечен. - assert "city=lot_city" in inspect.getsource( - save_listings - ), "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён" + assert "city=lot_city" in inspect.getsource(save_listings), ( + "save_listings отдаёт матчеру не lot_city — гео-guard соседнего города обойдён" + ) diff --git a/tradein-mvp/backend/tests/test_metrics.py b/tradein-mvp/backend/tests/test_metrics.py new file mode 100644 index 00000000..1107c9ac --- /dev/null +++ b/tradein-mvp/backend/tests/test_metrics.py @@ -0,0 +1,139 @@ +"""Слой метрик: метка маршрута не должна взрывать кардинальность (#3078). + +Проверяется не «эндпоинт отвечает 200», а ровно то, чем метрики убивают сами +себя. У Prometheus временной ряд стоит памяти постоянно, а не в момент запроса, +поэтому идентификатор заявки, попавший в метку, кладёт приёмник за сутки. Отказ +отложенный и не выглядит как ошибка кода — обычный тест на статус его не увидит. + +Маршруты-пробы названы уникально (`__metrics_probe__`): реестр +`prometheus_client` глобален на процесс, и совпади имя с боевым, тесты начали бы +влиять друг на друга через общий счётчик. По той же причине сравниваются +приращения, а не абсолютные значения. +""" + +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from prometheus_client import REGISTRY + +from app.observability import metrics as m + +_ROUTE = "/__metrics_probe__/{item_id}" +_BOOM = "/__metrics_probe_boom__" + + +@pytest.fixture +def client() -> TestClient: + app = FastAPI() + app.include_router(m.router) + + @app.get(_ROUTE) + def probe(item_id: str) -> dict[str, str]: + return {"item": item_id} + + @app.get(_BOOM) + def boom() -> dict[str, str]: + raise RuntimeError("нарочно — проверяем, что упавший запрос посчитан") + + # Последним, как в app/main.py: add_middleware вставляет в начало списка, + # значит зарегистрированный последним оказывается самым внешним. + app.add_middleware(m.MetricsMiddleware) + return TestClient(app, raise_server_exceptions=False) + + +def _count(route: str, status: str, method: str = "GET") -> float: + value = REGISTRY.get_sample_value( + "http_requests_total", {"method": method, "route": route, "status": status} + ) + return value or 0.0 + + +def test_route_label_is_template_not_path(client: TestClient) -> None: + """Три разные заявки дают ОДИН ряд, а не три.""" + before = _count(_ROUTE, "200") + + for item in ("lead-1001", "lead-1002", "lead-1003"): + assert client.get(f"/__metrics_probe__/{item}").status_code == 200 + + assert _count(_ROUTE, "200") - before == 3.0 + + body = client.get("/metrics").text + for item in ("lead-1001", "lead-1002", "lead-1003"): + assert item not in body, f"идентификатор утёк в метку: {item}" + + +def test_unmatched_paths_collapse_into_one_series(client: TestClient) -> None: + """Сканер, перебирающий адреса, не должен плодить ряды.""" + before = _count(m.UNMATCHED, "404") + + assert client.get("/wp-admin/setup-config.php").status_code == 404 + assert client.get("/.env").status_code == 404 + assert client.get("/явно-нет-такого-пути").status_code == 404 + + assert _count(m.UNMATCHED, "404") - before == 3.0 + assert "wp-admin" not in client.get("/metrics").text + + +def test_exception_is_counted_as_500(client: TestClient) -> None: + """Исключение проходит сквозь слой наружу — без finally запрос бы потерялся.""" + before = _count(_BOOM, "500") + assert client.get(_BOOM).status_code == 500 + assert _count(_BOOM, "500") - before == 1.0 + + +def test_in_progress_returns_to_baseline(client: TestClient) -> None: + """inc/dec сходятся, в том числе на упавшем запросе. + + Значение 1 — это сам скрейп, который в момент выгрузки ещё в обработке. + Разъехавшийся счётчик выглядел бы как вечно растущая линия «запросов в + работе» при простаивающем сервисе. + """ + client.get("/__metrics_probe__/x") + client.get(_BOOM) + assert "http_requests_in_progress 1.0" in client.get("/metrics").text + + +def test_exposition_carries_histogram_and_build_info(client: TestClient) -> None: + client.get("/__metrics_probe__/x") + body = client.get("/metrics").text + assert "http_request_duration_seconds_bucket{" in body + assert "http_request_duration_seconds_count{" in body + # Версия — из app/core/version.py, единственного источника правды; отдельного + # хардкода здесь быть не должно. + assert 'app_build_info{app="mera"' in body + + +def test_metrics_path_is_public_for_the_in_network_agent() -> None: + """Без этой записи скрейп получал бы 401 и метрик не было бы вовсе. + + Наружу путь при этом не открыт: у `gendsgn.ru` бэкенду «Меры» отдаётся + только `/trade-in/api/*`, у `meraocenka.ru` работает белый список, и в обоих + блоках на `/metrics` стоит явный `respond 404`. + """ + from app.core.rbac import _PUBLIC_PATHS + + assert "/metrics" in _PUBLIC_PATHS + + +def test_metrics_survives_the_rate_limiter(monkeypatch: pytest.MonkeyPatch) -> None: + """Скрейп не должен ловить 429. + + Агент ходит раз в 30 секунд бесконечно. Попади `/metrics` под общий лимитер — + метрики начали бы пропадать пачками именно под нагрузкой, то есть ровно + тогда, когда нужны. Сегодня спасает то, что `ratelimit.py` смотрит только на + пути под `/api/`; тест сторожит это свойство, а не переписывает его. + """ + from app.core.config import settings + from app.core.ratelimit import RateLimitMiddleware + + monkeypatch.setattr(settings, "rate_limit", 2, raising=False) + + app = FastAPI() + app.include_router(m.router) + app.add_middleware(RateLimitMiddleware) + probe = TestClient(app) + + statuses = [probe.get("/metrics").status_code for _ in range(6)] + assert statuses == [200] * 6, statuses 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 index 72500fe3..34af6701 100644 --- 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 @@ -51,9 +51,9 @@ def test_migration_201_deletes_by_domain_not_id() -> None: 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 разъезжается между средами" + assert re.search(r"where\s+id\s*(=|in)", flat) is None, ( + "миграция не должна фильтровать по id — id разъезжается между средами" + ) def test_migration_201_is_idempotent_by_construction() -> None: 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..492fdb1c --- /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_migration_numbering.py b/tradein-mvp/backend/tests/test_migration_numbering.py new file mode 100644 index 00000000..a97ebcc8 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_numbering.py @@ -0,0 +1,215 @@ +"""Инварианты нумерации миграций trade-in (issues #2216, #2683). + +КОНТРАКТ. ОДНА ФОРМУЛИРОВКА, И ОНА ЗДЕСЬ — больше нигде её дублировать не надо. + + Эталон «что уже закреплено на проде» — origin/main, а не файл-список. + deploy-tradein.yml прогоняет КАЖДЫЙ data/sql/*.sql из main под ON_ERROR_STOP + (падение миграции => красный деплой) и трекает применённое по bare filename в + public._schema_migrations. То есть «файл доехал до main» == «имя закреплено на + проде», и вести это знание отдельно от git незачем: git и есть журнал. + + Отсюда ровно два инварианта, и ниже проверяются именно они. + + 1. Имя, существовавшее в точке ветвления, нельзя переименовать или удалить. + Прод помнит СТАРОЕ имя; новое считается неприменённым и прогоняется + повторно — дубль-INSERT / повторный DDL / PK violation под ON_ERROR_STOP, + то есть либо красный деплой, либо тихо задвоенные данные. Нужно изменить + уже применённую миграцию — заводи НОВЫЙ файл, старый оставь как есть. + + 2. НОВЫЙ файл обязан нести NN-префикс, свободный не только в рабочем дереве, + но и в origin/main. Порядок применения — `ls | sort`, два файла с одним NN + дают неопределённый порядок. Шесть исторических дублей (084/108/113/121/ + 124/130) не «новые» и не флагаются. + + ДОПИСЫВАТЬ НИЧЕГО НЕ НАДО. Автор миграции кладёт файл со свободным номером — и + всё. Списка, который можно забыть обновить, здесь больше нет: до #2683 таким + списком был data/sql/_manifest_applied.txt, и он по построению не мог + покраснеть — «файл, которого нет в списке» и «новый файл этого PR» были для + теста одним и тем же, поэтому забытое имя навсегда оставалось зелёным + (замер 2026-08-07: 15 забытых имён, сьют зелёный). + + ПОЧЕМУ ТОЧКА ВЕТВЛЕНИЯ, А НЕ САМ origin/main. Ветка, отведённая неделю назад, + не содержит миграций, смерженных после неё. Правило «origin/main ⊆ рабочее + дерево» красило бы каждую такую ветку без вины автора — и его отключили бы + через неделю. Сверка с merge-base ловит ровно то, что удалила или + переименовала ЭТА ветка, а номера при этом сверяются с ПОЛНЫМ origin/main, + чтобы коллизия с миграцией, смерженной после ветвления, всё-таки нашлась. + + КАК УБЕДИТЬСЯ, ЧТО СТОРОЖ УМЕЕТ КРАСНЕТЬ (не на слово): + test_collision_rule_flags_a_taken_number ниже проверяет само правило на + литеральных входах, а сквозной прогон воспроизводится так — + git worktree add --detach /tmp/wt + touch /tmp/wt/tradein-mvp/backend/data/sql/<занятый-NN>_probe.sql + (cd /tmp/wt/tradein-mvp/backend && pytest tests/test_migration_numbering.py) +""" + +from __future__ import annotations + +import os +import re +import subprocess +from collections.abc import Iterable +from pathlib import Path, PurePosixPath + +import pytest + +_TESTS_DIR = Path(__file__).resolve().parent +_SQL_DIR = _TESTS_DIR.parent / "data" / "sql" +_REPO_ROOT = _TESTS_DIR.parents[2] +# Путь каталога ОТ КОРНЯ РЕПО — им адресуем дерево коммита через git ls-tree. +_SQL_PATHSPEC = "tradein-mvp/backend/data/sql" + +_NN_PREFIX = re.compile(r"^(\d+)_") +# origin — штатный remote; forgejo/main оставлен как исторический алиас. +_MAIN_REFS = ("origin/main", "forgejo/main", "main") + + +def _git(*args: str) -> str | None: + """stdout git-команды, либо None если git недоступен/команда упала.""" + try: + done = subprocess.run( + # Фиксированный argv, без shell — args приходят только из этого модуля. + ["git", "-C", str(_REPO_ROOT), *args], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + return done.stdout if done.returncode == 0 else None + + +def _sql_names_at(rev: str) -> set[str]: + """Bare-имена *.sql в data/sql на ревизии rev.""" + out = _git("ls-tree", "-r", "-z", "--name-only", rev, "--", _SQL_PATHSPEC) or "" + return {PurePosixPath(p).name for p in out.split("\0") if p.endswith(".sql")} + + +def _sql_names_on_disk() -> set[str]: + """Bare-имена *.sql в рабочем дереве — включая ещё не закоммиченные.""" + return {p.name for p in _SQL_DIR.glob("*.sql")} + + +def _prefix(name: str) -> str | None: + m = _NN_PREFIX.match(name) + return m.group(1) if m else None + + +def _collisions(new_names: Iterable[str], universe: Iterable[str]) -> list[str]: + """Для каждого НОВОГО имени — чужие имена с тем же NN-префиксом.""" + by_prefix: dict[str, set[str]] = {} + for name in universe: + p = _prefix(name) + if p is not None: + by_prefix.setdefault(p, set()).add(name) + + found: list[str] = [] + for name in sorted(new_names): + p = _prefix(name) + if p is None: + continue + others = sorted(by_prefix.get(p, set()) - {name}) + if others: + found.append(f"{name} — номер {p} уже занят: {', '.join(others)}") + return found + + +def _baseline() -> tuple[set[str], set[str]]: + """(имена в точке ветвления, имена в main). Без git-эталона проверять нечего.""" + main_ref = next( + (r for r in _MAIN_REFS if _git("rev-parse", "--verify", "--quiet", f"{r}^{{commit}}")), + None, + ) + merge_base = None + if main_ref is not None: + out = _git("merge-base", main_ref, "HEAD") + merge_base = out.strip() if out else None + + if main_ref is None or merge_base is None: + why = ( + f"нет git-эталона миграций: ни один из {_MAIN_REFS} не резолвится либо у него " + f"нет общего предка с HEAD (repo={_REPO_ROOT}). Лечится " + "`git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main` " + "и НЕ shallow-клоном (нужен общий предок)." + ) + # В CI это не «нечего проверять», а сломанный гейт: пропуск здесь и есть + # тот зелёный, который ничего не проверяет. Поэтому красим. + if os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"): + pytest.fail(why) + pytest.skip(why) + + base_names = _sql_names_at(merge_base) + main_names = _sql_names_at(main_ref) + # Анти-вакуум: пустой эталон сделал бы обе проверки зелёными всегда. + # Ровно так ломается сторож, если _SQL_PATHSPEC разъедется с раскладкой репо. + assert base_names, ( + f"эталон пуст: git ls-tree {merge_base} -- {_SQL_PATHSPEC} не вернул ни одного " + "*.sql. Проверка номеров была бы вакуумно-зелёной — почини путь." + ) + assert main_names, f"в {main_ref} не найдено *.sql по пути {_SQL_PATHSPEC} — то же самое." + return base_names, main_names + + +def test_applied_migration_is_not_renamed_or_deleted() -> None: + """Файл, существовавший в точке ветвления, обязан существовать и сейчас. + + Red => эта ветка переименовала или удалила миграцию, которую прод уже + применил и помнит по СТАРОМУ имени. Верни исходное имя; нужно поправить + поведение — заводи новый файл с новым номером. + """ + on_disk = _sql_names_on_disk() + assert on_disk, f"не найдено *.sql в {_SQL_DIR}" + base_names, _ = _baseline() + + gone = sorted(base_names - on_disk) + assert not gone, ( + f"миграции пропали из {_SQL_PATHSPEC}/ (переименованы или удалены): {gone}. " + "Прод трекает их по bare-filename в _schema_migrations — под новым именем " + "миграция прогонится повторно. Верни имена как были." + ) + + +def test_new_migration_takes_a_free_number() -> None: + """Новый файл не переиспользует NN, занятый в origin/main или в этой ветке. + + Red => номер уже занят. Возьми следующий свободный, сверяясь с origin/main: + + git fetch origin main + git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail + + `-r` обязателен: без него ls-tree печатает сам каталог, а не файлы (в этом + виде рецепт и ходил по issue #2683 — и молча возвращал одну строку). + Локального `ls` недостаточно: он не видит миграций, смерженных после + ветвления — ровно так разъехались 212 в #2682 и 234 в #2754. + """ + on_disk = _sql_names_on_disk() + base_names, main_names = _baseline() + + new_names = on_disk - base_names + problems = _collisions(new_names, main_names | on_disk) + assert not problems, "коллизия номеров миграций: " + "; ".join(problems) + + +def test_collision_rule_flags_a_taken_number() -> None: + """Проверка самого правила — сторож обязан уметь краснеть (#2683 п.6). + + Ожидания здесь — ЛИТЕРАЛЫ, а не производные от содержимого data/sql: тест, + который берёт ожидание из охраняемой настройки, зелен при любой настройке. + """ + # Реальный случай #2754: ветка отвелась до того, как в main приехал 234_scrape. + assert _collisions( + ["234_trade_in_estimates_retain_until.sql"], + { + "233_payments.sql", + "234_scrape_runs_ban_kind_unknown.sql", + "234_trade_in_estimates_retain_until.sql", + }, + ) == [ + "234_trade_in_estimates_retain_until.sql — номер 234 уже занят: " + "234_scrape_runs_ban_kind_unknown.sql" + ] + # Два новых файла с одним номером внутри одной ветки — оба названы. + assert len(_collisions(["300_a.sql", "300_b.sql"], {"300_a.sql", "300_b.sql"})) == 2 + # Свободный номер — тишина; исторические дубли не новые и не флагаются. + assert _collisions(["300_a.sql"], {"084_x.sql", "084_y.sql", "300_a.sql"}) == [] diff --git a/tradein-mvp/backend/tests/test_migrations_manifest.py b/tradein-mvp/backend/tests/test_migrations_manifest.py deleted file mode 100644 index 26e3375f..00000000 --- a/tradein-mvp/backend/tests/test_migrations_manifest.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Invariants over data/sql migrations (issue #2216). - -Прод применяет миграции по BARE FILENAME: deploy-tradein.yml трекает каждый -`data/sql/*.sql` в таблице `public._schema_migrations` (PRIMARY KEY = filename). -Из этого следуют два хрупких инварианта, которые этот тест защищает от регрессии: - -1. Переименование / удаление УЖЕ ПРИМЕНЁННОЙ миграции ломает прод: новый - filename считается неприменённым и прогоняется повторно (дубль-INSERT, - повторный DDL, PK violation под ON_ERROR_STOP => деплой падает или, хуже, - молча дублирует данные). Manifest `data/sql/_manifest_applied.txt` — это - слепок применённых имён на момент коммита; любой из них ОБЯЗАН существовать. - -2. Два разных файла с одинаковым NN-префиксом ("дубль-префикс") — источник - двусмысленного порядка применения (`ls | sort` детерминирован, но человек - легко создаёт коллизию). 6 исторических дублей grandfathered'ы (оба в - manifest). Любой НОВЫЙ файл обязан нести уникальный префикс. - -Self-maintenance: добавляя новую миграцию, допиши её имя в manifest В ТОМ ЖЕ PR -(см. assert-сообщения ниже). Тест требует data/sql ⊇ manifest и уникальность -префикса у новых файлов; сам manifest дополняет автор миграции. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -_BACKEND_ROOT = Path(__file__).resolve().parents[1] -_SQL_DIR = _BACKEND_ROOT / "data" / "sql" -_MANIFEST = _SQL_DIR / "_manifest_applied.txt" - -_NN_PREFIX = re.compile(r"^(\d+)_") - - -def _read_manifest() -> list[str]: - """Имена миграций из manifest; # comments и пустые строки игнорируются.""" - names: list[str] = [] - for raw in _MANIFEST.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - names.append(line) - return names - - -def _actual_sql_files() -> set[str]: - return {p.name for p in _SQL_DIR.glob("*.sql")} - - -def _prefix(name: str) -> str | None: - m = _NN_PREFIX.match(name) - return m.group(1) if m else None - - -def test_manifest_entries_all_exist() -> None: - """Каждый файл из manifest СУЩЕСТВУЕТ в data/sql/. - - Red => применённая миграция переименована или удалена. Прод трекает по - bare-filename: старое имя остаётся в _schema_migrations, НОВОЕ имя считается - неприменённым и прогоняется повторно. Восстанови исходное имя файла (или, - если переименование намеренное — так делать НЕЛЬЗЯ для уже-применённых - миграций: заведи НОВЫЙ файл, а старый оставь как есть). - """ - actual = _actual_sql_files() - manifest = _read_manifest() - missing = sorted(n for n in manifest if n not in actual) - assert not missing, ( - "Миграции из _manifest_applied.txt отсутствуют в data/sql/ " - f"(переименованы/удалены?): {missing}. Эти имена уже применены на проде " - "(tracking по bare-filename в _schema_migrations) — их нельзя " - "переименовывать/удалять. Верни исходные имена файлов." - ) - - -def test_manifest_is_sorted_and_unique() -> None: - """Manifest отсортирован (codepoint) и без дублей — детерминированный слепок.""" - manifest = _read_manifest() - assert manifest == sorted(manifest), ( - "_manifest_applied.txt не отсортирован. Пересортируй записи " - "(LC_ALL=C sort / Python sorted())." - ) - dupes = sorted({n for n in manifest if manifest.count(n) > 1}) - assert not dupes, f"Дублирующиеся строки в _manifest_applied.txt: {dupes}" - - -def test_new_files_do_not_reuse_prefix() -> None: - """Новые (не в manifest) .sql-файлы НЕ переиспользуют существующий NN-префикс. - - Grandfathered дубли (084/108/113/121/124/130) — оба файла в manifest, поэтому - не флагаются: считаются "существующими", а не "новыми". - - Red => новый файл взял префикс уже присутствующей миграции. Присвой - следующий свободный NN и допиши имя в _manifest_applied.txt (тот же PR). - """ - actual = _actual_sql_files() - manifest = set(_read_manifest()) - - # Префиксы, «занятые» уже-применёнными (manifest) миграциями. - baseline_prefixes: set[str] = set() - for name in manifest: - p = _prefix(name) - if p is not None: - baseline_prefixes.add(p) - - new_files = sorted(actual - manifest) - collisions: list[str] = [] - # Внутри новых файлов префикс тоже обязан быть уникален (два новых с одним NN). - seen_new_prefix: dict[str, str] = {} - for name in new_files: - p = _prefix(name) - if p is None: - continue - if p in baseline_prefixes: - collisions.append(f"{name} (префикс {p} занят применённой миграцией)") - elif p in seen_new_prefix: - collisions.append(f"{name} (префикс {p} уже у нового {seen_new_prefix[p]})") - else: - seen_new_prefix[p] = name - - assert not collisions, ( - "Новые миграции переиспользуют NN-префикс: " + "; ".join(collisions) + ". " - "Присвой следующий свободный номер и добавь имя файла в " - "_manifest_applied.txt в ЭТОМ ЖЕ PR." - ) - - -def test_manifest_covers_all_but_new_files() -> None: - """data/sql ⊇ manifest, и каждый новый файл имеет уникальный префикс — - напоминание о self-maintenance: manifest дополняется вместе с миграцией. - - Этот тест НЕ требует, чтобы новый файл уже был в manifest (иначе PR с новой - миграцией всегда красный). Он лишь гарантирует, что manifest не отстал от - реальности В ЧАСТИ применённых имён (см. test_manifest_entries_all_exist) и - что новые файлы не создают префикс-коллизий (см. предыдущий тест). - """ - # Sanity: manifest непустой и в data/sql есть файлы — защита от битых путей. - assert _actual_sql_files(), f"Не найдено *.sql в {_SQL_DIR}" - assert _read_manifest(), f"_manifest_applied.txt пуст: {_MANIFEST}" diff --git a/tradein-mvp/backend/tests/test_password.py b/tradein-mvp/backend/tests/test_password.py index a03e5c9f..8d1dd9e1 100644 --- a/tradein-mvp/backend/tests/test_password.py +++ b/tradein-mvp/backend/tests/test_password.py @@ -285,9 +285,9 @@ async def test_one_key_cannot_take_more_than_its_share(monkeypatch: pytest.Monke 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}" + assert time.monotonic() < deadline, ( + f"слотов занято {password_mod._verify_inflight} < {n}" + ) await asyncio.sleep(0.005) flood = [ diff --git a/tradein-mvp/backend/tests/test_pdf_security.py b/tradein-mvp/backend/tests/test_pdf_security.py index 7d6d21ed..7789b8e0 100644 --- a/tradein-mvp/backend/tests/test_pdf_security.py +++ b/tradein-mvp/backend/tests/test_pdf_security.py @@ -522,9 +522,9 @@ def test_brand_not_taken_from_query_param_docstring() -> None: sig = inspect.signature(estimate_pdf) param_names = list(sig.parameters.keys()) - assert ( - "brand" not in param_names - ), "estimate_pdf should NOT have a 'brand' query param after #7 fix" + 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 ── diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py new file mode 100644 index 00000000..5b834927 --- /dev/null +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -0,0 +1,454 @@ +"""Публичный B2C-API МЕРЫ — /api/public/mera/* (issue #2911). + +Что здесь запинено и почему именно это: + + 1. ПЕРИМЕТР. Набор маршрутов под /api/public/ проверяется на РАВЕНСТВО, а не + на вхождение. Третья ручка, добавленная сюда без правки этого теста, + уронит его — а решение «эта ручка доступна анониму» не должно приниматься + мимоходом, добавлением файла в каталог. + + 2. СВЯЗКА С rbac. Публичность даёт не расположение файла, а строка в + `rbac._PUBLIC_PATHS`; тест сверяет, что для каждого маршрута она есть, и + наоборот — что `_PUBLIC_PATHS` не содержит /api/public/ путей, которых + больше нет в роутере (иначе освободившийся путь остался бы открытым для + любого, кто его потом займёт). + + 3. УЗОСТЬ ИСКЛЮЧЕНИЯ. Соседний non-public путь в том же приложении обязан + по-прежнему отдавать 401 анониму. Без этой проверки тест «аноним получает + 200» одинаково зелёный и когда исключение точечное, и когда auth-гейт + снят целиком. + + 4. БЮДЖЕТ. 429 после исчерпания per-IP окна — иначе внешний (платный) + геокодер открыт наружу без ограничений. +""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +# Settings требует DATABASE_URL на конструирование — stub до любого app-импорта +# (тот же паттерн, что в остальных tests/*.py). +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +# WeasyPrint (GTK) в CI/Windows нет, а app.api.v1.trade_in тянет его на импорте — +# public/mera.py делегирует туда, значит стаб нужен и здесь. +_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.api.public import mera as public_mera # noqa: E402 +from app.api.v1.geocode import SuggestResponse # noqa: E402 +from app.core.db import get_db # noqa: E402 +from app.core.rbac import _PUBLIC_PATHS, rbac_guard # noqa: E402 +from app.schemas.trade_in import CoverageProbeResponse # noqa: E402 + +# Внешний префикс монтирования — ровно тот, что в app/main.py. +PREFIX = "/api/public/mera" + +_COVERAGE_BODY = {"lat": 56.838, "lon": 60.597, "rooms": 2, "area_m2": 54.0} + +_FAKE_COVERAGE = CoverageProbeResponse( + status="ok", + n_listings=34, + median_listing_age_days=44, + n_with_age=6, + radius_m=1000, + city="Екатеринбург", + threshold=10, +) + + +@pytest.fixture(autouse=True) +def _reset_limiters(): + """Лимитеры — состояние МОДУЛЯ, переживающее тест. + + Без сброса порядок тестов начинает влиять на результат: тест бюджета + выбирает окно, а следующий за ним «аноним получает 200» ловит 429 и падает + не по своей вине. + """ + public_mera._suggest_limiter._hits.clear() + public_mera._coverage_limiter._hits.clear() + yield + public_mera._suggest_limiter._hits.clear() + public_mera._coverage_limiter._hits.clear() + + +@pytest.fixture() +def client() -> TestClient: + """Приложение с РЕАЛЬНЫМ rbac_guard — тем же, что регистрирует app/main.py. + + Рядом с публичным роутером намеренно висит закрытый маршрут-двойник: он и + делает проверку 3 (узость исключения) возможной внутри одного приложения. + """ + app = FastAPI() + app.middleware("http")(rbac_guard) + app.include_router(public_mera.router, prefix=PREFIX) + + @app.get("/api/v1/trade-in/coverage") + def _closed_twin() -> dict[str, str]: # pragma: no cover — до хендлера не доходит + return {"ok": "should never be reachable anonymously"} + + def _override_db(): + yield MagicMock() + + app.dependency_overrides[get_db] = _override_db + return TestClient(app) + + +# ── 1-2. Периметр и его связка с rbac ──────────────────────────────────────── + + +def test_public_router_exposes_exactly_two_routes() -> None: + paths = {r.path for r in public_mera.router.routes} + assert paths == {"/suggest", "/coverage"}, ( + "изменился набор публичных (анонимных) ручек МЕРЫ. Это не рефакторинг: " + "всё под /api/public/ проксируется на meraocenka.ru целиком и доступно " + "без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS." + ) + + +def test_every_public_route_is_exempt_in_rbac() -> None: + for route in public_mera.router.routes: + full = f"{PREFIX}{route.path}" + assert full in _PUBLIC_PATHS, ( + f"{full} смонтирован в публичном пакете, но rbac его не пропускает — " + "анониму прилетит 401, форма на лэндинге молча сломается" + ) + + +def test_rbac_has_no_stale_public_mera_exemptions() -> None: + """Обратное направление: в `_PUBLIC_PATHS` нет /api/public/ путей-призраков. + + Удалённая ручка, чья строка осталась в исключениях, открывает этот путь + заранее — для любого маршрута, который потом займёт то же место. + """ + live = {f"{PREFIX}{r.path}" for r in public_mera.router.routes} + stale = {p for p in _PUBLIC_PATHS if p.startswith("/api/public/")} - live + assert not stale, f"в rbac._PUBLIC_PATHS остались несуществующие пути: {stale}" + + +# ── 3. Аноним проходит, но исключение узкое ────────────────────────────────── + + +def test_anonymous_gets_coverage(client: TestClient) -> None: + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) + assert resp.status_code == 200, resp.text + assert resp.json()["city"] == "Екатеринбург" + + +def test_anonymous_gets_suggest(client: TestClient) -> None: + with patch.object( + public_mera, + "suggest_addresses", + AsyncMock(return_value=SuggestResponse(items=[])), + ): + resp = client.post(f"{PREFIX}/suggest", json={"q": "Малышева"}) + assert resp.status_code == 200, resp.text + assert resp.json() == {"items": []} + + +def test_suggest_is_post_so_address_never_lands_in_access_log() -> None: + """Адрес едет ТЕЛОМ, а не в query. + + На публичном домене включён access-лог Caddy, и он пишет URI целиком — + `GET /suggest?q=Малышева+51` сохранил бы адрес квартиры в файл рядом с IP + посетителя. На той же странице мы обещаем, что введённый адрес нигде не + сохраняем. Метод — часть этого обещания, поэтому запинен: вернуть GET + «для каноничности» нельзя, не сломав обещание. + """ + route = next(r for r in public_mera.router.routes if r.path == "/suggest") + assert route.methods == {"POST"} + + +def test_closed_twin_still_401_for_anonymous(client: TestClient) -> None: + """Исключение точечное, а не «auth-гейт снят». + + Без этой проверки тесты выше остаются зелёными и в случае, когда + rbac_guard перестал требовать идентичность вообще. + """ + resp = client.get("/api/v1/trade-in/coverage") + assert resp.status_code == 401, resp.text + + +# ── 4. Бюджеты ─────────────────────────────────────────────────────────────── + + +def test_coverage_rate_limited_per_ip(client: TestClient) -> None: + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + codes = [ + client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY).status_code + for _ in range(public_mera._COVERAGE_LIMIT + 2) + ] + assert codes[: public_mera._COVERAGE_LIMIT] == [200] * public_mera._COVERAGE_LIMIT + assert codes[-1] == 429, f"бюджет не сработал: {codes}" + + +def test_rate_limited_response_carries_retry_after(client: TestClient) -> None: + """429 без Retry-After — это «попробуйте когда-нибудь»: клиент начнёт + долбить сразу же, а лимит существует ровно чтобы этого не было.""" + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + for _ in range(public_mera._COVERAGE_LIMIT): + client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) + resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) + assert resp.status_code == 429 + assert int(resp.headers["Retry-After"]) > 0 + + +def test_suggest_and_coverage_budgets_are_independent(client: TestClient) -> None: + """Общий лимитер на две ручки означал бы, что набор адреса (частые + подсказки) выедает бюджет пробы — человек не смог бы нажать кнопку.""" + with patch.object( + public_mera, + "suggest_addresses", + AsyncMock(return_value=SuggestResponse(items=[])), + ): + for _ in range(public_mera._SUGGEST_LIMIT): + client.post(f"{PREFIX}/suggest", json={"q": "Малышева"}) + + with patch.object(public_mera, "coverage_probe", return_value=_FAKE_COVERAGE): + resp = client.post(f"{PREFIX}/coverage", json=_COVERAGE_BODY) + assert resp.status_code == 200, resp.text + + +# ── Контракт ответа ────────────────────────────────────────────────────────── + + +def test_public_coverage_reuses_v1_response_model(client: TestClient) -> None: + """Публичная ручка отдаёт ТОТ ЖЕ тип, что и v1. + + Это не косметика: на `CoverageProbeResponse` уже стоит гейт «ни одного + price-подобного поля» (tests/test_coverage_probe_endpoint.py). Заведи здесь + отдельную модель — и публичный ответ окажется вне того гейта, а именно он + держит продуктовое правило «бесплатный шаг не отдаёт цену». + """ + route = next(r for r in public_mera.router.routes if r.path == "/coverage") + assert route.response_model is CoverageProbeResponse + + +def test_suggest_limit_ceiling_is_lower_than_v1(client: TestClient) -> None: + """Потолок limit публичной ручки — 10 (у v1 15). Каждый лишний кандидат + может стоить внешнего (платного) вызова, а публичный UI больше и не + показывает.""" + with patch.object( + public_mera, + "suggest_addresses", + AsyncMock(return_value=SuggestResponse(items=[])), + ): + at_ceiling = client.post(f"{PREFIX}/suggest", json={"q": "Малышева", "limit": 10}) + above = client.post(f"{PREFIX}/suggest", json={"q": "Малышева", "limit": 15}) + assert at_ceiling.status_code == 200, at_ceiling.text + assert above.status_code == 422, above.text + + +# ── 5. Обещание «адрес нигде не сохраняется» ───────────────────────────────── +# +# Три пути утечки, найденные состязательным ревью 16.08.2026 и воспроизведённые +# на живом коде. Тесты сформулированы от обещания, а не от реализации: пока на +# публичной странице и в политике ПДн написано «не сохраняется», эти проверки +# обязаны быть зелёными. + + +def test_address_is_redacted_from_logs_inside_public_scope(caplog) -> None: + """Геокодер логирует введённую строку открытым текстом на каждый вызов, а + прод пишет stdout контейнеров в persistent journald — то есть адрес ложился + на диск рядом с IP того же запроса в access-логе Caddy.""" + import logging + + from app.core.public_request import install_address_log_redaction, public_request_scope + + install_address_log_redaction() + dadata_logger = logging.getLogger("app.services.dadata") + + with caplog.at_level(logging.INFO): + with public_request_scope(): + dadata_logger.info("dadata suggest: %r → %d вариантов", "онуфриева 24", 5) + # Вне публичного контура логи прежние — они нужны для разбора жалоб + # пилотов на подсказки. + dadata_logger.info("dadata suggest: %r → %d вариантов", "малышева 51", 3) + + text = "\n".join(r.getMessage() for r in caplog.records) + assert "онуфриева" not in text.lower(), "адрес анонима попал в журнал" + assert "малышева" in text.lower(), "редакция протекла на закрытый контур" + + +def test_public_scope_is_not_leaked_after_request() -> None: + """Флаг обязан сниматься: иначе первый же публичный запрос заглушил бы логи + процесса до перезапуска.""" + from app.core.public_request import is_public_request, public_request_scope + + assert is_public_request.get() is False + with public_request_scope(): + assert is_public_request.get() is True + assert is_public_request.get() is False + + +def test_sentry_scrub_drops_public_request_body_and_geocoder_query() -> None: + """sentry_sdk кладёт в событие полное тело запроса, а `send_default_pii=False` + этот путь не гейтит (он про куки и IP). Тело публичной ручки — ровно + `{"q": "<адрес>"}`; брэдкрамб httpx несёт тот же адрес в query.""" + from app.observability.sentry_scrub import scrub_public_address + + event = { + "request": { + "url": "https://meraocenka.ru/trade-in/api/public/mera/suggest", + "data": {"q": "Малышева 30"}, + }, + "breadcrumbs": { + "values": [ + { + "category": "httpx", + "data": {"url": "https://nominatim.openstreetmap.org/search?q=Малышева+30"}, + }, + {"category": "httpx", "data": {"url": "https://example.com/x?a=1"}}, + ] + }, + } + + scrubbed = scrub_public_address(event, {}) + assert scrubbed is not None + assert "data" not in scrubbed["request"], "тело запроса с адресом уехало в GlitchTip" + + crumbs = scrubbed["breadcrumbs"]["values"] + assert "Малышева" not in crumbs[0]["data"]["url"], "адрес уехал в брэдкрамбе геокодера" + # Посторонние URL не трогаем — иначе разбирать чужие ошибки станет нечем. + assert crumbs[1]["data"]["url"] == "https://example.com/x?a=1" + + +def test_sentry_scrub_keeps_closed_contour_body() -> None: + """Редакция узкая: тела запросов закрытого контура нужны для разбора.""" + from app.observability.sentry_scrub import scrub_public_address + + event = { + "request": {"url": "https://gendsgn.ru/trade-in/api/v1/trade-in/estimate", "data": {"x": 1}} + } + scrubbed = scrub_public_address(event, {}) + assert scrubbed is not None + assert scrubbed["request"]["data"] == {"x": 1} + + +# ── 6. Бюджет внешнего геокодера ───────────────────────────────────────────── + + +def test_daily_suggest_budget_protects_shared_geocoder_quota(client: TestClient) -> None: + """Per-IP окна ограничивают одного клиента, но не сумму: 20/мин с адреса — + это 28 800 в сутки при общем бесплатном тире DaData в 10 000, ОБЩЕМ с + закрытым контуром. Без суточного потолка один скрипт оставлял бы без + подсказок платящих пилотов.""" + assert public_mera._DAILY_SUGGEST_BUDGET < 10_000, ( + "суточный потолок публичных подсказок обязан быть заметно меньше всего " + "тира геокодера — иначе публичная форма может навредить закрытому контуру" + ) + assert public_mera._SUGGEST_LIMIT * 60 * 24 > public_mera._DAILY_SUGGEST_BUDGET, ( + "если per-IP лимит сам по себе не может исчерпать суточный бюджет, " + "потолок бессмысленен — проверь, что тест сторожит реальный сценарий" + ) + + +# ── 7. Город: то, что предлагаем, и то, на что умеем отвечать ──────────────── + + +def test_offered_cities_match_coverage_cities() -> None: + """Дропдаун на сайте и списки покрытия — одно множество, записанное дважды. + + Разошлись на проде 16.08.2026: `Серов` предлагался к выбору, но + отсутствовал в порогах покрытия, поэтому проба резолвила город как + неизвестный и отвечала «этот адрес вне области, по которой мы собираем + данные» — про город В ТОЙ ЖЕ области, который мы сами же и предложили. + В обратную сторону `Берёзовский`, `Среднеуральск` и `Ревда` бэкенд + обслуживал, а выбрать их было нельзя. + + Шапка `city-registry.ts` этот риск прямо предсказывала («перед добавлением + 7-го города сверить оба списка вручную, теста на это пока нет»). Вот тест. + """ + import pathlib + import re + + from app.api.v1.trade_in import COVERAGE_GREEN_CITIES, COVERAGE_YELLOW_CITIES, _fold_city + + registry = ( + pathlib.Path(__file__).resolve().parents[2] + / "frontend" + / "src" + / "lib" + / "city-registry.ts" + ) + text = registry.read_text(encoding="utf-8") + block = re.search(r"OBLAST_CITIES:\s*readonly OblastCity\[\]\s*=\s*\[(.*?)\];", text, re.S) + assert block, "не нашли OBLAST_CITIES — проверка стала бы пустой" + + offered = {_fold_city(m) for m in re.findall(r'label:\s*"([^"]+)"', block.group(1))} + answerable = {_fold_city(c) for c in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES)} + + assert offered == answerable, ( + "предлагаем выбрать и умеем отвечать — это должно быть одно множество.\n" + f" предлагаем, но не отвечаем: {sorted(offered - answerable)}\n" + f" отвечаем, но не предлагаем: {sorted(answerable - offered)}" + ) + + +def test_every_coverage_city_has_a_centroid() -> None: + """Порог без центроида недостижим: город резолвится по координатам.""" + from app.api.v1.trade_in import ( + _CITY_CENTROIDS_DEG, + COVERAGE_GREEN_CITIES, + COVERAGE_YELLOW_CITIES, + _fold_city, + ) + + centroids = {_fold_city(c) for c in _CITY_CENTROIDS_DEG} + for city in (*COVERAGE_GREEN_CITIES, *COVERAGE_YELLOW_CITIES): + assert _fold_city(city) in centroids, ( + f"{city} есть в порогах, но нет в центроидах — по координатам он " + "никогда не резолвится, и порог мёртвый" + ) + + +# ── 8. Подсказки слушаются выбранного города ──────────────────────────────── + + +def test_city_goes_into_the_query_itself() -> None: + """`city_hint` на выдачу подсказок не влияет — его смотрит только + екатеринбургский кадастровый тир, а DaData ограничена регионом целиком. + + Замер на проде: выбран Серов, введено «Ленина 1» → первой подсказкой + «Невьянский р-н, пгт Верх-Нейвинский». Человек выбирает верхний вариант и + считает чужой дом. + """ + assert public_mera._query_with_city("Ленина 1", "Серов") == "Серов, Ленина 1" + + # Город уже назван — не дублируем («Серов, Серов Ленина 1»). + assert public_mera._query_with_city("Серов Ленина 1", "Серов") == "Серов Ленина 1" + assert public_mera._query_with_city("серов ленина 1", "Серов") == "серов ленина 1" + + # ёЁ нормализуются: «Березовский» в тексте и «Берёзовский» в списке — один город. + assert public_mera._query_with_city("Березовский, Мира 5", "Берёзовский") == ( + "Березовский, Мира 5" + ) + + # Города нет — строка не трогается. + assert public_mera._query_with_city("Ленина 1", None) == "Ленина 1" + + +def test_suggest_passes_city_prefixed_query_downstream(client: TestClient) -> None: + """Проверяем не текст помощника, а то, что ручка реально отдаёт его вниз.""" + captured: dict[str, object] = {} + + async def _fake(**kwargs): + captured.update(kwargs) + return SuggestResponse(items=[]) + + with patch.object(public_mera, "suggest_addresses", _fake): + resp = client.post(f"{PREFIX}/suggest", json={"q": "Ленина 1", "city_hint": "Серов"}) + + assert resp.status_code == 200, resp.text + assert captured["q"] == "Серов, Ленина 1" + # Сам хинт продолжаем передавать: от него зависит гейт кадастрового тира. + assert captured["city_hint"] == "Серов" 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 index a49f1a96..f539f67d 100644 --- a/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py +++ b/tradein-mvp/backend/tests/test_purge_expired_trade_in_data.py @@ -435,7 +435,7 @@ def test_real_purge_deletes_only_anonymous_expired_estimates() -> None: remaining_ids = { str(r) for r in db.execute( - _t("SELECT id FROM trade_in_estimates " "WHERE id = ANY(CAST(:ids AS uuid[]))"), + _t("SELECT id FROM trade_in_estimates WHERE id = ANY(CAST(:ids AS uuid[]))"), {"ids": [str(anon_id), str(pilot_id)]}, ) .scalars() @@ -497,9 +497,9 @@ def test_real_preflight_ignores_healthy_paid_row_flags_only_anomaly() -> None: {"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" + 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. diff --git a/tradein-mvp/backend/tests/test_ratelimit.py b/tradein-mvp/backend/tests/test_ratelimit.py index 4d1cd8dc..fdb75f74 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_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_revisit_floor_lateral_lookup.py b/tradein-mvp/backend/tests/test_revisit_floor_lateral_lookup.py new file mode 100644 index 00000000..c94f2c0a --- /dev/null +++ b/tradein-mvp/backend/tests/test_revisit_floor_lateral_lookup.py @@ -0,0 +1,428 @@ +"""LATERAL-поиск предшественника в поле переобхода (#2659 продолжение, PR-A). + +`_build_revisit_floor_sql` джойнил историю переобхода ПО РАВЕНСТВУ ДАТЫ: +`prev.snapshot_date = (SELECT max(snapshot_date) FROM listing_source_snapshots +WHERE snapshot_date <= CURRENT_DATE - health_window_days)`. Это ломается ДВАЖДЫ: + +1. listing_source_snapshots переходит на модель «строка на изменение» (пишется + только когда значение отличается от предыдущего снимка) -- в ней у подавляющего + большинства listing_source_id на конкретную календарную дату строки просто нет. + На 2026-08-20 «изменениями» являются 4 458 строк из 101 795 (95.6% пар теряются). +2. Дыры в истории ломали equality-join и раньше, до перехода на change-only (на + проде: 03-14.06 -- 12 суток подряд, 03-04.07, 12.07, 26.07, 30-31.07, 01.08). + +Правка меняет equality-join на `JOIN LATERAL (... ORDER BY snapshot_date DESC +LIMIT 1) prev ON true` -- предшественник ищется ПО СТРОКЕ (per listing_source_id), +не по единой глобальной дате, и гэпы в истории для него прозрачны. Семантика не +меняется: между изменениями last_seen_at по определению постоянен, поэтому +«последний снимок не позже якоря» и «снимок ровно на дату якоря» при СПЛОШНОЙ +ежедневной истории дают одно и то же число -- разница проявляется только там, где +equality-join терял пары. + +Тесты ниже: +- test_lateral_ignores_gaps_and_matches_dense_daily_history -- LATERAL на дырявой + (change-only) истории даёт ТОТ ЖЕ floor_days, что дала бы сплошная суточная + история для того же слушателя. +- test_equality_join_loses_gapped_pairs_and_gives_a_smaller_floor -- на ОДНИХ И ТЕХ + ЖЕ данных equality-join (воспроизведён буквально -- см. _OLD_EQUALITY_JOIN_FLOOR_SQL + ниже, это ровно то условие, что было в _build_revisit_floor_sql до этого PR) + теряет строки без снимка ровно на дату якоря и даёт МЕНЬШИЙ пол. +- Две pure-SQL проверки без БД (форма LATERAL-запроса, "count(*)" из того же среза). + +Живая БД: см. `_live_session()` -- self-skip без реального Postgres, как соседние +live-тесты в этом каталоге (test_2992_upsert_unchanged_gate.py и т.д.). В CI Trade-In +есть postgres-сервис (ci-tradein.yml), локально без БД эти проверки skip'аются, а +чисто-SQL тесты внизу файла бегут всегда. +""" + +from __future__ import annotations + +import os +import re +import uuid +from datetime import UTC, date, datetime, timedelta +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from sqlalchemy import text + +from app.tasks import deactivate_stale_avito as task_mod + +_HEALTH_WINDOW_DAYS = 5 + + +def _live_session() -> Any | None: + """Тот же контракт, что у соседних live-тестов (test_2992_upsert_unchanged_gate.py).""" + 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() + conn.execute(text("SELECT 1")) + conn.close() + return sessionmaker(bind=engine, future=True)() + except Exception: + return None + + +# ── Воспроизведение СТАРОГО equality-join (буквально то, что было в +# _build_revisit_floor_sql до этой правки) -- код удалён из app/, поэтому для +# сравнения "до/после" запрос встроен сюда как литерал. Единственное упрощение: +# вместо подзапроса `(SELECT max(snapshot_date) FROM listing_source_snapshots +# WHERE snapshot_date <= CURRENT_DATE - :N)` (глобальный по ВСЕЙ таблице, не +# скоупленный per-listing_source_id -- и в этом половина исходного бага) якорная +# дата передаётся явно предвычисленным `CURRENT_DATE - :health_window_days`. +# Само по себе равенство по дате -- ровно та семантика, что менялась в этом PR; +# зависимость от глобального max() по ВСЕЙ таблице сделала бы этот тест хрупким +# к данным других тестов в той же живой БД, никак не проверяя суть правки. +_OLD_EQUALITY_JOIN_FLOOR_SQL = text( + """ + SELECT percentile_disc(CAST(:revisit_quantile AS double precision)) + WITHIN GROUP ( + ORDER BY EXTRACT(epoch FROM (l.last_seen_at - 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 = CURRENT_DATE - CAST(:health_window_days AS integer) + WHERE l.source = :listing_source + AND l.last_seen_at > NOW() - CAST(:health_window_days || ' days' AS interval) + AND l.last_seen_at > prev.last_seen_at + """ +) + +_OLD_EQUALITY_JOIN_COUNT_SQL = text( + """ + SELECT count(*) + 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 = CURRENT_DATE - CAST(:health_window_days AS integer) + WHERE l.source = :listing_source + AND l.last_seen_at > NOW() - CAST(:health_window_days || ' days' AS interval) + AND l.last_seen_at > prev.last_seen_at + """ +) + + +def _insert_pair( + db: Any, + *, + source: str, + ext_id: str, + listing_last_seen_at: datetime, + snapshots: list[tuple[date, datetime]], +) -> int: + """Вставляет listings + listing_sources + N снимков listing_source_snapshots. + + БЕЗ commit -- вся синтетика живёт в ОДНОЙ транзакции теста и видна собственным + же SELECT'ам (same-session read-your-writes), очистка -- rollback в конце теста, + отдельного DELETE не нужно. + """ + listing_id = db.execute( + text( + """ + INSERT INTO listings + (source, source_url, source_id, dedup_hash, price_rub, + is_active, scraped_at, last_seen_at) + VALUES + (:source, :url, :ext_id, :dedup_hash, 5000000, + true, NOW(), :last_seen_at) + RETURNING id + """ + ), + { + "source": source, + "url": f"https://example.test/pr2659-lateral/{ext_id}", + "ext_id": ext_id, + "dedup_hash": f"pr2659-lateral-{ext_id}", + "last_seen_at": listing_last_seen_at, + }, + ).scalar_one() + + listing_source_id = db.execute( + text( + """ + INSERT INTO listing_sources + (listing_id, ext_source, ext_id, confidence, matched_method) + VALUES + (:listing_id, :source, :ext_id, 1.0, 'test') + RETURNING id + """ + ), + {"listing_id": listing_id, "source": source, "ext_id": ext_id}, + ).scalar_one() + + for snapshot_date, snapshot_last_seen_at in snapshots: + db.execute( + text( + """ + INSERT INTO listing_source_snapshots + (listing_source_id, snapshot_date, is_active, last_seen_at) + VALUES + (:lsid, :snapshot_date, true, :last_seen_at) + """ + ), + { + "lsid": listing_source_id, + "snapshot_date": snapshot_date, + "last_seen_at": snapshot_last_seen_at, + }, + ) + return int(listing_source_id) + + +def _floor(db: Any, *, source: str, quantile: float = 1.0) -> float | None: + result = db.execute( + task_mod._build_revisit_floor_sql("last_seen_at", with_segments=False), + { + "listing_source": source, + "health_window_days": _HEALTH_WINDOW_DAYS, + "revisit_quantile": quantile, + }, + ).scalar() + return float(result) if result is not None else None + + +def _n_pairs(db: Any, *, source: str) -> int: + result = db.execute( + task_mod._build_revisit_floor_pairs_count_sql("last_seen_at", with_segments=False), + {"listing_source": source, "health_window_days": _HEALTH_WINDOW_DAYS}, + ).scalar() + return int(result or 0) + + +def _old_floor(db: Any, *, source: str, quantile: float = 1.0) -> float | None: + result = db.execute( + _OLD_EQUALITY_JOIN_FLOOR_SQL, + { + "listing_source": source, + "health_window_days": _HEALTH_WINDOW_DAYS, + "revisit_quantile": quantile, + }, + ).scalar() + return float(result) if result is not None else None + + +def _old_n_pairs(db: Any, *, source: str) -> int: + result = db.execute( + _OLD_EQUALITY_JOIN_COUNT_SQL, + {"listing_source": source, "health_window_days": _HEALTH_WINDOW_DAYS}, + ).scalar() + return int(result or 0) + + +# ── Живые тесты (реальный Postgres, транзакция + rollback) ──────────────────── + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_lateral_ignores_gaps_and_matches_dense_daily_history() -> None: + """Дырявая (change-only) история даёт ТОТ ЖЕ floor, что дала бы сплошная суточная. + + Между изменениями last_seen_at по определению постоянен -- поэтому у одного и + того же listing_source_id снимок «ровно на дату якоря» и «последний снимок не + позже якоря» несут ОДНО И ТО ЖЕ last_seen_at, если между ними ничего не менялось. + Сначала считаем пол на СПЛОШНОЙ суточной истории (якорная дата присутствует), + затем удаляем все снимки, кроме самого раннего (change-only модель — сутки без + изменений не пишутся), и проверяем, что LATERAL даёт то же число. + """ + db = _live_session() + source = f"zzzlat_dense_{uuid.uuid4().hex[:8]}" + now = datetime.now(UTC) + anchor = date.today() - timedelta(days=_HEALTH_WINDOW_DAYS) + # last_seen_at не меняется все эти сутки -- то самое "между изменениями постоянен". + unchanged_last_seen_at = now - timedelta(days=_HEALTH_WINDOW_DAYS + 3) + try: + db.execute(text("BEGIN")) + lsid = _insert_pair( + db, + source=source, + ext_id="dense", + listing_last_seen_at=now, + snapshots=[ + (anchor - timedelta(days=3), unchanged_last_seen_at), + (anchor - timedelta(days=2), unchanged_last_seen_at), + (anchor - timedelta(days=1), unchanged_last_seen_at), + (anchor, unchanged_last_seen_at), # якорная дата ПРИСУТСТВУЕТ + ], + ) + floor_dense = _floor(db, source=source) + assert floor_dense is not None + assert _n_pairs(db, source=source) == 1 + + # Change-only модель: сутки без изменений не пишутся -- удаляем всё, кроме + # самого раннего снимка (якорная дата больше НЕ присутствует ровно). + db.execute( + text( + "DELETE FROM listing_source_snapshots " + "WHERE listing_source_id = :lsid AND snapshot_date > :keep_date" + ), + {"lsid": lsid, "keep_date": anchor - timedelta(days=3)}, + ) + floor_sparse = _floor(db, source=source) + assert floor_sparse is not None + assert _n_pairs(db, source=source) == 1 + + assert floor_sparse == pytest.approx(floor_dense, abs=0.01), ( + f"LATERAL обязан игнорировать гэп: сплошная история дала {floor_dense}, " + f"дырявая -- {floor_sparse}, а между изменениями last_seen_at постоянен" + ) + # Возраст известен точно (постоянный last_seen_at, N+3 суток разрыва) -- + # пиним не только "совпадают", но и КАКОЕ именно число. + assert floor_dense == pytest.approx(_HEALTH_WINDOW_DAYS + 3, abs=0.01) + finally: + db.rollback() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_equality_join_loses_gapped_pairs_and_gives_a_smaller_floor() -> None: + """На ОДНИХ И ТЕХ ЖЕ данных equality-join теряет пары и даёт МЕНЬШИЙ пол -- это + и есть суть правки PR-A: 3 строки, только у одной снимок ровно на дату якоря. + + Group A -- снимок РОВНО на дату якоря, возраст ~5.5 сут (выживает в ОБЕИХ моделях). + Group B, C -- снимок ТОЛЬКО раньше якоря (change-only гэп ровно на дату якоря), + возраст 65 и 30 сут -- выживают ТОЛЬКО в LATERAL. + + revisit_quantile=1.0 (максимум) -- детерминированно и без интерполяции percentile_disc + на маленькой выборке: LATERAL обязан дать max(5.5, 65, 30) = 65, equality-join -- + только 5.5 (единственная сохранившаяся пара). + """ + db = _live_session() + source = f"zzzlat_gap_{uuid.uuid4().hex[:8]}" + now = datetime.now(UTC) + anchor = date.today() - timedelta(days=_HEALTH_WINDOW_DAYS) + try: + db.execute(text("BEGIN")) + # Group A: снимок ровно на дату якоря -- переживает equality-join. + _insert_pair( + db, + source=source, + ext_id="group-a-on-anchor", + listing_last_seen_at=now, + snapshots=[(anchor, now - timedelta(days=5, hours=12))], # возраст 5.5 сут + ) + # Group B: снимок только на anchor-60 -- дыра ровно на дату якоря + # (типичный change-only разрыв: ничего не менялось 55+ суток подряд). + _insert_pair( + db, + source=source, + ext_id="group-b-gap-60d", + listing_last_seen_at=now, + snapshots=[(anchor - timedelta(days=60), now - timedelta(days=65))], + ) + # Group C: тот же класс гэпа, гэп короче (30 сут) -- контроль, что LATERAL + # берёт максимум по ВСЕЙ выборке, а не просто "последнюю вставленную пару". + _insert_pair( + db, + source=source, + ext_id="group-c-gap-25d", + listing_last_seen_at=now, + snapshots=[(anchor - timedelta(days=25), now - timedelta(days=30))], + ) + + lateral_floor = _floor(db, source=source) + lateral_n_pairs = _n_pairs(db, source=source) + old_floor = _old_floor(db, source=source) + old_n_pairs = _old_n_pairs(db, source=source) + + assert lateral_n_pairs == 3, "LATERAL обязан видеть все 3 пары, гэпы не теряют строк" + assert old_n_pairs == 1, "equality-join теряет Group B и C -- у них нет снимка на якоре" + + assert lateral_floor == pytest.approx(65.0, abs=0.01), ( + f"LATERAL max должен взять Group B (65 сут), получили {lateral_floor}" + ) + assert old_floor == pytest.approx(5.5, abs=0.01), ( + f"equality-join должен остаться только с Group A (5.5 сут), получили {old_floor}" + ) + assert lateral_floor > old_floor, ( + "равенство по дате даёт МЕНЬШИЙ пол на тех же данных -- это и есть регрессия, " + "которую эта правка чинит" + ) + finally: + db.rollback() + db.close() + + +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_missing_history_before_anchor_still_yields_no_floor_via_lateral() -> None: + """Контроль: если у listing_source_id вообще нет снимка не позже якоря (совсем + свежая строка), LATERAL закономерно не находит пару -- НЕ ломается на NULL/пусто, + а просто исключает строку (как и раньше исключал equality-join, если совсем + нет истории). Это НЕ регрессия, а ожидаемое поведение при отсутствии данных.""" + db = _live_session() + source = f"zzzlat_nohist_{uuid.uuid4().hex[:8]}" + now = datetime.now(UTC) + anchor = date.today() - timedelta(days=_HEALTH_WINDOW_DAYS) + try: + db.execute(text("BEGIN")) + _insert_pair( + db, + source=source, + ext_id="only-future-snapshot", + listing_last_seen_at=now, + # Снимок ЕСТЬ, но он ПОЗЖЕ якоря -- LATERAL (snapshot_date <= якорь) + # его не видит, что и требуется: свежая строка без прошлого не должна + # выдумывать пол из снимка, который сам моложе окна здоровья. + snapshots=[(anchor + timedelta(days=1), now - timedelta(days=1))], + ) + assert _floor(db, source=source) is None + assert _n_pairs(db, source=source) == 0 + finally: + db.rollback() + db.close() + + +# ── Pure-SQL проверки (без БД) -- форма LATERAL-запроса и общего среза ──────── + + +def test_lateral_sql_has_no_equality_join_on_snapshot_date() -> None: + """Ключевой негативный инвариант правки: РАВЕНСТВО по дате запрещено.""" + sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=False).text) + assert "JOIN LATERAL" in sql + assert "ORDER BY s.snapshot_date DESC" in sql + assert "LIMIT 1" in sql + assert not re.search(r"prev\.snapshot_date\s*=", sql), ( + "equality-join по snapshot_date должен быть полностью удалён из пола переобхода" + ) + + +def test_lateral_sql_still_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) + + +def test_pairs_count_sql_shares_the_exact_same_slice_as_the_floor_sql() -> None: + """count(*) и percentile_disc обязаны идти по ОДНОМУ И ТОМУ ЖЕ FROM..WHERE -- + иначе floor_n_pairs в counters не описывает реальную выборку percentile_disc.""" + floor_sql = str(task_mod._build_revisit_floor_sql("last_seen_at", with_segments=True).text) + count_sql = str( + task_mod._build_revisit_floor_pairs_count_sql("last_seen_at", with_segments=True).text + ) + floor_from_where = floor_sql.split("FROM listings l", 1)[1] + count_from_where = count_sql.split("FROM listings l", 1)[1] + assert floor_from_where == count_from_where, ( + "FROM..WHERE percentile_disc-запроса и count(*)-запроса разошлись -- " + "floor_n_pairs больше не описывает реальную выборку пола" + ) + + +def test_comparison_predicate_against_prev_last_seen_at_is_untouched() -> None: + """Предикат сравнения (l. > prev.last_seen_at) -- НЕ часть этой правки, + LATERAL меняет только то, КАК ищется prev, а не что с ним сравнивается.""" + 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 diff --git a/tradein-mvp/backend/tests/test_sber_index.py b/tradein-mvp/backend/tests/test_sber_index.py index 61b7f1c7..ae70ad4f 100644 --- a/tradein-mvp/backend/tests/test_sber_index.py +++ b/tradein-mvp/backend/tests/test_sber_index.py @@ -203,8 +203,7 @@ def test_active_dashboards_match_captured_secondary_series() -> None: d = _DASH[slug] encoded = build_sber_route(d.slug, "643", d.extra_filter) assert encoded in captured_b64, ( - f"active dashboard {slug!r} route not in captured-live set — " - f"filter={d.extra_filter}" + f"active dashboard {slug!r} route not in captured-live set — filter={d.extra_filter}" ) @@ -345,15 +344,15 @@ def test_decode_sber_response_real_fixture() -> None: # Verify region label decoded correctly regions = {r["ref_area"] for r in rows} - assert ( - "Свердловская область" in regions - ), f"Expected 'Свердловская область' in ref_area values, got: {regions}" + assert "Свердловская область" in regions, ( + f"Expected 'Свердловская область' in ref_area values, got: {regions}" + ) # Verify secondary-market segment present realty_vals = {r["realty"] for r in rows} - assert any( - "тори" in str(v) or "Вторичн" in str(v) for v in realty_vals - ), f"Expected secondary-market label in realty field, got: {realty_vals}" + assert any("тори" in str(v) or "Вторичн" in str(v) for v in realty_vals), ( + f"Expected secondary-market label in realty field, got: {realty_vals}" + ) # --------------------------------------------------------------------------- @@ -434,9 +433,9 @@ async def test_pull_sber_indices_upsert_on_conflict_idempotent() -> None: # Must NOT contain :: type casts (psycopg v3 rule) import re - assert not re.search( - r":[a-z_]+::[a-z]", upsert_sql - ), "SQL must not contain ::type casts — use CAST(... AS type) instead" + assert not re.search(r":[a-z_]+::[a-z]", upsert_sql), ( + "SQL must not contain ::type casts — use CAST(... AS type) instead" + ) @pytest.mark.asyncio @@ -532,9 +531,9 @@ async def test_pull_sber_indices_asking_benchmark_logged(caplog: pytest.LogCaptu dashboards=[_DASH["dinamika-tsen-obyavlenii"]], ) - assert any( - "benchmark" in record.message for record in caplog.records - ), "Expected a benchmark log line after upserting dinamika-tsen-obyavlenii" - assert any( - "115000" in record.message for record in caplog.records - ), "Benchmark log should include the latest asking index value" + assert any("benchmark" in record.message for record in caplog.records), ( + "Expected a benchmark log line after upserting dinamika-tsen-obyavlenii" + ) + assert any("115000" in record.message for record in caplog.records), ( + "Benchmark log should include the latest asking index value" + ) diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py index 813d78a2..ea7f0b5d 100644 --- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py +++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py @@ -477,9 +477,9 @@ def test_data_quality_pct_in_range(client: TestClient) -> None: for src in body["sources"]: for field_name, pct in src["fields"].items(): - assert ( - 0.0 <= pct <= 100.0 - ), f"source={src['source']} field={field_name} pct={pct} вне [0,100]" + assert 0.0 <= pct <= 100.0, ( + f"source={src['source']} field={field_name} pct={pct} вне [0,100]" + ) # ── Security: текст исключения не утекает в HTTP-ответ (#2234) ──────────────── 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 d2890511..ee7509ab 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py @@ -158,6 +158,78 @@ async def test_resolve_yandex_jk_slug_endpoint_none_without_config(monkeypatch): assert kwargs.get("endpoint") is None +# ── #2860: slug ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ──────────────── +# +# Замер 19.08.2026 через браузерный сайдкар прода: `?siteId=` Яндексом +# больше не применяется — отдаётся общий список новостроек (1.7 МБ, 35 разных +# ЖК), запрошенного id среди них нет. Разметка при этом цела: прежний regex +# находил 128 ссылок нужной формы, просто ни одной с нужным id. Из-за этого +# обход не разрешил НИ ОДНОГО дома с 16.07.2026 (14 прогонов подряд 5/0). + +_JK_PAGE_HTML = ( + "ЖК «Успенский»" + 'эта же страница' + 'ЖК «Успенский»' + "" +) + +# Общий список: ссылок много, с нужным id — ни одной. Ровно то, что прод +# отдавал на `?siteId=`. +_GENERAL_LIST_HTML = ( + 'ШишкINN' + 'Парковый' +) + + +@pytest.mark.asyncio +async def test_resolve_yandex_jk_slug_requests_jk_page_not_serp_filter(monkeypatch): + """Запрашивается страница ЖК по id, а НЕ выдача с ?siteId=. + + Это и есть суть правки: адрес запроса, а не разбор ответа. + """ + spy = _spy_browser_fetcher(_JK_PAGE_HTML) + monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + + await resolve_yandex_jk_slug("286394") + + url = spy.return_value.fetch.call_args[0][0] + assert "siteId=" not in url, f"фильтр выдачи больше не работает, а запрос идёт туда: {url}" + assert url.endswith("/kupit/novostrojka/zhk-286394/"), url + + +@pytest.mark.asyncio +async def test_resolve_yandex_jk_slug_reads_real_slug_from_page(monkeypatch): + """Со страницы ЖК берётся настоящий slug, а не плейсхолдер из нашего же URL.""" + spy = _spy_browser_fetcher(_JK_PAGE_HTML) + monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + + assert await resolve_yandex_jk_slug("286394") == "uspenskij" + + +@pytest.mark.asyncio +async def test_resolve_yandex_jk_slug_returns_none_on_general_list(monkeypatch): + """Отрицательный контроль: ссылки есть, с нашим id — нет → None, а не чужой slug. + + Прод-эквивалент: несуществующий id 999999999 отдаёт общий список. + Без привязки к id функция вернула бы «shishkinn» для чужого дома. + """ + spy = _spy_browser_fetcher(_GENERAL_LIST_HTML) + monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + + assert await resolve_yandex_jk_slug("286394") is None + + +@pytest.mark.asyncio +async def test_resolve_yandex_jk_slug_does_not_return_placeholder(monkeypatch): + """Если на странице только наша же ссылка — это не разрешение, а эхо.""" + spy = _spy_browser_fetcher( + 'сама страница' + ) + monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + + assert await resolve_yandex_jk_slug("286394") is None + + # ── cian.newbuilding.resolve_cian_zhk_url_via_search ───────────────────────── # NOTE (#2397 Part D3): this provider does NOT use BrowserFetcher (unlike # fetch_newbuilding above) — it builds its own curl_cffi session via 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 7fb52207..817783dd 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py @@ -24,6 +24,7 @@ from scraper_kit.providers._base import ( build_curl_cffi_session, build_document_session, http_proxies, + referer_headers, ) @@ -119,6 +120,37 @@ async def test_build_document_session_custom_timeout_and_proxy() -> None: await session.close() +# ── Sec-Fetch-Site (#3034) ─────────────────────────────────────────────────── + + +def test_document_headers_default_sec_fetch_site_is_none() -> None: + """Без Referer на конкретном запросе — "none" остаётся дефолтом session-level + заголовков (запрос без Referer = «адрес введён руками», это ЗАДУМАННОЕ значение + для caller'ов, которые никогда не добавляют Referer, например SERP-фетч).""" + assert DOCUMENT_HEADERS["Sec-Fetch-Site"] == "none" + + +def test_referer_headers_sets_cross_site() -> None: + """referer_headers() — per-request override для caller'ов с Referer с ДРУГОГО + домена (#3034): должен явно менять Sec-Fetch-Site на "cross-site", а не оставлять + его наследоваться от session-level DOCUMENT_HEADERS ("none").""" + headers = referer_headers("https://ya.ru/") + assert headers == {"Referer": "https://ya.ru/", "Sec-Fetch-Site": "cross-site"} + + +async def test_referer_headers_overrides_session_default_when_merged() -> None: + """Прямая проверка сценария #3034: сессия построена с DOCUMENT_HEADERS + (Sec-Fetch-Site="none"), per-request headers=referer_headers(...) должен + победить session-default при мёрже curl_cffi (per-request поверх session-level).""" + session = build_document_session(proxy_url=None) + try: + merged = dict(session.headers) + merged.update({k.lower(): v for k, v in referer_headers("https://ya.ru/").items()}) + assert merged["sec-fetch-site"] == "cross-site" + finally: + await session.close() + + # ── build_browser_fetcher ────────────────────────────────────────────────────── 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 b973e19a..781dd7c9 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py @@ -164,9 +164,9 @@ def test_real_build_product_handlers_covers_all_scheduled_sources() -> None: real_registry = build_registry(build_product_handlers(ctx=None)) # type: ignore[arg-type] for source in _PRODUCT_SOURCES | _KIT_NATIVE_SOURCES: - assert ( - resolve_handler(source, real_registry) is not None - ), f"real build_product_handlers()/build_registry() misses source={source}" + assert resolve_handler(source, real_registry) is not None, ( + f"real build_product_handlers()/build_registry() misses source={source}" + ) def test_kit_native_handler_set() -> None: diff --git a/tradein-mvp/backend/tests/test_segment_guard_1186.py b/tradein-mvp/backend/tests/test_segment_guard_1186.py index 289ff36d..78f7a465 100644 --- a/tradein-mvp/backend/tests/test_segment_guard_1186.py +++ b/tradein-mvp/backend/tests/test_segment_guard_1186.py @@ -40,9 +40,9 @@ def _norm(s: str) -> str: def test_common_where_has_canonical_guard() -> None: """_COMMON_WHERE используется Tier S и Tier H — должен содержать канон-предикат.""" - assert _GUARD_RE.search( - est_mod._COMMON_WHERE - ), "_COMMON_WHERE lacks novostroyki guard — Tier S/H comp set contaminated" + assert _GUARD_RE.search(est_mod._COMMON_WHERE), ( + "_COMMON_WHERE lacks novostroyki guard — Tier S/H comp set contaminated" + ) def test_common_where_no_old_neq_form() -> None: @@ -95,18 +95,18 @@ _REDERIVE_SQL_TEXT = str(ratio_mod._REDERIVE_SQL.text) def test_ask_side_cte_has_guard() -> None: """ask_side CTE в _REDERIVE_SQL (per-rooms asking медиана) — guard обязателен.""" - assert _GUARD_RE.search( - _REDERIVE_SQL_TEXT - ), "ask_side CTE in _REDERIVE_SQL lacks novostroyki guard" + assert _GUARD_RE.search(_REDERIVE_SQL_TEXT), ( + "ask_side CTE in _REDERIVE_SQL lacks novostroyki guard" + ) def test_ask_global_cte_has_guard() -> None: """ask_global CTE в _REDERIVE_SQL (global fallback asking медиана) — guard обязателен.""" # _REDERIVE_SQL содержит два `ask_global`-блока; ищем оба через count. matches = len(_GUARD_RE.findall(_REDERIVE_SQL_TEXT)) - assert ( - matches >= 2 - ), f"Expected ≥2 guard occurrences in _REDERIVE_SQL (ask_side + ask_global), got {matches}" + assert matches >= 2, ( + f"Expected ≥2 guard occurrences in _REDERIVE_SQL (ask_side + ask_global), got {matches}" + ) # ── Поведенческие тесты: _fetch_analogs (mock DB) ──────────────────────────── @@ -164,9 +164,9 @@ def test_fetch_analogs_sql_guard_present_novostroyki_excluded() -> None: """ src = inspect.getsource(est_mod._fetch_analogs) # Guard должен присутствовать хотя бы один раз в теле функции. - assert _GUARD_RE.search( - src - ), "_fetch_analogs SQL no longer contains novostroyki guard — guard was removed!" + assert _GUARD_RE.search(src), ( + "_fetch_analogs SQL no longer contains novostroyki guard — guard was removed!" + ) def test_null_segment_listing_not_excluded_by_guard() -> None: @@ -185,9 +185,9 @@ def test_null_segment_listing_not_excluded_by_guard() -> None: ) # NULL-segment listing должен присутствовать в результате (не отброшен Python-стороной). - assert any( - r.get("source") == "avito" for r in result - ), "NULL-segment listing was unexpectedly excluded from comp set" + assert any(r.get("source") == "avito" for r in result), ( + "NULL-segment listing was unexpectedly excluded from comp set" + ) def test_vtorichka_segment_listing_included() -> None: @@ -234,12 +234,12 @@ def test_no_neq_novostroyki_form_in_estimator() -> None: Канон: IS NULL OR = 'vtorichka'. """ src = _estimator_full_src() - assert ( - "<> 'novostroyki'" not in src - ), "estimator.py contains deprecated `<> 'novostroyki'` form — use canonical guard" - assert ( - "!= 'novostroyki'" not in src - ), "estimator.py contains deprecated `!= 'novostroyki'` form — use canonical guard" + assert "<> 'novostroyki'" not in src, ( + "estimator.py contains deprecated `<> 'novostroyki'` form — use canonical guard" + ) + assert "!= 'novostroyki'" not in src, ( + "estimator.py contains deprecated `!= 'novostroyki'` form — use canonical guard" + ) def test_anchor_comps_no_dead_listing_segment_param() -> None: @@ -269,6 +269,6 @@ def test_fetch_anchor_comps_tier_c_canonical_guard_not_parametric() -> None: "must be hardcoded canonical guard" ) # И канон-guard на месте (Tier C-блок). - assert _GUARD_RE.search( - src - ), "_fetch_anchor_comps lacks canonical guard after removing parametric form" + assert _GUARD_RE.search(src), ( + "_fetch_anchor_comps lacks canonical guard after removing parametric form" + ) 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 a779c98d..6cde5c51 100644 --- a/tradein-mvp/backend/tests/test_snapshot_writer.py +++ b/tradein-mvp/backend/tests/test_snapshot_writer.py @@ -303,9 +303,9 @@ def test_save_detail_enrichment_oph_on_conflict_constraint(): sqls = _get_all_sqls(db) oph_sqls = [s for s in sqls if "offer_price_history" in s] assert oph_sqls, "INSERT offer_price_history не найден" - assert ( - "offer_price_history_listing_change_uq" in oph_sqls[0] - ), "ON CONFLICT должен ссылаться на UNIQUE constraint" + assert "offer_price_history_listing_change_uq" in oph_sqls[0], ( + "ON CONFLICT должен ссылаться на UNIQUE constraint" + ) def test_save_detail_enrichment_skips_price_change_without_change_time(): diff --git a/tradein-mvp/backend/tests/test_street_deals_endpoint.py b/tradein-mvp/backend/tests/test_street_deals_endpoint.py index 003883e1..570711db 100644 --- a/tradein-mvp/backend/tests/test_street_deals_endpoint.py +++ b/tradein-mvp/backend/tests/test_street_deals_endpoint.py @@ -317,15 +317,15 @@ def test_street_regex_word_boundary_no_false_positive() -> None: # Must NOT match — 'мира' appears inside 'Макарова' substring check skipped # but more critically — 'мира' appears inside 'Адмирала Макарова' false_positive_addr = "улица Адмирала Макарова" - assert not re.search( - pattern, false_positive_addr, re.IGNORECASE - ), f"Pattern {pattern!r} should NOT match {false_positive_addr!r}" + assert not re.search(pattern, false_positive_addr, re.IGNORECASE), ( + f"Pattern {pattern!r} should NOT match {false_positive_addr!r}" + ) # Must match — exact word true_positive_addr = "улица Мира 5" - assert re.search( - pattern, true_positive_addr, re.IGNORECASE - ), f"Pattern {pattern!r} should match {true_positive_addr!r}" + assert re.search(pattern, true_positive_addr, re.IGNORECASE), ( + f"Pattern {pattern!r} should match {true_positive_addr!r}" + ) def test_street_regex_param_passed_to_db(trade_in_app: FastAPI) -> None: @@ -355,6 +355,6 @@ def test_street_regex_param_passed_to_db(trade_in_app: FastAPI) -> None: assert "street_regex" in params, f"street_regex not in params: {params}" regex_val = params["street_regex"] # Must contain word-boundary anchors - assert ( - r"\m" in regex_val or r"\b" in regex_val or regex_val.startswith(r"\m") - ), f"Expected word-boundary in regex, got: {regex_val!r}" + assert r"\m" in regex_val or r"\b" in regex_val or regex_val.startswith(r"\m"), ( + f"Expected word-boundary in regex, got: {regex_val!r}" + ) diff --git a/tradein-mvp/backend/tests/test_yandex_city_sweep.py b/tradein-mvp/backend/tests/test_yandex_city_sweep.py index b50729a9..fb3f895a 100644 --- a/tradein-mvp/backend/tests/test_yandex_city_sweep.py +++ b/tradein-mvp/backend/tests/test_yandex_city_sweep.py @@ -219,6 +219,6 @@ def test_combos_sweep_timeout_substantially_larger_than_anchor_timeout() -> None f"combos-mode will still timeout mid-sweep" ) # И намного > ANCHOR_TIMEOUT_SEC (240s) - assert ( - sweep_timeout > ANCHOR_TIMEOUT_SEC * 4 - ), f"sweep_timeout={sweep_timeout:.0f}s should be >> ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s" + assert sweep_timeout > ANCHOR_TIMEOUT_SEC * 4, ( + f"sweep_timeout={sweep_timeout:.0f}s should be >> ANCHOR_TIMEOUT_SEC={ANCHOR_TIMEOUT_SEC}s" + ) diff --git a/tradein-mvp/backend/tests/test_yandex_valuation.py b/tradein-mvp/backend/tests/test_yandex_valuation.py index c6196e84..638c5042 100644 --- a/tradein-mvp/backend/tests/test_yandex_valuation.py +++ b/tradein-mvp/backend/tests/test_yandex_valuation.py @@ -416,9 +416,9 @@ def test_area_regex_rejects_year_concat(): item = YandexValuationScraper._parse_item_text(text) # The "202452,2" token has digits jammed before it (no separator), so the # tightened regex must NOT match it. - assert ( - item is None or item.area_m2 is None - ), f"expected no area match for concat token, got {item.area_m2 if item else 'None item'}" + assert item is None or item.area_m2 is None, ( + f"expected no area match for concat token, got {item.area_m2 if item else 'None item'}" + ) def test_area_regex_accepts_isolated_token(): @@ -474,9 +474,9 @@ def test_area_regex_two_komn_chunk(): text = "8 000 000 ₽ за м²2-комнатная 52,2 м² 5 этаж 10.05.2024 8 000 000 ₽ В продаже" item = YandexValuationScraper._parse_item_text(text) assert item is not None - assert ( - item.area_m2 == 52.2 - ), f"2-комн area must parse despite preceding tokens, got {item.area_m2}" + assert item.area_m2 == 52.2, ( + f"2-комн area must parse despite preceding tokens, got {item.area_m2}" + ) def test_area_regex_still_blocks_year_concat(): diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index ed630a90..3d6f47a9 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -36,6 +36,16 @@ Per-provider модель (#1793): BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска (default: 15) BROWSER_NAV_TIMEOUT_MS — таймаут page.goto в мс (default: 60000) BROWSER_WAIT_MS — ожидание гидрации listings после DOMContentLoaded, мс (default: 6000) + BROWSER_CHALLENGE_WAIT_MS — бюджет ожидания QRATOR PoW-челленджа Авито (#3045), + мс (default: 30000). Челлендж-страница сама считает + proof-of-work в JS, ставит куку pow_solved и через + setTimeout(3000) делает window.location = location.href + (self-reload, URL не меняется). /fetch опрашивает + page.content() пока маркеры челленджа не исчезнут; по + истечении бюджета — ChallengeTimeoutError вместо тихой + отдачи заглушки. Действует ТОЛЬКО при обнаружении + маркеров челленджа в разметке — прочие провайдеры этот + путь никогда не задевают. BROWSER_BLOCK_RESOURCE_TYPES — CSV типов ресурсов Playwright, которые abort'ить через page.route при навигации (default: "font,media"). Снижает число одновременных под-коннектов на страницу → @@ -113,6 +123,17 @@ BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "6000 # выдача (~50 карточек, 3.2МБ). Подтверждено прод-дебагом 2026-05-31. BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000")) +# 30000: цепочка PoW-челленджа Авито — startPow() в JS, затем setTimeout(3000) на +# self-reload, затем повторная гидрация страницы. 3с таймера самой площадки — это +# ТОЛЬКО задержка перед reload, не бюджет на сам расчёт PoW: под headless-браузером +# и egress-прокси решение может занять заметно дольше, чем в обычном браузере +# пользователя. Живой замер 2026-08-21 (#3045): без ожидания челленджа 4 из 6 +# карточек с органической навигацией отдавали 7891-байтную челлендж-страницу вместо +# контента — не бан (403/429 не было), просто уходили раньше, чем страница себя +# перезагрузила. 30с — запас с кратным резервом на решение + reload + догидрацию, +# не превращающий единичный фетч в минуту ожидания при реальном бане/сетевой пробе. +BROWSER_CHALLENGE_WAIT_MS: int = int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000")) + # /fetch-json settle после goto(origin) перед in-page fetch (#1917). 500мс мало: # первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/ # сетевой стек страницы ещё не готов). Лечился внешним retry (re-navigation ~30-45с/дом). @@ -898,6 +919,138 @@ async def _do_fetch( raise +# ── QRATOR PoW-челлендж Авито (#3045) ──────────────────────────────────────────── +# Маркеры сняты живьём с challenge-страницы Авито 2026-08-21 (замер: 6 карточек, +# органическая навигация из выдачи, 4/6 ушли с челленджа раньше времени). Разметка +# площадки может поменяться со временем — при протухании маркеров переснять их +# заново вживую, а не гадать по памяти. Форма провайдер-агностична: детектор просто +# ищет строки в HTML, другие площадки (cian/yandex/generic) их никогда не отдают, +# поэтому ветка ожидания для них не включается. + +# Признак самого челленджа: JS-функция startPow(), которую страница вызывает в +# DOMContentLoaded (см. хвост challenge-скрипта в #3045), либо заголовок блока +# «Доступ ограничен: проверка безопасности» — оба встречались на снятых страницах. +_CHALLENGE_MARKERS: tuple[str, ...] = ( + "startpow", + "доступ ограничен: проверка безопасности", +) + +# Признак БАН-страницы (не челлендж): «Доступ ограничен: проблема с IP» — статика +# без PoW-скрипта, приходит с 403/429 и заметно меньше challenge-страницы весом. +# Ждать тут бессмысленно — адрес заблокирован, а не временно проверяется. +_BAN_MARKERS: tuple[str, ...] = ("доступ ограничен: проблема с ip",) + + +class ChallengeTimeoutError(Exception): + """PoW-челлендж не снялся за BROWSER_CHALLENGE_WAIT_MS. + + Caller должен трактовать как временный отказ (retry/backoff), НЕ как валидный + контент — раньше caller получал 7891-байтную challenge-страницу и парсер либо + падал на ней, либо молча ничего не находил (#3045). + """ + + +class BanPageDetectedError(Exception): + """Площадка отдала бан-страницу («проблема с IP») вместо контента/челленджа. + + В отличие от ChallengeTimeoutError ждать здесь бессмысленно: адрес забанен, а + не проходит временную проверку — поднимается сразу, без траты + BROWSER_CHALLENGE_WAIT_MS. + """ + + +def _is_pow_challenge(html: str) -> bool: + """True, если HTML — QRATOR PoW-челлендж Авито (см. _CHALLENGE_MARKERS).""" + lower = html.lower() + return any(marker in lower for marker in _CHALLENGE_MARKERS) + + +def _is_ban_page(html: str) -> bool: + """True, если HTML — бан-страница «проблема с IP» (см. _BAN_MARKERS).""" + lower = html.lower() + return any(marker in lower for marker in _BAN_MARKERS) + + +# Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по +# тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит +# уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для +# этого случая. +_NAVIGATION_RACE_MARKERS: tuple[str, ...] = ( + "execution context was destroyed", + "most likely because of a navigation", + "page is navigating", +) + + +async def _content_during_navigation(page: object) -> str | None: + """`page.content()`, устойчивый к перезагрузке страницы под руками. + + PoW-челлендж перезагружает себя сам (`window.location = location.href`), и + вызов content(), попавший ровно в этот момент, кидает «Execution context was + destroyed». Для нас это НЕ ошибка, а признак того, что перезагрузка — та + самая, которую мы ждём, — идёт прямо сейчас. Возвращаем None = «ещё не + устоялось, опроси снова», а не роняем фетч на самом успешном исходе. + + Всё остальное (закрытая страница, упавший браузер) пробрасываем как есть. + """ + try: + return await page.content() # type: ignore[attr-defined] + except Exception as exc: # noqa: BLE001 — тип не импортируем, различаем по тексту + text = str(exc).lower() + if any(marker in text for marker in _NAVIGATION_RACE_MARKERS): + return None + raise + + +async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str: + """Опрашивает page.content() пока не исчезнут маркеры PoW-челленджа. + + Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после + решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится; + опрашиваем контент с шагом ~1с вместо этого. По истечении + BROWSER_CHALLENGE_WAIT_MS — ChallengeTimeoutError, а не тихая отдача + challenge-страницы как будто это валидный контент. + + После снятия челленджа даём странице догидрироваться тем же BROWSER_WAIT_MS, + каким ждём обычную навигацию (второй таймаут не изобретаем). + """ + poll_interval_ms = 1000 + elapsed_ms = 0 + html: str | None = await _content_during_navigation(page) + while (html is None or _is_pow_challenge(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS: + await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined] + elapsed_ms += poll_interval_ms + html = await _content_during_navigation(page) + + if html is None or _is_pow_challenge(html): + raise ChallengeTimeoutError( + f"tradein-browser[{provider}]: PoW-челлендж не снялся за " + f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}" + ) + + logger.info( + "tradein-browser[%s]: PoW-челлендж снят за ~%dмс, догидрация url=%r", + provider, + elapsed_ms, + url, + ) + if BROWSER_WAIT_MS > 0: + await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined] + settled = await _content_during_navigation(page) + if settled is None: + # Догидрация совпала с ещё одной навигацией — даём один короткий добор + # вместо того, чтобы падать: контент уже не challenge, гонка чисто + # техническая. + await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined] + settled = await _content_during_navigation(page) + if settled is None: + raise ChallengeTimeoutError( + f"tradein-browser[{provider}]: челлендж снят, но страница не устоялась " + f"(навигация не прекращается) url={url!r}" + ) + return settled + + async def _fetch_once( provider: str, url: str, @@ -950,6 +1103,17 @@ async def _fetch_once( if BROWSER_WAIT_MS > 0: await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined] html: str = await page.content() # type: ignore[attr-defined] + + # Бан-страница («проблема с IP») распознаётся и падает СРАЗУ, без траты + # BROWSER_CHALLENGE_WAIT_MS — ждать там нечего, адрес заблокирован (#3045). + if _is_ban_page(html): + raise BanPageDetectedError( + f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}" + ) + # PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его + # прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент. + if _is_pow_challenge(html): + html = await _wait_out_pow_challenge(page, provider, url) finally: await page.close() # type: ignore[attr-defined] diff --git a/tradein-mvp/browser/test_server_pow_challenge.py b/tradein-mvp/browser/test_server_pow_challenge.py new file mode 100644 index 00000000..863b7a55 --- /dev/null +++ b/tradein-mvp/browser/test_server_pow_challenge.py @@ -0,0 +1,273 @@ +"""test_server_pow_challenge.py — QRATOR PoW-челлендж Авито в /fetch (#3045). + +Проблема, установленная замером на проде 2026-08-21: Авито за QRATOR отдаёт +proof-of-work челлендж (JS считает PoW, ставит куку pow_solved, через +setTimeout(3000) делает `window.location = location.href` — self-reload, URL не +меняется). Фиксированной паузы BROWSER_WAIT_MS (6с) на цепочку +«PoW → таймер 3с → перезагрузка → гидрация» хватало не всегда: живой замер дал +2 успеха из 6 карточек, 4 отказа — все «проверка безопасности», НЕ бан по IP. + +Тестируется _fetch_once целиком (не только вынесенный polling-хелпер), чтобы +проверить весь путь: goto → content() → детект → (ожидание | ошибка) → return. + +camoufox НЕ запускается: _browsers[provider] — поддельный browser/page, +возвращающие заранее заданную последовательность content(). wait_for_timeout +на фейковой page — no-op (без реального asyncio.sleep), поэтому тесты идут +мгновенно независимо от величины BROWSER_CHALLENGE_WAIT_MS/BROWSER_WAIT_MS. + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_pow_challenge.py -q +""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path +from typing import Any + +import pytest + +# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути. +_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) + + +@pytest.fixture(autouse=True) +def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: + """Чистое per-provider состояние на каждый тест (зеркалит test_server_smoke.py).""" + 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, "_locks_guard", asyncio.Lock()) + + +_CHALLENGE_HTML = ( + "" +) +_BAN_HTML = "Доступ ограничен: проблема с IP" +_REAL_HTML = "listing card content" + + +class _ChallengePage: + """Поддельная page: отдаёт заданную последовательность content() по вызовам. + + После исчерпания списка повторяет последний элемент (имитирует «страница + осталась в этом состоянии»). Фиксирует goto/wait_for_timeout-вызовы для + проверки, что бюджет ожидания не тратится там, где не должен. + + Элемент последовательности может быть исключением — тогда content() его + поднимает. Это нужно, чтобы воспроизвести гонку с self-reload челленджа: + playwright кидает «Execution context was destroyed» ровно в момент той + перезагрузки, которую мы ждём, и на моках без этого дефект не виден. + """ + + def __init__(self, html_sequence: list[str | Exception]) -> None: + self._html_sequence = html_sequence + self._call_count = 0 + self.goto_urls: list[str] = [] + self.wait_for_timeout_calls: list[int] = [] + self.closed = 0 + + async def route(self, pattern: str, handler: Any) -> None: + return None + + async def goto(self, url: str, **kwargs: Any) -> None: + self.goto_urls.append(url) + + async def wait_for_timeout(self, ms: int) -> None: + self.wait_for_timeout_calls.append(ms) + + async def content(self) -> str: + idx = min(self._call_count, len(self._html_sequence) - 1) + item = self._html_sequence[idx] + self._call_count += 1 + if isinstance(item, Exception): + raise item + return item + + async def close(self) -> None: + self.closed += 1 + + +class _ChallengeBrowser: + def __init__(self, page: _ChallengePage) -> None: + self._page = page + + async def new_page(self) -> _ChallengePage: + return self._page + + +def _install( + monkeypatch: pytest.MonkeyPatch, page: _ChallengePage, provider: str = "avito" +) -> None: + server._browsers[provider] = _ChallengeBrowser(page) + monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000) + + +# ── детекторы: чистые функции ──────────────────────────────────────────────────── + + +def test_is_pow_challenge_true_on_start_pow_marker() -> None: + assert server._is_pow_challenge(_CHALLENGE_HTML) is True + + +def test_is_pow_challenge_true_on_title_marker() -> None: + html = "Доступ ограничен: проверка безопасности" + assert server._is_pow_challenge(html) is True + + +def test_is_pow_challenge_false_on_ban_page() -> None: + """Бан-страница НЕ должна ложно матчиться как челлендж — разные ветки.""" + assert server._is_pow_challenge(_BAN_HTML) is False + + +def test_is_pow_challenge_false_on_real_content() -> None: + assert server._is_pow_challenge(_REAL_HTML) is False + + +def test_is_ban_page_true_on_ip_problem_marker() -> None: + assert server._is_ban_page(_BAN_HTML) is True + + +def test_is_ban_page_false_on_challenge_page() -> None: + assert server._is_ban_page(_CHALLENGE_HTML) is False + + +def test_is_ban_page_false_on_real_content() -> None: + assert server._is_ban_page(_REAL_HTML) is False + + +def test_challenge_wait_budget_defaults_to_30s(monkeypatch: pytest.MonkeyPatch) -> None: + """Дефолт BROWSER_CHALLENGE_WAIT_MS=30000 без переопределения env.""" + monkeypatch.delenv("BROWSER_CHALLENGE_WAIT_MS", raising=False) + import os + + assert int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000")) == 30000 + + +# ── _fetch_once: сценарии ──────────────────────────────────────────────────────── + + +def test_fetch_once_waits_out_challenge_then_returns_real_content( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Челлендж на первых двух опросах, третий — уже реальный контент → он и вернётся.""" + page = _ChallengePage([_CHALLENGE_HTML, _CHALLENGE_HTML, _REAL_HTML]) + _install(monkeypatch, page) + + html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) + + assert html == _REAL_HTML + assert page.closed == 1 + # Обычная гидро-пауза + минимум одна пауза опроса челленджа + финальная + # догидрация тем же BROWSER_WAIT_MS (второй таймаут не изобретаем). + assert server.BROWSER_WAIT_MS in page.wait_for_timeout_calls + assert 1000 in page.wait_for_timeout_calls # шаг опроса + + +def test_fetch_once_raises_challenge_timeout_when_budget_exhausted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Челлендж не снимается никогда → ChallengeTimeoutError, а не заглушка-контент.""" + monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 2000) + page = _ChallengePage([_CHALLENGE_HTML]) # всегда челлендж (последний элемент повторяется) + _install(monkeypatch, page) + + with pytest.raises(server.ChallengeTimeoutError): + asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) + + # finally всё равно закрывает страницу, несмотря на исключение. + assert page.closed == 1 + + +def test_fetch_once_raises_ban_error_immediately_without_spending_budget( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Бан-страница («проблема с IP») → своя ошибка сразу, БЕЗ цикла опроса.""" + page = _ChallengePage([_BAN_HTML]) + _install(monkeypatch, page) + + with pytest.raises(server.BanPageDetectedError): + asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) + + assert page.closed == 1 + # Единственный wait_for_timeout — обычная гидро-пауза ДО детекта; опроса + # челленджа (доп. паузы по 1000мс) не было — бюджет не потрачен. + assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS] + + +def test_fetch_once_normal_page_without_markers_unaffected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Страница без маркеров челленджа/бана → поведение как раньше, без polling-ветки.""" + page = _ChallengePage([_REAL_HTML]) + _install(monkeypatch, page) + + html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) + + assert html == _REAL_HTML + assert page.closed == 1 + assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS] + assert page.goto_urls == ["https://www.avito.ru/card/1"] + + +# ── гонка с self-reload челленджа (#3045, найдено при ревью ветки) ────────────── +# +# Челлендж перезагружает страницу САМ. Вызов page.content(), попавший ровно в этот +# момент, кидает «Execution context was destroyed» — то есть цикл ожидания падал бы +# именно на успешном исходе, ради которого написан. На моках без явной имитации +# это не воспроизводится, поэтому тесты ниже поднимают исключение из content(). + +_NAV_RACE = RuntimeError( + "Execution context was destroyed, most likely because of a navigation." +) + + +def test_navigation_race_during_reload_is_not_a_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """content() упал на перезагрузке → опрашиваем дальше, отдаём настоящий HTML.""" + page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE, _REAL_HTML]) + _install(monkeypatch, page) + + html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) + + assert html == _REAL_HTML + + +def test_permanent_navigation_race_raises_challenge_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Навигация не прекращается → своя ошибка, а не сырое исключение playwright.""" + page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE]) + _install(monkeypatch, page) + + with pytest.raises(server.ChallengeTimeoutError): + asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) + + +def test_unrelated_content_error_still_propagates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Глушим ТОЛЬКО гонку навигации; упавший браузер должен всплыть как есть.""" + boom = RuntimeError("Target page, context or browser has been closed") + page = _ChallengePage([_CHALLENGE_HTML, boom]) + _install(monkeypatch, page) + + with pytest.raises(RuntimeError, match="has been closed"): + asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) diff --git a/tradein-mvp/deploy/backup-tradein-db.sh b/tradein-mvp/deploy/backup-tradein-db.sh index 1829de51..81fb3fab 100755 --- a/tradein-mvp/deploy/backup-tradein-db.sh +++ b/tradein-mvp/deploy/backup-tradein-db.sh @@ -1,42 +1,199 @@ #!/usr/bin/env bash # Бэкап БД tradein-postgres — pg_dump по cron + retention (#397). +# Hardening для #2203: globals, integrity-check, S3-выгрузка. # # Запускается из cron на прод-хосте. pg_dump идёт через `docker exec` # (локальный сокет внутри контейнера — пароль не нужен). # -# Crontab пример (ежедневно в 04:30 UTC): -# 30 4 * * * /opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh >> /tmp/tradein-backup.log 2>&1 +# Crontab (ежедневно в 04:30 по времени хоста = 01:30 UTC; на проде именно так, #3005). +# Вызывать через `bash `, а не сырым путём — тогда потеря бита +x не сломает +# бэкап молча (инцидент #71 у основного бэкапа): +# 30 4 * * * bash /opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh >> /tmp/tradein-backup.log 2>&1 # # Restore: # gunzip -c tradein-YYYYMMDD-HHMMSS.sql.gz | \ # docker exec -i tradein-postgres psql -U tradein -d tradein +# Restore-дрель (безопасно, не трогает прод): ops/restore-drill.sh. +# +# Выгрузка в S3 ОБЯЗАТЕЛЬНА по умолчанию (#3085) — переменные в +# /etc/default/tradein-backup (root-owned, chmod 600, НЕ в git); если файла +# нет — фолбэк на /etc/default/gendesign-backup (общие креды с основным +# бэкапом). Без всех четырёх переменных скрипт падает ДО снятия дампа — +# BACKUP_ALLOW_LOCAL_ONLY=1 явно разрешает прежнее локальное поведение. +# S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru +# S3_BUCKET=gendsgn-backups +# S3_ACCESS_KEY=... +# S3_SECRET_KEY=... +# +# Детект пропущенного запуска (#2203): по успеху пишется sentinel-файл +# (SENTINEL_FILE) — отдельный cron-запуск ops/check-backup-staleness.sh +# следит за его возрастом и шлёт алерт, если он не свежел (см. заголовок +# того скрипта — канал алертов и cron-строка). Сам этот скрипт не алертит, +# только отмечает успех. set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../../ops/lib-backup.sh +source "$SCRIPT_DIR/../../ops/lib-backup.sh" + BACKUP_DIR="${BACKUP_DIR:-/opt/gendesign/backups/tradein}" KEEP="${KEEP:-7}" # сколько копий хранить PG_CONTAINER="${PG_CONTAINER:-tradein-postgres}" +MIN_DUMP_BYTES="${MIN_DUMP_BYTES:-10240}" # 10 KiB floor — пустая/битая + # схема заведомо меньше, живая + # БД — на порядки больше. +SENTINEL_FILE="${SENTINEL_FILE:-${BACKUP_DIR}/.last_success}" + +# S3-переменные: свой env-файл, а если его нет — общий с main-бэкапом. +if [[ -f /etc/default/tradein-backup ]]; then + source /etc/default/tradein-backup +elif [[ -f /etc/default/gendesign-backup ]]; then + source /etc/default/gendesign-backup +fi + +# --- guard: S3-выгрузка обязательна по умолчанию (#3085). Без неё дамп живёт +# только на том же диске, что и БД, — не бэкап, если хост потерян. До +# этой проверки отсутствие/неполнота env-файла давали в логе «дамп +# остаётся только локально» и код возврата 0, неотличимо от настоящего +# off-box бэкапа. Падаем ГРОМКО, до снятия дампа — незачем тратить время +# и место на дамп, которому всё равно некуда уехать. BACKUP_ALLOW_LOCAL_ONLY=1 — +# явная форточка (та же переменная, что у ops/backup.sh) для намеренного +# локального прогона; всё равно шумит предупреждением, чтобы это не +# забылось молча. --- +if [[ -z "${S3_ENDPOINT:-}" || -z "${S3_BUCKET:-}" || -z "${S3_ACCESS_KEY:-}" || -z "${S3_SECRET_KEY:-}" ]]; then + if [[ "${BACKUP_ALLOW_LOCAL_ONLY:-0}" == "1" ]]; then + log "ПРЕДУПРЕЖДЕНИЕ: S3-переменные заданы не полностью — BACKUP_ALLOW_LOCAL_ONLY=1, продолжаю с ЛОКАЛЬНЫМ бэкапом. Эта копия НЕ переживёт потерю этой VM." >&2 + BACKUP_LOCAL_ONLY_USED=1 + else + log "ОШИБКА: выгрузка в S3 не настроена — не хватает одной или нескольких из S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY." >&2 + log "ОШИБКА: заполни их в /etc/default/tradein-backup (или /etc/default/gendesign-backup) —" >&2 + log "ОШИБКА: см. ops/gendesign-backup.default.example, — либо выстави BACKUP_ALLOW_LOCAL_ONLY=1, чтобы явно принять локальный бэкап." >&2 + exit 1 + fi +fi + +# Проверка целостности сверх «не пустой»: битый посреди записи дамп (диск +# кончился, OOM-kill, оборвался docker exec) может дать структурно валидный, +# не крошечный .gz — ни `-s`, ни MIN_DUMP_BYTES это не ловят. Две проверки, +# сначала дешёвая: +# 1. gzip -t — ловит битый/оборванный gzip-поток как таковой. +# 2. trailer — pg_dump/pg_dumpall всегда пишут фиксированный комментарий +# «я дописал до конца» последней строкой потока; при обрыве записи +# трейлера не будет, даже если gzip-обёртка выглядит нормально. +# Проверяется через `gunzip -c | tail -N`, чтобы не распаковывать весь дамп +# на диск ради последних строк. +verify_dump_integrity() { + local file="$1" trailer="$2" label="$3" + if ! gzip -t "$file" 2>/dev/null; then + log "ОШИБКА: ${label} не прошёл проверку gzip -> $file" >&2 + return 1 + fi + if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF -- "$trailer"; then + log "ОШИБКА: ${label} без трейлера '${trailer}' -> $file (дамп оборван?)" >&2 + return 1 + fi + return 0 +} mkdir -p "$BACKUP_DIR" ts=$(date -u +'%Y%m%d-%H%M%S') out="$BACKUP_DIR/tradein-$ts.sql.gz" +globals_out="$BACKUP_DIR/tradein-globals-$ts.sql.gz" # --clean --if-exists → дамп самодостаточен для восстановления «с нуля». # --no-owner → restore не требует тех же ролей. +# Stderr больше не глотается (было `2>/dev/null` до #2203) — ошибка pg_dump +# должна попасть в лог, а не исчезнуть молча. `set -o pipefail` уже роняет +# весь пайп при ошибке pg_dump, так что поведение при сбое не меняется — +# меняется только видимость причины. docker exec "$PG_CONTAINER" pg_dump -U tradein -d tradein \ - --no-owner --clean --if-exists 2>/dev/null | gzip -6 > "$out" + --no-owner --clean --if-exists | gzip -6 > "$out" if [[ ! -s "$out" ]]; then - echo "[$(date -u +%H:%M:%S)] ОШИБКА: дамп пустой — $out" >&2 + log "ОШИБКА: дамп пустой — $out" >&2 rm -f "$out" exit 1 fi -# Retention — оставляем KEEP самых свежих, остальные tradein-*.sql.gz удаляем. -ls -1t "$BACKUP_DIR"/tradein-*.sql.gz 2>/dev/null \ +dump_bytes=$(wc -c < "$out" | tr -d ' ') +if (( dump_bytes < MIN_DUMP_BYTES )); then + log "ОШИБКА: дамп всего ${dump_bytes} байт (< ${MIN_DUMP_BYTES} floor) — похоже на неудачный дамп." >&2 + log "Удаляю подозрительный дамп, старые хорошие копии не трогаю." >&2 + rm -f "$out" + exit 1 +fi + +if ! verify_dump_integrity "$out" "-- PostgreSQL database dump complete" "основной дамп"; then + log "Удаляю подозрительный дамп, старые хорошие копии не трогаю." >&2 + rm -f "$out" + exit 1 +fi + +log "Дамп ok: $out ($(du -h "$out" | cut -f1), ${dump_bytes} байт)" + +# --- globals (роли/GRANT) — pg_dump их не включает по определению; без +# этого восстановленная база остаётся без владеющих ролей/привилегий. --- +log "Дамплю globals (роли/GRANT) -> $globals_out" +docker exec "$PG_CONTAINER" pg_dumpall -U tradein --globals-only \ + | gzip -6 > "$globals_out" + +if [[ ! -s "$globals_out" ]]; then + log "ОШИБКА: дамп globals пустой — $globals_out" >&2 + rm -f "$globals_out" + exit 1 +fi + +if ! verify_dump_integrity "$globals_out" "-- PostgreSQL database cluster dump complete" "globals-дамп"; then + rm -f "$globals_out" + exit 1 +fi + +log "Globals ok: $globals_out ($(du -h "$globals_out" | cut -f1))" + +# --- опциональная выгрузка в S3 (только если заданы все четыре переменные) --- +if [[ -n "${S3_ENDPOINT:-}" && -n "${S3_BUCKET:-}" && -n "${S3_ACCESS_KEY:-}" && -n "${S3_SECRET_KEY:-}" ]]; then + for f in "$out" "$globals_out"; do + log "Заливаю в s3://${S3_BUCKET}/$(basename "$f")" + # У aws-cli v2 свой CA-бандл (вшит в botocore), а не системное хранилище — + # в нём нет корня, которым подписан сертификат Selectel. Без переменной + # ниже выгрузка падает на CERTIFICATE_VERIFY_FAILED; системное хранилище + # контейнера этот корень содержит. + docker run --rm \ + -e AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \ + -e AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \ + -e AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \ + -v "$BACKUP_DIR":/backup:ro \ + amazon/aws-cli:latest \ + --endpoint-url "$S3_ENDPOINT" \ + s3 cp --no-progress "/backup/$(basename "$f")" "s3://${S3_BUCKET}/" + done + log "Выгрузка в S3 ok" +else + log "S3-переменные не заданы — дамп остаётся только локально" +fi + +# Retention — оставляем KEEP самых свежих, остальные удаляем. Основные дампы +# и globals-дампы — раздельные серии (иначе они смешаются в общей mtime- +# сортировке и retention посчитает их вместе). +ls -1t "$BACKUP_DIR"/tradein-[0-9]*.sql.gz 2>/dev/null \ + | tail -n +"$((KEEP + 1))" \ + | xargs -r rm -f +ls -1t "$BACKUP_DIR"/tradein-globals-*.sql.gz 2>/dev/null \ | tail -n +"$((KEEP + 1))" \ | xargs -r rm -f size=$(du -h "$out" | cut -f1) -count=$(ls -1 "$BACKUP_DIR"/tradein-*.sql.gz 2>/dev/null | wc -l | tr -d ' ') -echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] backup ok: $out ($size), копий хранится: $count" +count=$(ls -1 "$BACKUP_DIR"/tradein-[0-9]*.sql.gz 2>/dev/null | wc -l | tr -d ' ') +globals_count=$(ls -1 "$BACKUP_DIR"/tradein-globals-*.sql.gz 2>/dev/null | wc -l | tr -d ' ') +log "backup ok: $out ($size), копий хранится: $count данных + $globals_count globals" + +# Детект пропущенного запуска (#2203): отмечаем успех только тут, после всех +# проверок выше (и — под `set -euo pipefail` — после S3-выгрузки, если она +# включена). См. ops/check-backup-staleness.sh. +if [[ "${BACKUP_LOCAL_ONLY_USED:-0}" == "1" ]]; then + log "Sentinel не пишется — это ЛОКАЛЬНЫЙ прогон (BACKUP_ALLOW_LOCAL_ONLY=1), не подтверждённый off-box бэкап." >&2 + notify "⚠️ tradein backup: ЛОКАЛЬНЫЙ прогон (S3 не настроен, BACKUP_ALLOW_LOCAL_ONLY=1). Дамп ${out} НЕ ушёл off-box. Sentinel не обновлён — сторож свежести подаст сигнал, если это не поправить." +else + write_sentinel "$SENTINEL_FILE" +fi diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index c68d74c2..9e229983 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -123,8 +123,103 @@ services: # без свапа (shared_buffers не должны уходить в swap-thrash). # ревью #2214: 3g (idle 656MiB — БД дороже всего при ложном OOM; work_mem-спайки # тяжёлых PostGIS-сортировок бэктеста/эстиматора + autovacuum) - mem_limit: 3g - memswap_limit: 3g + # + # ── Переезд на выделенный сервер (#2989, репетиция 2026-08-23 на Poincare: + # 12 потоков / 62 ГиБ / NVMe RAID1) ───────────────────────────────────────── + # TRADEIN_PG_MEM_LIMIT — ОДНА переменная на mem_limit И memswap_limit + # намеренно: связка «без свапа» (memswap == mem, см. обоснование выше) обязана + # держаться и после правки, а не только на дефолте. Дефолт = ровно текущее + # прод-значение Beget (3g) — до задания переменной поведение стека НЕ меняется. + # ⚠️ mem_limit — потолок КОНТЕЙНЕРА, он срабатывает РАНЬШЕ postgresql.conf. + # На новом хосте поднимать TRADEIN_PG_MEM_LIMIT ДО правки + # TRADEIN_PG_SHARED_BUFFERS ниже — иначе shared_buffers=12GB при mem_limit=3g + # убьёт контейнер OOM-kill'ом на самом старте. Целевое на 62 ГиБ — 44g, а + # не 64g: нужен запас под page cache ВНЕ контейнера (это то, что реально + # отражает effective_cache_size — не shared_buffers). + mem_limit: ${TRADEIN_PG_MEM_LIMIT:-3g} + memswap_limit: ${TRADEIN_PG_MEM_LIMIT:-3g} + # TRADEIN_PG_SHM_SIZE: дефолт = текущее прод-значение (512m). Сюда ложатся + # только DSM-сегменты параллельных планов (не буферный кеш), поэтому рост + # /dev/shm не следует из тех же расчётов, что shared_buffers ниже — трогать + # только если после переезда параллельные PostGIS-сортировки начнут падать + # с «could not resize shared memory segment» (аналог #2812 в корневом + # docker-compose.prod.yml). + shm_size: ${TRADEIN_PG_SHM_SIZE:-512m} + # /dev/shm по умолчанию 64 МБ. Параллельные воркеры кладут туда shared memory + # segments; на тяжёлых PostGIS-сортировках это «could not resize shared memory». + # + # ── Конфигурация Postgres (#2991) ───────────────────────────────────────── + # До этого контейнер шёл на ПОЛНОСТЬЮ стоковых настройках. Замер прода 2026-08-20: + # 169 млрд blks_read за 91,75 сут ≈ 175 МБ/с мимо кеша при shared_buffers=128 МБ. + # + # Значения подобраны под ТЕКУЩИЙ сервер и НЕ выходят за mem_limit=3g. + # + # ── Параметризация под переезд (#2989, репетиция 2026-08-23 на Poincare) ── + # Пять параметров ниже вынесены в переменные ИМЕННО потому что они зависят + # от размера хоста; остальные (checkpoint_timeout, wal_compression, + # random_page_cost, pg_stat_statements и пр. ниже) от размера хоста НЕ + # зависят — оставлены как есть. Дефолты = буквально текущие прод-значения + # на Beget, так что до задания переменных поведение НЕ меняется ни на бит. + # Целевые значения для нового хоста (62 ГиБ) — задавать в окружении деплоя, + # НЕ здесь и НЕ на Beget (там 11 ГиБ, контейнер просто не поднимется): + # TRADEIN_PG_MEM_LIMIT=44g (сначала он — см. предупреждение выше) + # TRADEIN_PG_SHARED_BUFFERS=12GB + # TRADEIN_PG_EFFECTIVE_CACHE_SIZE=36GB + # TRADEIN_PG_WORK_MEM=64MB + # TRADEIN_PG_MAINTENANCE_WORK_MEM=2GB + # TRADEIN_PG_MAX_WAL_SIZE=16GB + # Порядок обязателен: сначала TRADEIN_PG_MEM_LIMIT (контейнерный потолок, + # см. выше), потом эти пять — иначе OOM-kill на старте. + command: + - postgres + # Память. 768MB shared_buffers — 6× от стоковых 128MB, с запасом внутри 3g + # (idle-замер контейнера был 292 МБ, work_mem-спайки и autovacuum сверху). + - -c + - shared_buffers=${TRADEIN_PG_SHARED_BUFFERS:-768MB} + # effective_cache_size — подсказка планировщику, НЕ аллокация. На хосте + # MemAvailable 6,1 ГБ, поэтому 6GB честно отражает доступный page cache. + - -c + - effective_cache_size=${TRADEIN_PG_EFFECTIVE_CACHE_SIZE:-6GB} + - -c + - work_mem=${TRADEIN_PG_WORK_MEM:-16MB} + # maintenance_work_mem: autovacuum по listings идёт 193 раза в сутки, + # с 64MB каждый проход перечитывает индексы лишними итерациями. + - -c + - maintenance_work_mem=${TRADEIN_PG_MAINTENANCE_WORK_MEM:-256MB} + # Чекпойнты. ГЛАВНОЕ: 288 чекпойнтов в сутки — это ровно 24ч/288 = 5 минут, + # то есть дефолтный checkpoint_timeout, а НЕ max_wal_size. При WAL 7 ГБ/сут + # лимит в 1 ГБ дал бы 7 чекпойнтов, а не 288. Поэтому поднимаем ИМЕННО + # timeout — иначе правка max_wal_size ничего бы не изменила. + # Реже чекпойнты → реже full-page images (сейчас 55-86% всего объёма WAL). + - -c + - checkpoint_timeout=30min + - -c + - checkpoint_completion_target=0.9 + # max_wal_size=4GB, а не 8GB: на диске сейчас всего 38 ГБ свободно, а pg_wal + # растёт до этого значения. При timeout=30min и 7 ГБ/сут между чекпойнтами + # накапливается ~0,15 ГБ, так что 4GB — потолок с большим запасом. + - -c + - max_wal_size=${TRADEIN_PG_MAX_WAL_SIZE:-4GB} + - -c + - min_wal_size=1GB + # Самая дешёвая победа при доле FPI 55-86%. zstd доступен с PG15, у нас 16. + - -c + - wal_compression=zstd + # Диск NVMe, а стоковый random_page_cost=4 — настройка под HDD: планировщик + # систематически недооценивает индексные сканы и уходит в Seq Scan. + - -c + - random_page_cost=1.1 + - -c + - effective_io_concurrency=200 + # pg_stat_statements: расширение есть в образе, но не подключено. Без него + # следующий разбор снова придётся вести по косвенным признакам. + # shared_preload_libraries требует рестарта — поэтому именно здесь. + - -c + - shared_preload_libraries=pg_stat_statements + - -c + - pg_stat_statements.max=5000 + - -c + - pg_stat_statements.track=top logging: *default-logging environment: POSTGRES_DB: tradein @@ -132,7 +227,17 @@ services: POSTGRES_PASSWORD: ${TRADEIN_POSTGRES_PASSWORD:?required} volumes: - tradein-postgres-data:/var/lib/postgresql/data - - ./backend/data/sql:/docker-entrypoint-initdb.d:ro + # TRADEIN_PG_INITDB_DIR (#2989): на СВЕЖЕМ томе postgres прогоняет ВСЁ из + # этого каталога как initdb-миграции ДО восстановления дампа — 249 миграций + # отработают первыми и засеют данные (`scrape_schedules` 157 строк, + # `tradein_users` 13, `deals` 80 — `003_seed_deals`/`193_tradein_users_seed`), + # а дамп идёт с `--clean --if-exists` и ляжет поверх (см. репетицию переноса + # 2026-08-23, vault fixes/Bug_Migration_Window_Operational_Blockers_OPEN п.2). + # На первом старте нового хоста задать переменную на ПУСТОЙ каталог — схема + # тогда приезжает восстановлением дампа, а не initdb-цепочкой; после + # restore переменную снять, дальше работает как обычно. + # Дефолт = текущий прод-путь, поведение Beget не меняется. + - ${TRADEIN_PG_INITDB_DIR:-./backend/data/sql}:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${TRADEIN_POSTGRES_USER:-tradein}"] interval: 10s @@ -182,6 +287,13 @@ services: - path: ./backend/.env.runtime required: false environment: + # Внешние оценки (Yandex/Cian) не ждать в запросе — догружать в фоне. + # Замер 2026-08-22: расчёт по новому адресу 8-12 с, из них ~6 с Yandex и + # ~1.5 с Cian, собственный расчёт < 1 с. Публикация в РБК 30.08 приведёт + # аудиторию на НОВЫЕ адреса, то есть мимо суточного кэша. + # Деградация источника в None — существующее состояние ответа (так же + # ведёт себя таймаут), контракт API не меняется. + ESTIMATE_EXTERNAL_SOURCES_BACKGROUND: "true" DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" PUBLIC_URL: "https://gendsgn.ru/trade-in" CORS_ORIGINS: '["https://gendsgn.ru"]' @@ -272,6 +384,18 @@ services: USE_KIT_SCHEDULER: "true" USE_PROXY_POOL_CURL: "true" USE_PROXY_POOL_BROWSER: "true" + # Транспорт detail_backfill: браузер, НЕ curl (замер 2026-08-21). + # Авито за QRATOR отдаёт JavaScript proof-of-work челлендж (startPow → + # кука pow_solved → self-reload). curl_cffi его не решает в принципе: + # прогоны 4348/4394/4508 дали 3-4 обогащённых из 46-53 попыток (~6%). + # Браузерный путь (camoufox + ожидание челленджа, #3045/#3046) на том же + # проде даёт ~74% (17/23 в свипе, 2/3 в точечной проверке). + # Исходное обоснование use_curl=True (browser открывает десятки коннектов + # и превышает cap прокси-аккаунта auv) больше не действует: браузер + # сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect + # SCRAPER_PROXY_URL, что и curl-путь. + # environment: перекрывает env_file — значение из runtime-env игнорируется. + AVITO_DETAIL_BACKFILL_USE_CURL: "false" depends_on: postgres: condition: service_healthy diff --git a/tradein-mvp/docker-compose.selectel.yml b/tradein-mvp/docker-compose.selectel.yml new file mode 100644 index 00000000..b50acc0c --- /dev/null +++ b/tradein-mvp/docker-compose.selectel.yml @@ -0,0 +1,116 @@ +# Override: закрепляет рабочий IP api.telegram.org для tgbot и backend. +# +# ⚠️ ОБНОВЛЕНО 25.08 (#3059). Файл больше НЕ Selectel-only и подключается +# БЕЗУСЛОВНО — его подмешивает deploy-tradein.yml (переменная COMPOSE_FILES). +# Имя оставлено прежним, чтобы не ломать ссылки на него в раннбуке и задачах. +# +# Что было не так: с 23.08 файл лежал в репозитории, но НИ ОДИН вызов compose в +# деплое его не подключал — везде было жёстко `-f docker-compose.prod.yml`. +# То есть первый же деплой на новом хосте поднял бы МЕРУ БЕЗ закрепления, и +# бот вместе с пересылкой алертов умерли бы молча. Написанный, но никем не +# подключённый оверрайд выглядит как решённая проблема, оставаясь нерешённой. +# +# Почему безусловно, а не только на Selectel (замер 25.08 С BEGET): +# 149.154.167.220 -> 302 за 0.18 с +# реальный Bot API /getMe -> {"ok":false,"error_code":401} +# то есть отвечает именно Telegram, а не заглушка +# Закрепляемый адрес живой и на старом хосте, поэтому условная логика «оверрайд +# только на новом» была бы лишней машинерией ради хоста, который после 30.08 +# перестанет быть продовым. Цена решения — на Beget пропадает запасной путь +# через резолвер (см. «ОДНА ТОЧКА ОТКАЗА» ниже); принято сознательно. +# +# ⚠️ Это оверрайд стека МЕРЫ (проект gendesign-tradein). Подмешивать его к стеку +# ПТИЦЫ НЕЛЬЗЯ: сервиса tgbot там нет, и compose отвергает весь проект — +# service "tgbot" has neither an image nor a build context specified +# а сервис backend есть в ОБОИХ стеках, так что закрепление молча легло бы на +# бэкенд Птицы. Правильный вызов — внутри проекта МЕРЫ: +# +# docker compose -p gendesign-tradein \ +# -f docker-compose.prod.yml -f docker-compose.selectel.yml up -d +# +# ── Почему это вообще нужно (замер 2026-08-23, с нового хоста) ───────────── +# У api.telegram.org семь публикуемых адресов. С Selectel отвечает РОВНО ОДИН: +# +# 149.154.167.220 -> 302 за 0.11 с (единственный живой) +# 149.154.166.110 -> 000 <- именно его отдаёт резолвер хоста! +# 149.154.167.99 -> 000 +# 149.154.175.100 -> 000 +# 149.154.171.5 -> 000 +# 91.108.4.5 -> 000 +# 91.108.56.130 -> 000 +# +# Сканом всей подсети 149.154.167.0/24 с этого же хоста подтверждено, что +# 149.154.167.220 — единственный отвечающий адрес Telegram во всём блоке, не +# только среди этих семи. Это НЕ блокировка Selectel и не наш файрвол: с +# Beget отвечают три адреса из тех же семи (включая тот, что отдаёт DNS там), +# т.е. Telegram частично фильтруется у ОБОИХ провайдеров по-разному — Beget +# просто попадает на живой адрес через штатный резолвер, а Selectel нет. +# +# Через прокси (ASocks, все четыре узла) Telegram тоже не проходит — все +# запросы 000, узлы российские. Этот путь закрыт, обход только через IP. +# +# Проверено закрепление (прежде чем закреплять в compose): +# - на хосте через /etc/hosts: curl https://api.telegram.org/ -> 302, 0.11 с +# - настоящий Bot API (/bot/getMe) -> {"ok":false,"error_code":401} за +# 0.11 с (для сравнения — с Beget тот же ответ приходит за 0.25 с, т.е. +# закреплённый адрес не просто живой, а ещё и быстрее дефолтного пути) +# - В КОНТЕЙНЕРЕ через docker --add-host -> 302 за 0.125 с (подтверждает, +# что extra_hosts ниже — не отличается от прямой проверки на хосте) +# - стабильность: 5 запросов подряд -> 302 302 302 302 302, без единого сбоя +# +# ── Что ломается без этого обхода ─────────────────────────────────────────── +# tradein-tgbot — long-polling воркер (см. docker-compose.prod.yml, сервис +# tgbot): он не слушает входящих соединений, а сам постоянно ходит наружу к +# api.telegram.org. Если резолвер отдаёт мёртвый адрес, httpx виснет на +# таймауте long-poll'а и процесс тихо умирает — restart: unless-stopped его +# поднимает заново, он снова резолвит тот же мёртвый адрес и снова виснет. +# Никакого явного краша или строки в логе, по которой это легко поймать — +# отсюда и требование закрепить адрес ДО переезда, а не разбираться постфактum. +# +# Вместе с ботом молча ложатся: +# - ops/lib-backup.sh — уведомления об успехе/провале бэкапов в Telegram +# - ops/uptime-healthcheck.sh — uptime-нотификации +# (оба — ХОСТ-скрипты, не контейнеры; extra_hosts на них не действует, этот +# файл их не чинит — см. TODO в конце файла). +# +# ── TELEGRAM_API_IP: почему переменная, а не жёсткий IP ───────────────────── +# Google/Cloudflare-класса анycast у Telegram нет: их адреса — обычные +# датацентровые IP, которые Telegram время от времени меняет. Если +# 149.154.167.220 однажды тоже станет мёртвым (или Selectel поменяет +# маршрутизацию и он перестанет быть единственным живым), правка — это +# одна строка в .env.runtime (TELEGRAM_API_IP=<новый адрес>) и +# `docker compose ... up -d --force-recreate --no-deps tgbot`, БЕЗ релиза и +# без правки этого файла. Дефолт ниже (:-149.154.167.220) — текущий +# подтверждённый живой адрес, применяется если переменная не задана. +# ── Почему ДВА сервиса, а не только бот ───────────────────────────────────── +# В api.telegram.org ходит не только tgbot. Внутри контейнера backend это +# делают ещё два пути: +# - app/api/v1/glitchtip.py — пересылка алертов GlitchTip в Telegram-тему +# - app/api/v1/support.py — support-чат +# Оба бьют httpx напрямую в api.telegram.org из того же контейнера, что и +# API. Ломаются они ЗАМЕТНЕЕ бота (не long-poll, а per-request: конкретный +# запрос отваливается по таймауту), но ломаются — и, что важнее, ломается +# пересылка алертов, то есть ещё один канал, которым мы узнали бы о беде. +# Закреплять адрес только боту значило бы починить треть и выглядеть готовым. +services: + tgbot: + extra_hosts: + - "api.telegram.org:${TELEGRAM_API_IP:-149.154.167.220}" + backend: + extra_hosts: + - "api.telegram.org:${TELEGRAM_API_IP:-149.154.167.220}" + +# ── Хостовая половина — закрыта в ops/selectel-bootstrap.sh ───────────────── +# ops/lib-backup.sh и ops/uptime-healthcheck.sh исполняются НА ХОСТЕ (cron, +# не в docker), поэтому extra_hosts им не помогает. Их закрывает шаг 11 +# bootstrap-скрипта: он кладёт ту же запись в системный /etc/hosts, идемпотентно +# и с той же переменной TELEGRAM_API_IP. Итого закреплены все три потребителя: +# tgbot, backend и хостовые скрипты. +# +# ⚠️ ОДНА ТОЧКА ОТКАЗА, ЗАФИКСИРОВАНА ОСОЗНАННО. 149.154.167.220 — единственный +# живой адрес во всём 149.154.167.0/24 с этого хоста. Если он умрёт, бот снова +# умрёт молча, а канал оповещения об этом сам идёт через Telegram — поломка +# скрывает сама себя. Поэтому закрепление НЕОБХОДИМО, НО НЕ ДОСТАТОЧНО: нужен +# независимый канал алертов. Проверено, что с нового хоста доступен +# smtp.beget.com:465 (порт 587 закрыт), плюс GlitchTip остаётся на Beget и +# доступен по имени. Отдельная задача, см. #3059. diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts new file mode 100644 index 00000000..60ea1434 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/coverage-copy.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { describeCoverage } from "../coverage-copy"; +import type { CoverageProbe } from "../public-api"; + +/** + * Что именно мы утверждаем о своих данных перед человеком, который пришёл + * узнать цену квартиры. Здесь не форматирование — здесь честность, поэтому + * тесты сформулированы через утверждения, а не через строки разметки. + */ + +function probe(over: Partial = {}): CoverageProbe { + return { + status: "ok", + n_listings: 34, + median_listing_age_days: 44, + n_with_age: 6, + radius_m: 1000, + city: "Екатеринбург", + threshold: 10, + ...over, + }; +} + +describe("describeCoverage", () => { + it("не молчит ни при одном статусе — директива «никогда не блокировать вывод»", () => { + for (const status of ["ok", "thin", "not_covered"] as const) { + const verdict = describeCoverage(probe({ status, n_listings: status === "not_covered" ? 0 : 34 })); + expect(verdict.title.length, `пустой заголовок при ${status}`).toBeGreaterThan(10); + expect(verdict.lines.length, `нет объяснения при ${status}`).toBeGreaterThan(0); + } + }); + + it("при неизвестном возрасте объявлений плитки нет — ноль вместо «неизвестно» хуже всего", () => { + const verdict = describeCoverage(probe({ median_listing_age_days: null, n_with_age: 2 })); + expect(verdict.tiles).toHaveLength(1); + expect(JSON.stringify(verdict.tiles)).not.toContain("дн."); + }); + + it("подпись под возрастом говорит про объявление, а не про срок продажи", () => { + // Выборка цензурирована: эти квартиры ЕЩЁ продаются, поэтому «за столько + // продаются» — прямая ложь. Формулировка запинена намеренно. + const [, ageTile] = describeCoverage(probe()).tiles; + expect(ageTile.label).toContain("объявление"); + expect(ageTile.label).not.toMatch(/продаётся|продаются|срок продажи|уходят/i); + }); + + it("город вне покрытия — говорим прямо и не предлагаем платный шаг", () => { + const verdict = describeCoverage(probe({ status: "not_covered", city: "", n_listings: 0 })); + expect(verdict.offerPaidStep).toBe(false); + expect(verdict.title).toMatch(/вне области/i); + }); + + it("пустая когорта в НАШЕМ городе — это про рынок, а не «сервис не работает»", () => { + const verdict = describeCoverage( + probe({ status: "not_covered", city: "Берёзовский", n_listings: 0 }), + ); + expect(verdict.offerPaidStep).toBe(false); + expect(verdict.lines.join(" ")).toContain("Берёзовский"); + // Подсказка, что делать дальше, обязана быть — иначе это тупик. + expect(verdict.lines.join(" ")).toMatch(/площадь|комнат/i); + }); + + it("тонкая выборка не скрывается: называем и найденное, и порог", () => { + const verdict = describeCoverage(probe({ status: "thin", n_listings: 4, threshold: 10 })); + expect(verdict.offerPaidStep).toBe(true); + expect(verdict.lines.join(" ")).toContain("4"); + expect(verdict.lines.join(" ")).toContain("10"); + }); + + it("склоняет существительное по числу — «1 похожая квартира», не «1 похожих квартир»", () => { + const one = describeCoverage(probe({ n_listings: 1 })).tiles[0].label; + const two = describeCoverage(probe({ n_listings: 2 })).tiles[0].label; + const five = describeCoverage(probe({ n_listings: 5 })).tiles[0].label; + const eleven = describeCoverage(probe({ n_listings: 11 })).tiles[0].label; + + expect(one).toContain("похожая квартира"); + expect(two).toContain("похожие квартиры"); + expect(five).toContain("похожих квартир"); + // 11-14 — исключение из правила последней цифры. + expect(eleven).toContain("похожих квартир"); + }); + + it("радиус показывается в километрах, когда он кратен тысяче метров", () => { + expect(describeCoverage(probe()).tiles[0].label).toContain("1 км"); + expect(describeCoverage(probe({ radius_m: 750 })).tiles[0].label).toContain("750 м"); + }); + + it("ни в одном ответе нет рублей — цену продаёт платный шаг", () => { + for (const status of ["ok", "thin", "not_covered"] as const) { + const verdict = describeCoverage(probe({ status })); + expect(JSON.stringify(verdict)).not.toMatch(/₽|руб/i); + } + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts new file mode 100644 index 00000000..e75875c6 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/estimate-draft.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { normalizeDraftRooms, saveDraft, takeDraft } from "../estimate-draft"; + +/** + * Черновик, который лэндинг передаёт на экран проверки. + * + * Тесты — на две вещи, каждая из которых уже ломалась при ревью: + * 1. поле «Комнат» на лэндинге свободное, и его значение нельзя подставлять + * в селект как есть; + * 2. черновик не должен переживать свой единственный переход. + */ + +const ROOMS = ["0", "1", "2", "3", "4", "5"]; + +describe("normalizeDraftRooms", () => { + it("пропускает то, что уже совпадает с вариантом селекта", () => { + expect(normalizeDraftRooms("2", ROOMS)).toBe("2"); + expect(normalizeDraftRooms("0", ROOMS)).toBe("0"); + }); + + it("понимает, как люди пишут на самом деле", () => { + expect(normalizeDraftRooms("2 комнаты", ROOMS)).toBe("2"); + expect(normalizeDraftRooms("3-комн.", ROOMS)).toBe("3"); + expect(normalizeDraftRooms(" 1к ", ROOMS)).toBe("1"); + expect(normalizeDraftRooms("Студия", ROOMS)).toBe("0"); + expect(normalizeDraftRooms("студию", ROOMS)).toBe("0"); + }); + + it("возвращает null вместо мусора — иначе селект покажет пустоту, а сервер получит null", () => { + // Именно этот путь и давал 422 с текстом «сломалось на нашей стороне»: + // селект пустой, parseInt → NaN, rooms: null улетает на бэкенд. + expect(normalizeDraftRooms("много", ROOMS)).toBeNull(); + expect(normalizeDraftRooms("", ROOMS)).toBeNull(); + expect(normalizeDraftRooms(undefined, ROOMS)).toBeNull(); + expect(normalizeDraftRooms("9", ROOMS)).toBeNull(); + }); +}); + +describe("черновик", () => { + beforeEach(() => window.sessionStorage.clear()); + + it("переживает ровно один переход", () => { + saveDraft({ address: "Малышева 51", rooms: "2" }); + expect(takeDraft()?.address).toBe("Малышева 51"); + // Второй раз — уже пусто: иначе возврат на /estimate через неделю в той же + // вкладке подставил бы чужой по смыслу адрес. + expect(takeDraft()).toBeNull(); + }); + + it("не падает на мусоре в хранилище", () => { + window.sessionStorage.setItem("mera:estimate-draft", "{это не json"); + expect(takeDraft()).toBeNull(); + + window.sessionStorage.setItem("mera:estimate-draft", JSON.stringify({ rooms: "2" })); + expect(takeDraft()).toBeNull(); + }); + + it("не тащит поля неожиданных типов", () => { + window.sessionStorage.setItem( + "mera:estimate-draft", + JSON.stringify({ address: "Ленина 1", rooms: 2, area: null }), + ); + const draft = takeDraft(); + expect(draft?.address).toBe("Ленина 1"); + expect(draft?.rooms).toBeUndefined(); + expect(draft?.area).toBeUndefined(); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/marketing-placeholders-gate.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/marketing-placeholders-gate.test.ts new file mode 100644 index 00000000..8f6ed5d1 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/marketing-placeholders-gate.test.ts @@ -0,0 +1,105 @@ +/** + * Гейт #2904: снять `noindex` можно только вместе с плейсхолдерными числами. + * + * В `marketing-v3.ts` лежат величины из дизайн-макета, НЕ подтверждённые замером: + * + * | здесь | факт по замеру | + * | ошибка 1,8% | 13,2% медиана |%| (бэктест n=277), held-out 16,1% | + * | 83% в диапазон | 81,95%, но коридор шириной 74% от точки | + * | ±6 дн по сроку | сверки срока с фактом НЕ СУЩЕСТВУЕТ | + * | 42 700 проверок за год | 1 084 оценки всего (база живёт 2,5 месяца) | + * | 42 города РФ | 6 городов области, полное покрытие — 1 (ЕКБ) | + * + * Пока страница закрыта `robots: { index: false }`, эти строки никому не видны, и + * держать их допустимо. Публикация же недостоверных характеристик ПЛАТНОЙ услуги + * подпадает под ст. 5 ФЗ «О рекламе» и ЗоЗПП. + * + * До сих пор это правило жило ТОЛЬКО в комментарии модуля. Правило, записанное + * словами, не мешает снять одну строку в layout.tsx и выкатить страницу — поэтому + * гейт здесь механический: он краснеет ровно в тот момент, когда noindex исчезает, + * а плейсхолдеры остаются. + * + * Тест НЕ запрещает открывать страницу. Он требует, чтобы открытие шло вместе с + * заменой чисел на вычисляемые (или их снятием) — то есть чтобы решение было + * осознанным, а не побочным следствием правки метаданных. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, ".."); + +function read(rel: string): string { + return readFileSync(join(ROOT, rel), "utf8"); +} + +/** Компоненты лэндинга v3, которые могут ссылаться на плейсхолдеры. */ +const V3_SOURCES = [ + "v3/page.tsx", + "_components/v3/HeroV3.tsx", + "_components/v3/AccuracyV3.tsx", + "_components/v3/FreeCheckCard.tsx", + "_components/v3/FreeResultV3.tsx", +]; + +const PLACEHOLDER_EXPORTS = [ + "ACCURACY_PLACEHOLDER", + "COST_OF_ERROR_PLACEHOLDER", + "FREE_SAMPLE_PLACEHOLDER", + "PROOF_ROWS_PLACEHOLDER", +]; + +function placeholdersInUse(): string[] { + const used: string[] = []; + for (const rel of V3_SOURCES) { + let src: string; + try { + src = read(rel); + } catch { + continue; // файл переименован/удалён — не повод падать + } + for (const name of PLACEHOLDER_EXPORTS) { + if (src.includes(name) && !used.includes(name)) used.push(name); + } + } + return used; +} + +function noindexIsOn(): boolean { + const layout = read("layout.tsx").replace(/\s+/g, " "); + return layout.includes("robots: { index: false"); +} + +describe("гейт noindex ↔ плейсхолдерные числа (#2904)", () => { + it("пока плейсхолдеры на странице — noindex обязан стоять", () => { + const used = placeholdersInUse(); + if (used.length === 0) { + // Числа заменены вычисляемыми — гейт больше не нужен, открывать можно. + return; + } + expect(noindexIsOn(), [ + `лэндинг v3 всё ещё использует непроверенные величины: ${used.join(", ")},`, + "но `robots: { index: false }` в layout.tsx снят.", + "Перед открытием страницы наружу каждая цифра обязана быть заменена", + "вычисляемой либо снята — см. шапку marketing-v3.ts и issue #2904.", + ].join(" ")).toBe(true); + }); + + it("сам гейт видит и плейсхолдеры, и noindex — иначе он проходил бы вхолостую", () => { + // Контроль на инструмент: если оба зонда перестанут находить своё + // (переименование файлов, смена формы записи), проверка выше станет + // зелёной по построению и перестанет что-либо охранять. + expect(placeholdersInUse().length, "ни один плейсхолдер не найден в компонентах v3 — зонд сломан").toBeGreaterThan(0); + expect(read("layout.tsx"), "layout.tsx не содержит блока robots — зонд сломан").toContain("robots"); + }); + + it("расхождение «заявлено против замера» остаётся в шапке модуля", () => { + // Таблица — единственное место, где зафиксировано, НАСКОЛЬКО числа расходятся + // с фактом. Потеряется она — и следующий читатель решит, что цифры проверены. + const mod = read("marketing-v3.ts"); + expect(mod).toContain("13,2%"); + expect(mod).toContain("НЕ СУЩЕСТВУЕТ"); + expect(mod).toContain("1 084"); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts new file mode 100644 index 00000000..5e7949c1 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts @@ -0,0 +1,172 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { PUBLIC_ROUTES } from "../content"; +import { b2cVars } from "../theme"; + +/** + * Структурные гейты публичного B2C-периметра. Каждый заведён на РЕАЛЬНО + * случившийся дефект — все три нашёл владелец на живом сайте, а не сборка. + * + * Проверки читают исходники с диска намеренно: то, что здесь ломается, — это + * связь между файлами (CSS ↔ токены, роуты ↔ Caddyfile, компоненты ↔ basePath), + * а не поведение одной функции. Такую связь юнит-тест на модуле не видит. + */ + +const MERA_PUBLIC = path.resolve(__dirname, ".."); +const REPO_ROOT = path.resolve(MERA_PUBLIC, "../../../../.."); + +function collectFiles(dir: string, exts: readonly string[]): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...collectFiles(full, exts)); + } else if (exts.includes(path.extname(full))) { + out.push(full); + } + } + return out; +} + +describe("палитра v3 доезжает до CSS", () => { + /** + * Дефект 15.08.2026: `b2c-tokens.ts` завёл палитру макета, `landing-v3.module.css` + * начал ссылаться на `var(--b2c-*)` — а моста между ними не появилось. + * Переменные не объявлял никто, каскад молча пропускал такие декларации, и + * лэндинг v3 на проде отдавал 200 бесцветным: карточки без фона, кнопки без + * заливки. Ни сборка, ни линт, ни тайпчек этого не видят — заметил владелец. + */ + const css = readFileSync(path.join(MERA_PUBLIC, "landing-v3.module.css"), "utf8"); + const used = new Set( + [...css.matchAll(/var\(\s*(--b2c-[a-z0-9-]+)/g)].map((m) => m[1]), + ); + + it("в CSS вообще есть ссылки на переменные (иначе тест бесполезен)", () => { + expect(used.size).toBeGreaterThan(5); + }); + + it("каждая использованная --b2c-* переменная объявлена в b2cVars", () => { + const declared = new Set(Object.keys(b2cVars)); + const missing = [...used].filter((name) => !declared.has(name)); + expect(missing, `не объявлены — на странице будут пустые значения: ${missing}`).toEqual([]); + }); +}); + +/** + * Эффективный конфиг Caddy = корневой Caddyfile + всё, что он импортирует из + * caddy/sites/*.caddy. С #3059 site-блоки живут в отдельных файлах (переезд на + * Selectel: на новом хосте поднимаются только apps, на Beget остаются infra), + * а корневой файл держит глобальные опции и два import'а. Читать один + * Caddyfile здесь больше нельзя — проверка стала бы пустой и молча зелёной. + * + * Берём ВСЕ файлы из caddy/sites/ намеренно: `import caddy/sites/{$CADDY_SITES:*}` + * без переменной раскрывается в звёздочку, то есть это и есть конфиг по умолчанию. + */ +function readEffectiveCaddyConfig(repoRoot: string): string { + const sitesDir = path.join(repoRoot, "caddy", "sites"); + const siteFiles = readdirSync(sitesDir) + .filter((f) => f.endsWith(".caddy")) + .sort() + .map((f) => readFileSync(path.join(sitesDir, f), "utf8")); + return [readFileSync(path.join(repoRoot, "Caddyfile"), "utf8"), ...siteFiles].join("\n"); +} + +describe("короткие адреса публичного домена", () => { + /** + * Дефект 15.08.2026: подвал v3 вёл «Главную» на `/mera-public`, а Caddy на + * публичном домене раздавал только `/trade-in/mera-public/*` (со слэшем) — + * голый путь падал в catch-all 404. Страница, которой нет в allowlist + * Caddyfile, недоступна независимо от того, что она собралась. + */ + const caddyfile = readEffectiveCaddyConfig(REPO_ROOT); + const meraBlock = caddyfile.slice( + caddyfile.indexOf("meraocenka.ru {"), + caddyfile.indexOf("merahome.ru {"), + ); + + it("блок meraocenka.ru найден (иначе проверки ниже пустые)", () => { + expect(meraBlock.length).toBeGreaterThan(500); + }); + + /** + * Разрешённый «лишний» путь: временное превью второго варианта дизайна. + * Ссылок на него нет, страница noindex; строка удаляется вместе с выбором + * варианта. Держим здесь, чтобы проверка ниже была ДВУСТОРОННЕЙ. + */ + const PREVIEW_ONLY = ["/v3"]; + + const meraPagesLine = meraBlock.match(/@meraPages path ([^\n]+)/); + + it("каждый маршрут из PUBLIC_ROUTES раздаётся публичным доменом", () => { + for (const route of Object.values(PUBLIC_ROUTES)) { + // Корень — отдельным `handle /`, остальные перечислены в матчере @meraPages. + const served = + route === "/" + ? /handle\s+\/\s*\{/.test(meraBlock) + : (meraPagesLine?.[1].split(/\s+/) ?? []).includes(route); + expect(served, `${route} не раздаётся на meraocenka.ru — ссылка на него будет 404`).toBe( + true, + ); + } + }); + + /** + * Обратное направление. Односторонняя проверка (каждый роут есть в Caddy) + * пропускает противоположную ошибку: путь, открытый на боевом домене, о + * котором приложение не знает. Так на публичный домен уже уехало `/v3` — + * черновой лэндинг с маркетинговыми плейсхолдерами вместо посчитанных чисел. + * Теперь любой такой путь обязан быть либо в PUBLIC_ROUTES, либо в списке + * превью выше — то есть названным вслух. + */ + it("на публичном домене не открыто ничего сверх известных страниц", () => { + const served = meraPagesLine?.[1].split(/\s+/).filter(Boolean) ?? []; + const known = new Set([...Object.values(PUBLIC_ROUTES), ...PREVIEW_ONLY]); + const unexpected = served.filter((p) => !known.has(p)); + expect( + unexpected, + `открыты наружу, но не объявлены ни в PUBLIC_ROUTES, ни как превью: ${unexpected}`, + ).toEqual([]); + }); + + /** + * Длинные адреса обязаны редиректить на короткие — иначе разосланные ссылки + * и закладки превращаются в 404. Список тоже поимённый (регексп с захватом + * хвоста здесь был бы открытым редиректом — см. комментарий в Caddyfile), + * поэтому он так же легко расходится с набором страниц. + */ + it("для каждой страницы есть 301 с длинного адреса", () => { + const longLine = meraBlock.match(/@meraLongPages path ([^\n]+)/)?.[1] ?? ""; + for (const route of [...Object.values(PUBLIC_ROUTES), ...PREVIEW_ONLY]) { + if (route === "/") continue; // корень ловит отдельный @meraLongRoot + expect( + longLine.includes(`/trade-in/mera-public${route}`), + `нет 301 с длинного адреса на ${route} — старые ссылки станут 404`, + ).toBe(true); + } + }); +}); + +describe("basePath не протекает в публичные ссылки", () => { + /** + * `next/link` подставляет `basePath=/trade-in` в каждый href, поэтому + * `` уехал бы на несуществующий `/trade-in/estimate`. + * Публичное дерево ходит обычным `` (компонент `PublicLink`). + * + * Проверка на импорт, а не на строку `/trade-in` в разметке: именно импорт + * `next/link` — источник подстановки, и именно он вернётся, если кто-то + * добавит ссылку «как везде в проекте». + */ + it("ни один файл под mera-public не импортирует next/link", () => { + const offenders = collectFiles(MERA_PUBLIC, [".ts", ".tsx"]) + .filter((file) => /from\s+["']next\/link["']/.test(readFileSync(file, "utf8"))) + .map((file) => path.relative(MERA_PUBLIC, file)); + + expect( + offenders, + `next/link подставит basePath и ссылка уедет на /trade-in/...: ${offenders}`, + ).toEqual([]); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx index d1eb1e7f..55126344 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/AddressForm.tsx @@ -29,10 +29,14 @@ * (useMe/useQuota/useHistory и соседей) запрещено — публичный экран не должен * иметь к ним доступа даже теоретически. * - * Когда бэкенд откроет анонимные ручки: переключить `PUBLIC_ESTIMATE_ENABLED` - * в content.ts и заменить ветку `notLaunched` в `handleSubmit` на реальный - * переход/запрос (комбобокс подсказок — по образцу ParamsPanel.tsx, вместе с - * его клавиатурной моделью и sr-live-регионом). + * КУДА ВЕДЁТ КНОПКА (15.08.2026). Раньше сабмит упирался в сообщение «расчёт + * ещё не открыли» — анонимных ручек не существовало. Теперь они есть + * (`/api/public/mera/*`), а сама проверка живёт на отдельном экране + * `/estimate`: там у автокомплита есть место под список подсказок, а у + * результата — место рядом с полями. Эта форма осталась входной точкой + * лэндинга и делает ровно две полезные вещи — не пускает дальше без адреса и + * требует явно назвать город (#2576: «Ленина, 1» без города давало уверенную + * цену по одноимённой улице в ЕКБ жителю Нижнего Тагила). */ import { useId, useRef, useState } from "react"; @@ -43,10 +47,11 @@ import { detectCityInText } from "@/lib/city-registry"; import { COVERED_CITIES, PRIMARY_CITY, - PUBLIC_ESTIMATE_ENABLED, + PUBLIC_ROUTES, REGION_NAME, SECONDARY_CITIES, } from "../content"; +import { saveDraft } from "../estimate-draft"; import styles from "../landing.module.css"; /** Значение `, не `next/link`. + * + * ПОЧЕМУ ТАК. В образ запечён `basePath=/trade-in`, и `next/link` подставляет + * его в КАЖДЫЙ href: `` уедет на `/trade-in/estimate`, + * которого не существует. Отключить подстановку для отдельной ссылки нельзя — + * это свойство сборки, а не пропс. Значит короткие адреса публичного домена + * (`/estimate`, `/oferta`, …) выразимы только обычным якорем. + * + * Что теряем: клиентскую навигацию и prefetch. На пяти статических страницах + * это незаметно, а prefetch публичному дереву прямо противопоказан — он + * подтягивал бы чанки страниц, которых анонимному посетителю видеть незачем. + * + * ЗАЧЕМ ОТДЕЛЬНЫЙ КОМПОНЕНТ, А НЕ ПРОСТО ``. Ради типа `to`: он принимает + * только ключи `PUBLIC_ROUTES`, поэтому опечатка в адресе или ссылка на + * страницу, которой нет в Caddy-allowlist публичного домена, не компилируется. + * Именно такие ссылки и ломались раньше — подвал v3 вёл на `/mera-public`, + * который на публичном домене отдавал 404, и заметил это владелец, а не тест. + */ + +type PublicRoute = keyof typeof PUBLIC_ROUTES; + +interface PublicLinkProps + extends Omit, "href"> { + to: PublicRoute; +} + +export function PublicLink({ to, children, ...rest }: PublicLinkProps) { + return ( + + {children} + + ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx index 086d4abc..e7f82812 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/SiteFooter.tsx @@ -1,33 +1,39 @@ /** * SiteFooter — подвал. Серверный компонент. * - * Что здесь честно ОТСУТСТВУЕТ: - * - Реквизиты юрлица/ИП. В репозитории их нет (поиск по коду, бэкенду и - * разметке не дал ни наименования, ни ИНН/ОГРН), а выдумывать реквизиты - * оператора персональных данных на публичной странице нельзя. Блок - * рендерится, как только `LEGAL_ENTITY` в content.ts перестанет быть null; - * заполнить обязательно до открытия домена наружу — 152-ФЗ требует - * идентифицируемого оператора. - * - E-mail поддержки: реального адреса в коде тоже нет. Единственный - * проверяемый канал — телеграм-бот из `v2/SupportChatContext.tsx`. + * 2026-08-13: блоки реквизитов и почты, которых здесь раньше не было, теперь + * рендерятся — данные появились (`LEGAL_ENTITY`, `SUPPORT_EMAIL` в content.ts), + * и это не косметика: наименование, ИНН, ОГРН и работающий контакт на странице + * с офертой — прямое требование модерации эквайера и условие идентифицируемости + * оператора по 152-ФЗ. Значения импортируются, а не пишутся строками: тот же + * набор рендерят оферта и политика ПДн, и разъехаться они не должны. + * + * ССЫЛКИ НА ДОКУМЕНТЫ — КОРОТКИЕ (изменено 15.08.2026 по решению владельца). + * Раньше здесь стояли внутренние пути `/mera-public/oferta` и соседи, чтобы + * подвал работал и при открытии поддерева с gendsgn.ru. Теперь наоборот: + * человек на публичном сервисе не должен видеть `/trade-in/mera-public/...` в + * адресной строке, и именно короткие адреса напечатаны внутри самих + * документов. Цена решения — переходы в подвале работают только на + * meraocenka.ru; разбор и что с этим делать — в шапке `PUBLIC_ROUTES` + * (content.ts). * * Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без * валидации схемы) и открывается в новой вкладке с rel="noreferrer". */ -import Link from "next/link"; - import { safeUrl } from "@/lib/safeUrl"; import { LEGAL_ENTITY, - PRIVACY_PATH, REGION_NAME, + SUPPORT_EMAIL, SUPPORT_TELEGRAM_LABEL, SUPPORT_TELEGRAM_URL, } from "../content"; import styles from "../landing.module.css"; +import { PublicLink } from "./PublicLink"; + export function SiteFooter() { const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL); const year = new Date().getFullYear(); @@ -49,6 +55,12 @@ export function SiteFooter() {

      Связаться

      +

      + Почта:{" "} + + {SUPPORT_EMAIL} + +

      {telegramHref ? (

      Поддержка в Telegram:{" "} @@ -61,18 +73,26 @@ export function SiteFooter() { {SUPPORT_TELEGRAM_LABEL}

      - ) : ( -

      Контакты появятся к запуску.

      - )} + ) : null}

      Документы

      • - + + Публичная оферта + +
      • +
      • + + Политика возврата + +
      • +
      • + Обработка персональных данных - +
      @@ -82,8 +102,8 @@ export function SiteFooter() { © {year} МЕРА {LEGAL_ENTITY && ( - {LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn},{" "} - {LEGAL_ENTITY.address} + {LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, ОГРН{" "} + {LEGAL_ENTITY.ogrn}, {LEGAL_ENTITY.address} )} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx index 4ec23dcc..a4c4d29a 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/SiteHeader.tsx @@ -4,6 +4,14 @@ * Логотип НЕ является ссылкой на "/": в next.config.ts стоит redirect "/" → "/v2", * то есть клик по нему выкинул бы публичного посетителя в закрытое B2B-приложение * (и дальше на /login). Пока публичная страница одна, вордмарк — просто текст. + * + * ФОРМУЛИРОВКА ДЕСКРИПТОРА (14.08.2026, юр-документ владельца, блок 3). Строка + * шапки обязана читаться как «МЕРА · Оценка вторичного жилья по рыночным данным · + * <регион>»: слова «по рыночным данным» — то, чем сервис отличается от оценки по + * 135-ФЗ, и юрист попросил, чтобы это стояло в самом заметном дескрипторе, а не + * только в дисклеймерах внизу. «МЕРА» приходит из вордмарка ниже (у него свой + * letter-spacing), поэтому в headerTag её нет — строка складывается из двух узлов. + * Регион — из REGION_NAME, не строкой (правило content.ts). */ import { REGION_NAME } from "../content"; @@ -18,7 +26,7 @@ export function SiteHeader() { МЕРА

    - Оценка вторичного жилья · {REGION_NAME} + Оценка вторичного жилья по рыночным данным · {REGION_NAME}

diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx new file mode 100644 index 00000000..bcf6dcdf --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/estimate/EstimateFlow.tsx @@ -0,0 +1,483 @@ +"use client"; + +/** + * EstimateFlow — публичная форма проверки квартиры на `meraocenka.ru/estimate`. + * + * ПОЧЕМУ ОТДЕЛЬНАЯ СТРАНИЦА, А НЕ КАРТОЧКА В ГЕРОЕ. Карточка на первом экране + * (`v3/FreeCheckCard.tsx`) продаёт: показывает, что сервис умеет, и уводит + * читать дальше. Здесь человек уже пришёл считать — экран должен принадлежать + * задаче целиком: автокомплит с местом под список подсказок, поля рядом, + * результат на том же месте, без соседних секций, конкурирующих за внимание. + * + * ЧТО ЭТА ФОРМА ДЕЛАЕТ СЕГОДНЯ. Бесплатную пробу покрытия: сколько похожих + * квартир продаётся рядом и как долго висят их объявления. Ни одной рублёвой + * цифры — их отдаёт платный шаг, которого пока нет (нужен платёжный контур, + * #2896). Это не заглушка: проба ходит в живой SQL по активным объявлениям и + * отвечает по конкретному дому. + * + * НИЧЕГО НЕ СОХРАНЯЕТСЯ. Обе ручки только читают (см. + * `backend/app/api/public/mera.py`) — адрес, который человек ввёл, никуда не + * пишется. Это и позволяет форме работать ДО контура согласия 152-ФЗ (#2895). + * Появится сохранение — обязано появиться и согласие, раньше запроса, а не + * после. + */ + +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import type { FormEvent, KeyboardEvent } from "react"; + +import { COVERED_CITIES, PRIMARY_CITY } from "../../content"; +import { describeCoverage } from "../../coverage-copy"; +import type { CoverageVerdict } from "../../coverage-copy"; +import { normalizeDraftRooms, takeDraft } from "../../estimate-draft"; +import { + PublicApiError, + fetchAddressSuggestions, + fetchCoverage, +} from "../../public-api"; +import type { AddressSuggestion } from "../../public-api"; +import styles from "../../landing-v3.module.css"; + +/** Задержка перед запросом подсказок. Каждый вызов платный (DaData-тир). */ +const SUGGEST_DEBOUNCE_MS = 300; +const SUGGEST_MIN_CHARS = 3; +/** Потолок ожидания пробы покрытия. Сам SQL укладывается в ~80 мс. */ +const COVERAGE_TIMEOUT_MS = 15_000; + +const ROOM_OPTIONS = [ + { value: 0, label: "Студия" }, + { value: 1, label: "1 комната" }, + { value: 2, label: "2 комнаты" }, + { value: 3, label: "3 комнаты" }, + { value: 4, label: "4 комнаты" }, + { value: 5, label: "5 и больше" }, +] as const; + +type Phase = + | { kind: "form" } + | { kind: "loading" } + | { kind: "result"; verdict: CoverageVerdict } + | { kind: "failed"; title: string; text: string }; + +const FORM: Phase = { kind: "form" }; + +function failureCopy(error: unknown): { title: string; text: string } { + if (error instanceof DOMException && error.name === "AbortError") { + return { + title: "Проверка заняла слишком долго", + text: "Мы прервали запрос, чтобы не держать вас в неизвестности. Нажмите «Проверить мой дом» ещё раз — введённое сохранилось.", + }; + } + if (error instanceof PublicApiError && error.kind === "rate-limited") { + const wait = error.retryAfterS ? `${error.retryAfterS} сек.` : "минуту"; + return { + title: "Слишком много проверок подряд", + // Инструкция, которую человек может выполнить, — а не «сервис сломался». + text: `Подождите ${wait} и попробуйте снова. Ограничение стоит, чтобы форму не выгребали роботами.`, + }; + } + if (error instanceof PublicApiError && error.kind === "offline") { + return { + title: "Не получилось связаться с сервисом", + text: "Похоже, пропала связь. Проверьте интернет и попробуйте ещё раз — введённое сохранится.", + }; + } + return { + title: "Проверка не прошла", + text: "Что-то сломалось на нашей стороне. Попробуйте ещё раз через пару минут; если повторится — напишите нам, ссылка в подвале.", + }; +} + +export function EstimateFlow() { + const addressId = useId(); + const listboxId = useId(); + const cityId = useId(); + const roomsId = useId(); + const areaId = useId(); + const statusId = useId(); + + const [city, setCity] = useState(PRIMARY_CITY); + const [query, setQuery] = useState(""); + const [suggestions, setSuggestions] = useState([]); + const [highlighted, setHighlighted] = useState(-1); + const [picked, setPicked] = useState(null); + const [rooms, setRooms] = useState("2"); + const [area, setArea] = useState(""); + const [phase, setPhase] = useState(FORM); + const [fieldError, setFieldError] = useState<"address" | "area" | null>(null); + const [suggestFailed, setSuggestFailed] = useState(false); + + const addressRef = useRef(null); + const areaRef = useRef(null); + const coverageAbort = useRef(null); + + // Незавершённый запрос покрытия при уходе со страницы отменяем — иначе + // setState прилетает в размонтированный компонент. + useEffect(() => () => coverageAbort.current?.abort(), []); + + // Черновик с лэндинга — то, что человек уже набрал там. Забираем ОДИН раз + // на монтировании; координат в нём нет (и быть не может — на лэндинге нет + // автокомплита), поэтому дом всё равно придётся выбрать из подсказок. + // + // Каждое поле ВАЛИДИРУЕТСЯ, а не подставляется как есть. На лэндинге + // «Комнат» — свободный текст, туда пишут «студия» или «2 комнаты»; такое + // значение не совпадает ни с одним