"""Tests for the cost rollup↔source drift reconciliation (PR-1). Pins the drift contract: * rollup == live recompute → no finding (consistency). * a supra-threshold gap at the CURRENT fingerprint → ``late_facts_or_drift``. * window materialised only under an OLD fingerprint → ``stale_fingerprint``. * window never materialised → ``not_materialized``. * the threshold band ``max($1, 0.5%)`` is respected at the boundary. * only CLOSED months are reconciled (the open month is excluded). """ from __future__ import annotations from datetime import date from decimal import Decimal from unittest.mock import AsyncMock, patch import pytest from app.domains.ai_spending.cost_drift import cost_rollup_drift from app.domains.ai_spending.models import AiSpendingPeriodRollup from app.domains.ai_spending.service import ( BUCKET_BEDROCK, BUCKET_SEATS, AiSpendingService, BucketRow, _current_period, _parse_period, _shift_period, ) _FP = "FP-CURRENT" # Most-recent closed month relative to "now" — always within months_back=3. _CLOSED_PERIOD = _shift_period(_current_period(), -1) _CLOSED_START, _CLOSED_END = _parse_period(_CLOSED_PERIOD) @pytest.fixture(autouse=True) def _stub_fingerprint(): """Pin the rollup fingerprint so seed + check agree without needing the migration-seeded whitelist platform_setting (absent under create_all). Tests that exercise the stale-fingerprint path seed rows under a DIFFERENT literal fingerprint, so the current-fp read still misses.""" with patch.object( AiSpendingService, "_rollup_fingerprint", AsyncMock(return_value=_FP), ): yield def _live_only_for_closed(rows: list[BucketRow]): """Return a side_effect for ``_compute_period_rows`` that yields ``rows`` for the target closed window and ``[]`` for every other month, so the other reconciled months don't spuriously report drift.""" def _side_effect(start: date, end: date) -> list[BucketRow]: if start == _CLOSED_START and end == _CLOSED_END: return list(rows) return [] return _side_effect def _seed(db, *, fp: str, bucket: str, cost: str) -> None: db.add(AiSpendingPeriodRollup( period_start=_CLOSED_START, period_end_excl=_CLOSED_END, whitelist_fingerprint=fp, bucket=bucket, fact_date=_CLOSED_START, cost_usd=Decimal(cost), )) def _bedrock(cost: str) -> BucketRow: return BucketRow( bucket=BUCKET_BEDROCK, date=_CLOSED_START, cost_usd=Decimal(cost), ) @pytest.mark.asyncio async def test_consistent_rollup_no_finding(db_session): # Rollup at current fp == live recompute → clean. _seed(db_session, fp=_FP, bucket=BUCKET_BEDROCK, cost="500") await db_session.flush() with patch.object( AiSpendingService, "_compute_period_rows", AsyncMock(side_effect=_live_only_for_closed([_bedrock("500")])), ): out = await cost_rollup_drift(db_session, months_back=3) assert out["findings"] == [] assert _CLOSED_PERIOD in out["periods_checked"] @pytest.mark.asyncio async def test_late_facts_or_drift(db_session): # Both present at current fp but unequal → late_facts_or_drift. _seed(db_session, fp=_FP, bucket=BUCKET_BEDROCK, cost="100") await db_session.flush() with patch.object( AiSpendingService, "_compute_period_rows", AsyncMock(side_effect=_live_only_for_closed([_bedrock("200")])), ): out = await cost_rollup_drift(db_session, months_back=3) findings = [f for f in out["findings"] if f["period"] == _CLOSED_PERIOD] assert len(findings) == 1 f = findings[0] assert f["bucket"] == BUCKET_BEDROCK assert f["likely_cause"] == "late_facts_or_drift" assert f["rollup_usd"] == 100.0 assert f["live_usd"] == 200.0 assert f["diff_usd"] == 100.0 @pytest.mark.asyncio async def test_stale_fingerprint(db_session): # Window materialised ONLY under an old fingerprint → current-fp read # misses, but the window exists → stale_fingerprint. _seed(db_session, fp="FP-OLD", bucket=BUCKET_BEDROCK, cost="500") await db_session.flush() with patch.object( AiSpendingService, "_compute_period_rows", AsyncMock(side_effect=_live_only_for_closed([_bedrock("500")])), ): out = await cost_rollup_drift(db_session, months_back=3) findings = [f for f in out["findings"] if f["period"] == _CLOSED_PERIOD] assert len(findings) == 1 assert findings[0]["likely_cause"] == "stale_fingerprint" @pytest.mark.asyncio async def test_not_materialized(db_session): # No rollup rows at all for the window → not_materialized. with patch.object( AiSpendingService, "_compute_period_rows", AsyncMock(side_effect=_live_only_for_closed([_bedrock("500")])), ): out = await cost_rollup_drift(db_session, months_back=3) findings = [f for f in out["findings"] if f["period"] == _CLOSED_PERIOD] assert len(findings) == 1 assert findings[0]["likely_cause"] == "not_materialized" @pytest.mark.asyncio async def test_threshold_boundary(db_session): # live=$1000, threshold = max($1, 0.5%*1000=$5)=$5. # rollup=$1004 → diff $4 ≤ $5 → NO finding. _seed(db_session, fp=_FP, bucket=BUCKET_SEATS, cost="1004") await db_session.flush() with patch.object( AiSpendingService, "_compute_period_rows", AsyncMock(side_effect=_live_only_for_closed( [BucketRow(bucket=BUCKET_SEATS, date=_CLOSED_START, cost_usd=Decimal("1000"))], )), ): out = await cost_rollup_drift(db_session, months_back=3) assert [f for f in out["findings"] if f["period"] == _CLOSED_PERIOD] == [] @pytest.mark.asyncio async def test_threshold_boundary_exceeded(db_session): # rollup=$1006 → diff $6 > $5 → finding. _seed(db_session, fp=_FP, bucket=BUCKET_SEATS, cost="1006") await db_session.flush() with patch.object( AiSpendingService, "_compute_period_rows", AsyncMock(side_effect=_live_only_for_closed( [BucketRow(bucket=BUCKET_SEATS, date=_CLOSED_START, cost_usd=Decimal("1000"))], )), ): out = await cost_rollup_drift(db_session, months_back=3) findings = [f for f in out["findings"] if f["period"] == _CLOSED_PERIOD] assert len(findings) == 1 assert findings[0]["likely_cause"] == "late_facts_or_drift" @pytest.mark.asyncio async def test_open_month_excluded(db_session): # Seed the OPEN (current) month with a wild rollup value and have live # return something else; the open month must NOT be reconciled. open_start, open_end = _parse_period(_current_period()) db_session.add(AiSpendingPeriodRollup( period_start=open_start, period_end_excl=open_end, whitelist_fingerprint=_FP, bucket=BUCKET_BEDROCK, fact_date=open_start, cost_usd=Decimal("9999"), )) await db_session.flush() def _side_effect(start: date, end: date) -> list[BucketRow]: if start == open_start: return [_bedrock("1")] return [] with patch.object( AiSpendingService, "_compute_period_rows", AsyncMock(side_effect=_side_effect), ): out = await cost_rollup_drift(db_session, months_back=3) assert out["findings"] == [] assert _current_period() not in out["periods_checked"] # ── Cron handler ────────────────────────────────────────────────── _DRIFTED = { "findings": [{ "period": _CLOSED_PERIOD, "bucket": BUCKET_BEDROCK, "rollup_usd": 100.0, "live_usd": 200.0, "diff_usd": 100.0, "diff_pct": 50.0, "likely_cause": "late_facts_or_drift", }], "periods_checked": [_CLOSED_PERIOD], "months_back": 3, } _CLEAN = { "findings": [], "periods_checked": [_CLOSED_PERIOD], "months_back": 3, } @pytest.mark.asyncio async def test_cron_handler_raises_when_drift_survives_heal(db_session): """A finding that persists AFTER the auto re-materialisation must RAISE so the failure_notifier alarms (returning status='failed' would be swallowed as ok — memory: handlers must raise).""" from app.scheduled_jobs.ai_spending_handlers import ( run_ai_spending_cost_drift_check, ) refresh = AsyncMock(return_value=0) with ( patch( "app.domains.ai_spending.cost_drift.cost_rollup_drift", AsyncMock(return_value=_DRIFTED), ), patch.object(AiSpendingService, "refresh_period_rollup", refresh), ): with pytest.raises(RuntimeError, match="drift over threshold"): await run_ai_spending_cost_drift_check(db_session, {}) # The heal was attempted on the drifted window before raising. refresh.assert_awaited_once_with(_CLOSED_START, _CLOSED_END) @pytest.mark.asyncio async def test_cron_handler_auto_heals_late_facts(db_session): """Late-facts drift (e.g. an out-of-band backfill restating a month older than the refresh cron's recent_months window — the 2026-07-28 incident) is healed by re-materialising the window; the handler returns ok with the healed period recorded instead of alarming forever.""" from app.scheduled_jobs.ai_spending_handlers import ( run_ai_spending_cost_drift_check, ) refresh = AsyncMock(return_value=7) with ( patch( "app.domains.ai_spending.cost_drift.cost_rollup_drift", AsyncMock(side_effect=[_DRIFTED, _CLEAN]), ), patch.object(AiSpendingService, "refresh_period_rollup", refresh), ): res = await run_ai_spending_cost_drift_check(db_session, {}) refresh.assert_awaited_once_with(_CLOSED_START, _CLOSED_END) assert res["drift_findings"] == 0 assert res["drift_findings_before_heal"] == 1 assert res["auto_healed_periods"] == [_CLOSED_PERIOD] @pytest.mark.asyncio async def test_cron_handler_clean_returns_counts(db_session): from app.scheduled_jobs.ai_spending_handlers import ( run_ai_spending_cost_drift_check, ) refresh = AsyncMock(return_value=0) with ( patch( "app.domains.ai_spending.cost_drift.cost_rollup_drift", AsyncMock(return_value=_CLEAN), ), patch.object(AiSpendingService, "refresh_period_rollup", refresh), ): res = await run_ai_spending_cost_drift_check(db_session, {}) assert res["drift_findings"] == 0 assert res["periods_checked"] == [_CLOSED_PERIOD] # No drift → no heal writes. refresh.assert_not_awaited() assert res["auto_healed_periods"] == [] @pytest.mark.asyncio async def test_overview_includes_cost_lens(db_session): from app.domains.reconciliation.service import get_reconciliation_overview with patch.object( AiSpendingService, "_compute_period_rows", AsyncMock(side_effect=_live_only_for_closed([])), ): out = await get_reconciliation_overview(db_session) assert "cost" in out # Lens ran cleanly (not the inline error capsule). assert "findings" in out["cost"]