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