from __future__ import annotations import io from inspect import unwrap from types import SimpleNamespace from unittest.mock import patch from uuid import UUID import pytest from flask import Flask from werkzeug.datastructures import FileStorage from werkzeug.exceptions import Forbidden, InternalServerError from controllers.console.app import audio as audio_module from controllers.console.app.audio import ( AgentChatMessageAudioApi, ChatMessageAudioApi, ChatMessageTextApi, TextModesApi, ) from controllers.console.app.error import ( AppUnavailableError, AudioTooLargeError, CompletionRequestError, NoAudioUploadedError, ProviderModelCurrentlyNotSupportError, ProviderNotInitializeError, ProviderNotSupportSpeechToTextError, ProviderQuotaExceededError, SpeechToTextDisabledError, UnsupportedAudioTypeError, ) from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from graphon.model_runtime.errors.invoke import InvokeError from models import AppMode from models.agent import AgentConfigDraftType from models.agent_config_entities import AgentSoulConfig from services.agent.composer_service import AgentComposerService from services.agent.errors import AgentVersionNotFoundError from services.app_ref_service import AppRef, MessageRef from services.audio_service import AudioService from services.errors.app_model_config import AppModelConfigBrokenError from services.errors.audio import ( AudioTooLargeServiceError, NoAudioUploadedServiceError, ProviderNotSupportSpeechToTextServiceError, ProviderNotSupportTextToSpeechLanageServiceError, SpeechToTextDisabledServiceError, UnsupportedAudioTypeServiceError, ) def _file_data(): return FileStorage(stream=io.BytesIO(b"audio"), filename="audio.wav", content_type="audio/wav") def test_console_audio_api_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: {"text": "ok"}) api = ChatMessageAudioApi() handler = unwrap(api.post) app_model = SimpleNamespace(id="a1") with app.test_request_context("/console/api/apps/app/audio-to-text", method="POST", data={"file": _file_data()}): response = handler(api, app_model=app_model) assert response == {"text": "ok"} def test_console_audio_api_accepts_published_agent_apps() -> None: assert AppMode.AGENT in audio_module._CONSOLE_AUDIO_TRANSCRIPT_APP_MODES def test_agent_console_audio_api_uses_agent_draft(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: agent_id = UUID("019ef3d2-b24c-7803-b428-18b5ee8fb853") app_model = SimpleNamespace(id="backing-app-1") agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": True}}}) calls: dict[str, object] = {} def resolve_agent_runtime_app_model(**kwargs): calls["resolver"] = kwargs return app_model def load_agent_soul_for_debug(**kwargs): calls["draft"] = kwargs return agent_soul def transcript_agent_asr(**kwargs): calls["asr"] = kwargs return {"text": "agent transcript"} def enforce_rbac_access(**kwargs): calls["rbac"] = kwargs monkeypatch.setattr(audio_module, "resolve_agent_runtime_app_model", resolve_agent_runtime_app_model) monkeypatch.setattr(audio_module, "enforce_rbac_access", enforce_rbac_access) monkeypatch.setattr(AgentComposerService, "load_agent_soul_for_debug", load_agent_soul_for_debug) monkeypatch.setattr(AudioService, "transcript_agent_asr", transcript_agent_asr) api = AgentChatMessageAudioApi() handler = unwrap(api.post) session = SimpleNamespace() current_user = SimpleNamespace(id="account-1") with app.test_request_context( f"/console/api/agent/{agent_id}/audio-to-text", method="POST", data={"file": _file_data(), "draft_type": "debug_build"}, ): response = handler( api, session=session, current_tenant_id="tenant-1", current_user=current_user, agent_id=agent_id, ) assert response == {"text": "agent transcript"} assert calls["resolver"] == {"session": session, "tenant_id": "tenant-1", "agent_id": agent_id} assert calls["rbac"] == { "tenant_id": "tenant-1", "account_id": "account-1", "resource_type": audio_module.RBACResourceScope.APP, "scene": audio_module.RBACPermission.APP_TEST_AND_RUN, "path_args": {"app_id": "backing-app-1"}, } assert calls["draft"] == { "tenant_id": "tenant-1", "agent_id": str(agent_id), "account_id": "account-1", "draft_type": AgentConfigDraftType.DEBUG_BUILD, "session": session, } assert calls["asr"] == { "app_model": app_model, "agent_soul": agent_soul, "file": calls["asr"]["file"], "session": session, "end_user": None, } def test_agent_console_audio_api_defaults_to_normal_draft(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: agent_id = UUID("019ef3d2-b24c-7803-b428-18b5ee8fb853") captured: dict[str, object] = {} monkeypatch.setattr( audio_module, "resolve_agent_runtime_app_model", lambda **_kwargs: SimpleNamespace(id="backing-app-1"), ) def load_agent_soul_for_debug(**kwargs): captured.update(kwargs) return AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": True}}}) monkeypatch.setattr(AgentComposerService, "load_agent_soul_for_debug", load_agent_soul_for_debug) monkeypatch.setattr(AudioService, "transcript_agent_asr", lambda **_kwargs: {"text": "ok"}) api = AgentChatMessageAudioApi() handler = unwrap(api.post) with app.test_request_context( f"/console/api/agent/{agent_id}/audio-to-text", method="POST", data={"file": _file_data()}, ): response = handler( api, session=SimpleNamespace(), current_tenant_id="tenant-1", current_user=SimpleNamespace(id="account-1"), agent_id=agent_id, ) assert response == {"text": "ok"} assert captured["draft_type"] == AgentConfigDraftType.DRAFT def test_agent_console_audio_api_checks_rbac_with_backing_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: agent_id = UUID("019ef3d2-b24c-7803-b428-18b5ee8fb853") app_model = SimpleNamespace(id="backing-app-1") soul_loaded = False monkeypatch.setattr(audio_module, "resolve_agent_runtime_app_model", lambda **_kwargs: app_model) def deny_access(**kwargs): assert kwargs["path_args"] == {"app_id": "backing-app-1"} raise Forbidden() def load_agent_soul_for_debug(**_kwargs): nonlocal soul_loaded soul_loaded = True return AgentSoulConfig() monkeypatch.setattr(audio_module, "enforce_rbac_access", deny_access) monkeypatch.setattr(AgentComposerService, "load_agent_soul_for_debug", load_agent_soul_for_debug) api = AgentChatMessageAudioApi() handler = unwrap(api.post) with app.test_request_context( f"/console/api/agent/{agent_id}/audio-to-text", method="POST", data={"file": _file_data()}, ): with pytest.raises(Forbidden): handler( api, session=SimpleNamespace(), current_tenant_id="tenant-1", current_user=SimpleNamespace(id="account-1"), agent_id=agent_id, ) assert soul_loaded is False def test_agent_console_audio_api_preserves_missing_build_draft_404(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: agent_id = UUID("019ef3d2-b24c-7803-b428-18b5ee8fb853") monkeypatch.setattr( audio_module, "resolve_agent_runtime_app_model", lambda **_kwargs: SimpleNamespace(id="backing-app-1"), ) monkeypatch.setattr( AgentComposerService, "load_agent_soul_for_debug", lambda **_kwargs: (_ for _ in ()).throw(AgentVersionNotFoundError()), ) api = AgentChatMessageAudioApi() handler = unwrap(api.post) with app.test_request_context( f"/console/api/agent/{agent_id}/audio-to-text", method="POST", data={"file": _file_data(), "draft_type": "debug_build"}, ): with pytest.raises(AgentVersionNotFoundError): handler( api, session=SimpleNamespace(), current_tenant_id="tenant-1", current_user=SimpleNamespace(id="account-1"), agent_id=agent_id, ) @pytest.mark.parametrize( ("exc", "expected"), [ (AppModelConfigBrokenError(), AppUnavailableError), (NoAudioUploadedServiceError(), NoAudioUploadedError), (AudioTooLargeServiceError("too big"), AudioTooLargeError), (UnsupportedAudioTypeServiceError(), UnsupportedAudioTypeError), (ProviderNotSupportSpeechToTextServiceError(), ProviderNotSupportSpeechToTextError), (SpeechToTextDisabledServiceError(), SpeechToTextDisabledError), (ProviderTokenNotInitError("token"), ProviderNotInitializeError), (QuotaExceededError(), ProviderQuotaExceededError), (ModelCurrentlyNotSupportError(), ProviderModelCurrentlyNotSupportError), (InvokeError("invoke"), CompletionRequestError), ], ) def test_console_audio_api_error_mapping(app: Flask, monkeypatch: pytest.MonkeyPatch, exc, expected) -> None: monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: (_ for _ in ()).throw(exc)) api = ChatMessageAudioApi() handler = unwrap(api.post) app_model = SimpleNamespace(id="a1") with app.test_request_context("/console/api/apps/app/audio-to-text", method="POST", data={"file": _file_data()}): with pytest.raises(expected): handler(api, app_model=app_model) def test_console_audio_api_unhandled_error(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) api = ChatMessageAudioApi() handler = unwrap(api.post) app_model = SimpleNamespace(id="a1") with app.test_request_context("/console/api/apps/app/audio-to-text", method="POST", data={"file": _file_data()}): with pytest.raises(InternalServerError): handler(api, app_model=app_model) def test_console_text_api_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(AudioService, "transcript_tts", lambda **_kwargs: {"audio": "ok"}) api = ChatMessageTextApi() handler = unwrap(api.post) app_model = SimpleNamespace(id="a1") with app.test_request_context( "/console/api/apps/app/text-to-audio", method="POST", json={"text": "hello", "voice": "v"}, ): response = handler(api, app_model=app_model) assert response == {"audio": "ok"} def test_console_text_api_builds_message_ref(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = ChatMessageTextApi() handler = unwrap(api.post) app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") calls = {} def fake_transcript_tts(**kwargs): calls.update(kwargs) return {"audio": "ok"} monkeypatch.setattr(AudioService, "transcript_tts", fake_transcript_tts) with ( app.test_request_context( "/console/api/apps/app-1/text-to-audio", method="POST", json={"text": "hello", "message_id": "message-1"}, ), patch("controllers.console.app.audio.current_user", SimpleNamespace(id="account-1")), ): response = handler(api, app_model=app_model) assert response == {"audio": "ok"} assert calls["message_ref"] == MessageRef(AppRef("tenant-1", "app-1"), "message-1", account_id="account-1") def test_console_text_api_error_mapping(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(AudioService, "transcript_tts", lambda **_kwargs: (_ for _ in ()).throw(QuotaExceededError())) api = ChatMessageTextApi() handler = unwrap(api.post) app_model = SimpleNamespace(id="a1") with app.test_request_context( "/console/api/apps/app/text-to-audio", method="POST", json={"text": "hello"}, ): with pytest.raises(ProviderQuotaExceededError): handler(api, app_model=app_model) def test_console_text_modes_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: expected_voices = [{"name": "Voice 1", "value": "voice-1"}] monkeypatch.setattr(AudioService, "transcript_tts_voices", lambda **_kwargs: expected_voices) api = TextModesApi() handler = unwrap(api.get) app_model = SimpleNamespace(tenant_id="t1") with app.test_request_context("/console/api/apps/app/text-to-audio/voices?language=en", method="GET"): response = handler(api, app_model=app_model) assert response == expected_voices def test_console_text_modes_language_error(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( AudioService, "transcript_tts_voices", lambda **_kwargs: (_ for _ in ()).throw(ProviderNotSupportTextToSpeechLanageServiceError()), ) api = TextModesApi() handler = unwrap(api.get) app_model = SimpleNamespace(tenant_id="t1") with app.test_request_context("/console/api/apps/app/text-to-audio/voices?language=en", method="GET"): with pytest.raises(AppUnavailableError): handler(api, app_model=app_model) def test_audio_to_text_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = ChatMessageAudioApi() method = unwrap(api.post) response_payload = {"text": "hello"} monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: response_payload) app_model = SimpleNamespace(id="app-1") data = {"file": (io.BytesIO(b"x"), "sample.wav")} with app.test_request_context( "/console/api/apps/app-1/audio-to-text", method="POST", data=data, content_type="multipart/form-data", ): response = method(api, app_model=app_model) assert response == response_payload def test_audio_to_text_maps_audio_too_large(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = ChatMessageAudioApi() method = unwrap(api.post) monkeypatch.setattr( AudioService, "transcript_asr", lambda **_kwargs: (_ for _ in ()).throw(AudioTooLargeServiceError("too large")), ) app_model = SimpleNamespace(id="app-1") data = {"file": (io.BytesIO(b"x"), "sample.wav")} with app.test_request_context( "/console/api/apps/app-1/audio-to-text", method="POST", data=data, content_type="multipart/form-data", ): with pytest.raises(AudioTooLargeError): method(api, app_model=app_model) def test_text_to_audio_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = ChatMessageTextApi() method = unwrap(api.post) monkeypatch.setattr(AudioService, "transcript_tts", lambda **_kwargs: {"audio": "ok"}) app_model = SimpleNamespace(id="app-1") with app.test_request_context( "/console/api/apps/app-1/text-to-audio", method="POST", json={"text": "hello"}, ): response = method(api, app_model=app_model) assert response == {"audio": "ok"} def test_text_to_audio_voices_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = TextModesApi() method = unwrap(api.get) expected_voices = [{"name": "Voice 1", "value": "voice-1"}] monkeypatch.setattr(AudioService, "transcript_tts_voices", lambda **_kwargs: expected_voices) app_model = SimpleNamespace(tenant_id="tenant-1") with app.test_request_context( "/console/api/apps/app-1/text-to-audio/voices", method="GET", query_string={"language": "en-US"}, ): response = method(api, app_model=app_model) assert response == expected_voices def test_audio_to_text_with_invalid_file(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = ChatMessageAudioApi() method = unwrap(api.post) monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: {"text": "test"}) app_model = SimpleNamespace(id="app-1") data = {"file": (io.BytesIO(b"invalid"), "sample.xyz")} with app.test_request_context( "/console/api/apps/app-1/audio-to-text", method="POST", data=data, content_type="multipart/form-data", ): # Should not raise, AudioService is mocked response = method(api, app_model=app_model) assert response == {"text": "test"} def test_text_to_audio_with_language_param(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = ChatMessageTextApi() method = unwrap(api.post) monkeypatch.setattr(AudioService, "transcript_tts", lambda **_kwargs: {"audio": "test"}) app_model = SimpleNamespace(id="app-1") with app.test_request_context( "/console/api/apps/app-1/text-to-audio", method="POST", json={"text": "hello", "language": "en-US"}, ): response = method(api, app_model=app_model) assert response == {"audio": "test"} def test_text_to_audio_voices_with_language_filter(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: api = TextModesApi() method = unwrap(api.get) monkeypatch.setattr( AudioService, "transcript_tts_voices", lambda **_kwargs: [{"name": "Voice 1", "value": "voice-1"}], ) app_model = SimpleNamespace(tenant_id="tenant-1") with app.test_request_context( "/console/api/apps/app-1/text-to-audio/voices?language=en-US", method="GET", ): response = method(api, app_model=app_model) assert isinstance(response, list)