feat(api): FastAPI — auth, RFC 7807 и роуты фазы 1
Префикс /api/v1, operationId = "<tag>_<name>", чтобы Dart-клиент получил методы вроде accountsList, а не list_accounts_api_v1_accounts_get. Ошибки — application/problem+json. Auth: access-JWT на час + refresh на 30 дней, который хранится хэшем и ротируется, логин ограничен по частоте в памяти. Деньги в JSON — всегда позиционные строки (api/schemas/common.py): float в проводе потерял бы копейки, которые NUMERIC(24,10) бережёт. Тестовый harness поднимает свой Postgres через pg_ctl (pytest-postgresql), мигрирует его один раз на сессию и усекает таблицы после каждого теста.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from factories import make_account, make_category, make_txn, month_back
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.models import AccountRole
|
||||
|
||||
|
||||
async def test_refresh_populates_every_metric_endpoint(client, auth_headers):
|
||||
card = await make_account(name="Карта", balance="200000")
|
||||
await make_account(name="Вклад", balance="100000", role=AccountRole.savings)
|
||||
await make_account(name="Кредитка", balance="-5000", role=AccountRole.debt)
|
||||
food = await make_category("Еда")
|
||||
m = month_back(1)
|
||||
await make_txn(m + timedelta(days=1), income="150000", income_account_id=card)
|
||||
await make_txn(
|
||||
m + timedelta(days=2),
|
||||
outcome="30000",
|
||||
outcome_account_id=card,
|
||||
primary_category_id=food,
|
||||
)
|
||||
await make_txn(today_local() - timedelta(days=1), outcome="500", outcome_account_id=card)
|
||||
|
||||
assert (await client.get("/api/v1/metrics/status", headers=auth_headers)).json() is None
|
||||
|
||||
r = await client.post("/api/v1/metrics/refresh", headers=auth_headers)
|
||||
assert r.status_code == 202
|
||||
assert r.json()["error"] is None
|
||||
assert r.json()["finished_at"] is not None
|
||||
|
||||
status = (await client.get("/api/v1/metrics/status", headers=auth_headers)).json()
|
||||
assert status["trigger"] == "manual"
|
||||
|
||||
series = (await client.get("/api/v1/networth/series", headers=auth_headers)).json()
|
||||
assert series
|
||||
assert series[-1]["d"] == str(today_local())
|
||||
assert series[-1]["total_rub"].startswith("295000")
|
||||
assert isinstance(series[-1]["by_currency"]["RUB"], str)
|
||||
|
||||
breakdown = (await client.get("/api/v1/networth/breakdown", headers=auth_headers)).json()
|
||||
assert breakdown["d"] == str(today_local())
|
||||
assert breakdown["debt_rub"].startswith("-5000")
|
||||
assert {a["name"] for a in breakdown["accounts"]} == {"Карта", "Вклад", "Кредитка"}
|
||||
assert all(isinstance(a["balance_rub"], str) for a in breakdown["accounts"])
|
||||
|
||||
monthly = (await client.get("/api/v1/cashflow/monthly", headers=auth_headers)).json()
|
||||
last_month = next(row for row in monthly if row["month"] == str(m))
|
||||
assert last_month["income_rub"].startswith("150000")
|
||||
assert last_month["expense_rub"].startswith("30000")
|
||||
assert last_month["savings_rate"].startswith("0.8")
|
||||
|
||||
spending = (
|
||||
await client.get(
|
||||
"/api/v1/spending/categories",
|
||||
headers=auth_headers,
|
||||
params={"month": m.strftime("%Y-%m")},
|
||||
)
|
||||
).json()
|
||||
assert spending[0]["category_name"] == "Еда"
|
||||
assert spending[0]["root_category_name"] == "Еда"
|
||||
assert spending[0]["amount_rub"].startswith("30000")
|
||||
|
||||
runway = (await client.get("/api/v1/runway", headers=auth_headers)).json()
|
||||
assert runway["liquid_reserve_rub"].startswith("300000")
|
||||
# 30000 baseline in the previous month, 0 in the two before it -> 10000 average
|
||||
assert runway["avg_baseline_3m_rub"].startswith("10000")
|
||||
assert runway["runway_months"].startswith("30")
|
||||
|
||||
quality = (await client.get("/api/v1/data-quality", headers=auth_headers)).json()
|
||||
assert isinstance(quality, list)
|
||||
|
||||
|
||||
async def test_spending_rejects_a_bad_month(client, auth_headers):
|
||||
r = await client.get(
|
||||
"/api/v1/spending/categories", headers=auth_headers, params={"month": "2026/01"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
r = await client.get(
|
||||
"/api/v1/spending/categories", headers=auth_headers, params={"month": "2026-13"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
async def test_empty_database_reports_no_transactions(client, auth_headers):
|
||||
await client.post("/api/v1/metrics/refresh", headers=auth_headers)
|
||||
quality = (await client.get("/api/v1/data-quality", headers=auth_headers)).json()
|
||||
assert [row["check_name"] for row in quality] == ["no_transactions"]
|
||||
assert (await client.get("/api/v1/runway", headers=auth_headers)).json()[
|
||||
"runway_months"
|
||||
] is None
|
||||
assert (await client.get("/api/v1/networth/series", headers=auth_headers)).json() == []
|
||||
assert (await client.get("/api/v1/spending/categories", headers=auth_headers)).json() == []
|
||||
Reference in New Issue
Block a user