test_minio_backend.py
python
sha256:eead4146ec6c9905a097a89f8dbffa3d5c5e8ef9c1acd0e8a5b2a93b0084d273
Mpack content-addressability
Human
6 days ago
| 1 | """MinIO — BlobBackend as the only storage backend. |
| 2 | |
| 3 | Tier 1 — Unit (no network) |
| 4 | - get_backend() raises RuntimeError when no bucket is configured |
| 5 | - get_backend() returns BlobBackend when blob_storage_bucket is set |
| 6 | - get_backend() returns BlobBackend when aws_s3_asset_bucket is set |
| 7 | - LocalBackend is not importable (deleted) |
| 8 | |
| 9 | Tier 2 — Integration (MinIO required) |
| 10 | - BlobBackend.put/get/exists/delete round-trip against a live MinIO container |
| 11 | - put is idempotent (second write is safe) |
| 12 | - get returns None for missing object |
| 13 | - exists returns False for missing object |
| 14 | |
| 15 | MinIO assumed at http://localhost:9000 with credentials minioadmin/minioadmin. |
| 16 | Tests in TestBlobBackendMinIO are skipped automatically when MinIO is unreachable. |
| 17 | """ |
| 18 | from __future__ import annotations |
| 19 | |
| 20 | import importlib |
| 21 | import secrets |
| 22 | from unittest.mock import patch |
| 23 | |
| 24 | import pytest |
| 25 | |
| 26 | from muse.core.types import long_id |
| 27 | |
| 28 | |
| 29 | # ─── helpers ────────────────────────────────────────────────────────────────── |
| 30 | |
| 31 | MINIO_ENDPOINT = "http://localhost:9000" |
| 32 | MINIO_BUCKET = "muse-objects" |
| 33 | MINIO_ACCESS_KEY = "minioadmin" |
| 34 | MINIO_SECRET_KEY = "minioadmin" |
| 35 | |
| 36 | |
| 37 | def _oid() -> str: |
| 38 | return long_id(secrets.token_hex(32)) |
| 39 | |
| 40 | |
| 41 | def _minio_backend() -> None: |
| 42 | from musehub.storage.backends import BlobBackend |
| 43 | return BlobBackend( |
| 44 | bucket=MINIO_BUCKET, |
| 45 | endpoint_url=MINIO_ENDPOINT, |
| 46 | access_key_id=MINIO_ACCESS_KEY, |
| 47 | secret_access_key=MINIO_SECRET_KEY, |
| 48 | region="us-east-1", |
| 49 | ) |
| 50 | |
| 51 | |
| 52 | def _minio_reachable() -> bool: |
| 53 | try: |
| 54 | import urllib.request |
| 55 | urllib.request.urlopen(f"{MINIO_ENDPOINT}/minio/health/live", timeout=2) |
| 56 | return True |
| 57 | except Exception: |
| 58 | return False |
| 59 | |
| 60 | |
| 61 | requires_minio = pytest.mark.skipif( |
| 62 | not _minio_reachable(), |
| 63 | reason="MinIO not reachable at localhost:9000 — start docker compose first", |
| 64 | ) |
| 65 | |
| 66 | |
| 67 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 68 | # Tier 1 — Unit: get_backend() contract after LocalBackend is deleted |
| 69 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 70 | |
| 71 | |
| 72 | class TestBlobBackendNaming: |
| 73 | def test_blob_backend_importable(self) -> None: |
| 74 | from musehub.storage.backends import BlobBackend # noqa: F401 |
| 75 | |
| 76 | def test_blob_backend_exported_from_storage(self) -> None: |
| 77 | import musehub.storage as mod |
| 78 | assert hasattr(mod, "BlobBackend") |
| 79 | |
| 80 | def test_s3_backend_name_deleted(self) -> None: |
| 81 | """S3Backend must not exist — the name leaks the transport protocol.""" |
| 82 | import musehub.storage.backends as mod |
| 83 | assert not hasattr(mod, "S3Backend"), ( |
| 84 | "S3Backend still exists — rename it to BlobBackend" |
| 85 | ) |
| 86 | |
| 87 | def test_get_backend_impl_returns_blob_backend(self) -> None: |
| 88 | # get_backend() is conftest-patched to MemoryBackend; the selection logic |
| 89 | # under test lives in _get_backend_impl() (see its docstring). |
| 90 | from musehub.storage.backends import BlobBackend, _get_backend_impl |
| 91 | from unittest.mock import patch |
| 92 | with patch("musehub.storage.backends.settings") as mock_settings: |
| 93 | mock_settings.blob_storage_bucket = "muse-objects" |
| 94 | mock_settings.blob_storage_endpoint = MINIO_ENDPOINT |
| 95 | mock_settings.blob_storage_access_key_id = MINIO_ACCESS_KEY |
| 96 | mock_settings.blob_storage_secret_access_key = MINIO_SECRET_KEY |
| 97 | mock_settings.blob_storage_region = "us-east-1" |
| 98 | result = _get_backend_impl() |
| 99 | assert isinstance(result, BlobBackend) |
| 100 | |
| 101 | |
| 102 | class TestGetBackendNoFallback: |
| 103 | def test_raises_when_no_bucket_configured(self) -> None: |
| 104 | """get_backend() must raise RuntimeError when neither blob_storage_bucket nor |
| 105 | aws_s3_asset_bucket is set. No silent LocalBackend fallback.""" |
| 106 | from musehub.storage.backends import _get_backend_impl |
| 107 | with patch("musehub.storage.backends.settings") as mock_settings: |
| 108 | mock_settings.blob_storage_bucket = None |
| 109 | mock_settings.aws_s3_asset_bucket = None |
| 110 | with pytest.raises(RuntimeError, match="No storage backend configured"): |
| 111 | _get_backend_impl() |
| 112 | |
| 113 | def test_returns_blob_backend_when_blob_storage_bucket_set(self) -> None: |
| 114 | from musehub.storage.backends import BlobBackend, _get_backend_impl |
| 115 | with patch("musehub.storage.backends.settings") as mock_settings: |
| 116 | mock_settings.blob_storage_bucket = "muse-objects" |
| 117 | mock_settings.blob_storage_endpoint = MINIO_ENDPOINT |
| 118 | mock_settings.blob_storage_access_key_id = MINIO_ACCESS_KEY |
| 119 | mock_settings.blob_storage_secret_access_key = MINIO_SECRET_KEY |
| 120 | mock_settings.blob_storage_region = "auto" |
| 121 | result = _get_backend_impl() |
| 122 | assert isinstance(result, BlobBackend) |
| 123 | |
| 124 | def test_returns_blob_backend_when_aws_bucket_set(self) -> None: |
| 125 | from musehub.storage.backends import BlobBackend, _get_backend_impl |
| 126 | with patch("musehub.storage.backends.settings") as mock_settings: |
| 127 | mock_settings.blob_storage_bucket = None |
| 128 | mock_settings.aws_s3_asset_bucket = "my-bucket" |
| 129 | mock_settings.aws_region = "us-east-1" |
| 130 | mock_settings.blob_storage_endpoint = None |
| 131 | mock_settings.blob_storage_access_key_id = None |
| 132 | mock_settings.blob_storage_secret_access_key = None |
| 133 | result = _get_backend_impl() |
| 134 | assert isinstance(result, BlobBackend) |
| 135 | |
| 136 | def test_local_backend_is_deleted(self) -> None: |
| 137 | """LocalBackend must not exist in the module after deletion.""" |
| 138 | import musehub.storage.backends as mod |
| 139 | assert not hasattr(mod, "LocalBackend"), ( |
| 140 | "LocalBackend still exists in musehub.storage.backends — delete it" |
| 141 | ) |
| 142 | |
| 143 | def test_local_backend_not_in_storage_init(self) -> None: |
| 144 | """LocalBackend must not be exported from musehub.storage.""" |
| 145 | import musehub.storage as mod |
| 146 | assert not hasattr(mod, "LocalBackend"), ( |
| 147 | "LocalBackend still exported from musehub.storage — remove it from __all__" |
| 148 | ) |
| 149 | |
| 150 | |
| 151 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 152 | # Tier 2 — Integration: S3Backend against live MinIO |
| 153 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 154 | |
| 155 | |
| 156 | class TestS3BackendMinIO: |
| 157 | @requires_minio |
| 158 | async def test_put_get_round_trip(self) -> None: |
| 159 | backend = _minio_backend() |
| 160 | oid = _oid() |
| 161 | data = b"hello minio " + secrets.token_bytes(32) |
| 162 | await backend.put(oid, data) |
| 163 | result = await backend.get(oid) |
| 164 | assert result == data |
| 165 | |
| 166 | @requires_minio |
| 167 | async def test_exists_false_before_put(self) -> None: |
| 168 | backend = _minio_backend() |
| 169 | oid = _oid() |
| 170 | assert await backend.exists(oid) is False |
| 171 | |
| 172 | @requires_minio |
| 173 | async def test_exists_true_after_put(self) -> None: |
| 174 | backend = _minio_backend() |
| 175 | oid = _oid() |
| 176 | await backend.put(oid, b"exists-check") |
| 177 | assert await backend.exists(oid) is True |
| 178 | |
| 179 | @requires_minio |
| 180 | async def test_put_is_idempotent(self) -> None: |
| 181 | backend = _minio_backend() |
| 182 | oid = _oid() |
| 183 | data = b"idempotent-data" |
| 184 | await backend.put(oid, data) |
| 185 | await backend.put(oid, data) |
| 186 | result = await backend.get(oid) |
| 187 | assert result == data |
| 188 | |
| 189 | @requires_minio |
| 190 | async def test_get_returns_none_for_missing(self) -> None: |
| 191 | backend = _minio_backend() |
| 192 | oid = _oid() |
| 193 | result = await backend.get(oid) |
| 194 | assert result is None |
| 195 | |
| 196 | @requires_minio |
| 197 | async def test_delete_removes_object(self) -> None: |
| 198 | backend = _minio_backend() |
| 199 | oid = _oid() |
| 200 | await backend.put(oid, b"to-be-deleted") |
| 201 | assert await backend.exists(oid) is True |
| 202 | await backend.delete(oid) |
| 203 | assert await backend.exists(oid) is False |
| 204 | |
| 205 | @requires_minio |
| 206 | async def test_uri_for_uses_s3_scheme(self) -> None: |
| 207 | backend = _minio_backend() |
| 208 | oid = _oid() |
| 209 | uri = backend.uri_for(oid) |
| 210 | assert uri.startswith(f"s3://{MINIO_BUCKET}/") |
| 211 | |
| 212 | @requires_minio |
| 213 | async def test_get_backend_with_minio_env_vars(self) -> None: |
| 214 | """get_backend() wired to MinIO env vars returns a working S3Backend.""" |
| 215 | from musehub.storage.backends import get_backend |
| 216 | with patch("musehub.storage.backends.settings") as mock_settings: |
| 217 | mock_settings.blob_storage_bucket = MINIO_BUCKET |
| 218 | mock_settings.blob_storage_endpoint = MINIO_ENDPOINT |
| 219 | mock_settings.blob_storage_access_key_id = MINIO_ACCESS_KEY |
| 220 | mock_settings.blob_storage_secret_access_key = MINIO_SECRET_KEY |
| 221 | mock_settings.blob_storage_region = "us-east-1" |
| 222 | backend = get_backend() |
| 223 | |
| 224 | oid = _oid() |
| 225 | await backend.put(oid, b"via-get-backend") |
| 226 | assert await backend.get(oid) == b"via-get-backend" |
File History
2 commits
sha256:eead4146ec6c9905a097a89f8dbffa3d5c5e8ef9c1acd0e8a5b2a93b0084d273
Mpack content-addressability
Human
6 days ago
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠
19 days ago