This repository has been archived on 2026-09-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
praxis/tests/test_assist_routes.py
T
Praxis CI 81d43666c7 feat(P01): complete assist core + guardrail phase — v0.1.11 tagged
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---
2026-08-04 21:19:20 +00:00

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