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,102 @@
|
||||
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
|
||||
@@ -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() == []
|
||||
@@ -0,0 +1,81 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from factories import make_account, make_txn
|
||||
from fintracker.analytics import today_local
|
||||
|
||||
|
||||
async def test_rules_crud(client, auth_headers):
|
||||
r = await client.post(
|
||||
"/api/v1/rules",
|
||||
headers=auth_headers,
|
||||
json={"kind": "savings", "match_type": "payee", "pattern": "Копилка", "priority": 10},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
rule_id = r.json()["id"]
|
||||
assert r.json()["enabled"] is True
|
||||
assert r.json()["match_count"] == 0
|
||||
|
||||
r = await client.get("/api/v1/rules", headers=auth_headers)
|
||||
assert [x["id"] for x in r.json()] == [rule_id]
|
||||
|
||||
r = await client.patch(
|
||||
f"/api/v1/rules/{rule_id}", headers=auth_headers, json={"pattern": "Копилка%"}
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["pattern"] == "Копилка%"
|
||||
|
||||
r = await client.patch(f"/api/v1/rules/{rule_id}", headers=auth_headers, json={"kind": None})
|
||||
assert r.status_code == 400
|
||||
|
||||
r = await client.post(
|
||||
"/api/v1/rules",
|
||||
headers=auth_headers,
|
||||
json={"kind": "nonsense", "match_type": "payee", "pattern": "x"},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
r = await client.delete(f"/api/v1/rules/{rule_id}", headers=auth_headers)
|
||||
assert r.status_code == 204
|
||||
r = await client.delete(f"/api/v1/rules/{rule_id}", headers=auth_headers)
|
||||
assert r.status_code == 404
|
||||
assert (await client.get("/api/v1/rules", headers=auth_headers)).json() == []
|
||||
|
||||
|
||||
async def test_apply_runs_the_refresh_and_reports_stale_rules(client, auth_headers):
|
||||
card = await make_account(name="Карта", balance="10000")
|
||||
await make_txn(
|
||||
today_local() - timedelta(days=2),
|
||||
outcome="5000",
|
||||
outcome_account_id=card,
|
||||
payee="Копилка",
|
||||
)
|
||||
matching = (
|
||||
await client.post(
|
||||
"/api/v1/rules",
|
||||
headers=auth_headers,
|
||||
json={"kind": "savings", "match_type": "payee", "pattern": "копилка"},
|
||||
)
|
||||
).json()["id"]
|
||||
rotten = (
|
||||
await client.post(
|
||||
"/api/v1/rules",
|
||||
headers=auth_headers,
|
||||
json={"kind": "one_off", "match_type": "payee", "pattern": "Ничего не совпадает"},
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
r = await client.post("/api/v1/rules/apply", headers=auth_headers)
|
||||
assert r.status_code == 202
|
||||
assert r.json()["error"] is None
|
||||
assert r.json()["trigger"] == "rules"
|
||||
|
||||
by_id = {x["id"]: x for x in (await client.get("/api/v1/rules", headers=auth_headers)).json()}
|
||||
assert by_id[matching]["match_count"] == 1
|
||||
assert by_id[matching]["last_matched_at"] is not None
|
||||
assert by_id[rotten]["match_count"] == 0
|
||||
|
||||
r = await client.get("/api/v1/rules/stale", headers=auth_headers)
|
||||
assert [x["id"] for x in r.json()] == [rotten]
|
||||
|
||||
quality = (await client.get("/api/v1/data-quality", headers=auth_headers)).json()
|
||||
stale = [row for row in quality if row["check_name"] == "stale_rule"]
|
||||
assert len(stale) == 1 and stale[0]["ref"] == {"rule_id": rotten}
|
||||
@@ -0,0 +1,110 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from factories import make_account, make_category, make_cbr_rate, make_txn, refresh
|
||||
from fintracker.analytics import today_local
|
||||
|
||||
|
||||
async def test_pagination_filters_and_string_money(client, auth_headers):
|
||||
card = await make_account(name="Карта", balance="0")
|
||||
other = await make_account(name="Вклад", balance="0")
|
||||
food = await make_category("Еда")
|
||||
t = today_local()
|
||||
await make_cbr_rate(t - timedelta(days=10), "USD", "90")
|
||||
|
||||
for i in range(1, 6):
|
||||
await make_txn(
|
||||
t - timedelta(days=i),
|
||||
outcome=f"{i}00.55",
|
||||
outcome_account_id=card,
|
||||
payee=f"Магазин {i}",
|
||||
primary_category_id=food if i == 1 else None,
|
||||
)
|
||||
await make_txn(
|
||||
t - timedelta(days=6),
|
||||
income="1000",
|
||||
income_account_id=other,
|
||||
payee="Зарплата",
|
||||
comment="аванс",
|
||||
)
|
||||
await make_txn(
|
||||
t - timedelta(days=7), outcome="10", outcome_currency="USD", outcome_account_id=card
|
||||
)
|
||||
await make_txn(t - timedelta(days=8), outcome="1", outcome_account_id=card, deleted=True)
|
||||
await refresh()
|
||||
|
||||
r = await client.get(
|
||||
"/api/v1/transactions", headers=auth_headers, params={"page_size": 3, "page": 1}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 7 # the deleted one is excluded
|
||||
assert body["page"] == 1 and body["page_size"] == 3
|
||||
assert len(body["items"]) == 3
|
||||
dates = [i["date"] for i in body["items"]]
|
||||
assert dates == sorted(dates, reverse=True)
|
||||
|
||||
first = body["items"][0]
|
||||
assert isinstance(first["outcome"], str)
|
||||
assert first["outcome"].startswith("100.55")
|
||||
assert first["outcome_rub"].startswith("100.55")
|
||||
assert first["flow_type"] == "expense"
|
||||
|
||||
page2 = await client.get(
|
||||
"/api/v1/transactions", headers=auth_headers, params={"page_size": 3, "page": 2}
|
||||
)
|
||||
assert len(page2.json()["items"]) == 3
|
||||
assert {i["id"] for i in page2.json()["items"]} & {i["id"] for i in body["items"]} == set()
|
||||
|
||||
# filters
|
||||
r = await client.get("/api/v1/transactions", headers=auth_headers, params={"account_id": other})
|
||||
assert [i["payee"] for i in r.json()["items"]] == ["Зарплата"]
|
||||
|
||||
r = await client.get("/api/v1/transactions", headers=auth_headers, params={"q": "аванс"})
|
||||
assert r.json()["total"] == 1
|
||||
|
||||
r = await client.get("/api/v1/transactions", headers=auth_headers, params={"q": "магазин"})
|
||||
assert r.json()["total"] == 5 # ILIKE, case-insensitive
|
||||
|
||||
r = await client.get(
|
||||
"/api/v1/transactions", headers=auth_headers, params={"flow_type": "income"}
|
||||
)
|
||||
assert r.json()["total"] == 1
|
||||
|
||||
r = await client.get("/api/v1/transactions", headers=auth_headers, params={"category_id": food})
|
||||
assert r.json()["total"] == 1
|
||||
assert r.json()["items"][0]["tags"] == [food]
|
||||
|
||||
r = await client.get(
|
||||
"/api/v1/transactions",
|
||||
headers=auth_headers,
|
||||
params={"from": str(t - timedelta(days=2)), "to": str(t)},
|
||||
)
|
||||
assert r.json()["total"] == 2
|
||||
|
||||
r = await client.get(
|
||||
"/api/v1/transactions", headers=auth_headers, params={"include_deleted": True}
|
||||
)
|
||||
assert r.json()["total"] == 8
|
||||
|
||||
# the USD purchase converts at its own date's rate, and never silently at another
|
||||
r = await client.get(
|
||||
"/api/v1/transactions", headers=auth_headers, params={"q": "", "page_size": 100}
|
||||
)
|
||||
usd = next(i for i in r.json()["items"] if i["outcome_currency"] == "USD")
|
||||
assert usd["outcome_rub"].startswith("900")
|
||||
|
||||
|
||||
async def test_unquoted_currency_gives_null_rub(client, auth_headers):
|
||||
card = await make_account(balance="0")
|
||||
await make_txn(
|
||||
today_local() - timedelta(days=1),
|
||||
outcome="2",
|
||||
outcome_currency="XBT",
|
||||
outcome_account_id=card,
|
||||
)
|
||||
await refresh()
|
||||
|
||||
r = await client.get("/api/v1/transactions", headers=auth_headers)
|
||||
(item,) = r.json()["items"]
|
||||
assert item["outcome"] == "2.0000000000"
|
||||
assert item["outcome_rub"] is None
|
||||
Reference in New Issue
Block a user