test: real backend pytest + E2E Playwright suites, both isolated from prod data

backend/tests/: 33 pytest tests against a real Postgres database
(aurum_test, created/migrated/dropped per run) exercised through the actual
HTTP API — not sqlite, not mocks, and the real `aurum` database is never
opened (app lifespan is skipped, get_session is overridden per test).
Covers transaction category-kind validation, year/month/date-range
filtering and pagination, dashboard summary arithmetic, budget status, cash
flow, and the /transactions/years picker.

e2e/: 6 Playwright specs driving a real browser against a fully separate
throwaway docker-compose stack (run.sh: separate project name, database,
port, and volume, torn down with `down -v` on exit) — regression coverage
for the Recent Transactions month bug, the Card grid-overflow bug, the
frozen-year bug, the Net Worth default range, and the Dashboard->
Transactions deep link, all fixed earlier this session.

Also synced the backend's APP_VERSION constant (stale at 0.15.2, embedded
in exported backups) with the frontend version.
This commit is contained in:
ZProger
2026-08-16 22:56:53 +03:00
parent 98c61d50d8
commit 8e46726b4d
24 changed files with 1104 additions and 2 deletions

View File

@@ -6,7 +6,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# Single source of truth for the running app's version — surfaced in the API
# title and embedded in exported backups so an old file can be told apart
# from a current one.
APP_VERSION = "0.15.2"
APP_VERSION = "0.18.0"
class Settings(BaseSettings):

5
backend/pytest.ini Normal file
View File

@@ -0,0 +1,5 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
pythonpath = .
testpaths = tests

View File

@@ -0,0 +1,8 @@
# Test-only dependencies — kept separate from requirements.txt so the
# production image never installs a test runner. Install inside the running
# backend container (see backend/tests/README.md) rather than baking these
# into the Docker image.
-r requirements.txt
pytest==8.3.4
pytest-asyncio==0.25.0
httpx==0.28.1

33
backend/tests/README.md Normal file
View File

@@ -0,0 +1,33 @@
# Backend tests
Real integration tests against a real Postgres database — a dedicated
`aurum_test` database created fresh, migrated, and dropped again on every
run. They never touch the real `aurum` database or its data: the app's
`lifespan` startup (which seeds against the real DB) is never triggered, and
every route's DB dependency is overridden to point at `aurum_test` instead.
## Running
The stack must already be up (`docker compose up -d`). Test dependencies
aren't baked into the production image, so install them once per container
lifetime, then run pytest inside the running `backend` container — it's the
only place with network access to Postgres:
```bash
docker compose exec backend pip install -r requirements-dev.txt
docker compose exec backend pytest -v
```
Re-run just `pytest -v` for subsequent runs; the `pip install` only needs
repeating after a container restart/rebuild.
## Adding a test
- Use the `client` fixture (an `httpx.AsyncClient` wired to the app) to hit
the API the same way the frontend does — prefer this over reaching into
services/models directly, so tests keep verifying the actual contract.
- `account_id` and `categories` fixtures give you the same default
account/categories a real fresh install seeds.
- Every table is truncated and reseeded before each test (see
`conftest.py::_clean_database`), so tests don't need to worry about
leftover state or guess at IDs from a previous test.

View File

