Files
praxis/tests/test_p2_spa_fallback.py
T
Praxis CI de2020eee1 feat(P02): SLICE-10 P2 integration — SPA fallback, router mount, e2e tests
TASK-10-01 (G-041 binding): server/__main__.py — SpaStaticFiles custom
  subclass that returns index.html for non-file paths (NOT a catch-all
  route). G-041 OVERRIDES the plan's catch-all approach — a catch-all
  before StaticFiles would shadow asset serving. The subclass serves
  assets normally + falls back to index.html for client-side routes.
  Mounted at / AFTER all API routes so they take precedence.
TASK-10-02: server/__main__.py — mount operator API routers (cohort,
  mastery, failure_patterns, credentials) before SPA fallback. Start
  NightlyScheduler in lifespan (03:00 CT), cancel on shutdown.
TASK-10-03: tests/test_p2_aggregation_integration.py — e2e aggregation→
  endpoint (12 learners non-suppressed, 5 suppressed), nightly reconcile
  refreshes last_updated, freshness ≤ 24h. G-038 differencing-attack at
  API layer. Requires Postgres (skips if no DSN).
TASK-10-04: tests/test_p2_spa_fallback.py — 9 assertions: / → voice UI,
  /operator/* → index.html, API routes → JSON, /assets/* → StaticFiles.
  R-DASH-03/05 verified. Deviation: GET to POST-only /pipecat/webrtc
  falls through to SPA fallback (not 405) — acceptable, the POST route
  is the real entrypoint; a GET is a client-side navigation attempt.
TASK-10-05: .ciagent/VERIFY-P2.md — REQ-ID → test mapping for all 4 P2
  REQ-IDs + G-038 + G-041 + R-DASH-05.

---ci---
project: praxis
phase: 2
milestone: v0.4
status: execute
persona: backend-engineer
task: 10-01..10-05
requirements:
  covered: [REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-MT-02]
---/ci---
2026-08-04 04:35:29 +00:00

128 lines
4.7 KiB
Python

"""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 <div id="root"> (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(
'<!doctype html><html><body><div id="root"></div></body></html>',
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 "<div id=\"root\">" 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 "<div id=\"root\">" 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 "<div id=\"root\">" 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 "<div id=\"root\">" 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 "<div id=\"root\">" 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 "<div id=\"root\">" 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 "<div id=\"root\">" in r.text