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:
50
e2e/README.md
Normal file
50
e2e/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# E2E tests
|
||||
|
||||
Playwright driving a real browser against a real, fully isolated Aurum
|
||||
stack — not the self-hosted instance you actually use day to day. These
|
||||
tests create and read real data over the real HTTP API and real rendered
|
||||
UI, which is the only way to actually catch things like a CSS layout
|
||||
overflow or a filter that's silently ignored — a backend-only test suite
|
||||
can't see either.
|
||||
|
||||
## Isolation
|
||||
|
||||
`run.sh` brings up a *second*, throwaway stack alongside your real one:
|
||||
|
||||
| | real instance | e2e stack |
|
||||
|---|---|---|
|
||||
| compose project | `aurum` (default) | `aurum-e2e` |
|
||||
| Postgres database | `aurum` | `aurum_e2e` |
|
||||
| web port | 3000 | 3100 |
|
||||
|
||||
Different project name means different containers, network, and — crucially
|
||||
— a different named Postgres volume, so the e2e Postgres data directory is
|
||||
physically separate from `aurum_pgdata`. `run.sh` always tears the e2e stack
|
||||
down with `docker compose down -v` on exit (even on failure/Ctrl-C), so no
|
||||
test data is left behind and your real instance is never touched, restarted,
|
||||
or read from.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Pass extra Playwright CLI args through, e.g. to run one file or open the
|
||||
trace viewer on failure:
|
||||
|
||||
```bash
|
||||
./run.sh tests/year-switcher.spec.ts
|
||||
./run.sh --headed
|
||||
```
|
||||
|
||||
## Adding a test
|
||||
|
||||
- Seed data via the `request` fixture and the helpers in `tests/helpers.ts`
|
||||
(hits `/api/...` directly — fast and doesn't depend on clicking through a
|
||||
form). Then drive the actual page for the behavior under test.
|
||||
- Tests run with `workers: 1` against one shared backend/DB within a single
|
||||
`run.sh` invocation, so give each spec's seeded data a distinct year/month
|
||||
(or otherwise non-overlapping scope) rather than relying on a clean slate
|
||||
between spec files.
|
||||
76
e2e/package-lock.json
generated
Normal file
76
e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "aurum-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aurum-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
e2e/package.json
Normal file
11
e2e/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "aurum-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1"
|
||||
}
|
||||
}
|
||||
19
e2e/playwright.config.ts
Normal file
19
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
// Points at the throwaway stack run.sh brings up (see README) — never the
|
||||
// user's real self-hosted instance, so tests can freely create/delete data.
|
||||
const baseURL = process.env.AURUM_E2E_BASE_URL ?? "http://localhost:3100";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: false, // tests share one backend/DB and assert on exact totals — no isolation between them
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
viewport: { width: 1440, height: 900 },
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
});
|
||||
37
e2e/run.sh
Executable file
37
e2e/run.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Spins up a throwaway Aurum stack for E2E tests, runs Playwright against it,
|
||||
# then always tears the stack down again — including its Postgres volume.
|
||||
#
|
||||
# Fully isolated from the real self-hosted instance:
|
||||
# - separate compose project name (aurum-e2e) -> separate containers/network
|
||||
# - separate Postgres database name (aurum_e2e) -> separate data, same server
|
||||
# - separate host port (3100, vs the real one's 3000) -> no port clash
|
||||
# - `down -v` on exit removes the isolated volume, so no test data persists
|
||||
# and the real `aurum`/`aurum_pgdata` are never opened.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
export AURUM_POSTGRES_DB=aurum_e2e
|
||||
export AURUM_POSTGRES_PASSWORD=aurum-e2e-only
|
||||
export AURUM_WEB_PORT=3100
|
||||
COMPOSE="docker compose -p aurum-e2e"
|
||||
|
||||
cleanup() {
|
||||
$COMPOSE down -v
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
$COMPOSE up -d --build
|
||||
|
||||
echo "waiting for http://localhost:${AURUM_WEB_PORT} ..."
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -sf "http://localhost:${AURUM_WEB_PORT}" >/dev/null; then
|
||||
echo "stack is up"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
cd e2e
|
||||
npm install
|
||||
AURUM_E2E_BASE_URL="http://localhost:${AURUM_WEB_PORT}" npx playwright test "$@"
|
||||
60
e2e/tests/dashboard-long-description-no-overflow.spec.ts
Normal file
60
e2e/tests/dashboard-long-description-no-overflow.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { createTransaction, getCategoryId, getDefaultAccountId } from "./helpers";
|
||||
|
||||
// Regression for v0.15.4: a transaction description with no spaces (so it
|
||||
// can't wrap) blew out the Dashboard's CSS Grid because Card had no
|
||||
// min-width: 0, growing the whole page wider than the viewport instead of
|
||||
// letting `truncate` ellipsize the text. See UPDATES.md (v0.15.4) and
|
||||
// frontend/src/components/ui/Card.tsx.
|
||||
const LONG_DESCRIPTION =
|
||||
"оченьдлинноеописаниебезпробеловкотороедолжнообрезатьсямноготочиемачтобынеломатьверсткукарточкинадашборде1234567890";
|
||||
|
||||
test("a long unbroken transaction description never causes horizontal page overflow", async ({ page, request }) => {
|
||||
const accountId = await getDefaultAccountId(request);
|
||||
const categoryId = await getCategoryId(request, "Groceries");
|
||||
await createTransaction(request, {
|
||||
account_id: accountId,
|
||||
category_id: categoryId,
|
||||
type: "expense",
|
||||
amount: "12.34",
|
||||
description: LONG_DESCRIPTION,
|
||||
date: "2016-08-01",
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: /^\d{4}$/ }).first().click();
|
||||
await page.getByRole("option", { name: "2016" }).click();
|
||||
await page.getByRole("button", { name: "Авг", exact: true }).click();
|
||||
await expect(page.getByText(LONG_DESCRIPTION.slice(0, 20), { exact: false })).toBeVisible();
|
||||
|
||||
const overflow = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
|
||||
});
|
||||
|
||||
test("same check on a narrow mobile viewport", async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
const accountId = await getDefaultAccountId(request);
|
||||
const categoryId = await getCategoryId(request, "Groceries");
|
||||
await createTransaction(request, {
|
||||
account_id: accountId,
|
||||
category_id: categoryId,
|
||||
type: "expense",
|
||||
amount: "12.34",
|
||||
description: LONG_DESCRIPTION,
|
||||
date: "2016-09-01",
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: /^\d{4}$/ }).first().click();
|
||||
await page.getByRole("option", { name: "2016" }).click();
|
||||
await page.getByRole("button", { name: "Сен", exact: true }).click();
|
||||
|
||||
const overflow = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
|
||||
});
|
||||
38
e2e/tests/dashboard-recent-transactions.spec.ts
Normal file
38
e2e/tests/dashboard-recent-transactions.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { createTransaction, getCategoryId, getDefaultAccountId } from "./helpers";
|
||||
|
||||
// Regression for the Aug 2026 bug: RecentTransactionsCard queried
|
||||
// transactions without year/month at all, so switching the Dashboard's
|
||||
// month did nothing — it just showed the latest N transactions globally,
|
||||
// including ones from a completely different month. See UPDATES.md
|
||||
// (v0.15.3) and frontend/src/components/dashboard/RecentTransactionsCard.tsx.
|
||||
test("Recent Transactions on Dashboard only shows the selected month", async ({ page, request }) => {
|
||||
const accountId = await getDefaultAccountId(request);
|
||||
const categoryId = await getCategoryId(request, "Groceries");
|
||||
|
||||
await createTransaction(request, {
|
||||
account_id: accountId,
|
||||
category_id: categoryId,
|
||||
type: "expense",
|
||||
amount: "12.00",
|
||||
description: "july-2015-only-txn",
|
||||
date: "2015-07-26",
|
||||
});
|
||||
await createTransaction(request, {
|
||||
account_id: accountId,
|
||||
category_id: categoryId,
|
||||
type: "expense",
|
||||
amount: "34.00",
|
||||
description: "august-2015-only-txn",
|
||||
date: "2015-08-08",
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: /^\d{4}$/ }).first().click();
|
||||
await page.getByRole("option", { name: "2015" }).click();
|
||||
await page.getByRole("button", { name: "Авг", exact: true }).click();
|
||||
|
||||
const recentCard = page.locator("div.rounded-xl", { has: page.getByText("Последние транзакции", { exact: true }) });
|
||||
await expect(recentCard.getByText("august-2015-only-txn")).toBeVisible();
|
||||
await expect(recentCard.getByText("july-2015-only-txn")).toHaveCount(0);
|
||||
});
|
||||
31
e2e/tests/dashboard-to-transactions-deeplink.spec.ts
Normal file
31
e2e/tests/dashboard-to-transactions-deeplink.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { createTransaction, getCategoryId, getDefaultAccountId } from "./helpers";
|
||||
|
||||
// Regression for v0.17.1: the "All transactions" link on Dashboard always
|
||||
// went to /transactions with no params, so it silently reset to the
|
||||
// current month even if you were looking at a different one. It should
|
||||
// carry the selected year/month through as query params. See UPDATES.md
|
||||
// (v0.17.1) and frontend/src/components/dashboard/RecentTransactionsCard.tsx.
|
||||
test("Dashboard's All transactions link opens Transactions on the same month", async ({ page, request }) => {
|
||||
const accountId = await getDefaultAccountId(request);
|
||||
const categoryId = await getCategoryId(request, "Groceries");
|
||||
await createTransaction(request, {
|
||||
account_id: accountId,
|
||||
category_id: categoryId,
|
||||
type: "expense",
|
||||
amount: "42.00",
|
||||
description: "2018-march-txn",
|
||||
date: "2018-03-15",
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: /^\d{4}$/ }).first().click();
|
||||
await page.getByRole("option", { name: "2018" }).click();
|
||||
await page.getByRole("button", { name: "Мар", exact: true }).click();
|
||||
|
||||
await page.getByRole("link", { name: "Все транзакции" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/transactions\?year=2018&month=3/);
|
||||
await expect(page.getByRole("button", { name: "2018", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("2018-march-txn")).toBeVisible();
|
||||
});
|
||||
36
e2e/tests/helpers.ts
Normal file
36
e2e/tests/helpers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { APIRequestContext } from "@playwright/test";
|
||||
|
||||
/** Thin wrapper over the real HTTP API (proxied at /api by nginx, same as
|
||||
* the frontend uses) — fast, reliable fixture setup so each spec can drive
|
||||
* the actual UI against known data instead of guessing at pre-existing
|
||||
* account/category IDs. */
|
||||
|
||||
export async function getDefaultAccountId(request: APIRequestContext): Promise<number> {
|
||||
const resp = await request.get("/api/accounts");
|
||||
const accounts = await resp.json();
|
||||
return accounts[0].id;
|
||||
}
|
||||
|
||||
export async function getCategoryId(request: APIRequestContext, name: string): Promise<number> {
|
||||
const resp = await request.get("/api/categories");
|
||||
const categories = await resp.json();
|
||||
const match = categories.find((c: { name: string }) => c.name === name);
|
||||
if (!match) throw new Error(`category "${name}" not found`);
|
||||
return match.id;
|
||||
}
|
||||
|
||||
export interface TransactionInput {
|
||||
account_id: number;
|
||||
category_id?: number;
|
||||
type: "income" | "expense" | "transfer";
|
||||
amount: string;
|
||||
description: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export async function createTransaction(request: APIRequestContext, input: TransactionInput): Promise<void> {
|
||||
const resp = await request.post("/api/transactions", { data: input });
|
||||
if (!resp.ok()) {
|
||||
throw new Error(`failed to create transaction: ${resp.status()} ${await resp.text()}`);
|
||||
}
|
||||
}
|
||||
19
e2e/tests/net-worth-default-range.spec.ts
Normal file
19
e2e/tests/net-worth-default-range.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Regression for v0.17.1: Net Worth defaulted to a 30-day window, which
|
||||
// frequently shows a dip just from short-window noise even when the
|
||||
// long-run trend is a climb — demotivating for no good reason. Should
|
||||
// default to 5 years instead. See UPDATES.md (v0.17.1) and
|
||||
// frontend/src/pages/NetWorthPage.tsx.
|
||||
test("Net Worth opens with the 5Y range active, not 30D", async ({ page }) => {
|
||||
await page.goto("/net-worth");
|
||||
|
||||
const fiveYearButton = page.getByRole("button", { name: "5Y", exact: true });
|
||||
const thirtyDayButton = page.getByRole("button", { name: "30D", exact: true });
|
||||
await expect(fiveYearButton).toBeVisible();
|
||||
|
||||
const fiveYearClass = await fiveYearButton.getAttribute("class");
|
||||
const thirtyDayClass = await thirtyDayButton.getAttribute("class");
|
||||
expect(fiveYearClass).toContain("bg-surface-2");
|
||||
expect(thirtyDayClass).not.toContain("bg-surface-2");
|
||||
});
|
||||
34
e2e/tests/year-switcher.spec.ts
Normal file
34
e2e/tests/year-switcher.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { createTransaction, getCategoryId, getDefaultAccountId } from "./helpers";
|
||||
|
||||
// Regression for v0.16.0/v0.16.1: `year` on Dashboard/Transactions/Budget
|
||||
// used to be `const [year] = useState(...)` — no setter, so no UI could
|
||||
// ever change it and every page was stuck on the current year forever.
|
||||
// This asserts switching the year picker actually changes the numbers
|
||||
// shown, not just that the picker's own visual state updates.
|
||||
//
|
||||
// Uses a sub-1000 amount so the assertion doesn't have to guess at
|
||||
// locale-specific thousands-separator formatting (space vs comma vs none).
|
||||
test("switching the Dashboard year picker changes the displayed income", async ({ page, request }) => {
|
||||
const accountId = await getDefaultAccountId(request);
|
||||
const salaryId = await getCategoryId(request, "Salary");
|
||||
await createTransaction(request, {
|
||||
account_id: accountId,
|
||||
category_id: salaryId,
|
||||
type: "income",
|
||||
amount: "999.00",
|
||||
description: "2017 august salary",
|
||||
date: "2017-08-01",
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Авг", exact: true }).click();
|
||||
|
||||
const realIncomeValue = page.locator('xpath=//p[text()="Реальный доход"]/following-sibling::p[1]');
|
||||
await expect(realIncomeValue).not.toContainText("999"); // fresh stack: nothing in the current year yet
|
||||
|
||||
await page.getByRole("button", { name: /^\d{4}$/ }).first().click();
|
||||
await page.getByRole("option", { name: "2017" }).click();
|
||||
|
||||
await expect(realIncomeValue).toContainText("999");
|
||||
});
|
||||
Reference in New Issue
Block a user