Files
praxis/tests/test_p2_spa_fallback.py
T
Praxis CI f2a12f9fed docs(milestone): complete v0.4-operator-tier — v0.1.9 tagged, milestone release, merged to main
v0.4 (Operator Tier — Cohort Dashboard + Auth + Postgres) milestone complete.

Phases:
  ✓ P0  pre-execution (planning)        → v0.1.6
  ✓ P1  operator foundation (Postgres+auth+VC migration) → v0.1.7
  ✓ P2  cohort dashboard + aggregation   → v0.1.8
  ✓ P3  final review + ship              → v0.1.9 (= v0.4 milestone release)

Requirements covered (8/8):
  REQ-MT-01 (Postgres store), REQ-MT-02 (aggregation pipeline),
  REQ-AUTH-01 (operator auth), REQ-DASH-01 (cohort dashboard),
  REQ-NFR-AUTH-01 (auth NFRs), REQ-NFR-MT-01 (Postgres-in-LXC),
  REQ-NFR-DASH-01 (k-anonymity ≥10), REQ-NFR-DASH-02 (freshness ≤24h)

Grill MUSTs honored (6/6): G-008, G-011, G-027, G-031, G-038, G-041

Tests: 317 pytest pass, 36 skip (Postgres-requiring), 0 fail; 17/17 vitest pass
Review: APPROVE_WITH_NOTES (6/6 personas, 0 P0, 8 P1+ carry-forward)
Audit: HEALTHY (reconstruction PASS, 8/8 REQ, 6/6 grill)

---ci---
project: praxis
phase: 3
milestone: v0.4
status: complete
phase_role: final
milestone_complete: true
milestone_merged_to_main: true
tag: v0.1.9
requirements:
  covered: [REQ-MT-01, REQ-MT-02, REQ-AUTH-01, REQ-DASH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02]
  partial: []
---/ci---
2026-08-04 11:58:44 +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