"""Unit tests for the dimension-aware trend + YoY breakdown logic. These exercise the pure transformation inside ``get_trend`` / ``get_breakdown`` by mocking the two async DB reads (``_collect_period_rows`` + ``_get_cny_rate``) — same pattern as ``test_period_rows_cache.py`` — so no MySQL is needed. Covered: * trend ``dimension='bucket'`` keeps the legacy ``by_bucket`` payload (the KPI/per-bucket sparklines read it) and leaves ``by_series`` empty. * trend ``dimension='service'`` folds to top-8 + ``__other__``, leaves ``by_bucket`` empty, and emits a stable ``series_order`` with the sentinel last. * trend rows with no value for the dimension fold into ``__none__``. * breakdown rows carry a YoY delta when the key existed a year ago and ``None`` when it's new. """ 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.service import ( BUCKET_BAILIAN, BUCKET_FOUNDRY, BUCKET_SEATS, AiSpendingService, BucketRow, ) pytestmark = pytest.mark.asyncio def _svc() -> AiSpendingService: return AiSpendingService(db=AsyncMock()) def _patch_reads(rows_or_sideeffect, *, rate: float = 0.1): """Patch the two async DB reads. Pass a list (fixed rows) or a callable side_effect ``(period_start, period_end_excl, bu=None)``.""" if callable(rows_or_sideeffect): collect = AsyncMock(side_effect=rows_or_sideeffect) else: collect = AsyncMock(return_value=rows_or_sideeffect) return ( patch.object(AiSpendingService, "_collect_period_rows", new=collect), patch.object( AiSpendingService, "_get_cny_rate", new=AsyncMock(return_value=rate), ), ) class TestTrendDimension: async def test_bucket_dimension_keeps_by_bucket(self) -> None: d = date(2026, 5, 1) rows = [ BucketRow(bucket=BUCKET_SEATS, date=d, cost_usd=Decimal("10")), BucketRow(bucket=BUCKET_BAILIAN, date=d, cost_usd=Decimal("30")), ] p_collect, p_rate = _patch_reads(rows) with p_collect, p_rate: trend = await _svc().get_trend( granularity="monthly", lookback_days=90, dimension="bucket", ) assert trend.dimension == "bucket" pt = trend.points[-1] # Legacy payload intact; by_series stays empty for bucket. assert pt.by_bucket == {BUCKET_SEATS: 10.0, BUCKET_BAILIAN: 30.0} assert pt.by_series == {} async def test_service_dimension_folds_top_n(self) -> None: # 10 distinct services, decreasing cost → top-8 kept, 2 folded. d = date(2026, 5, 1) rows = [ BucketRow( bucket=BUCKET_FOUNDRY, date=d, cost_usd=Decimal(str(100 - i)), service_name=f"svc-{i:02d}", ) for i in range(10) ] p_collect, p_rate = _patch_reads(rows) with p_collect, p_rate: trend = await _svc().get_trend( granularity="monthly", lookback_days=90, dimension="service", ) assert trend.dimension == "service" pt = trend.points[-1] assert pt.by_bucket == {} # not the bucket dimension # Top-8 named series + the folded sentinel. assert "__other__" in pt.by_series named = [k for k in pt.by_series if k != "__other__"] assert len(named) == 8 assert "svc-00" in named and "svc-09" not in named # __other__ = svc-08 (92) + svc-09 (91) = 183. assert pt.by_series["__other__"] == pytest.approx(183.0) # Stable order: top first, sentinel last. assert trend.series_order[0] == "svc-00" assert trend.series_order[-1] == "__other__" assert trend.series_labels["svc-00"] == "svc-00" assert trend.series_labels["__other__"] == "" # FE localises async def test_missing_dimension_value_folds_to_none(self) -> None: d = date(2026, 5, 1) rows = [ BucketRow( bucket=BUCKET_FOUNDRY, date=d, cost_usd=Decimal("40"), service_name="Azure OpenAI", ), # Seats have no service_name → __none__ series. BucketRow(bucket=BUCKET_SEATS, date=d, cost_usd=Decimal("60")), ] p_collect, p_rate = _patch_reads(rows) with p_collect, p_rate: trend = await _svc().get_trend( granularity="monthly", lookback_days=90, dimension="service", ) pt = trend.points[-1] assert pt.by_series["Azure OpenAI"] == pytest.approx(40.0) assert pt.by_series["__none__"] == pytest.approx(60.0) async def test_invalid_dimension_rejected(self) -> None: from app.core.exceptions import AppException p_collect, p_rate = _patch_reads([]) with p_collect, p_rate, pytest.raises(AppException): await _svc().get_trend( granularity="monthly", lookback_days=90, dimension="user", ) class TestBreakdownYoY: async def test_yoy_delta_and_new_key(self) -> None: cur = [ BucketRow( bucket=BUCKET_FOUNDRY, date=date(2026, 5, 1), cost_usd=Decimal("150"), service_name="Azure OpenAI", ), BucketRow( bucket=BUCKET_FOUNDRY, date=date(2026, 5, 1), cost_usd=Decimal("50"), service_name="New Service", ), ] yoy = [ BucketRow( bucket=BUCKET_FOUNDRY, date=date(2025, 5, 1), cost_usd=Decimal("100"), service_name="Azure OpenAI", ), ] def side_effect(period_start, period_end_excl, *, bu=None): return yoy if period_start.year == 2025 else cur p_collect, p_rate = _patch_reads(side_effect) with p_collect, p_rate: bd = await _svc().get_breakdown( period="2026-05", dimension="service", ) by_key = {r.dimension_key: r for r in bd.rows} # Azure OpenAI: 150 vs 100 last year → +50%. azure = by_key["Azure OpenAI"] assert azure.yoy_amount is not None assert azure.yoy_amount.usd == Decimal("100.00") assert azure.yoy_change_pct == pytest.approx(50.0) # New Service: no row a year ago → null YoY (not 0, not infinity). new = by_key["New Service"] assert new.yoy_amount is None assert new.yoy_change_pct is None