137
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,137 @@
"""Test harness wiring.
Everything here talks to a dedicated ``aurum_test`` Postgres database on the
same server the app already uses — created fresh, migrated with the real
Alembic chain, and dropped again at the end of the run. The app's own
``AsyncSessionLocal``/``lifespan`` (which would touch the real ``aurum``
database with the user's actual financial history) is never invoked: the
ASGI app is exercised directly over httpx without running startup events,
and the ``get_session`` dependency is overridden per-test to point at the
test database instead. See tests/README.md for how to run this.
"""
import asyncio
import os
import subprocess
from collections.abc import AsyncGenerator, Generator
from pathlib import Path
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.deps import get_session
from app.core.config import get_settings
from app.db.base import Base
from app.db.seed import seed_default_account, seed_default_app_settings, seed_default_categories
from app.main import app
BACKEND_DIR = Path(__file__).resolve().parents[1]
TEST_DB_NAME = "aurum_test"
_base_settings = get_settings()
def _url(db_name: str) -> str:
return (
f"postgresql+asyncpg://{_base_settings.postgres_user}:{_base_settings.postgres_password}"
f"@{_base_settings.postgres_host}:{_base_settings.postgres_port}/{db_name}"
)
async def _drop_and_create_test_database() -> None:
engine = create_async_engine(_url("postgres"), isolation_level="AUTOCOMMIT")
async with engine.connect() as conn:
await conn.execute(text(f'DROP DATABASE IF EXISTS "{TEST_DB_NAME}" WITH (FORCE)'))
await conn.execute(text(f'CREATE DATABASE "{TEST_DB_NAME}"'))
await engine.dispose()
async def _drop_test_database() -> None:
engine = create_async_engine(_url("postgres"), isolation_level="AUTOCOMMIT")
async with engine.connect() as conn:
await conn.execute(text(f'DROP DATABASE IF EXISTS "{TEST_DB_NAME}" WITH (FORCE)'))
await engine.dispose()
@pytest.fixture(scope="session")
def _test_database() -> Generator[None, None, None]:
"""Create aurum_test from scratch and run every Alembic migration
against it. Connects to Postgres' always-present `postgres` maintenance
database to issue CREATE/DROP DATABASE — the real `aurum` database is
never opened by this fixture.
Deliberately a *sync* fixture that drives asyncio.run() itself rather
than an async one: session-scoped async fixtures need to share a loop
with function-scoped async tests, which pytest-asyncio doesn't do by
default and led to "attached to a different loop" errors here. A plain
sync fixture sidesteps the whole question — asyncio.run() opens and
cleanly closes its own throwaway loop for each of the two calls below.
"""
asyncio.run(_drop_and_create_test_database())
env = {**os.environ, "AURUM_POSTGRES_DB": TEST_DB_NAME}
subprocess.run(["alembic", "upgrade", "head"], cwd=BACKEND_DIR, env=env, check=True)
yield
asyncio.run(_drop_test_database())
@pytest_asyncio.fixture
async def test_sessionmaker(_test_database) -> AsyncGenerator[async_sessionmaker[AsyncSession], None]:
# Function-scoped, not session-scoped: pytest-asyncio gives each test
# function its own event loop, and an asyncpg engine/pool created under
# one loop can't be reused from another ("attached to a different
# loop"). Recreating the engine per test keeps it bound to whichever
# loop is actually running.
engine = create_async_engine(_url(TEST_DB_NAME), pool_pre_ping=True)
yield async_sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
await engine.dispose()
@pytest_asyncio.fixture(autouse=True)
async def _clean_database(test_sessionmaker):
"""Wipe every table and reseed the default categories/account/app
settings before each test, so tests never see leftovers from a previous
one and never have to guess at auto-incremented IDs from prior runs."""
async with test_sessionmaker() as session:
for table in reversed(Base.metadata.sorted_tables):
await session.execute(text(f'TRUNCATE TABLE "{table.name}" RESTART IDENTITY CASCADE'))
await session.commit()
await seed_default_categories(session)
await seed_default_account(session)
await seed_default_app_settings(session)
yield
@pytest_asyncio.fixture
async def client(test_sessionmaker) -> AsyncGenerator[AsyncClient, None]:
async def override_get_session() -> AsyncGenerator[AsyncSession, None]:
async with test_sessionmaker() as session:
yield session
app.dependency_overrides[get_session] = override_get_session
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test/api") as ac:
yield ac
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def account_id(client: AsyncClient) -> int:
"""The default seeded account (see app/db/seed.py) — every transaction
needs one, and the app itself always seeds exactly this one on first
boot, so tests build on the same shape real usage does."""
resp = await client.get("/accounts")
accounts = resp.json()
assert accounts, "seed_default_account should have created exactly one account"
return accounts[0]["id"]
@pytest_asyncio.fixture
async def categories(client: AsyncClient) -> dict[str, dict]:
"""Default seeded categories keyed by name, e.g. categories["Groceries"]["id"]."""
resp = await client.get("/categories")
return {c["name"]: c for c in resp.json()}

