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