Merge pull request 'future/AURORA-1007' (#4) from future/AURORA-1007 into test
Reviewed-on: #4 Reviewed-by: tsygankoviva <tsygankov.itis@gmail.com>
This commit is contained in:
commit
748d81ef06
31
api/src/core/CONSTANTS.py
Normal file
31
api/src/core/CONSTANTS.py
Normal file
@ -0,0 +1,31 @@
|
||||
VALIDATION_PREFIXES = (
|
||||
"computed_field:",
|
||||
"normative_field:",
|
||||
"unknown_column:",
|
||||
"key_field:",
|
||||
"structural_field:",
|
||||
"bad_column_format:",
|
||||
"bad_booking_year:",
|
||||
"bad_booking_quarter:",
|
||||
"unsupported_scope:",
|
||||
)
|
||||
|
||||
PG_404_ERRORS = (
|
||||
"не существует",
|
||||
"не принадлежит",
|
||||
"not found",
|
||||
)
|
||||
PG_409_ERRORS = (
|
||||
"unique",
|
||||
"duplicate",
|
||||
"foreign key",
|
||||
)
|
||||
PG_400_ERRORS = (
|
||||
"violates check constraint",
|
||||
)
|
||||
PG_403_ERRORS = (
|
||||
"permission denied",
|
||||
"insufficient privilege",
|
||||
"role_not_allowed:",
|
||||
"window_closed:",
|
||||
)
|
||||
@ -1,8 +1,6 @@
|
||||
# import psycopg2
|
||||
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
import sqlalchemy
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
|
||||
|
||||
from src.core.errors import (
|
||||
AccessDeniedException,
|
||||
@ -11,41 +9,135 @@ from src.core.errors import (
|
||||
UsernameConflictException,
|
||||
ValidationException,
|
||||
)
|
||||
from src.core.CONSTANTS import (
|
||||
VALIDATION_PREFIXES,
|
||||
PG_404_ERRORS,
|
||||
PG_409_ERRORS,
|
||||
PG_400_ERRORS,
|
||||
PG_403_ERRORS,
|
||||
)
|
||||
|
||||
def _error_payload(code: str | int, message: str, field: str | None = None) -> dict:
|
||||
"""Формирует структурированный payload ошибки по спеке."""
|
||||
return {
|
||||
"code": code,
|
||||
"field": field,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
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 any(error in low for error in PG_404_ERRORS):
|
||||
return status.HTTP_404_NOT_FOUND, "not_found", message, None
|
||||
if any(error in low for error in PG_403_ERRORS):
|
||||
return status.HTTP_403_FORBIDDEN, "access_denied", message, None
|
||||
|
||||
if isinstance(exc, IntegrityError):
|
||||
if any(error in low for error in PG_409_ERRORS):
|
||||
return status.HTTP_409_CONFLICT, "conflict", message, None
|
||||
if any(error in low for error in PG_400_ERRORS):
|
||||
return status.HTTP_400_BAD_REQUEST, "db_error", 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 | int, 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", status_code)
|
||||
return status_code, code, message, field
|
||||
|
||||
message = str(detail) if detail else "HTTP error"
|
||||
return status_code, status_code, message, 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)
|
||||
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),
|
||||
)
|
||||
|
||||
@ -1,70 +0,0 @@
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, String,
|
||||
UniqueConstraint)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from src.db.base import Base
|
||||
|
||||
|
||||
class UserRole(int, enum.Enum):
|
||||
ADMIN = 1
|
||||
EXECUTOR_DFIP = 2
|
||||
EXECUTOR_RF = 3
|
||||
|
||||
|
||||
class Roles(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
role = Column(String, nullable=False)
|
||||
|
||||
user_role = relationship("Users", back_populates="role", lazy="select")
|
||||
|
||||
|
||||
class Users(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
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)
|
||||
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",
|
||||
foreign_keys=[role_id],
|
||||
back_populates="user_role",
|
||||
lazy="selectin",
|
||||
)
|
||||
user_ssp_link = relationship("UserSSPLink", back_populates="users", lazy="select")
|
||||
|
||||
|
||||
class SSP(Base):
|
||||
__tablename__ = "ssp"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||
is_ssp = Column(Boolean, default=True, nullable=False, server_default="true")
|
||||
|
||||
user_ssp_link = relationship("UserSSPLink", back_populates="ssp", lazy="select")
|
||||
|
||||
|
||||
class UserSSPLink(Base):
|
||||
__tablename__ = "user_ssp_link"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "ssp_id", name="uq_user_ssp_link_user_id_ssp_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
ssp_id = Column(Integer, ForeignKey("ssp.id"), nullable=False)
|
||||
|
||||
users = relationship("Users", back_populates="user_ssp_link", lazy="select")
|
||||
ssp = relationship("SSP", back_populates="user_ssp_link", lazy="select")
|
||||
@ -50,30 +50,54 @@ class BaseListResponse(ResponseBase, Generic[T]):
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import uuid
|
||||
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
def _auth_headers(tokens: dict) -> dict:
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
@ -23,7 +26,13 @@ def test_users_create_smoke(client, admin_tokens):
|
||||
}
|
||||
created = client.put(
|
||||
"/api/v1/users/",
|
||||
json=create_payload,
|
||||
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
|
||||
|
||||
155
api/tests/unit/test_exception_handlers.py
Normal file
155
api/tests/unit/test_exception_handlers.py
Normal file
@ -0,0 +1,155 @@
|
||||
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_check_constraint_is_400():
|
||||
exc = IntegrityError(
|
||||
statement="UPDATE v3.project ...",
|
||||
params={},
|
||||
orig=Exception("new row for relation violates check constraint chk_v3_project_name"),
|
||||
)
|
||||
|
||||
status_code, code, message, field = map_sqlalchemy_error(exc)
|
||||
|
||||
assert status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert code == "db_error"
|
||||
assert "violates check constraint" 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_role_window_prefixes():
|
||||
role_exc = SQLAlchemyError()
|
||||
role_exc.orig = Exception("role_not_allowed: q1.m1")
|
||||
status_code, code, message, field = map_sqlalchemy_error(role_exc)
|
||||
assert status_code == status.HTTP_403_FORBIDDEN
|
||||
assert code == "access_denied"
|
||||
assert message == "role_not_allowed: q1.m1"
|
||||
assert field is None
|
||||
|
||||
window_exc = SQLAlchemyError()
|
||||
window_exc.orig = Exception("window_closed: q1.m1 (closed 2026-05-01T00:00:00Z)")
|
||||
status_code, code, message, field = map_sqlalchemy_error(window_exc)
|
||||
assert status_code == status.HTTP_403_FORBIDDEN
|
||||
assert code == "access_denied"
|
||||
assert message.startswith("window_closed:")
|
||||
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 == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
assert message == "unknown_column: q5.plan"
|
||||
assert field is None
|
||||
|
||||
|
||||
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 == 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
|
||||
68
api/tests/unit/test_exception_handlers_http_shape.py
Normal file
68
api/tests/unit/test_exception_handlers_http_shape.py
Normal file
@ -0,0 +1,68 @@
|
||||
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": status.HTTP_401_UNAUTHORIZED,
|
||||
"field": None,
|
||||
"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": status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
"field": None,
|
||||
"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",
|
||||
"field": None,
|
||||
"message": "budget_form #99 не существует",
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user