"""Regression tests for AI seat-plan fingerprinting in the period rollup. The seats bucket is NOT frozen: ``_bucket_seats`` reads each AI-category plan's ``price`` / ``currency`` / ``billing_cycle`` live and converts to USD on every recompute. So an admin edit to one of those fields changes what a live recompute returns for every month the plan's seats span — closed months included. Before ``_seat_plan_fingerprint`` was folded into ``_rollup_fingerprint``, such an edit left the materialisation untouched: the dashboard kept serving the pre-edit seat USD until the refresh cron happened to rebuild that window for an unrelated reason. That is exactly how an OpenAI Team plan mis-entered as ``20 CNY`` (instead of ``20 USD``) showed AI-seat spend at ~1/6.76 of reality for a closed month and survived a data fix because the fingerprint never changed. These tests pin: a seat-plan price/currency/cycle edit flips the rollup fingerprint (→ old rows become a read miss → live fallback + cron rebuild), and an edit to a non-AI plan does not. """ from __future__ import annotations from decimal import Decimal from unittest.mock import AsyncMock, patch import pytest from app.domains.ai_spending.service import AiSpendingService from app.domains.asset.models import AssetSoftwareCatalog, SoftwareLicensePlan async def _make_plan( db, *, category: str, price: str, currency: str, cycle: str = "monthly", ) -> SoftwareLicensePlan: sw = AssetSoftwareCatalog(name=f"sw-{category}-{currency}", category=category) db.add(sw) await db.flush() plan = SoftwareLicensePlan( software_id=sw.id, name="plan", price=Decimal(price), billing_cycle=cycle, currency=currency, ) db.add(plan) await db.flush() return plan @pytest.mark.asyncio async def test_seat_plan_fingerprint_reflects_ai_plans(db_session): svc = AiSpendingService(db_session) assert await svc._seat_plan_fingerprint() == "" # no AI plans yet plan = await _make_plan( db_session, category="ai", price="20.00", currency="CNY", ) digest_cny = await svc._seat_plan_fingerprint() assert str(plan.id) in digest_cny assert "CNY" in digest_cny # The exact bug: currency CNY → USD must change the digest. plan.currency = "USD" await db_session.flush() digest_usd = await svc._seat_plan_fingerprint() assert digest_usd != digest_cny assert "USD" in digest_usd # Price and billing-cycle edits also move it. plan.price = Decimal("30.00") await db_session.flush() assert await svc._seat_plan_fingerprint() != digest_usd after_price = await svc._seat_plan_fingerprint() plan.billing_cycle = "yearly" await db_session.flush() assert await svc._seat_plan_fingerprint() != after_price @pytest.mark.asyncio async def test_non_ai_plan_does_not_affect_seat_fingerprint(db_session): svc = AiSpendingService(db_session) await _make_plan(db_session, category="ai", price="20.00", currency="USD") baseline = await svc._seat_plan_fingerprint() # A non-AI (e.g. "office") plan edit must not perturb the seat digest: # the seats bucket only sums category='ai' plans. office = await _make_plan( db_session, category="office", price="99.00", currency="USD", ) assert await svc._seat_plan_fingerprint() == baseline office.price = Decimal("199.00") await db_session.flush() assert await svc._seat_plan_fingerprint() == baseline @pytest.mark.asyncio async def test_rollup_fingerprint_invalidates_on_seat_plan_edit(db_session): """End-to-end: a seat-plan currency edit flips the full rollup fingerprint, so previously-materialised rows (tagged with the old fingerprint) read back as a miss → live recompute.""" svc = AiSpendingService(db_session) plan = await _make_plan( db_session, category="ai", price="20.00", currency="CNY", ) # Isolate the seat-plan input: pin whitelist + FX so only the plan # edit can move the hash. with patch( "app.domains.ai_spending.service.load_whitelist", AsyncMock(return_value={}), ), patch.object( AiSpendingService, "_get_cny_rate", AsyncMock(return_value=0.14782), ): fp_before = await svc._rollup_fingerprint() plan.currency = "USD" await db_session.flush() fp_after = await svc._rollup_fingerprint() assert fp_before != fp_after