"""Regression tests for session turn persistence ordering.""" from __future__ import annotations import unittest from unittest.mock import AsyncMock, patch from .deps import Principal, Role from .engine_client import EngineError from .routes import sessions from .routes import voice as voice_routes from .services import orchestrator, persona as persona_service, state_machine from .services.voice import VoicePreset from .store import InProcSession, store def _principal() -> Principal: return Principal( user_id="00000000-0000-0000-0000-000000000101", role=Role.LEARNER, cohort_ids=[], email="turn-test@hs.ac.kr", display_name="Turn Test", ) def _session(principal: Principal) -> InProcSession: card = persona_service.P1 sess = InProcSession( session_id="turn-persistence-session", case_id="turn-persistence-case", learner_id=principal.user_id, persona_code=card.code, theory_mode="humanistic", persona=card, state=state_machine.SessionState( resistance=card.base_resistance(), ideation_stage=card.ideation_baseline(), ), ) store.put(sess) return sess async def _consume_event_source(response: object) -> bytes: body = bytearray() iterator = getattr(response, "body_iterator") async for chunk in iterator: if isinstance(chunk, str): body.extend(chunk.encode("utf-8")) elif isinstance(chunk, (bytes, bytearray)): body.extend(chunk) else: body.extend(str(chunk).encode("utf-8")) return bytes(body) class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self) -> None: store._sessions.clear() async def asyncTearDown(self) -> None: store._sessions.clear() async def test_generate_turn_engine_failure_does_not_append_learner_turn(self) -> None: principal = _principal() sess = _session(principal) with patch.object( sessions.orchestrator, "run_turn_generate", AsyncMock(side_effect=EngineError("engine unavailable: test")), ): with self.assertRaises(sessions.HTTPException) as caught: await sessions.submit_turn( sess.session_id, sessions.TurnRequest(text="실패한 발화"), principal, ) self.assertEqual(caught.exception.status_code, 503) self.assertEqual(sess.turns, []) async def test_stream_turn_engine_error_event_does_not_append_partial_turns(self) -> None: principal = _principal() sess = _session(principal) async def failing_stream(*args, **kwargs): yield orchestrator.StreamEvent("token", {"text": "부분 응답"}) yield orchestrator.StreamEvent("error", {"detail": "engine unavailable: stream"}) with patch.object(sessions.orchestrator, "run_turn_stream", failing_stream): response = await sessions.stream_turn( sess.session_id, sessions.TurnRequest(text="스트림 실패 발화"), principal, ) body = await _consume_event_source(response) self.assertIn(b"engine unavailable: stream", body) self.assertEqual(sess.turns, []) async def test_voice_turn_engine_failure_does_not_append_learner_turn(self) -> None: class FakeWebSocket: def __init__(self) -> None: self.messages: list[dict[str, object]] = [] self.client_state = voice_routes.WebSocketState.CONNECTED async def send_text(self, data: str) -> None: import json self.messages.append(json.loads(data)) principal = _principal() sess = _session(principal) websocket = FakeWebSocket() with patch.object( voice_routes.orchestrator, "run_turn_generate", AsyncMock(side_effect=EngineError("voice engine unavailable")), ): await voice_routes._run_turn_and_speak( websocket, # type: ignore[arg-type] session_id=sess.session_id, principal=principal, voice_preset=VoicePreset(preset="neutral", openai_voice="sage"), learner_text="음성 실패 발화", ) self.assertTrue( any( message.get("type") == "error" and "engine unavailable" in str(message.get("detail")) for message in websocket.messages ), websocket.messages, ) self.assertEqual(sess.turns, []) if __name__ == "__main__": unittest.main()