"""Endpoint test for /spending/annual. Mocks the actualbudget upstream HTTP API with respx so the endpoint runs end-to-end without a live cluster connection. """ from __future__ import annotations from collections.abc import AsyncIterator from datetime import date from decimal import Decimal from unittest.mock import patch import httpx import pytest_asyncio import respx from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from fire_planner.api.dependencies import get_session from fire_planner.app import app API_URL = "http://budget-http-api.test" SYNC_ID = "sync-1" @pytest_asyncio.fixture async def client(engine: AsyncEngine, session: AsyncSession) -> AsyncIterator[AsyncClient]: factory = async_sessionmaker(engine, expire_on_commit=False) async def _override() -> AsyncIterator[AsyncSession]: async with factory() as s: yield s app.dependency_overrides[get_session] = _override transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test", timeout=30) as ac: yield ac app.dependency_overrides.clear() def _mock_two_months(rmock: respx.MockRouter) -> None: """Mock 2 months of spending data — Usual + Investments groups.""" rmock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months").mock( return_value=httpx.Response(200, json={"data": ["2025-01", "2025-02"]})) for m, usual, invest in [("2025-01", -3000, -50000), ("2025-02", -4000, -10000)]: rmock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/{m}").mock( return_value=httpx.Response(200, json={ "data": { "categoryGroups": [ {"name": "Usual Expenses", "is_income": False, "categories": [{"spent": usual}]}, {"name": "Investments and Savings", "is_income": False, "categories": [{"spent": invest}]}, ] } })) async def test_spending_annual_excludes_investments_by_default(client: AsyncClient) -> None: env = { "ACTUALBUDGET_API_URL": API_URL, "ACTUALBUDGET_API_KEY": "key", "ACTUALBUDGET_SYNC_ID": SYNC_ID, } with patch.dict("os.environ", env, clear=False), \ patch("fire_planner.actualbudget._today", lambda: date(2025, 3, 15)), \ respx.mock(assert_all_called=False) as rmock: _mock_two_months(rmock) # The endpoint asks for 12 months by default but we mocked only 2; # the remaining 10 are silently dropped (list_months only returns 2). resp = await client.get("/spending/annual?months=2&inflation_pct=0") assert resp.status_code == 200, resp.text body = resp.json() # 0% inflation → real == nominal. Usual: 3000 + 4000 = 7000p = £70 assert Decimal(body["total_gbp"]) == Decimal("70.00") assert Decimal(body["nominal_total_gbp"]) == Decimal("70.00") # Raw includes investments: 7000 + 60000 = 67000p = £670 assert Decimal(body["raw_total_gbp"]) == Decimal("670.00") assert body["window_start"] == "2025-01" assert body["window_end"] == "2025-02" assert body["excluded_groups"] == ["Investments and Savings", "Budget Reset"] assert body["by_group_total_gbp"]["Usual Expenses"] == "70.00" assert body["by_group_total_gbp"]["Investments and Savings"] == "600.00" assert Decimal(body["inflation_pct"]) == Decimal("0") async def test_spending_annual_custom_exclude(client: AsyncClient) -> None: env = { "ACTUALBUDGET_API_URL": API_URL, "ACTUALBUDGET_API_KEY": "key", "ACTUALBUDGET_SYNC_ID": SYNC_ID, } with patch.dict("os.environ", env, clear=False), \ patch("fire_planner.actualbudget._today", lambda: date(2025, 3, 15)), \ respx.mock(assert_all_called=False) as rmock: _mock_two_months(rmock) resp = await client.get( "/spending/annual?months=2&exclude=Usual%20Expenses&inflation_pct=0" ) assert resp.status_code == 200 body = resp.json() # Excluding "Usual Expenses" leaves only Investments: £600 assert Decimal(body["total_gbp"]) == Decimal("600.00") assert body["excluded_groups"] == ["Usual Expenses"] async def test_spending_annual_502_on_upstream_error(client: AsyncClient) -> None: env = { "ACTUALBUDGET_API_URL": API_URL, "ACTUALBUDGET_API_KEY": "key", "ACTUALBUDGET_SYNC_ID": SYNC_ID, } with patch.dict("os.environ", env, clear=False), \ respx.mock(assert_all_called=False) as rmock: rmock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months").mock( return_value=httpx.Response(503)) resp = await client.get("/spending/annual?months=2") assert resp.status_code == 502 assert "actualbudget" in resp.json()["detail"]