fix(voice): preserve pending message intent across reconnects
This commit is contained in:
@@ -20,6 +20,7 @@ from services.target_audio_cache import TargetAudioCache
|
||||
|
||||
DEVICE = 'TalkingQ_local_voice'
|
||||
PROCESS_PARENT_MESSAGE = messages.process_parent_leave_message
|
||||
PROCESS_DEVICE_MESSAGE = messages.process_cached_audio
|
||||
|
||||
|
||||
def packet(sid, kind, audio=b''):
|
||||
@@ -79,6 +80,223 @@ async def set_target(state, kind):
|
||||
return await state.targets.get_voice_target(DEVICE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('kind', ['parent', 'device'])
|
||||
@pytest.mark.parametrize('old_disconnect', ['before_connect', 'after_connect', 'after_start', 'none'])
|
||||
async def test_fresh_command_survives_socket_replacement_and_completes_message(state, kind, old_disconnect):
|
||||
"""Reproduce 03111: card command, then a new recording socket nine seconds later."""
|
||||
old = state.ws
|
||||
await state.connections.add_connection(DEVICE, old)
|
||||
target = await set_target(state, kind)
|
||||
deadline = target.expires_at
|
||||
state.now[0] += 9
|
||||
if old_disconnect == 'before_connect':
|
||||
await state.connections.remove_connection(DEVICE, old)
|
||||
await cleanup.cleanup_device_sessions(DEVICE, old)
|
||||
new = SimpleNamespace(send_text=AsyncMock())
|
||||
await state.connections.add_connection(DEVICE, new)
|
||||
await state.targets.bind_connection(DEVICE, new)
|
||||
if old_disconnect == 'after_connect':
|
||||
await cleanup.cleanup_device_sessions(DEVICE, old)
|
||||
assert await state.targets.get_voice_target(DEVICE) is target
|
||||
assert target.websocket is None
|
||||
assert target.expires_at == deadline
|
||||
await send(state, 'fresh-message', 1, ws=new)
|
||||
if old_disconnect == 'after_start':
|
||||
await cleanup.cleanup_device_sessions(DEVICE, old)
|
||||
await send(state, 'old-late', 1, ws=old)
|
||||
await send(state, 'fresh-message', 4, b'first-', ws=new)
|
||||
await send(state, 'fresh-message', 4, b'tail', ws=new)
|
||||
processor = messages.process_parent_leave_message if kind == 'parent' else messages.process_cached_audio
|
||||
archived = []
|
||||
|
||||
async def process(*args, **kwargs):
|
||||
archived.append(await state.buffers.get_audio_data(target.audio_cache_key))
|
||||
|
||||
processor.side_effect = process
|
||||
await send(state, 'fresh-message', 2, ws=new)
|
||||
processor.assert_awaited_once()
|
||||
assert archived == [b'first-tail']
|
||||
assert state.started == []
|
||||
assert [c.args[0] for c in new.send_text.call_args_list] == ['START_ACK', 'END_ACK']
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unowned_message_reports_failure_without_starting_ai_or_consuming_new_intent(state):
|
||||
await send(state, 'unowned', 1)
|
||||
await send(state, 'unowned', 4, b'unowned-audio')
|
||||
fresh = await set_target(state, 'parent')
|
||||
await send(state, 'unowned', 0, b'wrong-mode')
|
||||
await send(state, 'unowned', 2)
|
||||
assert state.started == []
|
||||
assert state.ended == []
|
||||
assert await state.targets.get_voice_target(DEVICE) is fresh
|
||||
replies = [c.args[0] for c in state.ws.send_text.call_args_list]
|
||||
assert replies[:2] == ['START_ACK', 'END_ACK']
|
||||
assert replies[2].endswith('/assets/audio/save_audio_fail.mp3')
|
||||
messages.process_parent_leave_message.assert_not_awaited()
|
||||
messages.process_cached_audio.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_silent_ai_still_starts_and_finishes_once(state):
|
||||
await send(state, 'silent-ai', 1)
|
||||
await send(state, 'silent-ai', 2)
|
||||
assert state.started == ['silent-ai']
|
||||
assert state.ended == [('silent-ai', b'')]
|
||||
assert [c.args[0] for c in state.ws.send_text.call_args_list] == ['START_ACK', 'END_ACK']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('kind', ['parent', 'device'])
|
||||
@pytest.mark.parametrize('previous', ['message_cancelled', 'message_disconnected', 'message_expired', 'ai_disconnected'])
|
||||
async def test_incomplete_recording_then_fresh_message_saves_only_new_audio(state, kind, previous):
|
||||
await state.connections.add_connection(DEVICE, state.ws)
|
||||
old_target = None
|
||||
if previous.startswith('message'):
|
||||
old_target = await set_target(state, kind)
|
||||
await send(state, 'incomplete', 1)
|
||||
await send(state, 'incomplete', 0 if previous == 'ai_disconnected' else 4, b'old-audio')
|
||||
if previous == 'message_cancelled':
|
||||
await send(state, 'incomplete', 3)
|
||||
elif previous == 'message_expired':
|
||||
state.now[0] += 10
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
else:
|
||||
session = await state.sessions.get_session((DEVICE, 'incomplete'))
|
||||
session.set_interrupted = AsyncMock()
|
||||
await state.connections.remove_connection(DEVICE, state.ws)
|
||||
await cleanup.cleanup_device_sessions(DEVICE, state.ws)
|
||||
fresh = await set_target(state, kind)
|
||||
new = SimpleNamespace(send_text=AsyncMock())
|
||||
await state.connections.add_connection(DEVICE, new)
|
||||
await state.targets.bind_connection(DEVICE, new)
|
||||
await send(state, 'fresh', 1, ws=new)
|
||||
await send(state, 'fresh', 4, b'new-audio', ws=new)
|
||||
processor = messages.process_parent_leave_message if kind == 'parent' else messages.process_cached_audio
|
||||
archived = []
|
||||
|
||||
async def process(*args, **kwargs):
|
||||
archived.append(await state.buffers.get_audio_data(fresh.audio_cache_key))
|
||||
|
||||
processor.side_effect = process
|
||||
await send(state, 'fresh', 2, ws=new)
|
||||
assert archived == [b'new-audio']
|
||||
processor.assert_awaited_once()
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
assert await state.buffers.get_audio_data(fresh.audio_cache_key) is None
|
||||
if old_target:
|
||||
assert await state.buffers.get_audio_data(old_target.audio_cache_key) is None
|
||||
assert state.started == (['incomplete'] if previous == 'ai_disconnected' else [])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_does_not_extend_pending_target_lifetime(state):
|
||||
target = await set_target(state, 'parent')
|
||||
for _ in range(2):
|
||||
new = SimpleNamespace(send_text=AsyncMock())
|
||||
state.now[0] += 4
|
||||
await state.connections.add_connection(DEVICE, new)
|
||||
await state.targets.bind_connection(DEVICE, new)
|
||||
assert await state.targets.get_voice_target(DEVICE) is target
|
||||
state.now[0] += 2
|
||||
await state.targets.bind_connection(DEVICE, new)
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
await send(state, 'expired-message', 1, ws=new)
|
||||
await send(state, 'expired-message', 4, b'late', ws=new)
|
||||
await send(state, 'expired-message', 2, ws=new)
|
||||
assert state.started == []
|
||||
assert new.send_text.call_args.args[0].endswith('/assets/audio/save_audio_fail.mp3')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('kind', ['parent', 'device'])
|
||||
async def test_mqtt_command_then_replacement_ws_reaches_real_message_processor(state, monkeypatch, kind):
|
||||
"""Use actual MQTT handlers, /ws and message processors; stub external I/O."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from api.websocket import router
|
||||
from handlers import mqtt_handler as mqtt
|
||||
|
||||
monkeypatch.setattr(mqtt, 'device_target_cache', state.targets)
|
||||
monkeypatch.setattr(messages, 'process_parent_leave_message', PROCESS_PARENT_MESSAGE)
|
||||
monkeypatch.setattr(messages, 'process_cached_audio', PROCESS_DEVICE_MESSAGE)
|
||||
monkeypatch.setattr(mqtt.card_service, 'get_card_by_uuid',
|
||||
AsyncMock(return_value=SimpleNamespace(device_id='TalkingQ_receiver', status=1)))
|
||||
monkeypatch.setattr(mqtt.card_service, 'is_active_for_device', AsyncMock(return_value=False))
|
||||
upload = AsyncMock(return_value=SimpleNamespace(file_key='local-test/parent.wav'))
|
||||
create = AsyncMock(return_value=(SimpleNamespace(child_name='local-test'), None, []))
|
||||
notify = Mock()
|
||||
save = AsyncMock(return_value=('http://localhost/peer.wav', '/local-test/peer.wav'))
|
||||
archive = AsyncMock(return_value=SimpleNamespace(message_id=123, media_file_key='local-test/peer.wav'))
|
||||
pending = AsyncMock()
|
||||
monkeypatch.setattr(messages, 'upload_message_audio', upload)
|
||||
monkeypatch.setattr(messages.im_conversation_service, 'create_device_parent_leave_message', create)
|
||||
monkeypatch.setattr(messages.wechat_mp_notification_service, 'schedule_leave_message_notification', notify)
|
||||
monkeypatch.setattr(messages.device_audio_cache_service, 'save_device_audio', save)
|
||||
monkeypatch.setattr(messages.device_voice_archive_service, 'archive_peer_voice_message', archive)
|
||||
monkeypatch.setattr(messages.pending_voice_message_service, 'add_pending_message', pending)
|
||||
service = mqtt.TalkingQMQTTService({'broker': ''})
|
||||
service._publish = AsyncMock(return_value=True)
|
||||
|
||||
async def authenticate(ws):
|
||||
await state.connections.add_connection(DEVICE, ws)
|
||||
await ws.send_text('LOCAL_AUTHENTICATED')
|
||||
return True, DEVICE, 'LOCAL-SERIAL'
|
||||
|
||||
monkeypatch.setattr(endpoint, 'authenticate_websocket', authenticate)
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
@app.post('/local-command')
|
||||
async def command():
|
||||
if kind == 'parent':
|
||||
await service._handle_short_press_message(DEVICE, {'params': {'uuid': DEVICE}})
|
||||
else:
|
||||
await service._handle_nfc_listen_report(DEVICE, {'params': {'uuid': 'LOCAL-CARD'}})
|
||||
return {'ok': True}
|
||||
|
||||
audio = b'\x01\x00' * 800
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect('/ws') as old:
|
||||
assert old.receive_text() == 'LOCAL_AUTHENTICATED'
|
||||
assert client.post('/local-command').json() == {'ok': True}
|
||||
payload = service._publish.call_args.args[1]
|
||||
assert payload['msg_id'] == ('011' if kind == 'parent' else '005')
|
||||
if kind == 'device':
|
||||
assert payload['type'] == 1
|
||||
state.now[0] += 9
|
||||
with client.websocket_connect('/ws') as new:
|
||||
assert new.receive_text() == 'LOCAL_AUTHENTICATED'
|
||||
new.send_bytes(packet('replacement', 1))
|
||||
assert new.receive_text() == 'START_ACK'
|
||||
old.close()
|
||||
new.send_bytes(packet('replacement', 4, audio[:800]))
|
||||
new.send_bytes(packet('replacement', 4, audio[800:]))
|
||||
new.send_bytes(packet('replacement', 2))
|
||||
assert new.receive_text() == 'END_ACK'
|
||||
assert new.receive_text().endswith('/assets/audio/message_ok.mp3')
|
||||
assert state.started == []
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
assert not state.buffers.audio_data_cache
|
||||
assert await state.sessions.get_all_sessions() == []
|
||||
if kind == 'parent':
|
||||
assert create.call_args.kwargs['audio_content'][44:] == audio
|
||||
upload.assert_awaited_once()
|
||||
notify.assert_called_once()
|
||||
pending.assert_not_awaited()
|
||||
else:
|
||||
save.assert_awaited_once_with(audio, device_id='TalkingQ_receiver')
|
||||
archive.assert_awaited_once_with(sender_device_id=DEVICE, receiver_device_id='TalkingQ_receiver',
|
||||
local_audio_path='/local-test/peer.wav')
|
||||
assert pending.call_args.kwargs['im_message_id'] == 123
|
||||
assert pending.call_args.kwargs['sender_device_id'] == DEVICE
|
||||
assert pending.call_args.kwargs['target_device_id'] == 'TalkingQ_receiver'
|
||||
create.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('kind', ['parent', 'device'])
|
||||
async def test_ai_audio_recovers_from_pending_message_without_losing_first_chunk(state, kind):
|
||||
@@ -213,7 +431,7 @@ async def test_two_senders_to_same_receiver_use_separate_buffers(state):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('claimed', [False, True])
|
||||
async def test_disconnect_clears_pending_or_partial_target(state, claimed):
|
||||
async def test_disconnect_only_clears_target_claimed_by_that_recording(state, claimed):
|
||||
await state.connections.add_connection(DEVICE, state.ws)
|
||||
target = await set_target(state, 'parent')
|
||||
if claimed:
|
||||
@@ -222,7 +440,7 @@ async def test_disconnect_clears_pending_or_partial_target(state, claimed):
|
||||
session = await state.sessions.get_session((DEVICE, 'message'))
|
||||
session.set_interrupted = AsyncMock()
|
||||
await cleanup.cleanup_device_sessions(DEVICE, state.ws)
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
assert await state.targets.get_voice_target(DEVICE) is (None if claimed else target)
|
||||
assert await state.buffers.get_audio_data(target.audio_cache_key) is None
|
||||
assert await state.sessions.get_all_sessions() == []
|
||||
|
||||
@@ -256,12 +474,15 @@ async def test_old_socket_cleanup_and_late_packets_preserve_new_connection(state
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_before_connect_is_adopted_and_cleared_on_early_disconnect(state):
|
||||
async def test_pending_command_survives_early_disconnect_without_extending_deadline(state):
|
||||
target = await set_target(state, 'parent')
|
||||
assert target.websocket is None
|
||||
await state.connections.add_connection(DEVICE, state.ws)
|
||||
await state.targets.bind_connection(DEVICE, state.ws)
|
||||
await cleanup.cleanup_device_sessions(DEVICE, state.ws)
|
||||
assert await state.targets.get_voice_target(DEVICE) is target
|
||||
assert target.websocket is None
|
||||
state.now[0] += 10
|
||||
assert await state.targets.get_voice_target(DEVICE) is None
|
||||
|
||||
|
||||
@@ -343,3 +564,4 @@ async def test_new_recording_recovers_abandoned_message_without_reusing_audio(st
|
||||
assert state.ended == [('new-ai', b'question')]
|
||||
assert await state.buffers.get_audio_data(abandoned.audio_cache_key) is None
|
||||
messages.process_parent_leave_message.assert_not_awaited()
|
||||
assert not any('save_audio_fail.mp3' in c.args[0] for c in state.ws.send_text.call_args_list)
|
||||
|
||||
Reference in New Issue
Block a user