"""Tencent billing is attributed by BILLING CYCLE, not 扣费时间 (PayTime). Root cause (prod, surfaced after the 2026-06-03 ``RealCost`` fix made amounts non-zero): the driver derived each ``DescribeBillDetail`` row's day from ``PayTime``. Tencent settles monthly charges (CDN / COS) on the **1st of the following month**, so a whole cycle's ¥5,390 CDN settlement (usage period = May, FeeBeginTime=2026-05-01) was *paid* 2026-06-01 and landed under June — 35× over-stating June vs the console's ¥161.50. The fix queries one cycle per month via the ``Month`` parameter (which matches the console 费用趋势/按计费周期 figure to the fen — verified in prod: ``Month=2026-05`` == ¥12,782.47) and attributes each row to its usage day (``FeeBeginTime``), clamped into the queried cycle so the per-day rollup always sums to that cycle's ``Month`` total. These tests pin: 1. ``_bill_cycle_months`` enumerates every cycle the window touches. 2. ``_bill_detail_usage_day`` prefers FeeBeginTime, clamps to the cycle, falls back to BillDay then PayTime. 3. A row whose ``PayTime`` is in the NEXT month but whose cycle is the queried month stays in its cycle (the exact prod bug). 4. Queries use ``req.Month`` (never BeginTime/EndTime). 5. A multi-cycle window pages each cycle once — no cross-cycle double count (the Month-keyed stub returns each cycle's rows exactly once). 6. cost_facts attributes by the same usage day + converts CNY→USD. """ # ruff: noqa: N802, N803, N815 from __future__ import annotations from datetime import date import pytest from app.integrations.driver_base import CloudScopeRef from app.integrations.drivers.tencent_cloud import ( TencentCloudDriver, _bill_cycle_months, _bill_detail_usage_day, ) _CFG = { "secret_id": "AK-test", "secret_key": "SK-test", "region": "ap-guangzhou", "account_mode": "iam", } def _driver() -> TencentCloudDriver: return TencentCloudDriver(integration_id="tc-test", config=_CFG) def _scope() -> CloudScopeRef: return CloudScopeRef( scope_type="standalone_iam", external_id="100002610019", display_name="tencent-test", ) class _Comp: def __init__(self, RealCost: str): self.RealCost = RealCost self.Cost = RealCost class _Row: """A ``DescribeBillDetail`` ``BillDetail`` row in its real shape: cost in ``ComponentSet``, usage period in ``FeeBeginTime``, payment in ``PayTime`` (which can fall in a later month for monthly settlements).""" def __init__( self, FeeBeginTime: str, real_cost: str, *, PayTime: str | None = None, BusinessCodeName: str = "对象存储 COS", ProductCodeName: str = "COS", ResourceId: str = "cos-1", RegionName: str = "广州", ActionType: str = "按量计费", ): self.FeeBeginTime = FeeBeginTime self.PayTime = PayTime or FeeBeginTime self.ComponentSet = [_Comp(real_cost)] self.BusinessCodeName = BusinessCodeName self.ProductCodeName = ProductCodeName self.ResourceId = ResourceId self.ResourceName = ResourceId self.RegionName = RegionName self.ActionType = ActionType self.ProjectName = "默认项目" self.Tags = [] class _Resp: def __init__(self, **fields): self.__dict__.update(fields) class _BillStub: """Mimics the SDK billing client, keyed by the ``Month`` parameter so each cycle's rows are returned exactly once (no cross-cycle bleed).""" def __init__(self, rows_by_month: dict[str, list[_Row]]): self._rows_by_month = rows_by_month self.queried_months: list[str] = [] def DescribeBillDetail(self, req): # The driver MUST query by Month, never BeginTime/EndTime. assert getattr(req, "Month", None), "driver must set req.Month" assert not getattr(req, "BeginTime", None), "must not set BeginTime" self.queried_months.append(req.Month) rows = self._rows_by_month.get(req.Month, []) return _Resp(DetailSet=rows, TotalCount=len(rows)) # ── helper units ───────────────────────────────────────────────────── def test_cycle_months_enumeration(): assert _bill_cycle_months(date(2026, 5, 20), date(2026, 5, 21)) == ["2026-05"] assert _bill_cycle_months(date(2025, 12, 30), date(2026, 1, 2)) == [ "2025-12", "2026-01", ] assert _bill_cycle_months(date(2025, 11, 1), date(2026, 2, 15)) == [ "2025-11", "2025-12", "2026-01", "2026-02", ] def test_usage_day_prefers_feebegin_and_clamps(): # FeeBeginTime wins. r = _Row("2026-05-14 00:00:00", "1.0", PayTime="2026-06-01 03:00:00") assert _bill_detail_usage_day(r, 2026, 5) == date(2026, 5, 14) # A monthly settlement (FeeBegin = cycle start) paid next month → cycle. r2 = _Row("2026-05-01 00:00:00", "1.0", PayTime="2026-06-01 03:00:00") assert _bill_detail_usage_day(r2, 2026, 5) == date(2026, 5, 1) # FeeBegin outside the queried cycle → clamp to cycle's first day. r3 = _Row("2026-04-28 00:00:00", "1.0") assert _bill_detail_usage_day(r3, 2026, 5) == date(2026, 5, 1) def test_usage_day_falls_back_to_paytime_when_no_feebegin(): class _Bare: PayTime = "2026-05-09 10:00:00" assert _bill_detail_usage_day(_Bare(), 2026, 5) == date(2026, 5, 9) # ── billing_daily: cycle attribution + the prod regression ─────────── @pytest.mark.asyncio async def test_billing_daily_settlement_stays_in_its_cycle(): """THE regression: a May-cycle settlement paid 2026-06-01 must roll up under May, not June.""" stub = _BillStub({ "2026-05": [ # daily COS usage _Row("2026-05-15 00:00:00", "100.00"), # monthly CDN settlement: usage May, PAID June 1 _Row("2026-05-01 00:00:00", "5390.00", PayTime="2026-06-01 03:33:01", BusinessCodeName="内容分发网络 CDN"), ], "2026-06": [ _Row("2026-06-02 00:00:00", "161.48"), ], }) driver = _driver() driver._billing_client = lambda: stub # type: ignore[method-assign] daily = await driver.list_billing_daily( date(2026, 5, 1), date(2026, 6, 2), scope=_scope(), ) by_day = {r.date: r.total_cost_usd for r in daily} # May owns the settlement; June only its own ¥161.48 — matches console. may_total = sum(v for d, v in by_day.items() if d.month == 5) jun_total = sum(v for d, v in by_day.items() if d.month == 6) assert may_total == pytest.approx(5490.00) # 100 + 5390 assert jun_total == pytest.approx(161.48) # The settlement landed on its cycle start, NOT on its PayTime (06-01). assert by_day[date(2026, 5, 1)] == pytest.approx(5390.00) assert date(2026, 6, 1) not in by_day # Queried by Month, one query per cycle. assert stub.queried_months == ["2026-05", "2026-06"] @pytest.mark.asyncio async def test_billing_daily_single_cycle_by_service(): stub = _BillStub({ "2026-05": [ _Row("2026-05-20 00:00:00", "3.00", BusinessCodeName="对象存储 COS"), _Row("2026-05-20 00:00:00", "2.00", BusinessCodeName="对象存储 COS"), _Row("2026-05-21 00:00:00", "10.00", BusinessCodeName="内容分发网络 CDN"), ], }) driver = _driver() driver._billing_client = lambda: stub # type: ignore[method-assign] daily = await driver.list_billing_daily( date(2026, 5, 20), date(2026, 5, 21), scope=_scope(), ) by_day = {r.date: r.total_cost_usd for r in daily} assert by_day == { date(2026, 5, 20): pytest.approx(5.00), date(2026, 5, 21): pytest.approx(10.00), } d20 = next(r for r in daily if r.date == date(2026, 5, 20)) assert d20.cost_by_service["对象存储 COS"] == pytest.approx(5.00) assert d20.currency == "CNY" # ── cost_facts: same attribution + currency conversion ─────────────── @pytest.mark.asyncio async def test_cost_facts_uses_cycle_usage_day_and_converts(): stub = _BillStub({ "2026-05": [ _Row("2026-05-01 00:00:00", "5390.00", PayTime="2026-06-01 03:33:01", BusinessCodeName="内容分发网络 CDN", ProductCodeName="CDN", ResourceId="cdn-1"), ], }) driver = _driver() driver._billing_client = lambda: stub # type: ignore[method-assign] facts = await driver.list_cost_facts( date(2026, 5, 1), date(2026, 5, 31), scope=_scope(), cny_per_usd=7.0, ) assert len(facts) == 1 f = facts[0] assert f.date == date(2026, 5, 1) # cycle usage day, not 06-01 assert f.amount_native == pytest.approx(5390.00) assert f.amount_usd == pytest.approx(5390.00 / 7.0) assert f.currency == "CNY" assert f.service == "内容分发网络 CDN" assert stub.queried_months == ["2026-05"] @pytest.mark.asyncio async def test_cost_facts_clamps_out_of_window_days(): """cost_facts must NOT emit days outside [start, end] even within the queried cycle — the ingestion only evicts stale rows in-window, so an out-of-window emit would leave a never-evicted fact. (billing_daily, which has no eviction, intentionally does NOT clamp.) Whole-cycle settlements are picked up by the month-close re-pull instead.""" stub = _BillStub({ "2026-05": [ _Row("2026-05-01 00:00:00", "5390.00", BusinessCodeName="内容分发网络 CDN", ResourceId="cdn-1"), _Row("2026-05-20 00:00:00", "100.00", ResourceId="cos-1"), ], }) driver = _driver() driver._billing_client = lambda: stub # type: ignore[method-assign] facts = await driver.list_cost_facts( date(2026, 5, 15), date(2026, 5, 31), scope=_scope(), cny_per_usd=7.0, ) # The 05-01 settlement is outside the window → dropped; 05-20 kept. assert {f.date for f in facts} == {date(2026, 5, 20)} assert facts[0].amount_native == pytest.approx(100.00) @pytest.mark.asyncio async def test_cost_facts_sums_rows_sharing_fact_key_grain(): """Multiple DescribeBillDetail sub-line-item rows that share the fact-key grain (date, resource_id, usage_type) MUST be summed into ONE emitted fact — otherwise the ingest's REPLACE-on-fact_key UPSERT keeps only the last and silently drops the rest (prod: COS ¥3,344 stored as ¥30.51). Pins the 2026-06-03 aggregation fix.""" stub = _BillStub({ "2026-05": [ # 4 COS sub-rows, same bucket / day / action → one grain. _Row("2026-05-25 00:00:00", "30.00", ResourceId="cos-bkt", ActionType="postpay_deduct_d"), _Row("2026-05-25 00:00:00", "50.00", ResourceId="cos-bkt", ActionType="postpay_deduct_d"), _Row("2026-05-25 00:00:00", "8.67", ResourceId="cos-bkt", ActionType="postpay_deduct_d"), _Row("2026-05-25 00:00:00", "50.00", ResourceId="cos-bkt", ActionType="postpay_deduct_d"), # a different resource on the same day → its own grain. _Row("2026-05-25 00:00:00", "10.00", ResourceId="cdn-x", ActionType="postpay_deduct_h", BusinessCodeName="内容分发网络 CDN"), ], }) driver = _driver() driver._billing_client = lambda: stub # type: ignore[method-assign] facts = await driver.list_cost_facts( date(2026, 5, 1), date(2026, 5, 31), scope=_scope(), cny_per_usd=7.0, ) by_resource = {f.resource_id: f.amount_native for f in facts} # 4 COS rows summed into ONE fact (NOT last-write-wins = 50.00). assert by_resource == { "cos-bkt": pytest.approx(138.67), # 30 + 50 + 8.67 + 50 "cdn-x": pytest.approx(10.00), } assert len(facts) == 2