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---
This commit is contained in:
Praxis CI
2026-08-04 04:35:29 +00:00
parent d39bd1423a
commit de2020eee1
4 changed files with 502 additions and 9 deletions
+236
View File
@@ -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
+128
View File
@@ -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 <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