Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Has been cancelled
Deploy Trade-In / test (push) Successful in 4m28s
Deploy Trade-In / build-backend (push) Successful in 2m0s
200 lines
12 KiB
Python
200 lines
12 KiB
Python
"""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`) и синглтон Telegram-клиента (см.
|
||
`_reset_telegram_shared_client`, #3471 — иначе его rate limiter копит
|
||
реальное время между тестами и вешает прогон).
|
||
"""
|
||
|
||
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 _reset_telegram_shared_client():
|
||
"""`app.services.tgbot.shared._client` — модульный синглтон `TelegramClient`
|
||
(#3471). Его `TelegramGroupRateLimiter` копит РЕАЛЬНЫЕ метки времени
|
||
(`time.monotonic()`, ничем не замоканные) по `chat_id` за весь pytest-процесс,
|
||
а не по тесту — а тестовые настройки `telegram_alerts_chat_id`/
|
||
`telegram_support_chat_id` дефолтятся в 0, так что ЛЮБЫЕ тесты, бьющие в
|
||
`app.api.v1.support`/`glitchtip` через реальный (не замоканный) shared-клиент,
|
||
делят ОДИН и тот же ключ бакета. После N-й (лимит роли, по умолчанию 12)
|
||
такой отправки в пределах 60 реальных секунд следующая уходит в настоящий
|
||
`asyncio.sleep` до 60с — тест не падает, а зависает, и именно так выглядела
|
||
смерть CI-джобы на #3494 (обрыв на ~9%, 75с жизни, ни строки об ошибке;
|
||
`pytest-timeout` затем добивает зависший тест снаружи).
|
||
|
||
Фикстура не выключает и не завышает лимит (в проде он ДОЛЖЕН оставаться
|
||
ниже площадочного потолка) — она просто гарантирует каждому тесту СВЕЖИЙ
|
||
клиент (и тем самым свежий, пустой `TelegramGroupRateLimiter`), так же как
|
||
`_reset_estimate_rate_limiter` выше делает для `_estimate_limiter`. Сброс
|
||
и ДО, и ПОСЛЕ теста — тест мог создать клиент через `get_telegram_client()`,
|
||
не пройдя явный локальный `_reset_singleton` (см. `test_shared.py`), и не
|
||
должен оставить накопленное состояние следующему тесту.
|
||
"""
|
||
from app.services.tgbot import shared
|
||
|
||
shared._client = None
|
||
yield
|
||
shared._client = None
|
||
|
||
|
||
@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
|
||
|
||
|
||
# ── Никакого НАСТОЯЩЕГО сна в тестах ─────────────────────────────────────────
|
||
# Фоновый дослальщик алертов GlitchTip (#3471) спит между попытками по
|
||
# настоящим часам: три попытки по 30 секунд. `TestClient` из Starlette ждёт
|
||
# завершения background-задачи, привязанной к ответу, поэтому один-единственный
|
||
# тест на отказ доставки держал весь прогон около минуты, а в CI прогон просто
|
||
# умирал по таймауту без единой строки об ошибке.
|
||
#
|
||
# Ставим паузу в ноль для ВСЕХ тестов: проверять надо, что дослальщик вызван и
|
||
# сколько раз, а не то, что интерпретатор умеет спать. Тест, которому нужна
|
||
# настоящая пауза, переопределяет значение сам.
|
||
@pytest.fixture(autouse=True)
|
||
def _no_real_retry_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
from app.tasks import glitchtip_alert_retry
|
||
|
||
monkeypatch.setattr(glitchtip_alert_retry, "_RETRY_DELAY_S", 0.0)
|