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