From 280dabfd95228580b4a5070d213d4f9f4632f11d Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Thu, 14 May 2026 11:16:35 +0300 Subject: [PATCH] Refactor contract of errors --- api/src/core/exception_handlers.py | 33 +++++-------------- api/tests/integration/test_users_api_smoke.py | 24 +++++++++++--- api/tests/unit/test_exception_handlers.py | 17 ++++++++-- .../test_exception_handlers_http_shape.py | 8 +++-- 4 files changed, 46 insertions(+), 36 deletions(-) diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index 1f1aaf1..5a69008 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -24,15 +24,13 @@ VALIDATION_PREFIXES = ( ) -def _error_payload(code: str, message: str, field: str | None = None) -> dict: +def _error_payload(code: str | int, message: str, field: str | None = None) -> dict: """Формирует структурированный payload ошибки по спеке.""" - payload = { + return { "code": code, + "field": field, "message": message, } - if field: - payload["field"] = field - return payload def _parse_prefixed_validation_error(message: str) -> tuple[str, str | None]: @@ -80,7 +78,7 @@ def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, str, str, str | Non return status.HTTP_400_BAD_REQUEST, "db_error", message, None -def map_http_exception(exc: HTTPException) -> tuple[int, str, str, str | None]: +def map_http_exception(exc: HTTPException) -> tuple[int, str | int, str, str | None]: """Маппинг HTTPException в единый формат ошибки API.""" status_code = int(exc.status_code) detail = exc.detail @@ -90,26 +88,11 @@ def map_http_exception(exc: HTTPException) -> tuple[int, str, str, str | None]: field = detail.get("field") if field is not None: field = str(field) - code = detail.get("code") - if code is not None: - return status_code, str(code), message, field - else: - message = str(detail) if detail else "HTTP error" - field = None + code = detail.get("code", status_code) + return status_code, code, message, field - if status_code == status.HTTP_422_UNPROCESSABLE_ENTITY: - code, parsed_field = _parse_prefixed_validation_error(message) - return status_code, code, message, parsed_field or field - if status_code == status.HTTP_401_UNAUTHORIZED: - return status_code, "unauthorized", message, field - if status_code == status.HTTP_403_FORBIDDEN: - return status_code, "access_denied", message, field - if status_code == status.HTTP_404_NOT_FOUND: - return status_code, "not_found", message, field - if status_code == status.HTTP_409_CONFLICT: - return status_code, "conflict", message, field - - return status_code, "http_error", message, field + message = str(detail) if detail else "HTTP error" + return status_code, status_code, message, None def register_exception_handlers(app: FastAPI) -> None: diff --git a/api/tests/integration/test_users_api_smoke.py b/api/tests/integration/test_users_api_smoke.py index d53530f..6ee25e6 100644 --- a/api/tests/integration/test_users_api_smoke.py +++ b/api/tests/integration/test_users_api_smoke.py @@ -1,3 +1,6 @@ +import uuid + + def _auth_headers(tokens: dict) -> dict: return {"Authorization": f"Bearer {tokens['access_token']}"} @@ -8,8 +11,19 @@ def test_users_me(client, admin_tokens): assert response.json()["username"] == "admin" -def test_users_list(client, admin_tokens): - listed = client.get("/api/v1/users/", headers=_auth_headers(admin_tokens)) - assert listed.status_code == 200 - usernames = [row["username"] for row in listed.json()["result"]] - assert "admin" in usernames +def test_users_create_smoke(client, admin_tokens): + suffix = uuid.uuid4().hex[:8] + username = f"user_{suffix}" + created = client.put( + "/api/v1/users/", + json={ + "email": f"{username}@example.com", + "username": username, + "password": "pass123", + "full_name": "User One", + "role_id": 2, + }, + headers=_auth_headers(admin_tokens), + ) + assert created.status_code == 200 + assert created.json()["result"]["username"] == username diff --git a/api/tests/unit/test_exception_handlers.py b/api/tests/unit/test_exception_handlers.py index f1350ba..2f86b2c 100644 --- a/api/tests/unit/test_exception_handlers.py +++ b/api/tests/unit/test_exception_handlers.py @@ -81,9 +81,9 @@ def test_map_http_exception_validation_prefix(): status_code, code, message, field = map_http_exception(exc) assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert code == "unknown_column" + assert code == status.HTTP_422_UNPROCESSABLE_ENTITY assert message == "unknown_column: q5.plan" - assert field == "q5.plan" + assert field is None def test_map_http_exception_from_structured_detail(): @@ -106,6 +106,17 @@ def test_map_http_exception_unauthorized(): status_code, code, message, field = map_http_exception(exc) assert status_code == status.HTTP_401_UNAUTHORIZED - assert code == "unauthorized" + assert code == status.HTTP_401_UNAUTHORIZED assert message == "Недействительный токен" assert field is None + + +def test_map_http_exception_not_found_maps_code_from_status(): + exc = HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found") + + status_code, code, message, field = map_http_exception(exc) + + assert status_code == status.HTTP_404_NOT_FOUND + assert code == status.HTTP_404_NOT_FOUND + assert message == "not found" + assert field is None diff --git a/api/tests/unit/test_exception_handlers_http_shape.py b/api/tests/unit/test_exception_handlers_http_shape.py index 397292c..cde3af6 100644 --- a/api/tests/unit/test_exception_handlers_http_shape.py +++ b/api/tests/unit/test_exception_handlers_http_shape.py @@ -38,7 +38,8 @@ def test_http_exception_returns_structured_payload(): assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.json() == { - "code": "unauthorized", + "code": status.HTTP_401_UNAUTHORIZED, + "field": None, "message": "Недействительный токен", } @@ -49,8 +50,8 @@ def test_http_validation_exception_extracts_field(): assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.json() == { - "code": "unknown_column", - "field": "q5.plan", + "code": status.HTTP_422_UNPROCESSABLE_ENTITY, + "field": None, "message": "unknown_column: q5.plan", } @@ -62,5 +63,6 @@ def test_sqlalchemy_exception_returns_structured_payload(): assert response.status_code == status.HTTP_404_NOT_FOUND assert response.json() == { "code": "not_found", + "field": None, "message": "budget_form #99 не существует", }