"""UCloud monthly billing via ``ListUBillDetail`` (consumption). Re-sourced 2026-07-14 from the old ``DescribeOrderDetailInfo`` (order) path, which returned its list under ``OrderInfos`` while the parser read ``OrderDetails`` (→ always 0) AND reported *orders*, not *consumption* (June 2026: 47.6 CNY of orders vs 13,167 CNY of usage — ~276x under). Monthly now sums the same ``ListUBillDetail`` line items as the daily / cost_facts paths, account-wide. """ from __future__ import annotations from datetime import date, timedelta from unittest.mock import patch import pytest from app.domains.cmdb.providers.base import ProviderCredentials from app.domains.cmdb.providers.ucloud import UCloudProvider from app.integrations.drivers.ucloud_cloud import UCloudDriver def _creds() -> ProviderCredentials: # Note: singular ``project_id`` set — the billing path MUST ignore it # and fetch account-wide (billing is account-level). return ProviderCredentials( provider_id="x", provider_type="ucloud", account_alias="x", fields={"public_key": "PK", "private_key": "SK", "project_id": "p1"}, ) def _row(product: str, amount: str, day: str = "2026-03-15") -> dict: return { "StartTime": day, "AmountReal": amount, "ItemDetails": [{"ProductName": product}], } @pytest.mark.asyncio async def test_get_monthly_billing_sums_consumption_via_ubill(): p = UCloudProvider(_creds()) # Page 1 returns exactly ``limit`` (100) rows → forces a second page; # page 2 is short → loop stops. Exercises the pagination contract. page1 = {"Items": [_row("云主机", "1.00") for _ in range(100)]} page2 = {"Items": [_row("对象存储", "0.50")]} page_iter = iter([page1, page2]) captured: list[dict] = [] async def _fake_call(action, params=None, *, endpoint=None, project_id="__unset__", **_kw): captured.append({"action": action, "params": dict(params or {}), "project_id": project_id}) return next(page_iter) with patch.object(p, "_call", new=_fake_call): out = await p.get_monthly_billing(date(2026, 3, 1)) await p.close() # Consumption source — NOT the old order API assert all(c["action"] == "ListUBillDetail" for c in captured) assert not any(c["action"] == "DescribeOrderDetailInfo" for c in captured) # Account-wide: ProjectId explicitly None even though creds set project_id=p1 assert all(c["project_id"] is None for c in captured) # BillingCycle keyed by YYYY-MM; pagination via Offset assert captured[0]["params"]["BillingCycle"] == "2026-03" assert captured[0]["params"]["Offset"] == 0 assert captured[1]["params"]["Offset"] == 100 # Currency CNY + native surfaced for the handler's CNY→USD rebase assert out["currency"] == "CNY" assert out["total_cost_usd"] == pytest.approx(100.50) assert out["total_cost_native"] == pytest.approx(100.50) assert out["cost_by_service"]["云主机"] == pytest.approx(100.00) assert out["cost_by_service"]["对象存储"] == pytest.approx(0.50) @pytest.mark.asyncio async def test_monthly_billing_excludes_out_of_month_rows(): """A billing cycle can carry edge rows; the day-guard drops rows whose usage-start falls outside the target month.""" p = UCloudProvider(_creds()) resp = {"Items": [ _row("云主机", "10.00", day="2026-03-20"), # in month _row("云主机", "99.00", day="2026-02-27"), # previous month — dropped _row("云主机", "77.00", day="2026-04-01"), # next month — dropped ]} async def _fake_call(action, params=None, *, endpoint=None, **_kw): return resp with patch.object(p, "_call", new=_fake_call): out = await p.get_monthly_billing(date(2026, 3, 1)) await p.close() assert out["total_cost_usd"] == pytest.approx(10.00) @pytest.mark.asyncio async def test_amountreal_preferred_then_amount_then_totalpayprice(): p = UCloudProvider(_creds()) resp = {"Items": [ {"StartTime": "2026-03-10", "AmountReal": "5.00", "Amount": "9.00"}, # AmountReal wins {"StartTime": "2026-03-10", "Amount": "3.00"}, # Amount fallback {"StartTime": "2026-03-10", "TotalPayPrice": "2.00"}, # last resort ]} async def _fake_call(action, params=None, *, endpoint=None, **_kw): return resp with patch.object(p, "_call", new=_fake_call): out = await p.get_monthly_billing(date(2026, 3, 1)) await p.close() assert out["total_cost_usd"] == pytest.approx(10.00) # 5 + 3 + 2 @pytest.mark.asyncio async def test_monthly_billing_skips_rows_without_usable_timestamp(): """The daily and cost_facts paths DROP rows with a missing or unparseable timestamp; the monthly path must do the same, or the monthly rollup stops reconciling against sum(daily) for the month (the un-dateable rows would be counted only once, on one side).""" p = UCloudProvider(_creds()) resp = {"Items": [ _row("云主机", "10.00", day="2026-03-20"), # in month — kept {"AmountReal": "88.00", # no ts at all — dropped "ItemDetails": [{"ProductName": "云主机"}]}, {"StartTime": "not-a-date", "AmountReal": "66.00", # unparseable — dropped "ItemDetails": [{"ProductName": "云主机"}]}, ]} async def _fake_call(action, params=None, *, endpoint=None, **_kw): return resp with patch.object(p, "_call", new=_fake_call): out = await p.get_monthly_billing(date(2026, 3, 1)) await p.close() assert out["total_cost_usd"] == pytest.approx(10.00) assert out["cost_by_service"]["云主机"] == pytest.approx(10.00) @pytest.mark.asyncio async def test_monthly_billing_amountreal_zero_not_fallthrough(): """A JSON numeric ``AmountReal: 0`` (fully-couponed line) is falsy — a truthiness fallback chain would fall through to the gross ``Amount`` and overcount. Presence-based ``_first_present`` keeps 0.""" p = UCloudProvider(_creds()) resp = {"Items": [ {"StartTime": "2026-03-10", "AmountReal": 0, "Amount": "9.00", "ItemDetails": [{"ProductName": "云主机"}]}, _row("云主机", "5.00", day="2026-03-11"), ]} async def _fake_call(action, params=None, *, endpoint=None, **_kw): return resp with patch.object(p, "_call", new=_fake_call): out = await p.get_monthly_billing(date(2026, 3, 1)) await p.close() # 0 + 5, NOT 9 + 5. assert out["total_cost_usd"] == pytest.approx(5.00) def test_first_present_presence_semantics(): """Unit contract of the shared amount-key resolver: zero survives, None/'' are skipped, exhaustion returns None.""" from app.domains.cmdb.providers.ucloud import _first_present keys = ("AmountReal", "Amount", "TotalPayPrice") # Numeric zero is present — must win over the gross Amount. assert _first_present({"AmountReal": 0, "Amount": "9.00"}, keys) == 0 assert _first_present({"AmountReal": "0", "Amount": "9.00"}, keys) == "0" # None / empty-string are absent — fall to the next key. assert _first_present({"AmountReal": None, "Amount": "3.00"}, keys) == "3.00" assert _first_present({"AmountReal": "", "Amount": "", "TotalPayPrice": "2.00"}, keys) == "2.00" # Nothing present at all. assert _first_present({}, keys) is None @pytest.mark.asyncio async def test_daily_billing_amountreal_zero_not_fallthrough(): """Driver ``list_billing_daily`` shares the presence-based amount resolution — an ``AmountReal: 0`` row contributes 0, not the gross ``Amount``, keeping daily totals aligned with the monthly path.""" driver = UCloudDriver( integration_id="int-ucloud-test", config={"public_key": "PUB", "private_key": "PRIV"}, ) resp = {"Items": [ {"StartTime": "2026-03-10", "AmountReal": 0, "Amount": "9.00", "ItemDetails": [{"ProductName": "云主机"}]}, {"StartTime": "2026-03-10", "AmountReal": "5.00", "ItemDetails": [{"ProductName": "云主机"}]}, ]} async def _fake_call(action, params=None, *, retryable=False, project_id="__unset__"): return resp with patch.object(driver, "_call", new=_fake_call): out = await driver.list_billing_daily(date(2026, 3, 1), date(2026, 3, 31)) assert len(out) == 1 assert out[0].date == date(2026, 3, 10) # 0 + 5, NOT 9 + 5. assert out[0].total_cost_usd == pytest.approx(5.00) @pytest.mark.asyncio async def test_unknown_action_fails_open_with_reason(): """Billing add-on not enabled → fail-open (0), reason in raw_response.""" from app.domains.cmdb.providers.exceptions import ProviderTransientError p = UCloudProvider(_creds()) async def _fake_call(action, params=None, *, endpoint=None, **_kw): raise ProviderTransientError( "UCloud RetCode=170, Action [ListUBillDetail]: UnknownAction", ) with patch.object(p, "_call", new=_fake_call): out = await p.get_monthly_billing(date(2026, 3, 1)) await p.close() assert out["total_cost_usd"] == 0 assert "UnknownAction" in str(out["raw_response"].get("reason", "")) @pytest.mark.asyncio async def test_real_auth_error_propagates(): """A genuine auth/permission failure must NOT fail-open (would silently persist a 0 monthly bill).""" from app.domains.cmdb.providers.exceptions import ProviderTransientError p = UCloudProvider(_creds()) async def _fake_call(action, params=None, *, endpoint=None, **_kw): raise ProviderTransientError("UCloud RetCode=160: NoAuth") with patch.object(p, "_call", new=_fake_call): with pytest.raises(ProviderTransientError): await p.get_monthly_billing(date(2026, 3, 1)) await p.close() @pytest.mark.asyncio async def test_default_month_is_previous_full_month(): p = UCloudProvider(_creds()) async def _fake_call(action, params=None, *, endpoint=None, **_kw): return {"Items": []} with patch.object(p, "_call", new=_fake_call): out = await p.get_monthly_billing() await p.close() today = date.today() expected = (today.replace(day=1) - timedelta(days=1)).replace(day=1) assert out["month"] == expected # ── driver _call account-wide override (billing is account-level) ────── @pytest.mark.asyncio async def test_driver_call_billing_is_account_wide_ignoring_singular_project(): """``project_id=None`` forces ProjectId omission for billing reads even when the integration has a singular ``project_id`` configured; other calls still default to the configured project.""" driver = UCloudDriver( integration_id="int-ucloud-test", config={"public_key": "PUB", "private_key": "PRIV", "project_id": "p1"}, ) seen: list[dict] = [] async def _capture(client, pub, priv, action, params=None, *, project_id=None, retryable=False): seen.append({"action": action, "project_id": project_id}) return {"Items": []} with patch("app.integrations.drivers.ucloud_cloud._signed_call", new=_capture): await driver._call("ListUBillDetail", {"BillingCycle": "2026-03"}, retryable=True, project_id=None) await driver._call("ListUsers", {}, retryable=True) # default path assert seen[0]["action"] == "ListUBillDetail" assert seen[0]["project_id"] is None # account-wide assert seen[1]["action"] == "ListUsers" assert seen[1]["project_id"] == "p1" # default keeps configured project