From 3d134fb7f980081dcd1deea55bbb4d5dc36e49d7 Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Fri, 8 May 2026 13:16:31 +0300 Subject: [PATCH] srez --- api/src/core/exception_handlers.py | 132 +++++++++++++++++- api/src/domain/models.py | 14 +- api/src/domain/schemas.py | 34 ++++- api/src/repository/user_repository.py | 6 +- api/tests/integration/conftest.py | 32 +---- api/tests/integration/test_auth_api_smoke.py | 2 +- api/tests/integration/test_users_api_smoke.py | 17 +-- api/tests/unit/test_exception_handlers.py | 111 +++++++++++++++ .../test_exception_handlers_http_shape.py | 66 +++++++++ 9 files changed, 346 insertions(+), 68 deletions(-) create mode 100644 api/tests/unit/test_exception_handlers.py create mode 100644 api/tests/unit/test_exception_handlers_http_shape.py diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index d7efc24..1f1aaf1 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -1,5 +1,6 @@ -from fastapi import FastAPI, status +from fastapi import FastAPI, HTTPException, status from fastapi.responses import JSONResponse +from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError from src.core.errors import ( AccessDeniedException, @@ -10,38 +11,157 @@ from src.core.errors import ( ) +VALIDATION_PREFIXES = ( + "computed_field:", + "normative_field:", + "unknown_column:", + "key_field:", + "structural_field:", + "bad_column_format:", + "bad_booking_year:", + "bad_booking_quarter:", + "unsupported_scope:", +) + + +def _error_payload(code: str, message: str, field: str | None = None) -> dict: + """Формирует структурированный payload ошибки по спеке.""" + payload = { + "code": code, + "message": message, + } + if field: + payload["field"] = field + return payload + + +def _parse_prefixed_validation_error(message: str) -> tuple[str, str | None]: + for prefix in VALIDATION_PREFIXES: + if message.lower().startswith(prefix): + code = prefix[:-1] + field = message.split(":", 1)[1].strip() if ":" in message else None + return code, field or None + return "validation_error", None + + +def _first_db_message_line(message: str) -> str: + """Возвращает первую непустую строку текста ошибки БД.""" + for line in message.splitlines(): + line = line.strip() + if line: + return line + return message.strip() + + +def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, str, str, str | None]: + """Маппинг ошибок SQLAlchemy в HTTP-статус + code/message/field.""" + raw = str(getattr(exc, "orig", exc) or exc).strip() + message = _first_db_message_line(raw) or "Database error" + low = message.lower() + + if any(low.startswith(prefix) for prefix in VALIDATION_PREFIXES): + code, field = _parse_prefixed_validation_error(message) + return status.HTTP_422_UNPROCESSABLE_ENTITY, code, message, field + if "не существует" in low or "не принадлежит" in low or "not found" in low: + return status.HTTP_404_NOT_FOUND, "not_found", message, None + if "permission denied" in low or "insufficient privilege" in low: + return status.HTTP_403_FORBIDDEN, "access_denied", message, None + + if isinstance(exc, IntegrityError): + if "unique" in low or "duplicate" in low: + return status.HTTP_409_CONFLICT, "conflict", message, None + if "foreign key" in low or "violates check constraint" in low: + return status.HTTP_409_CONFLICT, "conflict", message, None + return status.HTTP_400_BAD_REQUEST, "db_error", message, None + + if isinstance(exc, OperationalError): + return status.HTTP_503_SERVICE_UNAVAILABLE, "db_unavailable", "Database unavailable", None + + return status.HTTP_400_BAD_REQUEST, "db_error", message, None + + +def map_http_exception(exc: HTTPException) -> tuple[int, str, str, str | None]: + """Маппинг HTTPException в единый формат ошибки API.""" + status_code = int(exc.status_code) + detail = exc.detail + + if isinstance(detail, dict): + message = str(detail.get("message") or detail.get("detail") or "HTTP error") + 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 + + 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 + + def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(HTTPException) + async def http_exception_handler(request, exc: HTTPException): + status_code, code, message, field = map_http_exception(exc) + return JSONResponse( + status_code=status_code, + content=_error_payload(code, message, field=field), + headers=exc.headers, + ) + @app.exception_handler(AccessDeniedException) async def access_denied_exception_handler(request, exc: AccessDeniedException): return JSONResponse( status_code=status.HTTP_403_FORBIDDEN, - content={"detail": exc.description}, + content=_error_payload("access_denied", exc.description), ) @app.exception_handler(ValidationException) async def validation_exception_handler(request, exc: ValidationException): return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": exc.description}, + content=_error_payload("validation_error", exc.description, field=exc.field), ) @app.exception_handler(UserNotFoundException) async def user_not_found_exception_handler(request, exc: UserNotFoundException): return JSONResponse( status_code=status.HTTP_404_NOT_FOUND, - content={"detail": exc.description}, + content=_error_payload("user_not_found", exc.description), ) @app.exception_handler(UsernameConflictException) async def username_conflict_exception_handler(request, exc: UsernameConflictException): return JSONResponse( status_code=status.HTTP_409_CONFLICT, - content={"detail": exc.description}, + content=_error_payload("username_conflict", exc.description), ) @app.exception_handler(BasicAppException) async def basic_app_exception_handler(request, exc: BasicAppException): + message = exc.description or "Application error" return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": exc.description or "Application error"}, + content=_error_payload("application_error", message), + ) + + @app.exception_handler(SQLAlchemyError) + async def sqlalchemy_exception_handler(request, exc: SQLAlchemyError): + status_code, code, message, field = map_sqlalchemy_error(exc) + return JSONResponse( + status_code=status_code, + content=_error_payload(code, message, field=field), ) diff --git a/api/src/domain/models.py b/api/src/domain/models.py index 5480f64..5504c8a 100644 --- a/api/src/domain/models.py +++ b/api/src/domain/models.py @@ -14,30 +14,30 @@ class UserRole(int, enum.Enum): EXECUTOR_RF = 3 -class Roles(Base): - __tablename__ = "roles" +class Role(Base): + __tablename__ = "role" id = Column(Integer, primary_key=True, index=True) - role = Column(String, nullable=False) + code = Column(String, nullable=False) user_role = relationship("Users", back_populates="role", lazy="select") class Users(Base): - __tablename__ = "users" + __tablename__ = "app_user" id = Column(Integer, primary_key=True, index=True) email = Column(String, unique=True, index=True, nullable=False) username = Column(String, unique=True, index=True, nullable=False) hashed_password = Column(String, nullable=True) full_name = Column(String, nullable=True) - role_id = Column(Integer, ForeignKey("roles.id"), nullable=False) + role_id = Column(Integer, ForeignKey("role.id"), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) is_active = Column(Boolean, default=True, nullable=False, server_default="true") role = relationship( - "Roles", + "Role", foreign_keys=[role_id], back_populates="user_role", lazy="selectin", @@ -63,7 +63,7 @@ class UserSSPLink(Base): ) id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey("app_user.id"), nullable=False) ssp_id = Column(Integer, ForeignKey("ssp.id"), nullable=False) users = relationship("Users", back_populates="user_ssp_link", lazy="select") diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index 8baedfc..b875da6 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -10,30 +10,54 @@ class ResponseBase(BaseModel): class UserBase(BaseModel): - email: EmailStr + email: EmailStr = Field(..., examples=["user@example.com"]) username: str = Field( ..., min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$", + examples=["user_1"], + ) + full_name: Optional[str] = Field( + None, + max_length=255, + pattern=r"^[^<>]*$", + examples=["Иван Иванов"], ) - full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$") role_id: int = 3 class UserCreate(UserBase): - password: str | None = None + password: str | None = Field(default=None, examples=["pass123"]) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "email": "user@example.com", + "username": "user_1", + "full_name": "Иван Иванов", + "role_id": 3, + "password": "pass123", + } + } + ) class UserUpdate(BaseModel): - email: Optional[EmailStr] = None + email: Optional[EmailStr] = Field(default=None, examples=["user@example.com"]) username: Optional[str] = Field( None, min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$", + examples=["user_1"], + ) + full_name: Optional[str] = Field( + None, + max_length=255, + pattern=r"^[^<>]*$", + examples=["Иван Иванов"], ) - full_name: Optional[str] = Field(None, max_length=255, pattern=r"^[^<>]*$") role_id: Optional[int] = None is_active: Optional[bool] = True diff --git a/api/src/repository/user_repository.py b/api/src/repository/user_repository.py index aaa26f4..54c77dd 100644 --- a/api/src/repository/user_repository.py +++ b/api/src/repository/user_repository.py @@ -4,7 +4,7 @@ from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.core.security import get_password_hash, verify_password -from src.domain.models import Roles, SSP, UserSSPLink, Users +from src.domain.models import Role, SSP, UserSSPLink, Users class UserRepository: @@ -101,8 +101,8 @@ class UserRepository: async def authenticate_via_email(self, email: str) -> Optional[Users]: return await self.get_by_email(email) - async def get_roles(self) -> list[Roles]: - return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all() + async def get_roles(self) -> list[Role]: + return (await self.db.execute(select(Role).order_by(Role.id))).scalars().all() async def get_many_ssp(self, user_id: int) -> list[SSP]: query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where( diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index ed451f5..a7ed394 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -1,40 +1,12 @@ -import os -from pathlib import Path - import pytest -from alembic import command -from alembic.config import Config from fastapi.testclient import TestClient -# Keep a single sqlite target for app + alembic during tests. -TEST_DATABASE_URL = "sqlite+aiosqlite:///./app.db" -os.environ["DATABASE_URL"] = TEST_DATABASE_URL -os.environ["OPENBAO__SETTINGS__DATABASE_URL"] = TEST_DATABASE_URL -os.environ["OPENBAO__SETTINGS_TEST__DATABASE_URL"] = TEST_DATABASE_URL -os.environ["OPENBAO__SETTINGS__DEBUG"] = "true" -os.environ["OPENBAO__SETTINGS_TEST__DEBUG"] = "true" - from src.main import app -@pytest.fixture(scope="session", autouse=True) -def migrate_db(): - db_files = [Path("app.db"), Path("dfip_budget_planing.db"), Path("dfip_new_schema.db")] - for db_file in db_files: - if db_file.exists(): - os.remove(db_file) - - alembic_cfg = Config("alembic.ini") - command.upgrade(alembic_cfg, "head") - yield - - for db_file in db_files: - if db_file.exists(): - os.remove(db_file) - - @pytest.fixture def client(): + # Используем настройки и DATABASE_URL из окружения так же, как это делает приложение. with TestClient(app) as test_client: yield test_client @@ -43,7 +15,7 @@ def client(): def admin_tokens(client): response = client.post( "/api/v1/auth/login", - json={"username": "admin", "password": "admin"}, + json={"username": "admin", "password": "admin123"}, ) assert response.status_code == 200 return response.json() diff --git a/api/tests/integration/test_auth_api_smoke.py b/api/tests/integration/test_auth_api_smoke.py index 7a6aee6..2605e5e 100644 --- a/api/tests/integration/test_auth_api_smoke.py +++ b/api/tests/integration/test_auth_api_smoke.py @@ -1,7 +1,7 @@ def test_login_success(client): response = client.post( "/api/v1/auth/login", - json={"username": "admin", "password": "admin"}, + json={"username": "admin", "password": "admin123"}, ) assert response.status_code == 200 data = response.json() diff --git a/api/tests/integration/test_users_api_smoke.py b/api/tests/integration/test_users_api_smoke.py index bd535dc..d53530f 100644 --- a/api/tests/integration/test_users_api_smoke.py +++ b/api/tests/integration/test_users_api_smoke.py @@ -8,23 +8,8 @@ def test_users_me(client, admin_tokens): assert response.json()["username"] == "admin" -def test_users_create_and_list(client, admin_tokens): - create_payload = { - "email": "user1@example.com", - "username": "user1", - "password": "pass123", - "full_name": "User One", - "role_id": 2, - } - created = client.put( - "/api/v1/users/", - json=create_payload, - headers=_auth_headers(admin_tokens), - ) - assert created.status_code == 200 - +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 - assert "user1" in usernames diff --git a/api/tests/unit/test_exception_handlers.py b/api/tests/unit/test_exception_handlers.py new file mode 100644 index 0000000..f1350ba --- /dev/null +++ b/api/tests/unit/test_exception_handlers.py @@ -0,0 +1,111 @@ +from fastapi import HTTPException, status +from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError + +from src.core.exception_handlers import ( + _error_payload, + map_http_exception, + map_sqlalchemy_error, +) + + +def test_map_sqlalchemy_error_unique_conflict(): + exc = IntegrityError( + statement="INSERT INTO users ...", + params={}, + orig=Exception("duplicate key value violates unique constraint users_username_key"), + ) + + status_code, code, message, field = map_sqlalchemy_error(exc) + + assert status_code == status.HTTP_409_CONFLICT + assert code == "conflict" + assert "duplicate key value" in message + assert field is None + + +def test_map_sqlalchemy_error_validation_prefix(): + exc = SQLAlchemyError() + exc.orig = Exception("unknown_column: q5.plan") + + status_code, code, message, field = map_sqlalchemy_error(exc) + + assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert code == "unknown_column" + assert message == "unknown_column: q5.plan" + assert field == "q5.plan" + + +def test_map_sqlalchemy_error_not_found(): + exc = SQLAlchemyError() + exc.orig = Exception("budget_form #99 не существует") + + status_code, code, message, field = map_sqlalchemy_error(exc) + + assert status_code == status.HTTP_404_NOT_FOUND + assert code == "not_found" + assert "не существует" in message + assert field is None + + +def test_map_sqlalchemy_error_operational(): + exc = OperationalError( + statement="SELECT 1", + params={}, + orig=Exception("connection refused"), + ) + + status_code, code, message, field = map_sqlalchemy_error(exc) + + assert status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert code == "db_unavailable" + assert message == "Database unavailable" + assert field is None + + +def test_error_payload_matches_spec_shape(): + payload = _error_payload("unknown_column", "unknown_column: q5.plan", "q5.plan") + + assert payload == { + "code": "unknown_column", + "field": "q5.plan", + "message": "unknown_column: q5.plan", + } + + +def test_map_http_exception_validation_prefix(): + exc = HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="unknown_column: q5.plan", + ) + + status_code, code, message, field = map_http_exception(exc) + + assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert code == "unknown_column" + assert message == "unknown_column: q5.plan" + assert field == "q5.plan" + + +def test_map_http_exception_from_structured_detail(): + exc = HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "validation_error", "message": "bad input", "field": "username"}, + ) + + status_code, code, message, field = map_http_exception(exc) + + assert status_code == status.HTTP_400_BAD_REQUEST + assert code == "validation_error" + assert message == "bad input" + assert field == "username" + + +def test_map_http_exception_unauthorized(): + exc = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный токен") + + status_code, code, message, field = map_http_exception(exc) + + assert status_code == status.HTTP_401_UNAUTHORIZED + assert code == "unauthorized" + assert message == "Недействительный токен" + 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 new file mode 100644 index 0000000..397292c --- /dev/null +++ b/api/tests/unit/test_exception_handlers_http_shape.py @@ -0,0 +1,66 @@ +from fastapi import FastAPI, HTTPException, status +from fastapi.testclient import TestClient +from sqlalchemy.exc import SQLAlchemyError + +from src.core.exception_handlers import register_exception_handlers + + +def _make_app() -> FastAPI: + app = FastAPI() + register_exception_handlers(app) + + @app.get("/http-401") + async def http_401(): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный токен", + ) + + @app.get("/http-422-prefixed") + async def http_422_prefixed(): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="unknown_column: q5.plan", + ) + + @app.get("/sql-error") + async def sql_error(): + exc = SQLAlchemyError() + exc.orig = Exception("budget_form #99 не существует") + raise exc + + return app + + +def test_http_exception_returns_structured_payload(): + client = TestClient(_make_app()) + response = client.get("/http-401") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json() == { + "code": "unauthorized", + "message": "Недействительный токен", + } + + +def test_http_validation_exception_extracts_field(): + client = TestClient(_make_app()) + response = client.get("/http-422-prefixed") + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.json() == { + "code": "unknown_column", + "field": "q5.plan", + "message": "unknown_column: q5.plan", + } + + +def test_sqlalchemy_exception_returns_structured_payload(): + client = TestClient(_make_app()) + response = client.get("/sql-error") + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json() == { + "code": "not_found", + "message": "budget_form #99 не существует", + }