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 и печатает неучтённые.
127 lines
6.7 KiB
Python
127 lines
6.7 KiB
Python
"""Repo-wide test config for tradein-mvp/backend.
|
||
|
||
Регистрирует кастомные pytest-маркеры (иначе PytestUnknownMarkWarning:
|
||
`--strict-markers` в pyproject.toml не включён, так что незарегистрированный
|
||
маркер только предупреждал бы) и сторожит глобальное состояние, которое
|
||
переживает отдельный тест, — см. `_no_leaked_password_verify_slots`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
|
||
def pytest_configure(config) -> None:
|
||
config.addinivalue_line(
|
||
"markers",
|
||
"pdf_render: real (non-mocked) WeasyPrint render — needs native "
|
||
"Pango/cairo/GObject libs, self-skips where unavailable (see "
|
||
"tests/test_pdf_real_render.py docstring for how to run it for real).",
|
||
)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _no_leaked_password_verify_slots():
|
||
"""Тест не оставляет за собой занятых слотов проверки пароля (#2665, #2714).
|
||
|
||
Счётчики в `app.core.password` — состояние ПРОЦЕССА, а `pytest-asyncio` даёт
|
||
каждому тесту свой событийный цикл. Слот освобождает колбэк, посланный в
|
||
цикл через `call_soon_threadsafe`; если цикл к тому моменту закрыт,
|
||
`_schedule_verify_slot_release` ловит RuntimeError и слот не возвращается
|
||
никогда. На проде цикл живёт столько же, сколько процесс, и ветка
|
||
недостижима — а в тестах она копится молча и роняет НЕ ТОТ тест, который
|
||
её устроил: при пуле в 1 поток пары утечек хватает, чтобы всё дальнейшее
|
||
получало 429 «на ровном месте».
|
||
|
||
Поэтому проверка тут и общая: считаем слоты после каждого теста.
|
||
|
||
`sys.modules.get`, а не import: тестам, которые password.py не трогают
|
||
(большинство), незачем тянуть `Settings()` с его требованием DATABASE_URL.
|
||
"""
|
||
yield
|
||
|
||
password_mod = sys.modules.get("app.core.password")
|
||
if password_mod is None:
|
||
return
|
||
|
||
inflight = password_mod._verify_inflight
|
||
by_key = dict(password_mod._verify_inflight_by_key)
|
||
# Сброс ДО assert: иначе одна утечка красит все последующие тесты и виновник
|
||
# теряется среди пострадавших.
|
||
password_mod._verify_inflight = 0
|
||
password_mod._verify_inflight_by_key.clear()
|
||
|
||
assert inflight == 0 and not by_key, (
|
||
f"тест оставил {inflight} занятых слотов проверки пароля (по ключам: {by_key}) — "
|
||
"утечка слота при пуле в 1 поток это вечный 429 всем на входе"
|
||
)
|
||
|
||
|
||
# ── Бюджет пропусков (#2745) ──────────────────────────────────────────────────
|
||
#
|
||
# Три инцидента за одни сутки (#2722, #2729, #2740) — один класс дефекта:
|
||
# проверка, которая тихо не исполняется, со временем перестаёт быть верной, и
|
||
# узнают об этом ровно тогда, когда на неё понадобилось опереться. `-rs` в CI
|
||
# делает пропуск ГРОМКИМ; этот хук делает его УЧТЁННЫМ — пропущено может быть
|
||
# только то, что записано в `tests/skip_allowlist.txt`. Любой новый пропуск (в
|
||
# том числе возникший сам: отвалилась зависимость, исчезла БД, кто-то дописал
|
||
# skipif «пока починю») роняет прогон, пока его не внесут в список осознанно.
|
||
#
|
||
# Список — НАДмножество: покрывает и CI (где есть Postgres и native-libs), и
|
||
# ноутбук разработчика (где их нет). Запись, чей пропуск не случился, — не
|
||
# ошибка; ошибка — пропуск без записи.
|
||
#
|
||
# Сюда же попадает xfail: pytest рапортует его как skipped, поэтому xfail без
|
||
# `strict=True` (зеленеет и когда тест внезапно ПРОШЁЛ) тоже придётся объявить.
|
||
#
|
||
# Чего хук НЕ видит: `--deselect` / `-k` / `-m` — они убирают тест до прогона,
|
||
# отчёта о пропуске не возникает вовсе. Против них работает правило «никаких
|
||
# deselect'ов в workflow» (см. .forgejo/workflows/ci-tradein.yml).
|
||
|
||
_SKIP_ALLOWLIST_PATH = Path(__file__).parent / "skip_allowlist.txt"
|
||
_observed_skips: set[str] = set()
|
||
|
||
|
||
def _allowed_skips() -> set[str]:
|
||
if not _SKIP_ALLOWLIST_PATH.exists():
|
||
return set()
|
||
lines = _SKIP_ALLOWLIST_PATH.read_text(encoding="utf-8").splitlines()
|
||
return {stripped for line in lines if (stripped := line.split("#", 1)[0].strip())}
|
||
|
||
|
||
def _record_skip(report) -> None:
|
||
if report.skipped:
|
||
# nodeid без «[параметров]»: список фиксирует, какая ПРОВЕРКА может
|
||
# отсутствовать, а не каждую её параметризацию.
|
||
_observed_skips.add(report.nodeid.split("[", 1)[0])
|
||
|
||
|
||
def pytest_runtest_logreport(report) -> None:
|
||
_record_skip(report)
|
||
|
||
|
||
def pytest_collectreport(report) -> None:
|
||
# Модульный pytest.skip(allow_module_level=True) приходит сюда, а не в
|
||
# logreport, и nodeid у него — путь файла.
|
||
_record_skip(report)
|
||
|
||
|
||
def pytest_sessionfinish(session, exitstatus) -> None:
|
||
unlisted = sorted(_observed_skips - _allowed_skips())
|
||
if not unlisted:
|
||
return
|
||
print(
|
||
f"\nНЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не "
|
||
f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:"
|
||
)
|
||
for nodeid in unlisted:
|
||
print(f" - {nodeid}")
|
||
print(
|
||
"Почини тест либо внеси его в skip_allowlist.txt с причиной — "
|
||
"пропуск без записи неотличим от пройденной проверки."
|
||
)
|
||
if exitstatus == 0:
|
||
session.exitstatus = 1
|