From f4126ff3ece187cad5095d39bb0f61c1c51efe10 Mon Sep 17 00:00:00 2001 From: Tianyang Date: Sat, 1 Aug 2026 02:24:15 +0800 Subject: [PATCH] feat: add MinioClient wrapper for list/get/put/delete object operations --- backend/app/minio_client.py | 45 ++++++++++++ backend/tests/test_minio_client.py | 108 +++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 backend/app/minio_client.py create mode 100644 backend/tests/test_minio_client.py diff --git a/backend/app/minio_client.py b/backend/app/minio_client.py new file mode 100644 index 0000000..ccd3de0 --- /dev/null +++ b/backend/app/minio_client.py @@ -0,0 +1,45 @@ +import io +from minio.error import S3Error + + +class MinioClient: + def __init__(self, client, bucket: str): + self._client = client + self._bucket = bucket + + def list_objects(self, prefix: str) -> list[dict]: + objects = self._client.list_objects(self._bucket, prefix=prefix, recursive=False) + result = [] + for obj in objects: + result.append({ + "key": obj.object_name, + "size": obj.size, + "is_dir": obj.object_name.endswith("/"), + "modifiedAt": obj.last_modified.isoformat() if obj.last_modified else None, + }) + return result + + def get_object(self, key: str) -> bytes: + try: + response = self._client.get_object(self._bucket, key) + except S3Error as exc: + if exc.code == "NoSuchKey": + raise FileNotFoundError(key) from exc + raise + try: + return response.read() + finally: + response.close() + response.release_conn() + + def put_object(self, key: str, data: bytes, content_type: str) -> None: + self._client.put_object( + self._bucket, + key, + io.BytesIO(data), + length=len(data), + content_type=content_type, + ) + + def delete_object(self, key: str) -> None: + self._client.remove_object(self._bucket, key) diff --git a/backend/tests/test_minio_client.py b/backend/tests/test_minio_client.py new file mode 100644 index 0000000..2aa0a41 --- /dev/null +++ b/backend/tests/test_minio_client.py @@ -0,0 +1,108 @@ +from datetime import datetime, timezone + +import pytest +from minio.error import S3Error + +from app.minio_client import MinioClient + + +class _FakeObject: + def __init__(self, object_name, size, last_modified): + self.object_name = object_name + self.size = size + self.last_modified = last_modified + + +class _FakeResponse: + def __init__(self, data: bytes): + self._data = data + self.closed = False + self.released = False + + def read(self): + return self._data + + def close(self): + self.closed = True + + def release_conn(self): + self.released = True + + +class FakeMinioSDK: + def __init__(self): + self.objects = {} + self.put_calls = [] + self.removed_keys = [] + + def list_objects(self, bucket, prefix="", recursive=False): + for key, (data, content_type, last_modified) in self.objects.items(): + if key.startswith(prefix): + yield _FakeObject(key, len(data), last_modified) + + def get_object(self, bucket, key): + if key not in self.objects: + raise S3Error("NoSuchKey", "Object does not exist", key, "req-1", "host-1", None) + data, _, _ = self.objects[key] + return _FakeResponse(data) + + def put_object(self, bucket, key, stream, length, content_type): + data = stream.read() + self.objects[key] = (data, content_type, datetime.now(timezone.utc)) + self.put_calls.append((bucket, key, content_type)) + + def remove_object(self, bucket, key): + del self.objects[key] + self.removed_keys.append(key) + + +def test_list_objects_returns_keys_matching_prefix(): + sdk = FakeMinioSDK() + sdk.objects["产品文档/架构设计.md"] = (b"# hi", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc)) + sdk.objects["团队规范/编码规范.md"] = (b"# rules", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc)) + client = MinioClient(sdk, bucket="dochub") + + result = client.list_objects("产品文档/") + + assert len(result) == 1 + assert result[0]["key"] == "产品文档/架构设计.md" + assert result[0]["size"] == 4 + + +def test_get_object_returns_bytes(): + sdk = FakeMinioSDK() + sdk.objects["产品文档/架构设计.md"] = (b"# architecture", "text/markdown", datetime.now(timezone.utc)) + client = MinioClient(sdk, bucket="dochub") + + data = client.get_object("产品文档/架构设计.md") + + assert data == b"# architecture" + + +def test_get_object_raises_file_not_found_for_missing_key(): + sdk = FakeMinioSDK() + client = MinioClient(sdk, bucket="dochub") + + with pytest.raises(FileNotFoundError): + client.get_object("不存在/文档.md") + + +def test_put_object_stores_data_with_content_type(): + sdk = FakeMinioSDK() + client = MinioClient(sdk, bucket="dochub") + + client.put_object("产品文档/新文档.md", b"# new doc", "text/markdown") + + assert sdk.objects["产品文档/新文档.md"][0] == b"# new doc" + assert sdk.put_calls == [("dochub", "产品文档/新文档.md", "text/markdown")] + + +def test_delete_object_removes_key(): + sdk = FakeMinioSDK() + sdk.objects["产品文档/旧文档.md"] = (b"old", "text/markdown", datetime.now(timezone.utc)) + client = MinioClient(sdk, bucket="dochub") + + client.delete_object("产品文档/旧文档.md") + + assert "产品文档/旧文档.md" not in sdk.objects + assert sdk.removed_keys == ["产品文档/旧文档.md"]