Files
xiaoxia-saas/tests/unit/test_tts_streaming.py
T
CI Bot 745ea06659
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 29s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m10s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m47s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m17s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
fix: #317 CI修复 - asset_analyzer缩进 + TTS单测SSRF兼容
P0-1: asset_analyzer.py 缩进错误
- 第147行起23行缩进多了一层(改subprocess时弄乱)
- 修正 streams/format_info/for循环 等缩进

P0-2: TTS单测因SSRF接入挂了8个
- 根因:测试mock httpx,但safe_download_*在发请求前做DNS解析
- 修复:将httpx mock替换为safe_download_bytes/safe_download_file mock
- test_tts_oss_transfer.py: 4个失败修复,mock safe_download_bytes
- test_tts_segment_synthesis.py: 3个失败修复,mock safe_download_file
- test_tts_streaming.py: 1个失败修复,mock safe_download_bytes
- 安全层单测保持独立,33个test_url_security.py全绿

76个相关测试全绿(9+24+10+33)
2026-07-14 17:11:42 +08:00

235 lines
7.8 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""P2 WebSocket 流式合成单元测试。
覆盖:
- TTSStreamingService 流式合成逻辑
- 短文本流式合成
- 长文本分段流式合成
- 错误处理
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
from packages.application.tts_job.streaming_service import (
TTSStreamingService,
)
class MockWebSocket:
"""Mock WebSocket for testing."""
def __init__(self):
self.sent_json = []
self.sent_bytes = []
self.accepted = False
async def accept(self):
self.accepted = True
async def send_json(self, data):
self.sent_json.append(data)
async def send_bytes(self, data):
self.sent_bytes.append(data)
async def receive_json(self):
return {}
class TestTTSStreamingService:
"""测试 TTS 流式合成服务。"""
@pytest.mark.asyncio
async def test_empty_text_returns_error(self):
"""空文本返回错误。"""
cosyvoice = MagicMock(spec=CosyVoiceService)
service = TTSStreamingService(cosyvoice)
ws = MockWebSocket()
params = {"text": "", "voice_id": "test_voice"}
await service.synthesize_and_stream(ws, params)
assert len(ws.sent_json) == 1
assert ws.sent_json[0]["type"] == "error"
assert "文本不能为空" in ws.sent_json[0]["message"]
@pytest.mark.asyncio
async def test_text_too_long_returns_error(self):
"""超长文本返回错误。"""
cosyvoice = MagicMock(spec=CosyVoiceService)
service = TTSStreamingService(cosyvoice)
ws = MockWebSocket()
params = {"text": "x" * 10001, "voice_id": "test_voice"}
await service.synthesize_and_stream(ws, params)
assert len(ws.sent_json) == 1
assert ws.sent_json[0]["type"] == "error"
assert "文本过长" in ws.sent_json[0]["message"]
@pytest.mark.asyncio
async def test_short_text_stream_success(self):
"""短文本流式合成成功。"""
cosyvoice = MagicMock(spec=CosyVoiceService)
cosyvoice.submit_synthesize_task.return_value = {
"task_id": "task_1",
"audio_url": "https://temp.com/audio.mp3",
"duration": 5.0,
"file_size": 10000,
}
service = TTSStreamingService(cosyvoice)
ws = MockWebSocket()
params = {"text": "测试文本", "voice_id": "test_voice", "format": "mp3"}
with patch.object(service, "_download_audio", return_value=b"fake audio data"):
await service.synthesize_and_stream(ws, params)
# 验证发送了 started 帧
assert ws.sent_json[0]["type"] == "started"
assert ws.sent_json[0]["segment_count"] == 1
# 验证发送了二进制音频数据
assert len(ws.sent_bytes) > 0
# 验证发送了 done 帧
assert ws.sent_json[-1]["type"] == "done"
assert ws.sent_json[-1]["format"] == "mp3"
@pytest.mark.asyncio
async def test_short_text_cosyvoice_error(self):
"""短文本合成时 CosyVoice 报错。"""
cosyvoice = MagicMock(spec=CosyVoiceService)
cosyvoice.submit_synthesize_task.side_effect = CosyVoiceError("API error")
service = TTSStreamingService(cosyvoice)
ws = MockWebSocket()
params = {"text": "测试文本", "voice_id": "test_voice"}
await service.synthesize_and_stream(ws, params)
# 验证发送了 started 帧和 error 帧
assert len(ws.sent_json) == 2
assert ws.sent_json[0]["type"] == "started"
assert ws.sent_json[1]["type"] == "error"
assert "API error" in ws.sent_json[1]["message"]
@pytest.mark.asyncio
async def test_short_text_no_audio_url(self):
"""短文本合成未返回 audio_url。"""
cosyvoice = MagicMock(spec=CosyVoiceService)
cosyvoice.submit_synthesize_task.return_value = {
"task_id": "task_1",
"audio_url": "",
}
service = TTSStreamingService(cosyvoice)
ws = MockWebSocket()
params = {"text": "测试文本", "voice_id": "test_voice"}
await service.synthesize_and_stream(ws, params)
# 验证发送了 started 帧和 error 帧
assert len(ws.sent_json) == 2
assert ws.sent_json[0]["type"] == "started"
assert ws.sent_json[1]["type"] == "error"
assert "未返回音频 URL" in ws.sent_json[1]["message"]
@pytest.mark.asyncio
async def test_long_text_stream_success(self):
"""长文本分段流式合成成功。"""
cosyvoice = MagicMock(spec=CosyVoiceService)
# 每个分段都返回 audio_url
cosyvoice.submit_synthesize_task.side_effect = [
{"task_id": "", "audio_url": "https://temp.com/seg1.mp3", "duration": 2.0},
{"task_id": "", "audio_url": "https://temp.com/seg2.mp3", "duration": 3.0},
]
service = TTSStreamingService(cosyvoice)
ws = MockWebSocket()
# 超过 500 字的文本
params = {"text": "x" * 600, "voice_id": "test_voice", "format": "mp3"}
with patch.object(service, "_download_audio", return_value=b"segment audio"):
await service.synthesize_and_stream(ws, params)
# 验证发送了 started 帧(多段)
assert ws.sent_json[0]["type"] == "started"
assert ws.sent_json[0]["segment_count"] >= 2
# 验证发送了 segment_done 帧
segment_done_count = sum(1 for msg in ws.sent_json if msg["type"] == "segment_done")
assert segment_done_count >= 2
# 验证发送了 done 帧
assert ws.sent_json[-1]["type"] == "done"
@pytest.mark.asyncio
async def test_long_text_segment_failure(self):
"""长文本分段合成失败。"""
cosyvoice = MagicMock(spec=CosyVoiceService)
cosyvoice.submit_synthesize_task.side_effect = CosyVoiceError("Segment error")
service = TTSStreamingService(cosyvoice)
ws = MockWebSocket()
params = {"text": "x" * 600, "voice_id": "test_voice"}
await service.synthesize_and_stream(ws, params)
# 验证发送了错误帧
error_msgs = [msg for msg in ws.sent_json if msg["type"] == "error"]
assert len(error_msgs) > 0
assert "合成失败" in error_msgs[0]["message"]
@pytest.mark.asyncio
async def test_stream_audio_chunks(self):
"""音频分块推送。"""
cosyvoice = MagicMock(spec=CosyVoiceService)
service = TTSStreamingService(cosyvoice)
ws = MockWebSocket()
audio_data = b"x" * 10000 # 10KB
total = await service._stream_audio_chunks(ws, audio_data)
assert total == 10000
# 验证分块发送(4KB per chunk
assert len(ws.sent_bytes) == 3 # 4096 + 4096 + 1808
@pytest.mark.asyncio
async def test_send_json_suppresses_exceptions(self):
"""_send_json 抑制异常。"""
cosyvoice = MagicMock(spec=CosyVoiceService)
service = TTSStreamingService(cosyvoice)
ws = MockWebSocket()
ws.send_json = AsyncMock(side_effect=Exception("Send failed"))
# 不应该抛出异常
await service._send_json(ws, {"type": "test"})
@pytest.mark.asyncio
async def test_download_audio(self):
"""下载音频数据(SSRF防护走safe_download_bytesmock掉安全层)。"""
cosyvoice = MagicMock(spec=CosyVoiceService)
service = TTSStreamingService(cosyvoice)
with patch("packages.application.tts_job.streaming_service.safe_download_bytes") as mock_download:
mock_download.return_value = b"audio data"
result = service._download_audio("https://example.com/audio.mp3")
assert result == b"audio data"
mock_download.assert_called_once()