"""Deterministic WebSocket contract tests for the voice gateway route.""" from __future__ import annotations import asyncio import hashlib import json import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, patch from fastapi import HTTPException from .deps import Principal, Role from .persona_repository import PersonaVoiceMap from .routes import voice as voice_routes from .services import multimodal_alliance_store from .services.voice import ( MELOTTS_TTS_MODEL, StreamingTranscriptEvent, TranscriptResult, TranscriptWord, TTSChunk, VoicePreset, ) from .store import TurnRecord SESSION_ID = "voice-ws-contract-session" VOICE_PRESET = VoicePreset(preset="neutral", openai_voice="sage") def _principal( role: Role = Role.LEARNER, *, consent_at: float | None = 1.0, profile_completed_at: float | None = 1.0, ) -> Principal: return Principal( user_id="00000000-0000-0000-0000-000000000201", role=role, cohort_ids=[], email=f"voice-ws-{role.value}@hs.ac.kr", display_name="Voice WS Contract", consent_at=consent_at if role == Role.LEARNER else None, profile_completed_at=profile_completed_at if role == Role.LEARNER else None, ) def _control(payload: dict[str, object]) -> dict[str, object]: return {"text": json.dumps(payload)} def _binary(data: bytes) -> dict[str, object]: return {"bytes": data} class FakeWebSocket: def __init__(self, incoming: list[dict[str, object]] | None = None) -> None: self._incoming = list(incoming or []) self.accepted = False self.client_state = voice_routes.WebSocketState.CONNECTING self.cookies: dict[str, str] = {} self.query_params: dict[str, str] = {} self.sent_json: list[dict[str, object]] = [] self.sent_text: list[str] = [] self.sent_bytes: list[bytes] = [] self.close_codes: list[int] = [] async def accept(self) -> None: self.accepted = True self.client_state = voice_routes.WebSocketState.CONNECTED async def receive(self) -> dict[str, object]: if self._incoming: return self._incoming.pop(0) return {"type": "websocket.disconnect"} async def send_text(self, data: str) -> None: self.sent_text.append(data) self.sent_json.append(json.loads(data)) async def send_bytes(self, data: bytes) -> None: self.sent_bytes.append(data) async def close(self, code: int = 1000) -> None: self.close_codes.append(code) self.client_state = voice_routes.WebSocketState.DISCONNECTED class _FakeRouteStreamingSession: def __init__(self) -> None: self.on_event = None self.audio: list[bytes] = [] self.aborted = False async def send_audio(self, audio: bytes) -> None: self.audio.append(audio) assert self.on_event is not None await self.on_event( StreamingTranscriptEvent( text="안녕", final=False, speech_final=False, confidence=0.7, ) ) async def finish(self) -> TranscriptResult: assert self.on_event is not None await self.on_event( StreamingTranscriptEvent( text="안녕하세요 반가워요", final=True, speech_final=True, confidence=0.93, ) ) return TranscriptResult( text="안녕하세요 반가워요", language="ko", model="nova-3", duration=0.9, words=[ TranscriptWord("안녕하세요", 0.0, 0.45, 0.94), TranscriptWord("반가워요", 0.5, 0.9, 0.91), ], provider_events=[ { "type": "speech_final", "provider": "deepgram", "source": "streaming_stt", "start_ms": 0, "duration_ms": 900, "confidence": 0.93, "is_final": True, }, { "type": "stt_word", "provider": "deepgram", "source": "stt_word_timestamps", "start_ms": 0, "end_ms": 450, "confidence": 0.94, "is_final": True, }, ], ) async def abort(self) -> None: self.aborted = True class VoiceHealthContractTest(unittest.IsolatedAsyncioTestCase): async def test_melotts_health_reports_the_operated_model(self) -> None: service = SimpleNamespace( is_available=lambda: True, stt_available=lambda: True, tts_available=lambda: True, tts_provider=lambda: "melotts", stt_provider=lambda: "local_whisper", stt_model=lambda: "small", batch_stt_available=lambda: False, ) with patch.object(voice_routes, "voice_service", service): response = await voice_routes.voice_health() body = json.loads(bytes(response.body)) self.assertEqual(body["status"], "ok") self.assertEqual(body["tts_provider"], "melotts") self.assertEqual(body["tts_model"], MELOTTS_TTS_MODEL) class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase): def _bind_result(self) -> tuple[str, VoicePreset, None, dict[str, object]]: return ( SESSION_ID, VOICE_PRESET, None, {"degraded": False, "persona_catalog_source": "session"}, ) def test_provider_events_get_internal_taxonomy_without_raw_payload(self) -> None: events = voice_routes._safe_provider_events( [ { "type": "SIGH", "confidence": 0.81, "text": "raw transcript must drop", }, { "kind": "voice_activity", "start_ms": 10, "raw_text": "drop", }, { "label": "vendor custom marker", "score": 0.44, }, ] ) self.assertEqual( events, [ { "type": "SIGH", "confidence": 0.81, "event_type": "sigh", "category": "paralinguistic", }, { "kind": "voice_activity", "start_ms": 10, "event_type": "voice_activity", "category": "speech_activity", }, { "label": "vendor custom marker", "score": 0.44, "event_type": "vendor_custom_marker", "category": "unknown", }, ], ) def test_text_tts_uses_only_the_persisted_client_visible_reply(self) -> None: session = SimpleNamespace( turns=[ TurnRecord( turn_seq=2, speaker="counselor", stage="초기", text="raw learner text", text_masked="masked learner text", ), TurnRecord( turn_seq=2, speaker="client", stage="초기", text="raw client reply", text_masked=( "[NAME]은 [ORG]에서 상담받고 있고 [PHONE]는 [DATE]와 달라요." ), ), TurnRecord( turn_seq=3, speaker="client", stage="초기", text="hidden evaluator reply", text_masked="hidden evaluator reply", visible_to=("evaluator",), ), ] ) self.assertEqual( voice_routes._client_turn_text_for_speech(session, 2), "그 이름은 그 기관에서 상담받고 있고 연락처는 그 날짜와 달라요.", ) self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 3)) self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 99)) def test_text_tts_maps_logical_turn_to_db_transcript_sequence(self) -> None: session = SimpleNamespace( turns=[ TurnRecord( turn_seq=1, speaker="counselor", stage="초기", text="첫 질문", text_masked="첫 질문", ), TurnRecord( turn_seq=2, speaker="client", stage="초기", text="첫 응답", text_masked="첫 응답", ), TurnRecord( turn_seq=3, speaker="counselor", stage="초기", text="둘째 질문", text_masked="둘째 질문", ), TurnRecord( turn_seq=4, speaker="client", stage="초기", text="둘째 응답", text_masked="둘째 응답", ), ] ) self.assertEqual( voice_routes._client_turn_text_for_speech(session, 1), "첫 응답" ) self.assertEqual( voice_routes._client_turn_text_for_speech(session, 2), "둘째 응답" ) self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 3)) async def test_text_turn_speech_returns_openai_audio_for_owned_persisted_turn( self, ) -> None: session = SimpleNamespace( persona=SimpleNamespace(code="P1"), turns=[ TurnRecord( turn_seq=4, speaker="client", stage="초기", text="내담자 응답", text_masked="내담자 응답", ) ], ) synthesized: list[tuple[str, VoicePreset]] = [] async def synthesize(text: str, voice: VoicePreset): synthesized.append((text, voice)) yield TTSChunk(audio=b"mp3-a") yield TTSChunk(audio=b"mp3-b") with ( patch.object( voice_routes, "_practice_access_error", AsyncMock(return_value=None), ), patch.object( voice_routes.turn_runtime, "load_owned_session", AsyncMock(return_value=(session, None)), ), patch.object( voice_routes, "_resolve_session_voice", AsyncMock(return_value=VOICE_PRESET), ), patch.object( voice_routes.voice_service, "is_available", return_value=True, ), patch.object( voice_routes.voice_service, "tts_provider", return_value="openai", ), patch.object( voice_routes.voice_service, "tts_available", return_value=True, ), patch.object( voice_routes.voice_service, "tts_provider_for_voice", return_value="openai", ), patch.object( voice_routes.voice_service, "synthesize_stream", new=synthesize, ), ): response = await voice_routes.voice_speech( voice_routes.VoiceSpeechRequest(session_id=SESSION_ID, turn_seq=4), _principal(), ) self.assertEqual(response.status_code, 200) self.assertEqual(response.media_type, "audio/mpeg") self.assertEqual(response.body, b"mp3-amp3-b") self.assertEqual(response.headers["cache-control"], "no-store") self.assertEqual(response.headers["x-vignette-tts-provider"], "openai") self.assertEqual(synthesized, [("내담자 응답", VOICE_PRESET)]) async def test_audio_start_binary_chunks_audio_end_ping_close_contract( self, ) -> None: websocket = FakeWebSocket( [ _control({"type": "audio_start", "format": "webm"}), _binary(b"chunk-one"), _binary(b"chunk-two"), _control({"type": "ping"}), _control( { "type": "audio_end", "format": "webm", "silence_ms": "450", "barge_in": "true", "provider_events": [ { "type": "sigh", "confidence": 0.82, "text": "raw transcript must not persist", }, {"kind": "noise", "label": "x" * 120}, "invalid", ], } ), _control({"type": "close"}), ] ) handle_utterance = AsyncMock() with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal()), ), patch.object( voice_routes, "_bind_session", AsyncMock(return_value=self._bind_result()), ), patch.object( voice_routes.voice_service, "is_available", return_value=True, ), patch.object( voice_routes, "_handle_utterance", handle_utterance, ), patch.object( voice_routes, "_run_turn_and_speak", AsyncMock(), ), patch.object( voice_routes.time, "monotonic", side_effect=[10.0, 12.0], ), ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] self.assertTrue(websocket.accepted) self.assertEqual(websocket.close_codes, [1000]) self.assertEqual( [ (message.get("type"), message.get("state")) for message in websocket.sent_json ], [("ready", "idle"), ("state", "listening"), ("pong", None)], ) handle_utterance.assert_awaited_once() context = handle_utterance.await_args.args[1] utterance = handle_utterance.await_args.args[2] self.assertEqual(context.session_id, SESSION_ID) self.assertEqual(context.principal.user_id, _principal().user_id) self.assertEqual(context.voice_preset, VOICE_PRESET) self.assertEqual(utterance.audio, b"chunk-onechunk-two") self.assertEqual(utterance.fmt, "webm") self.assertIsNone(utterance.sample_rate) self.assertIsNone(utterance.channels) self.assertIsNone(utterance.sample_width) self.assertEqual(utterance.audio_started_at, 10.0) self.assertEqual(utterance.audio_ended_at, 12.0) self.assertEqual(utterance.prosody.silence_ms, 450) self.assertIs(utterance.prosody.barge_in, True) self.assertEqual( utterance.prosody.provider_events, [ { "type": "sigh", "confidence": 0.82, "event_type": "sigh", "category": "paralinguistic", }, { "kind": "noise", "label": "x" * 80, "event_type": "background_noise", "category": "audio_quality", }, ], ) def test_pcm_upload_is_wrapped_as_wav_before_stt(self) -> None: audio, fmt = voice_routes._normalize_audio_upload( b"\x00\x00\xff\x7f", fmt="pcm", sample_rate=16000, channels=1, sample_width=2, ) self.assertEqual(fmt, "wav") self.assertTrue(audio.startswith(b"RIFF")) self.assertEqual(audio[8:12], b"WAVE") self.assertEqual(audio[12:16], b"fmt ") self.assertEqual(int.from_bytes(audio[24:28], "little"), 16000) self.assertEqual(int.from_bytes(audio[22:24], "little"), 1) self.assertEqual(audio[36:40], b"data") self.assertEqual(int.from_bytes(audio[40:44], "little"), 4) self.assertEqual(audio[44:], b"\x00\x00\xff\x7f") async def test_deepgram_stream_relays_interim_final_and_appends_g7_timeline( self, ) -> None: websocket = FakeWebSocket( [ _control( { "type": "audio_start", "format": "pcm", "sample_rate": 16000, "channels": 1, "sample_width": 2, } ), _binary(b"\x00\x00\xff\x7f"), _control({"type": "audio_end", "format": "pcm"}), _control({"type": "close"}), ] ) session = _FakeRouteStreamingSession() consent_gate = AsyncMock(return_value={"consent_snapshot_id": "test"}) append_timeline = AsyncMock(return_value={"timeline_id": "timeline"}) run_turn = AsyncMock() transcribe = AsyncMock() async def open_stream(**kwargs): session.on_event = kwargs["on_event"] return session with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal()), ), patch.object( voice_routes, "_bind_session", AsyncMock(return_value=self._bind_result()), ), patch.object(voice_routes.voice_service, "is_available", return_value=True), patch.object( voice_routes.voice_service, "can_stream_audio", return_value=True ), patch.object( voice_routes.voice_service, "batch_stt_available", return_value=False, ), patch.object( voice_routes.voice_service, "stt_provider", return_value="deepgram" ), patch.object( voice_routes.voice_service, "stt_model", return_value="nova-3" ), patch.object( voice_routes.voice_service, "tts_provider_for_voice", return_value="openai", ), patch.object( voice_routes.voice_service, "tts_model_for_voice", return_value="gpt-4o-mini-tts", ), patch.object( voice_routes.voice_service, "open_streaming_transcription", new=open_stream, ), patch.object( voice_routes.multimodal_alliance_store, "assert_voice_processing_allowed", consent_gate, ), patch.object( voice_routes.multimodal_alliance_store, "append_runtime_timeline", append_timeline, ), patch.object(voice_routes.voice_service, "transcribe", transcribe), patch.object(voice_routes, "_run_turn_and_speak", run_turn), ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] self.assertEqual(session.audio, [b"\x00\x00\xff\x7f"]) self.assertFalse(session.aborted) self.assertEqual(consent_gate.await_count, 3) ready = next( payload for payload in websocket.sent_json if payload.get("type") == "ready" ) self.assertEqual(ready["stt_provider"], "deepgram") self.assertEqual(ready["stt_model"], "nova-3") self.assertEqual(ready["tts_provider"], "openai") self.assertEqual(ready["tts_model"], "gpt-4o-mini-tts") self.assertIs(ready["stt_batch_fallback_available"], False) transcribe.assert_not_awaited() append_timeline.assert_awaited_once() run_turn.assert_awaited_once() transcripts = [ payload for payload in websocket.sent_json if payload.get("type") == "transcript" ] self.assertEqual( transcripts, [ { "type": "transcript", "text": "안녕", "final": False, "speech_final": False, "speaker": "counselor", }, { "type": "transcript", "text": "안녕하세요 반가워요", "final": True, "speech_final": True, "speaker": "counselor", }, ], ) timeline = append_timeline.await_args.kwargs["timeline"] self.assertEqual(timeline.audio_duration_ms, 900) self.assertEqual(len(timeline.words), 2) self.assertEqual([word.word_index for word in timeline.words], [0, 1]) self.assertNotIn("안녕하세요", timeline.model_dump_json()) self.assertNotEqual( timeline.words[0].token_hash, hashlib.sha256("안녕하세요".encode("utf-8")).hexdigest(), ) self.assertEqual(timeline.events[0].event_type, "pace") turn = run_turn.await_args.args[2] self.assertEqual(turn.learner_text, "안녕하세요 반가워요") self.assertEqual(turn.prosody.provider_events[0]["provider"], "deepgram") self.assertEqual(turn.prosody.provider_events[0]["model"], "nova-3") async def test_streaming_consent_required_blocks_provider_before_audio(self) -> None: websocket = FakeWebSocket( [ _control( { "type": "audio_start", "format": "pcm", "sample_rate": 16000, "channels": 1, "sample_width": 2, } ), _binary(b"must-not-leave-process"), _control({"type": "audio_end", "format": "pcm"}), _control({"type": "close"}), ] ) open_stream = AsyncMock() handle_utterance = AsyncMock() consent_gate = AsyncMock( side_effect=multimodal_alliance_store.MultimodalConsentRequiredError( "voice consent required" ) ) with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal()), ), patch.object( voice_routes, "_bind_session", AsyncMock(return_value=self._bind_result()), ), patch.object(voice_routes.voice_service, "is_available", return_value=True), patch.object( voice_routes.voice_service, "can_stream_audio", return_value=True ), patch.object( voice_routes.voice_service, "open_streaming_transcription", open_stream, ), patch.object( voice_routes.multimodal_alliance_store, "assert_voice_processing_allowed", consent_gate, ), patch.object(voice_routes, "_handle_utterance", handle_utterance), ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] open_stream.assert_not_awaited() handle_utterance.assert_not_awaited() self.assertEqual(consent_gate.await_count, 1) self.assertIn( { "type": "error", "code": "multimodal_consent_required", "detail": "voice consent required", }, websocket.sent_json, ) async def test_streaming_consent_withdrawal_aborts_before_provider_finalize(self) -> None: websocket = FakeWebSocket( [ _control( { "type": "audio_start", "format": "pcm", "sample_rate": 16000, "channels": 1, "sample_width": 2, } ), _binary(b"\x00\x00\xff\x7f"), _control({"type": "audio_end", "format": "pcm"}), _control({"type": "close"}), ] ) session = _FakeRouteStreamingSession() run_turn = AsyncMock() async def open_stream(**kwargs): session.on_event = kwargs["on_event"] return session consent_gate = AsyncMock( side_effect=[ {"consent_snapshot_id": "granted"}, multimodal_alliance_store.MultimodalConsentWithdrawnError( "voice consent withdrawn" ), ] ) with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal()), ), patch.object( voice_routes, "_bind_session", AsyncMock(return_value=self._bind_result()), ), patch.object(voice_routes.voice_service, "is_available", return_value=True), patch.object( voice_routes.voice_service, "can_stream_audio", return_value=True ), patch.object( voice_routes.voice_service, "open_streaming_transcription", new=open_stream, ), patch.object( voice_routes.multimodal_alliance_store, "assert_voice_processing_allowed", consent_gate, ), patch.object(voice_routes, "_run_turn_and_speak", run_turn), ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] self.assertTrue(session.aborted) self.assertEqual(consent_gate.await_count, 2) run_turn.assert_not_awaited() self.assertIn( { "type": "error", "code": "multimodal_consent_withdrawn", "detail": "voice consent withdrawn", }, websocket.sent_json, ) async def test_streaming_consent_is_rechecked_before_more_audio_leaves_process( self, ) -> None: websocket = FakeWebSocket( [ _control( { "type": "audio_start", "format": "pcm", "sample_rate": 16000, "channels": 1, "sample_width": 2, } ), _binary(b"must-not-reach-provider-after-withdrawal"), _control({"type": "audio_end", "format": "pcm"}), _control({"type": "close"}), ] ) session = _FakeRouteStreamingSession() async def open_stream(**kwargs): session.on_event = kwargs["on_event"] return session consent_gate = AsyncMock( side_effect=[ {"consent_snapshot_id": "granted"}, multimodal_alliance_store.MultimodalConsentWithdrawnError( "voice consent withdrawn" ), ] ) with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal()), ), patch.object( voice_routes, "_bind_session", AsyncMock(return_value=self._bind_result()), ), patch.object(voice_routes.voice_service, "is_available", return_value=True), patch.object( voice_routes.voice_service, "can_stream_audio", return_value=True ), patch.object( voice_routes.voice_service, "open_streaming_transcription", new=open_stream, ), patch.object( voice_routes.multimodal_alliance_store, "assert_voice_processing_allowed", consent_gate, ), patch.object(voice_routes, "_STREAMING_CONSENT_RECHECK_SECONDS", 0.0), ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] self.assertTrue(session.aborted) self.assertEqual(session.audio, []) self.assertEqual(consent_gate.await_count, 2) self.assertIn( { "type": "error", "code": "multimodal_consent_withdrawn", "detail": "voice consent withdrawn", }, websocket.sent_json, ) async def test_streaming_connect_failure_uses_existing_batch_fallback(self) -> None: websocket = FakeWebSocket( [ _control( { "type": "audio_start", "format": "pcm", "sample_rate": 16000, "channels": 1, "sample_width": 2, } ), _binary(b"batch-fallback-audio"), _control({"type": "audio_end", "format": "pcm"}), _control({"type": "close"}), ] ) handle_utterance = AsyncMock() with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal()), ), patch.object( voice_routes, "_bind_session", AsyncMock(return_value=self._bind_result()), ), patch.object(voice_routes.voice_service, "is_available", return_value=True), patch.object( voice_routes.voice_service, "can_stream_audio", return_value=True ), patch.object( voice_routes.voice_service, "batch_stt_available", return_value=True, ), patch.object( voice_routes.voice_service, "open_streaming_transcription", AsyncMock(side_effect=RuntimeError("provider unavailable")), ), patch.object( voice_routes.multimodal_alliance_store, "assert_voice_processing_allowed", AsyncMock(return_value={"consent_snapshot_id": "test"}), ), patch.object(voice_routes, "_handle_utterance", handle_utterance), ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] handle_utterance.assert_awaited_once() utterance = handle_utterance.await_args.args[2] self.assertEqual(utterance.audio, b"batch-fallback-audio") self.assertIn( { "type": "degraded", "reason": "streaming STT unavailable; using batch fallback", }, websocket.sent_json, ) async def test_pcm_control_metadata_flows_to_handle_utterance(self) -> None: websocket = FakeWebSocket( [ _control( { "type": "audio_start", "format": "pcm", "sample_rate": 16000, "channels": 1, "sample_width": 2, } ), _binary(b"\x00\x00\xff\x7f"), _control({"type": "audio_end", "format": "pcm"}), _control({"type": "close"}), ] ) handle_utterance = AsyncMock() with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal()), ), patch.object( voice_routes, "_bind_session", AsyncMock(return_value=self._bind_result()), ), patch.object( voice_routes.voice_service, "is_available", return_value=True, ), patch.object( voice_routes, "_handle_utterance", handle_utterance, ), ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] handle_utterance.assert_awaited_once() utterance = handle_utterance.await_args.args[2] self.assertEqual(utterance.fmt, "pcm") self.assertEqual(utterance.sample_rate, 16000) self.assertEqual(utterance.channels, 1) self.assertEqual(utterance.sample_width, 2) async def test_text_turn_strips_text_runs_turn_and_ping_close_still_work( self, ) -> None: websocket = FakeWebSocket( [ _control({"type": "text_turn", "text": " I need help practicing. "}), _control({"type": "ping"}), _control({"type": "close"}), ] ) run_turn = AsyncMock() handle_utterance = AsyncMock() with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal()), ), patch.object( voice_routes, "_bind_session", AsyncMock(return_value=self._bind_result()), ), patch.object( voice_routes.voice_service, "is_available", return_value=True, ), patch.object( voice_routes, "_handle_utterance", handle_utterance, ), patch.object( voice_routes, "_run_turn_and_speak", run_turn, ), ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] self.assertEqual(websocket.close_codes, [1000]) self.assertEqual( [ (message.get("type"), message.get("state")) for message in websocket.sent_json ], [("ready", "idle"), ("pong", None)], ) handle_utterance.assert_not_awaited() run_turn.assert_awaited_once() context = run_turn.await_args.args[1] turn = run_turn.await_args.args[2] self.assertEqual(context.session_id, SESSION_ID) self.assertEqual(context.principal.user_id, _principal().user_id) self.assertEqual(context.voice_preset, VOICE_PRESET) self.assertEqual(turn.learner_text, "I need help practicing.") async def test_turn_persistence_failure_uses_structured_voice_error(self) -> None: websocket = FakeWebSocket( [ _control({"type": "text_turn", "text": "I need help practicing."}), _control({"type": "close"}), ] ) run_turn = AsyncMock( side_effect=HTTPException( status_code=503, detail=( "voice session turn append persistence unavailable; " "runtime fallback is disabled in prod" ), ) ) with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal()), ), patch.object( voice_routes, "_bind_session", AsyncMock(return_value=self._bind_result()), ), patch.object( voice_routes.voice_service, "is_available", return_value=True, ), patch.object( voice_routes, "_run_turn_and_speak", run_turn, ), ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] self.assertEqual(websocket.close_codes, [1000]) self.assertEqual(websocket.sent_json[0].get("type"), "ready") self.assertEqual(websocket.sent_json[0].get("state"), "idle") self.assertEqual( websocket.sent_json[-2], { "type": "error", "code": "turn_persistence_unavailable", "detail": "voice turn persistence unavailable; retry the utterance", }, ) self.assertEqual(websocket.sent_json[-1], {"type": "state", "state": "idle"}) async def test_stt_result_waits_for_final_transcript_before_running_turn( self, ) -> None: websocket = FakeWebSocket( [ _control( { "type": "stt_result", "text": "I am still talking", "final": False, "silence_ms": 2500, } ), _control({"type": "close"}), ] ) run_turn = AsyncMock() handle_utterance = AsyncMock() with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal()), ), patch.object( voice_routes, "_bind_session", AsyncMock(return_value=self._bind_result()), ), patch.object( voice_routes.voice_service, "is_available", return_value=True, ), patch.object( voice_routes, "_handle_utterance", handle_utterance, ), patch.object( voice_routes, "_run_turn_and_speak", run_turn, ), ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] handle_utterance.assert_not_awaited() run_turn.assert_not_awaited() self.assertIn( { "type": "eot", "ready": False, "reason": "final_transcript_pending", "silence_ms": 2500, "threshold_ms": 1200, }, websocket.sent_json, ) self.assertEqual( websocket.sent_json[-1], {"type": "state", "state": "listening"} ) async def test_stt_result_runs_turn_only_after_eot_ready(self) -> None: websocket = FakeWebSocket( [ _control({"type": "audio_start", "format": "pcm"}), _control( { "type": "stt_result", "text": " I am done now. ", "final": True, "silence_ms": 1300, "barge_in": False, "provider_events": [ { "type": "speech_final", "confidence": 0.91, "text": "raw transcript must not persist", } ], } ), _control({"type": "close"}), ] ) run_turn = AsyncMock() handle_utterance = AsyncMock() with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal()), ), patch.object( voice_routes, "_bind_session", AsyncMock(return_value=self._bind_result()), ), patch.object( voice_routes.voice_service, "is_available", return_value=True, ), patch.object( voice_routes, "_handle_utterance", handle_utterance, ), patch.object( voice_routes, "_run_turn_and_speak", run_turn, ), patch.object( voice_routes.time, "monotonic", side_effect=[10.0, 12.0], ), ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] handle_utterance.assert_not_awaited() run_turn.assert_awaited_once() self.assertIn( { "type": "eot", "ready": True, "reason": "ready", "silence_ms": 1300, "threshold_ms": 1200, }, websocket.sent_json, ) self.assertIn( { "type": "transcript", "text": "I am done now.", "final": True, "speaker": "counselor", }, websocket.sent_json, ) self.assertIn({"type": "state", "state": "thinking"}, websocket.sent_json) turn = run_turn.await_args.args[2] self.assertEqual(turn.learner_text, "I am done now.") self.assertEqual(turn.prosody.duration_s, 2.0) self.assertEqual(turn.prosody.silence_ms, 1300) self.assertIs(turn.prosody.barge_in, False) self.assertEqual( turn.prosody.provider_events, [ { "type": "speech_final", "confidence": 0.91, "event_type": "speech_final", "category": "speech_activity", } ], ) async def test_oversize_binary_audio_reports_error_and_drops_utterance( self, ) -> None: websocket = FakeWebSocket( [ _control({"type": "audio_start", "format": "webm"}), _binary(b"12345"), _control({"type": "close"}), ] ) handle_utterance = AsyncMock() run_turn = AsyncMock() with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal()), ), patch.object( voice_routes, "_bind_session", AsyncMock(return_value=self._bind_result()), ), patch.object( voice_routes.voice_service, "is_available", return_value=True, ), patch.object( voice_routes, "_handle_utterance", handle_utterance, ), patch.object( voice_routes, "_run_turn_and_speak", run_turn, ), patch.object( voice_routes, "_MAX_AUDIO_BYTES", 4, ), ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] self.assertEqual(websocket.close_codes, [1000]) self.assertIn( { "type": "error", "detail": "audio too large; please send a shorter utterance", }, websocket.sent_json, ) self.assertEqual( websocket.sent_json[-1], {"type": "state", "state": "idle"} ) handle_utterance.assert_not_awaited() run_turn.assert_not_awaited() def test_route_audio_buffer_never_exceeds_hard_cap(self) -> None: buffer = bytearray(b"123") with patch.object(voice_routes, "_MAX_AUDIO_BYTES", 4): self.assertFalse( voice_routes._append_audio_chunk_with_cap(buffer, b"45") ) self.assertEqual(buffer, b"123") async def test_streaming_event_queue_applies_backpressure_at_capacity(self) -> None: queue: asyncio.Queue[StreamingTranscriptEvent] = asyncio.Queue(maxsize=1) event = StreamingTranscriptEvent( text="중간", final=False, speech_final=False ) await queue.put(event) blocked_put = asyncio.create_task(queue.put(event)) await asyncio.sleep(0) self.assertFalse(blocked_put.done()) self.assertIs(queue.get_nowait(), event) await asyncio.wait_for(blocked_put, timeout=0.1) self.assertEqual(queue.qsize(), 1) async def test_streaming_finish_drains_full_queue_without_deadlock(self) -> None: websocket = FakeWebSocket() await websocket.accept() queue: asyncio.Queue[StreamingTranscriptEvent] = asyncio.Queue(maxsize=1) class BurstFinishSession: async def finish(self) -> TranscriptResult: for index in range(3): await queue.put( StreamingTranscriptEvent( text=f"중간-{index}", final=index == 2, speech_final=index == 2, ) ) return TranscriptResult(text="중간-2", model="nova-3") result, last = await asyncio.wait_for( voice_routes._finish_streaming_transcription( websocket, BurstFinishSession(), # type: ignore[arg-type] queue, ), timeout=0.5, ) self.assertEqual(result.text, "중간-2") self.assertEqual(last, ("중간-2", True)) self.assertEqual( [item["text"] for item in websocket.sent_json], ["중간-0", "중간-1", "중간-2"], ) async def test_existing_socket_rechecks_g7_consent_before_each_stt(self) -> None: websocket = FakeWebSocket( [ _control( { "type": "audio_start", "format": "pcm", "sample_rate": 16000, "channels": 1, "sample_width": 2, } ), _binary(b"\x00\x00\xff\x7f"), _control({"type": "audio_end", "format": "pcm"}), _control( { "type": "audio_start", "format": "pcm", "sample_rate": 16000, "channels": 1, "sample_width": 2, } ), _binary(b"\x00\x00\xff\x7f"), _control({"type": "audio_end", "format": "pcm"}), _control({"type": "close"}), ] ) gate_calls = 0 async def consent_gate(*, principal: Principal, session_id: str) -> None: nonlocal gate_calls self.assertEqual(principal.user_id, _principal().user_id) self.assertEqual(session_id, SESSION_ID) gate_calls += 1 if gate_calls == 2: raise multimodal_alliance_store.MultimodalConsentWithdrawnError( "multimodal voice consent was withdrawn" ) transcribe = AsyncMock( return_value=SimpleNamespace( text="first synthetic utterance", duration=0.01, provider_events=[], ) ) run_turn = AsyncMock() with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal()), ), patch.object( voice_routes, "_bind_session", AsyncMock(return_value=self._bind_result()), ), patch.object( voice_routes.voice_service, "is_available", return_value=True, ), patch.object( voice_routes.multimodal_alliance_store, "assert_voice_processing_allowed", new=consent_gate, ), patch.object( voice_routes.voice_service, "transcribe", transcribe, ), patch.object( voice_routes, "_run_turn_and_speak", run_turn, ), ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] self.assertEqual(gate_calls, 2) transcribe.assert_awaited_once() run_turn.assert_awaited_once() self.assertEqual(websocket.close_codes, [1000]) self.assertIn( { "type": "error", "code": "multimodal_consent_withdrawn", "detail": "multimodal voice consent was withdrawn", }, websocket.sent_json, ) withdrawn_index = next( index for index, payload in enumerate(websocket.sent_json) if payload.get("code") == "multimodal_consent_withdrawn" ) self.assertNotIn( "transcript", [ payload.get("type") for payload in websocket.sent_json[withdrawn_index + 1 :] ], ) async def test_unauthenticated_client_closes_before_session_or_voice_checks( self, ) -> None: websocket = FakeWebSocket() bind_session = AsyncMock() with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=None), ), patch.object( voice_routes, "_bind_session", bind_session, ), patch.object( voice_routes.voice_service, "is_available", return_value=True, ) as is_available, ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] self.assertTrue(websocket.accepted) self.assertEqual(websocket.close_codes, [voice_routes.WS_CLOSE_UNAUTHORIZED]) self.assertEqual( websocket.sent_json, [{"type": "error", "detail": "not authenticated"}] ) bind_session.assert_not_awaited() is_available.assert_not_called() async def test_non_learner_client_closes_before_session_or_voice_checks( self, ) -> None: websocket = FakeWebSocket() bind_session = AsyncMock() with ( patch.object( voice_routes, "_principal_from_websocket", AsyncMock(return_value=_principal(Role.TEACHER)), ), patch.object( voice_routes, "_bind_session", bind_session, ), patch.object( voice_routes.voice_service, "is_available", return_value=True, ) as is_available, ): await voice_routes.voice_ws(websocket) # type: ignore[arg-type] self.assertTrue(websocket.accepted) self.assertEqual(websocket.close_codes, [voice_routes.WS_CLOSE_UNAUTHORIZED]) self.assertEqual( websocket.sent_json, [{"type": "error", "detail": "only learners can use voice"}], ) bind_session.assert_not_awaited() is_available.assert_not_called() async def test_bind_session_uses_db_voice_map_for_existing_session(self) -> None: websocket = FakeWebSocket() websocket.query_params = {"session_id": SESSION_ID} sess = SimpleNamespace(persona=SimpleNamespace(code="P2")) voice_map = PersonaVoiceMap( provider="openai", voice_id="voice-p2-custom", base_params={ "preset": "calm-adult-male", "openai_voice": "onyx", "rate": 1.08, "instructions": "Low, guarded delivery.", }, ) get_voice_map = AsyncMock(return_value=voice_map) with ( patch.object( voice_routes, "_load_voice_session", AsyncMock(return_value=(sess, None)), ), patch.object( voice_routes, "get_session_voice_map", get_voice_map, ), ): session_id, voice, err, meta = await voice_routes._bind_session( websocket, _principal() ) self.assertEqual(session_id, SESSION_ID) self.assertIsNone(err) self.assertEqual(meta["persona_catalog_source"], "session") self.assertEqual( voice, VoicePreset( preset="calm-adult-male", openai_voice="onyx", rate=1.08, instructions="Low, guarded delivery.", ), ) get_voice_map.assert_awaited_once_with(SESSION_ID) async def test_bind_session_rejects_existing_session_without_onboarding( self, ) -> None: websocket = FakeWebSocket() websocket.query_params = {"session_id": SESSION_ID} load_voice_session = AsyncMock() with ( patch.object( voice_routes, "user_onboarding_complete", AsyncMock(return_value=False), ), patch.object( voice_routes, "user_has_consent", AsyncMock(return_value=True), ), patch.object( voice_routes, "_load_voice_session", load_voice_session, ), ): session_id, voice, err, meta = await voice_routes._bind_session( websocket, _principal(profile_completed_at=None), ) self.assertIsNone(session_id) self.assertIsNone(voice) self.assertEqual(err, "onboarding_required") self.assertEqual(meta, {}) load_voice_session.assert_not_awaited() async def test_bind_session_rejects_existing_session_without_consent(self) -> None: websocket = FakeWebSocket() websocket.query_params = {"session_id": SESSION_ID} load_voice_session = AsyncMock() with ( patch.object( voice_routes, "user_has_consent", AsyncMock(return_value=False), ), patch.object( voice_routes, "_load_voice_session", load_voice_session, ), ): session_id, voice, err, meta = await voice_routes._bind_session( websocket, _principal(consent_at=None), ) self.assertIsNone(session_id) self.assertIsNone(voice) self.assertEqual(err, "consent_required") self.assertEqual(meta, {}) load_voice_session.assert_not_awaited() async def test_bind_session_explicit_preset_overrides_db_voice_map(self) -> None: websocket = FakeWebSocket() websocket.query_params = {"session_id": SESSION_ID, "preset": "soft-young-fem"} sess = SimpleNamespace(persona=SimpleNamespace(code="P2")) get_voice_map = AsyncMock() with ( patch.object( voice_routes, "_load_voice_session", AsyncMock(return_value=(sess, None)), ), patch.object( voice_routes, "get_session_voice_map", get_voice_map, ), ): session_id, voice, err, _ = await voice_routes._bind_session( websocket, _principal() ) self.assertEqual(session_id, SESSION_ID) self.assertIsNone(err) self.assertEqual(voice.preset, "soft-young-fem") self.assertEqual(voice.openai_voice, "coral") get_voice_map.assert_not_awaited() async def test_catalog_voice_map_is_used_for_dev_persona_binding_helper( self, ) -> None: voice_map = PersonaVoiceMap( provider="openai", voice_id="verse", base_params={"preset": "soft-young-fem", "rate": 0.9}, ) get_voice_map = AsyncMock(return_value=voice_map) with patch.object(voice_routes, "get_persona_voice_map", get_voice_map): voice = await voice_routes._resolve_catalog_voice( persona_id="00000000-0000-0000-0000-000000000301", version=7, persona_code="P1", explicit_preset=None, ) self.assertEqual(voice.preset, "soft-young-fem") self.assertEqual(voice.openai_voice, "verse") self.assertAlmostEqual(voice.rate, 0.9) get_voice_map.assert_awaited_once_with( persona_id="00000000-0000-0000-0000-000000000301", version=7, ) if __name__ == "__main__": unittest.main()