81d43666c7
Phase 1 (Assist Core + Guardrail) complete. 8 slices, 4 waves, 24 tasks. 12 REQs covered (3 ASSIST + 3 NFR + 6 IDEATE). 92 new tests (409 total). G-049 + G-067 MUSTs resolved. Verify: APPROVE_WITH_NOTES, 5 P1+ flagged. Live Assist voice loop: shift-bounded sessions, context-binding, 3-layer guardrail (prompt + regex filter + audit log), tap-to-talk client control, warm WebRTC, reconnect logic, incremental audit write, PII policy, consent disclosure, mode-conflict enforcement. ---ci--- project: praxis phase: 1 milestone: v0.5 status: complete requirements: covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-05, REQ-IDEATE-08, REQ-IDEATE-09] partial: [] ---/ci---
181 lines
6.0 KiB
Python
181 lines
6.0 KiB
Python
"""Unit tests for the assist session API + lifecycle (TASK-02-05).
|
|
|
|
Covers SLICE-02:
|
|
- POST /api/assist/shift/start → 200 + shift_id + context + consent_disclosure
|
|
- Mode-conflict: starting a shift during an active practice session → 409
|
|
- POST /api/assist/shift/end → 200 + turn_count + guardrail_block_count
|
|
- GET /api/assist/shift/active → active shift or {active: false}
|
|
- 8h auto-end (mock time)
|
|
- Consent disclosure present in the start response
|
|
- Routes return JSON (not index.html — matched before StaticFiles)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import datetime as _dt
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from db.migrate import apply_migrations
|
|
from db.store import PraxisStore, HARDCODED_LEARNER_ID
|
|
from server.assist.consent import get_consent_disclosure
|
|
from server.assist.lifecycle import ShiftLifecycleManager
|
|
from server.assist.routes import router as assist_router
|
|
|
|
|
|
@pytest.fixture
|
|
def app_with_store(tmp_path: Path):
|
|
"""Build a FastAPI app with the assist router + a temp SQLite store."""
|
|
db = tmp_path / "test_assist_routes.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 = {}
|
|
app.include_router(assist_router)
|
|
return app, store
|
|
|
|
|
|
def test_shift_start_returns_200(app_with_store):
|
|
app, store = app_with_store
|
|
client = TestClient(app)
|
|
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()
|
|
assert "shift_id" in data
|
|
assert data["context"]["scenario_tag"] == "damaged-product refund"
|
|
assert "consent_disclosure" in data
|
|
assert "mic is active" in data["consent_disclosure"]
|
|
|
|
|
|
def test_shift_start_409_on_active_practice(app_with_store):
|
|
app, store = app_with_store
|
|
# Seed an active practice session.
|
|
asyncio.run(
|
|
store.start_session_typed(HARDCODED_LEARNER_ID, "cs_refund_ca_v01", "practice")
|
|
)
|
|
client = TestClient(app)
|
|
res = client.post(
|
|
"/api/assist/shift/start",
|
|
json={"path_slug": "customer_service", "scenario_tag": "escalation"},
|
|
)
|
|
assert res.status_code == 409
|
|
assert "practice session is active" in res.json()["detail"]
|
|
|
|
|
|
def test_shift_end_returns_200(app_with_store):
|
|
app, store = app_with_store
|
|
client = TestClient(app)
|
|
# Start a shift.
|
|
start = client.post(
|
|
"/api/assist/shift/start",
|
|
json={"path_slug": "customer_service", "scenario_tag": "escalation"},
|
|
)
|
|
assert start.status_code == 200
|
|
shift_id = start.json()["shift_id"]
|
|
# End it.
|
|
end = client.post(
|
|
"/api/assist/shift/end",
|
|
json={"shift_id": shift_id, "outcome": "completed"},
|
|
)
|
|
assert end.status_code == 200
|
|
data = end.json()
|
|
assert data["ok"] is True
|
|
assert "turn_count" in data
|
|
assert "guardrail_block_count" in data
|
|
|
|
|
|
def test_shift_active_returns_active_shift(app_with_store):
|
|
app, store = app_with_store
|
|
client = TestClient(app)
|
|
# No active shift → {active: false}.
|
|
res = client.get("/api/assist/shift/active")
|
|
assert res.status_code == 200
|
|
assert res.json() == {"active": False}
|
|
# Start a shift.
|
|
start = client.post(
|
|
"/api/assist/shift/start",
|
|
json={"path_slug": "customer_service", "scenario_tag": "policy exception"},
|
|
)
|
|
shift_id = start.json()["shift_id"]
|
|
# Now active.
|
|
res = client.get("/api/assist/shift/active")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["active"] is True
|
|
assert data["shift_id"] == shift_id
|
|
|
|
|
|
def test_routes_return_json_not_index_html(app_with_store):
|
|
"""Routes return JSON (not index.html — matched before StaticFiles)."""
|
|
app, store = app_with_store
|
|
client = TestClient(app)
|
|
res = client.get("/api/assist/shift/active")
|
|
assert res.headers["content-type"].startswith("application/json")
|
|
assert res.json() == {"active": False}
|
|
|
|
|
|
def test_consent_disclosure_text():
|
|
"""get_consent_disclosure() returns the disclosure text (D-070)."""
|
|
text = get_consent_disclosure()
|
|
assert "mic is active" in text.lower() or "microphone" in text.lower()
|
|
assert "consent laws" in text.lower()
|
|
assert "end the shift" in text.lower()
|
|
|
|
|
|
def test_auto_end_after_8h(app_with_store, tmp_path: Path):
|
|
"""A shift started 9h ago is auto-ended on the next check_auto_end() run."""
|
|
app, store = app_with_store
|
|
# Start a shift, then backdate the started_at timestamp.
|
|
client = TestClient(app)
|
|
start = client.post(
|
|
"/api/assist/shift/start",
|
|
json={"path_slug": "customer_service", "scenario_tag": "escalation"},
|
|
)
|
|
shift_id = start.json()["shift_id"]
|
|
# Backdate started_at to 9 hours ago.
|
|
old_time = (_dt.datetime.now(_dt.timezone.utc) - _dt.timedelta(hours=9)).strftime(
|
|
"%Y-%m-%d %H:%M:%S"
|
|
)
|
|
import sqlite3
|
|
|
|
conn = sqlite3.connect(str(store.db_path))
|
|
conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (old_time, shift_id))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
mgr = ShiftLifecycleManager(store, max_shift_hours=8)
|
|
ended = asyncio.run(mgr.check_auto_end())
|
|
assert shift_id in ended
|
|
# The session row should now have outcome='auto_ended'.
|
|
row = asyncio.run(store.get_session(shift_id))
|
|
assert row is not None
|
|
assert row.outcome == "auto_ended"
|
|
assert row.ended_at is not None
|
|
|
|
|
|
def test_auto_end_does_not_touch_recent_shifts(app_with_store):
|
|
"""A shift started 1h ago is NOT auto-ended."""
|
|
app, store = app_with_store
|
|
client = TestClient(app)
|
|
start = client.post(
|
|
"/api/assist/shift/start",
|
|
json={"path_slug": "customer_service", "scenario_tag": "escalation"},
|
|
)
|
|
shift_id = start.json()["shift_id"]
|
|
mgr = ShiftLifecycleManager(store, max_shift_hours=8)
|
|
ended = asyncio.run(mgr.check_auto_end())
|
|
assert shift_id not in ended |