feat(api): транзакция на запрос, RequestAudit, доменные ошибки 404/409/422, Page
- get_async_session: одна транзакция на запрос (commit при успехе, rollback при исключении), R-1;
объявляется только через SessionDep (scope="function") — commit до отправки ответа, одна сессия
на все зависимости запроса; current_user и dev-эмитент переведены на SessionDep.
- DomainError/NotFoundError/ConflictError/DomainValidationError + обработчик
{"detail": {"code", "message", ...}}; ответы HTTPException и ошибок БД не меняются.
- RequestAudit/get_request_audit: AuditService.record с user_id, ip_address, user_agent запроса.
- schemas/common.py: Page[T], PageParams (limit 1..200, по умолчанию 50; offset >= 0), ErrorDetail.
- tests/test_uow_and_errors.py: формат ошибок, пагинация, аудит, scope сессии во всех маршрутах,
настоящий commit/rollback на временной БД, commit до http.response.start, ошибка commit -> 409.
- План: Task 2 отмечена; tasks.md: 1.2 отмечен.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
c80e029b96
commit
5976986e1b
73
api/src/api/dependencies/audit.py
Normal file
73
api/src/api/dependencies/audit.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""`RequestAudit` — запись `audit_event` с контекстом запроса (spec audit-trail: `user_id`, `ip_address`, `user_agent`).
|
||||
|
||||
Пишет через `AuditService.record` в сессию запроса (`SessionDep`), то есть в той же транзакции, что и мутация:
|
||||
откат запроса откатывает и аудит. Журнал append-only — здесь только делегирование `record`.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import Depends, Request
|
||||
|
||||
from src.api.dependencies.auth import Principal, current_user
|
||||
from src.api.dependencies.db import SessionDep
|
||||
from src.models.audit_ex import AuditEvent
|
||||
from src.models.enums import AuditAction, AuditSection
|
||||
from src.services.audit import AuditService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestAudit:
|
||||
"""Сервис аудита, связанный с инициатором и клиентом текущего запроса."""
|
||||
|
||||
service: AuditService
|
||||
user_id: uuid.UUID
|
||||
ip_address: str | None
|
||||
user_agent: str | None
|
||||
|
||||
async def record( # noqa: PLR0913 - поля audit_event из спеки audit-trail (без контекста запроса)
|
||||
self,
|
||||
*,
|
||||
action: AuditAction,
|
||||
section: AuditSection,
|
||||
object_type: str,
|
||||
object_id: uuid.UUID | None = None,
|
||||
description: str | None = None,
|
||||
before_value: Mapping[str, Any] | None = None,
|
||||
after_value: Mapping[str, Any] | None = None,
|
||||
comment: str | None = None,
|
||||
result: str | None = None,
|
||||
changed_field: str | None = None,
|
||||
) -> AuditEvent:
|
||||
"""`AuditService.record` с `user_id`/`ip_address`/`user_agent` запроса."""
|
||||
return await self.service.record(
|
||||
action=action,
|
||||
section=section,
|
||||
object_type=object_type,
|
||||
object_id=object_id,
|
||||
description=description,
|
||||
before_value=before_value,
|
||||
after_value=after_value,
|
||||
comment=comment,
|
||||
result=result,
|
||||
changed_field=changed_field,
|
||||
user_id=self.user_id,
|
||||
ip_address=self.ip_address,
|
||||
user_agent=self.user_agent,
|
||||
)
|
||||
|
||||
|
||||
async def get_request_audit(
|
||||
request: Request,
|
||||
session: SessionDep,
|
||||
principal: Annotated[Principal, Depends(current_user)],
|
||||
) -> RequestAudit:
|
||||
"""Зависимость: аудит в сессии запроса; IP — адрес клиента (за прокси — из `ProxyHeadersMiddleware`)."""
|
||||
return RequestAudit(
|
||||
service=AuditService(session),
|
||||
user_id=principal.user.id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
@ -13,11 +13,10 @@ from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.dependencies.db import SessionDep
|
||||
from src.core.auth import AuthError, Claims, TokenVerifier, build_verifier
|
||||
from src.core.config import get_settings
|
||||
from src.db.db import get_async_session
|
||||
from src.repositories.users import AmbiguousUserError, UserWithAccess, load_principal_data
|
||||
|
||||
# Текущий пользователь вместе с филиалом, ролями и эффективными правами (то, что нужно `GET /me` и `require`)
|
||||
@ -74,7 +73,7 @@ async def verified_claims(
|
||||
|
||||
async def current_user(
|
||||
claims: Annotated[Claims, Depends(verified_claims)],
|
||||
session: Annotated[AsyncSession, Depends(get_async_session)],
|
||||
session: SessionDep,
|
||||
) -> Principal:
|
||||
"""Пользователь по email из токена с филиалом, ролями и эффективными правами (один запрос к БД).
|
||||
|
||||
|
||||
22
api/src/api/dependencies/db.py
Normal file
22
api/src/api/dependencies/db.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""Сессия БД запроса для маршрутов и зависимостей (R-1, design #12).
|
||||
|
||||
`SessionDep` — ЕДИНСТВЕННЫЙ способ объявить сессию в `Depends`: `get_async_session` с `scope="function"`.
|
||||
|
||||
- Commit до ответа. Зависимость с `yield` по умолчанию (`scope="request"`) завершается ПОСЛЕ отправки ответа: клиент
|
||||
получил бы 201, а commit мог бы затем упасть (данные потеряны) или ещё не произойти к следующему запросу клиента.
|
||||
С `scope="function"` выход из зависимости (commit/rollback) идёт сразу после эндпоинта и сериализации ответа, ошибка
|
||||
commit проходит через обработчики исключений (например, `IntegrityError` → 409).
|
||||
- Одна сессия на запрос. Кэш зависимостей FastAPI различает `scope`: `Depends(get_async_session)` и
|
||||
`Depends(get_async_session, scope="function")` в одном запросе дали бы ДВЕ сессии и две транзакции. Поэтому
|
||||
везде — только `SessionDep` (проверяет тест `test_api_routes_declare_session_with_function_scope` в
|
||||
`tests/test_uow_and_errors.py`).
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.db.db import get_async_session
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_async_session, scope="function")]
|
||||
@ -7,13 +7,12 @@
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.api.dependencies.db import SessionDep
|
||||
from src.core.auth import DevHs256Verifier
|
||||
from src.core.config import Settings
|
||||
from src.db.db import get_async_session
|
||||
from src.repositories.users import AmbiguousUserError, load_principal_data
|
||||
|
||||
USER_NOT_FOUND = "пользователь не найден или отключён"
|
||||
@ -55,9 +54,7 @@ def build_dev_router(settings: Settings) -> APIRouter:
|
||||
router = APIRouter(tags=["dev"])
|
||||
|
||||
@router.post("/dev/token", response_model=DevTokenResponse, summary="Dev-токен по email (только DEBUG)")
|
||||
async def issue_dev_token(
|
||||
body: DevTokenRequest, session: Annotated[AsyncSession, Depends(get_async_session)]
|
||||
) -> DevTokenResponse:
|
||||
async def issue_dev_token(body: DevTokenRequest, session: SessionDep) -> DevTokenResponse:
|
||||
"""HS256 JWT на 8 часов для существующего активного пользователя."""
|
||||
try:
|
||||
principal = await load_principal_data(session, body.email)
|
||||
|
||||
@ -1,6 +1,47 @@
|
||||
class NotFoundError(Exception):
|
||||
pass
|
||||
"""Доменные ошибки (spec directories-api): обработчик отдаёт `{"detail": {"code", "message", **extra}}`.
|
||||
|
||||
`code` — машинный код ошибки (`NOT_FOUND`, `INN_EXISTS`, …), `message` — текст по-русски, `extra` — дополнительные поля
|
||||
ответа (например, `legal_entity_id` существующей записи). Ответы `HTTPException` (401/403 строкой) — отдельно.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import status as http_status
|
||||
|
||||
_RESERVED_KEYS = frozenset({"code", "message"})
|
||||
|
||||
|
||||
class CouldntCreateError(Exception):
|
||||
pass
|
||||
class DomainError(Exception):
|
||||
"""Базовая доменная ошибка с HTTP-статусом ответа."""
|
||||
|
||||
def __init__(self, status: int, code: str, message: str, extra: dict[str, Any] | None = None) -> None:
|
||||
extra = dict(extra or {})
|
||||
shadowed = sorted(_RESERVED_KEYS & extra.keys())
|
||||
if shadowed:
|
||||
raise ValueError(f"дополнительные поля ошибки не могут называться {', '.join(shadowed)}")
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.extra = extra
|
||||
|
||||
|
||||
class NotFoundError(DomainError):
|
||||
"""404: объекта нет или он вне видимости пользователя (тело одинаковое — без утечки существования)."""
|
||||
|
||||
def __init__(self, message: str = "объект не найден") -> None:
|
||||
super().__init__(http_status.HTTP_404_NOT_FOUND, "NOT_FOUND", message)
|
||||
|
||||
|
||||
class ConflictError(DomainError):
|
||||
"""409: конфликт с существующими данными (`INN_EXISTS`, `MEMBER_EXISTS`, …)."""
|
||||
|
||||
def __init__(self, code: str, message: str, **extra: Any) -> None:
|
||||
super().__init__(http_status.HTTP_409_CONFLICT, code, message, extra)
|
||||
|
||||
|
||||
class DomainValidationError(DomainError):
|
||||
"""422: нарушение доменного правила валидации (`INVALID_INN`, `DIFFERENT_COMPANY_GROUP`, …)."""
|
||||
|
||||
def __init__(self, code: str, message: str, **extra: Any) -> None:
|
||||
super().__init__(http_status.HTTP_422_UNPROCESSABLE_CONTENT, code, message, extra)
|
||||
|
||||
@ -15,6 +15,8 @@ from fastapi.responses import JSONResponse
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import DBAPIError, IntegrityError, SQLAlchemyError
|
||||
|
||||
from src.common.exceptions import DomainError
|
||||
|
||||
LOG_STATEMENT_LIMIT = 200 # символов SQL-текста (с плейсхолдерами) в записи лога
|
||||
|
||||
|
||||
@ -36,6 +38,14 @@ def log_db_error(request: Request, exc: SQLAlchemyError, message: str) -> None:
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
# Доменные ошибки — до обработчиков `SQLAlchemyError`/`Exception` (spec directories-api: формат detail-объекта)
|
||||
@app.exception_handler(DomainError)
|
||||
async def domain_error_handler(_: Request, exc: DomainError):
|
||||
return JSONResponse(
|
||||
status_code=exc.status,
|
||||
content={"detail": {"code": exc.code, "message": exc.message, **jsonable_encoder(exc.extra)}},
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def request_validation_exception_handler(_: Request, exc: RequestValidationError):
|
||||
return JSONResponse(
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
# from fastapi_users.db import SQLAlchemyUserDatabase, SQLAlchemyBaseUserTableUUID
|
||||
@ -9,7 +11,11 @@ engine = create_async_engine(
|
||||
async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
# @asynccontextmanager
|
||||
async def get_async_session():
|
||||
async with async_session_maker() as session:
|
||||
async def get_async_session() -> AsyncIterator[AsyncSession]:
|
||||
"""Сессия запроса = одна транзакция: commit при успешном ответе, rollback при любом исключении (R-1, design #12).
|
||||
|
||||
В маршрутах объявляется только через `src.api.dependencies.db.SessionDep` (`scope="function"`): тогда commit
|
||||
выполняется до отправки ответа, а все зависимости запроса получают одну и ту же сессию.
|
||||
"""
|
||||
async with async_session_maker() as session, session.begin():
|
||||
yield session
|
||||
|
||||
33
api/src/schemas/common.py
Normal file
33
api/src/schemas/common.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""Общие схемы API: страница списка, параметры пагинации, тело доменной ошибки (spec directories-api)."""
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
DEFAULT_PAGE_LIMIT = 50
|
||||
MAX_PAGE_LIMIT = 200
|
||||
|
||||
|
||||
class Page(BaseModel, Generic[T]):
|
||||
"""Страница списка: элементы, общее число записей по фильтру, запрошенные `limit`/`offset`."""
|
||||
|
||||
items: list[T]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PageParams(BaseModel):
|
||||
"""Параметры пагинации из query: `Annotated[PageParams, Query()]`."""
|
||||
|
||||
limit: int = Field(DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT)
|
||||
offset: int = Field(0, ge=0)
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
"""Тело `detail` доменной ошибки (для `responses` в OpenAPI): `{"code": "...", "message": "..."}`."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
385
api/tests/test_uow_and_errors.py
Normal file
385
api/tests/test_uow_and_errors.py
Normal file
@ -0,0 +1,385 @@
|
||||
"""Инфраструктура запросов Среза 1 (tasks 1.2): транзакция на запрос, доменные ошибки, `RequestAudit`, пагинация.
|
||||
|
||||
Unit-часть — без БД: формат ответов доменных ошибок (`{"detail": {"code", "message", …}}`), прежний формат
|
||||
`HTTPException`, схемы `Page`/`PageParams`, делегирование `RequestAudit.record` и сборка `get_request_audit`.
|
||||
Integration-часть — единственное место, где проверяется НАСТОЯЩИЙ commit (SAVEPOINT-сессия `db_session` его не
|
||||
ловит): своя временная БД, `src.db.db.async_session_maker` подменён на фабрику сессий этой БД, видимость строк —
|
||||
из другой сессии (другого соединения).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
from types import SimpleNamespace
|
||||
from typing import Annotated, Any, cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query
|
||||
from fastapi import status as http_status
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||
from starlette.types import Message, Receive, Scope, Send
|
||||
|
||||
from src.api.dependencies.audit import RequestAudit, get_request_audit
|
||||
from src.api.dependencies.auth import current_user
|
||||
from src.api.dependencies.db import SessionDep
|
||||
from src.api.router import build_api_router
|
||||
from src.common.exceptions import ConflictError, DomainError, DomainValidationError, NotFoundError
|
||||
from src.core.config import get_settings
|
||||
from src.core.exception_handlers import register_exception_handlers
|
||||
from src.db.db import get_async_session
|
||||
from src.models.directories import RegionalOffice
|
||||
from src.models.enums import AuditAction, AuditSection
|
||||
from src.schemas.common import ErrorDetail, Page, PageParams
|
||||
from src.schemas.me import RegionalOfficeOut
|
||||
from src.services.audit import AuditService
|
||||
from tests.pg_helpers import alembic_async
|
||||
|
||||
CONFLICT_ID = uuid.UUID("00000000-0000-0000-0000-00000000c0de")
|
||||
|
||||
|
||||
def _client(app: FastAPI) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test")
|
||||
|
||||
|
||||
# --- Доменные ошибки ---------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _errors_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
register_exception_handlers(app)
|
||||
|
||||
@app.get("/not-found")
|
||||
async def not_found() -> None:
|
||||
raise NotFoundError("ГК не найдена")
|
||||
|
||||
@app.get("/not-found-default")
|
||||
async def not_found_default() -> None:
|
||||
raise NotFoundError()
|
||||
|
||||
@app.get("/conflict")
|
||||
async def conflict() -> None:
|
||||
raise ConflictError("INN_EXISTS", "ИНН уже есть", legal_entity_id=CONFLICT_ID)
|
||||
|
||||
@app.get("/invalid")
|
||||
async def invalid() -> None:
|
||||
raise DomainValidationError("INVALID_INN", "ИНН должен содержать 10 или 12 цифр")
|
||||
|
||||
@app.get("/forbidden")
|
||||
async def forbidden() -> None:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "x")
|
||||
|
||||
@app.get("/page")
|
||||
async def page(params: Annotated[PageParams, Query()]) -> dict[str, int]:
|
||||
return {"limit": params.limit, "offset": params.offset}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _get(app: FastAPI, path: str) -> httpx.Response:
|
||||
async with _client(app) as client:
|
||||
return await client.get(path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "status", "detail"),
|
||||
[
|
||||
("/not-found", HTTPStatus.NOT_FOUND, {"code": "NOT_FOUND", "message": "ГК не найдена"}),
|
||||
("/not-found-default", HTTPStatus.NOT_FOUND, {"code": "NOT_FOUND", "message": "объект не найден"}),
|
||||
(
|
||||
"/conflict",
|
||||
HTTPStatus.CONFLICT,
|
||||
{"code": "INN_EXISTS", "message": "ИНН уже есть", "legal_entity_id": str(CONFLICT_ID)},
|
||||
),
|
||||
(
|
||||
"/invalid",
|
||||
http_status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
{"code": "INVALID_INN", "message": "ИНН должен содержать 10 или 12 цифр"},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_domain_errors_format(path: str, status: int, detail: dict[str, Any]) -> None:
|
||||
response = await _get(_errors_app(), path)
|
||||
assert response.status_code == status
|
||||
assert response.json() == {"detail": detail}
|
||||
|
||||
|
||||
async def test_http_exception_keeps_string_detail() -> None:
|
||||
response = await _get(_errors_app(), "/forbidden")
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
assert response.json() == {"detail": "x"}
|
||||
|
||||
|
||||
def test_domain_error_fields() -> None:
|
||||
error = ConflictError("INN_EXISTS", "ИНН уже есть", legal_entity_id=CONFLICT_ID)
|
||||
assert isinstance(error, DomainError)
|
||||
assert (error.status, error.code, error.message, error.extra) == (
|
||||
HTTPStatus.CONFLICT,
|
||||
"INN_EXISTS",
|
||||
"ИНН уже есть",
|
||||
{"legal_entity_id": CONFLICT_ID},
|
||||
)
|
||||
assert NotFoundError().extra == {}
|
||||
invalid = DomainValidationError("INVALID_INN", "плохой ИНН", inn="1")
|
||||
assert (invalid.status, invalid.extra) == (http_status.HTTP_422_UNPROCESSABLE_CONTENT, {"inn": "1"})
|
||||
assert str(invalid) == "плохой ИНН"
|
||||
|
||||
|
||||
def test_domain_error_extra_cannot_shadow_code_or_message() -> None:
|
||||
with pytest.raises(ValueError, match="code"):
|
||||
DomainError(HTTPStatus.CONFLICT, "A", "b", {"code": "B"})
|
||||
with pytest.raises(ValueError, match="message"):
|
||||
DomainError(HTTPStatus.CONFLICT, "A", "b", {"message": "c"})
|
||||
|
||||
|
||||
# --- Пагинация ---------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_page_serializes() -> None:
|
||||
office = RegionalOfficeOut(id=CONFLICT_ID, name="ГО", is_head_office=True)
|
||||
page = Page[RegionalOfficeOut](items=[office], total=7, limit=1, offset=3)
|
||||
assert page.model_dump(mode="json") == {
|
||||
"items": [{"id": str(CONFLICT_ID), "name": "ГО", "is_head_office": True}],
|
||||
"total": 7,
|
||||
"limit": 1,
|
||||
"offset": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_page_params_bounds() -> None:
|
||||
assert PageParams().model_dump() == {"limit": 50, "offset": 0}
|
||||
assert PageParams(limit=200).limit == 200 # noqa: PLR2004 - граница из спеки
|
||||
for bad in ({"limit": 201}, {"limit": 0}, {"offset": -1}):
|
||||
with pytest.raises(ValidationError):
|
||||
PageParams(**bad)
|
||||
|
||||
|
||||
async def test_page_params_as_query() -> None:
|
||||
app = _errors_app()
|
||||
ok = await _get(app, "/page?limit=10&offset=5")
|
||||
assert ok.json() == {"limit": 10, "offset": 5}
|
||||
assert (await _get(app, "/page")).json() == {"limit": 50, "offset": 0}
|
||||
too_big = await _get(app, "/page?limit=201")
|
||||
assert too_big.status_code == http_status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
|
||||
|
||||
def test_error_detail_schema() -> None:
|
||||
assert ErrorDetail(code="NOT_FOUND", message="объект не найден").model_dump() == {
|
||||
"code": "NOT_FOUND",
|
||||
"message": "объект не найден",
|
||||
}
|
||||
|
||||
|
||||
# --- RequestAudit ------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RecordingService:
|
||||
calls: list[dict[str, Any]]
|
||||
|
||||
async def record(self, **kwargs: Any) -> str:
|
||||
self.calls.append(kwargs)
|
||||
return "event"
|
||||
|
||||
|
||||
async def test_request_audit_delegates_with_request_context() -> None:
|
||||
service = _RecordingService(calls=[])
|
||||
user_id = uuid.uuid4()
|
||||
audit = RequestAudit(
|
||||
service=cast(AuditService, service), user_id=user_id, ip_address="10.0.0.1", user_agent="pytest-agent"
|
||||
)
|
||||
object_id = uuid.uuid4()
|
||||
result = await audit.record(
|
||||
action=AuditAction.GROUP_CHANGE,
|
||||
section=AuditSection.DIRECTORIES,
|
||||
object_type="company_group",
|
||||
object_id=object_id,
|
||||
before_value={"name": "А"},
|
||||
after_value={"name": "Б"},
|
||||
changed_field="name",
|
||||
)
|
||||
assert result == "event"
|
||||
assert service.calls == [
|
||||
{
|
||||
"action": AuditAction.GROUP_CHANGE,
|
||||
"section": AuditSection.DIRECTORIES,
|
||||
"object_type": "company_group",
|
||||
"object_id": object_id,
|
||||
"description": None,
|
||||
"before_value": {"name": "А"},
|
||||
"after_value": {"name": "Б"},
|
||||
"comment": None,
|
||||
"result": None,
|
||||
"changed_field": "name",
|
||||
"user_id": user_id,
|
||||
"ip_address": "10.0.0.1",
|
||||
"user_agent": "pytest-agent",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_get_request_audit_takes_user_ip_and_user_agent_from_request() -> None:
|
||||
user_id = uuid.uuid4()
|
||||
sessions: list[object] = []
|
||||
app = FastAPI()
|
||||
register_exception_handlers(app)
|
||||
|
||||
@app.get("/audit")
|
||||
async def audit_context(audit: Annotated[RequestAudit, Depends(get_request_audit)]) -> dict[str, Any]:
|
||||
return {
|
||||
"user_id": str(audit.user_id),
|
||||
"ip": audit.ip_address,
|
||||
"ua": audit.user_agent,
|
||||
"service": type(audit.service).__name__,
|
||||
}
|
||||
|
||||
async def fake_session() -> AsyncIterator[object]:
|
||||
session = object()
|
||||
sessions.append(session)
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_async_session] = fake_session
|
||||
app.dependency_overrides[current_user] = lambda: SimpleNamespace(user=SimpleNamespace(id=user_id))
|
||||
async with _client(app) as client:
|
||||
response = await client.get("/audit", headers={"User-Agent": "pytest-agent/1.0"})
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json() == {
|
||||
"user_id": str(user_id),
|
||||
"ip": "127.0.0.1",
|
||||
"ua": "pytest-agent/1.0",
|
||||
"service": "AuditService",
|
||||
}
|
||||
assert len(sessions) == 1
|
||||
|
||||
|
||||
# --- Одна сессия на запрос: зависимость БД объявляется только через SessionDep ---------------------------------
|
||||
|
||||
|
||||
def _session_scopes(dependant: Any) -> list[str | None]:
|
||||
found: list[str | None] = []
|
||||
for sub in dependant.dependencies:
|
||||
if sub.call is get_async_session:
|
||||
found.append(sub.scope)
|
||||
found.extend(_session_scopes(sub))
|
||||
return found
|
||||
|
||||
|
||||
def test_api_routes_declare_session_with_function_scope() -> None:
|
||||
"""Все маршруты API берут сессию через `SessionDep` (`scope="function"`).
|
||||
|
||||
`Depends(get_async_session)` без scope даёт ДРУГОЙ ключ кэша зависимостей FastAPI — вторую сессию и вторую
|
||||
транзакцию в том же запросе, и commit после отправки ответа. Тест ловит такое объявление в любом маршруте.
|
||||
"""
|
||||
settings = get_settings().model_copy(update={"DEBUG": True, "AUTH_DEV_ISSUER": True, "AUTH_DEV_SECRET": "s" * 32})
|
||||
routes = [route for route in build_api_router(settings).routes if isinstance(route, APIRoute)]
|
||||
scopes = [scope for route in routes for scope in _session_scopes(route.dependant)]
|
||||
assert scopes, "ни один маршрут не использует сессию БД — тест ничего не проверяет"
|
||||
assert set(scopes) == {"function"}
|
||||
|
||||
|
||||
# --- Integration: настоящий commit/rollback ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def uow_maker(
|
||||
scratch_db_url: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||
"""Фабрика сессий на свежую мигрированную БД; `get_async_session` открывает сессии из неё."""
|
||||
await alembic_async(scratch_db_url, "upgrade", "head")
|
||||
engine: AsyncEngine = create_async_engine(scratch_db_url, connect_args={"server_settings": {"search_path": "dkb"}})
|
||||
maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
monkeypatch.setattr("src.db.db.async_session_maker", maker)
|
||||
try:
|
||||
yield maker
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _uow_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
register_exception_handlers(app)
|
||||
|
||||
@app.post("/ok", status_code=HTTPStatus.CREATED)
|
||||
async def ok(name: str, session: SessionDep) -> dict[str, str]:
|
||||
session.add(RegionalOffice(name=name))
|
||||
await session.flush()
|
||||
return {"name": name}
|
||||
|
||||
@app.post("/fail")
|
||||
async def fail(name: str, session: SessionDep) -> None:
|
||||
session.add(RegionalOffice(name=name))
|
||||
await session.flush()
|
||||
raise ConflictError("TEST_CONFLICT", "проверка отката")
|
||||
|
||||
@app.post("/fail-http")
|
||||
async def fail_http(name: str, session: SessionDep) -> None:
|
||||
session.add(RegionalOffice(name=name))
|
||||
await session.flush()
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "нет доступа")
|
||||
|
||||
@app.post("/no-flush", status_code=HTTPStatus.CREATED)
|
||||
async def no_flush(name: str, session: SessionDep) -> dict[str, str]:
|
||||
# без flush: INSERT уходит в БД только при commit — ошибка commit обязана стать ответом
|
||||
session.add(RegionalOffice(name=name))
|
||||
return {"name": name}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _office_exists(maker: async_sessionmaker[AsyncSession], name: str) -> bool:
|
||||
async with maker() as other:
|
||||
found = await other.scalar(select(RegionalOffice.id).where(RegionalOffice.name == name))
|
||||
return found is not None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_success_commits(uow_maker: async_sessionmaker[AsyncSession]) -> None:
|
||||
async with _client(_uow_app()) as client:
|
||||
response = await client.post("/ok", params={"name": "Филиал UoW"})
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
assert await _office_exists(uow_maker, "Филиал UoW")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize(("path", "status"), [("/fail", HTTPStatus.CONFLICT), ("/fail-http", HTTPStatus.FORBIDDEN)])
|
||||
async def test_exception_rolls_back(uow_maker: async_sessionmaker[AsyncSession], path: str, status: int) -> None:
|
||||
async with _client(_uow_app()) as client:
|
||||
response = await client.post(path, params={"name": f"Филиал {path}"})
|
||||
assert response.status_code == status
|
||||
assert not await _office_exists(uow_maker, f"Филиал {path}")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_commit_happens_before_response_is_sent(uow_maker: async_sessionmaker[AsyncSession]) -> None:
|
||||
"""В момент `http.response.start` строка уже видна другим соединениям: клиент не получит 201 раньше commit."""
|
||||
inner = _uow_app()
|
||||
seen_at_start: list[bool] = []
|
||||
|
||||
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
async def watching_send(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
seen_at_start.append(await _office_exists(uow_maker, "Филиал до ответа"))
|
||||
await send(message)
|
||||
|
||||
await inner(scope, receive, watching_send)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.post("/ok", params={"name": "Филиал до ответа"})
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
assert seen_at_start == [True]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_commit_failure_becomes_error_response(uow_maker: async_sessionmaker[AsyncSession]) -> None:
|
||||
"""Нарушение уникальности, всплывшее только при commit, — ответ 409, а не 201 с потерянными данными."""
|
||||
async with uow_maker() as session, session.begin():
|
||||
session.add(RegionalOffice(name="Филиал-дубль"))
|
||||
async with _client(_uow_app()) as client:
|
||||
response = await client.post("/no-flush", params={"name": "Филиал-дубль"})
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.json() == {"detail": "нарушение целостности данных в БД"}
|
||||
@ -196,16 +196,16 @@ Run: `cd api && .venv/bin/pytest -m integration tests/test_migrations.py tests/t
|
||||
- `RequestAudit` (`src/api/dependencies/audit.py`): `@dataclass(frozen=True) class RequestAudit: service: AuditService; user_id: uuid.UUID; ip_address: str | None; user_agent: str | None` с методом `async def record(self, *, action, section, object_type, object_id=None, description=None, before_value=None, after_value=None, comment=None, result=None, changed_field=None) -> AuditEvent` (делегирует `AuditService.record`, подставляя `user_id`/`ip_address`/`user_agent`). Зависимость `async def get_request_audit(request: Request, session: Annotated[AsyncSession, Depends(get_async_session)], principal: Annotated[Principal, Depends(current_user)]) -> RequestAudit` (`ip_address = request.client.host if request.client else None`, `user_agent = request.headers.get("user-agent")`).
|
||||
- `src/schemas/common.py`: `class Page(BaseModel, Generic[T]): items: list[T]; total: int; limit: int; offset: int`; `class PageParams(BaseModel): limit: int = Field(50, ge=1, le=200); offset: int = Field(0, ge=0)` — используется как `Annotated[PageParams, Query()]`; `class ErrorDetail(BaseModel): code: str; message: str` (для OpenAPI `responses`).
|
||||
|
||||
- [ ] **Step 1: Тесты**
|
||||
- [x] **Step 1: Тесты**
|
||||
|
||||
`tests/test_uow_and_errors.py`:
|
||||
- unit: мини-`FastAPI()` с `register_exception_handlers`, маршруты бросают `NotFoundError("ГК не найдена")`, `ConflictError("INN_EXISTS", "ИНН уже есть", legal_entity_id=uuid)`, `DomainValidationError("INVALID_INN", "…")` → 404/409/422 и тело `{"detail": {"code", "message", ...}}`; `HTTPException(403, "x")` по-прежнему `{"detail": "x"}`.
|
||||
- integration (`scratch_db_url` + `alembic upgrade head` через `tests.pg_helpers.alembic_async`, затем свой `async_sessionmaker` на этот URL с переопределением `src.db.db.async_session_maker` через `monkeypatch`): приложение с двумя маршрутами на `Depends(get_async_session)` — `POST /ok` вставляет `regional_office` и возвращает 201; `POST /fail` вставляет и бросает `ConflictError`. После `/ok` строка видна из **другой** сессии; после `/fail` — нет. Это единственный тест, который проверяет реальный commit (SAVEPOINT-сессия тестов его не ловит).
|
||||
- `Page[RegionalOfficeOut]` сериализуется; `PageParams(limit=201)` → ValidationError.
|
||||
|
||||
- [ ] **Step 2: Запустить — падает (нет модулей/обработчика)**
|
||||
- [x] **Step 2: Запустить — падает (нет модулей/обработчика)**
|
||||
|
||||
- [ ] **Step 3: Реализация** по интерфейсам выше. В `exception_handlers.py` обработчик `DomainError` регистрируется **до** `Exception`. `src/db/db.py`:
|
||||
- [x] **Step 3: Реализация** по интерфейсам выше. В `exception_handlers.py` обработчик `DomainError` регистрируется **до** `Exception`. `src/db/db.py`:
|
||||
```python
|
||||
async def get_async_session() -> AsyncIterator[AsyncSession]:
|
||||
"""Сессия запроса = одна транзакция: commit при успешном ответе, rollback при любом исключении (R-1, design #12)."""
|
||||
@ -214,9 +214,9 @@ async def get_async_session() -> AsyncIterator[AsyncSession]:
|
||||
```
|
||||
Проверить, что `src/api/endpoints/dev_auth.py` и обмен (`services/recognition_exchange.py`) не используют `get_async_session` с собственным `commit()` (они работают через `async_session_maker` напрямую — не трогать).
|
||||
|
||||
- [ ] **Step 4: Тесты зелёные**: `pytest tests/test_uow_and_errors.py -v`, затем `pytest -m "not integration"` и `pytest -m integration tests/test_me_and_dev_token.py tests/test_auth_deps.py` (регрессия Среза 0).
|
||||
- [x] **Step 4: Тесты зелёные**: `pytest tests/test_uow_and_errors.py -v`, затем `pytest -m "not integration"` и `pytest -m integration tests/test_me_and_dev_token.py tests/test_auth_deps.py` (регрессия Среза 0).
|
||||
|
||||
- [ ] **Step 5: Commit** `feat(api): транзакция на запрос, RequestAudit, доменные ошибки 404/409/422, Page`.
|
||||
- [x] **Step 5: Commit** `feat(api): транзакция на запрос, RequestAudit, доменные ошибки 404/409/422, Page`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ test-integration`). Пути — от корня репозитория. Доп
|
||||
`DBML_VERSION = "5.14"`, `Makefile DBML`, `dbml_parity.DBML_FILE_NAME`, `tests/test_migrations.HEAD`, `test_enums`,
|
||||
`test_check_dbml_script` (фиктивная версия 5.15), `_check_related_group_member_uniqueness`.
|
||||
Проверка: `make dbml-check` пуст; `make migration-cycle` зелёный; тест «ЮЛ в ГСК и ГСЗ одновременно, вторая ГСК — IntegrityError».
|
||||
- [ ] 1.2 **Единица работы, аудит, исключения, пагинация.** `api/src/api/dependencies/db.py:get_uow_session` (commit при успехе,
|
||||
- [x] 1.2 **Единица работы, аудит, исключения, пагинация.** `api/src/api/dependencies/db.py:get_uow_session` (commit при успехе,
|
||||
rollback при исключении); `dependencies/audit.py:get_audit_service` (ip/user-agent/user_id); `common/exceptions.py`:
|
||||
`NotFoundError`, `ConflictError(code, message, extra)`, `DomainValidationError(code, message)` + обработчики 404/409/422
|
||||
в `core/exception_handlers.py` (формат `{"detail": {"code", "message", …}}`); `schemas/common.py`: `Page[T]`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user