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
|
||||
Reference in New Issue
Block a user