"""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