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

6
.gitignore vendored
View File

@@ -28,12 +28,18 @@ __pycache__/
*.pyc *.pyc
.venv/ .venv/
*.egg-info/ *.egg-info/
.pytest_cache/
# Node / frontend # Node / frontend
node_modules/ node_modules/
dist/ dist/
.vite/ .vite/
# Playwright (e2e/) run artifacts
/e2e/test-results/
/e2e/playwright-report/
/e2e/.playwright/
# Editors / OS / local tooling # Editors / OS / local tooling
.DS_Store .DS_Store
.idea/ .idea/

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 # 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 # title and embedded in exported backups so an old file can be told apart
# from a current one. # from a current one.
APP_VERSION = "0.15.2" APP_VERSION = "0.18.0"
class Settings(BaseSettings): 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]

50
e2e/README.md Normal file
View File

@@ -0,0 +1,50 @@
# E2E tests
Playwright driving a real browser against a real, fully isolated Aurum
stack — not the self-hosted instance you actually use day to day. These
tests create and read real data over the real HTTP API and real rendered
UI, which is the only way to actually catch things like a CSS layout
overflow or a filter that's silently ignored — a backend-only test suite
can't see either.
## Isolation
`run.sh` brings up a *second*, throwaway stack alongside your real one:
| | real instance | e2e stack |
|---|---|---|
| compose project | `aurum` (default) | `aurum-e2e` |
| Postgres database | `aurum` | `aurum_e2e` |
| web port | 3000 | 3100 |
Different project name means different containers, network, and — crucially
— a different named Postgres volume, so the e2e Postgres data directory is
physically separate from `aurum_pgdata`. `run.sh` always tears the e2e stack
down with `docker compose down -v` on exit (even on failure/Ctrl-C), so no
test data is left behind and your real instance is never touched, restarted,
or read from.
## Running
```bash
cd e2e
./run.sh
```
Pass extra Playwright CLI args through, e.g. to run one file or open the
trace viewer on failure:
```bash
./run.sh tests/year-switcher.spec.ts
./run.sh --headed
```
## Adding a test
- Seed data via the `request` fixture and the helpers in `tests/helpers.ts`
(hits `/api/...` directly — fast and doesn't depend on clicking through a
form). Then drive the actual page for the behavior under test.
- Tests run with `workers: 1` against one shared backend/DB within a single
`run.sh` invocation, so give each spec's seeded data a distinct year/month
(or otherwise non-overlapping scope) rather than relying on a clean slate
between spec files.

76
e2e/package-lock.json generated Normal file
View File

@@ -0,0 +1,76 @@
{
"name": "aurum-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aurum-e2e",
"devDependencies": {
"@playwright/test": "1.62.1"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

11
e2e/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "aurum-e2e",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.62.1"
}
}

