arm-core/api/tests/test_openapi_routes.py
Andrey Epifancev 070a398c65 fix(api): гонка уникальных ограничений отдаёт тот же 409 с кодом, что и pre-check (T6.5, T7.1, T8.2, T12.7, T15.3, T15.4)
Реестр имя ограничения PostgreSQL -> (код, постоянный текст) в common/constraint_conflicts.py;
integrity_error_handler для unique_violation (23505) по известному ограничению отдаёт
{"detail": {"code", "message"}}, иначе прежняя общая строка. Имя берётся из полей драйвера
(asyncpg __cause__.constraint_name, psycopg diag), не из текста ошибки; resolved_code пишется в лог.
Тесты: unit обработчика/реестра (ключи есть в Base.metadata), интеграционные гонки (ГК/ЮЛ crm_id,
ИНН, алиас, email, членство, откат и следующий запрос, FK остаётся общим 409).
T15.3: README шаг 4 импорта; T15.4: test_components_have_no_duplicated_models.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 02:02:14 +04:00

84 lines
4.2 KiB
Python

"""OpenAPI содержит все маршруты Среза 1 (specs directories-api, directory-import, users-admin) без дублей моделей.
Схема строится из объявленных маршрутов без БД и сети. Состав маршрутов — по спекам change `slice-1-directories`;
проверка нужна, чтобы маршрут не пропал при переносе роутеров и чтобы `RegionalOfficeOut` (R-13) был одной моделью.
"""
import importlib
from typing import Any
import pytest
app_module = importlib.import_module("src.app")
D = "/api/v1/directories"
A = "/api/v1/admin"
# путь → методы (нижний регистр, как в OpenAPI)
EXPECTED_ROUTES: dict[str, set[str]] = {
"/api/v1/me": {"get"},
# directories-api: филиалы, ГК, ГСК/ГСЗ, ЮЛ/ИП
f"{D}/regional-offices": {"get"},
f"{D}/company-groups": {"get", "post"},
f"{D}/company-groups/{{group_id}}": {"get", "patch"},
f"{D}/company-groups/{{group_id}}/archive": {"post"},
f"{D}/company-groups/{{group_id}}/unarchive": {"post"},
f"{D}/company-groups/{{group_id}}/history": {"get"},
f"{D}/company-groups/{{company_group_id}}/related-groups": {"get", "post"},
f"{D}/related-groups/{{group_id}}": {"get", "patch", "delete"},
f"{D}/related-groups/{{group_id}}/members": {"post"},
f"{D}/related-groups/{{group_id}}/members/{{legal_entity_id}}": {"delete"},
f"{D}/legal-entities": {"get", "post"},
f"{D}/legal-entities/{{entity_id}}": {"get", "patch"},
f"{D}/legal-entities/{{entity_id}}/move": {"post"},
f"{D}/legal-entities/{{entity_id}}/withdraw": {"post"},
f"{D}/legal-entities/{{entity_id}}/history": {"get"},
f"{D}/legal-entities/{{entity_id}}/aliases": {"post"},
f"{D}/legal-entities/{{entity_id}}/aliases/{{alias_id}}": {"delete"},
# directory-import
f"{D}/import": {"post"},
# users-admin
f"{A}/users": {"get", "post"},
f"{A}/users/{{user_id}}": {"patch"},
f"{A}/users/{{user_id}}/roles": {"put"},
f"{A}/roles": {"get"},
f"{A}/permissions": {"get"},
f"{A}/roles/{{code}}/permissions": {"put"},
}
# Модели, которые обязаны быть в схеме ровно один раз под коротким именем
SINGLE_MODELS = ["RegionalOfficeOut", "CompanyGroupOut", "LegalEntityOut", "UserOut", "RoleOut", "ImportReportOut"]
@pytest.fixture(scope="module")
def schema() -> dict[str, Any]:
spec: dict[str, Any] = app_module.app.openapi()
return spec
@pytest.mark.parametrize("path", sorted(EXPECTED_ROUTES))
def test_spec_route_is_in_openapi(schema: dict[str, Any], path: str) -> None:
assert path in schema["paths"], f"в OpenAPI нет маршрута {path}"
assert set(schema["paths"][path]) >= EXPECTED_ROUTES[path], (path, sorted(schema["paths"][path]))
def test_no_unexpected_routes_under_directories_and_admin(schema: dict[str, Any]) -> None:
"""Маршрутов сверх спек нет: новые маршруты сначала попадают в спеку и в этот список."""
actual = {path for path in schema["paths"] if path.startswith((D, A))}
expected = {path for path in EXPECTED_ROUTES if path.startswith((D, A))}
assert actual == expected
for path in actual:
assert set(schema["paths"][path]) == EXPECTED_ROUTES[path], path
def test_components_have_no_duplicated_models(schema: dict[str, Any]) -> None:
"""Одна модель — одно имя: нет автоимён вида `src__schemas__x__RegionalOfficeOut` и суффиксов `-Input`/`-Output`.
Имена — ключи словаря, поэтому дубликатов быть не может; реальный страж — проверка на `__`/`-` (FastAPI даёт такие
имена, когда две разные модели совпали по имени или одна модель разошлась на вход/выход).
"""
names = list(schema["components"]["schemas"])
for model in SINGLE_MODELS:
assert model in names, model
assert not [name for name in names if "__" in name or "-" in name], names