From 310e63224dcd4b3c913a2b4e59e5d6c4d1f732dd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 12:55:00 +0400 Subject: [PATCH] =?UTF-8?q?feat(files):=20GET=20/files/{id}/content=20?= =?UTF-8?q?=E2=80=94=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=B8=D0=B7=20S3,=20RFC=205987=20(D-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поток из S3 порциями без чтения в память; нет объекта — 404 FILE_CONTENT_NOT_FOUND до заголовков; при обрыве потребителя ответ S3 закрывается и отпускается. Content-Disposition вынесен в common/filenames. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 1 + .../api/endpoints/consolidations/export.py | 14 +- api/src/api/endpoints/files/read.py | 36 +++- api/src/common/filenames.py | 8 + api/src/core/clients.py | 16 ++ api/src/core/storage.py | 9 +- api/src/services/primary_files.py | 56 +++++- api/tests/conftest.py | 10 +- api/tests/test_api_files_content.py | 167 ++++++++++++++++++ api/tests/test_clients.py | 32 ++++ api/tests/test_openapi_routes.py | 1 + 11 files changed, 334 insertions(+), 16 deletions(-) create mode 100644 api/tests/test_api_files_content.py diff --git a/README.md b/README.md index 7a85e03..d18f7cb 100644 --- a/README.md +++ b/README.md @@ -755,6 +755,7 @@ email без учёта регистра проверяет только сер | `FILE_SUPERSEDED` | 409 | подтверждение: документ файла, заменённого новой версией | | `DOCUMENT_NOT_CONFIRMABLE` | 409 | подтверждение: статус документа не `RECOGNIZED`/`NEEDS_CHECK` (например, `ERROR`) | | `FILE_HAS_CONFIRMED_DOCUMENTS` | 409 | удаление файла с `CONFIRMED` документом | +| `FILE_CONTENT_NOT_FOUND` | 404 | `GET /files/{id}/content`: файл есть, объекта нет в S3 (истёк или пропал) | | `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла, на который ссылаются `superseded_by_file_id` других файлов | | `CONSOLIDATION_EXISTS` | 409 | консолидация этой ГК за год и квартал уже создана (в т. ч. гонка двух `POST /consolidations`) | | `PERIMETER_TOO_SMALL` | 422 | в ГСК (при создании) или в новом периметре (`PUT …/members`) меньше двух ЮЛ; ничего не создаётся и не меняется | diff --git a/api/src/api/endpoints/consolidations/export.py b/api/src/api/endpoints/consolidations/export.py index a960f42..8d348de 100644 --- a/api/src/api/endpoints/consolidations/export.py +++ b/api/src/api/endpoints/consolidations/export.py @@ -5,19 +5,20 @@ import uuid from typing import Annotated, Any -from urllib.parse import quote from fastapi import APIRouter, Depends, Query, Response, status from src.api.dependencies.audit import RequestAudit, get_request_audit from src.api.dependencies.auth import require from src.api.dependencies.consolidations import ConsolidatedOsvServiceDep +from src.common.filenames import content_disposition from src.schemas.common import ErrorDetail from src.schemas.consolidations import OsvFilters router = APIRouter(prefix="/consolidations/versions") XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +EXPORT_ASCII_NAME = "svod-osv.xlsx" VIEW = [Depends(require("CONSOLIDATION_VIEW"))] RESPONSES: dict[int | str, dict[str, Any]] = { status.HTTP_200_OK: { @@ -28,12 +29,6 @@ RESPONSES: dict[int | str, dict[str, Any]] = { } -def content_disposition(filename: str) -> str: - """Заголовок вложения: ASCII-имя по умолчанию и `filename*` (RFC 5987) с полным percent-кодированием имени — - пробел, кавычки, `;`, `/`, переводы строк и кириллица экранируются, заголовок остаётся чистым ASCII.""" - return f"attachment; filename=\"svod-osv.xlsx\"; filename*=UTF-8''{quote(filename, safe='')}" - - @router.get( "/{version_id}/osv/export", response_class=Response, @@ -50,6 +45,5 @@ async def export_osv( """Файл `svod-osv_<ГК>_<год>-<квартал>_v.xlsx`: строки свода после фильтров (как у `GET …/osv`, без пагинации, в том же порядке) и лист пробелов по своду целиком. Пишет `audit_event(EXPORT)`.""" content, filename = await service.export(version_id, filters, audit) - return Response( - content=content, media_type=XLSX_MEDIA_TYPE, headers={"Content-Disposition": content_disposition(filename)} - ) + header = content_disposition(filename, ascii_fallback=EXPORT_ASCII_NAME) + return Response(content=content, media_type=XLSX_MEDIA_TYPE, headers={"Content-Disposition": header}) diff --git a/api/src/api/endpoints/files/read.py b/api/src/api/endpoints/files/read.py index 3a1eaa4..a2b1ba4 100644 --- a/api/src/api/endpoints/files/read.py +++ b/api/src/api/endpoints/files/read.py @@ -1,6 +1,7 @@ """`GET /legal-entities/{legal_entity_id}/files`, `GET /files/{file_id}` — файлы организации и карточка файла (spec documents-api «Файлы организации и карточка файла»; design D-11); `DELETE /files/{file_id}` — удаление файла -(spec primary-files «Удаление файла»; design D-10). +(spec primary-files «Удаление файла»; design D-10); `GET /files/{file_id}/content` — исходный файл потоком из S3 +(spec documents-api «Исходный файл»; design D-10). Право `DOCUMENT_VIEW` (удаление — `DOCUMENT_EDIT`) проверяется до видимости; ЮЛ или файл вне видимости — тот же 404, что несуществующий. @@ -8,9 +9,10 @@ """ import uuid -from typing import Annotated, Any +from typing import Annotated, Any, Literal from fastapi import APIRouter, Depends, Query, status +from fastapi.responses import StreamingResponse from src.api.dependencies.auth import require from src.api.dependencies.files import PrimaryFilesServiceDep @@ -66,6 +68,36 @@ async def get_file(file_id: uuid.UUID, service: PrimaryFilesServiceDep) -> FileO return await service.get_file(file_id) +CONTENT_RESPONSES: dict[int | str, dict[str, Any]] = { + status.HTTP_200_OK: { + "description": "содержимое исходного файла (тип — по `file_type`)", + "content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}}, + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorDetail, + "description": "файл нет или вне видимости; FILE_CONTENT_NOT_FOUND — объекта нет в хранилище", + }, + status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "STORAGE_UNAVAILABLE: S3 не подключено"}, +} + + +@router.get( + "/files/{file_id}/content", + response_class=StreamingResponse, + dependencies=VIEW, + responses=CONTENT_RESPONSES, + summary="Исходный файл", +) +async def get_file_content( + file_id: uuid.UUID, + service: PrimaryFilesServiceDep, + disposition: Annotated[Literal["attachment", "inline"], Query()] = "attachment", +) -> StreamingResponse: + """Содержимое исходного файла из S3 потоком (в память целиком не читается); `inline` — для просмотра в браузере.""" + stream, media_type, header = await service.content(file_id, disposition) + return StreamingResponse(stream, media_type=media_type, headers={"Content-Disposition": header}) + + @router.delete( "/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT, diff --git a/api/src/common/filenames.py b/api/src/common/filenames.py index 7f53139..c549cbc 100644 --- a/api/src/common/filenames.py +++ b/api/src/common/filenames.py @@ -6,6 +6,7 @@ import unicodedata from pathlib import PurePosixPath +from urllib.parse import quote from fastapi import HTTPException, UploadFile, status @@ -50,6 +51,13 @@ def safe_filename(filename: str | None, *, default: str = DEFAULT_FILENAME) -> s return name +def content_disposition(filename: str, *, disposition: str = "attachment", ascii_fallback: str) -> str: + """Заголовок `Content-Disposition`: ASCII-имя `ascii_fallback` и `filename*` (RFC 5987) с полным + percent-кодированием `filename` — пробел, кавычки, `;`, `/`, переводы строк и кириллица экранируются, + заголовок остаётся чистым ASCII.""" + return f"{disposition}; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quote(filename, safe='')}" + + def extension(name: str) -> str: """Расширение имени в нижнем регистре без точки (`ОСВ.XLS` → `xls`; без расширения — пустая строка).""" return PurePosixPath(name).suffix.lstrip(".").lower() diff --git a/api/src/core/clients.py b/api/src/core/clients.py index 36dd199..0d2860f 100644 --- a/api/src/core/clients.py +++ b/api/src/core/clients.py @@ -2,6 +2,7 @@ по docs/context/03-architecture.md (референс — reference-old-arm/api/src/core/clients.py). """ +from collections.abc import AsyncIterator from io import BytesIO from miniopy_async import Minio @@ -45,3 +46,18 @@ class S3Storage: finally: response.close() await response.release() + + async def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]: + """Объект порциями; `NoSuchKey` → `ObjectNotFoundError` до первой порции; ответ отпускается и при обрыве.""" + try: + response = await self.client.get_object(self.bucket, obj_name) + except S3Error as error: + if error.code == NO_SUCH_KEY: + raise ObjectNotFoundError(obj_name) from error + raise + try: + async for chunk in response.content.iter_chunked(chunk_size): + yield chunk + finally: + response.close() + await response.release() diff --git a/api/src/core/storage.py b/api/src/core/storage.py index 5ef1544..cdfb87a 100644 --- a/api/src/core/storage.py +++ b/api/src/core/storage.py @@ -4,6 +4,7 @@ `services/directory_import.py`: тот же интерфейс нужен загрузке файлов и маппингу результатов (Срез 2c, D-12). """ +from collections.abc import AsyncIterator from typing import Protocol @@ -12,7 +13,7 @@ class ObjectNotFoundError(Exception): class ObjectStorage(Protocol): - """Что ядру нужно от S3: бакет, запись и чтение объекта целиком.""" + """Что ядру нужно от S3: бакет, запись, чтение объекта целиком и порциями.""" bucket: str @@ -21,3 +22,9 @@ class ObjectStorage(Protocol): async def get_bytes(self, obj_name: str) -> bytes: """Содержимое объекта; отсутствие объекта — `ObjectNotFoundError`.""" ... + + def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]: + """Объект порциями по `chunk_size` байт; отсутствие объекта — `ObjectNotFoundError` до первой порции. + + Закрытие итератора (`aclose`) освобождает соединение с S3.""" + ... diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 5efd649..e5be153 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -18,7 +18,9 @@ audit-trail «События файлов и документов»; design D-4) import hashlib import uuid +from collections.abc import AsyncIterator from dataclasses import dataclass +from typing import Literal from loguru import logger from sqlalchemy import func, select @@ -28,10 +30,10 @@ from starlette import status as http_status from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.exceptions import ConflictError, DomainError, DomainValidationError, NotFoundError -from src.common.filenames import safe_filename +from src.common.filenames import CONTENT_TYPES, content_disposition, extension, safe_filename from src.contract import CONTRACT_VERSION, request_subject from src.core.config import Settings -from src.core.storage import ObjectStorage +from src.core.storage import ObjectNotFoundError, ObjectStorage from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file from src.domain.policy import IN_WORK_STATUSES, VisibilityScope from src.models.consolidation import ConsolidationVersion @@ -65,6 +67,7 @@ FILE_TOO_LARGE = "FILE_TOO_LARGE" STORAGE_UNAVAILABLE = "STORAGE_UNAVAILABLE" STORAGE_UNAVAILABLE_MESSAGE = "хранилище файлов недоступно" FILE_NOT_FOUND = "файл не найден" # одинаково для чужого и несуществующего +FILE_CONTENT_NOT_FOUND = "FILE_CONTENT_NOT_FOUND" FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS" FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS" @@ -76,6 +79,13 @@ class StorageUnavailableError(DomainError): super().__init__(http_status.HTTP_503_SERVICE_UNAVAILABLE, STORAGE_UNAVAILABLE, message) +class FileContentNotFoundError(DomainError): + """404 `FILE_CONTENT_NOT_FOUND`: файл есть в БД, но объекта нет в S3 (истёк или пропал).""" + + def __init__(self) -> None: + super().__init__(http_status.HTTP_404_NOT_FOUND, FILE_CONTENT_NOT_FOUND, "исходный файл недоступен в хранилище") + + @dataclass(frozen=True) class AcceptedFile: """Принятый файл: `stored_file.id`, `recognition_job.id`, очищенное имя.""" @@ -106,6 +116,18 @@ class RejectedFile: ) +async def _chain(first: bytes, rest: AsyncIterator[bytes]) -> AsyncIterator[bytes]: + """Первая (уже прочитанная) порция и остальной поток; при завершении, ошибке и обрыве потребителя поток S3 + закрывается (`aclose` — там `close` + `release` ответа), соединение не остаётся занятым.""" + try: + if first: + yield first + async for chunk in rest: + yield chunk + finally: + await rest.aclose() # type: ignore[attr-defined] + + def upload_key(file_id: uuid.UUID, job_id: uuid.UUID, name: str) -> str: """Ключ исходного файла в S3: `uploads/{file_id}/{job_id}/{имя}` (`03-architecture.md`, схема ключей).""" return f"{UPLOADS_PREFIX}/{file_id}/{job_id}/{name}" @@ -231,6 +253,36 @@ class PrimaryFilesService: (card,) = await self._cards([file]) return card + async def content( + self, file_id: uuid.UUID, disposition: Literal["attachment", "inline"] = "attachment" + ) -> tuple[AsyncIterator[bytes], str, str]: + """Исходный файл потоком: `(поток, Content-Type, Content-Disposition)` (spec documents-api, D-10). + + Файл любого статуса (в т. ч. `SUPERSEDED`) в области видимости; чужой и несуществующий — 404. Ключ и имя + читаются здесь, тело потока сессию не держит (ответ уходит после коммита запроса). Первая порция берётся до + отправки заголовков: отсутствие объекта в S3 — 404 `FILE_CONTENT_NOT_FOUND`, а не оборванный ответ. + Аудит не пишется.""" + file = await files_repo.get_file(self._session, self._scope, file_id) + if file is None: + raise NotFoundError(FILE_NOT_FOUND) + s3_object = await self._session.get(S3Object, file.s3_object_id) + if s3_object is None: + raise FileContentNotFoundError() + storage = self.require_storage() + iterator = storage.stream(s3_object.key) + try: + first = await anext(iterator, b"") + except ObjectNotFoundError: + raise FileContentNotFoundError() from None + except BaseException: + await iterator.aclose() # type: ignore[attr-defined] + raise + name = file.original_name or "" + suffix = extension(name) or file.file_type.value.lower() + header = content_disposition(name or f"file.{suffix}", disposition=disposition, ascii_fallback=f"file.{suffix}") + media_type = CONTENT_TYPES.get(file.file_type.value.lower(), "application/octet-stream") + return _chain(first, iterator), media_type, header + async def delete(self, file_id: uuid.UUID) -> None: """Удалить видимый файл (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`; design D-10). diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 108e1b9..b1caa39 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -207,7 +207,7 @@ async def auth_client(app: FastAPI, seeded: AsyncSession) -> AsyncIterator[AuthC class FakeStorage: - """Заглушка `S3Storage` (протокол `ObjectStorage`): `bucket`/`put_object`/`get_bytes`, объекты — в словаре.""" + """Заглушка `S3Storage` (протокол `ObjectStorage`): `put_object`/`get_bytes`/`stream`, объекты — в словаре.""" def __init__(self, bucket: str = "arm-ka") -> None: self.bucket = bucket @@ -222,6 +222,14 @@ class FakeStorage: except KeyError: raise ObjectNotFoundError(obj_name) from None + async def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]: + try: + data = self.objects[obj_name] + except KeyError: + raise ObjectNotFoundError(obj_name) from None + for start in range(0, len(data), chunk_size): + yield data[start : start + chunk_size] + @pytest_asyncio.fixture async def committed_sessionmaker() -> AsyncIterator[async_sessionmaker[AsyncSession]]: diff --git a/api/tests/test_api_files_content.py b/api/tests/test_api_files_content.py new file mode 100644 index 0000000..f4e9256 --- /dev/null +++ b/api/tests/test_api_files_content.py @@ -0,0 +1,167 @@ +"""Исходный файл потоком (spec documents-api «Исходный файл»; design D-10).""" + +from collections.abc import AsyncIterator + +import pytest +from httpx import AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.storage import ObjectNotFoundError +from src.models.enums import FileStatus, FileType, RecognitionStatus +from src.models.files import S3Object, StoredFile +from src.services.primary_files import _chain +from tests.conftest import AuthClientFactory, FakeStorage +from tests.factories import make_stored_file +from tests.test_api_documents import CLIENT_MANAGER, CURATOR_GO, KA_RF, RKA, _rostov_entity + +pytestmark = pytest.mark.integration +PDF = b"%PDF-1.4\n" + bytes(range(256)) * 4096 # ~1 МиБ: проверка порционной отдачи +HTTP_OK = 200 +HTTP_NOT_FOUND = 404 +HTTP_FORBIDDEN = 403 + + +async def _file( + session: AsyncSession, storage: FakeStorage, name: str, file_type: FileType, *, entity_name: str = "ООО Файлы" +) -> StoredFile: + entity = await _rostov_entity(session, name=entity_name) + file, _ = await make_stored_file( + session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, original_name=name + ) + file.file_type = file_type + key = (await session.execute(select(S3Object.key).where(S3Object.id == file.s3_object_id))).scalar_one() + storage.objects[key] = PDF + await session.flush() + return file + + +async def test_inline_pdf(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001 + storage = FakeStorage() + app.state.storage = storage + file = await _file(seeded, storage, "aep_6.pdf", FileType.PDF) + client: AsyncClient = await auth_client(KA_RF) + response = await client.get(f"/files/{file.id}/content", params={"disposition": "inline"}) + assert response.status_code == HTTP_OK + assert response.headers["content-type"] == "application/pdf" + assert response.headers["content-disposition"] == "inline; filename=\"file.pdf\"; filename*=UTF-8''aep_6.pdf" + assert response.content == PDF + + +async def test_cyrillic_name_and_missing(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001 + storage = FakeStorage() + app.state.storage = storage + file = await _file(seeded, storage, "ОСВ_66 за 2024.xls", FileType.XLS) + client = await auth_client(KA_RF) + response = await client.get(f"/files/{file.id}/content") + assert response.headers["content-type"] == "application/vnd.ms-excel" + assert response.headers["content-disposition"].startswith( + "attachment; filename=\"file.xls\"; filename*=UTF-8''%D0%9E" + ) + storage.objects.clear() + missing = await client.get(f"/files/{file.id}/content") + assert missing.status_code == HTTP_NOT_FOUND and missing.json()["detail"]["code"] == "FILE_CONTENT_NOT_FOUND" + + +async def test_superseded_file_is_served(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001 + storage = FakeStorage() + app.state.storage = storage + file = await _file(seeded, storage, "old.xlsx", FileType.XLSX) + file.status = FileStatus.SUPERSEDED + await seeded.flush() + client = await auth_client(KA_RF) + response = await client.get(f"/files/{file.id}/content") + assert response.status_code == HTTP_OK and response.content == PDF + assert response.headers["content-type"] == ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + + +async def test_foreign_unknown_and_permission(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001 + storage = FakeStorage() + app.state.storage = storage + file = await _file(seeded, storage, "a.pdf", FileType.PDF) + # RKA — только DOCUMENT_VIEW: доступ есть (тот же филиал); без права — 403 до видимости + assert (await (await auth_client(RKA)).get(f"/files/{file.id}/content")).status_code == HTTP_OK + assert (await (await auth_client(CURATOR_GO)).get(f"/files/{file.id}/content")).status_code == HTTP_FORBIDDEN + foreign = await (await auth_client(CLIENT_MANAGER)).get(f"/files/{file.id}/content") # другой филиал + assert foreign.status_code == HTTP_NOT_FOUND and foreign.json()["detail"]["code"] == "NOT_FOUND" + client = await auth_client(KA_RF) + unknown = await client.get("/files/00000000-0000-0000-0000-000000000000/content") + assert unknown.status_code == HTTP_NOT_FOUND + + +async def test_stream_error_before_first_chunk_is_object_not_found() -> None: + storage = FakeStorage() + with pytest.raises(ObjectNotFoundError): + await storage.stream("missing").__anext__() + + +async def test_stream_releases_on_error() -> None: + """Review Focus 4: при обрыве потребителя ответ S3 закрывается и отпускается.""" + from src.core.clients import S3Storage # noqa: PLC0415 + + class Content: + async def iter_chunked(self, size: int): # noqa: ANN202 + yield b"a" * size + yield b"b" * size + + class Response: + def __init__(self) -> None: + self.content = Content() + self.closed = self.released = False + + def close(self) -> None: + self.closed = True + + async def release(self) -> None: + self.released = True + + response = Response() + + class Client: + async def get_object(self, bucket: str, name: str) -> Response: + return response + + storage = S3Storage(Client(), "arm-ka") # type: ignore[arg-type] + stream = storage.stream("uploads/x", chunk_size=4) + assert await stream.__anext__() == b"aaaa" + await stream.aclose() # потребитель оборвал чтение + assert response.closed and response.released + + +class _Source: + """Источник порций с признаком закрытия (`finally` асинхронного генератора).""" + + def __init__(self, fail_after: int | None = None) -> None: + self.closed = False + self._fail_after = fail_after + self.iterator = self._run() + + async def _run(self) -> AsyncIterator[bytes]: + try: + for index in range(3): + if self._fail_after == index: + raise ConnectionError("обрыв S3") + yield b"x" + finally: + self.closed = True + + +async def test_chain_closes_source_when_consumer_stops() -> None: + """Обрыв клиента после первой порции: `aclose` обёртки закрывает исходный поток.""" + source = _Source() + first = await anext(source.iterator, b"") + chain = _chain(first, source.iterator) + assert await chain.__anext__() == b"x" + await chain.aclose() + assert source.closed + + +async def test_chain_closes_source_on_error_and_yields_all() -> None: + source = _Source(fail_after=2) + chain = _chain(await anext(source.iterator, b""), source.iterator) + with pytest.raises(ConnectionError): + _ = [chunk async for chunk in chain] + assert source.closed + complete = _Source() + assert b"".join([chunk async for chunk in _chain(await anext(complete.iterator, b""), complete.iterator)]) == b"xxx" + assert complete.closed diff --git a/api/tests/test_clients.py b/api/tests/test_clients.py index 4f7c6eb..c793767 100644 --- a/api/tests/test_clients.py +++ b/api/tests/test_clients.py @@ -1,6 +1,7 @@ """`S3Storage.get_bytes` на заглушке Minio (без сети): чтение, закрытие response, `NoSuchKey` → `ObjectNotFoundError`.""" +from collections.abc import AsyncIterator from typing import Any from unittest.mock import MagicMock @@ -11,11 +12,21 @@ from src.core.clients import S3Storage from src.core.storage import ObjectNotFoundError +class StubContent: + def __init__(self, data: bytes) -> None: + self._data = data + + async def iter_chunked(self, size: int) -> AsyncIterator[bytes]: + for start in range(0, len(self._data), size): + yield self._data[start : start + size] + + class StubResponse: """Ответ `aiohttp` с флагами закрытия.""" def __init__(self, data: bytes, *, fail_read: bool = False) -> None: self._data = data + self.content = StubContent(data) self._fail_read = fail_read self.closed = False self.released = False @@ -83,3 +94,24 @@ async def test_get_bytes_other_s3_error_is_not_swallowed() -> None: with pytest.raises(S3Error) as caught: await _storage(StubMinio(error=_s3_error("AccessDenied"))).get_bytes("k") assert caught.value.code == "AccessDenied" + + +async def test_stream_yields_chunks_and_releases_response() -> None: + response = StubResponse(b"abcdefghij") + + chunks = [chunk async for chunk in _storage(StubMinio(response)).stream("k", chunk_size=4)] + + assert chunks == [b"abcd", b"efgh", b"ij"] + assert response.closed is True + assert response.released is True + + +async def test_stream_no_such_key_is_object_not_found_before_first_chunk() -> None: + with pytest.raises(ObjectNotFoundError): + await _storage(StubMinio(error=_s3_error("NoSuchKey"))).stream("missing").__anext__() + + +async def test_stream_other_s3_error_is_not_swallowed() -> None: + with pytest.raises(S3Error) as caught: + await _storage(StubMinio(error=_s3_error("AccessDenied"))).stream("k").__anext__() + assert caught.value.code == "AccessDenied" diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 75738b9..8928736 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -48,6 +48,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = { # primary-files, documents-api (Срез 2c) "/api/v1/legal-entities/{legal_entity_id}/files": {"get", "post"}, "/api/v1/files/{file_id}": {"get", "delete"}, + "/api/v1/files/{file_id}/content": {"get"}, "/api/v1/documents/{document_id}": {"get"}, "/api/v1/documents/{document_id}/entries": {"get"}, "/api/v1/documents/{document_id}/confirm": {"post"},