Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
143
apps/api/app/test_session_turn_persistence.py
Normal file
143
apps/api/app/test_session_turn_persistence.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue