gendesign/backend/tests/conftest.py
bot-backend a068e4b612
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 4m24s
Deploy / build-worker (push) Successful in 4m55s
Deploy / deploy (push) Successful in 1m50s
test(ci): сторож пропусков кричит ::error:: под Actions (#2871) (#2876)
2026-08-13 21:05:54 +00:00

119 lines
6.6 KiB
Python
Raw Permalink 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.

"""Shared pytest fixtures для backend-сьюта.
NB: RBAC-гейт (app/main.py `rbac_guard`) требует заголовок `X-Authenticated-User`
на любом non-public пути — иначе 401. Тесты, бьющие по `app` через TestClient,
должны слать этот заголовок ЯВНО на запрос (напр. test_parcel_by_bbox), либо
тестировать именно no-auth путь (admin-тесты ждут 401 без заголовка). Глобально
заголовок НЕ инжектим — это ломает тесты, проверяющие отказ авторизации.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
# Включаем test-mode ДО любого импорта app: rbac_guard (app/main.py) пропускает
# запросы при settings.testing=True (юнит-тесты бьют по app мимо Caddy и не несут
# X-Authenticated-User → иначе 401 на всём api/v1). Ставим и env (на случай
# ре-инстанцирования Settings), и атрибут синглтона напрямую (надёжно независимо
# от порядка импортов). RBAC-логика (401/403) покрыта в test_rbac.py — там своя
# копия middleware, на settings.testing НЕ смотрит, поэтому остаётся валидной.
os.environ.setdefault("TESTING", "1")
from app.core.config import settings
settings.testing = True
@pytest.fixture(autouse=True)
def _clear_dependency_overrides():
"""Сбрасывать app.dependency_overrides после каждого теста (anti-leak).
Тест переопределяет get_db через app.dependency_overrides; без сброса override
течёт в следующий тест → ложные коннекты к реальной БД / флейки. Импорт app
локальный (ленивый) — не тянем тяжёлый app.main при сборе тестов без него.
"""
yield
try:
from app.main import app
app.dependency_overrides.clear()
except Exception:
pass
# ── Бюджет пропусков (#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
head = (
f"НЕУЧТЁННЫЙ ПРОПУСК ({len(unlisted)}): проверка не исполнилась и не "
f"объявлена в {_SKIP_ALLOWLIST_PATH.name}:"
)
print(f"\n{head}")
for nodeid in unlisted:
print(f" - {nodeid}")
print(
"Почини тест либо внеси его в skip_allowlist.txt с причиной — "
"пропуск без записи неотличим от пройденной проверки."
)
# #2871: под Actions дублируем в ::error:: — иначе сообщение тонет.
# 13.08 этот сторож четыре прогона подряд ронял job'у совершенно правильно,
# а его строка лежала посреди тысячи других (обычный print, по-русски) —
# и поиск по «FAILED / ERROR» её не находил. Причину искали три часа
# в диске, раннере, покрытии и кэше. Сторож, который роняет прогон,
# обязан кричать так, чтобы его нашли.
if os.environ.get("GITHUB_ACTIONS") or os.environ.get("CI"):
print(f"::error::{head} " + "; ".join(unlisted))
if exitstatus == 0:
session.exitstatus = 1