refactor(api): adopt with_session in audio controllers (#37403) (#43322)

Co-authored-by: !潤弘資訊熊冠詠 <RT025232@rtc.ruentex.corp>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-02 13:03:06 +00:00
1 parent bf62165e22
commit 30eadcc9e5
4 files changed
+76 -24

No files matched your search

+8 -5
View File
@@ -2,12 +2,14 @@ import logging
from flask import request
from flask_restx import Resource
from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError
import services
from controllers.common.controller_schemas import TextToAudioPayload
from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse
from controllers.common.schema import register_response_schema_models, register_schema_model
from controllers.common.session import with_session
from controllers.console.wraps import model_validate
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import (
@@ -26,7 +28,6 @@ from controllers.service_api.schema import binary_response, expect_with_user, mu
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from core.base.tts.audio_mime import SUPPORTED_TTS_AUDIO_MIME_TYPES
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import dump_response
from models.model import App, EndUser
@@ -98,7 +99,8 @@ class AudioApi(Resource):
service_api_ns.models[AudioTranscriptResponse.__name__],
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.FORM))
def post(self, app_model: App, end_user: EndUser):
@with_session(write=False)
def post(self, session: Session, app_model: App, end_user: EndUser):
"""Convert audio to text using speech-to-text.
Accepts an audio file upload and returns the transcribed text.
@@ -109,7 +111,7 @@ class AudioApi(Resource):
response = AudioService.transcript_asr(
app_model=app_model,
file=file,
session=db.session(),
session=session,
end_user=end_user.id,
)
@@ -182,8 +184,9 @@ class TextApi(Resource):
# TTS returns provider audio bytes, so the success response is intentionally schema-less.
@service_api_ns.response(200, "Text successfully converted to audio")
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
@with_session(write=False)
@model_validate(TextToAudioPayload)
def post(self, payload: TextToAudioPayload, app_model: App, end_user: EndUser):
def post(self, payload: TextToAudioPayload, session: Session, app_model: App, end_user: EndUser):
"""Convert text to audio using text-to-speech.
Converts the provided text to audio using the specified voice.
@@ -202,7 +205,7 @@ class TextApi(Resource):
)
return AudioService.transcript_tts(
app_model=app_model,
session=db.session(),
session=session,
text=text,
voice=voice,
end_user=end_user.external_user_id,
+8 -5
View File
@@ -2,10 +2,12 @@ import logging
from flask import request
from pydantic import field_validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError
import services
from controllers.common.controller_schemas import TextToAudioPayload as TextToAudioPayloadBase
from controllers.common.session import with_session
from controllers.console.wraps import model_validate
from controllers.web import web_ns
from controllers.web.error import (
@@ -22,7 +24,6 @@ from controllers.web.error import (
)
from controllers.web.wraps import WebApiResource
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import dump_response, uuid_value
@@ -75,7 +76,8 @@ class AudioApi(WebApiResource):
}
)
@web_ns.response(200, "Success", web_ns.models[AudioToTextResponse.__name__])
def post(self, app_model: App, end_user: EndUser):
@with_session(write=False)
def post(self, session: Session, app_model: App, end_user: EndUser):
"""Convert audio to text"""
file = request.files.get("file")
@@ -83,7 +85,7 @@ class AudioApi(WebApiResource):
response = AudioService.transcript_asr(
app_model=app_model,
file=file,
session=db.session(),
session=session,
end_user=end_user.external_user_id,
)
@@ -132,8 +134,9 @@ class TextApi(WebApiResource):
)
# response-contract:ignore provider audio bytes; TODO: model binary audio response if shape is standardized.
@web_ns.response(200, "Success")
@with_session(write=False)
@model_validate(TextToAudioPayload)
def post(self, payload: TextToAudioPayload, app_model: App, end_user: EndUser):
def post(self, payload: TextToAudioPayload, session: Session, app_model: App, end_user: EndUser):
"""Convert text to audio"""
try:
message_id = payload.message_id
@@ -149,7 +152,7 @@ class TextApi(WebApiResource):
)
return AudioService.transcript_tts(
app_model=app_model,
session=db.session(),
session=session,
text=text,
voice=voice,
end_user=end_user.external_user_id,
@@ -210,17 +210,24 @@ class TestAudioServiceMockedBehavior:
class TestAudioApi:
def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: {"text": "ok"})
def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
calls: dict[str, object] = {}
def fake_transcript_asr(**kwargs: object) -> dict[str, str]:
calls.update(kwargs)
return {"text": "ok"}
monkeypatch.setattr(AudioService, "transcript_asr", fake_transcript_asr)
api = AudioApi()
handler = unwrap(api.post)
app_model = _app()
end_user = _end_user()
with app.test_request_context("/audio-to-text", method="POST", data={"file": _file_data()}):
response = handler(api, app_model=app_model, end_user=end_user)
response = handler(api, sqlite_session, app_model=app_model, end_user=end_user)
assert response == {"text": "ok"}
assert calls["session"] is sqlite_session
@pytest.mark.parametrize(
("exc", "expected"),
@@ -237,7 +244,14 @@ class TestAudioApi:
(InvokeError("invoke"), CompletionRequestError),
],
)
def test_error_mapping(self, app: Flask, monkeypatch: pytest.MonkeyPatch, exc, expected) -> None:
def test_error_mapping(
self,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
exc: Exception,
expected: type[Exception],
) -> None:
monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: (_ for _ in ()).throw(exc))
api = AudioApi()
handler = unwrap(api.post)
@@ -246,9 +260,9 @@ class TestAudioApi:
with app.test_request_context("/audio-to-text", method="POST", data={"file": _file_data()}):
with pytest.raises(expected):
handler(api, app_model=app_model, end_user=end_user)
handler(api, sqlite_session, app_model=app_model, end_user=end_user)
def test_unhandled_error(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_unhandled_error(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
monkeypatch.setattr(
AudioService, "transcript_asr", lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("boom"))
)
@@ -259,11 +273,11 @@ class TestAudioApi:
with app.test_request_context("/audio-to-text", method="POST", data={"file": _file_data()}):
with pytest.raises(InternalServerError):
handler(api, app_model=app_model, end_user=end_user)
handler(api, sqlite_session, app_model=app_model, end_user=end_user)
class TestTextApi:
def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
monkeypatch.setattr(AudioService, "transcript_tts", lambda **_kwargs: {"audio": "ok"})
api = TextApi()
@@ -277,11 +291,13 @@ class TestTextApi:
json={"text": "hello", "voice": "v"},
):
payload = TextToAudioPayload.model_validate(request.get_json() or {})
response = handler(api, payload, app_model=app_model, end_user=end_user)
response = handler(api, payload, sqlite_session, app_model=app_model, end_user=end_user)
assert response == {"audio": "ok"}
def test_success_with_message_ref(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_success_with_message_ref(
self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
calls = {}
def fake_transcript_tts(**kwargs):
@@ -301,12 +317,13 @@ class TestTextApi:
json={"text": "hello", "message_id": "message-1"},
):
payload = TextToAudioPayload.model_validate(request.get_json() or {})
response = handler(api, payload, app_model=app_model, end_user=end_user)
response = handler(api, payload, sqlite_session, app_model=app_model, end_user=end_user)
assert response == {"audio": "ok"}
assert calls["message_ref"] == MessageRef(AppRef("tenant-1", "a1"), "message-1", end_user_id="end-user-1")
assert calls["session"] is sqlite_session
def test_error_mapping(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_error_mapping(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
monkeypatch.setattr(
AudioService, "transcript_tts", lambda **_kwargs: (_ for _ in ()).throw(QuotaExceededError())
)
@@ -319,4 +336,4 @@ class TestTextApi:
with app.test_request_context("/text-to-audio", method="POST", json={"text": "hello"}):
payload = TextToAudioPayload.model_validate(request.get_json() or {})
with pytest.raises(ProviderQuotaExceededError):
handler(api, payload, app_model=app_model, end_user=end_user)
handler(api, payload, sqlite_session, app_model=app_model, end_user=end_user)
@@ -2,13 +2,15 @@
from __future__ import annotations
import inspect
from io import BytesIO
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from controllers.web.audio import AudioApi, TextApi
from controllers.web.audio import AudioApi, TextApi, TextToAudioPayload
from controllers.web.error import (
AudioTooLargeError,
CompletionRequestError,
@@ -54,6 +56,13 @@ def _end_user() -> EndUser:
return make_end_user(end_user_id="eu-1", app_id="app-1", external_user_id="ext-1", name="Web User")
# The @with_session decorator opens a request-scoped session and injects it as
# the first argument after self; these undecorated handlers let tests pass a
# session explicitly and assert it is forwarded to AudioService.
_audio_post = inspect.unwrap(AudioApi.post)
_text_post = inspect.unwrap(TextApi.post)
# ---------------------------------------------------------------------------
# AudioApi (audio-to-text)
# ---------------------------------------------------------------------------
@@ -66,6 +75,16 @@ class TestAudioApi:
result = AudioApi().post(_app_model(), _end_user())
assert result == {"text": "hello"}
assert isinstance(mock_asr.call_args.kwargs["session"], Session)
@patch("controllers.web.audio.AudioService.transcript_asr", return_value={"text": "hello"})
def test_forwards_injected_session(self, mock_asr: MagicMock, app: Flask, sqlite_session: Session) -> None:
data = {"file": (BytesIO(b"fake-audio"), "test.mp3")}
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
result = _audio_post(AudioApi(), sqlite_session, _app_model(), _end_user())
assert result == {"text": "hello"}
assert mock_asr.call_args.kwargs["session"] is sqlite_session
@patch("controllers.web.audio.AudioService.transcript_asr", side_effect=NoAudioUploadedServiceError())
def test_no_audio_uploaded(self, mock_asr: MagicMock, app: Flask) -> None:
@@ -144,6 +163,16 @@ class TestTextApi:
assert result == "audio-bytes"
mock_tts.assert_called_once()
assert isinstance(mock_tts.call_args.kwargs["session"], Session)
@patch("controllers.web.audio.AudioService.transcript_tts", return_value="audio-bytes")
def test_forwards_injected_session(self, mock_tts: MagicMock, app: Flask, sqlite_session: Session) -> None:
payload = TextToAudioPayload.model_validate({"text": "hello", "voice": "alloy"})
with app.test_request_context("/text-to-audio", method="POST"):
result = _text_post(TextApi(), payload, sqlite_session, _app_model(), _end_user())
assert result == "audio-bytes"
assert mock_tts.call_args.kwargs["session"] is sqlite_session
@patch("controllers.web.audio.AudioService.transcript_tts", return_value="audio-bytes")
def test_happy_path_with_message_ref(self, mock_tts: MagicMock, app: Flask) -> None: