"""Goal CRUD and progress over HTTP (docs/ai/phase4-contract.md §4). The router is mounted here rather than taken from `create_app`: wiring it into `api/app.py` belongs to the phase-4 integration. """ from collections.abc import AsyncIterator from datetime import timedelta from decimal import Decimal import pytest from httpx import ASGITransport, AsyncClient from factories import make_account, make_event, make_instrument, make_price, refresh from fintracker.analytics import today_local from fintracker.models import AccountKind, AccountRole, AssetClass, EventKind D = Decimal PREFIX = "/api/v1" @pytest.fixture async def client(app) -> AsyncIterator[AsyncClient]: from fintracker.api.routers import goals app.include_router(goals.router, prefix=PREFIX) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: yield c @pytest.fixture async def portfolio(app) -> None: """A year of history ending 50 % up: 1000 shares bought at 100, now worth 150.""" t = today_local() start = t - timedelta(days=365) account = await make_account( name="Брокерский", kind=AccountKind.broker, role=AccountRole.investment, balance=None, include_in_net_worth=False, source="tinvest", ) share = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share) await make_event(start, account_id=account, kind=EventKind.deposit, amount="100000") await make_event( start, account_id=account, kind=EventKind.buy, instrument_id=share, quantity="1000", price="100", amount="-100000", ) d = start while d <= t: await make_price(d, instrument_id=share, close="100" if d < t else "150") d += timedelta(days=1) await refresh() async def create(client, auth_headers, **body): payload = {"name": "Капитал", "scope": "all", "target_amount": "1000000"} | body return await client.post(f"{PREFIX}/goals", json=payload, headers=auth_headers) # --------------------------------------------------------------------------- CRUD async def test_a_goal_round_trips(client, auth_headers, portfolio): r = await create(client, auth_headers, target_date="2030-01-01", monthly_contribution="30000") assert r.status_code == 201, r.text body = r.json() assert body["name"] == "Капитал" assert Decimal(body["target_amount"]) == 1000000 assert body["target_date"] == "2030-01-01" assert Decimal(body["monthly_contribution"]) == 30000 assert body["archived"] is False listing = await client.get(f"{PREFIX}/goals", headers=auth_headers) assert [g["id"] for g in listing.json()] == [body["id"]] async def test_a_duplicate_name_is_a_conflict(client, auth_headers, portfolio): await create(client, auth_headers) r = await create(client, auth_headers) assert r.status_code == 409 async def test_a_scope_the_metrics_never_built_is_refused(client, auth_headers, portfolio): r = await create(client, auth_headers, scope="portfolio:999") assert r.status_code == 404 async def test_patch_changes_only_what_is_sent(client, auth_headers, portfolio): created = (await create(client, auth_headers, monthly_contribution="1000")).json() r = await client.patch( f"{PREFIX}/goals/{created['id']}", json={"target_amount": "500000"}, headers=auth_headers, ) assert r.status_code == 200 body = r.json() assert Decimal(body["target_amount"]) == 500000 assert Decimal(body["monthly_contribution"]) == 1000 async def test_archived_goals_are_hidden_unless_asked_for(client, auth_headers, portfolio): created = (await create(client, auth_headers)).json() await client.patch( f"{PREFIX}/goals/{created['id']}", json={"archived": True}, headers=auth_headers ) assert (await client.get(f"{PREFIX}/goals", headers=auth_headers)).json() == [] shown = await client.get( f"{PREFIX}/goals", params={"include_archived": "true"}, headers=auth_headers ) assert [g["id"] for g in shown.json()] == [created["id"]] async def test_a_goal_can_be_deleted(client, auth_headers, portfolio): created = (await create(client, auth_headers)).json() r = await client.delete(f"{PREFIX}/goals/{created['id']}", headers=auth_headers) assert r.status_code == 204 assert (await client.get(f"{PREFIX}/goals", headers=auth_headers)).json() == [] assert ( await client.delete(f"{PREFIX}/goals/{created['id']}", headers=auth_headers) ).status_code == 404 async def test_goals_need_a_token(client, portfolio): assert (await client.get(f"{PREFIX}/goals")).status_code == 401 # --------------------------------------------------------------------------- progress async def test_progress_is_computed_from_the_live_metrics(client, auth_headers, portfolio): created = (await create(client, auth_headers, target_amount="300000")).json() r = await client.get(f"{PREFIX}/goals/{created['id']}/progress", headers=auth_headers) assert r.status_code == 200, r.text body = r.json() assert Decimal(body["current_value_rub"]) == 150000 assert Decimal(body["target_amount_rub"]) == 300000 assert Decimal(body["progress"]) == Decimal("0.5") assert body["basis"] == "xirr" assert body["projected_date"] is not None assert body["projected_date"] > str(today_local()) assert body["monthly_needed_rub"] is None assert body["on_track"] is None async def test_a_deadline_produces_a_monthly_need_and_an_on_track_flag( client, auth_headers, portfolio ): created = ( await create( client, auth_headers, target_amount="10000000", target_date=str(today_local() + timedelta(days=365)), ) ).json() body = ( await client.get(f"{PREFIX}/goals/{created['id']}/progress", headers=auth_headers) ).json() assert Decimal(body["monthly_needed_rub"]) > 0 assert body["on_track"] is False async def test_every_money_field_is_a_string(client, auth_headers, portfolio): created = (await create(client, auth_headers)).json() body = ( await client.get(f"{PREFIX}/goals/{created['id']}/progress", headers=auth_headers) ).json() for key in ("current_value_rub", "target_amount_rub", "progress"): assert isinstance(body[key], str) for key in ("assumed_rate", "monthly_needed_rub"): assert body[key] is None or isinstance(body[key], str) async def test_progress_of_an_unknown_goal_is_a_404(client, auth_headers, portfolio): r = await client.get(f"{PREFIX}/goals/999/progress", headers=auth_headers) assert r.status_code == 404