"""Integration tests for the new range-aware billing endpoints. Added 2026-04-29 alongside the redesigned ``/admin/cloud/billing`` page, which replaced the original month-only summary with a period selector that drives a per-day stacked area chart, per-provider cards, and a prior-period comparison. Coverage: * ``GET /cmdb/billing/overview`` returns the dense daily series (zero-filled for missing days), prior-period comparison using an immediately-preceding window of identical length, per-provider breakdown with delta_pct, top services, and by-account totals. * ``GET /cmdb/billing/export.csv`` emits both ``daily`` and ``by_account`` shapes with proper period metadata + filename. * Both endpoints respect ``integration_id`` filtering and gate access on ``can_read_cloud OR can_view_finance``. The standalone ``/billing/daily`` endpoint was deliberately NOT added in this change — that path is already taken by the scope-level drill-down handler in ``lifecycle_router.py``. The overview endpoint returns the same per-(integration, day) series under ``daily``, so the page consumes that and we avoid the route collision. """ from __future__ import annotations import uuid from datetime import date as _date from decimal import Decimal import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.models import ( CmdbCloudBillingDaily, CmdbCloudScope, ) from app.integrations.models import Integration async def _make_integration( db: AsyncSession, *, provider: str, name: str | None = None, ) -> Integration: row = Integration( id=uuid.uuid4(), kind="cloud", provider=provider, name=name or f"{provider}-billing-test", slug=f"{provider}-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=["billing"], ) db.add(row) await db.commit() return row async def _make_scope( db: AsyncSession, integration: Integration, *, external_id: str, ) -> CmdbCloudScope: s = CmdbCloudScope( id=uuid.uuid4(), integration_id=str(integration.id), scope_type="aws_account", external_id=external_id, display_name=external_id, status="active", ) db.add(s) await db.commit() return s async def _seed_daily( db: AsyncSession, *, integration: Integration, scope: CmdbCloudScope, day: _date, cost: float, cost_by_service: dict | None = None, ) -> None: db.add( CmdbCloudBillingDaily( integration_id=str(integration.id), scope_id=str(scope.id), date=day, total_cost_usd=Decimal(str(cost)), cost_by_service=cost_by_service or {}, currency="USD", ) ) await db.commit() @pytest.mark.asyncio async def test_overview_includes_prior_period_comparison_of_same_length( client, admin_headers, db_session, ) -> None: """``delta_pct`` must compare against an immediately-preceding window of identical length — that's the only definition that survives end-of-month edges cleanly. We seed two non-overlapping 2-day windows and verify the prior-period total + delta come out right.""" integ = await _make_integration(db_session, provider="azure") scope = await _make_scope(db_session, integ, external_id="sub-1") # Prior period: 2026-04-21..22 → $20 total await _seed_daily(db_session, integration=integ, scope=scope, day=_date(2026, 4, 21), cost=10.0) await _seed_daily(db_session, integration=integ, scope=scope, day=_date(2026, 4, 22), cost=10.0) # Current period: 2026-04-23..24 → $30 total → +50% await _seed_daily(db_session, integration=integ, scope=scope, day=_date(2026, 4, 23), cost=10.0) await _seed_daily(db_session, integration=integ, scope=scope, day=_date(2026, 4, 24), cost=20.0) resp = await client.get( "/api/v1/cmdb/billing/overview", headers=admin_headers, params={"from": "2026-04-23", "to": "2026-04-24"}, ) assert resp.status_code == 200, resp.text body = resp.json()["data"] assert body["from"] == "2026-04-23" assert body["to"] == "2026-04-24" assert body["days"] == 2 assert body["total_cost_usd"] == pytest.approx(30.0) assert body["prev_total_cost_usd"] == pytest.approx(20.0) assert body["delta_pct"] == pytest.approx(50.0) assert body["avg_daily_cost_usd"] == pytest.approx(15.0) @pytest.mark.asyncio async def test_overview_delta_pct_is_null_when_prior_period_is_zero( client, admin_headers, db_session, ) -> None: """A divide-by-zero would fabricate ``+inf``% growth which renders as a UI bug. The endpoint returns ``None`` so the frontend can show ``—`` instead of nonsense.""" integ = await _make_integration(db_session, provider="aws") scope = await _make_scope(db_session, integ, external_id="acc-1") await _seed_daily(db_session, integration=integ, scope=scope, day=_date(2026, 4, 24), cost=10.0) resp = await client.get( "/api/v1/cmdb/billing/overview", headers=admin_headers, params={"from": "2026-04-24", "to": "2026-04-24"}, ) assert resp.status_code == 200, resp.text body = resp.json()["data"] assert body["total_cost_usd"] == pytest.approx(10.0) assert body["prev_total_cost_usd"] == 0.0 assert body["delta_pct"] is None @pytest.mark.asyncio async def test_overview_daily_series_backfills_missing_days_with_zero( client, admin_headers, db_session, ) -> None: """The chart's x-axis must be contiguous — sync gaps and weekends in the underlying table can't punch holes in the rendered trend. Missing days come back as zero-cost points.""" integ = await _make_integration(db_session, provider="aws") scope = await _make_scope(db_session, integ, external_id="acc-1") # Only seed two of the three days in the range. await _seed_daily(db_session, integration=integ, scope=scope, day=_date(2026, 4, 23), cost=10.0) await _seed_daily(db_session, integration=integ, scope=scope, day=_date(2026, 4, 25), cost=15.0) resp = await client.get( "/api/v1/cmdb/billing/overview", headers=admin_headers, params={"from": "2026-04-23", "to": "2026-04-25"}, ) assert resp.status_code == 200, resp.text body = resp.json()["data"] daily = body["daily"] assert [d["date"] for d in daily] == [ "2026-04-23", "2026-04-24", "2026-04-25", ] assert daily[1]["total"] == pytest.approx(0.0) assert daily[1]["by_provider"] == {} @pytest.mark.asyncio async def test_overview_by_provider_aggregates_across_clouds( client, admin_headers, db_session, ) -> None: """Two distinct integrations on the same provider roll up into one ``by_provider`` row; ``account_count`` reflects that. Different providers stay separate and each gets a share-percentage.""" aws_a = await _make_integration(db_session, provider="aws", name="aws-prod") aws_b = await _make_integration(db_session, provider="aws", name="aws-staging") azure = await _make_integration(db_session, provider="azure", name="azure-main") scope_aws_a = await _make_scope(db_session, aws_a, external_id="111") scope_aws_b = await _make_scope(db_session, aws_b, external_id="222") scope_azure = await _make_scope(db_session, azure, external_id="sub-1") today = _date(2026, 4, 24) await _seed_daily(db_session, integration=aws_a, scope=scope_aws_a, day=today, cost=30.0) await _seed_daily(db_session, integration=aws_b, scope=scope_aws_b, day=today, cost=20.0) await _seed_daily(db_session, integration=azure, scope=scope_azure, day=today, cost=50.0) resp = await client.get( "/api/v1/cmdb/billing/overview", headers=admin_headers, params={"from": "2026-04-24", "to": "2026-04-24"}, ) assert resp.status_code == 200, resp.text body = resp.json()["data"] by_provider = {row["provider"]: row for row in body["by_provider"]} assert by_provider["aws"]["total_cost_usd"] == pytest.approx(50.0) assert by_provider["aws"]["account_count"] == 2 assert by_provider["aws"]["share_pct"] == pytest.approx(50.0) assert by_provider["azure"]["total_cost_usd"] == pytest.approx(50.0) assert by_provider["azure"]["account_count"] == 1 assert by_provider["azure"]["share_pct"] == pytest.approx(50.0) @pytest.mark.asyncio async def test_overview_top_services_sorted_descending( client, admin_headers, db_session, ) -> None: """``cost_by_service`` from each daily row sums into a global top-N list capped at 10 and sorted high-to-low. ``measure=invoice`` is explicit because the daily ``cost_by_service`` JSON column is invoice-basis only (the daily table has no list companion), so R3-F4 ships it on the invoice measure alone — a list total beside an invoice service split lets a reader divide out the discount rate at (service × day) grain. ``admin_headers`` is ``global_admin``, i.e. inside the finance circle, so the measure is allowed. The list branch's own source (``cmdb_cost_facts``) is pinned in ``test_billing_measure_fx_and_divergence_2026_07_29.py``.""" integ = await _make_integration(db_session, provider="aws") scope = await _make_scope(db_session, integ, external_id="acc-1") await _seed_daily( db_session, integration=integ, scope=scope, day=_date(2026, 4, 24), cost=100.0, cost_by_service={"EC2": 60.0, "S3": 30.0, "RDS": 10.0}, ) await _seed_daily( db_session, integration=integ, scope=scope, day=_date(2026, 4, 25), cost=45.0, cost_by_service={"S3": 40.0, "Lambda": 5.0}, ) # Totals: S3=70, EC2=60, RDS=10, Lambda=5. We deliberately avoid ties # so the assertion reads cleanly — the sort key is just cost_usd # descending; tie-breaking would be implementation-defined and # would make this test flaky. resp = await client.get( "/api/v1/cmdb/billing/overview", headers=admin_headers, params={ "from": "2026-04-24", "to": "2026-04-25", "measure": "invoice", }, ) assert resp.status_code == 200, resp.text assert resp.json()["data"]["top_services_basis"] == ( "billing_daily_cost_by_service" ) services = resp.json()["data"]["top_services"] assert [s["service"] for s in services] == ["S3", "EC2", "RDS", "Lambda"] assert services[0]["cost_usd"] == pytest.approx(70.0) assert services[1]["cost_usd"] == pytest.approx(60.0) @pytest.mark.asyncio async def test_overview_integration_id_filter_narrows_to_single_tenant( client, admin_headers, db_session, ) -> None: """The page's tenant-filter dropdown passes ``integration_id`` — the response must reflect only that tenant's spend, including the prior-period comparison (it'd be misleading to compare one tenant's "this week" against everyone's "last week").""" aws = await _make_integration(db_session, provider="aws") azure = await _make_integration(db_session, provider="azure") scope_aws = await _make_scope(db_session, aws, external_id="111") scope_azure = await _make_scope(db_session, azure, external_id="sub-1") today = _date(2026, 4, 24) await _seed_daily(db_session, integration=aws, scope=scope_aws, day=today, cost=10.0) await _seed_daily(db_session, integration=azure, scope=scope_azure, day=today, cost=999.0) resp = await client.get( "/api/v1/cmdb/billing/overview", headers=admin_headers, params={ "from": "2026-04-24", "to": "2026-04-24", "integration_id": str(aws.id), }, ) assert resp.status_code == 200, resp.text body = resp.json()["data"] assert body["total_cost_usd"] == pytest.approx(10.0) assert {row["provider"] for row in body["by_provider"]} == {"aws"} @pytest.mark.asyncio async def test_overview_rejects_inverted_range( client, admin_headers, ) -> None: """A from > to range is a client bug — fail loud rather than return an empty body that the frontend will misinterpret as "no data this period".""" resp = await client.get( "/api/v1/cmdb/billing/overview", headers=admin_headers, params={"from": "2026-04-25", "to": "2026-04-23"}, ) assert resp.status_code == 422, resp.text @pytest.mark.asyncio async def test_overview_rejects_overly_large_range( client, admin_headers, ) -> None: """The endpoint walks the full prev-period AND current-period rows in memory plus does a JOIN. Without a cap, a stray ``from=1970-01-01`` could OOM the worker. The cap is sized to accommodate the longest legit use case (YoY analysis ~18 months) and 422 anything beyond. """ resp = await client.get( "/api/v1/cmdb/billing/overview", headers=admin_headers, params={"from": "1970-01-01", "to": "2099-12-31"}, ) assert resp.status_code == 422, resp.text assert "Range too large" in resp.text @pytest.mark.asyncio async def test_overview_rejects_malformed_date( client, admin_headers, ) -> None: """Garbage ``from`` value → 422 with a labeled field, not a 500. Centralized in ``_parse_iso_date`` so all billing endpoints speak the same dialect.""" resp = await client.get( "/api/v1/cmdb/billing/overview", headers=admin_headers, params={"from": "yesterday", "to": "2026-04-25"}, ) assert resp.status_code == 422, resp.text @pytest.mark.asyncio async def test_overview_forbids_employee( client, employee_headers, ) -> None: """The overview endpoint is admin/finance-only. Employee tokens get 403, not 200 with someone else's spend.""" overview = await client.get( "/api/v1/cmdb/billing/overview", headers=employee_headers, params={"from": "2026-04-23", "to": "2026-04-24"}, ) assert overview.status_code == 403, overview.text # ── CSV export ───────────────────────────────────────────────────── @pytest.mark.asyncio async def test_csv_daily_export_one_row_per_integration_day( client, admin_headers, db_session, ) -> None: """``kind=daily`` flattens (integration, scope, date) rows up to (integration, date) — same aggregation as the JSON daily endpoint — so an analyst's pivot doesn't double-count when a tenant has multiple scopes.""" integ = await _make_integration(db_session, provider="aws", name="aws-prod") scope_a = await _make_scope(db_session, integ, external_id="111") scope_b = await _make_scope(db_session, integ, external_id="222") today = _date(2026, 4, 24) await _seed_daily(db_session, integration=integ, scope=scope_a, day=today, cost=10.0) await _seed_daily(db_session, integration=integ, scope=scope_b, day=today, cost=15.0) resp = await client.get( "/api/v1/cmdb/billing/export.csv", headers=admin_headers, params={"kind": "daily", "from": "2026-04-24", "to": "2026-04-24"}, ) assert resp.status_code == 200, resp.text assert resp.headers["content-type"].startswith("text/csv") body = resp.text.strip().splitlines() assert body[0] == ( "date,integration_id,integration_name,provider,total_cost_usd,currency" ) # Two scope rows collapse into one CSV row at $25.00. data_rows = body[1:] assert len(data_rows) == 1 assert "2026-04-24" in data_rows[0] assert "aws-prod" in data_rows[0] assert "25.0000" in data_rows[0] @pytest.mark.asyncio async def test_csv_by_account_includes_share_and_period_metadata( client, admin_headers, db_session, ) -> None: """``kind=by_account`` is the period-aggregate shape — one row per integration with the share % already computed so analysts don't have to reproduce the formula. ``from`` / ``to`` / ``days`` are embedded in every row so a saved CSV is self-documenting.""" aws = await _make_integration(db_session, provider="aws", name="aws-prod") azure = await _make_integration(db_session, provider="azure", name="azure-main") s_aws = await _make_scope(db_session, aws, external_id="111") s_az = await _make_scope(db_session, azure, external_id="sub-1") await _seed_daily(db_session, integration=aws, scope=s_aws, day=_date(2026, 4, 24), cost=30.0) await _seed_daily(db_session, integration=azure, scope=s_az, day=_date(2026, 4, 24), cost=70.0) resp = await client.get( "/api/v1/cmdb/billing/export.csv", headers=admin_headers, params={"kind": "by_account", "from": "2026-04-24", "to": "2026-04-24"}, ) assert resp.status_code == 200, resp.text body = resp.text.strip().splitlines() assert body[0] == ( "integration_id,integration_name,provider,from,to,days," "total_cost_usd,share_pct" ) # Sorted by total_cost_usd descending: azure ($70) before aws ($30). assert "azure-main" in body[1] assert "70.00" in body[1] assert "aws-prod" in body[2] assert "30.00" in body[2] # Share percentages add up to 100 (bar rounding). az_share = float(body[1].rsplit(",", 1)[-1]) aws_share = float(body[2].rsplit(",", 1)[-1]) assert az_share + aws_share == pytest.approx(100.0, abs=0.05) @pytest.mark.asyncio async def test_csv_export_rejects_unknown_kind( client, admin_headers, ) -> None: """``kind`` is whitelisted to ``daily`` / ``by_account``. Anything else 422s — silently rendering an empty CSV would mislead the operator into thinking they got the export they asked for.""" resp = await client.get( "/api/v1/cmdb/billing/export.csv", headers=admin_headers, params={"kind": "weekly"}, ) assert resp.status_code == 422, resp.text @pytest.mark.asyncio async def test_csv_export_attaches_filename_with_range( client, admin_headers, db_session, ) -> None: """The filename embeds the chosen window so a downloaded file self-describes ("which period was this?"). Browsers honour ``Content-Disposition: attachment; filename=...`` and offer that name in the save dialog.""" integ = await _make_integration(db_session, provider="aws") scope = await _make_scope(db_session, integ, external_id="111") await _seed_daily(db_session, integration=integ, scope=scope, day=_date(2026, 4, 24), cost=5.0) resp = await client.get( "/api/v1/cmdb/billing/export.csv", headers=admin_headers, params={"kind": "daily", "from": "2026-04-23", "to": "2026-04-25"}, ) assert resp.status_code == 200, resp.text cd = resp.headers["content-disposition"] assert "attachment" in cd assert "helix-billing-daily-2026-04-23-to-2026-04-25.csv" in cd @pytest.mark.asyncio async def test_csv_export_forbids_employee( client, employee_headers, ) -> None: resp = await client.get( "/api/v1/cmdb/billing/export.csv", headers=employee_headers, params={"kind": "daily"}, ) assert resp.status_code == 403, resp.text