fire-planner/tests/test_api_life_events_goals.py
Viktor Barzin ee6ed1d3c4
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
api: expand FastAPI surface for scenarios, networth, life-events, goals, simulate
Adds the read+write endpoints the frontend needs to drive a
ProjectionLab-style UX on top of the existing engine.

- /networth, /networth/history    — NW total + per-account from
                                     account_snapshot (frontend chart)
- /scenarios CRUD + projection    — list/get/create/patch/delete user
                                     scenarios; cartesian read-only
- /scenarios/{id}/life-events     — life event CRUD nested under scenario
- /life-events/{id}               — patch + delete by id
- /scenarios/{id}/goals,
  /goals/{id}                     — retirement goal CRUD
- /simulate, /compare             — sync, no-DB-write what-if endpoints

Auth: Bearer-token dependency on writes + simulate when API_BEARER_TOKEN
is set; reads always open (lock down via Authentik-fronted ingress in
prod). Existing /recompute keeps its bearer auth.

CORS middleware reads FRONTEND_ORIGINS (comma-separated) for the dev
SPA. Lifespan now provisions the SQLAlchemy engine + session_factory
on app.state and disposes them on shutdown.

40 new tests covering happy paths and validation. 172 tests total.
mypy strict + ruff clean (B008 ignore added — Depends() in defaults
is the canonical FastAPI pattern, not a bug).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:48:36 +00:00

151 lines
5.1 KiB
Python

"""Tests for /life-events and /goals."""
from __future__ import annotations
from collections.abc import AsyncIterator
from decimal import Decimal
import pytest_asyncio
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
from fire_planner.db import Scenario
@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") as ac:
yield ac
app.dependency_overrides.clear()
async def _seed_scenario(session: AsyncSession) -> int:
scen = Scenario(
external_id="user-host",
kind="user",
name="Host plan",
jurisdiction="uk",
strategy="trinity",
leave_uk_year=0,
glide_path="static",
spending_gbp=Decimal("60000"),
nw_seed_gbp=Decimal("1000000"),
savings_per_year_gbp=Decimal("0"),
config_json={},
)
session.add(scen)
await session.commit()
await session.refresh(scen)
return scen.id
# ── life events ──────────────────────────────────────────────────────
async def test_create_and_list_life_events(client: AsyncClient, session: AsyncSession) -> None:
sid = await _seed_scenario(session)
create = await client.post(
f"/scenarios/{sid}/life-events",
json={
"kind": "retirement",
"name": "Retire at 50",
"year_start": 15,
"year_end": 15,
},
)
assert create.status_code == 201, create.text
listed = await client.get(f"/scenarios/{sid}/life-events")
assert listed.status_code == 200
body = listed.json()
assert len(body) == 1
assert body[0]["name"] == "Retire at 50"
async def test_life_event_year_validation(client: AsyncClient, session: AsyncSession) -> None:
sid = await _seed_scenario(session)
resp = await client.post(
f"/scenarios/{sid}/life-events",
json={
"kind": "expense_range",
"name": "Bad range",
"year_start": 20,
"year_end": 5,
},
)
assert resp.status_code == 400
async def test_life_event_unknown_scenario(client: AsyncClient) -> None:
resp = await client.get("/scenarios/9999/life-events")
assert resp.status_code == 404
async def test_patch_life_event(client: AsyncClient, session: AsyncSession) -> None:
sid = await _seed_scenario(session)
create = await client.post(
f"/scenarios/{sid}/life-events",
json={"kind": "retirement", "name": "Retire", "year_start": 15},
)
eid = create.json()["id"]
resp = await client.patch(f"/life-events/{eid}",
json={"year_start": 20, "name": "Retire at 55"})
assert resp.status_code == 200
body = resp.json()
assert body["year_start"] == 20
assert body["name"] == "Retire at 55"
async def test_delete_life_event(client: AsyncClient, session: AsyncSession) -> None:
sid = await _seed_scenario(session)
create = await client.post(
f"/scenarios/{sid}/life-events",
json={"kind": "retirement", "name": "X", "year_start": 5},
)
eid = create.json()["id"]
resp = await client.delete(f"/life-events/{eid}")
assert resp.status_code == 204
listed = await client.get(f"/scenarios/{sid}/life-events")
assert listed.json() == []
# ── goals ────────────────────────────────────────────────────────────
async def test_create_and_list_goals(client: AsyncClient, session: AsyncSession) -> None:
sid = await _seed_scenario(session)
create = await client.post(
f"/scenarios/{sid}/goals",
json={
"kind": "target_nw",
"name": "≥ £2M at 50",
"target_amount_gbp": "2000000",
"target_year": 15,
"comparator": ">=",
"success_threshold": "0.90",
},
)
assert create.status_code == 201, create.text
listed = await client.get(f"/scenarios/{sid}/goals")
assert len(listed.json()) == 1
assert Decimal(listed.json()[0]["target_amount_gbp"]) == Decimal("2000000")
async def test_delete_goal(client: AsyncClient, session: AsyncSession) -> None:
sid = await _seed_scenario(session)
create = await client.post(
f"/scenarios/{sid}/goals",
json={"kind": "never_run_out", "name": "Last to 95", "target_year": 65},
)
gid = create.json()["id"]
resp = await client.delete(f"/goals/{gid}")
assert resp.status_code == 204