302 lines
17 KiB
Python
302 lines
17 KiB
Python
"""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}/elimination-pairs": {"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/preset/{rule_id}": {"get"},
|
||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/preset/{rule_id}/export": {"get"},
|
||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table": {"get"},
|
||
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table/export": {"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_result_table_export_route_declares_binary_xlsx_response(schema: dict[str, Any]) -> None:
|
||
"""Экспорт результирующей таблицы: 200 — xlsx, 422 — нет счетов статей, а не «неверный выбор счетов»."""
|
||
base = "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/result-table"
|
||
export = schema["paths"][f"{base}/export"]["get"]["responses"]
|
||
xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||
assert export["200"]["content"][xlsx]["schema"] == {"type": "string", "format": "binary"}
|
||
for responses in (export, schema["paths"][base]["get"]["responses"]):
|
||
assert responses["404"]["description"] == "Версия не найдена либо недоступна"
|
||
assert responses["422"]["description"] == "В плане счетов нет счетов статей результирующей таблицы"
|
||
|
||
|
||
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"])
|
||
|
||
|
||
def test_retry_routes_declare_different_conflicts(schema: dict[str, Any]) -> None:
|
||
single = schema["paths"]["/api/v1/files/{file_id}/retry"]["post"]["responses"]["409"]["description"]
|
||
batch = schema["paths"]["/api/v1/consolidations/versions/{version_id}/files/retry"]["post"]["responses"]["409"][
|
||
"description"
|
||
]
|
||
assert "FILE_NOT_RETRYABLE" in single and "MAX_ATTEMPTS_REACHED" in single
|
||
assert batch == "VERSION_NOT_EDITABLE"
|