from factories import make_account from fintracker.models import AccountRole async def test_auth_is_required(client): for method, url in ( ("get", "/api/v1/accounts"), ("get", "/api/v1/categories"), ("get", "/api/v1/transactions"), ("get", "/api/v1/rules"), ("get", "/api/v1/networth/series"), ("get", "/api/v1/cashflow/monthly"), ("get", "/api/v1/runway"), ("get", "/api/v1/data-quality"), ("get", "/api/v1/metrics/status"), ("post", "/api/v1/metrics/refresh"), ): r = await getattr(client, method)(url) assert r.status_code == 401, (url, r.status_code) assert r.headers["content-type"].startswith("application/problem+json") async def test_list_accounts_exposes_balance_as_string(client, auth_headers): await make_account(name="Карта", balance="1234.56") r = await client.get("/api/v1/accounts", headers=auth_headers) assert r.status_code == 200 (row,) = r.json() assert row["name"] == "Карта" assert isinstance(row["balance"], str) assert row["balance"].startswith("1234.56") assert row["role"] == "liquid" assert row["include_in_net_worth"] is True async def test_patch_account(client, auth_headers): broker = await make_account(name="Брокер", role=AccountRole.investment) zm = await make_account(name="Зеркало") r = await client.patch( f"/api/v1/accounts/{zm}", headers=auth_headers, json={"include_in_net_worth": False, "mirror_of_account_id": broker, "role": "investment"}, ) assert r.status_code == 200 assert r.json()["include_in_net_worth"] is False assert r.json()["mirror_of_account_id"] == broker assert r.json()["role"] == "investment" # unset fields are untouched r = await client.patch( f"/api/v1/accounts/{zm}", headers=auth_headers, json={"name": "Зеркало+"} ) assert r.json()["mirror_of_account_id"] == broker assert r.json()["name"] == "Зеркало+" async def test_patch_account_validation(client, auth_headers): account_id = await make_account() r = await client.patch("/api/v1/accounts/999999", headers=auth_headers, json={"name": "x"}) assert r.status_code == 404 r = await client.patch( f"/api/v1/accounts/{account_id}", headers=auth_headers, json={"mirror_of_account_id": account_id}, ) assert r.status_code == 400 r = await client.patch( f"/api/v1/accounts/{account_id}", headers=auth_headers, json={"mirror_of_account_id": 4242} ) assert r.status_code == 400 r = await client.patch( f"/api/v1/accounts/{account_id}", headers=auth_headers, json={"role": "nonsense"} ) assert r.status_code == 422 r = await client.patch( f"/api/v1/accounts/{account_id}", headers=auth_headers, json={"currency": "USD"} ) assert r.status_code == 422 # extra="forbid": source-owned fields are not patchable r = await client.patch( f"/api/v1/accounts/{account_id}", headers=auth_headers, json={"name": " "} ) assert r.status_code == 400 async def test_categories_are_flat_with_parent_ids(client, auth_headers): from factories import make_category food = await make_category("Еда") await make_category("Продукты", parent_id=food) r = await client.get("/api/v1/categories", headers=auth_headers) assert r.status_code == 200 by_name = {c["name"]: c for c in r.json()} assert by_name["Еда"]["parent_id"] is None assert by_name["Продукты"]["parent_id"] == food async def test_create_broker_account_for_report_imports(client, auth_headers): r = await client.post( "/api/v1/accounts", json={"name": " ИИС-Сбер ", "broker": "sber", "source_id": " 1234567 "}, headers=auth_headers, ) assert r.status_code == 201, r.text row = r.json() assert row["name"] == "ИИС-Сбер" assert row["kind"] == "broker" assert row["source"] == "report_sber" assert row["source_id"] == "1234567" assert row["broker"] == "sber" assert row["currency"] == "RUB" assert row["role"] == "investment" assert row["primary_event_source"] == "report_sber" assert row["include_in_net_worth"] is True listed = (await client.get("/api/v1/accounts", headers=auth_headers)).json() assert [a["id"] for a in listed] == [row["id"]] async def test_create_account_for_another_broker_and_currency(client, auth_headers): r = await client.post( "/api/v1/accounts", json={"name": "ВТБ", "broker": "vtb", "source_id": "42", "currency": "usd"}, headers=auth_headers, ) assert r.status_code == 201 assert (r.json()["source"], r.json()["currency"]) == ("report_vtb", "USD") r = await client.post( "/api/v1/accounts", json={"name": "Прочий", "broker": "other", "source_id": "7"}, headers=auth_headers, ) assert r.json()["source"] == "csv" assert r.json()["primary_event_source"] is None async def test_create_account_rejects_tinvest_blanks_and_duplicates(client, auth_headers): url = "/api/v1/accounts" body = {"name": "ИИС", "broker": "sber", "source_id": "1"} r = await client.post(url, json={**body, "broker": "tinvest"}, headers=auth_headers) assert r.status_code == 400 r = await client.post(url, json={**body, "name": " "}, headers=auth_headers) assert r.status_code == 400 r = await client.post(url, json={**body, "source_id": " "}, headers=auth_headers) assert r.status_code == 400 assert (await client.post(url, json=body, headers=auth_headers)).status_code == 201 r = await client.post(url, json={**body, "name": "Другое имя"}, headers=auth_headers) assert r.status_code == 409 assert r.headers["content-type"].startswith("application/problem+json") assert "ИИС" in r.json()["detail"] # the same agreement number at another broker is a different account r = await client.post(url, json={**body, "broker": "vtb"}, headers=auth_headers) assert r.status_code == 201 async def test_disabled_flag_is_a_user_switch_that_defaults_off(client, auth_headers): a = await make_account(name="Карта") assert (await client.get("/api/v1/accounts", headers=auth_headers)).json()[0][ "disabled" ] is False r = await client.patch(f"/api/v1/accounts/{a}", json={"disabled": True}, headers=auth_headers) assert r.status_code == 200 assert r.json()["disabled"] is True r = await client.patch(f"/api/v1/accounts/{a}", json={"disabled": None}, headers=auth_headers) assert r.status_code == 400 r = await client.patch(f"/api/v1/accounts/{a}", json={"disabled": False}, headers=auth_headers) assert r.json()["disabled"] is False async def test_a_disabled_account_leaves_scopes_and_returns_when_switched_on(client, auth_headers): from datetime import date from factories import make_event, refresh from fintracker.models import AccountKind, AccountRole, EventKind kept = await make_account( name="Оставить", kind=AccountKind.broker, role=AccountRole.investment, balance=None ) dropped = await make_account( name="Отключить", kind=AccountKind.broker, role=AccountRole.investment, balance=None ) for account in (kept, dropped): await make_event(date(2026, 1, 5), account_id=account, kind=EventKind.deposit, amount=1000) await refresh() async def scopes() -> set[str]: r = await client.get("/api/v1/analytics/scopes", headers=auth_headers) return {s["scope"] for s in r.json()} assert {f"account:{kept}", f"account:{dropped}"} <= await scopes() await client.patch(f"/api/v1/accounts/{dropped}", json={"disabled": True}, headers=auth_headers) await refresh() now = await scopes() assert f"account:{kept}" in now assert f"account:{dropped}" not in now (row,) = [ a for a in (await client.get("/api/v1/accounts", headers=auth_headers)).json() if a["id"] == dropped ] assert row["disabled"] is True # still listed, so it can be switched back on assert row["value_rub"] is None # no scope, so no valuation any more await client.patch( f"/api/v1/accounts/{dropped}", json={"disabled": False}, headers=auth_headers ) await refresh() assert f"account:{dropped}" in await scopes() async def test_broker_account_shows_its_ledger_valuation(client, auth_headers): from datetime import date from factories import make_event, refresh from fintracker.models import AccountKind, AccountRole, EventKind broker = await make_account( name="Брокер", kind=AccountKind.broker, role=AccountRole.investment, balance=None ) card = await make_account(name="Карта", balance="500") await make_event(date(2026, 1, 5), account_id=broker, kind=EventKind.deposit, amount=1000) await refresh() rows = {a["id"]: a for a in (await client.get("/api/v1/accounts", headers=auth_headers)).json()} assert float(rows[broker]["value_rub"]) == 1000.0 assert rows[card]["value_rub"] is None