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---
207 lines
7.6 KiB
Python
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 |