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
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Test harness: a throwaway Postgres (pytest-postgresql + pg_ctl on PATH), migrated with
|
||||
Alembic once per session, tables truncated after every test."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pytest_postgresql import factories
|
||||
from pytest_postgresql.janitor import DatabaseJanitor
|
||||
from sqlalchemy import text
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
postgresql_proc = factories.postgresql_proc(port=None, unixsocketdir="/tmp")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def database_url(postgresql_proc) -> Iterator[str]:
|
||||
p = postgresql_proc
|
||||
with DatabaseJanitor(
|
||||
user=p.user,
|
||||
host=p.host,
|
||||
port=p.port,
|
||||
dbname="fintracker_test",
|
||||
password=p.password,
|
||||
):
|
||||
pw = f":{p.password}" if p.password else ""
|
||||
url = f"postgresql+asyncpg://{p.user}{pw}@{p.host}:{p.port}/fintracker_test"
|
||||
os.environ["DATABASE_URL"] = url
|
||||
os.environ["JWT_SECRET"] = "test-secret-not-for-production-0123456789"
|
||||
from fintracker.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
yield url
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def migrated(database_url: str) -> str:
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
cfg = Config(str(BACKEND_DIR / "alembic.ini"))
|
||||
cfg.set_main_option("script_location", str(BACKEND_DIR / "alembic"))
|
||||
command.upgrade(cfg, "head")
|
||||
return database_url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app(migrated: str):
|
||||
from fintracker.api import app as app_module
|
||||
from fintracker.api.routers import auth as auth_router
|
||||
from fintracker.db import reset_engine
|
||||
|
||||
auth_router._login_limiter = None # fresh rate limiter per test
|
||||
application = app_module.create_app()
|
||||
yield application
|
||||
await _truncate_all()
|
||||
await reset_engine()
|
||||
|
||||
|
||||
async def _truncate_all() -> None:
|
||||
from fintracker.db import get_engine
|
||||
from fintracker.db.base import Base
|
||||
|
||||
tables = ", ".join(f'"{t.name}"' for t in Base.metadata.sorted_tables)
|
||||
async with get_engine().begin() as conn:
|
||||
await conn.execute(text(f"TRUNCATE {tables} RESTART IDENTITY CASCADE"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app) -> AsyncIterator[AsyncClient]:
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user(app) -> dict[str, str]:
|
||||
from fintracker.api.security import hash_password
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import AppUser
|
||||
|
||||
creds = {"email": "ada@example.com", "password": "correct horse battery staple"}
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(AppUser(email=creds["email"], password_hash=hash_password(creds["password"])))
|
||||
await session.commit()
|
||||
return creds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_headers(client: AsyncClient, user: dict[str, str]) -> dict[str, str]:
|
||||
r = await client.post("/api/v1/auth/login", json=user)
|
||||
assert r.status_code == 200, r.text
|
||||
return {"Authorization": f"Bearer {r.json()['access_token']}"}
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Row factories: insert core data directly, without going through a source sync.
|
||||
|
||||
Everything returns the new id. Amounts accept str/int/Decimal and are normalised to Decimal,
|
||||
so tests can write `outcome="1234.56"` and still exercise the NUMERIC(24,10) path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from itertools import count
|
||||
from typing import Any
|
||||
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
Account,
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
CashTxn,
|
||||
CashTxnTag,
|
||||
Category,
|
||||
Event,
|
||||
EventKind,
|
||||
EventStatus,
|
||||
Instrument,
|
||||
PriceDaily,
|
||||
RawCbrRate,
|
||||
Rule,
|
||||
RuleKind,
|
||||
RuleMatchType,
|
||||
Trip,
|
||||
)
|
||||
|
||||
_seq = count(1)
|
||||
|
||||
|
||||
def month_back(months: int) -> date:
|
||||
"""First day of the month `months` before the current one (in the deployment tz)."""
|
||||
from fintracker.analytics import today_local
|
||||
|
||||
t = today_local()
|
||||
total = (t.year * 12 + t.month - 1) - months
|
||||
return date(total // 12, total % 12 + 1, 1)
|
||||
|
||||
|
||||
Amount = str | int | float | Decimal
|
||||
|
||||
|
||||
def money(value: Amount | None) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
async def _add(obj: Any) -> Any:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(obj)
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
async def make_account(
|
||||
*,
|
||||
name: str = "Карта",
|
||||
currency: str = "RUB",
|
||||
role: AccountRole = AccountRole.liquid,
|
||||
kind: AccountKind = AccountKind.zm_card,
|
||||
balance: Amount | None = 0,
|
||||
balance_as_of: datetime | None = None,
|
||||
include_in_net_worth: bool = True,
|
||||
archived: bool = False,
|
||||
mirror_of_account_id: int | None = None,
|
||||
source: str = "zenmoney",
|
||||
source_id: str | None = None,
|
||||
) -> int:
|
||||
acc = await _add(
|
||||
Account(
|
||||
kind=kind,
|
||||
source=source,
|
||||
source_id=source_id or f"acc-{next(_seq)}",
|
||||
name=name,
|
||||
currency=currency,
|
||||
role=role,
|
||||
balance=money(balance),
|
||||
balance_as_of=balance_as_of or datetime.now(UTC),
|
||||
include_in_net_worth=include_in_net_worth,
|
||||
archived=archived,
|
||||
mirror_of_account_id=mirror_of_account_id,
|
||||
)
|
||||
)
|
||||
return acc.id
|
||||
|
||||
|
||||
async def make_category(
|
||||
name: str, *, parent_id: int | None = None, source_id: str | None = None
|
||||
) -> int:
|
||||
cat = await _add(
|
||||
Category(
|
||||
source="zenmoney",
|
||||
source_id=source_id or f"cat-{next(_seq)}",
|
||||
name=name,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
)
|
||||
return cat.id
|
||||
|
||||
|
||||
async def make_txn(
|
||||
d: date,
|
||||
*,
|
||||
income: Amount = 0,
|
||||
income_account_id: int | None = None,
|
||||
income_currency: str | None = None,
|
||||
outcome: Amount = 0,
|
||||
outcome_account_id: int | None = None,
|
||||
outcome_currency: str | None = None,
|
||||
payee: str | None = None,
|
||||
comment: str | None = None,
|
||||
mcc: int | None = None,
|
||||
hold: bool = False,
|
||||
deleted: bool = False,
|
||||
primary_category_id: int | None = None,
|
||||
tag_ids: list[int] | None = None,
|
||||
source_id: str | None = None,
|
||||
) -> int:
|
||||
income_d = money(income) or Decimal(0)
|
||||
outcome_d = money(outcome) or Decimal(0)
|
||||
txn = await _add(
|
||||
CashTxn(
|
||||
source="zenmoney",
|
||||
source_id=source_id or f"txn-{next(_seq)}",
|
||||
ts=datetime.combine(d, datetime.min.time(), tzinfo=UTC),
|
||||
date=d,
|
||||
income=income_d,
|
||||
income_account_id=income_account_id,
|
||||
income_currency=income_currency or ("RUB" if income_d else None),
|
||||
outcome=outcome_d,
|
||||
outcome_account_id=outcome_account_id,
|
||||
outcome_currency=outcome_currency or ("RUB" if outcome_d else None),
|
||||
payee=payee,
|
||||
comment=comment,
|
||||
mcc=mcc,
|
||||
hold=hold,
|
||||
deleted=deleted,
|
||||
primary_category_id=primary_category_id,
|
||||
)
|
||||
)
|
||||
tags = (
|
||||
tag_ids if tag_ids is not None else ([primary_category_id] if primary_category_id else [])
|
||||
)
|
||||
if tags:
|
||||
async with get_sessionmaker()() as session:
|
||||
for ord_, category_id in enumerate(tags):
|
||||
session.add(CashTxnTag(txn_id=txn.id, ord=ord_, category_id=category_id))
|
||||
await session.commit()
|
||||
return txn.id
|
||||
|
||||
|
||||
async def make_cbr_rate(rate_date: date, ccy: str, value: Amount, *, nominal: int = 1) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
RawCbrRate(
|
||||
rate_date=rate_date, ccy=ccy, nominal=nominal, value=money(value) or Decimal(0)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def make_rule(
|
||||
*,
|
||||
kind: RuleKind,
|
||||
match_type: RuleMatchType,
|
||||
pattern: str,
|
||||
value: str | None = None,
|
||||
enabled: bool = True,
|
||||
priority: int = 100,
|
||||
) -> int:
|
||||
rule = await _add(
|
||||
Rule(
|
||||
kind=kind,
|
||||
match_type=match_type,
|
||||
pattern=pattern,
|
||||
value=value,
|
||||
enabled=enabled,
|
||||
priority=priority,
|
||||
)
|
||||
)
|
||||
return rule.id
|
||||
|
||||
|
||||
async def make_trip(
|
||||
name: str, date_from: date, date_to: date, *, country: str | None = None
|
||||
) -> int:
|
||||
trip = await _add(Trip(name=name, date_from=date_from, date_to=date_to, country=country))
|
||||
return trip.id
|
||||
|
||||
|
||||
async def refresh(trigger: str = "test") -> Any:
|
||||
"""Run the whole metric refresh the way the worker does."""
|
||||
from fintracker.metrics.refresh import refresh_all
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
entry = await refresh_all(session, trigger=trigger)
|
||||
assert entry.error is None, entry.error
|
||||
return entry
|
||||
|
||||
|
||||
async def make_instrument(
|
||||
*,
|
||||
ticker: str = "GAZP",
|
||||
name: str | None = None,
|
||||
asset_class: AssetClass = AssetClass.share,
|
||||
currency: str = "RUB",
|
||||
board: str | None = "TQBR",
|
||||
) -> int:
|
||||
instrument = await _add(
|
||||
Instrument(
|
||||
asset_class=asset_class,
|
||||
ticker=ticker,
|
||||
board=board,
|
||||
name=name or ticker,
|
||||
currency=currency,
|
||||
)
|
||||
)
|
||||
return instrument.id
|
||||
|
||||
|
||||
async def make_event(
|
||||
d: date,
|
||||
*,
|
||||
account_id: int,
|
||||
kind: EventKind,
|
||||
instrument_id: int | None = None,
|
||||
quantity: Amount | None = None,
|
||||
price: Amount | None = None,
|
||||
amount: Amount = 0,
|
||||
currency: str = "RUB",
|
||||
fee: Amount | None = None,
|
||||
accrued_interest: Amount | None = None,
|
||||
status: EventStatus = EventStatus.confirmed,
|
||||
meta: dict[str, Any] | None = None,
|
||||
source_id: str | None = None,
|
||||
) -> int:
|
||||
key = source_id or f"ev-{next(_seq)}"
|
||||
event = await _add(
|
||||
Event(
|
||||
account_id=account_id,
|
||||
instrument_id=instrument_id,
|
||||
kind=kind,
|
||||
status=status,
|
||||
ts=datetime.combine(d, datetime.min.time(), tzinfo=UTC),
|
||||
trade_date=d,
|
||||
quantity=money(quantity),
|
||||
price=money(price),
|
||||
price_currency=currency if price is not None else None,
|
||||
amount=money(amount) or Decimal(0),
|
||||
currency=currency,
|
||||
fee=money(fee),
|
||||
fee_currency=currency if fee is not None else None,
|
||||
accrued_interest=money(accrued_interest),
|
||||
source="tinvest",
|
||||
source_id=key,
|
||||
dedupe_key=f"tinvest:{key}",
|
||||
meta=meta,
|
||||
)
|
||||
)
|
||||
return event.id
|
||||
|
||||
|
||||
async def make_price(
|
||||
d: date,
|
||||
*,
|
||||
instrument_id: int,
|
||||
close: Amount,
|
||||
currency: str = "RUB",
|
||||
accrued_interest: Amount | None = None,
|
||||
) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
PriceDaily(
|
||||
instrument_id=instrument_id,
|
||||
d=d,
|
||||
close=money(close) or Decimal(0),
|
||||
currency=currency,
|
||||
source="moex",
|
||||
accrued_interest=money(accrued_interest),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
@@ -0,0 +1,43 @@
|
||||
async def test_login_refresh_logout_cycle(client, user):
|
||||
r = await client.post("/api/v1/auth/login", json=user)
|
||||
assert r.status_code == 200, r.text
|
||||
pair = r.json()
|
||||
assert pair["token_type"] == "bearer"
|
||||
|
||||
me = await client.get(
|
||||
"/api/v1/auth/me", headers={"Authorization": f"Bearer {pair['access_token']}"}
|
||||
)
|
||||
assert me.status_code == 200
|
||||
assert me.json()["email"] == user["email"]
|
||||
|
||||
# rotation: the refresh token is single-use
|
||||
r2 = await client.post("/api/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]})
|
||||
assert r2.status_code == 200
|
||||
r3 = await client.post("/api/v1/auth/refresh", json={"refresh_token": pair["refresh_token"]})
|
||||
assert r3.status_code == 401
|
||||
assert r3.headers["content-type"].startswith("application/problem+json")
|
||||
|
||||
new_refresh = r2.json()["refresh_token"]
|
||||
assert (
|
||||
await client.post("/api/v1/auth/logout", json={"refresh_token": new_refresh})
|
||||
).status_code == 204
|
||||
assert (
|
||||
await client.post("/api/v1/auth/refresh", json={"refresh_token": new_refresh})
|
||||
).status_code == 401
|
||||
|
||||
|
||||
async def test_wrong_password_and_rate_limit(client, user):
|
||||
bad = {"email": user["email"], "password": "nope"}
|
||||
for _ in range(5):
|
||||
r = await client.post("/api/v1/auth/login", json=bad)
|
||||
assert r.status_code == 401
|
||||
r = await client.post("/api/v1/auth/login", json=bad)
|
||||
assert r.status_code == 429
|
||||
assert "retry-after" in r.headers
|
||||
|
||||
|
||||
async def test_protected_routes_need_token(client):
|
||||
r = await client.get("/api/v1/auth/me")
|
||||
assert r.status_code == 401
|
||||
r = await client.get("/api/v1/auth/me", headers={"Authorization": "Bearer garbage"})
|
||||
assert r.status_code == 401
|
||||
@@ -0,0 +1,6 @@
|
||||
async def test_health_reports_db(client):
|
||||
r = await client.get("/api/v1/health")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["database"] == "ok"
|
||||
@@ -0,0 +1,31 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def web_client(migrated: str, tmp_path: Path, monkeypatch):
|
||||
(tmp_path / "index.html").write_text("<!doctype html><title>spa</title>")
|
||||
(tmp_path / "main.dart.js").write_text("console.log(1)")
|
||||
monkeypatch.setenv("WEB_DIR", str(tmp_path))
|
||||
from fintracker.api.app import create_app
|
||||
from fintracker.config import get_settings
|
||||
from fintracker.db import reset_engine
|
||||
|
||||
get_settings.cache_clear()
|
||||
app = create_app()
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
await reset_engine()
|
||||
|
||||
|
||||
async def test_spa_fallback_and_api_precedence(web_client):
|
||||
assert (await web_client.get("/")).text.startswith("<!doctype html>")
|
||||
assert (await web_client.get("/login")).text.startswith("<!doctype html>") # deep link
|
||||
assert (await web_client.get("/main.dart.js")).text == "console.log(1)"
|
||||
assert (await web_client.get("/api/v1/health")).json()["status"] == "ok"
|
||||
r = await web_client.get("/api/v1/nope")
|
||||
assert r.status_code == 404 and r.headers["content-type"].startswith("application/problem+json")
|
||||
assert (await web_client.get("/../etc/passwd")).text.startswith("<!doctype html>")
|
||||
Reference in New Issue
Block a user