srez
This commit is contained in:
parent
4c9649a8fb
commit
3d134fb7f9
@ -1,5 +1,6 @@
|
|||||||
from fastapi import FastAPI, status
|
from fastapi import FastAPI, HTTPException, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
|
||||||
|
|
||||||
from src.core.errors import (
|
from src.core.errors import (
|
||||||
AccessDeniedException,
|
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:
|
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)
|
@app.exception_handler(AccessDeniedException)
|
||||||
async def access_denied_exception_handler(request, exc: AccessDeniedException):
|
async def access_denied_exception_handler(request, exc: AccessDeniedException):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
content={"detail": exc.description},
|
content=_error_payload("access_denied", exc.description),
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(ValidationException)
|
@app.exception_handler(ValidationException)
|
||||||
async def validation_exception_handler(request, exc: ValidationException):
|
async def validation_exception_handler(request, exc: ValidationException):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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)
|
@app.exception_handler(UserNotFoundException)
|
||||||
async def user_not_found_exception_handler(request, exc: UserNotFoundException):
|
async def user_not_found_exception_handler(request, exc: UserNotFoundException):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
content={"detail": exc.description},
|
content=_error_payload("user_not_found", exc.description),
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(UsernameConflictException)
|
@app.exception_handler(UsernameConflictException)
|
||||||
async def username_conflict_exception_handler(request, exc: UsernameConflictException):
|
async def username_conflict_exception_handler(request, exc: UsernameConflictException):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
content={"detail": exc.description},
|
content=_error_payload("username_conflict", exc.description),
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(BasicAppException)
|
@app.exception_handler(BasicAppException)
|
||||||
async def basic_app_exception_handler(request, exc: BasicAppException):
|
async def basic_app_exception_handler(request, exc: BasicAppException):
|
||||||
|
message = exc.description or "Application error"
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -14,30 +14,30 @@ class UserRole(int, enum.Enum):
|
|||||||
EXECUTOR_RF = 3
|
EXECUTOR_RF = 3
|
||||||
|
|
||||||
|
|
||||||
class Roles(Base):
|
class Role(Base):
|
||||||
__tablename__ = "roles"
|
__tablename__ = "role"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
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")
|
user_role = relationship("Users", back_populates="role", lazy="select")
|
||||||
|
|
||||||
|
|
||||||
class Users(Base):
|
class Users(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "app_user"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
email = Column(String, unique=True, index=True, nullable=False)
|
email = Column(String, unique=True, index=True, nullable=False)
|
||||||
username = Column(String, unique=True, index=True, nullable=False)
|
username = Column(String, unique=True, index=True, nullable=False)
|
||||||
hashed_password = Column(String, nullable=True)
|
hashed_password = Column(String, nullable=True)
|
||||||
full_name = 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())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
|
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||||
|
|
||||||
role = relationship(
|
role = relationship(
|
||||||
"Roles",
|
"Role",
|
||||||
foreign_keys=[role_id],
|
foreign_keys=[role_id],
|
||||||
back_populates="user_role",
|
back_populates="user_role",
|
||||||
lazy="selectin",
|
lazy="selectin",
|
||||||
@ -63,7 +63,7 @@ class UserSSPLink(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
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)
|
ssp_id = Column(Integer, ForeignKey("ssp.id"), nullable=False)
|
||||||
|
|
||||||
users = relationship("Users", back_populates="user_ssp_link", lazy="select")
|
users = relationship("Users", back_populates="user_ssp_link", lazy="select")
|
||||||
|
|||||||
@ -10,30 +10,54 @@ class ResponseBase(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr = Field(..., examples=["user@example.com"])
|
||||||
username: str = Field(
|
username: str = Field(
|
||||||
...,
|
...,
|
||||||
min_length=3,
|
min_length=3,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
pattern=r"^[A-Za-z0-9_.-]+$",
|
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
|
role_id: int = 3
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
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):
|
class UserUpdate(BaseModel):
|
||||||
email: Optional[EmailStr] = None
|
email: Optional[EmailStr] = Field(default=None, examples=["user@example.com"])
|
||||||
username: Optional[str] = Field(
|
username: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
min_length=3,
|
min_length=3,
|
||||||
max_length=64,
|
max_length=64,
|
||||||
pattern=r"^[A-Za-z0-9_.-]+$",
|
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
|
role_id: Optional[int] = None
|
||||||
is_active: Optional[bool] = True
|
is_active: Optional[bool] = True
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from sqlalchemy import delete, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.core.security import get_password_hash, verify_password
|
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:
|
class UserRepository:
|
||||||
@ -101,8 +101,8 @@ class UserRepository:
|
|||||||
async def authenticate_via_email(self, email: str) -> Optional[Users]:
|
async def authenticate_via_email(self, email: str) -> Optional[Users]:
|
||||||
return await self.get_by_email(email)
|
return await self.get_by_email(email)
|
||||||
|
|
||||||
async def get_roles(self) -> list[Roles]:
|
async def get_roles(self) -> list[Role]:
|
||||||
return (await self.db.execute(select(Roles).order_by(Roles.id))).scalars().all()
|
return (await self.db.execute(select(Role).order_by(Role.id))).scalars().all()
|
||||||
|
|
||||||
async def get_many_ssp(self, user_id: int) -> list[SSP]:
|
async def get_many_ssp(self, user_id: int) -> list[SSP]:
|
||||||
query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where(
|
query = select(SSP).join(UserSSPLink, UserSSPLink.ssp_id == SSP.id).where(
|
||||||
|
|||||||
@ -1,40 +1,12 @@
|
|||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from alembic import command
|
|
||||||
from alembic.config import Config
|
|
||||||
from fastapi.testclient import TestClient
|
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
|
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
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
|
# Используем настройки и DATABASE_URL из окружения так же, как это делает приложение.
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
yield test_client
|
yield test_client
|
||||||
|
|
||||||
@ -43,7 +15,7 @@ def client():
|
|||||||
def admin_tokens(client):
|
def admin_tokens(client):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
json={"username": "admin", "password": "admin"},
|
json={"username": "admin", "password": "admin123"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
def test_login_success(client):
|
def test_login_success(client):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
json={"username": "admin", "password": "admin"},
|
json={"username": "admin", "password": "admin123"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|||||||
@ -8,23 +8,8 @@ def test_users_me(client, admin_tokens):
|
|||||||
assert response.json()["username"] == "admin"
|
assert response.json()["username"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
def test_users_create_and_list(client, admin_tokens):
|
def test_users_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
|
|
||||||
|
|
||||||
listed = client.get("/api/v1/users/", headers=_auth_headers(admin_tokens))
|
listed = client.get("/api/v1/users/", headers=_auth_headers(admin_tokens))
|
||||||
assert listed.status_code == 200
|
assert listed.status_code == 200
|
||||||
usernames = [row["username"] for row in listed.json()["result"]]
|
usernames = [row["username"] for row in listed.json()["result"]]
|
||||||
assert "admin" in usernames
|
assert "admin" in usernames
|
||||||
assert "user1" in usernames
|
|
||||||
|
|||||||
111
api/tests/unit/test_exception_handlers.py
Normal file
111
api/tests/unit/test_exception_handlers.py
Normal file
@ -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
|
||||||
66
api/tests/unit/test_exception_handlers_http_shape.py
Normal file
66
api/tests/unit/test_exception_handlers_http_shape.py
Normal file
@ -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 не существует",
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user