"""Tests for native Azure monthly billing via Cost Management. Covers: * Body shape posted to Cost Management is correct * Multiple subscriptions are summed; per-sub breakdown surfaces * 403/404 on a single subscription doesn't break the whole call * Currency from response rows propagates to result * Default month = previous full month (handler default) """ from __future__ import annotations from datetime import date, timedelta from unittest.mock import patch import httpx import pytest from app.domains.cmdb.providers.azure import AzureProvider from app.domains.cmdb.providers.base import ProviderCredentials def _creds() -> ProviderCredentials: return ProviderCredentials( provider_id="x", provider_type="azure", account_alias="x", fields={ "tenant_id": "tid", "client_id": "cid", "client_secret": "secret", }, ) @pytest.mark.asyncio async def test_get_monthly_billing_sums_across_subscriptions(): p = AzureProvider(_creds()) captured: list[tuple[str, dict]] = [] async def _fake_token(_resource): return "fake-token" async def _fake_list_sub_ids(): return ["sub-A", "sub-B"] fake_responses = { "sub-A": { "properties": { "columns": [ {"name": "PreTaxCost"}, {"name": "ServiceName"}, {"name": "Currency"}, ], "rows": [ [120.5, "Compute", "USD"], [80.0, "Storage", "USD"], ], } }, "sub-B": { "properties": { "columns": [ {"name": "PreTaxCost"}, {"name": "ServiceName"}, {"name": "Currency"}, ], "rows": [ [50.25, "Compute", "USD"], [10.0, "Networking", "USD"], ], } }, } class FakeResp: def __init__(self, status_code: int, body: dict): self.status_code = status_code self._body = body self.headers = {} self.text = "" def json(self): return self._body class FakeClient: async def post(self, url, params=None, headers=None, json=None): captured.append((url, json)) sub_id = url.split("/subscriptions/", 1)[1].split("/", 1)[0] return FakeResp(200, fake_responses[sub_id]) async def aclose(self): pass p._client = FakeClient() # type: ignore[assignment] with patch.object(p, "_token", new=_fake_token), \ patch.object(p, "_list_subscription_ids", new=_fake_list_sub_ids): out = await p.get_monthly_billing(date(2026, 3, 1)) await p.close() assert out["currency"] == "USD" # 120.5 + 80 + 50.25 + 10 = 260.75 assert out["total_cost_usd"] == pytest.approx(260.75) # USD already → no native rebase needed; total_cost_native is None. assert out["total_cost_native"] is None # Compute summed across both subs assert out["cost_by_service"]["Compute"] == pytest.approx(170.75) assert out["cost_by_service"]["Storage"] == pytest.approx(80.0) assert out["cost_by_service"]["Networking"] == pytest.approx(10.0) # Per-subscription breakdown preserved per_sub = out["raw_response"]["per_subscription"] assert per_sub["sub-A"]["total"] == pytest.approx(200.5) assert per_sub["sub-B"]["total"] == pytest.approx(60.25) # Body shape: timeframe=Custom, last day inclusive of target month assert len(captured) == 2 url, body = captured[0] assert "/providers/Microsoft.CostManagement/query" in url assert body["timeframe"] == "Custom" assert body["timePeriod"]["from"].startswith("2026-03-01") assert body["timePeriod"]["to"].startswith("2026-03-31") assert body["dataset"]["aggregation"]["totalCost"]["name"] == "PreTaxCost" assert body["dataset"]["grouping"][0]["name"] == "ServiceName" @pytest.mark.asyncio async def test_per_subscription_403_doesnt_break_whole_call(): """If the SP lacks Cost-Management-Reader on one of N subs, that sub is skipped and the rest are still aggregated.""" p = AzureProvider(_creds()) async def _fake_token(_): return "tok" async def _fake_subs(): return ["good", "bad"] class FakeResp: def __init__(self, status, body): self.status_code = status self._body = body self.headers = {} self.text = "permission denied" def json(self): return self._body class FakeClient: async def post(self, url, **kw): sub = url.split("/subscriptions/", 1)[1].split("/", 1)[0] if sub == "bad": return FakeResp(403, {}) return FakeResp(200, { "properties": { "columns": [ {"name": "PreTaxCost"}, {"name": "ServiceName"}, {"name": "Currency"}, ], "rows": [[42.0, "Compute", "USD"]], }, }) async def aclose(self): pass p._client = FakeClient() # type: ignore[assignment] with patch.object(p, "_token", new=_fake_token), \ patch.object(p, "_list_subscription_ids", new=_fake_subs): out = await p.get_monthly_billing(date(2026, 3, 1)) await p.close() assert out["total_cost_usd"] == pytest.approx(42.0) # Only the good sub appears in per_subscription assert "good" in out["raw_response"]["per_subscription"] assert "bad" not in out["raw_response"]["per_subscription"] @pytest.mark.asyncio async def test_default_month_is_previous_full_month(): p = AzureProvider(_creds()) async def _fake_token(_): return "tok" async def _fake_subs(): return [] # no subs → no API calls; just check the target p._client = httpx.AsyncClient() # never used with patch.object(p, "_token", new=_fake_token), \ patch.object(p, "_list_subscription_ids", new=_fake_subs): 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 assert out["total_cost_usd"] == 0 @pytest.mark.asyncio async def test_cny_subscription_surfaces_total_cost_native_for_rebase(): """Azure China / Mooncake subs report CNY. The driver runs no in-driver FX, so it MUST surface ``total_cost_native`` (+ ``currency='CNY'``) so the billing_sync handler's CNY→USD rebase fires. Without it (the bug) the raw CNY total is persisted into ``total_cost_usd`` as if USD → ~7x overstatement. Regression for the 2026-06-14 fix. """ p = AzureProvider(_creds()) async def _fake_token(_resource): return "fake-token" async def _fake_list_sub_ids(): return ["sub-cn"] fake_responses = { "sub-cn": { "properties": { "columns": [ {"name": "PreTaxCost"}, {"name": "ServiceName"}, {"name": "Currency"}, ], "rows": [ [700.0, "Compute", "CNY"], [300.0, "Storage", "CNY"], ], } }, } class FakeResp: def __init__(self, status_code: int, body: dict): self.status_code = status_code self._body = body self.headers = {} self.text = "" def json(self): return self._body class FakeClient: async def post(self, url, params=None, headers=None, json=None): sub_id = url.split("/subscriptions/", 1)[1].split("/", 1)[0] return FakeResp(200, fake_responses[sub_id]) async def aclose(self): pass p._client = FakeClient() # type: ignore[assignment] with patch.object(p, "_token", new=_fake_token), \ patch.object(p, "_list_subscription_ids", new=_fake_list_sub_ids): out = await p.get_monthly_billing(date(2026, 3, 1)) await p.close() assert out["currency"] == "CNY" # The handler rebase keys on this being present + currency=='CNY'. assert out["total_cost_native"] == pytest.approx(1000.0) assert out["total_cost_usd"] == pytest.approx(1000.0) # bounded fallback def _fake_client_for(fake_responses: dict): class FakeResp: def __init__(self, status_code: int, body: dict): self.status_code = status_code self._body = body self.headers = {} self.text = "" def json(self): return self._body class FakeClient: async def post(self, url, params=None, headers=None, json=None): sub_id = url.split("/subscriptions/", 1)[1].split("/", 1)[0] return FakeResp(200, fake_responses[sub_id]) async def aclose(self): pass return FakeClient() @pytest.mark.asyncio async def test_mixed_currency_subs_are_fx_summed_not_raw_added(): """#1663 — one USD sub + one CNY sub must NOT raw-sum PreTaxCost into ``total_cost_usd`` (that folds ~7×-inflated yuan into the USD total). Each sub's subtotal is FX-converted to USD via its own currency BEFORE summing; the aggregate relabels to USD.""" p = AzureProvider(_creds()) async def _fake_token(_resource): return "fake-token" async def _fake_list_sub_ids(): return ["sub-usd", "sub-cn"] fake_responses = { "sub-usd": { "properties": { "columns": [ {"name": "PreTaxCost"}, {"name": "ServiceName"}, {"name": "Currency"}, ], "rows": [[100.0, "Compute", "USD"]], } }, "sub-cn": { "properties": { "columns": [ {"name": "PreTaxCost"}, {"name": "ServiceName"}, {"name": "Currency"}, ], "rows": [[700.0, "Compute", "CNY"]], } }, } async def _fake_rate(_session, integration_config=None): return 7.0 # 1 USD = 7.0 CNY p._client = _fake_client_for(fake_responses) # type: ignore[assignment] with patch.object(p, "_token", new=_fake_token), \ patch.object(p, "_list_subscription_ids", new=_fake_list_sub_ids), \ patch("app.core.fx_rate.resolve_cny_per_usd", new=_fake_rate): out = await p.get_monthly_billing(date(2026, 3, 1)) await p.close() # 100 USD + (700 CNY / 7.0) = 100 + 100 = 200 USD — NOT the raw 800. assert out["currency"] == "USD" assert out["total_cost_usd"] == pytest.approx(200.0) assert out["total_cost_usd"] != pytest.approx(800.0) assert out["total_cost_native"] is None @pytest.mark.asyncio async def test_mixed_currency_with_unconvertible_stays_mixed(): """#1663 — if a bucket's currency has no FX rate (EUR), we must keep the loud MIXED / native-None signal rather than trusting a partial sum. The handler then skips the monthly upsert instead of persisting a cross-currency sum as USD.""" p = AzureProvider(_creds()) async def _fake_token(_resource): return "fake-token" async def _fake_list_sub_ids(): return ["sub-usd", "sub-eur"] fake_responses = { "sub-usd": { "properties": { "columns": [ {"name": "PreTaxCost"}, {"name": "ServiceName"}, {"name": "Currency"}, ], "rows": [[100.0, "Compute", "USD"]], } }, "sub-eur": { "properties": { "columns": [ {"name": "PreTaxCost"}, {"name": "ServiceName"}, {"name": "Currency"}, ], "rows": [[50.0, "Compute", "EUR"]], } }, } p._client = _fake_client_for(fake_responses) # type: ignore[assignment] with patch.object(p, "_token", new=_fake_token), \ patch.object(p, "_list_subscription_ids", new=_fake_list_sub_ids): out = await p.get_monthly_billing(date(2026, 3, 1)) await p.close() assert out["currency"] == "MIXED" # native is None → billing_sync skips the upsert rather than trusting # a cross-currency sum. assert out["total_cost_native"] is None