test: real backend pytest + E2E Playwright suites, both isolated from prod data

backend/tests/: 33 pytest tests against a real Postgres database
(aurum_test, created/migrated/dropped per run) exercised through the actual
HTTP API — not sqlite, not mocks, and the real `aurum` database is never
opened (app lifespan is skipped, get_session is overridden per test).
Covers transaction category-kind validation, year/month/date-range
filtering and pagination, dashboard summary arithmetic, budget status, cash
flow, and the /transactions/years picker.

e2e/: 6 Playwright specs driving a real browser against a fully separate
throwaway docker-compose stack (run.sh: separate project name, database,
port, and volume, torn down with `down -v` on exit) — regression coverage
for the Recent Transactions month bug, the Card grid-overflow bug, the
frozen-year bug, the Net Worth default range, and the Dashboard->
Transactions deep link, all fixed earlier this session.

Also synced the backend's APP_VERSION constant (stale at 0.15.2, embedded
in exported backups) with the frontend version.
This commit is contained in:
ZProger
2026-08-16 22:56:53 +03:00
parent 98c61d50d8
commit 8e46726b4d
24 changed files with 1104 additions and 2 deletions

50
e2e/README.md Normal file
View File

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

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

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

11
e2e/package.json Normal file
View File

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

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

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

37
e2e/run.sh Executable file
View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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