"""Tests for native Tencent monthly billing via DescribeBillSummaryByProduct. Tencent SDK uses PascalCase for method + attribute names — we mirror that in the fake stand-ins so the patched code path matches the real SDK shape exactly. """ # ruff: noqa: N802, N803, N815 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.tencent import TencentProvider def _creds() -> ProviderCredentials: return ProviderCredentials( provider_id="x", provider_type="tencent", account_alias="x", fields={"secret_id": "AKID", "secret_key": "SK"}, ) class _Item: """Stand-in for a TencentCloud SDK SummaryOverview item — the SDK returns objects with attributes (not dicts), so we mirror that.""" def __init__(self, BusinessCodeName: str, RealTotalCost: str): self.BusinessCodeName = BusinessCodeName self.RealTotalCost = RealTotalCost class _Resp: def __init__(self, items): self.SummaryOverview = items @pytest.mark.asyncio async def test_get_monthly_billing_sums_real_total_cost(): """``RealTotalCost`` (post-discount) is the value we surface — it matches the customer's actual paid amount, not the list price.""" p = TencentProvider(_creds()) fake_resp = _Resp([ _Item("云服务器 CVM", "1234.56"), _Item("对象存储 COS", "78.90"), _Item("内容分发 CDN", "12.34"), ]) class FakeClient: def DescribeBillSummaryByProduct(self, req): # Tencent's DescribeBillSummaryByProduct does NOT accept # ``Month`` — only BeginTime+EndTime spanning the FULL # month. Earlier impl (PR #50) set both to the 1st of # month + tried to pass Month, causing # ``UnknownParameter `Onth``` in prod. assert not hasattr(req, "Month") or getattr(req, "Month", None) in (None, "") assert req.BeginTime == "2026-03-01 00:00:00" assert req.EndTime == "2026-03-31 23:59:59" return fake_resp with patch( "app.domains.cmdb.providers.tencent._make_tc_client", return_value=FakeClient(), ): out = await p.get_monthly_billing(date(2026, 3, 1)) assert out["currency"] == "CNY" # The summed amount is native CNY. The driver runs sync-in-thread with # no DB session, so it can't resolve the FX rate itself — it MUST # surface ``total_cost_native`` (+ ``currency='CNY'``) so the # billing_sync handler's CNY→USD rebase path fires. Regression for the # 2026-06-14 fix: previously only ``total_cost_usd`` (= raw CNY) was # returned, the rebase (keyed on ``total_cost_native is not None``) # never ran, and raw CNY was persisted as if USD (~7x overstatement). assert out["total_cost_native"] == pytest.approx(1325.80) assert out["currency"] == "CNY" # native label so the handler rebases assert out["total_cost_usd"] == pytest.approx(1325.80) # bounded fallback assert out["cost_by_service"]["云服务器 CVM"] == pytest.approx(1234.56) assert out["cost_by_service"]["对象存储 COS"] == pytest.approx(78.90) assert out["month"] == date(2026, 3, 1) @pytest.mark.asyncio async def test_default_month_is_previous_full_month(): p = TencentProvider(_creds()) captured_req: list = [] class FakeClient: def DescribeBillSummaryByProduct(self, req): captured_req.append(req) return _Resp([]) with patch( "app.domains.cmdb.providers.tencent._make_tc_client", return_value=FakeClient(), ): out = await p.get_monthly_billing() today = date.today() expected = (today.replace(day=1) - timedelta(days=1)).replace(day=1) assert out["month"] == expected # BeginTime starts on the 1st of the previous full month assert captured_req[0].BeginTime.startswith(expected.isoformat()) @pytest.mark.asyncio async def test_raw_response_is_json_serializable(): """Regression: prod 2026-04-26 first-trigger threw ``Object of type DescribeBillSummaryByProductResponse is not JSON serializable`` because the SDK response class was being passed through to SQLAlchemy. The fix calls ``to_json_string()`` then re-parses to a plain dict so ``cmdb_billing_monthly.raw_response`` can store it.""" import json as _json p = TencentProvider(_creds()) class FakeResp: # Tencent SDK responses expose ``to_json_string`` — mimic that. def to_json_string(self): return _json.dumps({"RequestId": "abc", "SummaryOverview": []}) SummaryOverview = [] RequestId = "abc" class FakeClient: def DescribeBillSummaryByProduct(self, req): return FakeResp() with patch( "app.domains.cmdb.providers.tencent._make_tc_client", return_value=FakeClient(), ): out = await p.get_monthly_billing(date(2026, 3, 1)) # raw_response must be a plain JSON-serializable dict raw = out["raw_response"] assert isinstance(raw, dict) _json.dumps(raw) # would raise if not serializable assert raw.get("RequestId") == "abc" @pytest.mark.asyncio async def test_raw_response_falls_back_when_no_to_json_string(): """Defensive: SDKs evolve. If the response lacks ``to_json_string`` (or it raises), the fallback path extracts SummaryOverview manually so we still produce a serializable snapshot.""" import json as _json p = TencentProvider(_creds()) class _Item2: BusinessCodeName = "云服务器 CVM" RealTotalCost = "12.34" TotalCost = "20.00" class WeirdResp: # Missing to_json_string entirely. SummaryOverview = [_Item2()] RequestId = "xyz" class FakeClient: def DescribeBillSummaryByProduct(self, req): return WeirdResp() with patch( "app.domains.cmdb.providers.tencent._make_tc_client", return_value=FakeClient(), ): out = await p.get_monthly_billing(date(2026, 3, 1)) raw = out["raw_response"] _json.dumps(raw) assert raw["RequestId"] == "xyz" assert raw["SummaryOverview"][0]["BusinessCodeName"] == "云服务器 CVM" @pytest.mark.asyncio async def test_auth_failure_propagates(): """An auth error on the billing API must NOT silently fall through — the admin needs to see it so they can rotate the key.""" from tencentcloud.common.exception.tencent_cloud_sdk_exception import ( TencentCloudSDKException, ) from app.domains.cmdb.providers.exceptions import ProviderAuthError p = TencentProvider(_creds()) class FakeClient: def DescribeBillSummaryByProduct(self, req): raise TencentCloudSDKException( "AuthFailure.SignatureFailure", "bad signature", "id-1", ) with patch( "app.domains.cmdb.providers.tencent._make_tc_client", return_value=FakeClient(), ): with pytest.raises(ProviderAuthError): await p.get_monthly_billing(date(2026, 3, 1))