diff --git a/tests/unit/test_ai_client.py b/tests/unit/test_ai_client.py new file mode 100755 index 000000000..76ba08ff9 --- /dev/null +++ b/tests/unit/test_ai_client.py @@ -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) diff --git a/tests/unit/test_config_base.py b/tests/unit/test_config_base.py new file mode 100755 index 000000000..af9291732 --- /dev/null +++ b/tests/unit/test_config_base.py @@ -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 diff --git a/tests/unit/test_ffmpeg_utils.py b/tests/unit/test_ffmpeg_utils.py new file mode 100755 index 000000000..e58ddf10a --- /dev/null +++ b/tests/unit/test_ffmpeg_utils.py @@ -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