From 8e46726b4d60d09b30dc19caa21d3009be89671a Mon Sep 17 00:00:00 2001 From: ZProger Date: Sun, 16 Aug 2026 22:56:53 +0300 Subject: [PATCH] test: real backend pytest + E2E Playwright suites, both isolated from prod data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 6 + backend/app/core/config.py | 2 +- backend/pytest.ini | 5 + backend/requirements-dev.txt | 8 + backend/tests/README.md | 33 +++ backend/tests/__init__.py | 0 backend/tests/conftest.py | 137 ++++++++++++ backend/tests/helpers.py | 22 ++ backend/tests/test_budgets.py | 82 +++++++ backend/tests/test_cash_flow.py | 84 ++++++++ backend/tests/test_dashboard.py | 110 ++++++++++ backend/tests/test_transactions.py | 204 ++++++++++++++++++ e2e/README.md | 50 +++++ e2e/package-lock.json | 76 +++++++ e2e/package.json | 11 + e2e/playwright.config.ts | 19 ++ e2e/run.sh | 37 ++++ ...board-long-description-no-overflow.spec.ts | 60 ++++++ .../dashboard-recent-transactions.spec.ts | 38 ++++ ...dashboard-to-transactions-deeplink.spec.ts | 31 +++ e2e/tests/helpers.ts | 36 ++++ e2e/tests/net-worth-default-range.spec.ts | 19 ++ e2e/tests/year-switcher.spec.ts | 34 +++ frontend/package.json | 2 +- 24 files changed, 1104 insertions(+), 2 deletions(-) create mode 100644 backend/pytest.ini create mode 100644 backend/requirements-dev.txt create mode 100644 backend/tests/README.md create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/helpers.py create mode 100644 backend/tests/test_budgets.py create mode 100644 backend/tests/test_cash_flow.py create mode 100644 backend/tests/test_dashboard.py create mode 100644 backend/tests/test_transactions.py create mode 100644 e2e/README.md create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100755 e2e/run.sh create mode 100644 e2e/tests/dashboard-long-description-no-overflow.spec.ts create mode 100644 e2e/tests/dashboard-recent-transactions.spec.ts create mode 100644 e2e/tests/dashboard-to-transactions-deeplink.spec.ts create mode 100644 e2e/tests/helpers.ts create mode 100644 e2e/tests/net-worth-default-range.spec.ts create mode 100644 e2e/tests/year-switcher.spec.ts diff --git a/.gitignore b/.gitignore index 0625fc9..3bffad0 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 3fb78b4..21659f8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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): diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..ea3fb65 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +pythonpath = . +testpaths = tests diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..8f93b12 --- /dev/null +++ b/backend/requirements-dev.txt @@ -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 diff --git a/backend/tests/README.md b/backend/tests/README.md new file mode 100644 index 0000000..b67c83a --- /dev/null +++ b/backend/tests/README.md @@ -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. diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..302dd4a --- /dev/null +++ b/backend/tests/conftest.py @@ -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()} diff --git a/backend/tests/helpers.py b/backend/tests/helpers.py new file mode 100644 index 0000000..e97b227 --- /dev/null +++ b/backend/tests/helpers.py @@ -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 diff --git a/backend/tests/test_budgets.py b/backend/tests/test_budgets.py new file mode 100644 index 0000000..d499ffb --- /dev/null +++ b/backend/tests/test_budgets.py @@ -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"] == [] diff --git a/backend/tests/test_cash_flow.py b/backend/tests/test_cash_flow.py new file mode 100644 index 0000000..044ae08 --- /dev/null +++ b/backend/tests/test_cash_flow.py @@ -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") diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py new file mode 100644 index 0000000..304af7e --- /dev/null +++ b/backend/tests/test_dashboard.py @@ -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") diff --git a/backend/tests/test_transactions.py b/backend/tests/test_transactions.py new file mode 100644 index 0000000..adb2646 --- /dev/null +++ b/backend/tests/test_transactions.py @@ -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] diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..6451fdb --- /dev/null +++ b/e2e/README.md @@ -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. diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..a79d713 --- /dev/null +++ b/e2e/package-lock.json @@ -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" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..03124b4 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "aurum-e2e", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.62.1" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..f9d317d --- /dev/null +++ b/e2e/playwright.config.ts @@ -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", + }, +}); diff --git a/e2e/run.sh b/e2e/run.sh new file mode 100755 index 0000000..37d7c0d --- /dev/null +++ b/e2e/run.sh @@ -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 "$@" diff --git a/e2e/tests/dashboard-long-description-no-overflow.spec.ts b/e2e/tests/dashboard-long-description-no-overflow.spec.ts new file mode 100644 index 0000000..b34d909 --- /dev/null +++ b/e2e/tests/dashboard-long-description-no-overflow.spec.ts @@ -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); +}); diff --git a/e2e/tests/dashboard-recent-transactions.spec.ts b/e2e/tests/dashboard-recent-transactions.spec.ts new file mode 100644 index 0000000..267e730 --- /dev/null +++ b/e2e/tests/dashboard-recent-transactions.spec.ts @@ -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); +}); diff --git a/e2e/tests/dashboard-to-transactions-deeplink.spec.ts b/e2e/tests/dashboard-to-transactions-deeplink.spec.ts new file mode 100644 index 0000000..4550402 --- /dev/null +++ b/e2e/tests/dashboard-to-transactions-deeplink.spec.ts @@ -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(); +}); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts new file mode 100644 index 0000000..ce1c5fe --- /dev/null +++ b/e2e/tests/helpers.ts @@ -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 { + 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 { + 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 { + const resp = await request.post("/api/transactions", { data: input }); + if (!resp.ok()) { + throw new Error(`failed to create transaction: ${resp.status()} ${await resp.text()}`); + } +} diff --git a/e2e/tests/net-worth-default-range.spec.ts b/e2e/tests/net-worth-default-range.spec.ts new file mode 100644 index 0000000..d282c07 --- /dev/null +++ b/e2e/tests/net-worth-default-range.spec.ts @@ -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"); +}); diff --git a/e2e/tests/year-switcher.spec.ts b/e2e/tests/year-switcher.spec.ts new file mode 100644 index 0000000..eaa4ef0 --- /dev/null +++ b/e2e/tests/year-switcher.spec.ts @@ -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"); +}); diff --git a/frontend/package.json b/frontend/package.json index b8994c3..6f894ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "0.17.1", + "version": "0.18.0", "type": "module", "scripts": { "dev": "vite",