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
|
||||
.venv/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
|
||||
# Node / frontend
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
|
||||
# Playwright (e2e/) run artifacts
|
||||
/e2e/test-results/
|
||||
/e2e/playwright-report/
|
||||
/e2e/.playwright/
|
||||
|
||||
# Editors / OS / local tooling
|
||||
.DS_Store
|
||||
.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
|
||||
# title and embedded in exported backups so an old file can be told apart
|
||||
# from a current one.
|
||||
APP_VERSION = "0.15.2"
|
||||
APP_VERSION = "0.18.0"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
5
backend/pytest.ini
Normal file
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",
|
||||
"private": true,
|
||||
"version": "0.17.1",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user