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:
Dmitry
2026-09-18 13:43:49 +03:00
parent 295438914d
commit 3fc7a954b9
40 changed files with 5261 additions and 0 deletions
+102
View File
@@ -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