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

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");
});