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:
Andrey Epifancev 2026-10-04 21:03:07 +04:00
parent c80e029b96
commit 5976986e1b
11 changed files with 588 additions and 22 deletions

View 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"),
)

View File

@ -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 из токена с филиалом, ролями и эффективными правами (один запрос к БД).

View 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")]

View File

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

View File

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

View File

@ -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(

View File

@ -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
View 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

View 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": "нарушение целостности данных в БД"}

View File

@ -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`.
---

View File

@ -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]`