Files
praxis/tests/test_cohort_aggregation.py
T
Praxis CI ec6fcc64bc feat(milestone): merge phase/02 cohort-dashboard → milestone/v0.4-operator-tier
Phase 2 complete — Cohort Dashboard + Aggregation:
- Cohort aggregation pipeline (k-anon ≥10 write-time suppression, async hook, nightly 03:00 CT reconcile)
- 4 auth-gated operator API endpoints (cohort, mastery, failure-patterns, credentials)
- React cohort dashboard (BrowserRouter, login, 3 views, inline SVG sparklines, auth gate)
- SPA fallback via SpaStaticFiles subclass (G-041 — NOT catch-all route)
- G-038 differencing-attack test (unit + API e2e)
- 317 pytest pass, 36 skip, 0 fail; 17/17 vitest pass; npm build + typecheck clean

---ci---
project: praxis
phase: 2
milestone: v0.4
status: complete
requirements:
  covered: [REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-MT-02]
  partial: []
---/ci---
2026-08-04 11:43:34 +00:00

246 lines
9.7 KiB
Python

"""Cohort aggregation unit tests (TASK-07-05) — mocked PgStore, no Postgres.
Covers: k-anonymity suppression (9 vs 10 vs 11 learners), idempotent upsert,
7-day window computation, multiple metrics, no PII in upsert calls.
G-038 (binding — differencing-attack test): seed 10 learners in window A and
9 in window B (one dropped), verify the API/aggregation cannot isolate the
dropped learner — both windows show k-anonymized aggregates with no
per-learner data leaks.
"""
from __future__ import annotations
import datetime as _dt
from unittest.mock import AsyncMock, MagicMock
import pytest
from server.cohort.aggregator import (
K_ANON_THRESHOLD,
_rolling_window,
aggregate_session,
)
from server.cohort.hook import on_session_end
def _mock_pg_store():
store = MagicMock()
store.upsert_cohort_aggregate = AsyncMock()
return store
def _session(learner_ref: str, path: str = "customer_service",
outcome: str = "pass", rubric_scores=None,
failure_mode=None, branch_path=None) -> dict:
return {
"learner_ref": learner_ref,
"path": path,
"scenario_id": f"{path}_v01",
"outcome": outcome,
"rubric_scores": rubric_scores or [
{"criterion_id": "empathy", "score": 4.0},
{"criterion_id": "resolution", "score": 3.5},
],
"failure_mode": failure_mode,
"branch_path": branch_path or ["accept"],
"timestamp": _dt.datetime.now(_dt.timezone.utc).isoformat(),
}
# ── k-anonymity threshold ───────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_k_anon_threshold_at_10():
assert K_ANON_THRESHOLD == 10
@pytest.mark.asyncio
async def test_9_learners_suppressed():
store = _mock_pg_store()
for i in range(9):
await aggregate_session(store, _session(f"learner-{i}"))
suppressed_calls = [
c for c in store.upsert_cohort_aggregate.call_args_list
if c.args[6] is True # cell_suppressed
]
non_suppressed = [
c for c in store.upsert_cohort_aggregate.call_args_list
if c.args[6] is False
]
assert suppressed_calls, "cells should be suppressed with <10 learners"
assert not non_suppressed, "no cell should be non-suppressed with 9 learners"
@pytest.mark.asyncio
async def test_10_learners_not_suppressed():
store = _mock_pg_store()
for i in range(10):
await aggregate_session(store, _session(f"learner-{i}"))
non_suppressed = [
c for c in store.upsert_cohort_aggregate.call_args_list
if c.args[6] is False
]
assert non_suppressed, "cells should NOT be suppressed at exactly 10 learners"
# value should be non-null for non-suppressed cells
for c in non_suppressed:
assert c.args[4] is not None, "non-suppressed cell value must not be None"
@pytest.mark.asyncio
async def test_11_learners_not_suppressed():
store = _mock_pg_store()
for i in range(11):
await aggregate_session(store, _session(f"learner-{i}"))
non_suppressed = [
c for c in store.upsert_cohort_aggregate.call_args_list
if c.args[6] is False
]
assert non_suppressed, "11 learners should NOT be suppressed"
# ── Idempotent upsert ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_idempotent_same_session_twice():
store = _mock_pg_store()
outcome = _session("learner-x")
await aggregate_session(store, outcome)
await aggregate_session(store, outcome)
# Re-running with the same outcome produces additional upsert calls but
# the ON CONFLICT in PgStore makes them idempotent at the DB layer. The
# hook itself is deterministic — the same learner produces the same
# distinct-count + counter state in the cache.
# Assert at least one upsert happened (the contract is DB-level idempotency).
assert store.upsert_cohort_aggregate.called
# ── 7-day window computation ───────────────────────────────────────────────
def test_rolling_window_7_days():
now = _dt.datetime(2026, 8, 4, 12, 0, tzinfo=_dt.timezone.utc)
start, end = _rolling_window(now)
assert (end - start).days == 6 # 7-day inclusive span
assert end == now.date()
assert start == _dt.date(2026, 7, 29)
# ── Multiple metrics ───────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_multiple_metrics_computed():
store = _mock_pg_store()
await aggregate_session(store, _session("learner-1", rubric_scores=[
{"criterion_id": "empathy", "score": 4.0},
{"criterion_id": "resolution", "score": 3.0},
], failure_mode="missed_apology", branch_path=["escalate"]))
metrics = {c.args[1] for c in store.upsert_cohort_aggregate.call_args_list}
assert "sessions_count" in metrics
assert "active_learners_count" in metrics
assert "gate_open_rate" in metrics
assert "median_mastery_score" in metrics
assert "rubric_criterion_mean:empathy" in metrics
assert "failure_mode:missed_apology" in metrics
assert "branch:escalate" in metrics
# ── No PII in upsert calls ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_no_pii_in_upsert_calls():
store = _mock_pg_store()
await aggregate_session(store, _session("learner-sensitive-id-1234"))
for c in store.upsert_cohort_aggregate.call_args_list:
# path, metric, window_start, window_end, value, cell_count, suppressed
# No argument should contain the raw learner_ref string as PII.
for arg in c.args:
assert "learner-sensitive-id-1234" not in str(arg), \
"raw learner_ref must not leak into aggregate cell args"
# cell_count is the distinct-learner count (an integer), not the ref.
assert isinstance(c.args[5], int)
# ── G-038: Differencing-attack test (binding) ──────────────────────────────
# Seed 10 learners in window A, 9 in window B (one dropped). Verify the
# aggregation/API cannot isolate the dropped learner — both windows produce
# k-anonymized aggregates with no per-learner data leaks.
@pytest.mark.asyncio
async def test_g038_differencing_attack_cannot_isolate_dropped_learner():
"""G-038 binding: 10 learners in window A, 9 in window B (one dropped).
A differencing attack tries to subtract window B's aggregate from
window A's to recover the dropped learner's contribution. With k-anon
write-time suppression, window B (9 learners) is FULLY suppressed
(value=NULL, cell_suppressed=TRUE), so the attacker cannot subtract
anything — the dropped learner's contribution is not recoverable.
"""
store_a = _mock_pg_store()
store_b = _mock_pg_store()
# Window A: 10 distinct learners → non-suppressed
for i in range(10):
await aggregate_session(store_a, _session(f"learner-{i}"))
# Window B: 9 distinct learners (learner-9 dropped) → suppressed
for i in range(9):
await aggregate_session(store_b, _session(f"learner-{i}"))
a_cells = list(store_a.upsert_cohort_aggregate.call_args_list)
b_cells = list(store_b.upsert_cohort_aggregate.call_args_list)
# Window A: at least some non-suppressed cells (10 >= threshold)
a_non_suppressed = [c for c in a_cells if c.args[6] is False]
assert a_non_suppressed, "window A (10 learners) should have non-suppressed cells"
# Window B: ALL cells suppressed (9 < threshold)
b_suppressed = [c for c in b_cells if c.args[6] is True]
b_non_suppressed = [c for c in b_cells if c.args[6] is False]
assert b_suppressed, "window B (9 learners) must have suppressed cells"
assert not b_non_suppressed, \
"window B (9 learners) must have NO non-suppressed cells (differencing blocked)"
# The critical differencing-attack defense: window B's suppressed cells
# have value=NULL, so subtracting B from A is not possible — the attacker
# cannot recover learner-9's contribution.
for c in b_suppressed:
assert c.args[4] is None, \
"suppressed cell value must be NULL (differencing-attack defense)"
# No per-learner data leaks in either window's aggregate cells.
for cells in (a_cells, b_cells):
for c in cells:
for arg in c.args:
assert "learner-9" not in str(arg), \
"dropped learner's ref must not appear in any aggregate cell"
# ── Hook (TASK-07-02) ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_hook_no_postgres_is_noop():
# No exception, just a warning log.
await on_session_end(None, _session("learner-1"))
@pytest.mark.asyncio
async def test_hook_failure_logs_does_not_raise(monkeypatch):
store = _mock_pg_store()
store.upsert_cohort_aggregate = AsyncMock(side_effect=RuntimeError("boom"))
# Must not raise — the hook swallows + logs; nightly reconciles.
await on_session_end(store, _session("learner-1"))
@pytest.mark.asyncio
async def test_hook_idempotent():
store = _mock_pg_store()
outcome = _session("learner-1")
await on_session_end(store, outcome)
await on_session_end(store, outcome)
assert store.upsert_cohort_aggregate.called