""" Comprehensive unit tests for AudioService. This test suite provides complete coverage of audio processing operations in Dify, following TDD principles with the Arrange-Act-Assert pattern. ## Test Coverage ### 1. Speech-to-Text (ASR) Operations (TestAudioServiceASR) Tests audio transcription functionality: - Successful transcription for different app modes - File validation (size, type, presence) - Feature flag validation (speech-to-text enabled) - Error handling for various failure scenarios - Model instance availability checks ### 2. Text-to-Speech (TTS) Operations (TestAudioServiceTTS) Tests text-to-audio conversion: - TTS with text input - TTS with message ID - Voice selection (explicit and default) - Feature flag validation (text-to-speech enabled) - Draft workflow handling - Streaming response handling - Error handling for missing/invalid inputs ### 3. TTS Voice Listing (TestAudioServiceTTSVoices) Tests available voice retrieval: - Get available voices for a tenant - Language filtering - Error handling for missing provider ## Testing Approach - **Isolation Strategy**: External dependencies (ModelManager and FileStorage) are mocked, while database paths use isolated in-memory SQLite sessions - **Factory Pattern**: AudioServiceTestDataFactory provides consistent test data - **Fixtures**: Mock objects are configured per test method - **Assertions**: Each test verifies return values, side effects, and error conditions ## Key Concepts **Audio Formats:** - Supported: mp3, wav, m4a, flac, ogg, opus, webm - File size limit: 30 MB **App Modes:** - ADVANCED_CHAT/WORKFLOW: Use workflow features - CHAT/COMPLETION: Use app_model_config **Feature Flags:** - speech_to_text: Enables ASR functionality - text_to_speech: Enables TTS functionality """ from decimal import Decimal from typing import Any from unittest.mock import MagicMock, Mock, create_autospec, patch import pytest from sqlalchemy.orm import Session from werkzeug.datastructures import FileStorage from models.agent_config_entities import AgentSoulConfig from models.enums import ConversationFromSource, MessageStatus from models.model import App, AppMode, AppModelConfig, Message from models.workflow import Workflow from services.app_ref_service import AppRef, MessageRef from services.audio_service import AudioService from services.errors.audio import ( AudioTooLargeServiceError, NoAudioUploadedServiceError, ProviderNotSupportSpeechToTextServiceError, ProviderNotSupportTextToSpeechServiceError, SpeechToTextDisabledServiceError, UnsupportedAudioTypeServiceError, ) APP_ID = "11111111-1111-1111-1111-111111111111" TENANT_ID = "22222222-2222-2222-2222-222222222222" MESSAGE_ID = "33333333-3333-3333-3333-333333333333" CONVERSATION_ID = "44444444-4444-4444-4444-444444444444" END_USER_ID = "55555555-5555-5555-5555-555555555555" ACCOUNT_ID = "66666666-6666-6666-6666-666666666666" OTHER_ID = "77777777-7777-7777-7777-777777777777" def _message(*, answer: str = "Message answer") -> Message: message = Message( id=MESSAGE_ID, app_id=APP_ID, conversation_id=CONVERSATION_ID, query="Question", message={"role": "user", "content": "Question"}, answer=answer, message_unit_price=Decimal(0), answer_unit_price=Decimal(0), currency="USD", status=MessageStatus.NORMAL, from_source=ConversationFromSource.API, from_end_user_id=END_USER_ID, from_account_id=ACCOUNT_ID, ) message._inputs = {} return message class AudioServiceTestDataFactory: """ Factory for creating test data and mock objects. Provides reusable methods to create consistent mock objects for testing audio-related operations. """ @staticmethod def create_app_mock( app_id: str = "app-123", mode: AppMode = AppMode.CHAT, tenant_id: str = "tenant-123", **kwargs, ) -> Mock: """ Create a mock App object. Args: app_id: Unique identifier for the app mode: App mode (CHAT, ADVANCED_CHAT, WORKFLOW, etc.) tenant_id: Tenant identifier **kwargs: Additional attributes to set on the mock Returns: Mock App object with specified attributes """ app = create_autospec(App, instance=True) app.id = app_id app.mode = mode app.tenant_id = tenant_id app.workflow = kwargs.get("workflow") app.app_model_config = kwargs.get("app_model_config") app.workflow_with_session.return_value = app.workflow app.app_model_config_with_session.return_value = app.app_model_config for key, value in kwargs.items(): setattr(app, key, value) return app @staticmethod def create_workflow_mock(features_dict: dict[str, Any] | None = None, **kwargs) -> Mock: """ Create a mock Workflow object. Args: features_dict: Dictionary of workflow features **kwargs: Additional attributes to set on the mock Returns: Mock Workflow object with specified attributes """ workflow = create_autospec(Workflow, instance=True) workflow.features_dict = features_dict or {} for key, value in kwargs.items(): setattr(workflow, key, value) return workflow @staticmethod def create_app_model_config_mock( speech_to_text_dict: dict[str, Any] | None = None, text_to_speech_dict: dict[str, Any] | None = None, **kwargs, ) -> Mock: """ Create a mock AppModelConfig object. Args: speech_to_text_dict: Speech-to-text configuration text_to_speech_dict: Text-to-speech configuration **kwargs: Additional attributes to set on the mock Returns: Mock AppModelConfig object with specified attributes """ config = create_autospec(AppModelConfig, instance=True) config.speech_to_text_dict = speech_to_text_dict or {"enabled": False} config.text_to_speech_dict = text_to_speech_dict or {"enabled": False} for key, value in kwargs.items(): setattr(config, key, value) return config @staticmethod def create_file_storage_mock( filename: str = "test.mp3", mimetype: str = "audio/mp3", content: bytes = b"fake audio content", **kwargs, ) -> Mock: """ Create a mock FileStorage object. Args: filename: Name of the file mimetype: MIME type of the file content: File content as bytes **kwargs: Additional attributes to set on the mock Returns: Mock FileStorage object with specified attributes """ file = Mock(spec=FileStorage) file.filename = filename file.mimetype = mimetype file.stream = Mock() file.stream.read = Mock(return_value=content) for key, value in kwargs.items(): setattr(file, key, value) return file @pytest.fixture def factory(): """Provide the test data factory to all tests.""" return AudioServiceTestDataFactory class TestAudioServiceASR: """Test speech-to-text (ASR) operations.""" @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_asr_success_chat_mode(self, mock_model_manager_class, factory: AudioServiceTestDataFactory): """Test successful ASR transcription in CHAT mode.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) app = factory.create_app_mock( mode=AppMode.CHAT, app_model_config=app_model_config, ) file = factory.create_file_storage_mock() # Mock ModelManager mock_model_manager = mock_model_manager_class.return_value mock_model_instance = MagicMock() mock_model_instance.invoke_speech2text.return_value = "Transcribed text" mock_model_manager.get_default_model_instance.return_value = mock_model_instance # Act result = AudioService.transcript_asr(app_model=app, file=file, session=MagicMock(), end_user="user-123") # Assert assert result == {"text": "Transcribed text"} mock_model_instance.invoke_speech2text.assert_called_once() mock_model_manager_class.assert_called_once_with(tenant_id=app.tenant_id, user_id="user-123") @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_asr_success_advanced_chat_mode( self, mock_model_manager_class, factory: AudioServiceTestDataFactory ): """Test successful ASR transcription in ADVANCED_CHAT mode.""" # Arrange workflow = factory.create_workflow_mock(features_dict={"speech_to_text": {"enabled": True}}) app = factory.create_app_mock( mode=AppMode.ADVANCED_CHAT, workflow=workflow, ) file = factory.create_file_storage_mock() # Mock ModelManager mock_model_manager = mock_model_manager_class.return_value mock_model_instance = MagicMock() mock_model_instance.invoke_speech2text.return_value = "Workflow transcribed text" mock_model_manager.get_default_model_instance.return_value = mock_model_instance # Act result = AudioService.transcript_asr(app_model=app, file=file, session=MagicMock()) # Assert assert result == {"text": "Workflow transcribed text"} @patch("services.audio_service.AgentRosterService", autospec=True) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_asr_success_published_agent_mode( self, mock_model_manager_class, mock_roster_service_class, factory: AudioServiceTestDataFactory, ): app = factory.create_app_mock(mode=AppMode.AGENT) file = factory.create_file_storage_mock() agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": True}}}) mock_roster_service_class.return_value.get_published_agent_soul_for_app.return_value = agent_soul mock_model_instance = MagicMock() mock_model_instance.invoke_speech2text.return_value = "Published Agent transcript" mock_model_manager_class.return_value.get_default_model_instance.return_value = mock_model_instance result = AudioService.transcript_asr(app_model=app, file=file, session=MagicMock(), end_user="end-user-1") assert result == {"text": "Published Agent transcript"} mock_roster_service_class.return_value.get_published_agent_soul_for_app.assert_called_once_with( tenant_id=app.tenant_id, app_id=app.id, ) @patch("services.audio_service.AgentRosterService", autospec=True) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_asr_legacy_agent_falls_back_to_app_model_config( self, mock_model_manager_class, mock_roster_service_class, factory: AudioServiceTestDataFactory, ): app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) app = factory.create_app_mock(mode=AppMode.AGENT, app_model_config=app_model_config) file = factory.create_file_storage_mock() mock_roster_service_class.return_value.get_published_agent_soul_for_app.return_value = None mock_model_instance = MagicMock() mock_model_instance.invoke_speech2text.return_value = "Legacy Agent transcript" mock_model_manager_class.return_value.get_default_model_instance.return_value = mock_model_instance result = AudioService.transcript_asr(app_model=app, file=file, session=MagicMock()) assert result == {"text": "Legacy Agent transcript"} @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_agent_asr_uses_agent_soul_feature( self, mock_model_manager_class, factory: AudioServiceTestDataFactory ): app = factory.create_app_mock(mode=AppMode.AGENT) file = factory.create_file_storage_mock() agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": True}}}) mock_model_instance = MagicMock() mock_model_instance.invoke_speech2text.return_value = "Agent transcript" mock_model_manager_class.return_value.get_default_model_instance.return_value = mock_model_instance result = AudioService.transcript_agent_asr( app_model=app, agent_soul=agent_soul, file=file, session=MagicMock(), end_user="account-1", ) assert result == {"text": "Agent transcript"} mock_model_manager_class.assert_called_once_with(tenant_id=app.tenant_id, user_id="account-1") @pytest.mark.parametrize( "agent_soul", [ AgentSoulConfig(), AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": False}}}), ], ) def test_transcript_agent_asr_rejects_disabled_feature( self, factory: AudioServiceTestDataFactory, agent_soul: AgentSoulConfig ): app = factory.create_app_mock(mode=AppMode.AGENT) file = factory.create_file_storage_mock() with pytest.raises(SpeechToTextDisabledServiceError): AudioService.transcript_agent_asr(app_model=app, agent_soul=agent_soul, file=file, session=MagicMock()) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_agent_asr_preserves_legacy_feature_fallback( self, mock_model_manager_class, factory: AudioServiceTestDataFactory ): app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) app_model_config.to_dict.return_value = {"speech_to_text": {"enabled": True}} app = factory.create_app_mock(mode=AppMode.AGENT, app_model_config=app_model_config) file = factory.create_file_storage_mock() mock_model_instance = MagicMock() mock_model_instance.invoke_speech2text.return_value = "Legacy feature transcript" mock_model_manager_class.return_value.get_default_model_instance.return_value = mock_model_instance result = AudioService.transcript_agent_asr( app_model=app, agent_soul=AgentSoulConfig(), file=file, session=MagicMock(), ) assert result == {"text": "Legacy feature transcript"} def test_transcript_agent_asr_soul_disabled_overrides_legacy_feature(self, factory: AudioServiceTestDataFactory): app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) app_model_config.to_dict.return_value = {"speech_to_text": {"enabled": True}} app = factory.create_app_mock(mode=AppMode.AGENT, app_model_config=app_model_config) file = factory.create_file_storage_mock() agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": False}}}) with pytest.raises(SpeechToTextDisabledServiceError): AudioService.transcript_agent_asr(app_model=app, agent_soul=agent_soul, file=file, session=MagicMock()) def test_transcript_asr_raises_error_when_feature_disabled_chat_mode(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error when speech-to-text is disabled in CHAT mode.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": False}) app = factory.create_app_mock( mode=AppMode.CHAT, app_model_config=app_model_config, ) file = factory.create_file_storage_mock() # Act & Assert with pytest.raises(SpeechToTextDisabledServiceError): AudioService.transcript_asr(app_model=app, file=file, session=MagicMock()) def test_transcript_asr_raises_error_when_feature_disabled_workflow_mode( self, factory: AudioServiceTestDataFactory ): """Test that ASR raises error when speech-to-text is disabled in WORKFLOW mode.""" # Arrange workflow = factory.create_workflow_mock(features_dict={"speech_to_text": {"enabled": False}}) app = factory.create_app_mock( mode=AppMode.WORKFLOW, workflow=workflow, ) file = factory.create_file_storage_mock() # Act & Assert with pytest.raises(SpeechToTextDisabledServiceError): AudioService.transcript_asr(app_model=app, file=file, session=MagicMock()) def test_transcript_asr_raises_error_when_workflow_missing(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error when workflow is missing in WORKFLOW mode.""" # Arrange app = factory.create_app_mock( mode=AppMode.WORKFLOW, workflow=None, ) file = factory.create_file_storage_mock() # Act & Assert with pytest.raises(SpeechToTextDisabledServiceError): AudioService.transcript_asr(app_model=app, file=file, session=MagicMock()) def test_transcript_asr_raises_error_when_no_file_uploaded(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error when no file is uploaded.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) app = factory.create_app_mock( mode=AppMode.CHAT, app_model_config=app_model_config, ) # Act & Assert with pytest.raises(NoAudioUploadedServiceError): AudioService.transcript_asr(app_model=app, file=None, session=MagicMock()) def test_transcript_asr_raises_error_for_unsupported_audio_type(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error for unsupported audio file types.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) app = factory.create_app_mock( mode=AppMode.CHAT, app_model_config=app_model_config, ) file = factory.create_file_storage_mock(mimetype="video/mp4") # Act & Assert with pytest.raises(UnsupportedAudioTypeServiceError): AudioService.transcript_asr(app_model=app, file=file, session=MagicMock()) def test_transcript_asr_raises_error_for_large_file(self, factory: AudioServiceTestDataFactory): """Test that ASR raises error when file exceeds size limit (30MB).""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) app = factory.create_app_mock( mode=AppMode.CHAT, app_model_config=app_model_config, ) # Create file larger than 30MB large_content = b"x" * (31 * 1024 * 1024) file = factory.create_file_storage_mock(content=large_content) # Act & Assert with pytest.raises(AudioTooLargeServiceError, match="Audio size larger than 30 mb"): AudioService.transcript_asr(app_model=app, file=file, session=MagicMock()) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_asr_raises_error_when_no_model_instance( self, mock_model_manager_class, factory: AudioServiceTestDataFactory ): """Test that ASR raises error when no model instance is available.""" # Arrange app_model_config = factory.create_app_model_config_mock(speech_to_text_dict={"enabled": True}) app = factory.create_app_mock( mode=AppMode.CHAT, app_model_config=app_model_config, ) file = factory.create_file_storage_mock() # Mock ModelManager to return None mock_model_manager = mock_model_manager_class.return_value mock_model_manager.get_default_model_instance.return_value = None # Act & Assert with pytest.raises(ProviderNotSupportSpeechToTextServiceError): AudioService.transcript_asr(app_model=app, file=file, session=MagicMock()) @pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True) class TestAudioServiceTTS: """Test text-to-speech (TTS) operations.""" @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_tts_with_text_success( self, mock_model_manager_class, factory: AudioServiceTestDataFactory, sqlite_session: Session, ): """Test successful TTS with text input.""" # Arrange app_model_config = factory.create_app_model_config_mock( text_to_speech_dict={"enabled": True, "voice": "en-US-Neural"} ) app = factory.create_app_mock( mode=AppMode.CHAT, app_model_config=app_model_config, ) # Mock ModelManager mock_model_manager = mock_model_manager_class.return_value mock_model_instance = MagicMock() mock_model_instance.invoke_tts.return_value = b"audio data" mock_model_manager.get_default_model_instance.return_value = mock_model_instance # Act result = AudioService.transcript_tts( app_model=app, session=sqlite_session, text="Hello world", voice="en-US-Neural", end_user="user-123", ) # Assert assert result == b"audio data" mock_model_manager_class.assert_called_once_with(tenant_id=app.tenant_id, user_id="user-123") mock_model_instance.invoke_tts.assert_called_once_with( content_text="Hello world", voice="en-US-Neural", ) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_tts_with_default_voice( self, mock_model_manager_class, factory: AudioServiceTestDataFactory, sqlite_session: Session, ): """Test TTS uses default voice when none specified.""" # Arrange app_model_config = factory.create_app_model_config_mock( text_to_speech_dict={"enabled": True, "voice": "default-voice"} ) app = factory.create_app_mock( mode=AppMode.CHAT, app_model_config=app_model_config, ) # Mock ModelManager mock_model_manager = mock_model_manager_class.return_value mock_model_instance = MagicMock() mock_model_instance.invoke_tts.return_value = b"audio data" mock_model_manager.get_default_model_instance.return_value = mock_model_instance # Act result = AudioService.transcript_tts( app_model=app, session=sqlite_session, text="Test", ) # Assert assert result == b"audio data" # Verify default voice was used call_args = mock_model_instance.invoke_tts.call_args assert call_args.kwargs["voice"] == "default-voice" @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_tts_gets_first_available_voice_when_none_configured( self, mock_model_manager_class, factory: AudioServiceTestDataFactory, sqlite_session: Session, ): """Test TTS gets first available voice when none is configured.""" # Arrange app_model_config = factory.create_app_model_config_mock( text_to_speech_dict={"enabled": True} # No voice specified ) app = factory.create_app_mock( mode=AppMode.CHAT, app_model_config=app_model_config, ) # Mock ModelManager mock_model_manager = mock_model_manager_class.return_value mock_model_instance = MagicMock() mock_model_instance.get_tts_voices.return_value = [{"value": "auto-voice"}] mock_model_instance.invoke_tts.return_value = b"audio data" mock_model_manager.get_default_model_instance.return_value = mock_model_instance # Act result = AudioService.transcript_tts( app_model=app, session=sqlite_session, text="Test", ) # Assert assert result == b"audio data" call_args = mock_model_instance.invoke_tts.call_args assert call_args.kwargs["voice"] == "auto-voice" @patch("services.audio_service.WorkflowService", autospec=True) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_tts_workflow_mode_with_draft( self, mock_model_manager_class, mock_workflow_service_class, factory: AudioServiceTestDataFactory, sqlite_session: Session, ): """Test TTS in WORKFLOW mode with draft workflow.""" # Arrange draft_workflow = factory.create_workflow_mock( features_dict={"text_to_speech": {"enabled": True, "voice": "draft-voice"}} ) app = factory.create_app_mock( mode=AppMode.WORKFLOW, ) # Mock WorkflowService mock_workflow_service = mock_workflow_service_class.return_value mock_workflow_service.get_draft_workflow.return_value = draft_workflow # Mock ModelManager mock_model_manager = mock_model_manager_class.return_value mock_model_instance = MagicMock() mock_model_instance.invoke_tts.return_value = b"draft audio" mock_model_manager.get_default_model_instance.return_value = mock_model_instance # Act result = AudioService.transcript_tts( app_model=app, session=sqlite_session, text="Draft test", is_draft=True, ) # Assert assert result == b"draft audio" mock_workflow_service.get_draft_workflow.assert_called_once_with(app_model=app, session=sqlite_session) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_tts_message_id_uses_provided_session( self, mock_model_manager_class, factory: AudioServiceTestDataFactory, sqlite_session: Session, ): """Test TTS message lookup uses the injected session.""" # Arrange app = factory.create_app_mock(app_id=APP_ID, tenant_id=TENANT_ID, mode=AppMode.CHAT) message_ref = MessageRef( app=AppRef(tenant_id=TENANT_ID, app_id=APP_ID), message_id=MESSAGE_ID, end_user_id=END_USER_ID, account_id=ACCOUNT_ID, ) sqlite_session.add(_message()) sqlite_session.commit() mock_model_manager = mock_model_manager_class.return_value mock_model_instance = MagicMock() mock_model_instance.invoke_tts.return_value = b"message audio" mock_model_manager.get_default_model_instance.return_value = mock_model_instance # Act for wrong_ref in ( MessageRef( app=AppRef(tenant_id=TENANT_ID, app_id=OTHER_ID), message_id=MESSAGE_ID, end_user_id=END_USER_ID, account_id=ACCOUNT_ID, ), MessageRef( app=AppRef(tenant_id=TENANT_ID, app_id=APP_ID), message_id=MESSAGE_ID, end_user_id=OTHER_ID, account_id=ACCOUNT_ID, ), MessageRef( app=AppRef(tenant_id=TENANT_ID, app_id=APP_ID), message_id=MESSAGE_ID, end_user_id=END_USER_ID, account_id=OTHER_ID, ), ): assert ( AudioService.transcript_tts( app_model=app, session=sqlite_session, message_ref=wrong_ref, voice="message-voice", ) is None ) result = AudioService.transcript_tts( app_model=app, session=sqlite_session, message_ref=message_ref, voice="message-voice", ) # Assert assert result == b"message audio" mock_model_instance.invoke_tts.assert_called_once_with( content_text="Message answer", voice="message-voice", ) def test_transcript_tts_raises_error_when_text_missing( self, factory: AudioServiceTestDataFactory, sqlite_session: Session, ): """Test that TTS raises error when text is missing.""" # Arrange app = factory.create_app_mock() # Act & Assert with pytest.raises(ValueError, match="Text is required"): AudioService.transcript_tts(app_model=app, session=sqlite_session, text=None) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_tts_raises_error_when_no_voices_available( self, mock_model_manager_class, factory: AudioServiceTestDataFactory, sqlite_session: Session, ): """Test that TTS raises error when no voices are available.""" # Arrange app_model_config = factory.create_app_model_config_mock( text_to_speech_dict={"enabled": True} # No voice specified ) app = factory.create_app_mock( mode=AppMode.CHAT, app_model_config=app_model_config, ) # Mock ModelManager mock_model_manager = mock_model_manager_class.return_value mock_model_instance = MagicMock() mock_model_instance.get_tts_voices.return_value = [] # No voices available mock_model_manager.get_default_model_instance.return_value = mock_model_instance # Act & Assert with pytest.raises(ValueError, match="Sorry, no voice available"): AudioService.transcript_tts(app_model=app, session=sqlite_session, text="Test") class TestAudioServiceTTSVoices: """Test TTS voice listing operations.""" @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_tts_voices_success(self, mock_model_manager_class, factory: AudioServiceTestDataFactory): """Test successful retrieval of TTS voices.""" # Arrange tenant_id = "tenant-123" language = "en-US" expected_voices = [ {"name": "Voice 1", "value": "voice-1"}, {"name": "Voice 2", "value": "voice-2"}, ] # Mock ModelManager mock_model_manager = mock_model_manager_class.return_value mock_model_instance = MagicMock() mock_model_instance.get_tts_voices.return_value = expected_voices mock_model_manager.get_default_model_instance.return_value = mock_model_instance # Act result = AudioService.transcript_tts_voices(tenant_id=tenant_id, language=language) # Assert assert result == expected_voices mock_model_instance.get_tts_voices.assert_called_once_with(language) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_tts_voices_raises_error_when_no_model_instance( self, mock_model_manager_class, factory: AudioServiceTestDataFactory ): """Test that TTS voices raises error when no model instance is available.""" # Arrange tenant_id = "tenant-123" language = "en-US" # Mock ModelManager to return None mock_model_manager = mock_model_manager_class.return_value mock_model_manager.get_default_model_instance.return_value = None # Act & Assert with pytest.raises(ProviderNotSupportTextToSpeechServiceError): AudioService.transcript_tts_voices(tenant_id=tenant_id, language=language) @patch("services.audio_service.ModelManager.for_tenant", autospec=True) def test_transcript_tts_voices_propagates_exceptions( self, mock_model_manager_class, factory: AudioServiceTestDataFactory ): """Test that TTS voices propagates exceptions from model instance.""" # Arrange tenant_id = "tenant-123" language = "en-US" # Mock ModelManager mock_model_manager = mock_model_manager_class.return_value mock_model_instance = MagicMock() mock_model_instance.get_tts_voices.side_effect = RuntimeError("Model error") mock_model_manager.get_default_model_instance.return_value = mock_model_instance # Act & Assert with pytest.raises(RuntimeError, match="Model error"): AudioService.transcript_tts_voices(tenant_id=tenant_id, language=language)