arm-core/api/tests/test_openapi_routes.py

278 lines
15 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.

"""OpenAPI содержит все маршруты Срезов 1–4 (specs directories-api, directory-import, users-admin,
consolidations-api) без дублей моделей.
Схема строится из объявленных маршрутов без БД и сети. Состав маршрутов — по спекам 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}/accounts": {"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
"/api/v1/users/brief": {"get"},
# 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"},
# primary-files, documents-api (Срез 2c)
"/api/v1/legal-entities/{legal_entity_id}/files": {"get", "post"},
"/api/v1/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files": {"get", "post"}, # v5.18
"/api/v1/files/{file_id}": {"get", "delete"},
"/api/v1/files/{file_id}/content": {"get"},
"/api/v1/files/{file_id}/retry": {"post"},
"/api/v1/consolidations/versions/{version_id}/files/retry": {"post"},
"/api/v1/documents/{document_id}/osv-header": {"patch"},
"/api/v1/documents/{document_id}": {"get"},
"/api/v1/documents/{document_id}/entries": {"get"},
"/api/v1/documents/{document_id}/confirm": {"post"},
"/api/v1/documents/{document_id}/comments": {"get", "post"},
# форма ББ/ОФР (Срез 3b; PATCH и accept добавит Task 6)
"/api/v1/documents/{document_id}/statement": {"get", "patch"},
"/api/v1/documents/{document_id}/statement/checks/accept": {"post"},
# consolidations-api (Срез 4)
"/api/v1/consolidations": {"post"},
"/api/v1/company-groups/{group_id}/consolidations": {"get"},
"/api/v1/consolidations/versions/{version_id}": {"get"},
"/api/v1/consolidations/versions/{version_id}/lock": {"post", "delete"},
"/api/v1/consolidations/versions/{version_id}/status": {"post"},
"/api/v1/consolidations/versions/{version_id}/members": {"put"},
"/api/v1/consolidations/versions/{version_id}/checklist": {"get"},
"/api/v1/consolidations/versions/{version_id}/osv": {"get"},
"/api/v1/consolidations/versions/{version_id}/osv/summary": {"get"},
"/api/v1/consolidations/versions/{version_id}/osv/export": {"get"},
"/api/v1/consolidations/versions/{version_id}/counterparty-candidates": {"get"},
"/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty": {"post"},
# шахматка ВГО и правила элиминации
"/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual/export": {"post"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table": {"get"},
}
# Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно
CHECKED_PREFIXES = (
D,
A,
"/api/v1/users",
"/api/v1/legal-entities",
"/api/v1/files",
"/api/v1/documents",
"/api/v1/consolidations",
"/api/v1/company-groups",
)
# Модели, которые обязаны быть в схеме ровно один раз под коротким именем
SINGLE_MODELS = [
"RegionalOfficeOut",
"CompanyGroupOut",
"LegalEntityOut",
"UserOut",
"UserBriefOut",
"RoleOut",
"ImportReportOut",
"FileOut",
"DocumentOut",
"EntryOut",
"VersionCard",
"ConsolidationPeriodOut",
"LockOut",
"StatusIn",
]
@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(CHECKED_PREFIXES)}
expected = {path for path in EXPECTED_ROUTES if path.startswith(CHECKED_PREFIXES)}
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
def test_consolidation_routes_declare_documented_responses(schema: dict[str, Any]) -> None:
"""Создание — 201 с карточкой версии и ошибки 404/409/422; чтение карточки и списка — 404 (ГК вне видимости)."""
create = schema["paths"]["/api/v1/consolidations"]["post"]["responses"]
assert {"201", "404", "409", "422"} <= set(create)
assert create["201"]["content"]["application/json"]["schema"]["$ref"].endswith("/VersionCard")
card = schema["paths"]["/api/v1/consolidations/versions/{version_id}"]["get"]["responses"]
assert {"200", "404"} <= set(card)
listing = schema["paths"]["/api/v1/company-groups/{group_id}/consolidations"]["get"]["responses"]
assert {"200", "404"} <= set(listing)
checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"]["responses"]
assert {"200", "404"} <= set(checklist)
assert checklist["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/ChecklistOut")
osv = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv"]["get"]
assert {"200", "404", "422"} <= set(osv["responses"])
assert osv["responses"]["200"]["content"]["application/json"]["schema"]["$ref"].endswith(
"/Page_ConsolidatedRowOut_"
)
params = {parameter["name"]: parameter for parameter in osv["parameters"]}
assert {
"legal_entity_id",
"account_root",
"counterparty_type",
"unmapped",
"match_kind",
"q",
"limit",
"offset",
} <= set(params)
assert params["match_kind"]["schema"]["anyOf"][0]["type"] == "array" # повторяемый параметр
summary = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv/summary"]["get"]["responses"]
assert {"200", "404"} <= set(summary)
assert summary["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/OsvSummaryOut")
def test_osv_export_route_declares_binary_xlsx_response(schema: dict[str, Any]) -> None:
"""Экспорт: 200 — файл xlsx (бинарный контент), 404/422; фильтры те же, что у страницы, но без пагинации."""
export = schema["paths"]["/api/v1/consolidations/versions/{version_id}/osv/export"]["get"]
assert {"200", "404", "422"} <= set(export["responses"])
xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
assert export["responses"]["200"]["content"][xlsx]["schema"] == {"type": "string", "format": "binary"}
names = {parameter["name"] for parameter in export["parameters"]}
assert {"legal_entity_id", "account_root", "counterparty_type", "unmapped", "match_kind", "q"} <= names
assert not {"limit", "offset"} & names
def test_checklist_route_declares_json_and_csv_response(schema: dict[str, Any]) -> None:
"""T12.2: чек-лист — 200 как JSON (`ChecklistOut`) и как CSV-файл (бинарный текст), 404/422."""
checklist = schema["paths"]["/api/v1/consolidations/versions/{version_id}/checklist"]["get"]
assert {"200", "404", "422"} <= set(checklist["responses"])
content = checklist["responses"]["200"]["content"]
assert content["application/json"]["schema"]["$ref"].endswith("/ChecklistOut")
assert content["text/csv"]["schema"] == {"type": "string", "format": "binary"}
def test_lock_and_status_routes_declare_documented_responses(schema: dict[str, Any]) -> None:
"""Взятие — 201/200 с `LockOut`, 404/409/422; снятие — 204/404/409; смена статуса — карточка, 404/409/422."""
lock = schema["paths"]["/api/v1/consolidations/versions/{version_id}/lock"]
assert {"200", "201", "404", "409", "422"} <= set(lock["post"]["responses"])
assert lock["post"]["responses"]["201"]["content"]["application/json"]["schema"]["$ref"].endswith("/LockOut")
assert {"204", "404", "409"} <= set(lock["delete"]["responses"])
change = schema["paths"]["/api/v1/consolidations/versions/{version_id}/status"]["post"]["responses"]
assert {"200", "404", "409", "422"} <= set(change)
assert change["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/VersionCard")
def test_counterparty_mapping_routes_declare_documented_responses(schema: dict[str, Any]) -> None:
"""Кандидаты — список `CandidateOut`, 404/422; сопоставление — `MapCounterpartyOut` (строка может быть `null`),
404/409/422."""
candidates = schema["paths"]["/api/v1/consolidations/versions/{version_id}/counterparty-candidates"]["get"]
assert {"200", "404", "422"} <= set(candidates["responses"])
assert candidates["responses"]["200"]["content"]["application/json"]["schema"]["items"]["$ref"].endswith(
"/CandidateOut"
)
assert {"q", "scope", "entry_id"} <= {parameter["name"] for parameter in candidates["parameters"]}
path = "/api/v1/consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty"
mapping = schema["paths"][path]["post"]["responses"]
assert {"200", "404", "409", "422"} <= set(mapping)
assert mapping["200"]["content"]["application/json"]["schema"]["$ref"].endswith("/MapCounterpartyOut")
row = schema["components"]["schemas"]["MapCounterpartyOut"]["properties"]["row"]
assert {"type": "null"} in row["anyOf"]
# --- Схема безопасности (issue #29): Swagger UI показывает Authorize только при securitySchemes в OpenAPI ---
BEARER_SCHEME = "bearerAuth"
# Маршруты без аутентификации: выдача dev-токена и healthcheck
PUBLIC_PATHS = {"/api/v1/dev/token", "/healthcheck"}
def test_openapi_declares_bearer_security_scheme(schema: dict[str, Any]) -> None:
schemes = schema.get("components", {}).get("securitySchemes", {})
bearer = schemes.get(BEARER_SCHEME, {})
assert (bearer.get("type"), bearer.get("scheme"), bearer.get("bearerFormat")) == ("http", "bearer", "JWT"), schemes
assert "Authorize" in bearer.get("description", "") # подсказка для Swagger UI
def test_protected_routes_require_bearer_and_public_do_not(schema: dict[str, Any]) -> None:
missing: list[str] = []
for path, methods in schema["paths"].items():
for method, operation in methods.items():
requirements = operation.get("security", [])
has_bearer = any(BEARER_SCHEME in requirement for requirement in requirements)
if path in PUBLIC_PATHS:
assert not has_bearer, f"{method.upper()} {path} — публичный маршрут помечен security"
elif not has_bearer:
missing.append(f"{method.upper()} {path}")
assert missing == [], f"маршруты без схемы {BEARER_SCHEME} (Authorize в Swagger не подставит токен): {missing}"
def test_consolidation_create_has_optional_selected_perimeter(schema: dict[str, Any]) -> None:
"""front-api-gaps D-1: `legal_entity_ids` в теле создания — необязательный массив."""
create = schema["components"]["schemas"]["ConsolidationCreate"]
assert "legal_entity_ids" in create["properties"]
assert "legal_entity_ids" not in create.get("required", [])
def test_checklist_cell_declares_its_rule(schema: dict[str, Any]) -> None:
"""front-api-gaps D-2: ячейка чек-листа самоописывающая."""
cell = schema["components"]["schemas"]["ChecklistCellOut"]
assert {"document_type", "account", "status", "missing_quarters", "document_ids"} <= set(cell["required"])
def test_version_member_declares_type_and_osv_accounts(schema: dict[str, Any]) -> None:
"""front-api-gaps D-3: участник периметра — тип ЮЛ и счета ОСВ к периоду версии."""
member = schema["components"]["schemas"]["MemberOut"]
assert {"entity_type", "osv_accounts", "checklist"} <= set(member["required"])