159 lines
4.9 KiB
Python
159 lines
4.9 KiB
Python
|
|
"""Content-Length header present on successful TTS responses (#3582).
|
||
|
|
|
||
|
|
The HTTP/1.0 server cannot signal end-of-body via connection close without
|
||
|
|
triggering a ~31 s client timeout. Buffering all chunks before writing lets
|
||
|
|
us include a Content-Length header so the browser can play the audio blob.
|
||
|
|
"""
|
||
|
|
import io
|
||
|
|
import json
|
||
|
|
import sys
|
||
|
|
import types
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
import api.routes as routes
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeHandler:
|
||
|
|
def __init__(self, body: bytes, command: str = "POST", headers=None, client="1.2.3.4"):
|
||
|
|
self.command = command
|
||
|
|
self.rfile = io.BytesIO(body)
|
||
|
|
self.wfile = io.BytesIO()
|
||
|
|
self.headers = headers or {}
|
||
|
|
self.headers.setdefault("Content-Length", str(len(body)))
|
||
|
|
self.client_address = (client, 12345)
|
||
|
|
self.status = None
|
||
|
|
self.sent_headers = {}
|
||
|
|
|
||
|
|
def send_response(self, status):
|
||
|
|
self.status = status
|
||
|
|
|
||
|
|
def send_header(self, key, value):
|
||
|
|
self.sent_headers[key] = value
|
||
|
|
|
||
|
|
def end_headers(self):
|
||
|
|
pass
|
||
|
|
|
||
|
|
def body(self):
|
||
|
|
return self.wfile.getvalue()
|
||
|
|
|
||
|
|
def payload(self):
|
||
|
|
try:
|
||
|
|
return json.loads(self.body().decode("utf-8"))
|
||
|
|
except Exception:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _post(body_dict, client="2.3.4.5", **kw):
|
||
|
|
body = json.dumps(body_dict).encode()
|
||
|
|
return _FakeHandler(body, client=client, **kw)
|
||
|
|
|
||
|
|
|
||
|
|
def _reset_limiter():
|
||
|
|
if hasattr(routes._handle_tts, "_tts_limiter"):
|
||
|
|
del routes._handle_tts._tts_limiter
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True)
|
||
|
|
def _setup(monkeypatch):
|
||
|
|
# Disable auth and reset limiter so guard-rail tests are deterministic.
|
||
|
|
import api.auth as _auth
|
||
|
|
monkeypatch.setattr(_auth, "is_auth_enabled", lambda: False)
|
||
|
|
monkeypatch.setattr(routes, "is_auth_enabled", lambda: False, raising=False)
|
||
|
|
_reset_limiter()
|
||
|
|
yield
|
||
|
|
_reset_limiter()
|
||
|
|
|
||
|
|
|
||
|
|
def _make_edge_tts_mock(audio_chunks):
|
||
|
|
"""Return a fake edge_tts module whose Communicate.stream_sync yields chunks."""
|
||
|
|
fake_module = types.ModuleType("edge_tts")
|
||
|
|
|
||
|
|
class FakeCommunicate:
|
||
|
|
def __init__(self, text, voice, **kwargs):
|
||
|
|
pass
|
||
|
|
|
||
|
|
def stream_sync(self):
|
||
|
|
for data in audio_chunks:
|
||
|
|
yield {"type": "audio", "data": data}
|
||
|
|
|
||
|
|
fake_module.Communicate = FakeCommunicate
|
||
|
|
return fake_module
|
||
|
|
|
||
|
|
|
||
|
|
def test_content_length_present_and_correct(monkeypatch):
|
||
|
|
"""Content-Length header matches the actual body written."""
|
||
|
|
chunk1 = b"\xff\xfb\x90" * 100
|
||
|
|
chunk2 = b"\xff\xfb\x90" * 50
|
||
|
|
expected_body = chunk1 + chunk2
|
||
|
|
|
||
|
|
monkeypatch.setitem(sys.modules, "edge_tts", _make_edge_tts_mock([chunk1, chunk2]))
|
||
|
|
|
||
|
|
h = _post({"text": "hello", "voice": "en-US-AriaNeural"}, client="3.0.0.1")
|
||
|
|
result = routes._handle_tts(h, None)
|
||
|
|
|
||
|
|
assert result is True
|
||
|
|
assert h.status == 200
|
||
|
|
assert h.sent_headers.get("Content-Length") == str(len(expected_body))
|
||
|
|
assert h.body() == expected_body
|
||
|
|
|
||
|
|
|
||
|
|
def test_content_type_is_audio_mpeg(monkeypatch):
|
||
|
|
"""Content-Type header must be audio/mpeg."""
|
||
|
|
monkeypatch.setitem(sys.modules, "edge_tts", _make_edge_tts_mock([b"\xff\xfb" * 10]))
|
||
|
|
|
||
|
|
h = _post({"text": "world", "voice": "en-US-GuyNeural"}, client="3.0.0.2")
|
||
|
|
routes._handle_tts(h, None)
|
||
|
|
|
||
|
|
assert h.sent_headers.get("Content-Type") == "audio/mpeg"
|
||
|
|
|
||
|
|
|
||
|
|
def test_empty_audio_returns_500(monkeypatch):
|
||
|
|
"""When TTS produces no audio chunks, return 500 before touching the response."""
|
||
|
|
monkeypatch.setitem(sys.modules, "edge_tts", _make_edge_tts_mock([]))
|
||
|
|
|
||
|
|
h = _post({"text": "silent", "voice": "en-US-AriaNeural"}, client="3.0.0.3")
|
||
|
|
routes._handle_tts(h, None)
|
||
|
|
|
||
|
|
assert h.status == 500
|
||
|
|
assert "no audio" in (h.payload() or {}).get("error", "")
|
||
|
|
|
||
|
|
|
||
|
|
def test_content_length_single_chunk(monkeypatch):
|
||
|
|
"""Single chunk path: Content-Length equals that chunk's length."""
|
||
|
|
data = b"A" * 1024
|
||
|
|
monkeypatch.setitem(sys.modules, "edge_tts", _make_edge_tts_mock([data]))
|
||
|
|
|
||
|
|
h = _post({"text": "one chunk", "voice": "zh-CN-XiaoxiaoNeural"}, client="3.0.0.4")
|
||
|
|
routes._handle_tts(h, None)
|
||
|
|
|
||
|
|
assert h.status == 200
|
||
|
|
assert h.sent_headers.get("Content-Length") == "1024"
|
||
|
|
assert h.body() == data
|
||
|
|
|
||
|
|
|
||
|
|
def test_non_audio_chunks_ignored(monkeypatch):
|
||
|
|
"""Metadata/WordBoundary chunks must not contribute to the audio buffer."""
|
||
|
|
audio_data = b"\xff\xfb" * 20
|
||
|
|
|
||
|
|
fake_module = types.ModuleType("edge_tts")
|
||
|
|
|
||
|
|
class FakeCommunicate:
|
||
|
|
def __init__(self, text, voice, **kwargs):
|
||
|
|
pass
|
||
|
|
|
||
|
|
def stream_sync(self):
|
||
|
|
yield {"type": "WordBoundary", "data": b"ignored"}
|
||
|
|
yield {"type": "audio", "data": audio_data}
|
||
|
|
yield {"type": "SessionEnd", "data": None}
|
||
|
|
|
||
|
|
fake_module.Communicate = FakeCommunicate
|
||
|
|
monkeypatch.setitem(sys.modules, "edge_tts", fake_module)
|
||
|
|
|
||
|
|
h = _post({"text": "mixed chunks", "voice": "en-US-AriaNeural"}, client="3.0.0.5")
|
||
|
|
routes._handle_tts(h, None)
|
||
|
|
|
||
|
|
assert h.status == 200
|
||
|
|
assert h.body() == audio_data
|
||
|
|
assert h.sent_headers.get("Content-Length") == str(len(audio_data))
|