"""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