"""Unit tests for the actualbudget client + trailing-spending helper. We mock the upstream HTTP API with respx so the tests don't need a live cluster connection. The interesting axes are: - _trailing_months walks back the right complete-month window - fetch_month flips sign and skips income groups - fetch_trailing_spending applies exclusions correctly """ from __future__ import annotations from datetime import date from decimal import Decimal import httpx import pytest import respx from fire_planner.actualbudget import ( ActualBudgetClient, _trailing_months, fetch_trailing_spending, ) API_URL = "http://budget-http-api.test" SYNC_ID = "sync-1" def _client() -> ActualBudgetClient: return ActualBudgetClient(api_url=API_URL, api_key="key", sync_id=SYNC_ID) def test_trailing_months_skips_current_and_walks_back() -> None: # 2025-04 mid-month → trailing 3 months = 2025-01, 2025-02, 2025-03 result = _trailing_months(date(2025, 4, 15), 3) assert result == ["2025-01", "2025-02", "2025-03"] def test_trailing_months_crosses_year_boundary() -> None: result = _trailing_months(date(2025, 2, 1), 4) assert result == ["2024-10", "2024-11", "2024-12", "2025-01"] def test_trailing_months_zero_returns_empty() -> None: assert _trailing_months(date(2025, 5, 1), 0) == [] @pytest.mark.asyncio async def test_fetch_month_flips_sign_and_drops_income(respx_mock: respx.MockRouter) -> None: respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/2025-03").mock( return_value=httpx.Response(200, json={ "data": { "categoryGroups": [ { "name": "Usual Expenses", "is_income": False, "categories": [ {"spent": -1500}, # £15.00 spent {"spent": -2000}, # £20.00 spent ], }, { "name": "Salary", "is_income": True, "categories": [{"spent": 0}], }, { "name": "Empty Group", "is_income": False, "categories": [], }, ], } })) cli = _client() async with httpx.AsyncClient() as h: result = await cli.fetch_month(h, "2025-03") # Income skipped; outflows positive assert result.by_group == {"Usual Expenses": 3500, "Empty Group": 0} assert result.total_pence == 3500 @pytest.mark.asyncio async def test_fetch_trailing_spending_excludes_groups(respx_mock: respx.MockRouter) -> None: # Two months, both with Usual + Investments; we exclude Investments. respx_mock.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", -1000, -50000), ("2025-02", -2000, -10000)]: respx_mock.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}]}, ], } })) spends, nominal_gbp, real_gbp = await fetch_trailing_spending( months=2, exclude_groups=frozenset(["Investments and Savings"]), annual_inflation_pct=0.0, # no adjustment for the simple assertion client_factory=_client(), today=date(2025, 3, 15), ) assert [ms.month for ms in spends] == ["2025-01", "2025-02"] # Usual: 1000 + 2000 = 3000 pence = £30 assert nominal_gbp == Decimal("30.00") assert real_gbp == Decimal("30.00") # 0% inflation → same number @pytest.mark.asyncio async def test_fetch_trailing_spending_inflation_adjusts(respx_mock: respx.MockRouter) -> None: """At 12% annual inflation, a £10 expense from 12 months ago is worth £11.20 in today's £. Sanity-check the compounding via the oldest month of a 12-month window (today=2025-04 → 2024-04 is 12mo old).""" # Only one month available in the window. respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months").mock( return_value=httpx.Response(200, json={"data": ["2024-04"]})) respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/2024-04").mock( return_value=httpx.Response(200, json={ "data": {"categoryGroups": [ {"name": "Usual Expenses", "is_income": False, "categories": [{"spent": -1000}]}, # £10 ]} })) _, nominal_gbp, real_gbp = await fetch_trailing_spending( months=12, exclude_groups=frozenset(), annual_inflation_pct=0.12, client_factory=_client(), today=date(2025, 4, 15), ) assert nominal_gbp == Decimal("10.00") assert real_gbp == Decimal("11.20") @pytest.mark.asyncio async def test_fetch_trailing_spending_drops_unavailable_months( respx_mock: respx.MockRouter, ) -> None: """If the API surfaces fewer months than asked, missing months are skipped instead of erroring.""" respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months").mock( return_value=httpx.Response(200, json={"data": ["2025-02"]})) respx_mock.get(f"{API_URL}/v1/budgets/{SYNC_ID}/months/2025-02").mock( return_value=httpx.Response(200, json={ "data": {"categoryGroups": [ {"name": "Usual Expenses", "is_income": False, "categories": [{"spent": -5000}]}, ]} })) spends, nominal_gbp, _real_gbp = await fetch_trailing_spending( months=3, exclude_groups=frozenset(), annual_inflation_pct=0.0, client_factory=_client(), today=date(2025, 3, 15), ) assert [ms.month for ms in spends] == ["2025-02"] assert nominal_gbp == Decimal("50.00")