"""「$0 是真的 0,还是我们没取到」—— 覆盖判定的规则与端点契约。 两层: * **纯函数层**(表驱动)—— :func:`classify_coverage` 的五个状态各自的 触发条件与边界(尾部容差、连续性阈值、集成滞后优先级)。不碰 DB, 所以边界可以密集覆盖。 * **端点层**(真 MySQL)—— 抽屉响应里的 ``coverage`` 块。核心用例是 2026-08-14 生产事故的形状:同一个集成下另一个订阅一路同步到今天, 而本订阅的账单停在 25 天前 ⇒ 必须判 ``gap``,不能判「本月无花费」。 为什么端点层不能只测纯函数:判定的输入来自四个查询,而这四个查询必须 和页面上那个数字问的是同一批行(同一个 ``fact_where`` / ``amount_col``)。 喂给纯函数手工造的信号,恰好会把「取信号取错了」这类缺陷全部放过 —— 所以 gap 用例的 fact 行是真写进库的。 """ from __future__ import annotations import hashlib import uuid from datetime import UTC, date, datetime, timedelta from decimal import Decimal import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.cost_coverage import ( COVERAGE_COMPLETE, COVERAGE_GAP, COVERAGE_INDETERMINATE, COVERAGE_NO_HISTORY, COVERAGE_PIPELINE_STALE, CoverageSignals, classify_coverage, ) from app.domains.cmdb.models import ( SCOPE_STATUS_ACTIVE, CmdbCloudScope, CmdbCostFact, ) from app.integrations.models import Integration # ── 纯函数层 ──────────────────────────────────────────────────────── _M_START = date(2026, 9, 1) _M_END = date(2026, 9, 7) # 当月已 clamp 到今天 def _signals(**over) -> CoverageSignals: base = { "month_start": _M_START, "month_end": _M_END, "integration_last_fact": date(2026, 9, 5), "scope_last_fact_in_window": date(2026, 9, 5), "scope_last_fact_anchor": date(2026, 9, 5), "continuity_days": 14, "prior_daily_avg": Decimal("100"), } base.update(over) return CoverageSignals(**base) def test_scope_keeping_up_with_its_cloud_is_complete(): v = classify_coverage(_signals()) assert v.status == COVERAGE_COMPLETE assert v.covered_through == date(2026, 9, 5) assert v.expected_through == date(2026, 9, 5) assert v.missing_days == 0 # 估算金额只在 gap 上给 —— 完整时给一个「缺 $0」会被读成一个结论。 assert v.estimated_missing is None assert v.is_trustworthy def test_one_day_behind_is_normal_settlement_lag_not_a_gap(): """厂商 T+1 结算天然会让个别订阅晚一天。容差内不报 —— 否则每天 早上全平台一半订阅标红,gap 这个信号立刻失去意义。""" v = classify_coverage(_signals( scope_last_fact_in_window=date(2026, 9, 4), scope_last_fact_anchor=date(2026, 9, 4), )) assert v.status == COVERAGE_COMPLETE def test_two_days_behind_a_current_cloud_is_a_gap(): v = classify_coverage(_signals( scope_last_fact_in_window=date(2026, 9, 3), scope_last_fact_anchor=date(2026, 9, 3), )) assert v.status == COVERAGE_GAP # 9/04 与 9/05 两天。 assert v.missing_days == 2 assert v.estimated_missing == Decimal("200") def test_no_rows_this_month_but_had_rows_before_is_a_gap(): """生产事故的形状:账单停在上个月,而同云链路还在推进。""" v = classify_coverage(_signals( scope_last_fact_in_window=None, scope_last_fact_anchor=date(2026, 8, 13), )) assert v.status == COVERAGE_GAP assert v.covered_through is None # 缺口按窗口算:9/01–9/05 五天。 assert v.missing_days == 5 assert v.estimated_missing == Decimal("500") assert not v.is_trustworthy def test_sporadic_biller_that_stopped_is_indeterminate_not_a_gap(): """只在跑批时才计费的订阅,缺几天是常态。没有证据说它坏了,就 别说它坏了 —— 这条弃权是有意的,别为了「多报一点」把它删掉。""" v = classify_coverage(_signals( scope_last_fact_in_window=None, scope_last_fact_anchor=date(2026, 8, 13), continuity_days=3, )) assert v.status == COVERAGE_INDETERMINATE assert v.missing_days == 5 # 天数照算,只是不下缺口结论 assert v.estimated_missing is None assert v.is_trustworthy def test_continuity_threshold_boundary(): """12/14 算连续,11/14 不算。""" assert classify_coverage(_signals( scope_last_fact_in_window=None, scope_last_fact_anchor=date(2026, 8, 13), continuity_days=12, )).status == COVERAGE_GAP assert classify_coverage(_signals( scope_last_fact_in_window=None, scope_last_fact_anchor=date(2026, 8, 13), continuity_days=11, )).status == COVERAGE_INDETERMINATE def test_whole_cloud_stale_beats_per_scope_gap(): """一个云整体断流时,它下面每个订阅都「落后」了 —— 但那不是 每个订阅各自的缺口。先判集成,否则一次链路故障会同时标红几十个 订阅,读者无法看出真正的单点在哪。""" v = classify_coverage(_signals( integration_last_fact=date(2026, 8, 20), scope_last_fact_in_window=None, scope_last_fact_anchor=date(2026, 8, 20), )) assert v.status == COVERAGE_PIPELINE_STALE assert v.expected_through == date(2026, 8, 20) def test_cloud_with_no_facts_at_all_is_pipeline_stale(): v = classify_coverage(_signals( integration_last_fact=None, scope_last_fact_in_window=None, scope_last_fact_anchor=None, )) assert v.status == COVERAGE_PIPELINE_STALE def test_scope_that_never_billed_is_no_history(): v = classify_coverage(_signals( scope_last_fact_in_window=None, scope_last_fact_anchor=None, continuity_days=0, prior_daily_avg=None, )) assert v.status == COVERAGE_NO_HISTORY assert v.is_trustworthy def test_settled_past_month_truncated_mid_month_is_a_gap(): """往月的月末不再 clamp:8 月停在 8/13 ⇒ 缺 8/14–8/31 十八天。 这一格守的是「页面显示 $1,700 而真实是整月」那种静默错数 —— 比 $0 更危险,因为它看起来像个正常数字。""" v = classify_coverage(CoverageSignals( month_start=date(2026, 8, 1), month_end=date(2026, 8, 31), integration_last_fact=date(2026, 9, 5), scope_last_fact_in_window=date(2026, 8, 13), scope_last_fact_anchor=date(2026, 9, 5), continuity_days=14, prior_daily_avg=Decimal("122.5"), )) assert v.status == COVERAGE_GAP assert v.missing_days == 18 assert v.expected_through == date(2026, 8, 31) def test_rg_scope_without_routing_abstains_instead_of_claiming_complete(): """``fact_where`` 空(RG 型 scope 缺 rg_name)时取不到任何行。 调用方会喂进全 None 的信号 —— 判定必须落在 pipeline_stale/no_history 这类弃权状态上,绝不能因为「没查到落后」而报 complete。""" v = classify_coverage(_signals( integration_last_fact=None, scope_last_fact_in_window=None, scope_last_fact_anchor=None, continuity_days=0, prior_daily_avg=None, )) assert v.status != COVERAGE_COMPLETE # ── 端点层(真 DB)───────────────────────────────────────────────── def _now_naive() -> datetime: return datetime.now(UTC).replace(tzinfo=None) async def _mk_integ(db: AsyncSession) -> Integration: row = Integration( id=uuid.uuid4(), kind="cloud", provider="gcp", name=f"gcp-{uuid.uuid4().hex[:4]}", slug=f"gcp-{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) -> CmdbCloudScope: s = CmdbCloudScope( id=uuid.uuid4(), integration_id=str(integ.id), scope_type="gcp_project", external_id=uuid.uuid4().hex[:16], display_name=f"proj-{uuid.uuid4().hex[:6]}", status=SCOPE_STATUS_ACTIVE, environment="dev", budget_usd_monthly=Decimal("300"), first_seen_at=_now_naive(), last_synced_at=_now_naive(), ) db.add(s) await db.commit() return s async def _mk_facts( db: AsyncSession, *, integ_id: str, scope_id: str | None, days: list[date], amount: str = "10.00", ) -> None: for d in days: rid = f"res-{uuid.uuid4().hex[:8]}" raw = f"cov:{integ_id}:{scope_id}:{d}:{rid}" db.add(CmdbCostFact( fact_key=hashlib.sha256(raw.encode()).hexdigest(), date=d, integration_id=integ_id, scope_id=scope_id, resource_id=rid, resource_name=rid, resource_type="instance", service="Compute Engine", region="us-west2", amount_usd=Decimal(amount), amount_native=Decimal(amount), currency="USD", )) await db.commit() def _span(first: date, last: date) -> list[date]: out, cur = [], first while cur <= last: out.append(cur) cur += timedelta(days=1) return out async def _coverage(client, token, scope_id, month: str | None = None): url = f"/api/v1/cmdb/scopes/{scope_id}/billing-detail" if month: url += f"?month={month}" resp = await client.get(url, headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200, resp.text return resp.json()["data"] @pytest.mark.asyncio async def test_endpoint_flags_a_scope_whose_billing_stopped_while_its_cloud_kept_going( client, admin_user, db_session, ): """2026-08-14 生产事故的回归:三个 GCP 项目被挪到新账单账号, Helix 的导出表配置里没有它 ⇒ 25 天 `$0.00`,零告警。 查的是**上一个自然月**,不是当月 —— 上月恒有 28+ 天且已结算, 用例因此在任何一天跑都一样;锚在当月会在每月 1 号翻车 (``month_start == today``,链路最后一天落在月前 ⇒ 判 pipeline_stale)。 这也正是生产上 8 月那一格的形状:显示 $1,700 而真实是整月。 """ _, token = admin_user today = datetime.now(UTC).date() prev_end = today.replace(day=1) - timedelta(days=1) prev_start = prev_end.replace(day=1) prev_month = f"{prev_start.year:04d}-{prev_start.month:02d}" integ = await _mk_integ(db_session) stalled = await _mk_scope(db_session, integ) healthy = await _mk_scope(db_session, integ) # 断流的订阅:上月 1–14 号每天都有账单,14 号之后没有。 stalled_last = prev_start + timedelta(days=13) await _mk_facts( db_session, integ_id=str(integ.id), scope_id=str(stalled.id), days=_span(prev_start, stalled_last), ) # 同一个云的另一个订阅一路同步到今天 —— 这是「链路还活着」的证据。 await _mk_facts( db_session, integ_id=str(integ.id), scope_id=str(healthy.id), days=_span(prev_start, today), ) cov = (await _coverage(client, token, stalled.id, prev_month))["coverage"] assert cov["status"] == COVERAGE_GAP, cov assert cov["covered_through"] == stalled_last.isoformat() # 往月不 clamp:该有数据到月末。 assert cov["expected_through"] == prev_end.isoformat() assert cov["missing_days"] == (prev_end - stalled_last).days assert cov["prior_daily_avg_usd"] == pytest.approx(10.0) assert cov["estimated_missing_usd"] == pytest.approx( 10.0 * cov["missing_days"], ) assert cov["measured_on"] == "cost_facts" # 同一次事故里,健康的那个订阅必须仍然是 complete —— 否则一个坏 # 订阅会把同云所有订阅一起标红。 healthy_cov = ( await _coverage(client, token, healthy.id, prev_month) )["coverage"] assert healthy_cov["status"] == COVERAGE_COMPLETE, healthy_cov @pytest.mark.asyncio async def test_endpoint_calls_a_genuine_zero_complete( client, admin_user, db_session, ): """真的没花钱:每天都有账单行,金额是 0。这个 $0 可以直接用, 不该带任何警示 —— 把两种 0 都标红等于没标。""" _, token = admin_user today = datetime.now(UTC).date() month_start = today.replace(day=1) integ = await _mk_integ(db_session) scope = await _mk_scope(db_session, integ) await _mk_facts( db_session, integ_id=str(integ.id), scope_id=str(scope.id), days=_span(month_start - timedelta(days=20), today), amount="0.00", ) data = await _coverage(client, token, scope.id) assert data["summary"]["total_usd"] == pytest.approx(0.0) assert data["coverage"]["status"] == COVERAGE_COMPLETE, data["coverage"] assert data["coverage"]["estimated_missing_usd"] is None @pytest.mark.asyncio async def test_endpoint_reports_no_history_for_a_never_billed_scope( client, admin_user, db_session, ): _, token = admin_user today = datetime.now(UTC).date() integ = await _mk_integ(db_session) other = await _mk_scope(db_session, integ) fresh = await _mk_scope(db_session, integ) # 集成本身有数据(否则会先判 pipeline_stale),但这个订阅一条都没有。 # 用 ``today`` 而不是昨天:每月 1 号昨天落在上个月,链路最后一天 # 就会早于所查月份,先判 pipeline_stale,这一格会随日期偶发翻红。 await _mk_facts( db_session, integ_id=str(integ.id), scope_id=str(other.id), days=[today], ) cov = (await _coverage(client, token, fresh.id))["coverage"] assert cov["status"] == COVERAGE_NO_HISTORY, cov @pytest.mark.asyncio async def test_endpoint_does_not_invent_a_gap_for_a_month_before_the_scope_existed( client, admin_user, db_session, ): """一个这个月才开始计费的订阅,查**上个月**必须落 no_history。 连续性锚点如果不以窗口末尾为上界,它会拿到本月的日期、把上月判成 「缺了整月」—— 一个当时还不存在的订阅的假缺口。Round-2 自审抓到的。 """ _, token = admin_user today = datetime.now(UTC).date() month_start = today.replace(day=1) prev_start = (month_start - timedelta(days=1)).replace(day=1) prev_month = f"{prev_start.year:04d}-{prev_start.month:02d}" integ = await _mk_integ(db_session) newborn = await _mk_scope(db_session, integ) # 只有本月的账单,而且天天都有(足以判成连续计费型)。 await _mk_facts( db_session, integ_id=str(integ.id), scope_id=str(newborn.id), days=_span(month_start, today), ) cov = (await _coverage(client, token, newborn.id, prev_month))["coverage"] assert cov["status"] == COVERAGE_NO_HISTORY, cov assert cov["estimated_missing_usd"] is None # 本月自己仍然是 complete。 cur = (await _coverage(client, token, newborn.id))["coverage"] assert cur["status"] == COVERAGE_COMPLETE, cur @pytest.mark.asyncio async def test_endpoint_reports_pipeline_stale_when_the_whole_cloud_is_behind( client, admin_user, db_session, ): _, token = admin_user today = datetime.now(UTC).date() month_start = today.replace(day=1) integ = await _mk_integ(db_session) scope = await _mk_scope(db_session, integ) # 整个集成的最后一条 fact 落在所查月份之前。 await _mk_facts( db_session, integ_id=str(integ.id), scope_id=str(scope.id), days=[month_start - timedelta(days=3)], ) cov = (await _coverage(client, token, scope.id))["coverage"] assert cov["status"] == COVERAGE_PIPELINE_STALE, cov