"""Fallback to ``cmdb_cloud_billing_daily`` when ``cmdb_cost_facts`` is empty. Bug #3: the daily-spike alert reads ``cmdb_cloud_billing_daily`` while the ``/cmdb/scopes/{id}/billing-detail`` page reads ``cmdb_cost_facts``. The two are populated by independent cron jobs, so they can disagree — the alert can fire "MTD $5,505.80" while the page shows "本月花费 $0.00" for the same scope+month. These tests pin the fallback contract in ``get_scope_billing_detail``: * cost_facts populated → ``data_source == "cost_facts"`` (no fallback). * cost_facts empty + billing_daily populated for a tenant-level scope → ``data_source == "billing_daily_fallback"``, ``total_usd`` matches the billing_daily sum, ``data_source_note`` carries the operator-facing caveat. * both empty → ``data_source == "cost_facts"`` (nothing to fall back to; payload remains ``$0`` rather than asserting fallback mode over a still-empty result). * RG-typed scope (``azure_resource_group``) → no fallback even when billing_daily has matching rows, because billing_daily is keyed by (integration, scope_id) and cannot answer the RG branch's (integration, resource_group) query. Hits the real test MySQL via the ``db_session`` fixture so the billing_daily aggregate query exercises the actual unique key / indexes. """ from __future__ import annotations import uuid from datetime import UTC, date, datetime from decimal import Decimal import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.models import ( SCOPE_STATUS_ACTIVE, SCOPE_TYPE_AZURE_RESOURCE_GROUP, CmdbCloudBillingDaily, CmdbCloudScope, CmdbCostFact, ) from app.domains.cmdb.scope_billing_detail import get_scope_billing_detail from app.integrations.models import Integration def _now_naive() -> datetime: return datetime.now(UTC).replace(tzinfo=None) async def _mk_integ(db: AsyncSession, provider: str = "aws") -> Integration: row = Integration( id=uuid.uuid4(), kind="cloud", provider=provider, name=f"{provider}-{uuid.uuid4().hex[:4]}", slug=f"{provider}-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=["servers"], ) db.add(row) await db.commit() return row async def _mk_scope( db: AsyncSession, integ: Integration, *, scope_type: str | None = None, external_id: str | None = None, ) -> CmdbCloudScope: s = CmdbCloudScope( id=uuid.uuid4(), integration_id=str(integ.id), scope_type=scope_type or f"{integ.provider}_account", external_id=external_id or uuid.uuid4().hex[:16], display_name=f"scope-{uuid.uuid4().hex[:6]}", status=SCOPE_STATUS_ACTIVE, environment="dev", budget_usd_monthly=Decimal("1000"), first_seen_at=_now_naive(), last_synced_at=_now_naive(), ) db.add(s) await db.commit() return s async def _mk_fact( db: AsyncSession, *, integ_id: str, scope_id: str | None, fact_date: date, service: str = "ECS", amount: str = "10.00", ) -> CmdbCostFact: import hashlib rid = f"res-{uuid.uuid4().hex[:8]}" raw = f"test:{integ_id}:{scope_id}:{fact_date}:{rid}:{service}" fact_key = hashlib.sha256(raw.encode()).hexdigest() f = CmdbCostFact( fact_key=fact_key, date=fact_date, integration_id=integ_id, scope_id=scope_id, resource_id=rid, resource_name=f"name-{rid}", resource_type="instance", service=service, region="us-east-1", amount_usd=Decimal(amount), amount_native=Decimal(amount), currency="USD", ) db.add(f) await db.commit() return f async def _mk_billing_daily( db: AsyncSession, *, integ_id: str, scope_id: str | None, row_date: date, amount: str = "100.00", currency: str = "USD", ) -> CmdbCloudBillingDaily: row = CmdbCloudBillingDaily( id=uuid.uuid4(), integration_id=integ_id, scope_id=scope_id, linked_account_id="", date=row_date, total_cost_usd=Decimal(amount), currency=currency, ) db.add(row) await db.commit() return row def _month_str() -> str: today = datetime.now(UTC).date() return f"{today.year:04d}-{today.month:02d}" # ── Tests ────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_cost_facts_populated_no_fallback(db_session: AsyncSession): """cost_facts has rows → data_source is ``cost_facts``; the billing_daily fallback never fires even though it has data too.""" integ = await _mk_integ(db_session) scope = await _mk_scope(db_session, integ) today = datetime.now(UTC).date() month_start = today.replace(day=1) await _mk_fact( db_session, integ_id=str(integ.id), scope_id=str(scope.id), fact_date=month_start, amount="13.00", ) # Plant a billing_daily row with a clearly different sum — if the # fallback fires by mistake, total_usd would be 999.00, not 13.00. await _mk_billing_daily( db_session, integ_id=str(integ.id), scope_id=str(scope.id), row_date=month_start, amount="999.00", ) payload = await get_scope_billing_detail( db_session, scope=scope, month=_month_str(), redact_member_roles=False, ) assert payload["summary"]["data_source"] == "cost_facts" assert payload["summary"]["data_source_note"] is None assert payload["summary"]["currency"] == "USD" assert pytest.approx(payload["summary"]["total_usd"]) == 13.0 @pytest.mark.asyncio async def test_cost_facts_empty_billing_daily_fallback(db_session: AsyncSession): """cost_facts empty + billing_daily populated → fallback fires. Sum + daily_trend reflect billing_daily, and ``data_source`` flips to ``billing_daily_fallback`` with the operator-facing note set so the frontend can banner the precision caveat. """ integ = await _mk_integ(db_session) scope = await _mk_scope(db_session, integ) today = datetime.now(UTC).date() month_start = today.replace(day=1) await _mk_billing_daily( db_session, integ_id=str(integ.id), scope_id=str(scope.id), row_date=month_start, amount="5505.80", ) payload = await get_scope_billing_detail( db_session, scope=scope, month=_month_str(), redact_member_roles=False, ) assert payload["summary"]["data_source"] == "billing_daily_fallback" assert payload["summary"]["data_source_note"] is not None assert "汇总" in payload["summary"]["data_source_note"] assert payload["summary"]["currency"] == "USD" assert pytest.approx(payload["summary"]["total_usd"]) == 5505.80 # Budget % should be computed off the fallback total, not the # pre-fallback zero — budget is $1000, total is $5505.80, so % is # ~550.58. assert payload["summary"]["budget_progress_pct"] is not None assert pytest.approx( payload["summary"]["budget_progress_pct"], rel=1e-3, ) == 550.58 # Daily-trend has one non-zero day matching the billing_daily row. nonzero = [d for d in payload["daily_trend"] if d["usd"] > 0] assert len(nonzero) == 1 assert pytest.approx(nonzero[0]["usd"]) == 5505.80 # billing_daily has no service/resource decomposition, so those # arrays stay empty in fallback mode. assert payload["cost_by_service"] == [] assert payload["top_resources"] == [] @pytest.mark.asyncio async def test_both_empty_no_fallback(db_session: AsyncSession): """No data in either table → data_source stays ``cost_facts`` (nothing to fall back to; we don't lie about coverage).""" integ = await _mk_integ(db_session) scope = await _mk_scope(db_session, integ) payload = await get_scope_billing_detail( db_session, scope=scope, month=_month_str(), redact_member_roles=False, ) assert payload["summary"]["data_source"] == "cost_facts" assert payload["summary"]["data_source_note"] is None assert pytest.approx(payload["summary"]["total_usd"]) == 0.0 assert payload["summary"]["data_available"] is False @pytest.mark.asyncio async def test_rg_scope_no_fallback_even_with_billing_daily( db_session: AsyncSession, ): """RG-typed scopes (azure_resource_group) skip the fallback. billing_daily is keyed by (integration, scope_id), which can't answer the RG branch's (integration, resource_group) query — a fallback here would either return zero (no rows match scope_id) or worse, return rows from an unrelated parent subscription scope. """ integ = await _mk_integ(db_session, provider="azure") rg_name = f"rg-{uuid.uuid4().hex[:6]}" scope = await _mk_scope( db_session, integ, scope_type=SCOPE_TYPE_AZURE_RESOURCE_GROUP, external_id=rg_name, ) today = datetime.now(UTC).date() month_start = today.replace(day=1) # Plant a billing_daily row keyed on the RG scope's id — if the # fallback misbehaved it would still pick this up. await _mk_billing_daily( db_session, integ_id=str(integ.id), scope_id=str(scope.id), row_date=month_start, amount="777.77", ) payload = await get_scope_billing_detail( db_session, scope=scope, month=_month_str(), redact_member_roles=False, ) assert payload["routing"]["routing"] == "rg" assert payload["summary"]["data_source"] == "cost_facts" assert payload["summary"]["data_source_note"] is None assert pytest.approx(payload["summary"]["total_usd"]) == 0.0 async def _mk_fact_rid( db: AsyncSession, *, integ_id: str, scope_id: str | None, fact_date: date, resource_id: str, service: str = "Foundry Models", amount: str = "10.00", ) -> CmdbCostFact: """Like ``_mk_fact`` but with a caller-chosen ``resource_id`` so a test can plant the SAME resource across multiple months.""" import hashlib raw = f"test:{integ_id}:{scope_id}:{fact_date}:{resource_id}:{service}" fact_key = hashlib.sha256(raw.encode()).hexdigest() f = CmdbCostFact( fact_key=fact_key, date=fact_date, integration_id=integ_id, scope_id=scope_id, resource_id=resource_id, resource_name=resource_id, resource_type="Microsoft.CognitiveServices/accounts", service=service, region="eastus2", amount_usd=Decimal(amount), amount_native=Decimal(amount), currency="USD", ) db.add(f) await db.commit() return f def _prev_month_first(today: date) -> date: y, m = today.year, today.month m -= 1 if m == 0: m, y = 12, y - 1 return date(y, m, 1) @pytest.mark.asyncio async def test_resource_monthly_history_per_foundry(db_session: AsyncSession): """Each Foundry (distinct ``resource_id``) gets its own monthly series aligned to the payload's ``months`` array — this is the "how much did each Foundry spend each month" view. Two Foundry resources under one subscription; one spends across the current month AND the previous month, the other only in the current month. The payload must carry both as separate rows with the right per-month figures, and the current month's per-resource sum must reconcile with monthly_history. """ integ = await _mk_integ(db_session, provider="azure") scope = await _mk_scope(db_session, integ) today = datetime.now(UTC).date() cur_first = today.replace(day=1) prev_first = _prev_month_first(today) await _mk_fact_rid( db_session, integ_id=str(integ.id), scope_id=str(scope.id), fact_date=cur_first, resource_id="tidbdocstrans-resource", amount="30.00", ) await _mk_fact_rid( db_session, integ_id=str(integ.id), scope_id=str(scope.id), fact_date=prev_first, resource_id="tidbdocstrans-resource", amount="20.00", ) await _mk_fact_rid( db_session, integ_id=str(integ.id), scope_id=str(scope.id), fact_date=cur_first, resource_id="tidbclaw-resource", amount="12.00", ) payload = await get_scope_billing_detail( db_session, scope=scope, month=_month_str(), redact_member_roles=False, ) rmh = payload["resource_monthly_history"] months = rmh["months"] assert len(months) == 6 cur_key = f"{today.year:04d}-{today.month:02d}" prev_key = f"{prev_first.year:04d}-{prev_first.month:02d}" assert months[-1] == cur_key assert prev_key in months by_name = {r["resource_name"]: r for r in rmh["resources"]} assert "tidbdocstrans-resource" in by_name assert "tidbclaw-resource" in by_name docs = by_name["tidbdocstrans-resource"] assert docs["total_usd"] == pytest.approx(50.0) assert docs["monthly"][months.index(cur_key)] == pytest.approx(30.0) assert docs["monthly"][months.index(prev_key)] == pytest.approx(20.0) claw = by_name["tidbclaw-resource"] assert claw["total_usd"] == pytest.approx(12.0) assert claw["monthly"][months.index(cur_key)] == pytest.approx(12.0) assert claw["monthly"][months.index(prev_key)] == pytest.approx(0.0) # Current-month per-resource column reconciles with the subscription # total for that month (30 + 12 = 42). cur_idx = months.index(cur_key) col_sum = sum(r["monthly"][cur_idx] for r in rmh["resources"]) assert col_sum == pytest.approx(42.0)