feat: Phase 1+2 — project setup and core infrastructure
- requirements.txt, config.yaml, .env, Dockerfile, docker-compose.yml - app/core: config (YAML+env override), logging (JSON structured), exceptions (typed hierarchy), json_utils (Markdown fence stripping) - app/clients: LLMClient ABC + ZhipuAIClient (run_in_executor), StorageClient ABC + RustFSClient (boto3 head_object for size check) - app/main.py: FastAPI app with health endpoint and router registration - app/core/dependencies.py: lru_cache singleton factories - tests/conftest.py: mock_llm, mock_storage, test_app, client fixtures - pytest.ini: asyncio_mode=auto - 11 unit tests passing
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
BIN
tests/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
tests/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/conftest.cpython-312-pytest-9.0.3.pyc
Normal file
BIN
tests/__pycache__/conftest.cpython-312-pytest-9.0.3.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_config.cpython-312-pytest-9.0.3.pyc
Normal file
BIN
tests/__pycache__/test_config.cpython-312-pytest-9.0.3.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_llm_client.cpython-312-pytest-9.0.3.pyc
Normal file
BIN
tests/__pycache__/test_llm_client.cpython-312-pytest-9.0.3.pyc
Normal file
Binary file not shown.
Binary file not shown.
39
tests/conftest.py
Normal file
39
tests/conftest.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.clients.llm.base import LLMClient
|
||||
from app.clients.storage.base import StorageClient
|
||||
from app.core.dependencies import get_llm_client, get_storage_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm() -> LLMClient:
|
||||
client = MagicMock(spec=LLMClient)
|
||||
client.chat = AsyncMock(return_value='[]')
|
||||
client.chat_vision = AsyncMock(return_value='[]')
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_storage() -> StorageClient:
|
||||
client = MagicMock(spec=StorageClient)
|
||||
client.download_bytes = AsyncMock(return_value=b"")
|
||||
client.upload_bytes = AsyncMock(return_value=None)
|
||||
client.get_presigned_url = AsyncMock(return_value="http://example.com/presigned")
|
||||
client.get_object_size = AsyncMock(return_value=10 * 1024 * 1024) # 10 MB default
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_app(mock_llm, mock_storage):
|
||||
from app.main import app
|
||||
app.dependency_overrides[get_llm_client] = lambda: mock_llm
|
||||
app.dependency_overrides[get_storage_client] = lambda: mock_storage
|
||||
yield app
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(test_app):
|
||||
return TestClient(test_app)
|
||||
40
tests/test_config.py
Normal file
40
tests/test_config.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
def test_yaml_defaults_load(monkeypatch):
|
||||
# Clear lru_cache so each test gets a fresh load
|
||||
from app.core import config as cfg_module
|
||||
cfg_module.get_config.cache_clear()
|
||||
|
||||
# Remove env overrides that might bleed from shell environment
|
||||
for var in ["MAX_VIDEO_SIZE_MB", "LOG_LEVEL", "STORAGE_ENDPOINT"]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
cfg = cfg_module.get_config()
|
||||
|
||||
assert cfg["server"]["port"] == 8000
|
||||
assert cfg["video"]["max_file_size_mb"] == 200
|
||||
assert cfg["models"]["default_text"] == "glm-4-flash"
|
||||
assert cfg["models"]["default_vision"] == "glm-4v-flash"
|
||||
assert cfg["storage"]["buckets"]["source_data"] == "source-data"
|
||||
|
||||
|
||||
def test_max_video_size_env_override(monkeypatch):
|
||||
from app.core import config as cfg_module
|
||||
cfg_module.get_config.cache_clear()
|
||||
|
||||
monkeypatch.setenv("MAX_VIDEO_SIZE_MB", "500")
|
||||
cfg = cfg_module.get_config()
|
||||
|
||||
assert cfg["video"]["max_file_size_mb"] == 500
|
||||
|
||||
|
||||
def test_log_level_env_override(monkeypatch):
|
||||
from app.core import config as cfg_module
|
||||
cfg_module.get_config.cache_clear()
|
||||
|
||||
monkeypatch.setenv("LOG_LEVEL", "DEBUG")
|
||||
cfg = cfg_module.get_config()
|
||||
|
||||
assert cfg["server"]["log_level"] == "DEBUG"
|
||||
40
tests/test_llm_client.py
Normal file
40
tests/test_llm_client.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.clients.llm.zhipuai_client import ZhipuAIClient
|
||||
from app.core.exceptions import LLMCallError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sdk_response():
|
||||
resp = MagicMock()
|
||||
resp.choices[0].message.content = '{"result": "ok"}'
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with patch("app.clients.llm.zhipuai_client.ZhipuAI"):
|
||||
c = ZhipuAIClient(api_key="test-key")
|
||||
return c
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_returns_content(client, mock_sdk_response):
|
||||
client._client.chat.completions.create.return_value = mock_sdk_response
|
||||
result = await client.chat("glm-4-flash", [{"role": "user", "content": "hello"}])
|
||||
assert result == '{"result": "ok"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_vision_returns_content(client, mock_sdk_response):
|
||||
client._client.chat.completions.create.return_value = mock_sdk_response
|
||||
result = await client.chat_vision("glm-4v-flash", [{"role": "user", "content": []}])
|
||||
assert result == '{"result": "ok"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_call_error_on_sdk_exception(client):
|
||||
client._client.chat.completions.create.side_effect = RuntimeError("quota exceeded")
|
||||
with pytest.raises(LLMCallError, match="大模型调用失败"):
|
||||
await client.chat("glm-4-flash", [{"role": "user", "content": "hi"}])
|
||||
62
tests/test_storage_client.py
Normal file
62
tests/test_storage_client.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from app.clients.storage.rustfs_client import RustFSClient
|
||||
from app.core.exceptions import StorageError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with patch("app.clients.storage.rustfs_client.boto3") as mock_boto3:
|
||||
c = RustFSClient(
|
||||
endpoint="http://rustfs:9000",
|
||||
access_key="key",
|
||||
secret_key="secret",
|
||||
)
|
||||
c._s3 = MagicMock()
|
||||
return c
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_bytes_returns_bytes(client):
|
||||
client._s3.get_object.return_value = {"Body": MagicMock(read=lambda: b"hello")}
|
||||
result = await client.download_bytes("source-data", "text/test.txt")
|
||||
assert result == b"hello"
|
||||
client._s3.get_object.assert_called_once_with(Bucket="source-data", Key="text/test.txt")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_bytes_raises_storage_error(client):
|
||||
client._s3.get_object.side_effect = ClientError(
|
||||
{"Error": {"Code": "NoSuchKey", "Message": "Not Found"}}, "GetObject"
|
||||
)
|
||||
with pytest.raises(StorageError, match="存储下载失败"):
|
||||
await client.download_bytes("source-data", "missing.txt")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_object_size_returns_content_length(client):
|
||||
client._s3.head_object.return_value = {"ContentLength": 1024}
|
||||
size = await client.get_object_size("source-data", "video/test.mp4")
|
||||
assert size == 1024
|
||||
client._s3.head_object.assert_called_once_with(Bucket="source-data", Key="video/test.mp4")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_object_size_raises_storage_error(client):
|
||||
client._s3.head_object.side_effect = ClientError(
|
||||
{"Error": {"Code": "NoSuchKey", "Message": "Not Found"}}, "HeadObject"
|
||||
)
|
||||
with pytest.raises(StorageError, match="获取文件大小失败"):
|
||||
await client.get_object_size("source-data", "video/missing.mp4")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_bytes_calls_put_object(client):
|
||||
client._s3.put_object.return_value = {}
|
||||
await client.upload_bytes("source-data", "frames/1/0.jpg", b"jpeg-data", "image/jpeg")
|
||||
client._s3.put_object.assert_called_once()
|
||||
call_kwargs = client._s3.put_object.call_args
|
||||
assert call_kwargs.kwargs["Bucket"] == "source-data"
|
||||
assert call_kwargs.kwargs["Key"] == "frames/1/0.jpg"
|
||||
Reference in New Issue
Block a user