fire-planner/tests/test_api_spending.py
Viktor Barzin e12e8f9290
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
whatif: live data refresh, inflation-adjusted spending, legend fix
Three follow-ups to the actualbudget integration:

**Always-fresh autofill.** Drop the one-shot `*AutoFilled` boolean
gates; replace with `nwUserEdited` / `spendingUserEdited` flags. Until
the user types into either field, every refetch (mount, window
focus) updates the form value. Once they edit, we leave it alone. A
small ↻ button next to each anchor input flips the edited flag back
off so the user can re-snap to live data on demand. React Query
configured with staleTime=0 + refetchOnMount='always' +
refetchOnWindowFocus=true so the cache never serves stale numbers.
NW provenance shows the snapshot date.

**Inflation-adjusted spending.** Backend now revalues each trailing
month's nominal pence forward to today's £ using monthly compounding
of `inflation_pct` (default 0.03 ≈ UK CPI 2024-26). Headline
`total_gbp` is the real-£ figure — matches the simulator's
real-GBP convention. Response also includes `nominal_total_gbp` and
`inflation_pct` for transparency. New /spending/annual?inflation_pct=
override param. 10/10 actualbudget tests pass.

**FanChart legend.** The bottom-anchored legend was overlapping the
x-axis label. Moved to top: 8 with itemGap=18 + type=scroll for
narrow viewports; bumped grid top→48 / bottom→56 + xAxis nameGap→28
so nothing collides.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 11:27:22 +00:00

120 lines
4.8 KiB
Python

"""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"]