ci: пропуск теста обязан назвать себя — иначе прогон красный #2745

Merged
bot-backend merged 3 commits from chore/loud-skipped-tests into main 2026-08-06 17:52:57 +00:00
6 changed files with 441 additions and 12 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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