gendesign/backend/tests/conftest.py
bot-backend 662ada3927
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m20s
CI / backend-tests (pull_request) Successful in 16m44s
test(ci): сторож пропусков кричит ::error:: под Actions
13.08 этот сторож четыре прогона подряд ронял backend-tests совершенно
правильно: два новых теста скипались без TEST_DATABASE_URL и не были
объявлены в skip_allowlist.txt. Но его строка лежала посреди тысячи
других — обычный print, по-русски, на 1014-й строке лога, за две секунды
до зелёной сводки pytest. Поиск по «FAILED / ERROR» её не находил.
Причину искали три часа: диск, раннер, покрытие, кэш, два невиновных шага.

Защита сработала, её не услышали. Под Actions дублируем сообщение
в ::error:: — Forgejo поднимает такие строки в аннотации job'ы.
Локально ничего не меняется: человеческий текст как был.

Тесты двусторонние: против conftest из main падает ровно новый
(test_guard_emits_error_annotation_under_actions), два контрольных —
«локально ::error:: нет» и «без незадекларированных пропусков молчит
и не роняет» — зелёные с обеих сторон.

Один хунк форматирования — не мой: pre-commit ruff v0.7.4 против 0.15.12 (#2864).

Refs #2871
2026-08-13 22:36:19 +05:00

119 lines
6.6 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.

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