Files
praxis/tests/test_p1_assist_integration.py
Praxis CI ec397f2c65 docs(milestone): complete v0.5-live-assist — v0.1.13 tagged, milestone release, merged to main
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---
2026-08-04 22:35:56 +00:00

207 lines
7.6 KiB
Python

"""P1 integration test — shift lifecycle e2e (TASK-08-02).
End-to-end P1 integration test using FastAPI TestClient + temp SQLite (no
Postgres required for the assist voice loop — the aggregation hook is no-op
without pg_store).
Verifies:
1. POST /api/assist/shift/start → 200 + shift_id + context + consent_disclosure
2. The shift session row has session_type='assist'
3. POST /api/assist/webrtc with a valid shift_id → 200 + WebRTC answer (mocked)
4. A tap-to-talk turn is logged to the turns table with guardrail_verdict_json
5. POST /api/assist/shift/end → 200 + turn_count + guardrail_block_count
6. The shift session row has ended_at + outcome='completed'
7. run_mastery_flow() was NOT called (D-063 — no mastery update for assist)
8. Mode-conflict: start practice → start assist → 409; end practice → start assist → 200
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
from db.migrate import apply_migrations
from db.store import PraxisStore, HARDCODED_LEARNER_ID
from server.assist.routes import router as assist_router
class AssistWebRTCOffer(BaseModel):
"""Client→server assist WebRTC offer (test fixture copy of __main__.py model)."""
shift_id: str
sdp: str
type: str = "offer"
def _add_assist_webrtc_endpoint(app: FastAPI, store: PraxisStore) -> None:
"""Add the /api/assist/webrtc endpoint to a test app (mirrors __main__.py)."""
@app.post("/api/assist/webrtc")
async def _assist_webrtc(offer: AssistWebRTCOffer):
from fastapi import HTTPException
from server.assist.mode_conflict import ModeConflictError, enforce_mutual_exclusivity
try:
await enforce_mutual_exclusivity(store, "learner-1", "assist")
except ModeConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc))
active_shifts: dict = getattr(app.state, "assist_shifts", {})
session = active_shifts.get(offer.shift_id)
if session is None:
raise HTTPException(status_code=404, detail=f"assist shift {offer.shift_id} not found")
answer = await app.state.assist_webrtc_manager.open(
offer.shift_id, {"sdp": offer.sdp, "type": offer.type},
context=session.context, session=session,
)
return {"sdp": answer["sdp"], "type": answer["type"]}
@pytest.fixture
def app_with_store(tmp_path: Path):
db = tmp_path / "test_p1_integration.db"
apply_migrations(db)
store = PraxisStore(db)
asyncio.run(store.init())
app = FastAPI()
app.state.praxis_store = store
app.state.pg_store = None
app.state.assist_shifts = {}
# Mock the WarmWebRTCManager so /api/assist/webrtc doesn't need live keys.
mock_manager = MagicMock()
mock_manager.open = AsyncMock(return_value={"sdp": "mock-sdp", "type": "answer"})
app.state.assist_webrtc_manager = mock_manager
app.include_router(assist_router)
_add_assist_webrtc_endpoint(app, store)
return app, store
def test_p1_shift_lifecycle_e2e(app_with_store):
"""Full shift lifecycle: start → turn → end (TASK-08-02)."""
app, store = app_with_store
client = TestClient(app)
# 1. Start a shift.
res = client.post(
"/api/assist/shift/start",
json={"path_slug": "customer_service", "scenario_tag": "damaged-product refund"},
)
assert res.status_code == 200
data = res.json()
shift_id = data["shift_id"]
assert data["context"]["scenario_tag"] == "damaged-product refund"
assert "consent_disclosure" in data
# 2. Verify the session row has session_type='assist'.
row = asyncio.run(store.get_session(shift_id))
assert row is not None
assert row.session_type == "assist"
# 3. POST /api/assist/webrtc (mocked — returns a mock answer).
webrtc_res = client.post(
"/api/assist/webrtc",
json={"shift_id": shift_id, "sdp": "mock-offer-sdp", "type": "offer"},
)
assert webrtc_res.status_code == 200
assert webrtc_res.json()["sdp"] == "mock-sdp"
# 4. Simulate a tap-to-talk turn (mock — the AssistSession is in app.state).
active_shifts = app.state.assist_shifts
session = active_shifts[shift_id]
asyncio.run(
session.log_assist_turn(
asr_text="The customer wants a refund",
tts_text="What do you think the customer needs?",
guardrail_verdict={"allowed": True, "category": "coaching"},
latency_ms=580.0,
)
)
turns = asyncio.run(store.get_turns(shift_id))
assert len(turns) == 1
assert turns[0].guardrail_verdict_json is not None
verdict = json.loads(turns[0].guardrail_verdict_json)
assert verdict["allowed"] is True
# 5. End the shift.
end_res = client.post(
"/api/assist/shift/end",
json={"shift_id": shift_id, "outcome": "completed"},
)
assert end_res.status_code == 200
end_data = end_res.json()
assert end_data["ok"] is True
assert end_data["turn_count"] == 1
assert end_data["guardrail_block_count"] == 0
# 6. Verify the session row has ended_at + outcome.
row = asyncio.run(store.get_session(shift_id))
assert row is not None
assert row.ended_at is not None
assert row.outcome == "completed"
# 7. D-063: run_mastery_flow() was NOT called (no mastery_result on the session).
assert not hasattr(session, "mastery_result") or session.mastery_result is None
def test_p1_mode_conflict_practice_then_assist(app_with_store):
"""Mode-conflict: start practice → start assist → 409; end practice → assist → 200."""
app, store = app_with_store
client = TestClient(app)
# Start a practice session (active).
asyncio.run(
store.start_session_typed(HARDCODED_LEARNER_ID, "cs_refund_ca_v01", "practice")
)
# Starting an assist shift → 409.
res = client.post(
"/api/assist/shift/start",
json={"path_slug": "customer_service", "scenario_tag": "escalation"},
)
assert res.status_code == 409
# End the practice session.
practice_sessions = asyncio.run(store.list_active_assist_sessions())
# list_active_assist_sessions only lists assist; end the practice row directly.
active_practice = asyncio.run(store.get_active_session(HARDCODED_LEARNER_ID, "practice"))
assert active_practice is not None
asyncio.run(store.end_session(active_practice["id"], branch_path=[], outcome="success"))
# Now starting an assist shift → 200.
res = client.post(
"/api/assist/shift/start",
json={"path_slug": "customer_service", "scenario_tag": "escalation"},
)
assert res.status_code == 200
def test_p1_assist_webrtc_404_for_unknown_shift(app_with_store):
"""POST /api/assist/webrtc with an unknown shift_id → 404."""
app, store = app_with_store
client = TestClient(app)
res = client.post(
"/api/assist/webrtc",
json={"shift_id": "nonexistent", "sdp": "mock", "type": "offer"},
)
assert res.status_code == 404
def test_p1_assist_webrtc_409_during_active_practice(app_with_store):
"""POST /api/assist/webrtc during an active practice session → 409."""
app, store = app_with_store
client = TestClient(app)
# Seed an active practice session.
asyncio.run(
store.start_session_typed(HARDCODED_LEARNER_ID, "cs_refund_ca_v01", "practice")
)
res = client.post(
"/api/assist/webrtc",
json={"shift_id": "any", "sdp": "mock", "type": "offer"},
)
assert res.status_code == 409