19
e2e/playwright.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from "@playwright/test";
// Points at the throwaway stack run.sh brings up (see README) — never the
// user's real self-hosted instance, so tests can freely create/delete data.
const baseURL = process.env.AURUM_E2E_BASE_URL ?? "http://localhost:3100";
export default defineConfig({
testDir: "./tests",
fullyParallel: false, // tests share one backend/DB and assert on exact totals — no isolation between them
workers: 1,
retries: 0,
reporter: [["list"]],
use: {
baseURL,
viewport: { width: 1440, height: 900 },
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
});

37
e2e/run.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Spins up a throwaway Aurum stack for E2E tests, runs Playwright against it,
# then always tears the stack down again — including its Postgres volume.
#
# Fully isolated from the real self-hosted instance:
# - separate compose project name (aurum-e2e) -> separate containers/network
# - separate Postgres database name (aurum_e2e) -> separate data, same server
# - separate host port (3100, vs the real one's 3000) -> no port clash
# - `down -v` on exit removes the isolated volume, so no test data persists
# and the real `aurum`/`aurum_pgdata` are never opened.
set -euo pipefail
cd "$(dirname "$0")/.."
export AURUM_POSTGRES_DB=aurum_e2e
export AURUM_POSTGRES_PASSWORD=aurum-e2e-only
export AURUM_WEB_PORT=3100
COMPOSE="docker compose -p aurum-e2e"
cleanup() {
$COMPOSE down -v
}
trap cleanup EXIT
$COMPOSE up -d --build
echo "waiting for http://localhost:${AURUM_WEB_PORT} ..."
for _ in $(seq 1 60); do
if curl -sf "http://localhost:${AURUM_WEB_PORT}" >/dev/null; then
echo "stack is up"
break
fi
sleep 1
done
cd e2e
npm install
AURUM_E2E_BASE_URL="http://localhost:${AURUM_WEB_PORT}" npx playwright test "$@"

View File

@@ -0,0 +1,60 @@
import { test, expect } from "@playwright/test";
import { createTransaction, getCategoryId, getDefaultAccountId } from "./helpers";
// Regression for v0.15.4: a transaction description with no spaces (so it
// can't wrap) blew out the Dashboard's CSS Grid because Card had no
// min-width: 0, growing the whole page wider than the viewport instead of
// letting `truncate` ellipsize the text. See UPDATES.md (v0.15.4) and
// frontend/src/components/ui/Card.tsx.
const LONG_DESCRIPTION =
"оченьдлинноеописаниебезпробеловкотороедолжнообрезатьсямноготочиемачтобынеломатьверсткукарточкинадашборде1234567890";
test("a long unbroken transaction description never causes horizontal page overflow", async ({ page, request }) => {
const accountId = await getDefaultAccountId(request);
const categoryId = await getCategoryId(request, "Groceries");
await createTransaction(request, {
account_id: accountId,
category_id: categoryId,
type: "expense",
amount: "12.34",
description: LONG_DESCRIPTION,
date: "2016-08-01",
});
await page.goto("/");
await page.getByRole("button", { name: /^\d{4}$/ }).first().click();
await page.getByRole("option", { name: "2016" }).click();
await page.getByRole("button", { name: "Авг", exact: true }).click();
await expect(page.getByText(LONG_DESCRIPTION.slice(0, 20), { exact: false })).toBeVisible();
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
});
test("same check on a narrow mobile viewport", async ({ page, request }) => {
await page.setViewportSize({ width: 375, height: 812 });
const accountId = await getDefaultAccountId(request);
const categoryId = await getCategoryId(request, "Groceries");
await createTransaction(request, {
account_id: accountId,
category_id: categoryId,
type: "expense",
amount: "12.34",
description: LONG_DESCRIPTION,
date: "2016-09-01",
});
await page.goto("/");
await page.getByRole("button", { name: /^\d{4}$/ }).first().click();
await page.getByRole("option", { name: "2016" }).click();
await page.getByRole("button", { name: "Сен", exact: true }).click();
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
});

View File

@@ -0,0 +1,38 @@
import { test, expect } from "@playwright/test";
import { createTransaction, getCategoryId, getDefaultAccountId } from "./helpers";
// Regression for the Aug 2026 bug: RecentTransactionsCard queried
// transactions without year/month at all, so switching the Dashboard's
// month did nothing — it just showed the latest N transactions globally,
// including ones from a completely different month. See UPDATES.md
// (v0.15.3) and frontend/src/components/dashboard/RecentTransactionsCard.tsx.
test("Recent Transactions on Dashboard only shows the selected month", async ({ page, request }) => {
const accountId = await getDefaultAccountId(request);
const categoryId = await getCategoryId(request, "Groceries");
await createTransaction(request, {
account_id: accountId,
category_id: categoryId,
type: "expense",
amount: "12.00",
description: "july-2015-only-txn",
date: "2015-07-26",
});
await createTransaction(request, {
account_id: accountId,
category_id: categoryId,
type: "expense",
amount: "34.00",
description: "august-2015-only-txn",
date: "2015-08-08",
});
await page.goto("/");
await page.getByRole("button", { name: /^\d{4}$/ }).first().click();
await page.getByRole("option", { name: "2015" }).click();
await page.getByRole("button", { name: "Авг", exact: true }).click();
const recentCard = page.locator("div.rounded-xl", { has: page.getByText("Последние транзакции", { exact: true }) });
await expect(recentCard.getByText("august-2015-only-txn")).toBeVisible();
await expect(recentCard.getByText("july-2015-only-txn")).toHaveCount(0);
});

View File

@@ -0,0 +1,31 @@
import { test, expect } from "@playwright/test";
import { createTransaction, getCategoryId, getDefaultAccountId } from "./helpers";
// Regression for v0.17.1: the "All transactions" link on Dashboard always
// went to /transactions with no params, so it silently reset to the
// current month even if you were looking at a different one. It should
// carry the selected year/month through as query params. See UPDATES.md
// (v0.17.1) and frontend/src/components/dashboard/RecentTransactionsCard.tsx.
test("Dashboard's All transactions link opens Transactions on the same month", async ({ page, request }) => {
const accountId = await getDefaultAccountId(request);
const categoryId = await getCategoryId(request, "Groceries");
await createTransaction(request, {
account_id: accountId,
category_id: categoryId,
type: "expense",
amount: "42.00",
description: "2018-march-txn",
date: "2018-03-15",
});
await page.goto("/");
await page.getByRole("button", { name: /^\d{4}$/ }).first().click();
await page.getByRole("option", { name: "2018" }).click();
await page.getByRole("button", { name: "Мар", exact: true }).click();
await page.getByRole("link", { name: "Все транзакции" }).click();
await expect(page).toHaveURL(/\/transactions\?year=2018&month=3/);
await expect(page.getByRole("button", { name: "2018", exact: true })).toBeVisible();
await expect(page.getByText("2018-march-txn")).toBeVisible();
});

36
e2e/tests/helpers.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { APIRequestContext } from "@playwright/test";
/** Thin wrapper over the real HTTP API (proxied at /api by nginx, same as
* the frontend uses) — fast, reliable fixture setup so each spec can drive
* the actual UI against known data instead of guessing at pre-existing
* account/category IDs. */
export async function getDefaultAccountId(request: APIRequestContext): Promise<number> {
const resp = await request.get("/api/accounts");
const accounts = await resp.json();
return accounts[0].id;
}
export async function getCategoryId(request: APIRequestContext, name: string): Promise<number> {
const resp = await request.get("/api/categories");
const categories = await resp.json();
const match = categories.find((c: { name: string }) => c.name === name);
if (!match) throw new Error(`category "${name}" not found`);
return match.id;
}
export interface TransactionInput {
account_id: number;
category_id?: number;
type: "income" | "expense" | "transfer";
amount: string;
description: string;
date: string; // YYYY-MM-DD
}
export async function createTransaction(request: APIRequestContext, input: TransactionInput): Promise<void> {
const resp = await request.post("/api/transactions", { data: input });
if (!resp.ok()) {
throw new Error(`failed to create transaction: ${resp.status()} ${await resp.text()}`);
}
}

View File

@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test";
// Regression for v0.17.1: Net Worth defaulted to a 30-day window, which
// frequently shows a dip just from short-window noise even when the
// long-run trend is a climb — demotivating for no good reason. Should
// default to 5 years instead. See UPDATES.md (v0.17.1) and
// frontend/src/pages/NetWorthPage.tsx.
test("Net Worth opens with the 5Y range active, not 30D", async ({ page }) => {
await page.goto("/net-worth");
const fiveYearButton = page.getByRole("button", { name: "5Y", exact: true });
const thirtyDayButton = page.getByRole("button", { name: "30D", exact: true });
await expect(fiveYearButton).toBeVisible();
const fiveYearClass = await fiveYearButton.getAttribute("class");
const thirtyDayClass = await thirtyDayButton.getAttribute("class");
expect(fiveYearClass).toContain("bg-surface-2");
expect(thirtyDayClass).not.toContain("bg-surface-2");
});

View File

@@ -0,0 +1,34 @@
import { test, expect } from "@playwright/test";
import { createTransaction, getCategoryId, getDefaultAccountId } from "./helpers";
// Regression for v0.16.0/v0.16.1: `year` on Dashboard/Transactions/Budget
// used to be `const [year] = useState(...)` — no setter, so no UI could
// ever change it and every page was stuck on the current year forever.
// This asserts switching the year picker actually changes the numbers
// shown, not just that the picker's own visual state updates.
//
// Uses a sub-1000 amount so the assertion doesn't have to guess at
// locale-specific thousands-separator formatting (space vs comma vs none).
test("switching the Dashboard year picker changes the displayed income", async ({ page, request }) => {
const accountId = await getDefaultAccountId(request);
const salaryId = await getCategoryId(request, "Salary");
await createTransaction(request, {
account_id: accountId,
category_id: salaryId,
type: "income",
amount: "999.00",
description: "2017 august salary",
date: "2017-08-01",
});
await page.goto("/");
await page.getByRole("button", { name: "Авг", exact: true }).click();
const realIncomeValue = page.locator('xpath=//p[text()="Реальный доход"]/following-sibling::p[1]');
await expect(realIncomeValue).not.toContainText("999"); // fresh stack: nothing in the current year yet
await page.getByRole("button", { name: /^\d{4}$/ }).first().click();
await page.getByRole("option", { name: "2017" }).click();
await expect(realIncomeValue).toContainText("999");
});

View File

@@ -1,7 +1,7 @@
{ {
"name": "aurum-frontend", "name": "aurum-frontend",
"private": true, "private": true,
"version": "0.17.1", "version": "0.18.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",