Files
baiyue-logistics-chat/tests/test_speech.py
T

174 lines
10 KiB
Python

"""Short recordings are validated and sent only to the separately configured ASR."""
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import subprocess
import requests
from logistics_chat import create_app
from logistics_chat.speech_service import MAX_AUDIO_BYTES
class SpeechResponse:
def __init__(self, payload=None, status=200, raw=None):
self.status_code = status
self.raw = raw if raw is not None else json.dumps(payload or {'choices': [{'message': {'content': '查询订单状态'}}]}).encode()
self.closed = False
def iter_content(self, size):
yield self.raw
def close(self):
self.closed = True
class SpeechTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
self.app = create_app({'TESTING': True, 'DATABASE': self.root/'history.db',
'UPLOAD_DIR': self.root/'uploads', 'BACKUP_DIR': self.root/'backups', 'LOG_DIR': self.root/'logs',
'DEEPSEEK_API_KEY': 'separate-chat-key', 'ASR_PROVIDER':'dashscope', 'ASR_API_KEY': 'asr-test-key',
'ASR_URL': 'https://asr.example.test/chat/completions', 'ASR_MODEL': 'test-asr', 'RATE_LIMIT_PER_MIN':1000})
self.client = self.app.test_client()
def recording(self, content=b'\x1aE\xdf\xa3' + b'x' * 40, mime='audio/webm', duration='2.5', **extra):
response = self.client.post('/api/speech/transcribe', data={'audio': (io.BytesIO(content), 'private-filename.webm', mime),
'duration': duration, 'language':'zh', **extra})
self.addCleanup(response.close)
self.addCleanup(response.request.environ['wsgi.input'].close)
return response
def test_missing_asr_key_does_not_reuse_chat_key_or_accept_audio(self):
self.app.config['ASR_API_KEY'] = ''
response = self.client.get('/api/speech/config')
self.assertFalse(response.json['enabled'])
self.assertIn('no-store', response.headers['Cache-Control'])
self.assertNotIn('key', response.get_data(as_text=True).lower())
with patch('logistics_chat.speech_service.requests.post') as post:
self.assertEqual(self.recording().status_code, 503)
post.assert_not_called()
def test_valid_recording_uses_asr_only_and_never_persists_audio(self):
upstream = SpeechResponse()
with patch('logistics_chat.speech_service.requests.post', return_value=upstream) as post:
response = self.recording()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json, {'ok': True, 'text': '查询订单状态'})
url = post.call_args.args[0]
options = post.call_args.kwargs
self.assertEqual(url, self.app.config['ASR_URL'])
self.assertEqual(options['headers']['Authorization'], 'Bearer asr-test-key')
self.assertEqual(options['json']['model'], 'test-asr')
self.assertEqual(options['json']['asr_options']['language'], 'zh')
self.assertTrue(options['json']['messages'][0]['content'][0]['input_audio']['data'].startswith('data:audio/webm;base64,'))
self.assertFalse(options['allow_redirects'])
self.assertTrue(upstream.closed)
self.assertEqual(list(self.root.glob('uploads/**/*')), [])
self.assertEqual(self.client.get('/api/get_history').json, [])
def test_browser_audio_containers_are_supported_without_trusting_filename(self):
samples = [(b'\x00\x00\x00\x18ftypisom' + b'x'*20, 'audio/mp4'),
(b'OggS'+b'x'*32, 'audio/ogg'), (b'RIFF'+b'x'*4+b'WAVE'+b'x'*20, 'audio/wav')]
for content, mime in samples:
with self.subTest(mime=mime), patch('logistics_chat.speech_service.requests.post', return_value=SpeechResponse()):
self.assertEqual(self.recording(content, mime).status_code, 200)
def test_invalid_audio_size_duration_and_container_never_reach_provider(self):
with patch('logistics_chat.speech_service.requests.post') as post:
self.assertEqual(self.recording(b'html masquerading as audio'*2).status_code, 415)
self.assertEqual(self.recording(b'\x1aE\xdf\xa3'+b'x'*MAX_AUDIO_BYTES).status_code, 413)
for duration in ('NaN', 'inf', '-1', '0.1', '62', 'broken'):
with self.subTest(duration=duration):
self.assertEqual(self.recording(duration=duration).status_code, 400)
self.assertEqual(self.client.post('/api/speech/transcribe', data={}).status_code, 400)
post.assert_not_called()
def test_request_body_limit_returns_json_instead_of_an_html_error(self):
response = self.recording(b'x'*(MAX_AUDIO_BYTES+128*1024))
self.assertEqual(response.status_code, 413)
self.assertFalse(response.json['ok'])
def test_upstream_failures_timeout_and_malformed_results_are_recoverable(self):
for upstream in (SpeechResponse(status=401, raw=b'secret upstream message'),
SpeechResponse(raw=b'invalid'), SpeechResponse(payload={'choices': []}),
SpeechResponse(raw=b'x' * 65537)):
with self.subTest(raw=upstream.raw[:20]), patch('logistics_chat.speech_service.requests.post', return_value=upstream):
response = self.recording()
self.assertEqual(response.status_code, 502)
self.assertNotIn('secret', response.get_data(as_text=True))
self.assertTrue(upstream.closed)
with patch('logistics_chat.speech_service.requests.post', side_effect=requests.Timeout):
self.assertEqual(self.recording().status_code, 504)
def test_silence_and_excessively_long_transcripts_are_not_sent_to_chat(self):
for text in ('', ' ', '字' * 2001):
with self.subTest(length=len(text)), patch('logistics_chat.speech_service.requests.post',
return_value=SpeechResponse(payload={'choices': [{'message': {'content': text}}]})):
self.assertEqual(self.recording().status_code, 422)
def test_baidu_converts_browser_audio_and_caches_a_separate_access_token(self):
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='baidu-test-key', BAIDU_ASR_SECRET_KEY='baidu-test-secret')
responses=[SpeechResponse(payload={'access_token':'baidu-test-token','expires_in':3600}),
SpeechResponse(payload={'err_no':0,'result':['查询订单。']}),
SpeechResponse(payload={'err_no':0,'result':['查询订单。']})]
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
patch('logistics_chat.speech_service.to_pcm',return_value=b'\0'*32000), \
patch('logistics_chat.speech_service.requests.post',side_effect=responses) as post:
for _ in range(2):
response=self.recording()
self.assertEqual(response.status_code,200)
self.assertEqual(response.json['text'],'查询订单。')
self.assertEqual(post.call_count,3)
self.assertEqual(post.call_args_list[0].kwargs['data']['client_id'],'baidu-test-key')
for call in post.call_args_list[1:]:
self.assertEqual(call.args[0],'https://vop.baidu.com/server_api')
self.assertEqual(call.kwargs['json']['format'],'pcm')
self.assertEqual(call.kwargs['json']['rate'],16000)
self.assertEqual(call.kwargs['json']['channel'],1)
self.assertEqual(call.kwargs['json']['token'],'baidu-test-token')
self.assertTrue(all(response.closed for response in responses))
def test_baidu_reports_permission_failure_and_never_returns_upstream_secrets(self):
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='baidu-test-key', BAIDU_ASR_SECRET_KEY='baidu-test-secret')
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
patch('logistics_chat.speech_service.to_pcm',return_value=b'\0'*32000), \
patch('logistics_chat.speech_service.requests.post',side_effect=[
SpeechResponse(payload={'access_token':'token','expires_in':3600}),
SpeechResponse(payload={'err_no':3302,'err_msg':'private provider details'})]):
response=self.recording()
self.assertEqual(response.status_code,503)
self.assertNotIn('private',response.get_data(as_text=True))
def test_decoder_rejects_real_duration_beyond_limit_and_handles_failure_or_timeout(self):
self.app.config.update(ASR_PROVIDER='baidu', BAIDU_ASR_API_KEY='test-key', BAIDU_ASR_SECRET_KEY='test-secret')
cases=[(subprocess.CompletedProcess([],0,stdout=b'\0'*(32000*61),stderr=b''),400),
(subprocess.CompletedProcess([],1,stdout=b'',stderr=b'private decoder error'),422)]
with patch('logistics_chat.speech_service.ffmpeg_binary',return_value='ffmpeg'), \
patch('logistics_chat.speech_service.requests.post') as post:
for result,status in cases:
with patch('logistics_chat.speech_service.subprocess.run',return_value=result):
self.assertEqual(self.recording(duration='1').status_code,status)
with patch('logistics_chat.speech_service.subprocess.run',side_effect=subprocess.TimeoutExpired('ffmpeg',10)):
self.assertEqual(self.recording().status_code,504)
post.assert_not_called()
def test_large_recordings_stay_in_memory_and_the_upload_stream_is_closed(self):
from logistics_chat import speech_service
original=speech_service.transcribe_recording
streams=[]
def inspect(upload,*args):
self.assertIsInstance(upload.stream,io.BytesIO)
streams.append(upload.stream)
return original(upload,*args)
with patch('logistics_chat.speech_service.transcribe_recording',side_effect=inspect), \
patch('logistics_chat.speech_service.requests.post',return_value=SpeechResponse()):
self.assertEqual(self.recording(b'\x1aE\xdf\xa3'+b'x'*600000).status_code,200)
self.assertTrue(streams[0].closed)