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,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]
|
||||
Reference in New Issue
Block a user