gendesign/tradein-mvp/backend/tests/conftest.py
lekss361 d409e31f00
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m50s
Deploy Trade-In / build-backend (push) Successful in 4m23s
Deploy Trade-In / deploy (push) Successful in 2m32s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
feat(mera/b2c): анти-абуз для анонимного трафика — этап 2 из 8 (#2546)
2026-08-25 16:27:02 +00:00

152 lines
8 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 не включён, так что незарегистрированный
маркер только предупреждал бы) и сторожит глобальное состояние, которое
переживает отдельный тест: общий rate-limiter POST /estimate (см.
`_reset_estimate_rate_limiter`) и слоты проверки пароля (см.
`_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 _reset_estimate_rate_limiter() -> None:
"""`app.api.v1.trade_in._estimate_limiter` (#b2c-antiabuse-2) is a module-level
`SlidingWindowLimiter` singleton that accumulates hits across ALL tests
hitting POST /estimate within one pytest process (a dozen+ test files build
their own FastAPI app around `trade_in_module.router` — see grep for
`trade_in_module.router` under tests/). Without a reset, unrelated test
files could trip the 429 rate-limit purely from cross-test state leakage
(same class of issue `test_support.py::_fresh_rate_limiter` solves locally
for `_send_limiter` — this one needs to be global since so many files touch
the trade_in router). Lazy import: keeps conftest.py import-light and avoids
forcing DATABASE_URL to be set before any test module has had a chance to
default it.
"""
from app.api.v1 import trade_in as trade_in_module
from app.core.config import settings
from app.core.ratelimit import SlidingWindowLimiter
trade_in_module._estimate_limiter = SlidingWindowLimiter(
limit=settings.estimate_rate_limit, window_s=settings.estimate_rate_limit_window_s
)
@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