4d99c20f6b
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m58s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m28s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m26s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m16s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m1s
AI Code Review / AI Code Review (pull_request) Successful in 5m50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m54s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 10m15s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 10m21s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 18m38s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 18m42s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 5s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
- DoubaoClient 新增 chat_completion_stream() 方法:httpx.stream SSE 读取, 逐块 yield delta.content;connect=10s/read=120s 流式超时;带重试 - _step_script_generation 新增 on_delta 回调:边收 token 边累积 full_text, 每 ~40 字符或换行/标签闭合时触发回调;流式失败自动降级到同步 chat_completion - run_viral_video_generate_copy 传入 on_delta,通过 Redis pub/sub 推 viral_video:script_delta 事件给前端实时渲染(WS 桥接无需新建接口) - 双层节流:回调层 250ms/40 字符 + emit 层 200ms 兜底,防止 WS 消息风暴 - 进度按已生成字数线性估算到 70% 位,终态 viral_video:copy_generated 不变 - 新增 9 个单测覆盖 stream 方法/事件格式/节流降级逻辑,74 个相关用例全通过
355 lines
14 KiB
Python
Executable File
355 lines
14 KiB
Python
Executable File
"""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=45,
|
||
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=45,
|
||
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 == 45
|
||
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)
|
||
|
||
|
||
class TestChatCompletionStream:
|
||
"""chat_completion_stream 方法测试"""
|
||
|
||
def _fake_sse(self, chunks: list[str]):
|
||
"""构造一个伪装的 httpx stream 响应,按 chunks 逐行返回 SSE。"""
|
||
import json as _json
|
||
|
||
lines = []
|
||
for piece in chunks:
|
||
evt = {"choices": [{"delta": {"content": piece}, "finish_reason": None}]}
|
||
lines.append("data: " + _json.dumps(evt, ensure_ascii=False))
|
||
lines.append("data: " + _json.dumps({"choices": [{"delta": {}, "finish_reason": "stop"}]}))
|
||
lines.append("data: [DONE]")
|
||
|
||
m = MagicMock()
|
||
m.status_code = 200
|
||
m.__enter__ = MagicMock(return_value=m)
|
||
m.__exit__ = MagicMock(return_value=False)
|
||
m.raise_for_status = MagicMock()
|
||
m.iter_lines = MagicMock(return_value=iter(lines))
|
||
return m
|
||
|
||
def test_stream_yields_delta_content(self, client_with_key):
|
||
"""流式调用逐段 yield delta.content"""
|
||
import json as _json
|
||
|
||
fake = self._fake_sse(["你好", ",", "世界"])
|
||
with patch("packages.shared.ai_client.httpx.stream", return_value=fake) as mock_stream:
|
||
out = list(
|
||
client_with_key.chat_completion_stream(
|
||
messages=[{"role": "user", "content": "hi"}], max_tokens=100, timeout=30
|
||
)
|
||
)
|
||
assert out == ["你好", ",", "世界"]
|
||
call_kwargs = mock_stream.call_args[1]
|
||
assert call_kwargs["json"]["stream"] is True
|
||
assert call_kwargs["json"]["max_tokens"] == 100
|
||
|
||
def test_stream_unavailable_returns_empty(self, client_without_key):
|
||
"""不可用时返回空生成器(不调用 httpx.stream)"""
|
||
with patch("packages.shared.ai_client.httpx.stream") as mock_stream:
|
||
out = list(client_without_key.chat_completion_stream(messages=[{"role": "user", "content": "hi"}]))
|
||
assert out == []
|
||
mock_stream.assert_not_called()
|
||
|
||
def test_stream_handles_done_marker(self, client_with_key):
|
||
"""遇到 [DONE] 正确终止,不把它当内容"""
|
||
import json as _json
|
||
|
||
lines = [
|
||
"data: " + _json.dumps({"choices": [{"delta": {"content": "A"}}]}),
|
||
"data: [DONE]",
|
||
"data: " + _json.dumps({"choices": [{"delta": {"content": "NEVER"}}]}), # 应被忽略
|
||
]
|
||
m = MagicMock()
|
||
m.status_code = 200
|
||
m.__enter__ = MagicMock(return_value=m)
|
||
m.__exit__ = MagicMock(return_value=False)
|
||
m.raise_for_status = MagicMock()
|
||
m.iter_lines = MagicMock(return_value=iter(lines))
|
||
with patch("packages.shared.ai_client.httpx.stream", return_value=m):
|
||
out = list(
|
||
client_with_key.chat_completion_stream(
|
||
messages=[{"role": "user", "content": "hi"}], max_tokens=50, timeout=30
|
||
)
|
||
)
|
||
assert out == ["A"]
|
||
|
||
def test_stream_skips_empty_delta(self, client_with_key):
|
||
"""空 delta(role 等 metadata)不应产出内容"""
|
||
import json as _json
|
||
|
||
lines = [
|
||
"data: " + _json.dumps({"choices": [{"delta": {"role": "assistant"}}]}),
|
||
"data: " + _json.dumps({"choices": [{"delta": {"content": "hi"}}]}),
|
||
"data: " + _json.dumps({"choices": [{"delta": {}}]}),
|
||
"data: [DONE]",
|
||
]
|
||
m = MagicMock()
|
||
m.status_code = 200
|
||
m.__enter__ = MagicMock(return_value=m)
|
||
m.__exit__ = MagicMock(return_value=False)
|
||
m.raise_for_status = MagicMock()
|
||
m.iter_lines = MagicMock(return_value=iter(lines))
|
||
with patch("packages.shared.ai_client.httpx.stream", return_value=m):
|
||
out = list(
|
||
client_with_key.chat_completion_stream(
|
||
messages=[{"role": "user", "content": "x"}], max_tokens=50, timeout=30
|
||
)
|
||
)
|
||
assert out == ["hi"]
|
||
|
||
def test_stream_invalid_json_lines_skipped(self, client_with_key):
|
||
"""SSE 行里脏数据/非 JSON 不应中断流"""
|
||
import json as _json
|
||
|
||
lines = [
|
||
"data: " + _json.dumps({"choices": [{"delta": {"content": "ok"}}]}),
|
||
"data: not-a-json",
|
||
":comment line",
|
||
"",
|
||
"event: ping",
|
||
"data: " + _json.dumps({"choices": [{"delta": {"content": "2"}}]}),
|
||
"data: [DONE]",
|
||
]
|
||
m = MagicMock()
|
||
m.status_code = 200
|
||
m.__enter__ = MagicMock(return_value=m)
|
||
m.__exit__ = MagicMock(return_value=False)
|
||
m.raise_for_status = MagicMock()
|
||
m.iter_lines = MagicMock(return_value=iter(lines))
|
||
with patch("packages.shared.ai_client.httpx.stream", return_value=m):
|
||
out = list(
|
||
client_with_key.chat_completion_stream(
|
||
messages=[{"role": "user", "content": "x"}], max_tokens=50, timeout=30
|
||
)
|
||
)
|
||
assert out == ["ok", "2"]
|
||
|
||
def test_stream_network_error_yields_empty(self, client_with_key):
|
||
"""网络错误(重试耗尽)yield 空,不抛异常给调用方"""
|
||
import httpx as _httpx
|
||
|
||
# max_retries=2 → 3 次总尝试
|
||
client = client_with_key
|
||
client.max_retries = 1 # 只重试 1 次,缩短测试
|
||
with patch("packages.shared.ai_client.httpx.stream", side_effect=_httpx.ConnectError("boom")):
|
||
out = list(
|
||
client.chat_completion_stream(messages=[{"role": "user", "content": "x"}], max_tokens=50, timeout=5)
|
||
)
|
||
assert out == []
|
||
|
||
def test_stream_records_finish_reason(self, client_with_key):
|
||
"""流式结束后 last_finish_reason 被正确记录"""
|
||
import json as _json
|
||
|
||
lines = [
|
||
"data: " + _json.dumps({"choices": [{"delta": {"content": "x"}, "finish_reason": None}]}),
|
||
"data: " + _json.dumps({"choices": [{"delta": {}, "finish_reason": "stop"}]}),
|
||
"data: [DONE]",
|
||
]
|
||
m = MagicMock()
|
||
m.status_code = 200
|
||
m.__enter__ = MagicMock(return_value=m)
|
||
m.__exit__ = MagicMock(return_value=False)
|
||
m.raise_for_status = MagicMock()
|
||
m.iter_lines = MagicMock(return_value=iter(lines))
|
||
with patch("packages.shared.ai_client.httpx.stream", return_value=m):
|
||
list(
|
||
client_with_key.chat_completion_stream(
|
||
messages=[{"role": "user", "content": "x"}], max_tokens=50, timeout=30
|
||
)
|
||
)
|
||
assert client_with_key.last_finish_reason == "stop"
|