Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Failing after 21s
CI Trade-In / browser-tests (pull_request) Successful in 51s
CI Trade-In / frontend-checks (pull_request) Successful in 1m31s
CI / frontend-tests (pull_request) Successful in 1m20s
CI / openapi-codegen-check (pull_request) Successful in 2m3s
CI / backend-tests (pull_request) Successful in 15m46s
Общее у #2722, #2729 и #2740 — не три разных бага, а один: пропуск, которого не видно. Под `pytest -q` пропуск рисуется точкой `s`, неотличимой от прогона, и проверка годами «зелёная», ничего не проверяя. Три меры, от дешёвой к жёсткой. 1. `-rs` во всех pytest-шагах (ci.yml, ci-tradein.yml backend+browser). Каждый пропуск печатает причину в лог job'а. Одна опция — и молчаливых пропусков больше нет ни одного. 2. Postgres-сервисы вместо заглушечных DSN. ci-tradein: DATABASE_URL вёл на заведомо мёртвый localhost:5432/test, и девять тестов с `_live_session()` self-skip'ались — в CI не бежали НИ РАЗУ. Именно так #2740 разъехался со схемой (houses.url стал NOT NULL), а test_gar_flats_loader падал до первого утверждения (#2744). Теперь postgis-сервис + сборка схемы из backend/data/sql/ тем же строгим циклом, что в deploy-tradein.yml (ON_ERROR_STOP, падение миграции → job RED). Единственное исключение названо вслух в коде: 077 — backfill через postgres_fdw к БД другого стека, которой в CI нет. ci.yml: plain postgres:16 (без PostGIS — tests/sql/ строят себе временные таблицы) поднимает 10 тестов SQL-логики (#17, #99, #295), не бежавших с момента написания. TEST_DATABASE_URL намеренно НЕ задан: на нём висит phantom-column gate, которому нужна копия ПРОДОВОЙ схемы, и пустой контейнер дал бы там красноту на пустом месте. Замер до включения: tradein 122с/3858 passed/10 skipped на моке против 106с/3867 passed/1 skipped на живой БД; backend 783с/4594/48 против 723с/4604/38. Живая БД не медленнее — поэтому не второй job, а починка существующего. Накладные: подъём сервиса + bootstrap схемы (219 файлов, ~20с в tradein; в backend схема не нужна вовсе). 3. skip_allowlist.txt + хук в conftest обоих сьютов. Пропущено может быть только то, что объявлено с причиной. Любой новый пропуск — дописал кто-то skipif «пока починю», отвалилась зависимость, исчезла БД — роняет прогон. Список это ещё и инвентарь: против каждой записи сказано, почему проверку нельзя выполнить здесь и где она выполняется вместо этого. Сюда же попадает xfail (pytest рапортует его как skipped), так что xfail без strict=True тоже придётся объявить. Список — надмножество сред: в CI часть записей не срабатывает, на ноутбуке без Postgres и native-libs — срабатывает; лишняя запись безвредна, пропуск без записи — нет. Проверено локально в конфигурации, которую задаёт этот PR: backend 4604 passed / 38 skipped / exit 0, tradein 3858 passed / 10 skipped / exit 0; при удалённом allowlist тот же прогон даёт exit 1 и печатает неучтённые.
110 lines
5.8 KiB
Python
110 lines
5.8 KiB
Python
"""Shared pytest fixtures для backend-сьюта.
|
||
|
||
NB: RBAC-гейт (app/main.py `rbac_guard`) требует заголовок `X-Authenticated-User`
|
||
на любом non-public пути — иначе 401. Тесты, бьющие по `app` через TestClient,
|
||
должны слать этот заголовок ЯВНО на запрос (напр. test_parcel_by_bbox), либо
|
||
тестировать именно no-auth путь (admin-тесты ждут 401 без заголовка). Глобально
|
||
заголовок НЕ инжектим — это ломает тесты, проверяющие отказ авторизации.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
# Включаем test-mode ДО любого импорта app: rbac_guard (app/main.py) пропускает
|
||
# запросы при settings.testing=True (юнит-тесты бьют по app мимо Caddy и не несут
|
||
# X-Authenticated-User → иначе 401 на всём api/v1). Ставим и env (на случай
|
||
# ре-инстанцирования Settings), и атрибут синглтона напрямую (надёжно независимо
|
||
# от порядка импортов). RBAC-логика (401/403) покрыта в test_rbac.py — там своя
|
||
# копия middleware, на settings.testing НЕ смотрит, поэтому остаётся валидной.
|
||
os.environ.setdefault("TESTING", "1")
|
||
from app.core.config import settings
|
||
|
||
settings.testing = True
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clear_dependency_overrides():
|
||
"""Сбрасывать app.dependency_overrides после каждого теста (anti-leak).
|
||
|
||
Тест переопределяет get_db через app.dependency_overrides; без сброса override
|
||
течёт в следующий тест → ложные коннекты к реальной БД / флейки. Импорт app
|
||
локальный (ленивый) — не тянем тяжёлый app.main при сборе тестов без него.
|
||
"""
|
||
yield
|
||
try:
|
||
from app.main import app
|
||
|
||
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
|