From 5976986e1b6b7e9747ed8e167252bae0e676fec3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 21:03:07 +0400 Subject: [PATCH] =?UTF-8?q?feat(api):=20=D1=82=D1=80=D0=B0=D0=BD=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=86=D0=B8=D1=8F=20=D0=BD=D0=B0=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81,=20RequestAudit,=20=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B8=20404/409/422,=20Page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/dependencies/audit.py | 73 ++++ api/src/api/dependencies/auth.py | 5 +- api/src/api/dependencies/db.py | 22 + api/src/api/endpoints/dev_auth.py | 9 +- api/src/common/exceptions.py | 49 ++- api/src/core/exception_handlers.py | 10 + api/src/db/db.py | 12 +- api/src/schemas/common.py | 33 ++ api/tests/test_uow_and_errors.py | 385 ++++++++++++++++++ .../2026-10-04-slice-1-directories-phase1.md | 10 +- openspec/changes/slice-1-directories/tasks.md | 2 +- 11 files changed, 588 insertions(+), 22 deletions(-) create mode 100644 api/src/api/dependencies/audit.py create mode 100644 api/src/api/dependencies/db.py create mode 100644 api/src/schemas/common.py create mode 100644 api/tests/test_uow_and_errors.py diff --git a/api/src/api/dependencies/audit.py b/api/src/api/dependencies/audit.py new file mode 100644 index 0000000..2d41694 --- /dev/null +++ b/api/src/api/dependencies/audit.py @@ -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"), + ) diff --git a/api/src/api/dependencies/auth.py b/api/src/api/dependencies/auth.py index 69ad7f1..5fd7b8e 100644 --- a/api/src/api/dependencies/auth.py +++ b/api/src/api/dependencies/auth.py @@ -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 из токена с филиалом, ролями и эффективными правами (один запрос к БД). diff --git a/api/src/api/dependencies/db.py b/api/src/api/dependencies/db.py new file mode 100644 index 0000000..332091f --- /dev/null +++ b/api/src/api/dependencies/db.py @@ -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")] diff --git a/api/src/api/endpoints/dev_auth.py b/api/src/api/endpoints/dev_auth.py index e1708d6..681d9a9 100644 --- a/api/src/api/endpoints/dev_auth.py +++ b/api/src/api/endpoints/dev_auth.py @@ -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) diff --git a/api/src/common/exceptions.py b/api/src/common/exceptions.py index 3eaa18e..8e7360f 100644 --- a/api/src/common/exceptions.py +++ b/api/src/common/exceptions.py @@ -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) diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index 6ede971..25b1aa4 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -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( diff --git a/api/src/db/db.py b/api/src/db/db.py index 44bf885..b32ef1a 100644 --- a/api/src/db/db.py +++ b/api/src/db/db.py @@ -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 diff --git a/api/src/schemas/common.py b/api/src/schemas/common.py new file mode 100644 index 0000000..688d712 --- /dev/null +++ b/api/src/schemas/common.py @@ -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 diff --git a/api/tests/test_uow_and_errors.py b/api/tests/test_uow_and_errors.py new file mode 100644 index 0000000..cf9d1da --- /dev/null +++ b/api/tests/test_uow_and_errors.py @@ -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": "нарушение целостности данных в БД"} diff --git a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md index 1cba46c..4ec6936 100644 --- a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md +++ b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md @@ -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`. --- diff --git a/openspec/changes/slice-1-directories/tasks.md b/openspec/changes/slice-1-directories/tasks.md index 93388d6..e559724 100644 --- a/openspec/changes/slice-1-directories/tasks.md +++ b/openspec/changes/slice-1-directories/tasks.md @@ -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]`