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
View File
+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
+91
View File
@@ -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() == []
+81
View File
@@ -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}
+110
View File
@@ -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
+97
View File
@@ -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']}"}
+291
View File
@@ -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()
+43
View File
@@ -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
+6
View File
@@ -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"
+31
View File
@@ -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>")