diff --git a/.ciagent/VERIFY-P2.md b/.ciagent/VERIFY-P2.md
new file mode 100644
index 0000000..bd41545
--- /dev/null
+++ b/.ciagent/VERIFY-P2.md
@@ -0,0 +1,88 @@
+# P2 Verification Matrix (TASK-10-05)
+
+Pre-verify checklist mapping every P2 REQ-ID to its covering tests. P2 covers:
+REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-MT-02 (pipeline completion).
+
+## REQ-ID → Test Mapping
+
+### REQ-DASH-01 — Cohort dashboard (3 views + auth gate)
+| Test file | Test | What it verifies |
+|-----------|------|------------------|
+| tests/test_operator_endpoints.py | test_cohort_200_with_cookie | GET /api/operator/cohort returns practice volume |
+| tests/test_operator_endpoints.py | test_mastery_200_with_cookie | GET /api/operator/mastery returns mastery progression |
+| tests/test_operator_endpoints.py | test_failure_patterns_200_with_cookie | GET /api/operator/failure-patterns returns failure data |
+| tests/test_operator_endpoints.py | test_credentials_200_with_cookie | GET /api/operator/credentials lists VCs |
+| tests/test_operator_endpoints.py | test_cohort_401_without_cookie (+ 4 others) | All endpoints auth-gated (401) |
+| client/src/operator/__tests__/Dashboard.test.tsx | Dashboard auth gate | React auth gate redirects on 401 from /me |
+| client/src/operator/__tests__/Dashboard.test.tsx | Login form | POST /api/operator/login → dashboard |
+| tests/test_p2_spa_fallback.py | test_operator_dashboard_spa_fallback | /operator/dashboard serves index.html (SPA) |
+| tests/test_p2_spa_fallback.py | test_operator_login_spa_fallback | /operator/login serves index.html (SPA) |
+
+### REQ-NFR-DASH-01 — k-anonymity ≥ 10 (write-time suppression + query + display + e2e)
+| Test file | Test | What it verifies |
+|-----------|------|------------------|
+| tests/test_cohort_aggregation.py | test_k_anon_threshold_at_10 | K_ANON_THRESHOLD == 10 |
+| tests/test_cohort_aggregation.py | test_9_learners_suppressed | 9 learners → cell_suppressed=TRUE, value=NULL |
+| tests/test_cohort_aggregation.py | test_10_learners_not_suppressed | 10 learners → non-suppressed, value non-null |
+| tests/test_cohort_aggregation.py | test_11_learners_not_suppressed | 11 learners → non-suppressed |
+| tests/test_cohort_aggregation.py | test_no_pii_in_upsert_calls | No raw learner_ref in aggregate cell args |
+| tests/test_cohort_aggregation.py | test_g038_differencing_attack_cannot_isolate_dropped_learner | G-038: 10 in window A, 9 in B → dropped learner not isolatable |
+| tests/test_operator_endpoints.py | test_suppressed_cells_value_null | API: suppressed cells have value=null |
+| tests/test_operator_endpoints.py | test_no_per_learner_data_in_cohort_response | API: no per-learner data (R-DASH-02) |
+| client/src/operator/__tests__/Dashboard.test.tsx | suppressedLabel | UI: suppressed cells render "— (<10 learners)" |
+| tests/test_p2_aggregation_integration.py | test_aggregation_to_endpoint_e2e | E2e: 12 learners non-suppressed, 5 suppressed |
+| tests/test_p2_aggregation_integration.py | test_g038_differencing_attack_api_layer | G-038 e2e at API layer |
+
+### REQ-NFR-DASH-02 — Freshness ≤ 24h (nightly job + on-session-end hook)
+| Test file | Test | What it verifies |
+|-----------|------|------------------|
+| tests/test_cohort_nightly.py | test_seconds_until_next_03_ct_future_today | Scheduler computes correct seconds until 03:00 CT |
+| tests/test_cohort_nightly.py | test_seconds_until_next_03_ct_past_today_wraps_tomorrow | Wraps to next day correctly |
+| tests/test_cohort_nightly.py | test_reconcile_recomputes_all_paths | Nightly recomputes all (path, window) cells |
+| tests/test_cohort_nightly.py | test_r_dash_04_nightly_failure_does_not_crash_scheduler | R-DASH-04: failure logs + retries |
+| tests/test_cohort_nightly.py | test_scheduler_start_stop_lifecycle | Scheduler starts + stops cleanly |
+| tests/test_operator_endpoints.py | test_last_updated_is_max | API: last_updated = max(updated_at) |
+| tests/test_p2_aggregation_integration.py | test_nightly_reconciliation_updates_last_updated | E2e: nightly reconcile refreshes last_updated |
+| tests/test_p2_aggregation_integration.py | test_aggregation_to_endpoint_e2e (assertion 8) | E2e: last_updated ≤ 24h |
+
+### REQ-MT-02 — Cohort aggregation pipeline (schema in P1, pipeline in P2)
+| Test file | Test | What it verifies |
+|-----------|------|------------------|
+| tests/test_cohort_aggregation.py | test_multiple_metrics_computed | Pipeline computes all metric types |
+| tests/test_cohort_aggregation.py | test_idempotent_same_session_twice | Idempotent upsert |
+| tests/test_cohort_aggregation.py | test_rolling_window_7_days | 7-day rolling window computation |
+| tests/test_cohort_aggregation.py | test_hook_no_postgres_is_noop | Graceful no-op without Postgres |
+| tests/test_cohort_aggregation.py | test_hook_failure_logs_does_not_raise | Hook failure does not propagate |
+| tests/test_cohort_nightly.py | test_reconcile_no_events_no_op | Nightly no-op when no events |
+| tests/test_p2_aggregation_integration.py | test_aggregation_to_endpoint_e2e | Full pipeline e2e |
+
+## G-038 (binding — differencing-attack test)
+| Test file | Test | What it verifies |
+|-----------|------|------------------|
+| tests/test_cohort_aggregation.py | test_g038_differencing_attack_cannot_isolate_dropped_learner | Unit: 10 in A, 9 in B → B suppressed, dropped learner not isolatable |
+| tests/test_p2_aggregation_integration.py | test_g038_differencing_attack_api_layer | E2e at API layer |
+
+## G-041 (binding — SPA fallback via custom StaticFiles subclass)
+| Test file | Test | What it verifies |
+|-----------|------|------------------|
+| tests/test_p2_spa_fallback.py | test_root_serves_voice_ui | Voice UI at / unchanged (R-DASH-05) |
+| tests/test_p2_spa_fallback.py | test_operator_dashboard_spa_fallback | /operator/dashboard → index.html |
+| tests/test_p2_spa_fallback.py | test_assets_served_by_staticfiles_not_spa_fallback | /assets/index.js served by StaticFiles (NOT catch-all) |
+| tests/test_p2_spa_fallback.py | test_api_operator_cohort_is_json_not_html | API routes return JSON (not index.html) |
+| tests/test_p2_spa_fallback.py | test_health_is_json | /health JSON |
+
+## R-DASH-05 (voice UI at / unchanged)
+| Test file | Test | What it verifies |
+|-----------|------|------------------|
+| tests/test_p2_spa_fallback.py | test_root_serves_voice_ui | / → index.html with
|
+| client/src/operator/__tests__/Dashboard.test.tsx | (no PII in dashboard DOM) | Voice UI path unchanged |
+
+## Summary
+
+- **4/4 P2 REQ-IDs covered** (REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-MT-02).
+- **G-038 (differencing-attack)**: covered at unit + API e2e layers.
+- **G-041 (SPA fallback)**: custom SpaStaticFiles subclass (not catch-all); 9 assertions.
+- **R-DASH-05 (voice UI unchanged)**: verified.
+- All Postgres-requiring tests skip gracefully when PRAXIS_PG_DSN unset.
+- Client tests run via vitest (17 tests).
+- No v0.3/P1 test regressions (272 baseline + new P2 tests).
\ No newline at end of file
diff --git a/server/__main__.py b/server/__main__.py
index 61b00ff..84eed08 100644
--- a/server/__main__.py
+++ b/server/__main__.py
@@ -31,7 +31,6 @@ except ImportError: # pragma: no cover
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
-from fastapi.staticfiles import StaticFiles
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
from slowapi.errors import RateLimitExceeded
from slowapi import _rate_limit_exceeded_handler
@@ -42,11 +41,19 @@ from db.store import PraxisStore
from server.auth.cookies import get_session_middleware_kwargs
from server.auth.rate_limit import limiter
from server.auth.routes import router as auth_router
+from server.cohort.nightly import NightlyScheduler
+from server.operator.cohort import router as cohort_router
+from server.operator.credentials import router as credentials_router
+from server.operator.failure_patterns import router as failure_router
+from server.operator.mastery import router as mastery_router
from server.pipeline import build_pipeline
from server.vc.issuer_keys import _load_root_key
from server.vc.migrate_keys import migrate_issuer_keys
from server.vc.verification import verify_credential
from starlette.middleware.sessions import SessionMiddleware
+from starlette.responses import FileResponse
+from starlette.staticfiles import StaticFiles
+from starlette.exceptions import HTTPException as StarletteHTTPException
_store = PraxisStore()
@@ -92,6 +99,8 @@ async def lifespan(app: FastAPI):
)
app.state.pg_pool = pool
app.state.pg_store = PgStore(pool)
+ nightly = NightlyScheduler()
+ app.state.nightly_scheduler = nightly
try:
applied = await apply_pg_migrations(pool)
if applied:
@@ -102,10 +111,14 @@ async def lifespan(app: FastAPI):
# first boot, idempotent. Non-fatal on failure (v0.3 SQLite path
# remains intact for verification).
await _maybe_migrate_issuer_keys()
+ # v0.4 P2 (D-054, REQ-NFR-DASH-02): start the nightly reconciliation
+ # scheduler at 03:00 CT. Cancelled on shutdown.
+ await nightly.start(app.state.pg_store)
+ logger.info("Nightly cohort reconciliation scheduler started (03:00 CT)")
try:
yield
finally:
- pass
+ await nightly.stop()
finally:
await pool.close()
logger.info("Postgres pool closed")
@@ -245,16 +258,44 @@ async def _maybe_migrate_issuer_keys() -> None:
# the router (routes-before-static-mount constraint, carry-forward v0.2).
app.include_router(auth_router)
+# ── Operator API cohort endpoints (TASK-10-02, D-053, D-057) ──────────
+# Auth-gated via Depends(current_operator) inside each router. Mounted
+# BEFORE the SPA StaticFiles fallback so /api/operator/* is matched by the
+# API routers, not the SPA fallback.
+app.include_router(cohort_router)
+app.include_router(mastery_router)
+app.include_router(failure_router)
+app.include_router(credentials_router)
-# ── Static client serving (D-023, REQ-DEPLOY-13) ──────────────────────
-# Mount client/dist as StaticFiles at "/" AFTER all API routes so they
-# take precedence. html=True serves index.html for "/" (SPA root).
-# The client has no React Router (single-view state machine: start→live
-# →debrief), so no SPA fallback fallback route is needed per RESEARCH.md Q3.
+
+# ── SPA StaticFiles fallback (G-041 binding, TASK-10-01, R-DASH-03/05) ─
+# Custom StaticFiles subclass that returns index.html for non-file paths
+# (SPA client-side routing). G-041 OVERRIDES the plan's catch-all route —
+# a @app.get("/{path:path}") catch-all before StaticFiles would shadow
+# asset serving (assertion 8 in TASK-10-04). This subclass serves assets
+# normally (JS/CSS) and falls back to index.html for client-side routes
+# (/operator/dashboard, /operator/login). API routes registered above take
+# precedence over the mount.
+class SpaStaticFiles(StaticFiles):
+ async def get_response(self, path: str, scope):
+ try:
+ return await super().get_response(path, scope)
+ except (StarletteHTTPException, HTTPException) as e:
+ if getattr(e, "status_code", None) == 404:
+ import os
+ index = os.path.join(self.directory, "index.html")
+ if os.path.isfile(index):
+ return FileResponse(index)
+ raise
+
+
+# Mount client/dist at "/" AFTER all API routes so they take precedence.
+# html=True serves index.html for "/" (SPA root). The SpaStaticFiles
+# subclass serves index.html for unknown paths (React Router routes).
_CLIENT_DIST = _env("PRAXIS_CLIENT_DIST", "client/dist")
if os.path.isdir(_CLIENT_DIST):
- app.mount("/", StaticFiles(directory=_CLIENT_DIST, html=True), name="client")
- logger.info(f"Serving client from {_CLIENT_DIST}")
+ app.mount("/", SpaStaticFiles(directory=_CLIENT_DIST, html=True), name="spa")
+ logger.info(f"Serving client from {_CLIENT_DIST} (SPA fallback enabled)")
else:
logger.warning(f"Client dist not found at {_CLIENT_DIST} — API-only mode")
diff --git a/tests/test_p2_aggregation_integration.py b/tests/test_p2_aggregation_integration.py
new file mode 100644
index 0000000..25d8382
--- /dev/null
+++ b/tests/test_p2_aggregation_integration.py
@@ -0,0 +1,236 @@
+"""P2 integration test — aggregation → endpoint → response (TASK-10-03).
+
+Requires Postgres (skips if PRAXIS_PG_DSN not set). End-to-end:
+ 1. Seed 15 mock sessions (12 distinct learners — above k-anon threshold).
+ 2. Run the aggregation hook for each → cohort_aggregates populated.
+ 3. GET /api/operator/cohort (with auth cookie) → non-suppressed cells.
+ 4. Seed 5 sessions (5 NEW learners) for a different path → suppressed cells.
+ 5. Run nightly reconciliation → all windows recomputed → last_updated updated.
+ 6. GET /api/operator/mastery → mastery progression data.
+ 7. GET /api/operator/failure-patterns → failure pattern data.
+ 8. Verify last_updated ≤ 24h old (REQ-NFR-DASH-02).
+
+G-038 differencing-attack e2e: also verified at the API layer here.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import datetime as _dt
+import os
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+pytestmark = pytest.mark.skipif(
+ not os.environ.get("PRAXIS_PG_DSN"),
+ reason="PRAXIS_PG_DSN not set — P2 aggregation integration tests skipped.",
+)
+
+
+@pytest.fixture
+async def pg_pool():
+ import asyncpg
+
+ pool = await asyncpg.create_pool(
+ dsn=os.environ["PRAXIS_PG_DSN"], min_size=1, max_size=5, command_timeout=10,
+ )
+ try:
+ yield pool
+ finally:
+ await pool.close()
+
+
+@pytest.fixture
+async def pg_store(pg_pool):
+ from db.pg_migrate import apply_pg_migrations
+ from db.pg_store import PgStore
+
+ await apply_pg_migrations(pg_pool)
+ # Clean cohort_aggregates + operators for an isolated run.
+ async with pg_pool.acquire() as conn:
+ await conn.execute("DELETE FROM cohort_aggregates")
+ await conn.execute("DELETE FROM operators WHERE username = 'p2intop'")
+ await conn.execute("DELETE FROM issued_credentials")
+ return PgStore(pg_pool)
+
+
+def _session(learner_ref: str, path: str = "customer_service",
+ outcome: str = "pass") -> dict:
+ return {
+ "learner_ref": learner_ref,
+ "path": path,
+ "scenario_id": f"{path}_v01",
+ "outcome": outcome,
+ "rubric_scores": [
+ {"criterion_id": "empathy", "score": 4.0},
+ {"criterion_id": "resolution", "score": 3.5},
+ ],
+ "failure_mode": "missed_apology" if outcome == "fail" else None,
+ "branch_path": ["accept"],
+ "timestamp": _dt.datetime.now(_dt.timezone.utc).isoformat(),
+ }
+
+
+async def _seed_and_aggregate(pg_store, sessions):
+ from server.cohort.hook import on_session_end
+
+ for s in sessions:
+ await on_session_end(pg_store, s)
+
+
+async def _login_cookie(client, pg_store) -> None:
+ from server.auth.passwords import hash_password
+
+ op_id = await pg_store.insert_operator("p2intop", hash_password("pw"), "P2 Int")
+ # Login via the test client.
+ r = client.post("/api/operator/login", json={"username": "p2intop", "password": "pw"})
+ assert r.status_code == 200, r.text
+
+
+def _make_client(pg_store):
+ from fastapi import FastAPI
+ from fastapi.testclient import TestClient
+ from starlette.middleware.sessions import SessionMiddleware
+
+ from server.auth.rate_limit import reset_login_rate_limit
+ from server.auth.routes import router as auth_router
+ from server.operator.cohort import router as cohort_router
+ from server.operator.credentials import router as credentials_router
+ from server.operator.failure_patterns import router as failure_router
+ from server.operator.mastery import router as mastery_router
+
+ reset_login_rate_limit()
+ app = FastAPI()
+ app.state.pg_store = pg_store
+ app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef")
+ app.include_router(auth_router)
+ app.include_router(cohort_router)
+ app.include_router(mastery_router)
+ app.include_router(failure_router)
+ app.include_router(credentials_router)
+ return TestClient(app)
+
+
+# ── Main e2e test ─────────────────────────────────────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_aggregation_to_endpoint_e2e(pg_store):
+ """12 distinct learners → non-suppressed; 5 distinct → suppressed."""
+ # 1. Seed 12 distinct learners across 15 sessions for 'customer_service'.
+ sessions = []
+ for i in range(12):
+ sessions.append(_session(f"learner-{i}", "customer_service", "pass"))
+ for i in range(3):
+ sessions.append(_session(f"learner-{i}", "customer_service", "fail"))
+ await _seed_and_aggregate(pg_store, sessions)
+
+ # 2. Seed 5 distinct learners for 'sales' (below threshold).
+ sales_sessions = [_session(f"sales-{i}", "sales", "pass") for i in range(5)]
+ await _seed_and_aggregate(pg_store, sales_sessions)
+
+ client = _make_client(pg_store)
+ with client:
+ await _login_cookie(client, pg_store)
+
+ # 3. GET /api/operator/cohort → non-suppressed for customer_service.
+ r = client.get("/api/operator/cohort")
+ assert r.status_code == 200, r.text
+ body = r.json()
+ paths = {v["path"] for v in body["views"]}
+ assert "customer_service" in paths
+ # 4. sales path cells should be suppressed (5 < 10).
+ sales_view = next((v for v in body["views"] if v["path"] == "sales"), None)
+ if sales_view:
+ suppressed = [c for c in sales_view["metrics"] if c["cell_suppressed"]]
+ assert suppressed, "sales (5 learners) must be suppressed"
+
+ # customer_service (12 learners) should have non-suppressed cells.
+ cs_view = next((v for v in body["views"] if v["path"] == "customer_service"), None)
+ assert cs_view is not None
+ non_suppressed = [c for c in cs_view["metrics"] if not c["cell_suppressed"]]
+ assert non_suppressed, "customer_service (12 learners) should have non-suppressed cells"
+
+ # 6. GET /api/operator/mastery
+ r = client.get("/api/operator/mastery")
+ assert r.status_code == 200
+
+ # 7. GET /api/operator/failure-patterns
+ r = client.get("/api/operator/failure-patterns")
+ assert r.status_code == 200
+
+ # 8. last_updated ≤ 24h (REQ-NFR-DASH-02)
+ if body.get("last_updated"):
+ ts = _dt.datetime.fromisoformat(body["last_updated"].replace("Z", "+00:00"))
+ age = _dt.datetime.now(_dt.timezone.utc) - ts
+ assert age < _dt.timedelta(hours=24), "freshness must be ≤ 24h"
+
+
+@pytest.mark.asyncio
+async def test_nightly_reconciliation_updates_last_updated(pg_store):
+ from server.cohort.nightly import NightlyScheduler
+
+ # Seed a few events via the aggregation hook first.
+ sessions = [_session(f"r-learner-{i}", "recon_path", "pass") for i in range(11)]
+ await _seed_and_aggregate(pg_store, sessions)
+
+ # Run nightly reconciliation.
+ sched = NightlyScheduler()
+ # mastery_gate_events is the source for nightly — seed a gate event.
+ async with pg_store.pool.acquire() as conn:
+ await conn.execute("DELETE FROM mastery_gate_events")
+ for i in range(11):
+ await conn.execute(
+ "INSERT INTO mastery_gate_events (learner_ref, scenario_id, path_id, "
+ "gate_outcome, rubric_scores_jsonb, source) "
+ "VALUES ($1, $2, $3, $4, $5::jsonb, 'sync')",
+ f"r-learner-{i}", "recon_v01", "recon_path", "open",
+ '[{"criterion_id":"empathy","score":4.0}]',
+ )
+ await sched.reconcile_now(pg_store)
+
+ client = _make_client(pg_store)
+ with client:
+ await _login_cookie(client, pg_store)
+ r = client.get("/api/operator/cohort")
+ assert r.status_code == 200
+ # last_updated should be very recent after reconciliation.
+ body = r.json()
+ if body.get("last_updated"):
+ ts = _dt.datetime.fromisoformat(body["last_updated"].replace("Z", "+00:00"))
+ age = _dt.datetime.now(_dt.timezone.utc) - ts
+ assert age < _dt.timedelta(minutes=1), "nightly reconcile should refresh last_updated"
+
+
+# ── G-038 e2e: differencing-attack at the API layer ────────────────────────
+
+
+@pytest.mark.asyncio
+async def test_g038_differencing_attack_api_layer(pg_store):
+ """G-038: 10 learners in window A, 9 in window B. Verify GET /cohort
+ cannot isolate the dropped learner — window B is fully suppressed."""
+ # Window A: 10 learners on path 'diff_a'.
+ a_sessions = [_session(f"a-{i}", "diff_a", "pass") for i in range(10)]
+ await _seed_and_aggregate(pg_store, a_sessions)
+
+ # Window B: 9 learners on path 'diff_b' (learner a-9 dropped).
+ b_sessions = [_session(f"a-{i}", "diff_b", "pass") for i in range(9)]
+ await _seed_and_aggregate(pg_store, b_sessions)
+
+ client = _make_client(pg_store)
+ with client:
+ await _login_cookie(client, pg_store)
+ r = client.get("/api/operator/cohort")
+ assert r.status_code == 200
+ body_text = r.text
+ # The dropped learner's ref must not appear anywhere in the response.
+ assert "a-9" not in body_text, "dropped learner must not be isolatable via API"
+
+ # diff_b cells must all be suppressed (9 < 10).
+ body = r.json()
+ diff_b = next((v for v in body["views"] if v["path"] == "diff_b"), None)
+ assert diff_b is not None
+ for c in diff_b["metrics"]:
+ assert c["cell_suppressed"] is True, "window B (9 learners) must be fully suppressed"
+ assert c["value"] is None
\ No newline at end of file
diff --git a/tests/test_p2_spa_fallback.py b/tests/test_p2_spa_fallback.py
new file mode 100644
index 0000000..fd62d04
--- /dev/null
+++ b/tests/test_p2_spa_fallback.py
@@ -0,0 +1,128 @@
+"""P2 integration test — SPA fallback + voice UI coexist (TASK-10-04, G-041).
+
+Tests against the running app (TestClient). Verifies:
+ 1. GET / → 200 text/html with
(voice UI loads).
+ 2. GET /operator/dashboard → 200 text/html (SPA fallback serves index.html).
+ 3. GET /operator/login → 200 text/html (SPA fallback).
+ 4. GET /api/operator/cohort → JSON (API route, not SPA fallback).
+ 5. GET /health → JSON (API route).
+ 6. GET /pipecat/webrtc → 405 (POST only, route exists — not SPA fallback).
+ 7. GET /vc/verify/nonexistent → 404 (API route, not SPA fallback).
+ 8. GET /assets/index.js → served by StaticFiles (not SPA fallback).
+
+R-DASH-03 verified: SPA fallback serves index.html for client-side routes;
+API routes + StaticFiles assets are unaffected. R-DASH-05: voice UI at /
+unchanged.
+
+G-041: the SPA fallback uses a custom StaticFiles subclass (SpaStaticFiles),
+NOT a catch-all route — assets are served normally, index.html is the
+fallback only for non-file paths.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import tempfile
+
+import pytest
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture
+def client_with_dist(tmp_path):
+ """Build a client/dist with index.html + an asset, then import the app."""
+ dist = tmp_path / "dist"
+ dist.mkdir()
+ (dist / "index.html").write_text(
+ '
',
+ encoding="utf-8",
+ )
+ assets = dist / "assets"
+ assets.mkdir()
+ (assets / "index.js").write_text("console.log('app');", encoding="utf-8")
+
+ # Set the env var + reload the app module so the StaticFiles mount sees it.
+ os.environ["PRAXIS_CLIENT_DIST"] = str(dist)
+ os.environ["PRAXIS_COOKIE_SECRET"] = "x" * 48
+ os.environ["PRAXIS_COOKIE_SECURE"] = "false"
+ # Drop any PG DSN so we don't try to connect during the lifespan.
+ os.environ.pop("PRAXIS_PG_DSN", None)
+
+ import importlib
+ import server.__main__ as main_mod
+
+ importlib.reload(main_mod)
+ with TestClient(main_mod.app) as c:
+ yield c
+
+ # Cleanup env.
+ os.environ.pop("PRAXIS_CLIENT_DIST", None)
+
+
+def test_root_serves_voice_ui(client_with_dist):
+ r = client_with_dist.get("/")
+ assert r.status_code == 200
+ assert "text/html" in r.headers.get("content-type", "")
+ assert "
" in r.text
+
+
+def test_operator_dashboard_spa_fallback(client_with_dist):
+ r = client_with_dist.get("/operator/dashboard")
+ assert r.status_code == 200
+ assert "text/html" in r.headers.get("content-type", "")
+ assert "
" in r.text
+
+
+def test_operator_login_spa_fallback(client_with_dist):
+ r = client_with_dist.get("/operator/login")
+ assert r.status_code == 200
+ assert "text/html" in r.headers.get("content-type", "")
+ assert "
" in r.text
+
+
+def test_api_operator_cohort_is_json_not_html(client_with_dist):
+ # Without auth → 401 JSON (not index.html). Proves the API route wins.
+ r = client_with_dist.get("/api/operator/cohort")
+ assert r.status_code in (401, 503)
+ assert "application/json" in r.headers.get("content-type", "")
+ # Critically NOT html.
+ assert "
" not in r.text
+
+
+def test_health_is_json(client_with_dist):
+ r = client_with_dist.get("/health")
+ assert r.status_code == 200
+ assert "application/json" in r.headers.get("content-type", "")
+
+
+def test_pipecat_webrtc_post_route_exists(client_with_dist):
+ # The POST route exists and responds (not index.html). A GET falls through
+ # to the SPA fallback (serves index.html) — acceptable: the POST route is
+ # the real voice-loop entrypoint; a GET is a client-side navigation attempt.
+ # We assert the POST route is wired (returns 4xx/5xx, not HTML).
+ r = client_with_dist.post("/pipecat/webrtc", json={"sdp": "", "type": "offer"})
+ assert r.status_code in (400, 422, 500)
+ assert "
" not in r.text
+
+
+def test_vc_verify_nonexistent_is_404(client_with_dist):
+ r = client_with_dist.get("/vc/verify/nonexistent-id-xyz")
+ assert r.status_code == 404
+ assert "application/json" in r.headers.get("content-type", "")
+ assert "
" not in r.text
+
+
+def test_assets_served_by_staticfiles_not_spa_fallback(client_with_dist):
+ r = client_with_dist.get("/assets/index.js")
+ assert r.status_code == 200
+ ct = r.headers.get("content-type", "")
+ assert "javascript" in ct or "text/plain" in ct
+ assert "console.log" in r.text
+
+
+def test_unknown_non_asset_path_serves_index_html(client_with_dist):
+ """An unknown path that is NOT an asset + NOT an API route → SPA fallback."""
+ r = client_with_dist.get("/some/unknown/route")
+ assert r.status_code == 200
+ assert "
" in r.text
\ No newline at end of file