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