22
backend/tests/helpers.py Normal file
View File

@@ -0,0 +1,22 @@
"""Shared test fixtures' plain-Python helpers (not pytest fixtures
themselves — those live in conftest.py)."""
from decimal import Decimal
def money(value) -> Decimal:
"""Compares amounts by value regardless of whether the API serializes
Decimal as a JSON string or number — that's a wire-format detail, not
business behavior worth pinning a test to."""
return Decimal(str(value))
def txn_payload(account_id: int, **overrides) -> dict:
payload = {
"account_id": account_id,
"type": "expense",
"amount": "10.00",
"description": "test transaction",
"date": "2026-01-15",
}
payload.update(overrides)
return payload

View File

@@ -0,0 +1,82 @@
"""Budgets: category-kind restriction, one-budget-per-category, and the
spent/remaining/over-budget math behind the Budget page's progress bars.
"""
from decimal import Decimal
from httpx import AsyncClient
from tests.helpers import money, txn_payload
async def test_budget_rejects_income_category(client: AsyncClient, categories):
resp = await client.post(
"/budgets", json={"category_id": categories["Salary"]["id"], "monthly_limit": "500.00"}
)
assert resp.status_code == 400
async def test_budget_rejects_duplicate_category(client: AsyncClient, categories):
category_id = categories["Groceries"]["id"]
first = await client.post("/budgets", json={"category_id": category_id, "monthly_limit": "300.00"})
assert first.status_code == 201
second = await client.post("/budgets", json={"category_id": category_id, "monthly_limit": "400.00"})
assert second.status_code == 400
async def test_status_reports_spent_remaining_and_over_budget(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
await client.post("/budgets", json={"category_id": category_id, "monthly_limit": "100.00"})
for amount in ["60.00", "60.00"]: # 120.00 total, over a 100.00 limit
await client.post(
"/transactions",
json=txn_payload(account_id, amount=amount, category_id=category_id, date="2026-08-10"),
)
resp = await client.get("/budgets/status", params={"year": 2026, "month": 8})
item = resp.json()["items"][0]
assert money(item["spent"]) == Decimal("120.00")
assert money(item["remaining"]) == Decimal("-20.00")
assert item["percent"] == 120.0
assert item["is_over_budget"] is True
async def test_status_only_counts_the_selected_month(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
await client.post("/budgets", json={"category_id": category_id, "monthly_limit": "100.00"})
await client.post(
"/transactions", json=txn_payload(account_id, amount="90.00", category_id=category_id, date="2026-07-15")
)
resp = await client.get("/budgets/status", params={"year": 2026, "month": 8})
item = resp.json()["items"][0]
assert money(item["spent"]) == 0
assert item["is_over_budget"] is False
async def test_status_is_empty_list_when_no_budgets_exist(client: AsyncClient):
resp = await client.get("/budgets/status", params={"year": 2026, "month": 8})
assert resp.json()["items"] == []
async def test_update_budget_changes_the_limit(client: AsyncClient, categories):
category_id = categories["Groceries"]["id"]
created = await client.post("/budgets", json={"category_id": category_id, "monthly_limit": "100.00"})
budget_id = created.json()["id"]
resp = await client.patch(f"/budgets/{budget_id}", json={"monthly_limit": "250.00"})
assert resp.status_code == 200
assert money(resp.json()["monthly_limit"]) == Decimal("250.00")
async def test_delete_budget_removes_it_from_status(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
created = await client.post("/budgets", json={"category_id": category_id, "monthly_limit": "100.00"})
budget_id = created.json()["id"]
delete_resp = await client.delete(f"/budgets/{budget_id}")
assert delete_resp.status_code == 204
status = await client.get("/budgets/status", params={"year": 2026, "month": 8})
assert status.json()["items"] == []

View File

@@ -0,0 +1,84 @@
"""Cash flow: month-by-month income vs. expense across an explicit date
range, or the full transaction history when no range is given.
"""
from decimal import Decimal
from httpx import AsyncClient
from tests.helpers import money, txn_payload
async def test_explicit_range_excludes_transactions_outside_it(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
salary_id = categories["Salary"]["id"]
await client.post(
"/transactions", json=txn_payload(account_id, amount="1000.00", type="income", category_id=salary_id, date="2022-05-01")
)
await client.post(
"/transactions", json=txn_payload(account_id, amount="9999.00", type="income", category_id=salary_id, date="2021-01-01")
)
await client.post(
"/transactions", json=txn_payload(account_id, amount="9999.00", type="income", category_id=salary_id, date="2023-01-01")
)
resp = await client.get("/cash-flow", params={"start_date": "2022-01-01", "end_date": "2022-12-31"})
body = resp.json()
assert money(body["total_income"]) == Decimal("1000.00")
assert len(body["points"]) == 12 # every month of 2022, including zero months
assert body["start_date"] == "2022-01-01"
assert body["end_date"] == "2022-12-31"
async def test_transfers_are_excluded_from_totals(client: AsyncClient, account_id):
other = await client.post("/accounts", json={"name": "Savings", "type": "savings", "currency": "USD"})
other_id = other.json()["id"]
await client.post(
"/transactions",
json=txn_payload(account_id, type="transfer", amount="500.00", transfer_account_id=other_id, date="2022-03-01"),
)
resp = await client.get("/cash-flow", params={"start_date": "2022-01-01", "end_date": "2022-12-31"})
body = resp.json()
assert money(body["total_income"]) == 0
assert money(body["total_expense"]) == 0
async def test_no_range_falls_back_to_earliest_and_latest_transaction_dates(
client: AsyncClient, account_id, categories
):
category_id = categories["Groceries"]["id"]
await client.post("/transactions", json=txn_payload(account_id, category_id=category_id, date="2021-06-15"))
await client.post("/transactions", json=txn_payload(account_id, category_id=category_id, date="2024-02-10"))
resp = await client.get("/cash-flow")
body = resp.json()
assert body["start_date"] == "2021-06-15"
assert body["end_date"] == "2024-02-10"
async def test_no_transactions_returns_empty_points_and_null_bounds(client: AsyncClient):
resp = await client.get("/cash-flow")
body = resp.json()
assert body["points"] == []
assert body["start_date"] is None
assert body["end_date"] is None
assert money(body["total_net"]) == 0
async def test_monthly_points_split_income_and_expense_correctly(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
salary_id = categories["Salary"]["id"]
await client.post(
"/transactions", json=txn_payload(account_id, type="income", amount="2000.00", category_id=salary_id, date="2022-04-01")
)
await client.post(
"/transactions", json=txn_payload(account_id, type="expense", amount="300.00", category_id=category_id, date="2022-04-15")
)
resp = await client.get("/cash-flow", params={"start_date": "2022-04-01", "end_date": "2022-04-30"})
point = resp.json()["points"][0]
assert point["year"] == 2022
assert point["month"] == 4
assert money(point["income"]) == Decimal("2000.00")
assert money(point["expense"]) == Decimal("300.00")
assert money(point["net"]) == Decimal("1700.00")

View File

@@ -0,0 +1,110 @@
"""Dashboard summary: the arithmetic and month-scoping behind the Overview
page's four stat cards and the spending-by-category donut.
"""
from decimal import Decimal
from httpx import AsyncClient
from tests.helpers import money, txn_payload as _txn
async def test_summary_is_zero_for_a_month_with_no_transactions(client: AsyncClient):
resp = await client.get("/dashboard/summary", params={"year": 2030, "month": 1})
body = resp.json()
assert money(body["real_income"]) == 0
assert money(body["spent"]) == 0
assert money(body["net"]) == 0
assert body["spending_by_category"] == []
async def test_income_expense_and_net_only_count_the_selected_month(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
salary = categories["Salary"]["id"]
# In scope: August 2026.
await client.post(
"/transactions", json=_txn(account_id, type="income", amount="3000.00", category_id=salary, date="2026-08-01")
)
await client.post(
"/transactions", json=_txn(account_id, type="expense", amount="1200.00", category_id=groceries, date="2026-08-20")
)
# Out of scope: same categories, different month — must not leak in.
await client.post(
"/transactions", json=_txn(account_id, type="income", amount="9999.00", category_id=salary, date="2026-07-31")
)
await client.post(
"/transactions", json=_txn(account_id, type="expense", amount="9999.00", category_id=groceries, date="2026-09-01")
)
resp = await client.get("/dashboard/summary", params={"year": 2026, "month": 8})
body = resp.json()
assert money(body["real_income"]) == Decimal("3000.00")
assert money(body["spent"]) == Decimal("1200.00")
assert money(body["net"]) == Decimal("1800.00")
async def test_transfers_are_excluded_from_income_and_spent(client: AsyncClient, account_id, categories):
other = await client.post("/accounts", json={"name": "Savings", "type": "savings", "currency": "USD"})
other_id = other.json()["id"]
await client.post(
"/transactions",
json=_txn(account_id, type="transfer", amount="500.00", transfer_account_id=other_id, date="2026-08-05"),
)
await client.post(
"/transactions",
json=_txn(account_id, type="income", amount="100.00", category_id=categories["Salary"]["id"], date="2026-08-05"),
)
resp = await client.get("/dashboard/summary", params={"year": 2026, "month": 8})
body = resp.json()
assert money(body["real_income"]) == Decimal("100.00")
assert money(body["spent"]) == 0
assert money(body["transferred_out"]) == Decimal("500.00")
async def test_spending_by_category_amounts_and_percent(client: AsyncClient, account_id, categories):
groceries = categories["Groceries"]["id"]
dining = categories["Dining Out"]["id"]
await client.post(
"/transactions", json=_txn(account_id, amount="75.00", category_id=groceries, date="2026-08-01")
)
await client.post(
"/transactions", json=_txn(account_id, amount="25.00", category_id=dining, date="2026-08-02")
)
resp = await client.get("/dashboard/summary", params={"year": 2026, "month": 8})
breakdown = {row["name"]: row for row in resp.json()["spending_by_category"]}
assert money(breakdown["Groceries"]["amount"]) == Decimal("75.00")
assert breakdown["Groceries"]["percent"] == 75.0
assert money(breakdown["Dining Out"]["amount"]) == Decimal("25.00")
assert breakdown["Dining Out"]["percent"] == 25.0
async def test_more_than_eight_expense_categories_roll_up_into_other(client: AsyncClient, account_id, categories):
expense_categories = [c for c in categories.values() if c["kind"] == "expense"]
# The default seed only ships 8 expense categories (see app/db/seed.py) —
# add a 9th ourselves rather than depending on that number ever changing.
extra = await client.post(
"/categories", json={"name": "Extra Expense", "kind": "expense", "color": "#123456", "sort_order": 99}
)
expense_categories.append(extra.json())
assert len(expense_categories) == 9
# Distinct amounts so we can tell which ones survive as their own slice.
for index, category in enumerate(expense_categories[:9]):
await client.post(
"/transactions",
json=_txn(account_id, amount=f"{(9 - index) * 10}.00", category_id=category["id"], date="2026-08-01"),
)
resp = await client.get("/dashboard/summary", params={"year": 2026, "month": 8})
breakdown = resp.json()["spending_by_category"]
assert len(breakdown) == 9 # 8 explicit categories + one "Other" rollup
other = [row for row in breakdown if row["category_id"] is None]
assert len(other) == 1
assert other[0]["name"] == "Other"
# The 9th (smallest, index 8) category's 10.00 must be folded into Other,
# not silently dropped from the total.
assert money(other[0]["amount"]) == Decimal("10.00")

View File

@@ -0,0 +1,204 @@
"""Transactions: creation rules, filtering, pagination, sorting, and the
/transactions/years picker endpoint.
These assert the actual contract the frontend relies on — e.g. the year/month
filter test below is the backend half of the "Recent Transactions showed last
month's data too" regression from Aug 2026 (see UPDATES.md): the bug was in
the frontend not passing year/month at all, but if the backend ever stopped
honoring those filters correctly, the frontend fix would silently stop
working and nothing would catch it except this test.
"""
from datetime import date
from decimal import Decimal
from httpx import AsyncClient
from tests.helpers import money, txn_payload as _txn
async def test_create_expense_rejects_income_category(client: AsyncClient, account_id, categories):
income_category = categories["Salary"]
resp = await client.post(
"/transactions", json=_txn(account_id, type="expense", category_id=income_category["id"])
)
assert resp.status_code == 400
async def test_create_income_rejects_expense_category(client: AsyncClient, account_id, categories):
expense_category = categories["Groceries"]
resp = await client.post(
"/transactions", json=_txn(account_id, type="income", amount="500.00", category_id=expense_category["id"])
)
assert resp.status_code == 400
async def test_create_expense_with_matching_category_succeeds(client: AsyncClient, account_id, categories):
category = categories["Groceries"]
resp = await client.post("/transactions", json=_txn(account_id, category_id=category["id"]))
assert resp.status_code == 201
body = resp.json()
assert body["category"]["id"] == category["id"]
assert money(body["amount"]) == Decimal("10.00")
async def test_transfer_requires_destination_account(client: AsyncClient, account_id):
resp = await client.post("/transactions", json=_txn(account_id, type="transfer", amount="50.00"))
assert resp.status_code == 422
async def test_transfer_rejects_same_source_and_destination(client: AsyncClient, account_id):
resp = await client.post(
"/transactions",
json=_txn(account_id, type="transfer", amount="50.00", transfer_account_id=account_id),
)
assert resp.status_code == 422
async def test_transfer_rejects_a_category(client: AsyncClient, account_id, categories):
other = await client.post("/accounts", json={"name": "Savings", "type": "savings", "currency": "USD"})
other_id = other.json()["id"]
resp = await client.post(
"/transactions",
json=_txn(
account_id,
type="transfer",
amount="50.00",
transfer_account_id=other_id,
category_id=categories["Groceries"]["id"],
),
)
assert resp.status_code == 422
async def test_valid_transfer_between_two_accounts_succeeds(client: AsyncClient, account_id):
other = await client.post("/accounts", json={"name": "Savings", "type": "savings", "currency": "USD"})
other_id = other.json()["id"]
resp = await client.post(
"/transactions", json=_txn(account_id, type="transfer", amount="50.00", transfer_account_id=other_id)
)
assert resp.status_code == 201
async def test_list_filters_by_year_and_month_excludes_other_months(client: AsyncClient, account_id, categories):
"""The exact shape of the Aug 2026 Dashboard bug: three transactions in
three different months must never leak into a query scoped to just one
of them."""
category_id = categories["Groceries"]["id"]
for txn_date, description in [
("2021-07-26", "july transaction"),
("2021-08-08", "august transaction"),
("2026-08-16", "current year august"),
]:
resp = await client.post(
"/transactions", json=_txn(account_id, category_id=category_id, date=txn_date, description=description)
)
assert resp.status_code == 201
resp = await client.get("/transactions", params={"year": 2021, "month": 8})
body = resp.json()
assert body["total"] == 1
assert [item["description"] for item in body["items"]] == ["august transaction"]
async def test_list_filters_by_explicit_date_range(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
for txn_date in ["2024-01-01", "2024-06-15", "2024-12-31"]:
await client.post("/transactions", json=_txn(account_id, category_id=category_id, date=txn_date))
resp = await client.get("/transactions", params={"start_date": "2024-02-01", "end_date": "2024-11-01"})
body = resp.json()
assert body["total"] == 1
assert body["items"][0]["date"] == "2024-06-15"
async def test_pagination_covers_every_item_with_no_overlap(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
for day in range(1, 26):
await client.post(
"/transactions",
json=_txn(account_id, category_id=category_id, date=f"2025-03-{day:02d}", description=f"txn-{day}"),
)
seen_ids: set[int] = set()
page = 1
total = None
while True:
resp = await client.get("/transactions", params={"page": page, "page_size": 10, "sort": "date_desc"})
body = resp.json()
total = body["total"]
if not body["items"]:
break
for item in body["items"]:
assert item["id"] not in seen_ids, "pagination must not repeat an item across pages"
seen_ids.add(item["id"])
page += 1
assert total == 25
assert len(seen_ids) == 25
async def test_sort_amount_desc_and_asc_are_opposite_orders(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
for amount in ["5.00", "50.00", "20.00"]:
await client.post("/transactions", json=_txn(account_id, category_id=category_id, amount=amount))
desc = await client.get("/transactions", params={"sort": "amount_desc"})
asc = await client.get("/transactions", params={"sort": "amount_asc"})
desc_amounts = [money(item["amount"]) for item in desc.json()["items"]]
asc_amounts = [money(item["amount"]) for item in asc.json()["items"]]
assert desc_amounts == [Decimal("50.00"), Decimal("20.00"), Decimal("5.00")]
assert asc_amounts == list(reversed(desc_amounts))
async def test_update_transaction_persists_changes(client: AsyncClient, account_id, categories):
category_id = categories["Groceries"]["id"]
created = await client.post("/transactions", json=_txn(account_id, category_id=category_id))
txn_id = created.json()["id"]
resp = await client.patch(f"/transactions/{txn_id}", json={"amount": "99.99", "description": "updated"})
assert resp.status_code == 200
assert money(resp.json()["amount"]) == Decimal("99.99")
assert resp.json()["description"] == "updated"
refetched = await client.get("/transactions", params={"page_size": 1})
assert money(refetched.json()["items"][0]["amount"]) == Decimal("99.99")
async def test_update_transaction_rejects_category_kind_mismatch(client: AsyncClient, account_id, categories):
created = await client.post(
"/transactions", json=_txn(account_id, type="expense", category_id=categories["Groceries"]["id"])
)
txn_id = created.json()["id"]
resp = await client.patch(f"/transactions/{txn_id}", json={"category_id": categories["Salary"]["id"]})
assert resp.status_code == 400
async def test_delete_transaction_removes_it_from_listing(client: AsyncClient, account_id, categories):
created = await client.post(
"/transactions", json=_txn(account_id, category_id=categories["Groceries"]["id"])
)
txn_id = created.json()["id"]
delete_resp = await client.delete(f"/transactions/{txn_id}")
assert delete_resp.status_code == 204
listing = await client.get("/transactions")
assert txn_id not in [item["id"] for item in listing.json()["items"]]
async def test_years_endpoint_spans_earliest_transaction_through_current_year(
client: AsyncClient, account_id, categories
):
category_id = categories["Groceries"]["id"]
await client.post("/transactions", json=_txn(account_id, category_id=category_id, date="2021-03-01"))
await client.post("/transactions", json=_txn(account_id, category_id=category_id, date="2023-11-20"))
resp = await client.get("/transactions/years")
current_year = date.today().year
assert resp.json() == list(range(2021, current_year + 1))
async def test_years_endpoint_with_no_transactions_returns_only_current_year(client: AsyncClient):
resp = await client.get("/transactions/years")
assert resp.json() == [date.today().year]