The backend no longer mentions MinIO anywhere: MinioClient -> GarageClient (minio_client.py -> garage_client.py), MINIO_* env vars -> GARAGE_*, and the `minio` Python package is replaced with `boto3` (AWS's generic S3 client) since Garage is the only storage backend this project targets now. S3 API semantics are unchanged, so document_service.py's behavior is identical — this is a naming and dependency swap, not a logic change. Also updates test fixtures/names (FakeMinioClient -> FakeGarageClient, FakeMinioSDK -> FakeS3SDK) and storage/README.md, which no longer frames Garage as a MinIO replacement (that migration note has served its purpose now that no MinIO reference remains in the codebase). Backend: 54/54 tests passing. Frontend: 38/38 tests passing (unaffected, verified for regressions since it talks to the backend only through the unchanged REST API).
134 lines
4.4 KiB
Python
134 lines
4.4 KiB
Python
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import app.main as main_module
|
|
from app.main import app
|
|
from app.document_service import DocumentNotFoundError
|
|
from app.dependencies import get_document_service, get_search_client
|
|
|
|
client = TestClient(app)
|
|
|
|
|
|
def test_health_returns_ok():
|
|
response = client.get("/health")
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "ok"}
|
|
|
|
|
|
def test_startup_creates_bucket_when_missing(monkeypatch):
|
|
fake_garage = MagicMock()
|
|
fake_garage.list_buckets.return_value = {"Buckets": []}
|
|
monkeypatch.setattr(main_module, "_get_garage_sdk", lambda: fake_garage)
|
|
|
|
with TestClient(app):
|
|
pass
|
|
|
|
fake_garage.list_buckets.assert_called()
|
|
fake_garage.create_bucket.assert_called()
|
|
|
|
|
|
def test_startup_does_not_create_bucket_when_it_already_exists(monkeypatch):
|
|
fake_garage = MagicMock()
|
|
fake_garage.list_buckets.return_value = {"Buckets": [{"Name": "dochub"}]}
|
|
monkeypatch.setattr(main_module, "_get_garage_sdk", lambda: fake_garage)
|
|
|
|
with TestClient(app):
|
|
pass
|
|
|
|
fake_garage.create_bucket.assert_not_called()
|
|
|
|
|
|
def test_startup_does_not_crash_app_when_garage_is_unreachable(monkeypatch):
|
|
def raise_connection_error():
|
|
raise ConnectionError("garage unreachable")
|
|
|
|
monkeypatch.setattr(main_module, "_get_garage_sdk", raise_connection_error)
|
|
|
|
with TestClient(app) as test_client:
|
|
response = test_client.get("/health")
|
|
assert response.status_code == 200
|
|
|
|
|
|
class FakeDocumentService:
|
|
def __init__(self):
|
|
self.storage = {}
|
|
|
|
def list_documents(self, path):
|
|
prefix = path if not path or path.endswith("/") else f"{path}/"
|
|
files = [
|
|
{"key": key, "name": key[len(prefix):], "size": len(data), "modifiedAt": "2026-08-01T10:00:00Z"}
|
|
for key, data in self.storage.items()
|
|
if key.startswith(prefix)
|
|
]
|
|
return {"folders": [], "files": files}
|
|
|
|
def get_document(self, key):
|
|
if key not in self.storage:
|
|
raise DocumentNotFoundError(key)
|
|
return {"key": key, "content": self.storage[key].decode("utf-8"), "contentType": "text/markdown"}
|
|
|
|
def save_document(self, key, content, content_type):
|
|
self.storage[key] = content
|
|
return {"key": key, "updatedAt": "2026-08-01T10:05:00Z"}
|
|
|
|
def delete_document(self, key):
|
|
self.storage.pop(key, None)
|
|
|
|
def upload_document(self, key, data, content_type):
|
|
self.storage[key] = data
|
|
return {"key": key, "name": key.rsplit("/", 1)[-1], "size": len(data)}
|
|
|
|
|
|
class FakeSearchClient:
|
|
def search(self, query):
|
|
return [
|
|
{
|
|
"key": "产品文档/架构设计.md",
|
|
"title": "架构设计.md",
|
|
"path": "产品文档",
|
|
"snippet": "全文<em>检索</em>示例",
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def e2e_client():
|
|
fake_service = FakeDocumentService()
|
|
fake_search = FakeSearchClient()
|
|
app.dependency_overrides[get_document_service] = lambda: fake_service
|
|
app.dependency_overrides[get_search_client] = lambda: fake_search
|
|
with TestClient(app) as test_client:
|
|
yield test_client
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_end_to_end_upload_get_list_search_delete_flow(e2e_client):
|
|
upload_response = e2e_client.post(
|
|
"/api/upload",
|
|
data={"path": "产品文档"},
|
|
files={"file": ("架构设计.md", "# 架构设计\n\nDocHub 支持全文检索。".encode("utf-8"), "text/markdown")},
|
|
)
|
|
assert upload_response.status_code == 201
|
|
key = upload_response.json()["key"]
|
|
assert key == "产品文档/架构设计.md"
|
|
|
|
get_response = e2e_client.get(f"/api/documents/{key}")
|
|
assert get_response.status_code == 200
|
|
assert "架构设计" in get_response.json()["content"]
|
|
|
|
list_response = e2e_client.get("/api/documents", params={"path": "产品文档"})
|
|
assert list_response.status_code == 200
|
|
assert any(f["key"] == key for f in list_response.json()["files"])
|
|
|
|
search_response = e2e_client.get("/api/search", params={"q": "检索"})
|
|
assert search_response.status_code == 200
|
|
assert search_response.json()["results"][0]["key"] == "产品文档/架构设计.md"
|
|
|
|
delete_response = e2e_client.delete(f"/api/documents/{key}")
|
|
assert delete_response.status_code == 204
|
|
|
|
missing_response = e2e_client.get(f"/api/documents/{key}")
|
|
assert missing_response.status_code == 404
|