"""Tests for ``GET /api/v1/apikeys/assignments/{id}/spend/detail`` and the backing ``ApiKeyService.get_assignment_spend_detail`` method. Contract under test (the new detail-page payload): * All fields the basic ``/spend`` endpoint returns (bucket, data_available, daily_series, threshold_state, …) PLUS: - ``monthly_series`` — six rows, oldest first, calendar-month sums. - ``by_model`` — per-``usage_type`` breakdown for the requested month, rows sorted ``mtd_usd`` desc. * ``by_model[*].effective_unit_price_usd`` is ``mtd_usd / usage_quantity`` (NOT the official list price). ``None`` when quantity is zero. * Bailian / missing-IAM / missing-RG / unknown buckets return ``data_available=False`` with ``by_model=[]`` and a six-month zero-filled ``monthly_series`` so the UI bar chart still has a domain to draw on. * Sum-of-rows in ``by_model`` reconciles to ``mtd_usd`` to the cent — same filter recipe. * The MTD value and ``daily_series`` agree with the basic endpoint (regression: the two surfaces must not drift). """ from __future__ import annotations import uuid from datetime import date, datetime from decimal import Decimal from types import SimpleNamespace from zoneinfo import ZoneInfo import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.domains.apikey.models import ApiKeyAssignment, ApiKeyProvider from app.domains.apikey.service import ApiKeyService from app.domains.cmdb.models import ( CmdbCloudScope, CmdbCostFact, CmdbIamUser, ) from app.identity.person_models import IdentityPerson from app.integrations.models import Integration # ── Inline helpers (mirror test_assignment_spend.py shape) ───────── async def _make_provider( db: AsyncSession, *, name: str = "Azure OpenAI", ) -> ApiKeyProvider: p = ApiKeyProvider( id=uuid.uuid4(), name=name, description="x", is_active=True, sort_order=0, ) db.add(p) await db.commit() return p async def _make_person( db: AsyncSession, *, email: str, ) -> IdentityPerson: person = IdentityPerson( id=uuid.uuid4(), source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", display_name=email.split("@")[0], work_email=email, employment_status="active", ) db.add(person) await db.commit() return person async def _make_integration( db: AsyncSession, *, provider: str = "azure", ) -> Integration: integ = Integration( id=uuid.uuid4(), kind="cloud", provider=provider, name=f"{provider.upper()} Test", slug=f"{provider}-test-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=[], config_public={}, ) db.add(integ) await db.commit() return integ async def _make_rg_scope( db: AsyncSession, *, integration: Integration, rg_name: str, owner: IdentityPerson | None = None, ) -> CmdbCloudScope: scope = CmdbCloudScope( id=uuid.uuid4(), integration_id=str(integration.id), scope_type="azure_resource_group", external_id=rg_name, display_name=f"Foundry · {rg_name}", status="active", owner_person_id=str(owner.id) if owner else None, ) db.add(scope) await db.commit() return scope async def _make_assignment( db: AsyncSession, *, provider: ApiKeyProvider, applicant: IdentityPerson, monthly_budget_usd: Decimal, cloud_scope: CmdbCloudScope | None = None, iam_user_arn: str | None = None, iam_integration: Integration | None = None, iam_user: CmdbIamUser | None = None, ) -> ApiKeyAssignment: a = ApiKeyAssignment( id=uuid.uuid4(), provider_id=provider.id, assigned_to=applicant.id, status="active", request_number=f"AK-{uuid.uuid4().hex[:8].upper()}", project_name="LLM eval", reason="test", monthly_budget_usd=monthly_budget_usd, cloud_scope_id=str(cloud_scope.id) if cloud_scope else None, iam_user_arn=iam_user_arn, iam_integration_id=( str(iam_integration.id) if iam_integration else None ), iam_user_id=iam_user.id if iam_user else None, ) db.add(a) await db.commit() return a async def _add_fact( db: AsyncSession, *, integration: Integration, fact_date: date, amount_usd: Decimal, service: str, resource_group: str | None = None, resource_id: str | None = None, usage_type: str | None = None, usage_quantity: Decimal | None = None, usage_unit: str | None = None, ) -> CmdbCostFact: fact = CmdbCostFact( fact_key=uuid.uuid4().hex, date=fact_date, integration_id=str(integration.id), service=service, amount_usd=amount_usd, amount_native=amount_usd, currency="USD", resource_group=resource_group, resource_id=resource_id, usage_type=usage_type, usage_quantity=usage_quantity, usage_unit=usage_unit, auto_owner_source="scope_owner", ) db.add(fact) await db.commit() return fact def _auth(person: IdentityPerson, *, admin: bool = False) -> SimpleNamespace: return SimpleNamespace( id=person.id, role_names=["asset_admin"] if admin else ["employee"], ) # ── Foundry: by_model + monthly_series happy path ────────────────── @pytest.mark.asyncio async def test_foundry_detail_groups_by_usage_type( db_session: AsyncSession, ): """Two Foundry cost-facts on different ``usage_type`` (GPT-4o input vs output) land as two rows in ``by_model``, sorted by spend desc. Effective unit price = amount / quantity per row, NOT the list price. Sum reconciles to ``mtd_usd``. """ provider = await _make_provider(db_session, name="Azure OpenAI") integ = await _make_integration(db_session, provider="azure") applicant = await _make_person(db_session, email="erin@pingcap.com") rg = await _make_rg_scope( db_session, integration=integ, rg_name="DetailRG-Foundry", owner=applicant, ) assignment = await _make_assignment( db_session, provider=provider, applicant=applicant, monthly_budget_usd=Decimal("100.00"), cloud_scope=rg, ) today = datetime.now(ZoneInfo("Asia/Shanghai")).date() # GPT-4o input: $20 for 2M tokens → $0.00001/token effective rate. await _add_fact( db_session, integration=integ, fact_date=today, amount_usd=Decimal("20.00"), service="Foundry Models", resource_group="DetailRG-Foundry", usage_type="Azure OpenAI - GPT-4o Input Tokens", usage_quantity=Decimal("2000000"), usage_unit="tokens", ) # GPT-4o output: $5 for 250k tokens → $0.00002/token. await _add_fact( db_session, integration=integ, fact_date=today, amount_usd=Decimal("5.00"), service="Foundry Models", resource_group="DetailRG-Foundry", usage_type="Azure OpenAI - GPT-4o Output Tokens", usage_quantity=Decimal("250000"), usage_unit="tokens", ) out = await ApiKeyService(db_session).get_assignment_spend_detail( assignment.id, current_user=_auth(applicant), ) assert out["bucket"] == "foundry" assert out["data_available"] is True assert out["mtd_usd"] == pytest.approx(25.00) # by_model: two rows, sorted by spend desc, sum reconciles. by_model = out["by_model"] assert len(by_model) == 2 assert by_model[0]["model"] == "Azure OpenAI - GPT-4o Input Tokens" assert by_model[0]["mtd_usd"] == pytest.approx(20.00) assert by_model[0]["usage_quantity"] == pytest.approx(2_000_000) assert by_model[0]["usage_unit"] == "tokens" assert by_model[0]["effective_unit_price_usd"] == pytest.approx(0.00001) assert by_model[0]["percent_of_month"] == pytest.approx(80.0) assert by_model[1]["model"] == "Azure OpenAI - GPT-4o Output Tokens" assert by_model[1]["mtd_usd"] == pytest.approx(5.00) assert by_model[1]["effective_unit_price_usd"] == pytest.approx(0.00002) assert by_model[1]["percent_of_month"] == pytest.approx(20.0) total_from_rows = sum(r["mtd_usd"] for r in by_model) assert total_from_rows == pytest.approx(out["mtd_usd"]) # monthly_series: exactly 6 rows, oldest first, current month last. series = out["monthly_series"] assert len(series) == 6 months = [r["month"] for r in series] assert months == sorted(months) # oldest first assert series[-1]["usd"] == pytest.approx(25.00) assert series[0]["usd"] == pytest.approx(0.0) # 5 months ago @pytest.mark.asyncio async def test_detail_mtd_matches_basic_endpoint( db_session: AsyncSession, ): """Regression: the detail endpoint's ``mtd_usd`` and ``daily_series`` must equal the basic endpoint's for the same AK, same month. Both route through the same bucket filter recipe — if they ever drift, the panel and detail page would show two different numbers for the same Key. """ provider = await _make_provider(db_session, name="Azure OpenAI") integ = await _make_integration(db_session, provider="azure") applicant = await _make_person(db_session, email="frank@pingcap.com") rg = await _make_rg_scope( db_session, integration=integ, rg_name="DetailParityRG", owner=applicant, ) assignment = await _make_assignment( db_session, provider=provider, applicant=applicant, monthly_budget_usd=Decimal("100.00"), cloud_scope=rg, ) today = datetime.now(ZoneInfo("Asia/Shanghai")).date() await _add_fact( db_session, integration=integ, fact_date=today, amount_usd=Decimal("17.50"), service="Foundry Models", resource_group="DetailParityRG", usage_type="Azure OpenAI - GPT-4 Turbo", usage_quantity=Decimal("1000000"), usage_unit="tokens", ) svc = ApiKeyService(db_session) basic = await svc.get_assignment_spend( assignment.id, current_user=_auth(applicant), ) detail = await svc.get_assignment_spend_detail( assignment.id, current_user=_auth(applicant), ) assert basic["mtd_usd"] == detail["mtd_usd"] assert basic["percent"] == detail["percent"] assert basic["threshold_state"] == detail["threshold_state"] assert basic["daily_series"] == detail["daily_series"] # ── Effective-unit-price edge case: zero quantity ─────────────────── @pytest.mark.asyncio async def test_detail_zero_quantity_returns_null_unit_price( db_session: AsyncSession, ): """A cost-fact with non-zero ``amount_usd`` but zero / missing ``usage_quantity`` (e.g. a service-fee row) must report ``effective_unit_price_usd=None`` rather than divide by zero or print "$inf / unit". """ provider = await _make_provider(db_session, name="Azure OpenAI") integ = await _make_integration(db_session, provider="azure") applicant = await _make_person(db_session, email="grace@pingcap.com") rg = await _make_rg_scope( db_session, integration=integ, rg_name="ZeroQtyRG", owner=applicant, ) assignment = await _make_assignment( db_session, provider=provider, applicant=applicant, monthly_budget_usd=Decimal("100.00"), cloud_scope=rg, ) today = datetime.now(ZoneInfo("Asia/Shanghai")).date() await _add_fact( db_session, integration=integ, fact_date=today, amount_usd=Decimal("3.00"), service="Foundry Models", resource_group="ZeroQtyRG", usage_type="Azure OpenAI - Service Fee", usage_quantity=None, usage_unit=None, ) out = await ApiKeyService(db_session).get_assignment_spend_detail( assignment.id, current_user=_auth(applicant), ) assert len(out["by_model"]) == 1 row = out["by_model"][0] assert row["mtd_usd"] == pytest.approx(3.00) assert row["effective_unit_price_usd"] is None # ── Null usage_type collapses to "(uncategorised)" ────────────────── @pytest.mark.asyncio async def test_detail_null_usage_type_collapses_to_uncategorised_bucket( db_session: AsyncSession, ): """Cost-facts with ``usage_type IS NULL`` (legacy rows, drivers that couldn't classify) must NOT silently drop from the breakdown — they collapse into a single ``"(uncategorised)"`` bucket so the sum-of-rows still reconciles to ``mtd_usd``. """ provider = await _make_provider(db_session, name="Azure OpenAI") integ = await _make_integration(db_session, provider="azure") applicant = await _make_person(db_session, email="hank@pingcap.com") rg = await _make_rg_scope( db_session, integration=integ, rg_name="NullMeterRG", owner=applicant, ) assignment = await _make_assignment( db_session, provider=provider, applicant=applicant, monthly_budget_usd=Decimal("100.00"), cloud_scope=rg, ) today = datetime.now(ZoneInfo("Asia/Shanghai")).date() await _add_fact( db_session, integration=integ, fact_date=today, amount_usd=Decimal("2.00"), service="Foundry Models", resource_group="NullMeterRG", usage_type=None, ) out = await ApiKeyService(db_session).get_assignment_spend_detail( assignment.id, current_user=_auth(applicant), ) assert len(out["by_model"]) == 1 assert out["by_model"][0]["model"] == "(uncategorised)" assert out["by_model"][0]["mtd_usd"] == pytest.approx(2.00) # ── Bailian: data unavailable → empty by_model + zero monthly_series ─ @pytest.mark.asyncio async def test_bailian_detail_empty_by_model_and_zero_monthly_series( db_session: AsyncSession, ): """This self-service per-AK detail panel keeps Bailian data_available=False (per-key Bailian cost lives on the admin AI Spending dashboard; wiring it here would route Bailian keys through the budget-alert cron — deferred). The detail payload must return an empty ``by_model`` and a six-row zero-filled ``monthly_series`` — the UI renders the caveat instead of charting non-existent rows. """ provider = await _make_provider( db_session, name="Alibaba Cloud Model Studio", ) applicant = await _make_person(db_session, email="ivy@pingcap.com") assignment = await _make_assignment( db_session, provider=provider, applicant=applicant, monthly_budget_usd=Decimal("100.00"), ) out = await ApiKeyService(db_session).get_assignment_spend_detail( assignment.id, current_user=_auth(applicant), ) assert out["bucket"] == "bailian" assert out["data_available"] is False assert out["data_caveat"] assert out["by_model"] == [] assert len(out["monthly_series"]) == 6 assert all(r["usd"] == 0.0 for r in out["monthly_series"]) # ── Foundry without RG → same data-unavailable behaviour ───────────── @pytest.mark.asyncio async def test_foundry_without_rg_detail_returns_empty_breakdown( db_session: AsyncSession, ): """Foundry AK with no ``cloud_scope_id`` cannot resolve per-AK spend — the detail payload mirrors the basic endpoint's data_available=False with empty breakdown + zero series.""" provider = await _make_provider(db_session, name="Azure OpenAI") applicant = await _make_person(db_session, email="jack@pingcap.com") assignment = await _make_assignment( db_session, provider=provider, applicant=applicant, monthly_budget_usd=Decimal("100.00"), cloud_scope=None, ) out = await ApiKeyService(db_session).get_assignment_spend_detail( assignment.id, current_user=_auth(applicant), ) assert out["bucket"] == "foundry" assert out["data_available"] is False assert out["by_model"] == [] assert len(out["monthly_series"]) == 6 assert all(r["usd"] == 0.0 for r in out["monthly_series"])