"""Unit tests for S3FileStorage — all S3 calls are mocked.""" from __future__ import annotations import io from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.domain.errors import StorageError from app.infrastructure.storage.s3 import S3FileStorage def _make_storage(**kwargs: Any) -> S3FileStorage: return S3FileStorage("test-bucket", **kwargs) def _client_error(code: str) -> Exception: from botocore.exceptions import ClientError return ClientError({"Error": {"Code": code, "Message": code}}, "op") class _FakeBody: """Async-iterable body that yields chunks from a bytes buffer.""" def __init__(self, data: bytes, chunk_size: int = 65536) -> None: self._buf = io.BytesIO(data) self._chunk_size = chunk_size async def read(self, size: int = -1) -> bytes: return self._buf.read(size) def _make_client_ctx(s3_mock: Any) -> Any: ctx = MagicMock() ctx.__aenter__ = AsyncMock(return_value=s3_mock) ctx.__aexit__ = AsyncMock(return_value=False) return ctx @pytest.fixture() def storage() -> S3FileStorage: return _make_storage() # --------------------------------------------------------------------------- # save_file # --------------------------------------------------------------------------- async def test_save_file_calls_put_object(tmp_path: Path, storage: S3FileStorage) -> None: src = tmp_path / "track.mp3" src.write_bytes(b"audio bytes") s3 = AsyncMock() with patch.object(storage, "_client", return_value=_make_client_ctx(s3)): size = await storage.save_file("tracks/ab/track.mp3", src) s3.put_object.assert_awaited_once_with( Bucket="test-bucket", Key="tracks/ab/track.mp3", Body=b"audio bytes" ) assert size == 11 # --------------------------------------------------------------------------- # stat # --------------------------------------------------------------------------- async def test_stat_returns_size_and_content_type(storage: S3FileStorage) -> None: s3 = AsyncMock() s3.head_object.return_value = {"ContentLength": 1024, "ContentType": "audio/mpeg"} with patch.object(storage, "_client", return_value=_make_client_ctx(s3)): stat = await storage.stat("tracks/ab/track.mp3") assert stat.size == 1024 assert stat.content_type == "audio/mpeg" async def test_stat_falls_back_to_ext_content_type(storage: S3FileStorage) -> None: s3 = AsyncMock() s3.head_object.return_value = {"ContentLength": 500, "ContentType": None} with patch.object(storage, "_client", return_value=_make_client_ctx(s3)): stat = await storage.stat("tracks/ab/track.flac") assert stat.content_type == "audio/flac" async def test_stat_not_found_raises_storage_error(storage: S3FileStorage) -> None: s3 = AsyncMock() s3.head_object.side_effect = _client_error("404") with patch.object(storage, "_client", return_value=_make_client_ctx(s3)): with pytest.raises(StorageError, match="not found"): await storage.stat("tracks/missing.mp3") # --------------------------------------------------------------------------- # exists # --------------------------------------------------------------------------- async def test_exists_true(storage: S3FileStorage) -> None: s3 = AsyncMock() s3.head_object.return_value = {"ContentLength": 1} with patch.object(storage, "_client", return_value=_make_client_ctx(s3)): assert await storage.exists("tracks/ab/track.mp3") is True async def test_exists_false_on_404(storage: S3FileStorage) -> None: s3 = AsyncMock() s3.head_object.side_effect = _client_error("NoSuchKey") with patch.object(storage, "_client", return_value=_make_client_ctx(s3)): assert await storage.exists("tracks/missing.mp3") is False # --------------------------------------------------------------------------- # delete # --------------------------------------------------------------------------- async def test_delete_calls_delete_object(storage: S3FileStorage) -> None: s3 = AsyncMock() with patch.object(storage, "_client", return_value=_make_client_ctx(s3)): await storage.delete("tracks/ab/track.mp3") s3.delete_object.assert_awaited_once_with(Bucket="test-bucket", Key="tracks/ab/track.mp3") # --------------------------------------------------------------------------- # open_range # --------------------------------------------------------------------------- async def test_open_range_full(storage: S3FileStorage) -> None: data = b"hello world" s3 = AsyncMock() s3.head_object.return_value = {"ContentLength": len(data)} s3.get_object.return_value = {"Body": _FakeBody(data)} with patch.object(storage, "_client", return_value=_make_client_ctx(s3)): stream, total = await storage.open_range("tracks/ab/t.mp3", 0, None) chunks = [c async for c in stream] assert b"".join(chunks) == data assert total == len(data) s3.get_object.assert_awaited_once_with( Bucket="test-bucket", Key="tracks/ab/t.mp3", Range="bytes=0-" ) async def test_open_range_partial(storage: S3FileStorage) -> None: full = b"0123456789" ranged = b"34567" s3 = AsyncMock() s3.head_object.return_value = {"ContentLength": len(full)} s3.get_object.return_value = {"Body": _FakeBody(ranged)} with patch.object(storage, "_client", return_value=_make_client_ctx(s3)): stream, total = await storage.open_range("tracks/ab/t.mp3", 3, 7) result = b"".join([c async for c in stream]) assert result == ranged assert total == len(full) s3.get_object.assert_awaited_once_with( Bucket="test-bucket", Key="tracks/ab/t.mp3", Range="bytes=3-7" ) async def test_open_range_not_found_raises_storage_error(storage: S3FileStorage) -> None: s3 = AsyncMock() s3.head_object.side_effect = _client_error("NoSuchKey") with patch.object(storage, "_client", return_value=_make_client_ctx(s3)): with pytest.raises(StorageError, match="not found"): await storage.open_range("tracks/missing.mp3", 0, None) # --------------------------------------------------------------------------- # as_local_path # --------------------------------------------------------------------------- async def test_as_local_path_yields_file_with_content(storage: S3FileStorage) -> None: data = b"local copy bytes" s3 = AsyncMock() s3.get_object.return_value = {"Body": _FakeBody(data)} with patch.object(storage, "_client", return_value=_make_client_ctx(s3)): async with storage.as_local_path("tracks/ab/track.mp3") as path: assert path.exists() assert path.read_bytes() == data assert not path.exists() async def test_as_local_path_cleans_up_on_error(storage: S3FileStorage) -> None: s3 = AsyncMock() s3.get_object.side_effect = _client_error("NoSuchKey") captured: list[Path] = [] with patch.object(storage, "_client", return_value=_make_client_ctx(s3)): with pytest.raises(StorageError): async with storage.as_local_path("tracks/missing.mp3") as path: captured.append(path) if captured: assert not captured[0].exists() # --------------------------------------------------------------------------- # provider wiring # --------------------------------------------------------------------------- def test_provider_returns_s3_storage_when_configured(tmp_path: Path) -> None: from app.core.config import Settings from app.infrastructure.storage import provider provider._storage = None mock_settings = Settings( database_url="postgresql+asyncpg://x:x@localhost/x", storage_backend="s3", s3_bucket="my-bucket", s3_region="us-east-1", ) with patch("app.infrastructure.storage.provider.get_settings", return_value=mock_settings): storage_instance = provider.get_file_storage() assert isinstance(storage_instance, S3FileStorage) provider._storage = None # reset singleton for other tests def test_provider_raises_when_s3_bucket_missing() -> None: from app.core.config import Settings from app.infrastructure.storage import provider provider._storage = None mock_settings = Settings( database_url="postgresql+asyncpg://x:x@localhost/x", storage_backend="s3", s3_bucket=None, ) with patch("app.infrastructure.storage.provider.get_settings", return_value=mock_settings): with pytest.raises(RuntimeError, match="S3_BUCKET"): provider.get_file_storage() provider._storage = None