c47aed95fc
CI/CD Pipeline / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 48h27m21s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48h27m21s
- TTSWorkflowService 新增 _transfer_audio_to_oss 方法 - start_synthesis 同步路径和 process_synthesis_result 均集成 OSS 转存 - OSS 转存失败时优雅降级,回退到 CosyVoice 临时 URL - 支持 mp3/wav/pcm/opus 格式 content-type 映射 - 新增 9 个单元测试覆盖转存成功/失败/降级场景 - 向后兼容:storage_service 为可选参数,现有调用方无需修改 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
300 lines
11 KiB
Python
300 lines
11 KiB
Python
"""TTS 音频转存 OSS 单元测试。
|
||
|
||
验证 TTSWorkflowService 在合成完成后将 CosyVoice 临时音频转存到 OSS,
|
||
存储永久 URL 到 TTSJob.output_audio_url,OSS key 到 output_audio_key。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timezone
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from packages.application.cosyvoice_service import CosyVoiceService
|
||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||
|
||
|
||
def _make_job(**kwargs) -> TTSJob:
|
||
defaults = {
|
||
"id": "test_job_001",
|
||
"user_id": "user_001",
|
||
"input_text": "测试文本",
|
||
"voice_id": "voice_001",
|
||
"voice_model": "",
|
||
"project_id": "",
|
||
"voice_clone_profile_id": "",
|
||
"status": TTSJobStatus.PENDING,
|
||
"output_audio_url": "",
|
||
"output_audio_key": "",
|
||
"duration": 0.0,
|
||
"file_size": 0,
|
||
"sample_rate": 22050,
|
||
"format": "mp3",
|
||
"error_message": "",
|
||
"retry_count": 0,
|
||
"max_retries": 3,
|
||
"metadata": {},
|
||
"started_at": None,
|
||
"completed_at": None,
|
||
"created_at": datetime.now(timezone.utc),
|
||
"updated_at": datetime.now(timezone.utc),
|
||
}
|
||
defaults.update(kwargs)
|
||
return TTSJob(**defaults)
|
||
|
||
|
||
def _make_workflow(
|
||
cosyvoice_service: MagicMock | None = None,
|
||
repo: MagicMock | None = None,
|
||
storage: MagicMock | None = None,
|
||
) -> TTSWorkflowService:
|
||
if cosyvoice_service is None:
|
||
cosyvoice_service = MagicMock(spec=CosyVoiceService)
|
||
if repo is None:
|
||
repo = MagicMock()
|
||
repo.get.return_value = _make_job()
|
||
repo.update.side_effect = lambda j: j
|
||
if storage is None:
|
||
storage = MagicMock()
|
||
storage.upload_file.return_value = "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
|
||
return TTSWorkflowService(
|
||
repository=repo,
|
||
cosyvoice_service=cosyvoice_service,
|
||
storage_service=storage,
|
||
)
|
||
|
||
|
||
class TestTransferAudioToOSS:
|
||
"""测试 _transfer_audio_to_oss 方法。"""
|
||
|
||
@patch("packages.application.tts_job.workflow.httpx")
|
||
def test_success_download_and_upload(self, mock_httpx: MagicMock) -> None:
|
||
"""成功下载音频并上传到 OSS,返回永久 URL 和 storage_key。"""
|
||
mock_resp = MagicMock()
|
||
mock_resp.content = b"fake audio data"
|
||
mock_resp.raise_for_status.return_value = None
|
||
mock_httpx.get.return_value = mock_resp
|
||
|
||
storage = MagicMock()
|
||
storage.upload_file.return_value = "https://oss.example.com/tts-outputs/user_001/job_123.mp3"
|
||
|
||
workflow = _make_workflow(storage=storage)
|
||
url, key = workflow._transfer_audio_to_oss(
|
||
"https://cosyvoice-temp.com/audio.mp3",
|
||
"user_001",
|
||
"job_123",
|
||
"mp3",
|
||
)
|
||
|
||
assert url == "https://oss.example.com/tts-outputs/user_001/job_123.mp3"
|
||
assert key == "tts-outputs/user_001/job_123.mp3"
|
||
|
||
mock_httpx.get.assert_called_once_with(
|
||
"https://cosyvoice-temp.com/audio.mp3",
|
||
timeout=60.0,
|
||
follow_redirects=True,
|
||
)
|
||
storage.upload_file.assert_called_once()
|
||
call_args = storage.upload_file.call_args
|
||
assert call_args[0][1] == "tts-outputs/user_001/job_123.mp3"
|
||
assert call_args[1]["content_type"] == "audio/mpeg"
|
||
|
||
@patch("packages.application.tts_job.workflow.httpx")
|
||
def test_download_failure_fallback(self, mock_httpx: MagicMock) -> None:
|
||
"""下载失败时回退到原始临时 URL,storage_key 为空。"""
|
||
mock_httpx.get.side_effect = Exception("Network error")
|
||
|
||
workflow = _make_workflow()
|
||
url, key = workflow._transfer_audio_to_oss(
|
||
"https://cosyvoice-temp.com/audio.mp3",
|
||
"user_001",
|
||
"job_123",
|
||
)
|
||
|
||
assert url == "https://cosyvoice-temp.com/audio.mp3"
|
||
assert key == ""
|
||
|
||
@patch("packages.application.tts_job.workflow.httpx")
|
||
def test_upload_failure_fallback(self, mock_httpx: MagicMock) -> None:
|
||
"""上传 OSS 失败时回退到原始临时 URL。"""
|
||
mock_resp = MagicMock()
|
||
mock_resp.content = b"fake audio data"
|
||
mock_resp.raise_for_status.return_value = None
|
||
mock_httpx.get.return_value = mock_resp
|
||
|
||
storage = MagicMock()
|
||
storage.upload_file.side_effect = Exception("OSS bucket error")
|
||
|
||
workflow = _make_workflow(storage=storage)
|
||
url, key = workflow._transfer_audio_to_oss(
|
||
"https://cosyvoice-temp.com/audio.mp3",
|
||
"user_001",
|
||
"job_123",
|
||
)
|
||
|
||
assert url == "https://cosyvoice-temp.com/audio.mp3"
|
||
assert key == ""
|
||
|
||
@patch("packages.application.tts_job.workflow.httpx")
|
||
def test_wav_content_type(self, mock_httpx: MagicMock) -> None:
|
||
"""wav 格式使用正确的 content_type。"""
|
||
mock_resp = MagicMock()
|
||
mock_resp.content = b"fake wav data"
|
||
mock_resp.raise_for_status.return_value = None
|
||
mock_httpx.get.return_value = mock_resp
|
||
|
||
storage = MagicMock()
|
||
storage.upload_file.return_value = "https://oss.example.com/audio.wav"
|
||
|
||
workflow = _make_workflow(storage=storage)
|
||
workflow._transfer_audio_to_oss(
|
||
"https://cosyvoice-temp.com/audio.wav",
|
||
"user_001",
|
||
"job_456",
|
||
"wav",
|
||
)
|
||
|
||
call_args = storage.upload_file.call_args
|
||
assert call_args[1]["content_type"] == "audio/wav"
|
||
assert call_args[0][1] == "tts-outputs/user_001/job_456.wav"
|
||
|
||
|
||
class TestProcessSynthesisResultWithOSS:
|
||
"""测试 process_synthesis_result 集成 OSS 转存。"""
|
||
|
||
@patch("packages.application.tts_job.workflow.httpx")
|
||
def test_stores_permanent_url_and_key(self, mock_httpx: MagicMock) -> None:
|
||
"""合成结果存 OSS 永久 URL 和 storage_key。"""
|
||
mock_resp = MagicMock()
|
||
mock_resp.content = b"audio bytes"
|
||
mock_resp.raise_for_status.return_value = None
|
||
mock_httpx.get.return_value = mock_resp
|
||
|
||
storage = MagicMock()
|
||
storage.upload_file.return_value = "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
|
||
|
||
repo = MagicMock()
|
||
job = _make_job(status=TTSJobStatus.PROCESSING)
|
||
repo.get.return_value = job
|
||
repo.update.side_effect = lambda j: j
|
||
|
||
workflow = _make_workflow(repo=repo, storage=storage)
|
||
result = workflow.process_synthesis_result(
|
||
"test_job_001",
|
||
audio_url="https://cosyvoice-temp.com/expiring.mp3",
|
||
duration=5.0,
|
||
file_size=50000,
|
||
)
|
||
|
||
assert result.status == TTSJobStatus.COMPLETED
|
||
assert result.output_audio_url == "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
|
||
assert result.output_audio_key == "tts-outputs/user_001/test_job_001.mp3"
|
||
assert result.duration == 5.0
|
||
assert result.file_size == 50000
|
||
|
||
@patch("packages.application.tts_job.workflow.httpx")
|
||
def test_fallback_to_temp_url_on_oss_failure(self, mock_httpx: MagicMock) -> None:
|
||
"""OSS 转存失败时,使用 CosyVoice 临时 URL(不阻塞合成流程)。"""
|
||
mock_httpx.get.side_effect = Exception("Download failed")
|
||
|
||
repo = MagicMock()
|
||
job = _make_job(status=TTSJobStatus.PROCESSING)
|
||
repo.get.return_value = job
|
||
repo.update.side_effect = lambda j: j
|
||
|
||
workflow = _make_workflow(repo=repo)
|
||
result = workflow.process_synthesis_result(
|
||
"test_job_001",
|
||
audio_url="https://cosyvoice-temp.com/expiring.mp3",
|
||
)
|
||
|
||
assert result.status == TTSJobStatus.COMPLETED
|
||
assert result.output_audio_url == "https://cosyvoice-temp.com/expiring.mp3"
|
||
assert result.output_audio_key == ""
|
||
|
||
|
||
class TestStartSynthesisSyncWithOSS:
|
||
"""测试 start_synthesis 同步路径的 OSS 转存。"""
|
||
|
||
@patch("packages.application.tts_job.workflow.httpx")
|
||
def test_sync_path_transfers_to_oss(self, mock_httpx: MagicMock) -> None:
|
||
"""CosyVoice 同步返回 audio_url 时,也走 OSS 转存。"""
|
||
mock_resp = MagicMock()
|
||
mock_resp.content = b"sync audio bytes"
|
||
mock_resp.raise_for_status.return_value = None
|
||
mock_httpx.get.return_value = mock_resp
|
||
|
||
storage = MagicMock()
|
||
storage.upload_file.return_value = "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
|
||
|
||
service = MagicMock(spec=CosyVoiceService)
|
||
service.submit_synthesize_task.return_value = {
|
||
"task_id": "",
|
||
"audio_url": "https://cosyvoice-temp.com/sync.mp3",
|
||
"duration": 2.0,
|
||
"file_size": 20000,
|
||
"request_id": "req_sync",
|
||
}
|
||
|
||
repo = MagicMock()
|
||
repo.get.return_value = _make_job()
|
||
repo.update.side_effect = lambda j: j
|
||
|
||
workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)
|
||
job = workflow.start_synthesis("test_job_001")
|
||
|
||
assert job.status == TTSJobStatus.COMPLETED
|
||
assert job.output_audio_url == "https://oss.example.com/tts-outputs/user_001/test_job_001.mp3"
|
||
assert job.output_audio_key == "tts-outputs/user_001/test_job_001.mp3"
|
||
assert job.duration == 2.0
|
||
|
||
@patch("packages.application.tts_job.workflow.httpx")
|
||
def test_sync_path_oss_failure_stores_temp_url(self, mock_httpx: MagicMock) -> None:
|
||
"""同步路径 OSS 失败时,降级存储临时 URL。"""
|
||
mock_httpx.get.side_effect = Exception("Network error")
|
||
|
||
service = MagicMock(spec=CosyVoiceService)
|
||
service.submit_synthesize_task.return_value = {
|
||
"task_id": "",
|
||
"audio_url": "https://cosyvoice-temp.com/sync.mp3",
|
||
"duration": 2.0,
|
||
"file_size": 20000,
|
||
"request_id": "req_sync",
|
||
}
|
||
|
||
repo = MagicMock()
|
||
repo.get.return_value = _make_job()
|
||
repo.update.side_effect = lambda j: j
|
||
|
||
workflow = _make_workflow(cosyvoice_service=service, repo=repo)
|
||
job = workflow.start_synthesis("test_job_001")
|
||
|
||
assert job.status == TTSJobStatus.COMPLETED
|
||
assert job.output_audio_url == "https://cosyvoice-temp.com/sync.mp3"
|
||
assert job.output_audio_key == ""
|
||
|
||
def test_async_path_no_oss_transfer(self) -> None:
|
||
"""异步路径(返回 task_id,无 audio_url)不触发 OSS 转存。"""
|
||
service = MagicMock(spec=CosyVoiceService)
|
||
service.submit_synthesize_task.return_value = {
|
||
"task_id": "cosy_task_async",
|
||
"audio_url": "",
|
||
"duration": 0.0,
|
||
"file_size": 0,
|
||
"request_id": "req_async",
|
||
}
|
||
|
||
repo = MagicMock()
|
||
repo.get.return_value = _make_job()
|
||
repo.update.side_effect = lambda j: j
|
||
|
||
storage = MagicMock()
|
||
workflow = _make_workflow(cosyvoice_service=service, repo=repo, storage=storage)
|
||
job = workflow.start_synthesis("test_job_001")
|
||
|
||
assert job.status == TTSJobStatus.PROCESSING
|
||
# 异步路径不应调用 OSS 上传
|
||
storage.upload_file.assert_not_called()
|