"""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, total_gbp = await fetch_trailing_spending( months=2, exclude_groups=frozenset(["Investments and Savings"]), 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 total_gbp == Decimal("30.00") @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, total_gbp = await fetch_trailing_spending( months=3, exclude_groups=frozenset(), client_factory=_client(), today=date(2025, 3, 15), ) assert [ms.month for ms in spends] == ["2025-02"] assert total_gbp == Decimal("50.00")