"""Lazy-refresh behaviour of /networth against the wealthfolio_sync mirror. The /networth, /networth/history, and /scenarios/{id}/progress endpoints read from `account_snapshot`. When that cache is older than `NETWORTH_CACHE_TTL_DAYS` (default 1) and a wf_sync session is available, they refresh it from the live mirror before returning. These tests pin that contract. """ from __future__ import annotations from collections.abc import AsyncIterator from datetime import date, timedelta from decimal import Decimal import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy import select, text from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine, ) from fire_planner.api.dependencies import get_session, get_wf_sync_session from fire_planner.app import app from fire_planner.db import AccountSnapshot @pytest_asyncio.fixture async def wf_sync_engine() -> AsyncIterator[AsyncEngine]: """In-memory aiosqlite engine standing in for the wealthfolio_sync mirror. Schema mirrors the real wealthfolio_sync PG tables (defined in `infra/stacks/wealthfolio/main.tf`) — we only need the subset that `read_account_snapshots_from_pg` reads. """ eng = create_async_engine("sqlite+aiosqlite:///:memory:") async with eng.begin() as conn: await conn.exec_driver_sql( """ CREATE TABLE accounts ( id TEXT PRIMARY KEY, name TEXT, account_type TEXT, currency TEXT, is_active BOOLEAN ) """) await conn.exec_driver_sql( """ CREATE TABLE daily_account_valuation ( id TEXT PRIMARY KEY, account_id TEXT NOT NULL, valuation_date DATE NOT NULL, account_currency TEXT, base_currency TEXT, fx_rate_to_base NUMERIC, cash_balance NUMERIC, investment_market_value NUMERIC, total_value NUMERIC, cost_basis NUMERIC, net_contribution NUMERIC ) """) yield eng await eng.dispose() async def _seed_wf_sync(wf_engine: AsyncEngine, valuation_date: date) -> None: factory = async_sessionmaker(wf_engine, expire_on_commit=False) async with factory() as sess: await sess.execute( text("INSERT INTO accounts (id, name, account_type, currency, is_active) " "VALUES ('acc-isa', 'ISA', 'ISA', 'GBP', 1)")) await sess.execute( text(""" INSERT INTO daily_account_valuation (id, account_id, valuation_date, account_currency, base_currency, fx_rate_to_base, total_value, cost_basis) VALUES (:id, 'acc-isa', :d, 'GBP', 'GBP', 1.0, :tv, :cb) """), {"id": "dav-isa", "d": valuation_date.isoformat(), "tv": 1_234_567, "cb": 1_000_000}) await sess.commit() @pytest_asyncio.fixture async def client( engine: AsyncEngine, session: AsyncSession, wf_sync_engine: AsyncEngine, ) -> AsyncIterator[AsyncClient]: fp_factory = async_sessionmaker(engine, expire_on_commit=False) wf_factory = async_sessionmaker(wf_sync_engine, expire_on_commit=False) async def _override_fp() -> AsyncIterator[AsyncSession]: async with fp_factory() as s: yield s async def _override_wf() -> AsyncIterator[AsyncSession]: async with wf_factory() as s: yield s app.dependency_overrides[get_session] = _override_fp app.dependency_overrides[get_wf_sync_session] = _override_wf transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac app.dependency_overrides.clear() async def test_networth_refreshes_empty_cache_from_wf_sync( client: AsyncClient, session: AsyncSession, wf_sync_engine: AsyncEngine, ) -> None: """Cold cache → first /networth call pulls from wf_sync and upserts.""" today = date.today() await _seed_wf_sync(wf_sync_engine, today) # Cache starts empty. pre = (await session.execute(select(AccountSnapshot))).scalars().all() assert pre == [] resp = await client.get("/networth") assert resp.status_code == 200, resp.text body = resp.json() assert body["snapshot_date"] == today.isoformat() assert Decimal(body["total_gbp"]) == Decimal("1234567.00") # Cache populated. persisted = (await session.execute(select(AccountSnapshot))).scalars().all() assert len(persisted) == 1 assert persisted[0].account_id == "acc-isa" assert persisted[0].snapshot_date == today async def test_networth_refreshes_stale_cache_from_wf_sync( client: AsyncClient, session: AsyncSession, wf_sync_engine: AsyncEngine, ) -> None: """Cache older than TTL → /networth refreshes from wf_sync.""" today = date.today() stale_date = today - timedelta(days=5) # Pre-seed account_snapshot with stale data. session.add( AccountSnapshot( external_id="wealthfolio:acc-isa:stale", snapshot_date=stale_date, account_id="acc-isa", account_name="ISA", account_type="ISA", currency="GBP", market_value=Decimal("999"), market_value_gbp=Decimal("999"), )) await session.commit() # wf_sync has today's row. await _seed_wf_sync(wf_sync_engine, today) resp = await client.get("/networth") assert resp.status_code == 200 body = resp.json() assert body["snapshot_date"] == today.isoformat() assert Decimal(body["total_gbp"]) == Decimal("1234567.00") async def test_networth_skips_refresh_when_cache_is_fresh( client: AsyncClient, session: AsyncSession, wf_sync_engine: AsyncEngine, ) -> None: """Cache fresh (today's date) → no refresh, returns cached value.""" today = date.today() # Cache has today's row with one value. session.add( AccountSnapshot( external_id="wealthfolio:acc-isa:fresh", snapshot_date=today, account_id="acc-isa", account_name="ISA", account_type="ISA", currency="GBP", market_value=Decimal("42"), market_value_gbp=Decimal("42"), )) await session.commit() # wf_sync has a DIFFERENT value; if refresh fires the response will # carry 1234567, not 42. await _seed_wf_sync(wf_sync_engine, today) resp = await client.get("/networth") assert resp.status_code == 200 assert Decimal(resp.json()["total_gbp"]) == Decimal("42")