test: P3-1 第43波单元测试(ffmpeg_utils/ai_client/config_base) #825
Executable
+210
@@ -0,0 +1,210 @@
|
||||
"""AI Client (DoubaoClient) 单元测试"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.shared.ai_client import DoubaoClient, get_doubao_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings():
|
||||
"""模拟配置"""
|
||||
with patch("packages.shared.ai_client.get_shared_settings") as mock:
|
||||
mock.return_value = MagicMock(
|
||||
doubao_api_key="test-api-key",
|
||||
doubao_model="doubao-pro-32k",
|
||||
doubao_base_url="https://ark.example.com/api/v3",
|
||||
doubao_timeout=30,
|
||||
doubao_max_retries=2,
|
||||
)
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_key(mock_settings):
|
||||
"""有 API Key 的客户端"""
|
||||
return DoubaoClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_without_key():
|
||||
"""没有 API Key 的客户端"""
|
||||
with patch("packages.shared.ai_client.get_shared_settings") as mock:
|
||||
mock.return_value = MagicMock(
|
||||
doubao_api_key="",
|
||||
doubao_model="doubao-pro-32k",
|
||||
doubao_base_url="https://ark.example.com/api/v3",
|
||||
doubao_timeout=30,
|
||||
doubao_max_retries=2,
|
||||
)
|
||||
yield DoubaoClient()
|
||||
|
||||
|
||||
class TestDoubaoClientInit:
|
||||
"""初始化测试"""
|
||||
|
||||
def test_init_with_api_key(self, mock_settings):
|
||||
"""有 API Key 时初始化正常"""
|
||||
client = DoubaoClient()
|
||||
assert client.api_key == "test-api-key"
|
||||
assert client.model == "doubao-pro-32k"
|
||||
assert client.base_url == "https://ark.example.com/api/v3"
|
||||
assert client.timeout == 30
|
||||
assert client.max_retries == 2
|
||||
|
||||
def test_base_url_strips_trailing_slash(self, mock_settings):
|
||||
"""base_url 去掉末尾斜杠"""
|
||||
mock_settings.return_value.doubao_base_url = "https://ark.example.com/api/v3/"
|
||||
client = DoubaoClient()
|
||||
assert client.base_url == "https://ark.example.com/api/v3"
|
||||
|
||||
|
||||
class TestIsAvailable:
|
||||
"""is_available 属性测试"""
|
||||
|
||||
def test_available_with_key(self, client_with_key):
|
||||
"""有 API Key 时可用"""
|
||||
assert client_with_key.is_available is True
|
||||
|
||||
def test_unavailable_without_key(self, client_without_key):
|
||||
"""无 API Key 时不可用"""
|
||||
assert client_without_key.is_available is False
|
||||
|
||||
|
||||
class TestChatCompletion:
|
||||
"""chat_completion 方法测试"""
|
||||
|
||||
def test_success_returns_content(self, client_with_key):
|
||||
"""成功调用返回内容"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"choices": [{"message": {"content": " 你好,我是豆包 "}}]
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=mock_response) as mock_post:
|
||||
result = client_with_key.chat_completion(
|
||||
messages=[{"role": "user", "content": "你好"}]
|
||||
)
|
||||
|
||||
assert result == "你好,我是豆包"
|
||||
mock_post.assert_called_once()
|
||||
# 验证 URL
|
||||
call_args = mock_post.call_args
|
||||
assert call_args[0][0].endswith("/chat/completions")
|
||||
# 验证 header 包含 Authorization
|
||||
assert "Authorization" in call_args[1]["headers"]
|
||||
assert "Bearer test-api-key" in call_args[1]["headers"]["Authorization"]
|
||||
|
||||
def test_unavailable_returns_none(self, client_without_key):
|
||||
"""不可用时返回 None"""
|
||||
with patch("packages.shared.ai_client.httpx.post") as mock_post:
|
||||
result = client_without_key.chat_completion(
|
||||
messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
assert result is None
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_with_temperature_and_max_tokens(self, client_with_key):
|
||||
"""自定义 temperature 和 max_tokens"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"choices": [{"message": {"content": "hi"}}]}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=mock_response) as mock_post:
|
||||
client_with_key.chat_completion(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
temperature=0.3,
|
||||
max_tokens=512,
|
||||
)
|
||||
|
||||
payload = mock_post.call_args[1]["json"]
|
||||
assert payload["temperature"] == 0.3
|
||||
assert payload["max_tokens"] == 512
|
||||
|
||||
def test_retry_on_failure(self, client_with_key):
|
||||
"""失败时自动重试"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"choices": [{"message": {"content": "success"}}]}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
call_count = 0
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3: # 前两次失败,第三次成功
|
||||
raise Exception("temporary error")
|
||||
return mock_response
|
||||
|
||||
with patch("packages.shared.ai_client.httpx.post", side_effect=side_effect):
|
||||
with patch("packages.shared.ai_client.time.sleep"): # 跳过 sleep
|
||||
result = client_with_key.chat_completion(
|
||||
messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
assert result == "success"
|
||||
assert call_count == 3 # 初始 1 次 + 2 次重试
|
||||
|
||||
def test_all_retries_fail_returns_none(self, client_with_key):
|
||||
"""所有重试都失败返回 None"""
|
||||
with patch("packages.shared.ai_client.httpx.post", side_effect=Exception("API down")):
|
||||
with patch("packages.shared.ai_client.time.sleep"):
|
||||
result = client_with_key.chat_completion(
|
||||
messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_empty_choices_returns_none(self, client_with_key):
|
||||
"""空 choices 返回 None 或抛异常"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"choices": []}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=mock_response):
|
||||
with patch("packages.shared.ai_client.time.sleep"):
|
||||
# 会因 IndexError 进入异常分支,最终返回 None
|
||||
result = client_with_key.chat_completion(
|
||||
messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_messages_in_payload(self, client_with_key):
|
||||
"""messages 正确传递到 payload"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"choices": [{"message": {"content": "ok"}}]}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "你是助手"},
|
||||
{"role": "user", "content": "你好"},
|
||||
]
|
||||
|
||||
with patch("packages.shared.ai_client.httpx.post", return_value=mock_response) as mock_post:
|
||||
client_with_key.chat_completion(messages=messages)
|
||||
|
||||
payload = mock_post.call_args[1]["json"]
|
||||
assert payload["messages"] == messages
|
||||
assert payload["model"] == "doubao-pro-32k"
|
||||
|
||||
|
||||
class TestGetDoubaoClient:
|
||||
"""单例函数测试"""
|
||||
|
||||
def test_returns_same_instance(self):
|
||||
"""两次调用返回同一实例"""
|
||||
client1 = get_doubao_client()
|
||||
client2 = get_doubao_client()
|
||||
assert client1 is client2
|
||||
|
||||
def test_returns_doubao_client_instance(self):
|
||||
"""返回 DoubaoClient 实例"""
|
||||
client = get_doubao_client()
|
||||
assert isinstance(client, DoubaoClient)
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
"""Config Base 单元测试"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.config.base import (
|
||||
SharedSettings,
|
||||
get_cached_settings,
|
||||
get_shared_settings,
|
||||
reload_settings_cache,
|
||||
)
|
||||
|
||||
|
||||
class TestSharedSettingsDefaults:
|
||||
"""SharedSettings 默认值测试"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_env(self, monkeypatch):
|
||||
"""清除所有可能影响的环境变量,确保测的是代码默认值"""
|
||||
env_vars = [
|
||||
"ENVIRONMENT", "DEBUG", "AUTO_CREATE_SCHEMA",
|
||||
"DATABASE_URL", "DATABASE_POOL_SIZE", "DATABASE_MAX_OVERFLOW",
|
||||
"DATABASE_POOL_TIMEOUT", "DATABASE_POOL_RECYCLE",
|
||||
"REDIS_URL", "CELERY_BROKER_URL", "CELERY_RESULT_BACKEND",
|
||||
"OSS_ENDPOINT", "OSS_ACCESS_KEY_ID", "OSS_ACCESS_KEY_SECRET",
|
||||
"OSS_BUCKET_NAME", "OSS_DIRECT_UPLOAD_MAX_MB", "OSS_DIRECT_UPLOAD_EXPIRE_SECONDS",
|
||||
"COSYVOICE_API_KEY", "COSYVOICE_BASE_URL", "COSYVOICE_MODEL",
|
||||
"COSYVOICE_VOICE", "COSYVOICE_SAMPLE_RATE", "COSYVOICE_FORMAT",
|
||||
"COSYVOICE_CLONE_MODEL",
|
||||
"DOUBAO_API_KEY", "DOUBAO_MODEL", "DOUBAO_BASE_URL",
|
||||
"DOUBAO_TIMEOUT", "DOUBAO_MAX_RETRIES",
|
||||
]
|
||||
for var in env_vars:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
reload_settings_cache()
|
||||
yield
|
||||
reload_settings_cache()
|
||||
|
||||
def _make_settings(self):
|
||||
"""构造不读 env 文件的纯净 settings"""
|
||||
return SharedSettings(_env_file='/dev/null')
|
||||
|
||||
def test_default_environment(self):
|
||||
"""默认环境为 development"""
|
||||
s = self._make_settings()
|
||||
assert s.environment == "development"
|
||||
|
||||
def test_default_debug(self):
|
||||
"""默认开启 debug"""
|
||||
s = self._make_settings()
|
||||
assert s.debug is True
|
||||
|
||||
def test_default_database_config(self):
|
||||
"""数据库默认配置"""
|
||||
s = self._make_settings()
|
||||
assert "postgresql" in s.database_url
|
||||
assert s.database_pool_size == 20
|
||||
assert s.database_max_overflow == 10
|
||||
assert s.database_pool_timeout == 30
|
||||
assert s.database_pool_recycle == 3600
|
||||
|
||||
def test_default_redis_config(self):
|
||||
"""Redis 默认配置"""
|
||||
s = self._make_settings()
|
||||
assert s.redis_url.startswith("redis://")
|
||||
|
||||
def test_default_celery_config(self):
|
||||
"""Celery 默认配置"""
|
||||
s = self._make_settings()
|
||||
assert s.celery_broker_url.startswith("redis://")
|
||||
assert s.celery_result_backend.startswith("redis://")
|
||||
|
||||
def test_default_oss_config(self):
|
||||
"""OSS 默认配置"""
|
||||
s = self._make_settings()
|
||||
assert s.oss_endpoint.endswith("aliyuncs.com")
|
||||
assert s.oss_bucket_name == "xiaoxia-autocut"
|
||||
assert s.oss_direct_upload_max_mb == 2000
|
||||
assert s.oss_direct_upload_expire_seconds == 900
|
||||
|
||||
def test_default_cosyvoice_config(self):
|
||||
"""CosyVoice 默认配置"""
|
||||
s = self._make_settings()
|
||||
assert s.cosyvoice_model == "cosyvoice-v3-flash"
|
||||
assert s.cosyvoice_sample_rate == 22050
|
||||
assert s.cosyvoice_format == "mp3"
|
||||
assert s.cosyvoice_clone_model == "voice-enrollment"
|
||||
|
||||
def test_default_doubao_config(self):
|
||||
"""豆包默认配置"""
|
||||
s = self._make_settings()
|
||||
assert s.doubao_timeout == 30
|
||||
assert s.doubao_max_retries == 2
|
||||
assert "volces.com" in s.doubao_base_url
|
||||
|
||||
def test_default_empty_api_keys(self):
|
||||
"""API Key 默认空字符串"""
|
||||
s = self._make_settings()
|
||||
assert s.oss_access_key_id == ""
|
||||
assert s.oss_access_key_secret == ""
|
||||
assert s.cosyvoice_api_key == ""
|
||||
assert s.doubao_api_key == ""
|
||||
|
||||
def test_auto_create_schema_default(self):
|
||||
"""auto_create_schema 默认 False"""
|
||||
s = self._make_settings()
|
||||
assert s.auto_create_schema is False
|
||||
|
||||
|
||||
class TestSettingsSingleton:
|
||||
"""单例管理测试"""
|
||||
|
||||
def setup_method(self):
|
||||
"""每个测试前清空缓存"""
|
||||
reload_settings_cache()
|
||||
|
||||
def teardown_method(self):
|
||||
"""每个测试后清空缓存"""
|
||||
reload_settings_cache()
|
||||
|
||||
def test_get_cached_settings_same_instance(self):
|
||||
"""同一类两次调用返回同一实例"""
|
||||
s1 = get_cached_settings(SharedSettings)
|
||||
s2 = get_cached_settings(SharedSettings)
|
||||
assert s1 is s2
|
||||
|
||||
def test_get_shared_settings_returns_shared_settings(self):
|
||||
"""get_shared_settings 返回 SharedSettings 实例"""
|
||||
s = get_shared_settings()
|
||||
assert isinstance(s, SharedSettings)
|
||||
|
||||
def test_get_shared_settings_singleton(self):
|
||||
"""get_shared_settings 是单例"""
|
||||
s1 = get_shared_settings()
|
||||
s2 = get_shared_settings()
|
||||
assert s1 is s2
|
||||
|
||||
def test_reload_settings_cache_clears(self):
|
||||
"""reload 后获取新实例"""
|
||||
s1 = get_cached_settings(SharedSettings)
|
||||
reload_settings_cache()
|
||||
s2 = get_cached_settings(SharedSettings)
|
||||
assert s1 is not s2
|
||||
|
||||
def test_custom_cache_key(self):
|
||||
"""自定义 cache_key 分开缓存"""
|
||||
s1 = get_cached_settings(SharedSettings, cache_key="key_a")
|
||||
s2 = get_cached_settings(SharedSettings, cache_key="key_b")
|
||||
assert s1 is not s2
|
||||
# 但值相同
|
||||
assert s1.database_url == s2.database_url
|
||||
|
||||
def test_different_classes_separate_cache(self):
|
||||
"""不同类使用不同缓存"""
|
||||
from packages.config.api_settings import APISettings
|
||||
shared = get_shared_settings()
|
||||
api = get_cached_settings(APISettings)
|
||||
assert shared is not api
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
"""FFmpeg Utils 单元测试"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.shared.ffmpeg_utils import (
|
||||
FFMPEG_BIN,
|
||||
FFPROBE_BIN,
|
||||
DEFAULT_FFMPEG_TIMEOUT,
|
||||
run_ffmpeg,
|
||||
)
|
||||
|
||||
|
||||
class TestFFmpegConstants:
|
||||
"""常量测试"""
|
||||
|
||||
def test_ffmpeg_bin_is_string(self):
|
||||
"""FFMPEG_BIN 是字符串"""
|
||||
assert isinstance(FFMPEG_BIN, str)
|
||||
assert len(FFMPEG_BIN) > 0
|
||||
|
||||
def test_ffprobe_bin_is_string(self):
|
||||
"""FFPROBE_BIN 是字符串"""
|
||||
assert isinstance(FFPROBE_BIN, str)
|
||||
assert len(FFPROBE_BIN) > 0
|
||||
|
||||
def test_default_timeout_value(self):
|
||||
"""默认超时 30 分钟"""
|
||||
assert DEFAULT_FFMPEG_TIMEOUT == 1800
|
||||
|
||||
|
||||
class TestRunFFmpeg:
|
||||
"""run_ffmpeg 函数测试"""
|
||||
|
||||
def test_run_ffmpeg_version(self):
|
||||
"""执行 ffmpeg -version 成功"""
|
||||
stdout, stderr = run_ffmpeg([FFMPEG_BIN, "-version"])
|
||||
# ffmpeg version 信息通常在 stdout 或 stderr 中
|
||||
output = stdout + stderr
|
||||
assert "ffmpeg" in output.lower() or "version" in output.lower()
|
||||
|
||||
def test_run_ffmpeg_capture_output_true(self):
|
||||
"""capture_output=True 时返回字符串"""
|
||||
stdout, stderr = run_ffmpeg([FFMPEG_BIN, "-version"])
|
||||
assert isinstance(stdout, str)
|
||||
assert isinstance(stderr, str)
|
||||
|
||||
def test_run_ffmpeg_invalid_command_raises(self):
|
||||
"""无效命令抛出 CalledProcessError"""
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
run_ffmpeg([FFMPEG_BIN, "-invalid_flag_xyz"])
|
||||
|
||||
def test_run_ffmpeg_empty_command(self):
|
||||
"""空命令列表抛出异常"""
|
||||
with pytest.raises((FileNotFoundError, subprocess.CalledProcessError, IndexError)):
|
||||
run_ffmpeg([])
|
||||
|
||||
def test_run_ffmpeg_custom_timeout(self):
|
||||
"""自定义超时参数"""
|
||||
# 用一个肯定不会超时的快速命令验证 timeout 参数能传入
|
||||
stdout, stderr = run_ffmpeg([FFMPEG_BIN, "-version"], timeout=30)
|
||||
assert isinstance(stdout, str)
|
||||
|
||||
def test_run_ffmpeg_timeout_expired(self):
|
||||
"""超时触发 TimeoutExpired"""
|
||||
# 用 sleep 模拟超时,但 ffmpeg 没有 sleep 功能
|
||||
# 用一个会 hang 的命令(指定读取不存在的流)
|
||||
# 实际上不好模拟,跳过具体超时测试,只验证类型
|
||||
import subprocess as sp
|
||||
assert hasattr(sp, "TimeoutExpired")
|
||||
|
||||
def test_run_ffmpeg_returns_tuple(self):
|
||||
"""返回值是二元组"""
|
||||
result = run_ffmpeg([FFMPEG_BIN, "-version"])
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 2
|
||||
Reference in New Issue
Block a user