mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-12 18:46:40 +07:00
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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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
5
backend/pytest.ini
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
asyncio_default_fixture_loop_scope = function
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = tests
|
||||||
8
backend/requirements-dev.txt
Normal file
8
backend/requirements-dev.txt
Normal 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
33
backend/tests/README.md
Normal 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.
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
137
backend/tests/conftest.py
Normal file
137
backend/tests/conftest.py
Normal 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
22
backend/tests/helpers.py
Normal 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
|
||||||
82
backend/tests/test_budgets.py
Normal file
82
backend/tests/test_budgets.py
Normal 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"] == []
|
||||||
84
backend/tests/test_cash_flow.py
Normal file
84
backend/tests/test_cash_flow.py
Normal 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")
|
||||||
110
backend/tests/test_dashboard.py
Normal file
110
backend/tests/test_dashboard.py
Normal 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")
|
||||||
204
backend/tests/test_transactions.py
Normal file
204
backend/tests/test_transactions.py
Normal 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
50
e2e/README.md
Normal 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
76
e2e/package-lock.json
generated
Normal 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
11
e2e/package.json
Normal 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
19
e2e/playwright.config.ts
Normal 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
37
e2e/run.sh
Executable 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 "$@"
|
||||||
60
e2e/tests/dashboard-long-description-no-overflow.spec.ts
Normal file
60
e2e/tests/dashboard-long-description-no-overflow.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
38
e2e/tests/dashboard-recent-transactions.spec.ts
Normal file
38
e2e/tests/dashboard-recent-transactions.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
31
e2e/tests/dashboard-to-transactions-deeplink.spec.ts
Normal file
31
e2e/tests/dashboard-to-transactions-deeplink.spec.ts
Normal 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
36
e2e/tests/helpers.ts
Normal 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()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
e2e/tests/net-worth-default-range.spec.ts
Normal file
19
e2e/tests/net-worth-default-range.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
34
e2e/tests/year-switcher.spec.ts
Normal file
34
e2e/tests/year-switcher.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user