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:
Raykov-MS 2026-05-14 16:13:28 +03:00
commit 748d81ef06
7 changed files with 393 additions and 84 deletions

31
api/src/core/CONSTANTS.py Normal file
View 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:",
)

View File

@ -1,8 +1,6 @@
# import psycopg2
from fastapi import FastAPI, HTTPException, status from fastapi import FastAPI, HTTPException, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
import sqlalchemy from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError
from src.core.errors import ( from src.core.errors import (
AccessDeniedException, AccessDeniedException,
@ -11,41 +9,135 @@ from src.core.errors import (
UsernameConflictException, UsernameConflictException,
ValidationException, 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: 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),
) )

View File

@ -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")

View File

@ -50,30 +50,54 @@ class BaseListResponse(ResponseBase, Generic[T]):
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

View File

@ -1,6 +1,9 @@
import uuid import uuid
import uuid
def _auth_headers(tokens: dict) -> dict: def _auth_headers(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"} return {"Authorization": f"Bearer {tokens['access_token']}"}
@ -23,7 +26,13 @@ def test_users_create_smoke(client, admin_tokens):
} }
created = client.put( created = client.put(
"/api/v1/users/", "/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), headers=_auth_headers(admin_tokens),
) )
assert created.status_code == 200 assert created.status_code == 200

View 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

View 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 не существует",
}