gendesign/tradein-mvp/backend/tests/conftest.py
bot-backend 1fa3cb6949
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
ci: пропуск теста обязан назвать себя — иначе прогон красный
Общее у #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 и печатает неучтённые.
2026-08-06 22:10:08 +05:00

127 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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