Поток из S3 порциями без чтения в память; нет объекта — 404 FILE_CONTENT_NOT_FOUND до заголовков; при обрыве потребителя ответ S3 закрывается и отпускается. Content-Disposition вынесен в common/filenames. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
118 lines
3.8 KiB
Python
118 lines
3.8 KiB
Python
"""`S3Storage.get_bytes` на заглушке Minio (без сети):
|
||
чтение, закрытие response, `NoSuchKey` → `ObjectNotFoundError`."""
|
||
|
||
from collections.abc import AsyncIterator
|
||
from typing import Any
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
from miniopy_async.error import S3Error
|
||
|
||
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
|
||
|
||
async def read(self) -> bytes:
|
||
if self._fail_read:
|
||
raise ConnectionError("обрыв чтения")
|
||
return self._data
|
||
|
||
def close(self) -> None:
|
||
self.closed = True
|
||
|
||
async def release(self) -> None:
|
||
self.released = True
|
||
|
||
|
||
class StubMinio:
|
||
def __init__(self, response: StubResponse | None = None, error: Exception | None = None) -> None:
|
||
self._response = response
|
||
self._error = error
|
||
self.calls: list[tuple[str, str]] = []
|
||
|
||
async def get_object(self, bucket: str, obj_name: str) -> StubResponse:
|
||
self.calls.append((bucket, obj_name))
|
||
if self._error is not None:
|
||
raise self._error
|
||
assert self._response is not None # noqa: S101
|
||
return self._response
|
||
|
||
|
||
def _s3_error(code: str) -> S3Error:
|
||
return S3Error(code, "сообщение", None, None, None, MagicMock())
|
||
|
||
|
||
def _storage(minio: StubMinio) -> S3Storage:
|
||
client: Any = minio
|
||
return S3Storage(client, "arm-ka")
|
||
|
||
|
||
async def test_get_bytes_returns_content_and_closes_response() -> None:
|
||
response = StubResponse(b"payload")
|
||
minio = StubMinio(response)
|
||
|
||
assert await _storage(minio).get_bytes("uploads/a/b.xls") == b"payload"
|
||
assert minio.calls == [("arm-ka", "uploads/a/b.xls")]
|
||
assert response.closed is True
|
||
assert response.released is True
|
||
|
||
|
||
async def test_get_bytes_closes_response_when_read_fails() -> None:
|
||
response = StubResponse(b"", fail_read=True)
|
||
|
||
with pytest.raises(ConnectionError):
|
||
await _storage(StubMinio(response)).get_bytes("k")
|
||
assert response.closed is True
|
||
assert response.released is True
|
||
|
||
|
||
async def test_get_bytes_no_such_key_is_object_not_found() -> None:
|
||
with pytest.raises(ObjectNotFoundError):
|
||
await _storage(StubMinio(error=_s3_error("NoSuchKey"))).get_bytes("missing")
|
||
|
||
|
||
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"
|