Files
praxis/tests/test_cohort_nightly.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

199 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Nightly reconciliation + hook integration tests (TASK-07-06) — mocked PgStore.
Covers: scheduler timing (seconds until 03:00 CT), reconciliation recomputes
all windows, hook failure + nightly reconciliation = correct final state,
R-DASH-04 (nightly failure logs + retries next night).
"""
from __future__ import annotations
import datetime as _dt
from unittest.mock import AsyncMock, MagicMock
import pytest
from server.cohort.nightly import (
CT,
NightlyScheduler,
seconds_until_next_03_ct,
)
# ── Scheduler timing ───────────────────────────────────────────────────────
def test_seconds_until_next_03_ct_future_today():
# 01:00 CT → next 03:00 CT is in 2h
now = _dt.datetime(2026, 8, 4, 1, 0, tzinfo=CT)
secs = seconds_until_next_03_ct(now)
assert 7190 <= secs <= 7200 # ~2h
def test_seconds_until_next_03_ct_past_today_wraps_tomorrow():
# 04:00 CT → next 03:00 CT is tomorrow (23h)
now = _dt.datetime(2026, 8, 4, 4, 0, tzinfo=CT)
secs = seconds_until_next_03_ct(now)
assert 82790 <= secs <= 82810 # ~23h
def test_seconds_until_next_03_ct_exactly_03_rolls_to_tomorrow():
now = _dt.datetime(2026, 8, 4, 3, 0, 0, tzinfo=CT)
secs = seconds_until_next_03_ct(now)
# exactly 03:00:00 → next run is tomorrow (0 secs would mean "now", but
# the scheduler sleeps then runs, so it must be ~24h)
assert secs >= 86390 # ~24h
# ── Reconciliation recomputes all windows ──────────────────────────────────
class _FakeRecord(dict):
"""Mimics an asyncpg Record — dict(record) returns the dict."""
pass
def _mock_pg_store_with_events(events):
store = MagicMock()
store.upsert_cohort_aggregate = AsyncMock()
conn = MagicMock()
rows = [_FakeRecord(e) for e in events]
conn.fetch = AsyncMock(return_value=rows)
cm = MagicMock()
cm.__aenter__ = AsyncMock(return_value=conn)
cm.__aexit__ = AsyncMock(return_value=None)
store.pool = MagicMock()
store.pool.acquire = MagicMock(return_value=cm)
return store
@pytest.mark.asyncio
async def test_reconcile_recomputes_all_paths():
events = [
{"learner_ref": "l1", "path_id": "customer_service", "gate_outcome": "open",
"rubric_scores_jsonb": '[{"criterion_id":"empathy","score":4.0}]',
"recorded_at": _dt.datetime.now(_dt.timezone.utc)},
{"learner_ref": "l2", "path_id": "customer_service", "gate_outcome": "open",
"rubric_scores_jsonb": '[{"criterion_id":"empathy","score":3.0}]',
"recorded_at": _dt.datetime.now(_dt.timezone.utc)},
{"learner_ref": "l3", "path_id": "sales", "gate_outcome": "closed",
"rubric_scores_jsonb": '[]',
"recorded_at": _dt.datetime.now(_dt.timezone.utc)},
]
store = _mock_pg_store_with_events(events)
sched = NightlyScheduler()
await sched.reconcile_now(store)
# upserts should cover both paths × multiple metrics
paths = {c.args[0] for c in store.upsert_cohort_aggregate.call_args_list}
assert "customer_service" in paths
assert "sales" in paths
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
@pytest.mark.asyncio
async def test_reconcile_suppresses_below_threshold():
# 3 distinct learners → suppressed
events = [
{"learner_ref": f"l{i}", "path_id": "p", "gate_outcome": "open",
"rubric_scores_jsonb": "[]",
"recorded_at": _dt.datetime.now(_dt.timezone.utc)}
for i in range(3)
]
store = _mock_pg_store_with_events(events)
sched = NightlyScheduler()
await sched.reconcile_now(store)
suppressed = [c for c in store.upsert_cohort_aggregate.call_args_list if c.args[6] is True]
non_suppressed = [c for c in store.upsert_cohort_aggregate.call_args_list if c.args[6] is False]
assert suppressed, "3 learners must be suppressed"
assert not non_suppressed, "no cell should be non-suppressed with 3 learners"
@pytest.mark.asyncio
async def test_reconcile_no_events_no_op():
store = _mock_pg_store_with_events([])
sched = NightlyScheduler()
await sched.reconcile_now(store)
store.upsert_cohort_aggregate.assert_not_called()
# ── Hook failure → nightly reconciles ──────────────────────────────────────
@pytest.mark.asyncio
async def test_hook_failure_then_nightly_reconciles_correct_state():
"""A hook failure leaves no aggregate; the nightly job recomputes from
mastery_gate_events and produces the correct final state."""
events = [
{"learner_ref": f"l{i}", "path_id": "p", "gate_outcome": "open",
"rubric_scores_jsonb": "[]",
"recorded_at": _dt.datetime.now(_dt.timezone.utc)}
for i in range(10)
]
store = _mock_pg_store_with_events(events)
# Simulate hook failure: upsert raises first time, then nightly runs.
# (In production the hook + nightly use the same store; here we just
# verify the nightly path produces correct aggregates independently.)
sched = NightlyScheduler()
await sched.reconcile_now(store)
non_suppressed = [c for c in store.upsert_cohort_aggregate.call_args_list if c.args[6] is False]
assert non_suppressed, "nightly should produce non-suppressed cells for 10 learners"
# ── R-DASH-04: nightly failure logs + retries ──────────────────────────────
@pytest.mark.asyncio
async def test_r_dash_04_nightly_failure_does_not_crash_scheduler():
"""R-DASH-04: a reconciliation failure logs + the scheduler continues.
The scheduler loop (_run_loop) catches exceptions from _reconcile and
retries the next night. We simulate this by invoking the loop with a
broken store and confirming the loop catches + continues.
"""
store = MagicMock()
store.upsert_cohort_aggregate = AsyncMock(side_effect=RuntimeError("db down"))
store.pool = MagicMock()
cm = MagicMock()
cm.__aenter__ = AsyncMock(side_effect=RuntimeError("pool down"))
cm.__aexit__ = AsyncMock(return_value=None)
store.pool.acquire = MagicMock(return_value=cm)
sched = NightlyScheduler()
import server.cohort.nightly as nightly_mod
orig = nightly_mod.seconds_until_next_03_ct
calls = []
def _fake_secs():
calls.append(1)
return 0.01
nightly_mod.seconds_until_next_03_ct = _fake_secs
try:
task = await sched.start(store)
await _sleep(0.1)
await sched.stop()
# The loop ran at least once despite the failure (R-DASH-04).
assert len(calls) >= 1
finally:
nightly_mod.seconds_until_next_03_ct = orig
@pytest.mark.asyncio
async def test_scheduler_start_stop_lifecycle():
store = _mock_pg_store_with_events([])
sched = NightlyScheduler()
# Patch seconds_until to be tiny so the loop is testable.
import server.cohort.nightly as nightly_mod
orig = nightly_mod.seconds_until_next_03_ct
nightly_mod.seconds_until_next_03_ct = lambda: 0.01
try:
task = await sched.start(store)
await _sleep(0.05)
await sched.stop()
assert task.cancelled() or task.done()
finally:
nightly_mod.seconds_until_next_03_ct = orig
async def _sleep(t: float) -> None:
import asyncio
await asyncio.sleep(t)