arm-scan-gateway/api/tests/unit/test_clients.py
Andrey Epifancev c6dfb3e22c Срез 0 [5.2]: S3Storage (src/core/clients.py): get_bytes, put_bytes, put_json, from_settings с secure
Ошибки не проглатываются: 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>
2026-10-04 03:27:35 +04:00

202 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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