Ошибки не проглатываются: S3ObjectNotFoundError (NoSuchKey) и S3ReadError для чтения, S3WriteError для записи, исходная причина — в __cause__. JSON пишется в UTF-8 без экранирования, Content-Type application/json. Шаблонные put_object/get_object удалены (не использовались); удалён и шаблонный nats_callback.py — его единственный импорт S3Storage, сам он нигде не используется (слой NATS — следующим коммитом в src/core/nats.py). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
202 lines
7.0 KiB
Python
202 lines
7.0 KiB
Python
"""S3-клиент сервиса (`src.core.clients.S3Storage`) поверх фейкового `Minio`: аргументы вызовов, JSON в UTF-8,
|
||
классификация ошибок чтения/записи. Настоящий MinIO — в tests/integration/test_nats_minio_integration.py.
|
||
"""
|
||
|
||
import json
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from src.core import clients
|
||
from src.core.clients import (
|
||
S3ObjectNotFoundError,
|
||
S3ReadError,
|
||
S3Storage,
|
||
S3StorageError,
|
||
S3WriteError,
|
||
)
|
||
from src.core.config import get_settings
|
||
|
||
|
||
class FakeResponse:
|
||
def __init__(self, data: bytes) -> None:
|
||
self._data = data
|
||
self.released = False
|
||
|
||
async def read(self) -> bytes:
|
||
return self._data
|
||
|
||
def release(self) -> None:
|
||
self.released = True
|
||
|
||
|
||
class FakeS3Error(Exception):
|
||
"""Подмена `miniopy_async.error.S3Error`: у настоящего — атрибут `code` (`NoSuchKey`, `AccessDenied`, ...)."""
|
||
|
||
def __init__(self, code: str) -> None:
|
||
super().__init__(code)
|
||
self.code = code
|
||
|
||
|
||
class FakeMinio:
|
||
def __init__(self) -> None:
|
||
self.objects: dict[tuple[str, str], tuple[bytes, str]] = {}
|
||
self.get_calls: list[tuple[str, str]] = []
|
||
self.put_calls: list[dict[str, Any]] = []
|
||
self.responses: list[FakeResponse] = []
|
||
self.get_error: BaseException | None = None
|
||
self.put_error: BaseException | None = None
|
||
self.session_closed = False
|
||
|
||
async def get_object(self, bucket_name: str, object_name: str) -> FakeResponse:
|
||
self.get_calls.append((bucket_name, object_name))
|
||
if self.get_error is not None:
|
||
raise self.get_error
|
||
response = FakeResponse(self.objects[(bucket_name, object_name)][0])
|
||
self.responses.append(response)
|
||
return response
|
||
|
||
async def put_object(
|
||
self, bucket_name: str, object_name: str, data: Any, length: int, content_type: str = ""
|
||
) -> None:
|
||
body = data.read()
|
||
self.put_calls.append(
|
||
{"bucket": bucket_name, "key": object_name, "data": body, "length": length, "content_type": content_type}
|
||
)
|
||
if self.put_error is not None:
|
||
raise self.put_error
|
||
self.objects[(bucket_name, object_name)] = (body, content_type)
|
||
|
||
async def close_session(self) -> None:
|
||
self.session_closed = True
|
||
|
||
|
||
@pytest.fixture
|
||
def minio() -> FakeMinio:
|
||
return FakeMinio()
|
||
|
||
|
||
@pytest.fixture
|
||
def storage(minio: FakeMinio) -> S3Storage:
|
||
return S3Storage(minio, "arm-ka")
|
||
|
||
|
||
async def test_put_bytes_passes_bucket_key_length_and_content_type(storage: S3Storage, minio: FakeMinio):
|
||
await storage.put_bytes("uploads/a.pdf", b"%PDF-1.7", content_type="application/pdf")
|
||
|
||
assert minio.put_calls == [
|
||
{
|
||
"bucket": "arm-ka",
|
||
"key": "uploads/a.pdf",
|
||
"data": b"%PDF-1.7",
|
||
"length": 8,
|
||
"content_type": "application/pdf",
|
||
}
|
||
]
|
||
|
||
|
||
async def test_put_bytes_default_content_type_is_octet_stream(storage: S3Storage, minio: FakeMinio):
|
||
await storage.put_bytes("k", b"x")
|
||
assert minio.put_calls[0]["content_type"] == "application/octet-stream"
|
||
|
||
|
||
async def test_put_json_writes_utf8_without_ascii_escaping(storage: S3Storage, minio: FakeMinio):
|
||
payload = {"error": {"code": "INVALID_DOCUMENT", "message": "Документ не распознан"}, "n": 1}
|
||
|
||
await storage.put_json("raw/job.json", payload)
|
||
|
||
[call] = minio.put_calls
|
||
assert call["key"] == "raw/job.json"
|
||
assert call["content_type"] == "application/json"
|
||
assert "Документ не распознан".encode() in call["data"]
|
||
assert call["length"] == len(call["data"])
|
||
assert json.loads(call["data"].decode("utf-8")) == payload
|
||
|
||
|
||
async def test_get_bytes_reads_and_releases_response(storage: S3Storage, minio: FakeMinio):
|
||
minio.objects[("arm-ka", "uploads/a.pdf")] = (b"%PDF", "application/pdf")
|
||
|
||
assert await storage.get_bytes("uploads/a.pdf") == b"%PDF"
|
||
assert minio.get_calls == [("arm-ka", "uploads/a.pdf")]
|
||
assert minio.responses[0].released is True
|
||
|
||
|
||
async def test_get_bytes_from_explicit_bucket(storage: S3Storage, minio: FakeMinio):
|
||
minio.objects[("other", "k")] = (b"data", "")
|
||
assert await storage.get_bytes("k", bucket="other") == b"data"
|
||
assert minio.get_calls == [("other", "k")]
|
||
|
||
|
||
async def test_get_bytes_missing_key_raises_not_found(
|
||
storage: S3Storage, minio: FakeMinio, monkeypatch: pytest.MonkeyPatch
|
||
):
|
||
monkeypatch.setattr(clients, "S3Error", FakeS3Error)
|
||
minio.get_error = FakeS3Error("NoSuchKey")
|
||
|
||
with pytest.raises(S3ObjectNotFoundError) as exc_info:
|
||
await storage.get_bytes("uploads/missing.pdf")
|
||
|
||
assert isinstance(exc_info.value, S3ReadError)
|
||
assert exc_info.value.key == "uploads/missing.pdf"
|
||
assert exc_info.value.bucket == "arm-ka"
|
||
assert exc_info.value.__cause__ is minio.get_error
|
||
|
||
|
||
@pytest.mark.parametrize("error", [FakeS3Error("AccessDenied"), ConnectionRefusedError(), RuntimeError("x")])
|
||
async def test_get_bytes_other_errors_raise_read_error(
|
||
storage: S3Storage, minio: FakeMinio, monkeypatch: pytest.MonkeyPatch, error: BaseException
|
||
):
|
||
monkeypatch.setattr(clients, "S3Error", FakeS3Error)
|
||
minio.get_error = error
|
||
|
||
with pytest.raises(S3ReadError) as exc_info:
|
||
await storage.get_bytes("uploads/a.pdf")
|
||
|
||
assert not isinstance(exc_info.value, S3ObjectNotFoundError)
|
||
assert exc_info.value.__cause__ is error
|
||
|
||
|
||
async def test_put_errors_raise_write_error(storage: S3Storage, minio: FakeMinio):
|
||
minio.put_error = ConnectionRefusedError()
|
||
|
||
with pytest.raises(S3WriteError) as exc_info:
|
||
await storage.put_json("raw/job.json", {"a": 1})
|
||
|
||
assert isinstance(exc_info.value, S3StorageError)
|
||
assert exc_info.value.key == "raw/job.json"
|
||
assert exc_info.value.__cause__ is minio.put_error
|
||
|
||
|
||
async def test_close_closes_http_session(storage: S3Storage, minio: FakeMinio):
|
||
await storage.close()
|
||
assert minio.session_closed is True
|
||
|
||
|
||
def test_from_settings_builds_minio_with_secure_flag(monkeypatch: pytest.MonkeyPatch):
|
||
created: list[dict[str, Any]] = []
|
||
|
||
def fake_minio(endpoint: str, **kwargs: Any) -> FakeMinio:
|
||
created.append({"endpoint": endpoint, **kwargs})
|
||
return FakeMinio()
|
||
|
||
monkeypatch.setattr(clients, "Minio", fake_minio)
|
||
settings = get_settings() # .env.test: 127.0.0.1:9000, minioadmin, бакет arm-ka, MINIO_SECURE=false
|
||
|
||
storage = S3Storage.from_settings(settings.OPENBAO.MINIO)
|
||
|
||
assert created == [
|
||
{"endpoint": "127.0.0.1:9000", "access_key": "minioadmin", "secret_key": "minioadmin", "secure": False}
|
||
]
|
||
assert storage.bucket == "arm-ka"
|
||
|
||
|
||
def test_from_settings_secure_true(monkeypatch: pytest.MonkeyPatch):
|
||
created: list[dict[str, Any]] = []
|
||
monkeypatch.setattr(clients, "Minio", lambda endpoint, **kwargs: created.append(kwargs) or FakeMinio())
|
||
monkeypatch.setenv("OPENBAO__MINIO__MINIO_SECURE", "true")
|
||
get_settings.cache_clear()
|
||
|
||
S3Storage.from_settings(get_settings().OPENBAO.MINIO)
|
||
|
||
assert created[0]["secure"] is True
|