"""TTS 音频转存 OSS 单元测试。 验证 TTSWorkflowService 在合成完成后将 CosyVoice 临时音频转存到 OSS, 存储永久 URL 到 TTSJob.output_audio_url,OSS key 到 output_audio_key。 """ from __future__ import annotations import unittest from datetime import datetime, timezone from unittest.mock import MagicMock, patch 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.safe_download_bytes") def test_success_download_and_upload(self, mock_download: MagicMock) -> None: """成功下载音频并上传到 OSS,返回永久 URL 和 storage_key。""" mock_download.return_value = b"fake audio data" 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_download.assert_called_once_with( "https://cosyvoice-temp.com/audio.mp3", purpose="tts_audio_download", allowed_mime_types=unittest.mock.ANY, timeout=60.0, ) 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.safe_download_bytes") def test_download_failure_fallback(self, mock_download: MagicMock) -> None: """下载失败时回退到原始临时 URL,storage_key 为空。""" mock_download.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.safe_download_bytes") def test_upload_failure_fallback(self, mock_download: MagicMock) -> None: """上传 OSS 失败时回退到原始临时 URL。""" mock_download.return_value = b"fake audio data" 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.safe_download_bytes") def test_wav_content_type(self, mock_download: MagicMock) -> None: """wav 格式使用正确的 content_type。""" mock_download.return_value = b"fake wav data" 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.safe_download_bytes") def test_stores_permanent_url_and_key(self, mock_download: MagicMock) -> None: """合成结果存 OSS 永久 URL 和 storage_key。""" mock_download.return_value = b"audio bytes" 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.safe_download_bytes") def test_fallback_to_temp_url_on_oss_failure(self, mock_download: MagicMock) -> None: """OSS 转存失败时,使用 CosyVoice 临时 URL(不阻塞合成流程)。""" mock_download.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.safe_download_bytes") def test_sync_path_transfers_to_oss(self, mock_download: MagicMock) -> None: """CosyVoice 同步返回 audio_url 时,也走 OSS 转存。""" mock_download.return_value = b"sync audio bytes" 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.safe_download_bytes") def test_sync_path_oss_failure_stores_temp_url(self, mock_download: MagicMock) -> None: """同步路径 OSS 失败时,降级存储临时 URL。""" mock_download.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()