104 lines
4.5 KiB
JavaScript
104 lines
4.5 KiB
JavaScript
|
|
// Authenticated critical path (docs/QA_PLAN.md B15). Runs logged-in via the
|
||
|
|
// storageState produced by auth.setup.js against the seeded scratch DB.
|
||
|
|
//
|
||
|
|
// Selectors are grounded in real DOM (no test-ids exist yet):
|
||
|
|
// - quick-pay: StatusBadge button, title "Click to mark paid" / "...unpaid"
|
||
|
|
// - bills: seeded names from scripts/seedDemoData.js (e.g. "Electric Company")
|
||
|
|
// - nav: sidebar link labels from client/components/layout/Sidebar.jsx
|
||
|
|
const { test, expect } = require('@playwright/test');
|
||
|
|
const { STORAGE_STATE } = require('./constants');
|
||
|
|
|
||
|
|
test.use({ storageState: STORAGE_STATE });
|
||
|
|
|
||
|
|
test('tracker renders seeded bills', async ({ page }) => {
|
||
|
|
await page.goto('/');
|
||
|
|
// Date-independent proof the seeded month loaded with rows.
|
||
|
|
await expect(page.getByText(/\d+ bills/).first()).toBeVisible();
|
||
|
|
// At least one clickable status toggle is rendered (seeded bills are mostly
|
||
|
|
// autopay → "autodraft", so the toggle usually reads "mark unpaid"). The app
|
||
|
|
// renders both a desktop table row and a hidden mobile row, so scope to :visible.
|
||
|
|
const toggles = page.locator('button[title="Click to mark paid"]:visible, button[title="Click to mark unpaid"]:visible');
|
||
|
|
await expect(toggles.first()).toBeVisible();
|
||
|
|
});
|
||
|
|
|
||
|
|
test("toggling a bill's paid status updates then restores it (desktop)", async ({ page, isMobile }) => {
|
||
|
|
test.skip(isMobile, 'mobile rows use a different control; covered on desktop');
|
||
|
|
await page.goto('/');
|
||
|
|
|
||
|
|
// :visible avoids the hidden mobile-row duplicates the app also renders.
|
||
|
|
const pay = page.locator('button[title="Click to mark paid"]:visible'); // currently unpaid
|
||
|
|
const paid = page.locator('button[title="Click to mark unpaid"]:visible'); // currently paid/autodraft
|
||
|
|
await expect(pay.or(paid).first()).toBeVisible();
|
||
|
|
|
||
|
|
const p0 = await pay.count();
|
||
|
|
const u0 = await paid.count();
|
||
|
|
expect(p0 + u0).toBeGreaterThan(0);
|
||
|
|
|
||
|
|
// Marking a bill PAID is immediate; marking it UNPAID pops a "Remove Payment"
|
||
|
|
// confirmation (the deletion-safety flow) that must be confirmed.
|
||
|
|
const confirmRemoveIfPrompted = async () => {
|
||
|
|
await page
|
||
|
|
.getByRole('button', { name: 'Remove Payment' })
|
||
|
|
.click({ timeout: 3000 })
|
||
|
|
.catch(() => {});
|
||
|
|
};
|
||
|
|
|
||
|
|
// Toggle one bill and confirm the optimistic update reconciles (counts shift by
|
||
|
|
// exactly one), then toggle back and confirm the original counts return — no
|
||
|
|
// double-count, no stuck state. Direction depends on the seed's mostly-autopay bills.
|
||
|
|
if (p0 > 0) {
|
||
|
|
await pay.first().click();
|
||
|
|
await expect(pay).toHaveCount(p0 - 1);
|
||
|
|
await expect(paid).toHaveCount(u0 + 1);
|
||
|
|
|
||
|
|
await paid.first().click();
|
||
|
|
await confirmRemoveIfPrompted();
|
||
|
|
await expect(pay).toHaveCount(p0);
|
||
|
|
await expect(paid).toHaveCount(u0);
|
||
|
|
} else {
|
||
|
|
await paid.first().click();
|
||
|
|
await confirmRemoveIfPrompted();
|
||
|
|
await expect(paid).toHaveCount(u0 - 1);
|
||
|
|
await expect(pay).toHaveCount(p0 + 1);
|
||
|
|
|
||
|
|
await pay.first().click();
|
||
|
|
await expect(paid).toHaveCount(u0);
|
||
|
|
await expect(pay).toHaveCount(p0);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('authenticated pages render without server errors or crashes', async ({ page }) => {
|
||
|
|
const pageErrors = [];
|
||
|
|
const serverErrors = [];
|
||
|
|
page.on('pageerror', (e) => pageErrors.push(String(e)));
|
||
|
|
page.on('response', (r) => {
|
||
|
|
if (r.url().includes('/api/') && r.status() >= 500) serverErrors.push(`${r.status()} ${r.url()}`);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Mirrors the sidebar; navigate by URL so it's viewport-independent.
|
||
|
|
for (const path of ['/', '/summary', '/bills', '/calendar', '/analytics', '/spending', '/snowball', '/categories']) {
|
||
|
|
await page.goto(path);
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
await expect(page.locator('body')).not.toBeEmpty();
|
||
|
|
}
|
||
|
|
|
||
|
|
expect(pageErrors, `uncaught page errors:\n${pageErrors.join('\n')}`).toEqual([]);
|
||
|
|
expect(serverErrors, `5xx API responses:\n${serverErrors.join('\n')}`).toEqual([]);
|
||
|
|
|
||
|
|
// TODO: once Summary's DOM is pinned, assert its paid/unpaid/remaining totals
|
||
|
|
// reconcile numerically with the Tracker for the same month (QA_PLAN B5).
|
||
|
|
});
|
||
|
|
|
||
|
|
test('top nav navigates to Analytics (desktop)', async ({ page, isMobile }) => {
|
||
|
|
test.skip(isMobile, 'nav collapses to a menu on mobile');
|
||
|
|
await page.goto('/');
|
||
|
|
// Desktop uses a top nav ("Analytics" is a direct item; most pages live under
|
||
|
|
// the "Tracker" dropdown). Click the visible Analytics nav item.
|
||
|
|
const analytics = page
|
||
|
|
.getByRole('link', { name: 'Analytics' })
|
||
|
|
.or(page.getByRole('button', { name: 'Analytics' }))
|
||
|
|
.first();
|
||
|
|
await analytics.click();
|
||
|
|
await expect(page).toHaveURL(/\/analytics$/);
|
||
|
|
});
|