ec397f2c65
v0.5 (Live Assist — on-the-job voice companion) milestone complete. 4 phases: P0 (pre-execution, v0.1.10) → P1 (assist core + guardrail, v0.1.11) → P2 (integration + tech-debt + NFR, v0.1.12) → P3 (final review + ship, v0.1.13 = milestone release). 16/16 REQs covered (3 ASSIST + 4 NFR + 9 IDEATE). 4 v0.6 backlog. 469 tests passed, 0 failed. 1 P0 fixed (guardrail processor safety). 8 P1+ flagged for v0.6. 8 v0.4 P1+ tech-debt addressed. G-049 + G-067 grill MUSTs resolved. ESCALATION-01 (PIPEDA) OPEN for human legal review before assist surface go-live. ---ci--- project: praxis phase: 3 milestone: v0.5 status: complete requirements: covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-01, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-04, REQ-IDEATE-05, REQ-IDEATE-06, REQ-IDEATE-07, REQ-IDEATE-08, REQ-IDEATE-09] partial: [] ---/ci---
175 lines
6.0 KiB
Python
175 lines
6.0 KiB
Python
"""Chaos test for the WebRTC reconnect logic (REQ-IDEATE-08, TASK-06-03).
|
|
|
|
Verifies the reconnect state machine:
|
|
- Open a warm connection → 'connected'
|
|
- Simulate a disconnect → 'reconnecting'
|
|
- New offer within 30s → 'connected' (pipeline rebuilt)
|
|
- Disconnect + no new offer within 30s → 'disconnected'
|
|
- The shift is NOT auto-ended on disconnect (the session row is still active)
|
|
- The 8h auto-end still fires on a disconnected shift (D-069)
|
|
|
|
The test uses a shortened reconnect wait (1s) to keep CI fast.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from server.assist.webrtc import (
|
|
WarmWebRTCManager,
|
|
WarmConnection,
|
|
_RECONNECT_WAIT_S,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def manager():
|
|
return WarmWebRTCManager()
|
|
|
|
|
|
def test_reconnect_state_machine_disconnected_after_timeout(manager: WarmWebRTCManager):
|
|
"""Disconnect + no new offer within the wait → 'disconnected'."""
|
|
# Seed a fake warm connection in 'connected' state.
|
|
warm = WarmConnection(
|
|
connection=MagicMock(),
|
|
task=MagicMock(),
|
|
runner=MagicMock(),
|
|
shift_id="shift-1",
|
|
reconnect_state="connected",
|
|
)
|
|
manager._connections["shift-1"] = warm
|
|
|
|
async def _run():
|
|
# Shorten the reconnect wait so the test is fast.
|
|
with patch("server.assist.webrtc._RECONNECT_WAIT_S", 0.1):
|
|
await manager._on_disconnect("shift-1")
|
|
|
|
asyncio.run(_run())
|
|
assert manager.get_reconnect_state("shift-1") == "disconnected"
|
|
|
|
|
|
def test_reconnect_state_machine_reconnect_within_window(manager: WarmWebRTCManager):
|
|
"""New offer within the wait → 'connected' (pipeline rebuilt)."""
|
|
warm = WarmConnection(
|
|
connection=MagicMock(),
|
|
task=MagicMock(),
|
|
runner=MagicMock(),
|
|
shift_id="shift-2",
|
|
reconnect_state="connected",
|
|
)
|
|
manager._connections["shift-2"] = warm
|
|
|
|
async def _run():
|
|
# Start the disconnect handler (it will wait 0.1s).
|
|
with patch("server.assist.webrtc._RECONNECT_WAIT_S", 0.1):
|
|
task = asyncio.create_task(manager._on_disconnect("shift-2"))
|
|
await asyncio.sleep(0.02) # let it enter 'reconnecting'
|
|
assert manager.get_reconnect_state("shift-2") == "reconnecting"
|
|
# Simulate a reconnect offer arriving before the timeout.
|
|
warm.reconnect_state = "connected"
|
|
await task
|
|
|
|
asyncio.run(_run())
|
|
# The state was set back to 'connected' by the reconnect.
|
|
assert manager.get_reconnect_state("shift-2") == "connected"
|
|
|
|
|
|
def test_shift_not_auto_ended_on_disconnect(manager: WarmWebRTCManager):
|
|
"""The shift is NOT auto-ended on disconnect (the session row stays active).
|
|
|
|
The WarmWebRTCManager doesn't touch the sessions table — only the
|
|
ShiftLifecycleManager (8h auto-end) ends shifts. This test verifies the
|
|
manager doesn't end the shift on disconnect.
|
|
"""
|
|
warm = WarmConnection(
|
|
connection=MagicMock(),
|
|
task=MagicMock(),
|
|
runner=MagicMock(),
|
|
shift_id="shift-3",
|
|
reconnect_state="connected",
|
|
)
|
|
manager._connections["shift-3"] = warm
|
|
|
|
async def _run():
|
|
with patch("server.assist.webrtc._RECONNECT_WAIT_S", 0.1):
|
|
await manager._on_disconnect("shift-3")
|
|
|
|
asyncio.run(_run())
|
|
# The connection is still in the map (not removed) — the shift is still active.
|
|
assert manager.get("shift-3") is not None
|
|
assert manager.get_reconnect_state("shift-3") == "disconnected"
|
|
|
|
|
|
def test_close_removes_connection(manager: WarmWebRTCManager):
|
|
"""close() removes the connection from the active map."""
|
|
warm = WarmConnection(
|
|
connection=MagicMock(),
|
|
task=MagicMock(),
|
|
runner=MagicMock(),
|
|
shift_id="shift-4",
|
|
reconnect_state="connected",
|
|
heartbeat_task=None,
|
|
)
|
|
# Mock the connection close so it doesn't fail.
|
|
warm.connection.close = AsyncMock()
|
|
manager._connections["shift-4"] = warm
|
|
|
|
async def _run():
|
|
await manager.close("shift-4")
|
|
|
|
asyncio.run(_run())
|
|
assert manager.get("shift-4") is None
|
|
|
|
|
|
def test_get_reconnect_state_unknown_shift(manager: WarmWebRTCManager):
|
|
"""An unknown shift_id returns 'disconnected'."""
|
|
assert manager.get_reconnect_state("unknown-shift") == "disconnected"
|
|
assert manager.get("unknown-shift") is None
|
|
|
|
|
|
def test_8h_auto_end_fires_on_disconnected_shift():
|
|
"""D-069: the 8h auto-end still fires on a disconnected shift.
|
|
|
|
The ShiftLifecycleManager checks list_active_assist_sessions() (sessions
|
|
with ended_at IS NULL) — the WebRTC connection state is irrelevant. A
|
|
disconnected shift still has an active session row, so the 8h auto-end
|
|
fires. This test verifies the two systems are decoupled.
|
|
"""
|
|
import datetime as _dt
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from tempfile import NamedTemporaryFile
|
|
|
|
from db.migrate import apply_migrations
|
|
from db.store import PraxisStore, HARDCODED_LEARNER_ID
|
|
from server.assist.lifecycle import ShiftLifecycleManager
|
|
|
|
async def _run():
|
|
with NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
db_path = Path(f.name)
|
|
apply_migrations(db_path)
|
|
store = PraxisStore(db_path)
|
|
await store.init()
|
|
# Start an assist shift.
|
|
sid = await store.start_session_typed(
|
|
HARDCODED_LEARNER_ID, "assist:refund", "assist"
|
|
)
|
|
# Backdate started_at to 9h ago.
|
|
old = (_dt.datetime.now(_dt.timezone.utc) - _dt.timedelta(hours=9)).strftime(
|
|
"%Y-%m-%d %H:%M:%S"
|
|
)
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (old, sid))
|
|
conn.commit()
|
|
conn.close()
|
|
# The 8h auto-end should fire (the shift is active regardless of WebRTC state).
|
|
mgr = ShiftLifecycleManager(store, max_shift_hours=8)
|
|
ended = await mgr.check_auto_end()
|
|
assert sid in ended
|
|
row = await store.get_session(sid)
|
|
assert row.outcome == "auto_ended"
|
|
|
|
asyncio.run(_run()) |