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
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
119 lines
6.6 KiB
Python
119 lines
6.6 KiB
Python
"""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
|