From eb98852ddf1f3eeb366f3bb42b71284475d09d80 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 17:52:56 +0000 Subject: [PATCH] =?UTF-8?q?ci:=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=20=D0=BE=D0=B1=D1=8F?= =?UTF-8?q?=D0=B7=D0=B0=D0=BD=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20=D1=81=D0=B5=D0=B1=D1=8F=20=E2=80=94=20=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D1=87=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=20=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D0=BD=D1=8B=D0=B9=20(#2745)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .forgejo/workflows/ci-tradein.yml | 112 ++++++++++++++++++- .forgejo/workflows/ci.yml | 77 +++++++++++-- backend/tests/conftest.py | 68 +++++++++++ backend/tests/skip_allowlist.txt | 89 +++++++++++++++ tradein-mvp/backend/tests/conftest.py | 68 +++++++++++ tradein-mvp/backend/tests/skip_allowlist.txt | 39 +++++++ 6 files changed, 441 insertions(+), 12 deletions(-) create mode 100644 backend/tests/skip_allowlist.txt create mode 100644 tradein-mvp/backend/tests/skip_allowlist.txt diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index c3f6bcda..5b228267 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -66,16 +66,104 @@ jobs: runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.backend == 'true' + # Postgres-сервис (#2745). ДО него лэйн был mock-only: DATABASE_URL указывал на + # заведомо мёртвый `localhost:5432/test`, и девять тестов с `_live_session()` + # self-skip'ались — в CI они не бежали НИ РАЗУ. Так и разъехался со схемой + # test_house_dedup_merge (#2740: houses.url стал NOT NULL), а + # test_gar_flats_loader вообще падал до первого утверждения (#2744). + # + # Замер перед включением: полный сьют на mock-лэйне 122с / 3858 passed / 10 skipped, + # тот же сьют против живой БД — 106с / 3867 passed / 1 skipped. Живая БД не + # медленнее, поэтому НЕ добавляем второй job, а чиним этот: один прогон, на + # девять реальных проверок больше. Накладные — только подъём контейнера и + # bootstrap схемы (219 файлов, ~20с). defaults: run: working-directory: ./tradein-mvp/backend env: - # psycopg v3 требует parseable URL на импорте; реального коннекта нет — - # DB-тесты мокаются (mirror deploy-tradein.yml test-job). - DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test + # Имя контейнера уникально на прогон: параллельные PR не дерутся за него. + CI_PG: ci-pg-tradein-${{ github.run_id }} steps: - uses: actions/checkout@v4 + - name: Поднять Postgres и собрать схему tradein + working-directory: . + # ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА. + # Раннер запускает и job, и сервис-контейнеры с `--network host` (видно в + # логе прогона: `docker create image=... network="host"`), а на 5432 того + # же хоста слушает ПРОДОВЫЙ Postgres. Попытка через `services:` + + # `ports: 5432:5432` кончилась тем, что сервис-контейнер не смог занять + # порт, а psql из job'а ушёл В ПРОД и получил + # `password authentication failed for user "tradein"`. То есть + # `localhost:5432` из job'а на этом раннере — боевая база, а не тестовая. + # Поэтому контейнер поднимаем сами, в bridge-сети, БЕЗ публикации порта, + # и ходим по его собственному IP: прод недостижим в принципе, параллельные + # прогоны не конфликтуют, psql берём из самого контейнера. + # + # ОДИН шаг, а не два: между шагами контейнер успевал исчезнуть, и + # bootstrap падал на `container is not running`. + # + # `pg_isready -h 127.0.0.1`, а НЕ через unix-сокет: на время initdb образ + # поднимает ВРЕМЕННЫЙ сервер с listen_addresses='' — по сокету он уже + # отвечает «accepting connections», хотя снаружи БД ещё не существует, а + # впереди рестарт. Проба по TCP зеленеет только на настоящем сервере — + # том самом, к которому пойдут тесты. + # + # postgis, не plain postgres: tests/tasks/test_cadastral_geo_match.py + # проверяет KNN по geometry (PostGIS_Version() в connectivity-probe). + # Имя БД ОБЯЗАНО отличаться от `test`: `_live_session()` считает DSN с + # `localhost:5432/test` заглушкой и вернул бы None — контейнер поднялся + # бы, а тесты всё равно скипались. + run: | + set -u + docker rm -f "$CI_PG" >/dev/null 2>&1 || true + docker run -d --name "$CI_PG" \ + -e POSTGRES_DB=tradein -e POSTGRES_USER=tradein -e POSTGRES_PASSWORD=tradein \ + postgis/postgis:16-3.4 + + ready="" + for _ in $(seq 1 45); do + if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U tradein -q 2>/dev/null; then + ready=1; break + fi + [ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break + sleep 2 + done + if [ -z "$ready" ]; then + echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)" + docker logs --tail 50 "$CI_PG" 2>&1 || true + exit 1 + fi + + ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG") + [ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; } + echo "DATABASE_URL=postgresql+psycopg://tradein:tradein@${ip}:5432/tradein" >> "$GITHUB_ENV" + echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)" + + # Тот же порядок и тот же строгий режим, что в deploy-tradein.yml: + # `ls | sort` + ON_ERROR_STOP=on, падение любой миграции → job RED. + # Никаких «применилось как получилось»: схема в CI либо та же, что на + # проде, либо гейта нет. + docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q -c \ + "CREATE EXTENSION IF NOT EXISTS postgis; + CREATE EXTENSION IF NOT EXISTS pg_trgm; + CREATE ROLE gendesign_reader;" + for sql_file in $(ls -1 tradein-mvp/backend/data/sql/*.sql | sort); do + fname=$(basename "$sql_file") + # ЕДИНСТВЕННОЕ исключение, и оно названо вслух: 077 — не DDL, а + # backfill, читающий foreign table gendesign_rosreestr_deals из БД + # ДРУГОГО стека через postgres_fdw. В CI второй БД нет, USER MAPPING + # создать не из чего. На пустых таблицах backfill всё равно no-op. + if [ "$fname" = "077_dedup_hash_plain_key_backfill.sql" ]; then + echo "⚠ пропускаю $fname — postgres_fdw к БД gendesign, которой в CI нет" + continue + fi + docker exec -i "$CI_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -q < "$sql_file" \ + || { echo "::error::миграция $fname не применилась"; docker logs --tail 20 "$CI_PG" 2>&1 || true; exit 1; } + done + echo "✓ схема собрана: $(docker exec "$CI_PG" psql -U tradein -d tradein -tAc \ + "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'") таблиц" + - name: Install uv # Официальный standalone-инсталлер. НЕ astral-sh/setup-uv — он ломается # на Forgejo-runner с PEP 668 externally-managed-environment (#666 CI). @@ -121,7 +209,19 @@ jobs: # NB: в deploy-tradein.yml (post-merge test-job) свой экземпляр этого # deselect'а — он остаётся до #2680, который правит тот файл. Расхождение # безвредно: pre-merge гейт тест гоняет, post-merge просто пропустит зелёный. - run: uv run pytest -q + # + # `-rs` (#2745) — КАЖДЫЙ пропуск печатает свою причину в лог job'а. Без него + # `-q` рисует пропуск точкой `s`, неотличимой на глаз от прогона: ровно так + # девять DB-тестов «шли зелёными», ничего не проверяя. Пропуск, который не + # называет себя вслух, со временем перестаёт быть верным. + run: uv run pytest -q -rs + + - name: Снести тестовый Postgres + # if: always() — контейнер уходит и когда сьют красный, и когда прогон + # отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры. + if: always() + working-directory: . + run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true # Тесты браузерного сайдкара (#2722). До этого job'а они не бежали НИГДЕ: # ci-tradein гейтил только backend/frontend, deploy-tradein — тоже, а каталог @@ -169,7 +269,9 @@ jobs: - name: Run pytest (tradein-mvp/browser) # Каталог без pyproject/pytest.ini → дефолтная конфигурация, ничего # не deselect'ится. Ожидание: 108 passed, 0 failed, 0 skipped. - run: pytest -q + # `-rs`: если однажды появится пропуск, он назовёт причину в логе, а не + # растворится в строке точек. + run: pytest -q -rs frontend-checks: runs-on: ubuntu-latest diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index a261bc30..66d5fd7f 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -69,6 +69,20 @@ jobs: runs-on: ubuntu-latest needs: changes if: needs.changes.outputs.backend == 'true' + # Postgres-сервис (#2745). Раньше DATABASE_URL указывал на заведомо мёртвый + # хост, и весь tests/sql/ (10 тестов: #17 velocity-alerts, #99 ДДУ-индикатор, + # #295 weighted AVG) self-skip'ался connectivity-probe'ом — в CI эти проверки + # не бежали ни разу с момента написания. + # + # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные + # таблицы (CREATE TEMP TABLE) и не трогают ни geometry, ни реальную схему — + # проверено локально, 16 passed за 1.3с. Поэтому и bootstrap схемы здесь не + # нужен, в отличие от tradein-лэйна. + # + # TEST_DATABASE_URL НАМЕРЕННО НЕ задаётся: на него завязан tests/integration/ + # (phantom-column gate), которому нужна КОПИЯ ПРОДОВОЙ схемы через pg_dump по + # SSH-туннелю. Пустой контейнер дал бы там красноту на пустом месте, поэтому + # integration остаётся честно пропущенным — с причиной в логе (`-rs`). defaults: run: working-directory: backend @@ -76,14 +90,53 @@ jobs: # TESTING=1 активирует RBAC-bypass (app/main.py rbac_guard пропускает # запросы при settings.testing=True) — иначе 401 на всём /api/v1. TESTING: "1" - # Stub DSN: psycopg v3 требует parseable URL на импорте; реального коннекта - # нет — DB-тесты мокаются, real-DB тест (tests/sql/) self-skip'ается через - # connectivity-probe к этому хосту (5432 недоступен → skip). - DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test REDIS_URL: redis://localhost:6379/0 + # Имя контейнера уникально на прогон: параллельные PR не дерутся за него. + CI_PG: ci-pg-backend-${{ github.run_id }} steps: - uses: actions/checkout@v4 + - name: Поднять Postgres для тестов + working-directory: . + # ПОЧЕМУ НЕ `services:` И ПОЧЕМУ БЕЗ ПУБЛИКАЦИИ ПОРТА — подробный разбор в + # ci-tradein.yml (тот же раннер). Кратко: job и сервис-контейнеры идут с + # `--network host`, а на 5432 этого хоста слушает ПРОДОВЫЙ Postgres, то + # есть `localhost:5432` из job'а — боевая база. Поднимаем контейнер сами, + # в bridge-сети, без публикации порта, ходим по его IP. + # + # `pg_isready -h 127.0.0.1`, а не через unix-сокет: по сокету отвечает + # ВРЕМЕННЫЙ сервер фазы initdb (listen_addresses=''), после которой БД + # ещё перезапускается. Проба по TCP зеленеет только на настоящем сервере. + # + # plain postgres:16, БЕЗ PostGIS: тесты tests/sql/ строят себе временные + # таблицы и не трогают ни geometry, ни реальную схему — bootstrap схемы + # здесь не нужен вовсе, в отличие от tradein-лэйна. + run: | + set -u + docker rm -f "$CI_PG" >/dev/null 2>&1 || true + docker run -d --name "$CI_PG" \ + -e POSTGRES_DB=gendesign_ci -e POSTGRES_USER=gendesign -e POSTGRES_PASSWORD=gendesign \ + postgres:16 + + ready="" + for _ in $(seq 1 45); do + if docker exec "$CI_PG" pg_isready -h 127.0.0.1 -U gendesign -q 2>/dev/null; then + ready=1; break + fi + [ "$(docker inspect -f '{{.State.Status}}' "$CI_PG" 2>/dev/null)" = "running" ] || break + sleep 2 + done + if [ -z "$ready" ]; then + echo "::error::Postgres не поднялся; статус=$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' "$CI_PG" 2>&1)" + docker logs --tail 50 "$CI_PG" 2>&1 || true + exit 1 + fi + + ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CI_PG") + [ -n "$ip" ] || { echo "::error::не удалось узнать IP контейнера $CI_PG"; exit 1; } + echo "DATABASE_URL=postgresql+psycopg://gendesign:gendesign@${ip}:5432/gendesign_ci" >> "$GITHUB_ENV" + echo "✓ Postgres на ${ip}:5432 (контейнер $CI_PG)" + - name: Set up Python uses: actions/setup-python@v5 with: @@ -136,10 +189,13 @@ jobs: # но --ignore — belt-and-suspenders на случай сбора фикстур). # tests/integration self-skip'ается через requires_test_db (skipif на # TEST_DATABASE_URL, который тут не задан) → НЕ игнорим, оно чисто skip'ается. - # tests/sql/ mv_layout self-skip'ается через Postgres-connectivity probe - # (5432 недоступен в этом mock-lane) → SKIP. Это intended. + # tests/sql/ теперь РЕАЛЬНО ИДУТ — postgres-контейнер выше (#2745). # PDF-тесты ИДУТ (libpango выше). Target: 0 failed, skips OK. # + # `-rs` (#2745): каждый оставшийся пропуск печатает причину. Под `-q` без + # него пропуск неотличим от прогона — именно так проверка тихо перестаёт + # исполняться и об этом узнают, когда на неё надо опереться (#2722/#2729/#2740). + # # Coverage-gate (#68): --cov=app меряет покрытие пакета app/. # --cov-fail-under=65 → job RED если покрытие упало ниже baseline # (измерено 2026-06: mock-lane сьют ~71%, см. [tool.coverage] в pyproject; @@ -148,7 +204,7 @@ jobs: # coverage.xml — артефакт для будущего Codecov/Coveralls upload (#68 badge). # term-missing → видно непокрытые строки прямо в job-логе. run: | - uv run pytest -q --ignore=tests/smoke \ + uv run pytest -q -rs --ignore=tests/smoke \ --cov=app \ --cov-report=term-missing:skip-covered \ --cov-report=xml:coverage.xml \ @@ -169,6 +225,13 @@ jobs: echo "$report" fi + - name: Снести тестовый Postgres + # if: always() — контейнер уходит и когда сьют красный, и когда прогон + # отменён concurrency-группой. Иначе на раннере копятся мёртвые контейнеры. + if: always() + working-directory: . + run: docker rm -f "$CI_PG" >/dev/null 2>&1 || true + frontend-tests: runs-on: ubuntu-latest needs: changes diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4a5fb6f2..a3266cb6 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,7 @@ NB: RBAC-гейт (app/main.py `rbac_guard`) требует заголовок ` from __future__ import annotations import os +from pathlib import Path import pytest @@ -40,3 +41,70 @@ def _clear_dependency_overrides(): app.dependency_overrides.clear() except Exception: pass + + +# ── Бюджет пропусков (#2745) ────────────────────────────────────────────────── +# +# Три инцидента за одни сутки (#2722, #2729, #2740) — один класс дефекта: +# проверка, которая тихо не исполняется, со временем перестаёт быть верной, и +# узнают об этом ровно тогда, когда на неё понадобилось опереться. `-rs` в CI +# делает пропуск ГРОМКИМ; этот хук делает его УЧТЁННЫМ — пропущено может быть +# только то, что записано в `tests/skip_allowlist.txt`. Любой новый пропуск (в +# том числе возникший сам: отвалилась зависимость, исчезла БД, кто-то дописал +# skipif «пока починю») роняет прогон, пока его не внесут в список осознанно. +# +# Список — НАДмножество: покрывает и CI (где есть Postgres и native-libs), и +# ноутбук разработчика (где их нет). Запись, чей пропуск не случился, — не +# ошибка; ошибка — пропуск без записи. +# +# Сюда же попадает xfail: pytest рапортует его как skipped, поэтому xfail без +# `strict=True` (зеленеет и когда тест внезапно ПРОШЁЛ) тоже придётся объявить. +# +# Чего хук НЕ видит: `--deselect` / `-k` / `-m` — они убирают тест до прогона, +# отчёта о пропуске не возникает вовсе. Против них работает правило «никаких +# deselect'ов в workflow» (см. .forgejo/workflows/ci-tradein.yml). + +_SKIP_ALLOWLIST_PATH = Path(__file__).parent / "skip_allowlist.txt" +_observed_skips: set[str] = set() + + +def _allowed_skips() -> set[str]: + if not _SKIP_ALLOWLIST_PATH.exists(): + return set() + lines = _SKIP_ALLOWLIST_PATH.read_text(encoding="utf-8").splitlines() + return {stripped for line in lines if (stripped := line.split("#", 1)[0].strip())} + + +def _record_skip(report) -> None: + if report.skipped: + # nodeid без «[параметров]»: список фиксирует, какая ПРОВЕРКА может + # отсутствовать, а не каждую её параметризацию. + _observed_skips.add(report.nodeid.split("[", 1)[0]) + + +def pytest_runtest_logreport(report) -> None: + _record_skip(report) + + +def pytest_collectreport(report) -> None: + # Модульный pytest.skip(allow_module_level=True) приходит сюда, а не в + # logreport, и nodeid у него — путь файла. + _record_skip(report) + + +def pytest_sessionfinish(session, exitstatus) -> None: + unlisted = sorted(_observed_skips - _allowed_skips()) + if not unlisted: + return + print( + f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не " + f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:" + ) + for nodeid in unlisted: + print(f" - {nodeid}") + print( + "Почини тест либо внеси его в skip_allowlist.txt с причиной — " + "пропуск без записи неотличим от пройденной проверки." + ) + if exitstatus == 0: + session.exitstatus = 1 diff --git a/backend/tests/skip_allowlist.txt b/backend/tests/skip_allowlist.txt new file mode 100644 index 00000000..6fda8d13 --- /dev/null +++ b/backend/tests/skip_allowlist.txt @@ -0,0 +1,89 @@ +# Объявленные пропуски сьюта backend/. +# +# Пропуск, которого здесь нет, роняет прогон (хук в tests/conftest.py). Смысл: не +# запретить пропуски, а запретить НЕЗАМЕТНЫЕ. Проверка, которая тихо перестала +# исполняться, со временем расходится с кодом, и узнают об этом ровно тогда, +# когда на неё понадобилось опереться (#2722, #2729, #2740). +# +# Формат: один nodeid в строке, без «[параметров]»; `#` — комментарий. +# Прежде чем добавить строку, ответь: почему эту проверку нельзя выполнить ЗДЕСЬ +# и где она выполняется вместо этого. Нет ответа — это не пропуск, а дыра. +# +# Список — НАДмножество сред: на CI (ubuntu + libpango + postgres-сервис) часть +# записей не срабатывает, на ноутбуке без них — срабатывает. Запись, чей пропуск +# не случился, безвредна; пропуск без записи — нет. + +# ── WeasyPrint: нужны native-либы (Pango/cairo/GObject) ─────────────────────── +# В CI ЭТИ ТЕСТЫ ИДУТ: ci.yml ставит libcairo2/libpango-1.0-0/libpangoft2-1.0-0. +# Записи нужны только для macOS-dev, где libgobject-2.0-0 нет. +tests/api/v1/test_parcels_forecast.py::test_export_forecast_pdf_returns_pdf +tests/services/exporters/test_report_pdf.py::TestConfidenceSectionParity::test_dict_shaped_factor_produces_valid_pdf +tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_accepts_as_dict_input +tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_non_trivial_length +tests/services/exporters/test_report_pdf.py::TestFullReportExport::test_returns_pdf_magic_bytes +tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_empty_report_still_valid_pdf +tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_garbage_input_does_not_crash +tests/services/exporters/test_report_pdf.py::TestGracefulPartialReport::test_partial_report_some_sections +tests/services/generative/test_exporters.py::test_pdf_export_produces_pdf_bytes +tests/test_layout_tz_pdf.py + +# ── phantom-column gate: нужна КОПИЯ ПРОДОВОЙ схемы ─────────────────────────── +# Смысл этих тестов — поймать колонку, которой нет в реальной БД, поэтому пустой +# сервис-контейнер их не заменяет: против пустой схемы они дали бы красноту, не +# относящуюся к делу. Условие: TEST_DATABASE_URL на SSH-туннель к проду +# (`ssh -N gendesign` → localhost:15432), см. tests/integration/conftest.py. +# ЗАПУСКАТЬ ВРУЧНУЮ после правок SQL-запросов в app/services/**. +tests/integration/test_analyze_parcels_sql.py::TestIrdOverlapSql::test_explain_ird_overlap +tests/integration/test_analyze_parcels_sql.py::TestNeighborsSummarySql::test_explain_neighbors_summary +tests/integration/test_phantom_columns.py::TestCadGeoTables::test_parcel_centroid_query +tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_avg_price_query +tests/integration/test_phantom_columns.py::TestDomrfKnFlats::test_supply_batch_query +tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_full_cte_query +tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_competitors_radius_query +tests/integration/test_phantom_columns.py::TestDomrfKnObjects::test_velocity_competitor_query_columns +tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rns_columns +tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_permits_rve_columns +tests/integration/test_phantom_columns.py::TestEkburgConstructionPermits::test_recent_permits_query +tests/integration/test_phantom_columns.py::TestMvLayoutVelocity::test_velocity_by_room_bucket +tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_corpus_room_month_columns +tests/integration/test_phantom_columns.py::TestObjectiveTables::test_objective_mapping_columns +tests/integration/test_phantom_columns.py::TestObjectiveTables::test_sold_count_query + +# ── SQL-логика на живой Postgres ────────────────────────────────────────────── +# В CI ЭТИ ТЕСТЫ ИДУТ с #2745 (postgres-сервис в ci.yml); до него не бежали ни +# разу с момента написания. Схема не нужна — строят себе временные таблицы. +# Локально: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=... postgres:16 +tests/sql/test_ddu_price_indicator.py::test_basis_and_previous_index +tests/sql/test_ddu_price_indicator.py::test_packaged_deal_bucketed_by_per_unit_area +tests/sql/test_ddu_price_indicator.py::test_prev_period_value_skips_filtered_quarter +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_hand_computed_weighted_average +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_no_zero_months_weighted_equals_naive +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_nullif_prevents_division_by_zero +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_weighted_differs_from_naive_sparse_project +tests/sql/test_mv_layout_velocity_weighted_avg.py::TestWeightedAvgFormula::test_zero_deal_months_skew_naive_avg +tests/sql/test_velocity_alerts.py::test_lookback_anchors_to_latest_data_month +tests/sql/test_velocity_alerts.py::test_sharp_drop_is_flagged + +# ── Живая сеть NSPD (nspd.gov.ru) ───────────────────────────────────────────── +# Ходят в внешний сервис: в гейте это флейк и зависимость от чужого аптайма. +# Запуск вручную: uv run pytest tests/scrapers/ -m slow / -m integration -s +tests/scrapers/test_nspd_bulk_client.py::test_list_objects_in_building_real +tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_empty +tests/scrapers/test_nspd_bulk_client.py::test_search_by_quarter_real_ekb +tests/scrapers/test_nspd_bulk_client.py::test_wms_feature_info_real +tests/scrapers/test_nspd_grid_walk.py::test_live_nspd_grid_walk_skipped + +# ── Образцы, которых нет в git ──────────────────────────────────────────────── +# ДОЛГ, а не норма: парсеры проверяются на реальных дампах, которые лежат только +# в untracked `.playwright-mcp/`. Пока файлы не в репозитории, эти 8 проверок не +# исполняются нигде. Правильная починка — закоммитить образцы в tests/fixtures/ +# (как сделано в tradein-mvp/backend/tests/fixtures/*.html), после чего строки +# отсюда убрать. +tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_no_duplicate_site_names +tests/services/scrapers/test_ekb_krt_registry.py::TestMultiSiteCountFromDump::test_site_count_from_dump +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_area +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_phasing_has_rows +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_raw_tables_not_empty +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_tep_has_rows +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_itogo +tests/services/scrapers/test_ekb_ppt_tep_parser.py::TestParsePptTepRealPdf::test_zone_balance_has_rows diff --git a/tradein-mvp/backend/tests/conftest.py b/tradein-mvp/backend/tests/conftest.py index f9d02c57..9651c161 100644 --- a/tradein-mvp/backend/tests/conftest.py +++ b/tradein-mvp/backend/tests/conftest.py @@ -9,6 +9,7 @@ from __future__ import annotations import sys +from pathlib import Path import pytest @@ -57,3 +58,70 @@ def _no_leaked_password_verify_slots(): f"тест оставил {inflight} занятых слотов проверки пароля (по ключам: {by_key}) — " "утечка слота при пуле в 1 поток это вечный 429 всем на входе" ) + + +# ── Бюджет пропусков (#2745) ────────────────────────────────────────────────── +# +# Три инцидента за одни сутки (#2722, #2729, #2740) — один класс дефекта: +# проверка, которая тихо не исполняется, со временем перестаёт быть верной, и +# узнают об этом ровно тогда, когда на неё понадобилось опереться. `-rs` в CI +# делает пропуск ГРОМКИМ; этот хук делает его УЧТЁННЫМ — пропущено может быть +# только то, что записано в `tests/skip_allowlist.txt`. Любой новый пропуск (в +# том числе возникший сам: отвалилась зависимость, исчезла БД, кто-то дописал +# skipif «пока починю») роняет прогон, пока его не внесут в список осознанно. +# +# Список — НАДмножество: покрывает и CI (где есть Postgres и native-libs), и +# ноутбук разработчика (где их нет). Запись, чей пропуск не случился, — не +# ошибка; ошибка — пропуск без записи. +# +# Сюда же попадает xfail: pytest рапортует его как skipped, поэтому xfail без +# `strict=True` (зеленеет и когда тест внезапно ПРОШЁЛ) тоже придётся объявить. +# +# Чего хук НЕ видит: `--deselect` / `-k` / `-m` — они убирают тест до прогона, +# отчёта о пропуске не возникает вовсе. Против них работает правило «никаких +# deselect'ов в workflow» (см. .forgejo/workflows/ci-tradein.yml). + +_SKIP_ALLOWLIST_PATH = Path(__file__).parent / "skip_allowlist.txt" +_observed_skips: set[str] = set() + + +def _allowed_skips() -> set[str]: + if not _SKIP_ALLOWLIST_PATH.exists(): + return set() + lines = _SKIP_ALLOWLIST_PATH.read_text(encoding="utf-8").splitlines() + return {stripped for line in lines if (stripped := line.split("#", 1)[0].strip())} + + +def _record_skip(report) -> None: + if report.skipped: + # nodeid без «[параметров]»: список фиксирует, какая ПРОВЕРКА может + # отсутствовать, а не каждую её параметризацию. + _observed_skips.add(report.nodeid.split("[", 1)[0]) + + +def pytest_runtest_logreport(report) -> None: + _record_skip(report) + + +def pytest_collectreport(report) -> None: + # Модульный pytest.skip(allow_module_level=True) приходит сюда, а не в + # logreport, и nodeid у него — путь файла. + _record_skip(report) + + +def pytest_sessionfinish(session, exitstatus) -> None: + unlisted = sorted(_observed_skips - _allowed_skips()) + if not unlisted: + return + print( + f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не " + f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:" + ) + for nodeid in unlisted: + print(f" - {nodeid}") + print( + "Почини тест либо внеси его в skip_allowlist.txt с причиной — " + "пропуск без записи неотличим от пройденной проверки." + ) + if exitstatus == 0: + session.exitstatus = 1 diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt new file mode 100644 index 00000000..5c0b9a20 --- /dev/null +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -0,0 +1,39 @@ +# Объявленные пропуски сьюта tradein-mvp/backend. +# +# Пропуск, которого здесь нет, роняет прогон (хук в tests/conftest.py). Смысл: не +# запретить пропуски, а запретить НЕЗАМЕТНЫЕ. Проверка, которая тихо перестала +# исполняться, со временем расходится с кодом, и узнают об этом ровно тогда, +# когда на неё понадобилось опереться (#2722, #2729, #2740). +# +# Формат: один nodeid в строке, без «[параметров]»; `#` — комментарий. +# Прежде чем добавить строку, ответь: почему эту проверку нельзя выполнить ЗДЕСЬ +# и где она выполняется вместо этого. Нет ответа — это не пропуск, а дыра. +# +# Список — НАДмножество сред: в CI Postgres есть (ci-tradein.yml поднимает +# сервис-контейнер и собирает схему из backend/data/sql/), на ноутбуке без БД — +# нет. Запись, чей пропуск не случился, безвредна; пропуск без записи — нет. + +# ── Реальный рендер PDF: нужны native-либы WeasyPrint (Pango/cairo/GObject) ──── +# Где выполняется: внутри prod-образа — runner-stage tradein-mvp/backend/Dockerfile +# ставит libcairo2 + libpango-1.0-0 + libpangoft2-1.0-0: +# docker exec tradein-backend python -m pytest -q -m pdf_render tests/test_pdf_real_render.py +# На голом ubuntu-latest в ci-tradein.yml этих либ нет (apt-шага в job'е нет). +tests/test_pdf_real_render.py + +# ── Тесты против живой Postgres ─────────────────────────────────────────────── +# В CI они ИДУТ (postgres-сервис в ci-tradein.yml, #2745) — записи ниже нужны +# только для прогона на машине без БД. Именно эта группа разъехалась со схемой, +# пока не бежала нигде: #2740 (houses.url стал NOT NULL) и #2744 +# (test_gar_flats_loader падал до первого утверждения). Поднять локально: +# docker run -d -p 5432:5432 -e POSTGRES_DB=tradein -e POSTGRES_USER=tradein \ +# -e POSTGRES_PASSWORD=tradein postgis/postgis:16-3.4 +# psql ... -f backend/data/sql/*.sql # порядок как в deploy-tradein.yml +tests/tasks/test_cadastral_geo_match.py::test_real_knn_nearest_within_threshold_picked +tests/test_audit_api.py::test_real_accounts_and_analytics_aggregate_inserted_rows +tests/test_gar_flats_loader.py::test_upsert_and_canon_match_populates_gar_flat_count +tests/test_house_dedup_merge.py::test_real_canon_clusterkey_and_geo_guard_merge_semantics +tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover +tests/test_house_dedup_merge.py::test_real_fias_pass_ignores_geo_guard +tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal +tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent +tests/test_user_events.py::test_real_record_event_inserts_row