fix(qa): seed demo data amounts, bill amount validation, negative USD format, a11y aria-labels, Playwright E2E setup (batch 0.41.0 QA)
This commit is contained in:
parent
bdbf231538
commit
029c227685
|
|
@ -21,6 +21,14 @@ simplefin-bank-sync-issue.md
|
||||||
project-wide-data-input-and-sync-issue.md
|
project-wide-data-input-and-sync-issue.md
|
||||||
docs/SIMPLEFIN_CONSUMER_GUARDRAILS.md
|
docs/SIMPLEFIN_CONSUMER_GUARDRAILS.md
|
||||||
|
|
||||||
|
# Playwright E2E run artifacts (visual baselines under e2e/**/*-snapshots/ ARE committed)
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
.playwright/
|
||||||
|
e2e/.auth/
|
||||||
|
db/e2e.db*
|
||||||
|
|
||||||
# MkDocs docs site (auto-generated, not part of app source)
|
# MkDocs docs site (auto-generated, not part of app source)
|
||||||
mkdocs/
|
mkdocs/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
# Bill Tracker — Changelog
|
# Bill Tracker — Changelog
|
||||||
## v0.41.0
|
## v0.41.0
|
||||||
|
|
||||||
|
### 🐛 QA Fixes
|
||||||
|
|
||||||
|
- **[Data] Seed Demo Data amounts were 100× too small** — `scripts/seedDemoData.js` inserted demo dollar amounts straight into `bills.expected_amount` / `current_balance` / `minimum_payment`, which became integer-cents columns in the v1.03 migration, so a seeded "$85" bill showed as $0.85 (a whole demo month totalled ~$32 instead of ~$3,200). Now wraps demo values in `toCents()` before insert. Regression guard added in `e2e/api.probe.spec.js`. (was QA-B9-01)
|
||||||
|
- **[Bills] `expected_amount` was unvalidated** — POST/PUT `/api/bills` accepted negative amounts, non-numeric input (silently coerced to $0), and absurd values (`1e15` → cents past `Number.MAX_SAFE_INTEGER`). `validateBillData` (`services/billsService.js`) now rejects non-numeric / negative / out-of-range with a structured `VALIDATION_ERROR`, accepting `0`…`$100,000,000`. Regression assertions in `e2e/api.probe.spec.js`. (was QA-B13-01)
|
||||||
|
- **[UI] Negative amounts rendered as "$-50.00"** — client `fmt()` (`client/lib/utils.js`) and server `formatUSD()` (`utils/money.js`) placed the minus sign after the currency symbol; now render the conventional "-$50.00". Test added in `client/lib/utils.test.js`. (was QA-B6-01)
|
||||||
|
- **[a11y] Icon-only controls and chart SVGs had no accessible name** — Radix Select filter/sort triggers (Tracker, Bills) and the Spending month-nav buttons rendered with no discernible text (screen readers announced a bare "button"); Analytics chart `<svg role="img">`s had no name; a Snowball drag-handle `<div>` used a prohibited `aria-label`. Added `aria-label`s to the triggers/buttons and a `label` prop to the Analytics `SvgFrame`, and switched the drag-handle to `title`. Clears axe **critical `button-name`** and **serious `svg-img-alt` / `aria-prohibited-attr`** across those pages; guarded by `e2e/a11y.authed.spec.js`. (was QA-B14-01, part of QA-B14-02)
|
||||||
|
|
||||||
### ✨ Spending
|
### ✨ Spending
|
||||||
|
|
||||||
- **Category groups** — Organize spending categories into named groups (e.g. "Bills", "Everyday", "Subscriptions"). New `category_groups` table with CRUD endpoints. Categories can be assigned to a group via the Spending page or API. Groups appear as collapsible headers in the category breakdown.
|
- **Category groups** — Organize spending categories into named groups (e.g. "Bills", "Everyday", "Subscriptions"). New `category_groups` table with CRUD endpoints. Categories can be assigned to a group via the Spending page or API. Groups appear as collapsible headers in the category breakdown.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ export function cn(...inputs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fmt(amount) {
|
export function fmt(amount) {
|
||||||
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
const n = Number(amount) || 0;
|
||||||
|
const sign = n < 0 ? '-' : '';
|
||||||
|
return sign + '$' + Math.abs(n).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fmtDate(dateStr) {
|
export function fmtDate(dateStr) {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@ describe('fmt (money display)', () => {
|
||||||
expect(fmt(null)).toBe('$0.00');
|
expect(fmt(null)).toBe('$0.00');
|
||||||
expect(fmt(undefined)).toBe('$0.00');
|
expect(fmt(undefined)).toBe('$0.00');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('places the minus sign before the currency symbol (QA-B6-01)', () => {
|
||||||
|
expect(fmt(-50)).toBe('-$50.00');
|
||||||
|
expect(fmt(-1234.5)).toBe('-$1,234.50');
|
||||||
|
expect(fmt(-0.99)).toBe('-$0.99');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('local dates', () => {
|
describe('local dates', () => {
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,10 @@ function ChartCard({ title, subtitle, children, summary }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SvgFrame({ children, height = 260 }) {
|
function SvgFrame({ children, height = 260, label = 'Chart' }) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full overflow-hidden rounded-lg border border-border/60 bg-background/60">
|
<div className="w-full overflow-hidden rounded-lg border border-border/60 bg-background/60">
|
||||||
<svg viewBox={`0 0 720 ${height}`} role="img" className="h-auto w-full">
|
<svg viewBox={`0 0 720 ${height}`} role="img" aria-label={label} className="h-auto w-full">
|
||||||
{children}
|
{children}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -102,7 +102,7 @@ function LineChart({ rows, area = false }) {
|
||||||
const areaPoints = `${pad.left},${pad.top + chartH} ${line} ${pad.left + chartW},${pad.top + chartH}`;
|
const areaPoints = `${pad.left},${pad.top + chartH} ${line} ${pad.left + chartW},${pad.top + chartH}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SvgFrame height={height}>
|
<SvgFrame height={height} label="Trend line chart">
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map(tick => {
|
{[0, 0.25, 0.5, 0.75, 1].map(tick => {
|
||||||
const y = pad.top + chartH - tick * chartH;
|
const y = pad.top + chartH - tick * chartH;
|
||||||
return (
|
return (
|
||||||
|
|
@ -142,7 +142,7 @@ function GroupedBarChart({ rows }) {
|
||||||
const barW = Math.max(5, Math.min(17, groupW * 0.28));
|
const barW = Math.max(5, Math.min(17, groupW * 0.28));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SvgFrame height={height}>
|
<SvgFrame height={height} label="Grouped bar chart">
|
||||||
{[0, 0.5, 1].map(tick => {
|
{[0, 0.5, 1].map(tick => {
|
||||||
const y = pad.top + chartH - tick * chartH;
|
const y = pad.top + chartH - tick * chartH;
|
||||||
return (
|
return (
|
||||||
|
|
@ -191,7 +191,7 @@ function DonutChart({ rows }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-5 md:grid-cols-[260px_1fr] md:items-center">
|
<div className="grid gap-5 md:grid-cols-[260px_1fr] md:items-center">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<svg viewBox="0 0 220 220" role="img" className="h-56 w-56">
|
<svg viewBox="0 0 220 220" role="img" aria-label="Category breakdown donut chart" className="h-56 w-56">
|
||||||
<circle cx="110" cy="110" r={radius} fill="none" stroke="currentColor" strokeWidth="30" opacity="0.08" />
|
<circle cx="110" cy="110" r={radius} fill="none" stroke="currentColor" strokeWidth="30" opacity="0.08" />
|
||||||
{rows.map((row, index) => {
|
{rows.map((row, index) => {
|
||||||
const value = Number(row.total || 0);
|
const value = Number(row.total || 0);
|
||||||
|
|
@ -378,7 +378,7 @@ function ForecastChart({ historical, forecast }) {
|
||||||
const showLabel = (index) => allRows.length <= 14 || index % Math.ceil(allRows.length / 14) === 0;
|
const showLabel = (index) => allRows.length <= 14 || index % Math.ceil(allRows.length / 14) === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SvgFrame height={height}>
|
<SvgFrame height={height} label="Forecast chart">
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map(tick => {
|
{[0, 0.25, 0.5, 0.75, 1].map(tick => {
|
||||||
const y = pad.top + chartH - tick * chartH;
|
const y = pad.top + chartH - tick * chartH;
|
||||||
|
|
|
||||||
|
|
@ -921,7 +921,7 @@ export default function BillsPage() {
|
||||||
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
|
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
|
||||||
<DisplayPrefsPanel prefs={prefs} onToggle={togglePref} />
|
<DisplayPrefsPanel prefs={prefs} onToggle={togglePref} />
|
||||||
<Select value="placeholder" onValueChange={handleTemplateSelect}>
|
<Select value="placeholder" onValueChange={handleTemplateSelect}>
|
||||||
<SelectTrigger className="h-9 min-w-[160px] flex-1 bg-card sm:w-[180px] sm:flex-none">
|
<SelectTrigger className="h-9 min-w-[160px] flex-1 bg-card sm:w-[180px] sm:flex-none" aria-label="Use a bill template">
|
||||||
<SelectValue placeholder="Use template" />
|
<SelectValue placeholder="Use template" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -970,7 +970,7 @@ export default function BillsPage() {
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Select value={filters.category} onValueChange={value => setFilterValue('category', value)}>
|
<Select value={filters.category} onValueChange={value => setFilterValue('category', value)}>
|
||||||
<SelectTrigger className="h-10">
|
<SelectTrigger className="h-10" aria-label="Filter by category">
|
||||||
<SelectValue placeholder="Category" />
|
<SelectValue placeholder="Category" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -981,7 +981,7 @@ export default function BillsPage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
|
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
|
||||||
<SelectTrigger className="h-10 capitalize">
|
<SelectTrigger className="h-10 capitalize" aria-label="Filter by billing schedule">
|
||||||
<SelectValue placeholder="Billing schedule" />
|
<SelectValue placeholder="Billing schedule" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
|
||||||
|
|
@ -830,7 +830,7 @@ export default function SnowballPage() {
|
||||||
? 'text-muted-foreground/10 cursor-not-allowed'
|
? 'text-muted-foreground/10 cursor-not-allowed'
|
||||||
: 'text-muted-foreground/55 hover:text-muted-foreground/80 cursor-grab active:cursor-grabbing',
|
: 'text-muted-foreground/55 hover:text-muted-foreground/80 cursor-grab active:cursor-grabbing',
|
||||||
)}
|
)}
|
||||||
aria-label={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'}
|
title={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'}
|
||||||
>
|
>
|
||||||
<GripVertical className="h-5 w-5" />
|
<GripVertical className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -945,13 +945,13 @@ export default function SpendingPage() {
|
||||||
<p className="text-sm text-muted-foreground">Unmatched bank transactions by category</p>
|
<p className="text-sm text-muted-foreground">Unmatched bank transactions by category</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navMonth(-1)}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navMonth(-1)} aria-label="Previous month">
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm font-medium w-24 text-center">
|
<span className="text-sm font-medium w-24 text-center">
|
||||||
{MONTH_NAMES[month - 1]} {year}
|
{MONTH_NAMES[month - 1]} {year}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navMonth(1)}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navMonth(1)} aria-label="Next month">
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<SpendingSettingsMenu settings={spendingSettings} onToggle={(key, checked) => saveSpendingSetting({ [key]: checked ? 'true' : 'false' })} saving={savingSpendingSetting} />
|
<SpendingSettingsMenu settings={spendingSettings} onToggle={(key, checked) => saveSpendingSetting({ [key]: checked ? 'true' : 'false' })} saving={savingSpendingSetting} />
|
||||||
|
|
|
||||||
|
|
@ -712,7 +712,7 @@ export default function TrackerPage() {
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<Select value={filters.category} onValueChange={value => setFilterValue('category', value)}>
|
<Select value={filters.category} onValueChange={value => setFilterValue('category', value)}>
|
||||||
<SelectTrigger className="h-10">
|
<SelectTrigger className="h-10" aria-label="Filter by category">
|
||||||
<SelectValue placeholder="Category" />
|
<SelectValue placeholder="Category" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -723,7 +723,7 @@ export default function TrackerPage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
|
<Select value={filters.cycle} onValueChange={value => setFilterValue('cycle', value)}>
|
||||||
<SelectTrigger className="h-10 capitalize">
|
<SelectTrigger className="h-10 capitalize" aria-label="Filter by billing schedule">
|
||||||
<SelectValue placeholder="Billing schedule" />
|
<SelectValue placeholder="Billing schedule" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -734,7 +734,7 @@ export default function TrackerPage() {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={sortKey} onValueChange={setSort}>
|
<Select value={sortKey} onValueChange={setSort}>
|
||||||
<SelectTrigger className="h-10">
|
<SelectTrigger className="h-10" aria-label="Sort by">
|
||||||
<SelectValue placeholder="Sort by" />
|
<SelectValue placeholder="Sort by" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,602 @@
|
||||||
|
# BillTracker — Master QA Plan (living document)
|
||||||
|
|
||||||
|
**Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-02
|
||||||
|
|
||||||
|
This is a **living, operational** QA document, not a static spec. Claude runs it,
|
||||||
|
in **batches**, actively hunting for bugs/errors/rough edges, **fixing** them, and
|
||||||
|
**archiving** each fixed finding to `HISTORY.md`. Update this document whenever a
|
||||||
|
better approach, a new risk area, or a missed surface is discovered.
|
||||||
|
|
||||||
|
> **The prime directive:** don't just confirm the happy path — try to *break*
|
||||||
|
> the product. Every batch should end with the tree green, the Findings Log
|
||||||
|
> up to date, and any fixes archived to `HISTORY.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
1. [Execution model — find, then fix, then repeat](#0-execution-model--find-then-fix-then-repeat)
|
||||||
|
2. [Batch plan & progress tracker](#1-batch-plan--progress-tracker)
|
||||||
|
3. [Active Findings Log](#2-active-findings-log)
|
||||||
|
4. [Archiving fixed findings to HISTORY.md](#3-archiving-fixed-findings-to-historymd)
|
||||||
|
5. [Environment & setup](#4-environment--setup)
|
||||||
|
6. [Test data strategy](#5-test-data-strategy)
|
||||||
|
7. [Cross-cutting checks (every page)](#6-cross-cutting-checks-every-page)
|
||||||
|
8. [Batch playbooks (detailed checklists)](#7-batch-playbooks-detailed-checklists)
|
||||||
|
9. [Appendices](#8-appendices)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Execution model — find, then fix, then repeat
|
||||||
|
|
||||||
|
**Separate finding from fixing.** During a QA pass we *hunt and log* — we do **not**
|
||||||
|
fix as we go (except show-stoppers, see below). Only after the whole plan has run
|
||||||
|
do we enter a dedicated **fix phase** and fix **every** logged finding. Then we run
|
||||||
|
the **entire** QA plan again from the top. Repeat until a full pass finds **zero**
|
||||||
|
errors. Two nested loops:
|
||||||
|
|
||||||
|
```
|
||||||
|
OUTER — QA CYCLE (repeat until a full pass finds zero findings)
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ PHASE 1 · FIND Run every batch B0→B15 in find-only mode. │
|
||||||
|
│ Probe hard, LOG everything to the Findings Log. │
|
||||||
|
│ Do NOT fix (except show-stoppers). │
|
||||||
|
│ ↓ │
|
||||||
|
│ PHASE 2 · FIX QA pass done. Now fix EVERY logged finding — │
|
||||||
|
│ all of them (S1→IMP). Root-cause, with tests. │
|
||||||
|
│ ↓ │
|
||||||
|
│ PHASE 3 · VERIFY Re-run each fix's repro; `npm run ci` green. │
|
||||||
|
│ ↓ │
|
||||||
|
│ PHASE 4 · ARCHIVE Move every fixed finding to HISTORY.md (§3). │
|
||||||
|
│ ↓ │
|
||||||
|
│ PHASE 5 · RE-RUN Start a new cycle at PHASE 1. If that full pass │
|
||||||
|
│ logs zero findings → QA is clean, STOP. │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
INNER — per batch during PHASE 1 (find-only)
|
||||||
|
PICK next ⬜ batch → SET UP (app, data state, role, console open) →
|
||||||
|
PROBE (actively break it, §5 adversarial inputs) → LOG every finding to §2 →
|
||||||
|
mark batch status in §1 → next batch. (No fixing here.)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Show-stopper exception.** A *show-stopper* is a finding that **blocks continued
|
||||||
|
QA** — the app won't boot, you can't log in, or a page crashes so hard you can't
|
||||||
|
test the rest of it. Only these get fixed immediately (mid-pass), because you
|
||||||
|
can't proceed otherwise. Log it, fix it, verify, and note it was a mid-pass fix;
|
||||||
|
then continue the find pass. **Everything else is logged and left for Phase 2** —
|
||||||
|
no matter how tempting or trivial.
|
||||||
|
|
||||||
|
**Discipline (for best results)**
|
||||||
|
- **Phase 1 is log-only.** Resist fixing. A clean, complete inventory of findings beats a scattered fix-as-you-go pass and produces better batching.
|
||||||
|
- Keep each find batch tight and focused — one batch per session — so probing stays thorough.
|
||||||
|
- **Phase 2 fixes everything**, not just S1/S2. Root-cause over surface patch; add/extend a test in `tests/` or `client/**/*.test.*` for every logic bug so it can't silently return.
|
||||||
|
- Never leave the repo red at the end of Phase 3 — `npm run ci` must be green before archiving.
|
||||||
|
- Touch product behavior? Run the `/verify` skill on the affected flow before archiving.
|
||||||
|
- **The exit is empirical:** you're done only when an entire find pass (B0→B15) turns up zero new findings — not when you *think* it's clean. Log the cycle result in the [Cycle Log](#11-qa-cycle-log) each time.
|
||||||
|
- Improve THIS plan whenever a pass reveals a missed surface, a better repro, or a batch that should be reordered/split.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Batch plan & progress tracker
|
||||||
|
|
||||||
|
Batches are ordered **foundation-first** (baseline & auth before features; features
|
||||||
|
before cross-cutting; regression last). Update **Status** and **Findings** every run.
|
||||||
|
|
||||||
|
**Status key:** ⬜ Not started · 🔄 In progress · ✅ Done (green, findings archived) · 🔁 Needs recheck
|
||||||
|
|
||||||
|
| # | Batch | Primary surface | Data state | Status | Open / Fixed |
|
||||||
|
|---|-------|-----------------|-----------|--------|--------------|
|
||||||
|
| B0 | Baseline, tooling & **coverage recon** | `npm run ci`/`check`, app boots, console clean, **re-scan routes/pages/API vs plan & update it**, **control census** | any | 🔄 | 1 / 0 |
|
||||||
|
| B-UI | **Design-system primitives** | each `client/components/ui/*` × state matrix (default/hover/focus/active/disabled/loading/error/read-only) × light/dark × keyboard | any | ⬜ | 0 / 0 |
|
||||||
|
| B1 | Auth & authorization | login (pw/OIDC/TOTP/WebAuthn), roles, single-user, CSRF, data isolation | multi + single user | ⬜ | 0 / 0 |
|
||||||
|
| B2 | Tracker (core) | `/` buckets, pay/skip/notes/overrides, balance cards, overdue, ledger, drift | seeded + adversarial | ⬜ | 0 / 0 |
|
||||||
|
| B3 | Bills & schedules | `/bills` CRUD, custom schedules, reorder, merchant rules, historical import | adversarial | ⬜ | 0 / 0 |
|
||||||
|
| B4 | Subscriptions & Categories | `/subscriptions`, catalog, `/categories`, groups, reorder | seeded | ⬜ | 0 / 0 |
|
||||||
|
| B5 | Reporting reconciliation | `/summary`, `/calendar`, `/analytics`, `/health` cross-check totals | seeded + large | ⬜ | 0 / 0 |
|
||||||
|
| B6 | Spending | `/spending` YNAB view, averages, cover-overspending, safe-to-spend | seeded + edge months | 🔄 | 0 / 1 |
|
||||||
|
| B7 | Debt planning (math) | `/snowball`, `/payoff` APR/amortization vs hand-calc | edge (APR=0, $0 debt) | 🔄 | 2 / 0 |
|
||||||
|
| B8 | Banking & bank sync | `/bank-transactions`, SimpleFIN sync, matching, merchant/store, advisory filter | seeded txns | ⬜ | 0 / 0 |
|
||||||
|
| B9 | Data lifecycle | `/data` import (XLSX/CSV/SQLite), export, ICS feed, backups round-trip | empty + seeded | 🔄 | 0 / 1 |
|
||||||
|
| B10 | Notifications & workers | email + ntfy/Gotify/Discord/Telegram, reminders, cron workers | seeded | ⬜ | 0 / 0 |
|
||||||
|
| B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | ⬜ | 0 / 0 |
|
||||||
|
| B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | ⬜ | 0 / 0 |
|
||||||
|
| B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | 🔄 | 0 / 1 |
|
||||||
|
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | 🔄 | 2 / 1 |
|
||||||
|
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | ⬜ | 0 / 0 |
|
||||||
|
|
||||||
|
> After B15, if any batch is 🔁 or has open S1/S2, loop back. Then start a new
|
||||||
|
> cycle from B0 against the next build/version.
|
||||||
|
|
||||||
|
### 1.1 QA Cycle Log
|
||||||
|
|
||||||
|
One row per full QA cycle (Phase 1 find → Phase 2 fix → … → Phase 5 re-run). A
|
||||||
|
cycle is only "clean" when its **find pass logged zero findings**. Keep going
|
||||||
|
until you get a clean cycle.
|
||||||
|
|
||||||
|
| Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result |
|
||||||
|
|-------|---------|----------------|-----------------|------------------|--------|
|
||||||
|
| 1 | 2026-07-02 | `bdbf231` (dev) | 9 (find pass ongoing) | 4 (QA-B9-01, B13-01, B6-01, B14-01 → HISTORY v0.41.0; + a11y svg/aria parts of B14-02) | 🔄 in progress — B0/B1/B4/B6/B7/B9/B13/B14 probed. Solid: auth-isolation, CSRF, payment/date validation, subscription+spending math, XSS. **Fixed & archived: seed 100× cents (S2), bill-amount validation, negative-money format, a11y button-name/svg/aria labels.** Open: 5 (B14-02 nested-interactive S3, B7-01 rounding S3, 3 IMP) |
|
||||||
|
|
||||||
|
**Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Active Findings Log
|
||||||
|
|
||||||
|
**This is the live log.** Record every finding here the moment it's found — before
|
||||||
|
fixing. Keep only **Open / Fixing / Fixed** rows here. Once a finding is
|
||||||
|
**Fixed + verified + archived to `HISTORY.md`**, delete its row from this table
|
||||||
|
(its permanent record is the changelog entry).
|
||||||
|
|
||||||
|
**Finding ID:** `QA-B{batch}-{nn}` (e.g. `QA-B2-01`).
|
||||||
|
**Severity:** S1 Critical · S2 Major · S3 Minor · S4 Cosmetic · IMP Improvement (see [Appendix A](#appendix-a--severity-definitions)).
|
||||||
|
**Status:** 🔴 Open → 🟡 Fixing → 🟢 Fixed (verified, awaiting archive) → then remove on 📦 Archive.
|
||||||
|
|
||||||
|
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
|
||||||
|
|----|-----|--------------------|---------|--------|---------------|
|
||||||
|
| QA-B7-01 | S3 | `utils/money.js:29` | `toCents` mis-rounds fractional cents: `toCents(1.005)` → 100 (`$1.00`) not 101 | 🔴 Open | see write-up |
|
||||||
|
| QA-B7-02 | IMP | `services/aprService.js:46` | `totalInterestPaid` is unused outside tests and its unrounded model diverges from `amortizationSchedule` | 🔴 Open | see write-up |
|
||||||
|
| QA-B0-01 | IMP | `vite build` / `client/index` | Main JS bundle 659 kB (203 kB gzip) — over Vite's 500 kB warning | 🔴 Open | see write-up |
|
||||||
|
| QA-B14-02 | S3 | `/categories` (8), `/snowball` (1) | axe **serious** `nested-interactive`: draggable/expandable rows are `role=button` yet contain nested buttons | 🔴 Open | see write-up (deferred — architectural) |
|
||||||
|
| QA-B14-03 | IMP | `package.json` / security docs | `react-markdown`/`rehype-sanitize`/`remark-gfm` unused; XSS docs credit sanitize that isn't wired | 🔴 Open | see write-up |
|
||||||
|
|
||||||
|
**Finding template** (paste a new row above; keep the full write-up here until archived):
|
||||||
|
|
||||||
|
```
|
||||||
|
ID: QA-B?-??
|
||||||
|
Severity: S1 / S2 / S3 / S4 / IMP
|
||||||
|
Environment: browser / viewport / theme / role / auth mode / data state
|
||||||
|
Area: file:line (if known)
|
||||||
|
Steps to reproduce:
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
Expected:
|
||||||
|
Actual:
|
||||||
|
Evidence: console / network / DB row / screenshot
|
||||||
|
Fix: (what changed, commit) — Verified by: (repro re-run + ci)
|
||||||
|
```
|
||||||
|
|
||||||
|
Log console errors, failed network requests, and unhandled rejections as findings
|
||||||
|
**even if the UI looks fine**.
|
||||||
|
|
||||||
|
### Cycle 1 — logged write-ups
|
||||||
|
|
||||||
|
```
|
||||||
|
ID: QA-B7-01
|
||||||
|
Severity: S3 (minor — wrong edge behavior in money core that advertises exactness)
|
||||||
|
Environment: server-side money math
|
||||||
|
Area: utils/money.js:29 (toCents → Math.round(n * 100))
|
||||||
|
Steps to reproduce:
|
||||||
|
1. toCents(1.005) → 100 (i.e. $1.00), not 101 ($1.01).
|
||||||
|
2. Round-trip fromCents(toCents(1.005)) → 1 (a cent silently lost).
|
||||||
|
Expected: "cent-exact" per the file's own docstring — 1.005 → 101.
|
||||||
|
Actual: float multiply (1.005*100 = 100.4999…) rounds down before Math.round.
|
||||||
|
Evidence: node probe. Other 3-decimal inputs also affected (values near .xx5).
|
||||||
|
Impact: bounded to sub-cent, and only when a 3+ decimal dollar value reaches the
|
||||||
|
boundary (proration/interest), so low severity — but it contradicts the exactness
|
||||||
|
guarantee and is the plan's named "fractional cents" adversarial case.
|
||||||
|
Fix (deferred): round on a string/scaled-integer basis, or add epsilon before round.
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
ID: QA-B7-02
|
||||||
|
Severity: IMP (dead / latent-inconsistent code)
|
||||||
|
Area: services/aprService.js:46 (totalInterestPaid)
|
||||||
|
Finding: totalInterestPaid is referenced only by its own definition/export and
|
||||||
|
tests — no route/client consumer. Its unrounded interest accumulation diverges
|
||||||
|
from amortizationSchedule's per-month rounded interest (measured up to $0.16 over
|
||||||
|
a 154-month term). If ever wired to a UI that also shows the schedule, the
|
||||||
|
headline total and the table would disagree.
|
||||||
|
Fix (deferred): remove it, or switch it to the per-month rounded model so it can't
|
||||||
|
diverge, and add a reconciliation test (sum of schedule interest == total).
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
ID: QA-B0-01
|
||||||
|
Severity: IMP (performance/build)
|
||||||
|
Area: vite build output — dist/assets/index-*.js
|
||||||
|
Finding: main chunk 658.95 kB (203.72 kB gzip), over Vite's 500 kB warn threshold;
|
||||||
|
build prints the chunk-size advisory. Pages are already lazy/route-split, so the
|
||||||
|
index chunk is the shared vendor/core.
|
||||||
|
Fix (deferred): build.rollupOptions.output.manualChunks to split vendor (react,
|
||||||
|
radix, framer-motion, xlsx) or raise chunkSizeWarningLimit if accepted. Ties to
|
||||||
|
B14 performance (Slow-3G initial load).
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
ID: QA-B14-02 (partially fixed — nested-interactive remains)
|
||||||
|
Severity: S3 (accessibility — WCAG 2 A/AA, axe "serious")
|
||||||
|
Environment: authenticated app; found/verified via e2e/a11y.authed.spec.js
|
||||||
|
FIXED this cycle (archived to HISTORY.md, was part of B14-02 + all of B14-01):
|
||||||
|
- button-name (critical) → Tracker/Bills Select triggers + Spending month-nav +
|
||||||
|
Analytics chart svgs now have aria-labels. All 4 pages pass.
|
||||||
|
- svg-img-alt → /analytics chart svgs got aria-labels (SvgFrame `label` prop). Pass.
|
||||||
|
- aria-prohibited-attr → /snowball drag-handle div aria-label → title. Pass.
|
||||||
|
REMAINING (deferred — architectural, needs manual drag/keyboard/SR verification):
|
||||||
|
- nested-interactive → /categories (8): each row is <div role="button" tabindex=0
|
||||||
|
aria-expanded> AND draggable AND contains nested edit/delete/drag buttons.
|
||||||
|
- nested-interactive → /snowball (1): a <button class="w-full text-left"
|
||||||
|
aria-controls aria-expanded> accordion trigger wraps other interactive controls.
|
||||||
|
Fix (deferred): don't make the whole row/trigger a button — use a dedicated
|
||||||
|
expand/collapse control and keep the row a plain container, so interactive
|
||||||
|
children aren't nested inside an interactive ancestor. Re-verify drag + keyboard
|
||||||
|
after. Guard: e2e/a11y.authed.spec.js (currently red on these two pages by design).
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
ID: QA-B14-03
|
||||||
|
Severity: IMP (dependency hygiene + inaccurate security documentation)
|
||||||
|
Area: package.json (react-markdown, rehype-sanitize, remark-gfm) + SECURITY docs /
|
||||||
|
this plan's §6 and Appendix references to "markdown via rehype-sanitize".
|
||||||
|
Finding: those three deps are NOT imported anywhere in client/ (verified by grep).
|
||||||
|
Client markdown is rendered by a custom client/components/MarkdownText.jsx
|
||||||
|
(renderInlineMarkdown) — bold/code/links only, link regex restricted to
|
||||||
|
https?:// hrefs, output is React-escaped, and there is NO dangerouslySetInnerHTML
|
||||||
|
anywhere in the client. So the real XSS defense is React escaping + the restrictive
|
||||||
|
custom renderer, not rehype-sanitize.
|
||||||
|
POSITIVE (confirmed, not a finding): XSS surface is safe — stored <script> bill names
|
||||||
|
return as inert data and render escaped; About/Privacy admin content is plain
|
||||||
|
escaped text; no javascript: href vector; no raw-HTML sink.
|
||||||
|
Fix (deferred): remove the unused deps (or wire react-markdown+rehype-sanitize if
|
||||||
|
richer markdown is intended) and correct the security docs / this plan to describe
|
||||||
|
the actual defense. Low risk, small cleanup.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Archiving fixed findings to HISTORY.md
|
||||||
|
|
||||||
|
`HISTORY.md` is the project changelog (version-organized, emoji section headers).
|
||||||
|
When a finding is Fixed **and verified**, write a concise entry there, then remove
|
||||||
|
the row from the Active Findings Log.
|
||||||
|
|
||||||
|
**Where:** under the current in-progress version heading (e.g. `## v0.41.x`). If a
|
||||||
|
QA cycle produces several fixes, group them under a `### 🐛 QA Fixes` (bug fixes)
|
||||||
|
or `### 🧹 QA` (polish/improvements) section, matching the existing changelog voice.
|
||||||
|
|
||||||
|
**Entry format** (match the terse, specific style already in `HISTORY.md`):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### 🐛 QA Fixes
|
||||||
|
|
||||||
|
- **[Area] Short title** — What was wrong and the user-visible impact, then the
|
||||||
|
fix. Reference the file/function and any migration or test added.
|
||||||
|
(was QA-B7-03)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules**
|
||||||
|
- One bullet per finding; include the old `QA-B?-??` id in parentheses for traceability.
|
||||||
|
- If a fix added/changed a test, say which (`tests/…` or `client/…test.*`).
|
||||||
|
- Don't archive until the fix is verified (repro gone + `npm run ci` green).
|
||||||
|
- IMP items that were implemented are archived the same way; IMP items merely *noted* stay in the Findings Log (or graduate to `FUTURE.md`/`roadmap.md` if deferred).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Environment & setup
|
||||||
|
|
||||||
|
### 4.1 Running the app
|
||||||
|
|
||||||
|
| Mode | Command | URL |
|
||||||
|
|------|---------|-----|
|
||||||
|
| Dev (API + UI, hot reload) | `npm run dev` | UI `http://localhost:5173` (proxies API → `:3000`) |
|
||||||
|
| API only | `npm run dev:api` | `http://localhost:3000` |
|
||||||
|
| Production build | `npm run build` then `npm start` | `http://localhost:3000` |
|
||||||
|
| Docker | `docker-compose up` | per compose config |
|
||||||
|
|
||||||
|
- Backend: Node/Express on `PORT` (default `3000`). Frontend dev: Vite on `5173`.
|
||||||
|
- Data: SQLite at `db/bills.db` (WAL). **Back it up before destructive tests** (`backups/` or a manual copy). Prefer a scratch DB for B9/B11 restore tests.
|
||||||
|
- Configure a dedicated **test** `.env` from `.env.example`. Never point tests at production data or a live SimpleFIN account with real credentials.
|
||||||
|
- Test commands: `npm run ci` (check + all tests + build), `npm run check` (syntax + build), `npm run test` (server), `npm run test:client` (vitest).
|
||||||
|
|
||||||
|
### 4.2 Test matrix
|
||||||
|
|
||||||
|
Full functional pass across reasonable combinations; smoke (B15) across all.
|
||||||
|
|
||||||
|
| Dimension | Values |
|
||||||
|
|-----------|--------|
|
||||||
|
| Browser | Chrome/Chromium, Firefox, Safari (WebAuthn differs per browser) |
|
||||||
|
| Viewport | Desktop ≥1280, tablet ~768, mobile ~375 (iPhone SE), ~414 |
|
||||||
|
| Theme | Light, Dark, system-follow |
|
||||||
|
| Role | `user`, `admin`, default admin (first-run) |
|
||||||
|
| Auth mode | Multi-user, single-user |
|
||||||
|
| Density | Normal + compact desktop |
|
||||||
|
| Network | Online, Slow 3G, offline (PWA shell) |
|
||||||
|
| Data state | Empty, seeded demo, large/stress, adversarial |
|
||||||
|
|
||||||
|
### 4.3 Accounts to prepare
|
||||||
|
- `admin`, `user`, a **second** `user` (data-isolation), a single-user-mode instance (separate DB).
|
||||||
|
- Demo reference: `guest / guest123` (do not run destructive flows on any shared demo server).
|
||||||
|
|
||||||
|
### 4.4 Automated E2E harness (Playwright)
|
||||||
|
|
||||||
|
Manual passes prove a button works **once**; they don't stop it regressing next cycle. The Playwright suite is the regression net — it drives real clicks in a real browser, and it's where visual-regression, axe-a11y, and fault-injection (§B14) are wired so they re-run every cycle for free.
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|--------------|
|
||||||
|
| `npm run test:e2e` | run the E2E suite headless (boots the app via `webServer`) |
|
||||||
|
| `npm run test:e2e:ui` | Playwright UI mode — watch/debug interactively |
|
||||||
|
| `npm run test:e2e:update` | re-baseline visual-regression screenshots (review the diff before committing) |
|
||||||
|
|
||||||
|
- **Setup (one-time):** `npm install` then `npx playwright install chromium`. Config: `playwright.config.js`; specs in `e2e/`.
|
||||||
|
- **Scope:** the suite is a **thin critical-path smoke**, not a replacement for the manual playbooks — it locks the happy paths (login → pay bill → skip → note → reconcile), the primitive state matrix, per-page axe scans, and page screenshots. Grow it whenever a manual pass finds a UI regression that a click-test could have caught.
|
||||||
|
- **Don't** point it at production data or a live SimpleFIN account — it runs against a scratch DB with seeded demo data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Test data strategy
|
||||||
|
|
||||||
|
- **Empty:** brand-new account. Every page must render a sensible empty state — no crash, no `NaN`, no blank white screen.
|
||||||
|
- **Seeded:** use **Data → Seed Demo Data** for a realistic mid-size dataset.
|
||||||
|
- **Large/stress:** 500+ bills, 5,000+ transactions, 24+ months history — exercises virtualization (`@tanstack/react-virtual`), charts, query perf.
|
||||||
|
- **Adversarial (deliberately try to break it):**
|
||||||
|
- Amounts: `0`, `0.01`, negative, `9,999,999.99`, fractional cents.
|
||||||
|
- Text: emoji, RTL, `<script>` XSS probe, 1,000-char strings, leading/trailing spaces, SQL-ish input.
|
||||||
|
- Dates: 1st/14th/15th/31st boundaries; 28/29/30/31-day months; Feb 29; month/year crossing; inactive ranges; skipped months; overrides.
|
||||||
|
- Transactions: duplicate amount+date, same-day merchant repeats, refunds/negatives.
|
||||||
|
- Debt: APR `0%`, very high APR, `$0` balance, absurd inputs.
|
||||||
|
- Non-UTC system timezone + a DST boundary date.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Cross-cutting checks (every page)
|
||||||
|
|
||||||
|
Run on **every** page during its batch — don't assume a shared component behaves the same everywhere.
|
||||||
|
|
||||||
|
**Navigation & routing** — reachable from nav and by direct URL (deep link) + after hard refresh · back/forward restores state, no stuck spinners · unknown sub-paths → `NotFoundPage` · active nav highlighted · `simplefinOnly` (Banking) gated · `Ctrl+K` palette finds & opens it.
|
||||||
|
|
||||||
|
**Buttons & interactions** — every button/link/icon/dropdown/tab/toggle/menu does something or is disabled with a reason · no dead controls · double-click doesn't duplicate records · **rapid repeated toggling** (spam a switch / pay-skip) resolves to one correct state, no stuck spinner · action started then **navigate away mid-flight** doesn't corrupt or throw · destructive actions confirm + cancel · primary action keyboard-reachable (Tab/Enter/Esc).
|
||||||
|
|
||||||
|
**Forms & validation** — required fields enforced · numeric/currency reject letters, handle 0/negative/decimal · errors don't wipe entered data · **paste** into every field (incl. `"$1,234.56"` into currency) · **browser/password-manager autofill** on login & forms · **IME/composition** (emoji, CJK) in text fields commits correctly · success shows toast (sonner) and the view updates without manual refresh (React Query invalidation).
|
||||||
|
|
||||||
|
**Number inputs (you have ~45 `type="number"` fields — the highest-risk control type)** — scroll-wheel over a focused field must **not** silently change the value · spinner up/down buttons step correctly and respect min/max · reject/`e`/`+`/exponent and multiple decimals · locale decimal comma vs dot · leading zeros · empty field ⇒ no `NaN` submitted · cents fields never accept >2 decimals.
|
||||||
|
|
||||||
|
**Per-control state matrix** — for each control on the page, verify every applicable state renders and behaves in **both light and dark**: default · hover · keyboard-focus (visible ring) · active/pressed · disabled (and truly non-interactive) · loading/in-flight · error/invalid · read-only · filled-to-overflow (1,000-char string / max-digit number wraps or truncates, no layout break).
|
||||||
|
|
||||||
|
> **Note — "sliders":** this app has **no `<input type=range>` sliders.** The `SlidersHorizontal` glyph is just the Bills **filter-panel** button; the closest real thing to a slider is a number stepper. Test those two surfaces where a slider would otherwise be expected.
|
||||||
|
|
||||||
|
**States** — loading skeleton/spinner, no layout jump · helpful empty state · error state (4xx/5xx/offline) recovers, `ErrorBoundary` shows a fallback not a white page.
|
||||||
|
|
||||||
|
**Visual & responsive** — correct at desktop/tablet/mobile, no overflow/h-scroll · dark mode contrast, no white flash · compact mode readable · long strings/big numbers wrap/truncate.
|
||||||
|
|
||||||
|
**Data integrity** — money 2-decimals, no float artifacts (`9.999999`) · dates in expected tz, period boundaries correct · values agree across pages (a bill total on Tracker == Summary == Analytics).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Batch playbooks (detailed checklists)
|
||||||
|
|
||||||
|
Each batch below is the detailed script for the matching row in [§1](#1-batch-plan--progress-tracker). Apply [§6](#6-cross-cutting-checks-every-page) throughout.
|
||||||
|
|
||||||
|
### B0 — Baseline, tooling & coverage recon
|
||||||
|
**Run FIRST in every cycle.** This is where the plan re-syncs with reality — new
|
||||||
|
pages, routes, endpoints, or features added since the last cycle get discovered
|
||||||
|
and folded in **before** testing, so coverage never silently rots.
|
||||||
|
|
||||||
|
**Tooling baseline**
|
||||||
|
- [ ] `npm run ci` — record any failing server/client test or build error as a finding (S1/S2).
|
||||||
|
- [ ] `npm run check` — server syntax + build clean.
|
||||||
|
- [ ] App boots via `npm run dev` **and** production `npm start`; note startup warnings.
|
||||||
|
- [ ] Load the app; browser console + server logs clean on first load and first navigation.
|
||||||
|
- [ ] Confirm which auth mode / seed state the DB is in; snapshot a backup before proceeding.
|
||||||
|
|
||||||
|
**Coverage recon — enumerate the *actual* product and diff it against this plan.**
|
||||||
|
Run these, then compare the output to the batch playbooks (§7) and the [route map](#appendix-c--page--route--api-quick-map):
|
||||||
|
- [ ] **Client routes** — `grep -nE "<Route" client/App.jsx` — every path present here must appear in a batch playbook and Appendix C.
|
||||||
|
- [ ] **Pages** — `ls client/pages/` — every page has an owning batch.
|
||||||
|
- [ ] **Sidebar / nav entries** — `grep -nE "to:|label:|Only" client/components/layout/Sidebar.jsx` — new nav links (incl. conditional ones like `simplefinOnly`) are covered.
|
||||||
|
- [ ] **API route mounts** — `grep -nE "app.use\('/api" server.js` — every mounted route group is in B13's list and mapped in Appendix C.
|
||||||
|
- [ ] **Services & components** — `ls services/` and `ls client/components/**/` — new service/component families have a home in a playbook.
|
||||||
|
- [ ] **UI primitives** — `ls client/components/ui/` — every shared primitive is covered by the [B-UI](#b-ui--design-system-primitives) playbook; a new primitive gets a row there.
|
||||||
|
- [ ] **Interactive-control census (makes "every button tested" *provable*)** — for each page, enumerate every button, link, toggle/switch, checkbox, select, text/number/date/file input, tab, menu, and filter control, and record it in a per-page control checklist (template: [Appendix E](#appendix-e--per-page-control-census)). A control that isn't on a checklist hasn't been tested — the census is the completeness guarantee the batch playbooks alone don't give you. Quick starting inventory: `grep -rnoE "type=[\"'][a-z]+[\"']" client/pages client/components` and `grep -rn "onClick=" client/pages/<Page>.jsx`.
|
||||||
|
- [ ] **Feature flags / conditional surfaces** — search for `Only`, `enabled`, `featureFlag`, env gates that hide/show pages; ensure each state is tested.
|
||||||
|
- [ ] **What changed since last cycle** — skim `git log`/`HISTORY.md` since the previous cycle's commit (see [Cycle Log](#11-qa-cycle-log)) for new features/pages.
|
||||||
|
|
||||||
|
**Update the plan (do this now, not later)** — for anything the recon surfaced that isn't already covered:
|
||||||
|
- [ ] Add it to the relevant batch playbook (or create a new batch and a row in the [§1 table](#1-batch-plan--progress-tracker)).
|
||||||
|
- [ ] Add/adjust its entry in [Appendix C](#appendix-c--page--route--api-quick-map).
|
||||||
|
- [ ] Note the plan update in the [Cycle Log](#11-qa-cycle-log) row for this cycle.
|
||||||
|
- [ ] If a whole surface is *missing* from the product that the plan expected (page removed/renamed), reconcile the plan too — don't test ghosts.
|
||||||
|
|
||||||
|
### B-UI — Design-system primitives
|
||||||
|
**Test each shared control once, thoroughly, in isolation — a bug here breaks every page at once.** Drive them wherever they're already mounted (or a scratch page); run each against the [per-control state matrix](#6-cross-cutting-checks-every-page) × light/dark × keyboard-only. One finding row per primitive.
|
||||||
|
|
||||||
|
| Primitive (`client/components/ui/`) | Must verify |
|
||||||
|
|---|---|
|
||||||
|
| `button.jsx` | every variant (default/destructive/outline/ghost/link) + size; **disabled truly blocks click**; loading state; focus ring; Enter/Space activate |
|
||||||
|
| `input.jsx` | text/number/password/date/search/file types; placeholder; disabled/read-only; error styling; paste/autofill; number-input rules above |
|
||||||
|
| `select.jsx` (Radix) | opens by mouse **and** keyboard; type-ahead; long lists scroll; onChange fires in **Firefox+Safari**; disabled options; value persists; Esc closes |
|
||||||
|
| `checkbox.jsx` / `switch.jsx` | toggles by click **and** Space; indeterminate (if used); disabled; label click toggles; controlled value round-trips |
|
||||||
|
| `dialog.jsx` / `alert-dialog.jsx` / `confirm-dialog.jsx` / `input-dialog.jsx` | open/close; **focus trap + restore**; Esc closes; overlay click behaves; **Cancel actually cancels (no side effect)**; Confirm fires once; scroll-lock releases |
|
||||||
|
| `dropdown-menu.jsx` | keyboard arrow nav; Esc; submenu; disabled items; click-outside closes; no clipping at viewport edge |
|
||||||
|
| `tabs.jsx` | arrow-key nav; active state; content swaps; deep-link/refresh keeps tab (if applicable) |
|
||||||
|
| `tooltip.jsx` | hover **and** keyboard-focus show it; dismiss on blur; touch behavior; not a11y-only info trap |
|
||||||
|
| `table.jsx` | header/zebra/hover; horizontal scroll on narrow viewport (no page h-scroll); empty state |
|
||||||
|
| `collapsible.jsx` | expand/collapse animation; state persists; keyboard operable |
|
||||||
|
| `sonner.jsx` (toast) | success/error/loading; **stack + dismiss**; auto-dismiss timing; doesn't cover primary actions; announced to SR |
|
||||||
|
| `save-status.jsx` | idle/saving/saved/error transitions reflect real autosave (`useAutoSave.test.jsx`) |
|
||||||
|
| `Skeleton.jsx` | matches final layout (no jump); no infinite skeleton on error |
|
||||||
|
| `badge.jsx` / `card.jsx` / `separator.jsx` / `label.jsx` | contrast in dark mode; label `htmlFor` focuses its control; no overflow on long text |
|
||||||
|
| `theme-toggle.jsx` | light↔dark↔system; applied **before first paint** (no flash); persists across reload |
|
||||||
|
|
||||||
|
- [ ] Every primitive above passes its row in light **and** dark, keyboard-only, at mobile width.
|
||||||
|
- [ ] Axe scan (see B14) on a page densely using primitives → zero critical violations.
|
||||||
|
|
||||||
|
### B1 — Auth & authorization
|
||||||
|
- [ ] **Password:** valid login → correct landing (Tracker for `user`, `/admin` for default admin); wrong password → clear error, no user-enumeration timing/message difference; logout clears session; expired session redirects and preserves `state.from`; session persists across refresh.
|
||||||
|
- [ ] **Rate limiting:** repeated failed logins throttled (`loginLimiter`/`loginUsernameLimiter`), clear message, resets.
|
||||||
|
- [ ] **TOTP:** enroll (QR + secret), code accepted, backup codes work once, login prompts for TOTP, wrong code rejected+throttled, disable requires re-auth.
|
||||||
|
- [ ] **WebAuthn:** register/login/remove passkey in Chrome, Firefox, Safari; password fallback works.
|
||||||
|
- [ ] **OIDC/Authentik:** SSO flow creates/links account; admin config errors surface cleanly; `oidcLimiter` throttles.
|
||||||
|
- [ ] **Roles/guards:** `user` blocked from `/admin*`, `/status` (redirect) and admin APIs (403); default admin forced to `/admin`; single-user bypass correct but admin surfaces still protected; unauth API → 401.
|
||||||
|
- [ ] **Data isolation (critical):** user A cannot read/modify user B's bills, payments, transactions, categories, snowball plans — test by ID enumeration on the API.
|
||||||
|
- [ ] **CSRF:** state-changing request without a valid token → rejected.
|
||||||
|
|
||||||
|
### B2 — Tracker (`/`)
|
||||||
|
- [ ] Month nav (prev/next/jump), current month highlighted, data reloads per month.
|
||||||
|
- [ ] Bills land in correct `1–14` / `15–31` bucket by due date; pin-due sorting works.
|
||||||
|
- [ ] Quick pay marks paid + updates balance cards/progress; undo works; no double-count.
|
||||||
|
- [ ] Skip excludes from totals for that month only; unskip restores.
|
||||||
|
- [ ] Per-month amount override persists, doesn't affect base bill or other months.
|
||||||
|
- [ ] Notes cell add/edit/clear persists per month.
|
||||||
|
- [ ] Inactive/date-range bill doesn't show or count outside its range.
|
||||||
|
- [ ] Balance/starting-amount cards period-aware + editable; income − bills / safe-to-spend correct.
|
||||||
|
- [ ] Overdue command center: accurate list/count, pay/skip actions work.
|
||||||
|
- [ ] Cash flow card, drift insight, payment ledger (add/edit/delete reconciles), autopay suggestion apply/dismiss.
|
||||||
|
- [ ] Editable cells autosave; Esc cancels; invalid input handled. Mobile rows equal desktop actions. Compact mode intact.
|
||||||
|
|
||||||
|
### B3 — Bills (`/bills`)
|
||||||
|
- [ ] Create with all fields (name, amount, due date, category, schedule, account, autopay, active range).
|
||||||
|
- [ ] Edit propagates to Tracker/Summary/Calendar/Analytics; delete confirms + handles orphan payments/history.
|
||||||
|
- [ ] Custom schedules (weekly/biweekly/monthly/quarterly/annual/custom): next-due & occurrences correct across month/year boundaries.
|
||||||
|
- [ ] Drag reorder persists (cross-check `billReorder.test.js`); search/filter panel filters + clears; large-list virtualization smooth.
|
||||||
|
- [ ] Merchant rules: create/matches/edit/delete; historical import dialog attributes month-crossing payments correctly.
|
||||||
|
- [ ] BillModal open/close, validation, cancel discards unsaved changes.
|
||||||
|
|
||||||
|
### B4 — Subscriptions & Categories
|
||||||
|
- [ ] Subscriptions: add/edit/delete, active/cancelled, renewal & annual→monthly normalization; totals feed Tracker/Summary/Analytics.
|
||||||
|
- [ ] Catalog: browse/search, add-from-catalog pre-fills.
|
||||||
|
- [ ] Categories: create/edit/delete (in-use handled: reassign/prevent); groups create/assign/reorder (`categoryGroups`/`categoryReorder` tests); colors/icons consistent on Tracker/Spending/Analytics.
|
||||||
|
|
||||||
|
### B5 — Reporting reconciliation
|
||||||
|
- [ ] Summary totals (paid/unpaid/overdue/remaining) reconcile with Tracker for the same month; income breakdown modal matches.
|
||||||
|
- [ ] Calendar plots bills/payments on correct days (**timezone**: a bill due on the 1st must not render on the 31st); day totals correct.
|
||||||
|
- [ ] Analytics charts render with data AND empty (no broken SVG/`NaN` axes); period selectors update all charts; figures reconcile with Summary/Tracker; large dataset perf OK.
|
||||||
|
- [ ] Health indicators compute from real data, no crash on empty; recommendations sane.
|
||||||
|
|
||||||
|
### B6 — Spending (`/spending`)
|
||||||
|
- [ ] Category-group view assigned/spent/available math correct; 3-month averages correct.
|
||||||
|
- [ ] Cover-overspending reallocates funds correctly and is reversible.
|
||||||
|
- [ ] Safe-to-spend matches Tracker (`safeToSpend.test.js`); month nav; empty/partial months handled.
|
||||||
|
|
||||||
|
### B7 — Debt planning (`/snowball`, `/payoff`)
|
||||||
|
- [ ] Add debts (balance/APR/min); snowball vs avalanche ordering correct.
|
||||||
|
- [ ] Projection + amortization vs a **hand-calculated** example; APR=0 and already-paid debts correct.
|
||||||
|
- [ ] Extra-payment/budget updates payoff date + total interest; chart renders; plan history saves/restores; status banner accurate.
|
||||||
|
- [ ] Edge: single debt, many debts, `$0` debt, negative/absurd inputs rejected.
|
||||||
|
|
||||||
|
### B8 — Banking (`/bank-transactions`)
|
||||||
|
- [ ] Ledger loads/virtualizes/filters (date/account/amount/merchant/status).
|
||||||
|
- [ ] Transaction matching (match/unmatch), auto-match review approve/reject, no double-match (`transactionMatchService.test.js`).
|
||||||
|
- [ ] Merchant/store matching rules + confidence/duplicates; advisory non-bill filter flags/hides with override.
|
||||||
|
- [ ] Matched payments reflect on Tracker/ledger without double-counting; category picker persists.
|
||||||
|
|
||||||
|
### B9 — Data lifecycle (`/data`)
|
||||||
|
- [ ] Imports: spreadsheet (XLSX/CSV) map/preview/commit, malformed rejected, dup/partial handled; transaction CSV (`csvTransactionImportService.test.js`) dedupe + parsing; SQLite user import version-checked + confirms overwrite; seed demo data safe; import history lists + rollback.
|
||||||
|
- [ ] Exports: download SQLite **round-trips** (export → fresh account → import → matches); Excel export opens uncorrupted; ICS calendar feed valid in a client AND properly **token-gated** (route mounts before auth — verify not open).
|
||||||
|
- [ ] Backups: manual + scheduled restorable on a scratch instance; permissions not world-readable; old backups pruned (`backupAndCleanup.test.js`).
|
||||||
|
|
||||||
|
### B10 — Notifications & workers
|
||||||
|
- [ ] Each channel (email/SMTP, ntfy, Gotify, Discord, Telegram): test message delivers; bad token/URL → clear error, logged, no secret leak.
|
||||||
|
- [ ] Reminders fire at configured lead time for upcoming/overdue; no duplicates; paid/skipped excluded; respects per-user prefs.
|
||||||
|
- [ ] Workers: `dailyWorker`, `bankSyncWorker` (interval + guardrails), `backupScheduler` run on schedule; errors caught/logged, don't crash server, next run unblocked.
|
||||||
|
|
||||||
|
### B11 — Admin panel (`/admin`)
|
||||||
|
- [ ] Onboarding wizard completes without a broken state.
|
||||||
|
- [ ] Users table: add/edit-role/reset-pw/disable/delete; **cannot remove the last admin**.
|
||||||
|
- [ ] Login mode switch single↔multi verified live, no lockout; auth-methods enable/disable + bad config surfaced.
|
||||||
|
- [ ] Email notif config + test send; bank sync admin (configure/manual/auto/status/revoke).
|
||||||
|
- [ ] Backups create/list/download/restore/delete; cleanup panel previews impact + confirms (counts match `backupAndCleanup.test.js`).
|
||||||
|
- [ ] Privacy admin edits reflect on public `/privacy`; system status metrics/versions/jobs accurate (`statusService.test.js`); admin actions rate-limited + audited (`auditService` — spot-check log).
|
||||||
|
|
||||||
|
### B12 — Settings, Profile & global UI
|
||||||
|
- [ ] Settings: theme (light/dark/system) persists; notification prefs save + reflect in B10; display/density/period/search-panel prefs persist; invalid rejected.
|
||||||
|
- [ ] Profile: change password (current required, invalidates sessions), manage 2FA/passkeys, sessions revoke (`profileRoute.test.js`).
|
||||||
|
- [ ] Static: About (public + admin, version shown), Privacy, Release Notes (dialog once per `user`, dismiss persists), Roadmap (admin), NotFound friendly + way home.
|
||||||
|
- [ ] Global: command palette (`Ctrl+K`) search/keyboard/Esc, hidden for default admin; sidebar collapse/expand + mobile overlay (check overflow issue in `docs/UI_IMPROVEMENTS.md`); toasts stack/dismiss; page transitions no flash/double-fetch; theme applied before first paint.
|
||||||
|
|
||||||
|
### B13 — API / backend direct
|
||||||
|
Route groups: `auth`, `auth/oidc`, `admin`, `tracker`, `bills`, `subscriptions`, `payments`, `data-sources`, `transactions`, `matches`, `categories`, `settings`, `user`, `calendar`, `summary`, `monthly-starting-amounts`, `analytics`, `spending`, `snowball`, `notifications`, `status`, `about`, `about-admin`, `privacy`, `version`, `profile`, `export`, `import`/`imports`.
|
||||||
|
- [ ] Auth: unauth → 401, wrong role → 403, right role → 200.
|
||||||
|
- [ ] CSRF: state-changing without valid token rejected; with token succeeds (`middleware/csrf.js`).
|
||||||
|
- [ ] Validation: bad/missing body → structured 4xx (`middleware/errorFormatter.js`, `utils/apiError.js`), never a raw 500 stack.
|
||||||
|
- [ ] IDOR/isolation: other user's resource by id → 403/404, no leak.
|
||||||
|
- [ ] Rate limits: login/admin/export/import/OIDC limiters trigger + reset (`middleware/rateLimiter.js`).
|
||||||
|
- [ ] Money in **integer cents** end-to-end (per `docs/cents-migration-plan.md`); API and DB agree; no float drift.
|
||||||
|
- [ ] Idempotency: repeated create doesn't duplicate; concurrent edits resolve sanely.
|
||||||
|
- [ ] Consistent error JSON + correct status codes; security headers present (`middleware/securityHeaders.js`); public routes (`about`/`privacy`/`version`/calendar feed) leak nothing sensitive.
|
||||||
|
|
||||||
|
### B14 — Non-functional
|
||||||
|
- [ ] **a11y (manual):** keyboard-only reach/operate every control, visible focus, skip-link works; screen-reader labels/roles (Radix `aria-*`); WCAG-AA contrast light+dark; modals trap+restore focus, Esc closes; errors announced not color-only.
|
||||||
|
- [ ] **a11y (automated):** run **axe-core** on every page (`@axe-core/playwright`, or `jest-axe` for component-level) — **zero critical/serious** violations; triage moderate. Wire it into the E2E suite so it re-runs every cycle, not just once.
|
||||||
|
- [ ] **Visual regression:** capture a baseline screenshot per page × {desktop, mobile} × {light, dark} (Playwright `toHaveScreenshot`); diff against baseline each cycle. Every non-trivial pixel diff is either an intended change (update the baseline in the same commit) or a finding — never ignore it. This is what makes "every page looks right" repeatable instead of eyeballed.
|
||||||
|
- [ ] **Performance:** initial load + lazy route splitting OK on Slow 3G; large lists responsive; no memory leak over 10+ navigations; no duplicate/excess requests (React Query `staleTime`).
|
||||||
|
- [ ] **PWA/offline:** installs; manifest/icon correct; offline shell loads with graceful messaging; SW updates without stale-cache breakage.
|
||||||
|
- [ ] **Security spot-checks:** XSS in bill names/notes/category names/imported data escaped everywhere (markdown via `rehype-sanitize`); no secrets (SimpleFIN token, SMTP creds, OIDC secret) in bundle/responses/logs; cookies `HttpOnly`/`Secure`/`SameSite`; `encryptionService` protects at-rest secrets, keys not committed. (Depth: `SECURITY_AUDIT.md`.)
|
||||||
|
- [ ] **Resilience:** kill API mid-session → recoverable errors, no data loss on next save; locked/corrupt SQLite surfaces clearly; SimpleFIN/SMTP/push down → graceful degrade; two-tab concurrent edits don't silently clobber.
|
||||||
|
- [ ] **Fault injection (systematic):** with a request-interception harness (Playwright `page.route`, or DevTools network overrides), force each page's API calls to **401 mid-session / 403 / 429 / 500 / network-timeout / malformed-JSON** and confirm the UI shows a recoverable error (toast or `ErrorBoundary` fallback), never a white screen, stuck spinner, or silent success. Do this per page, not once globally — each page handles failure differently.
|
||||||
|
- [ ] **Timezone/locale:** non-UTC tz + DST boundary — due dates and calendar stay correct.
|
||||||
|
|
||||||
|
### B15 — Regression & sign-off
|
||||||
|
Run on the **production build** (`npm start`), not dev:
|
||||||
|
- [ ] `npm run ci` green. Log in as `user` and `admin`.
|
||||||
|
- [ ] `npm run test:e2e` green (Playwright smoke + axe + visual-regression baselines match, §4.4).
|
||||||
|
- [ ] Tracker: create bill → quick-pay → skip another → add note; reflected on Summary/Calendar/Analytics.
|
||||||
|
- [ ] Create a category + subscription → appear on Tracker/Spending; Spending safe-to-spend correct.
|
||||||
|
- [ ] Snowball: add debt → projection. Data: seed → export → import round-trip (scratch DB).
|
||||||
|
- [ ] Admin: open panel, users, system status, run a backup. Banking loads + matches (if SimpleFIN configured).
|
||||||
|
- [ ] Notifications: one test message on configured channel. Toggle dark mode; mobile viewport; `Ctrl+K` navigates.
|
||||||
|
- [ ] Bogus URL → 404; logout → login redirect. Console clean throughout.
|
||||||
|
- [ ] Confirm [exit criteria](#appendix-b--exit--sign-off-criteria).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Appendices
|
||||||
|
|
||||||
|
### Appendix A — Severity definitions
|
||||||
|
|
||||||
|
| Level | Definition |
|
||||||
|
|-------|------------|
|
||||||
|
| **S1 – Critical** | Data loss/corruption, security hole, crash/blank page, wrong money math, cannot log in/save. |
|
||||||
|
| **S2 – Major** | Feature broken/unusable, wrong results, broken navigation, unhandled error. |
|
||||||
|
| **S3 – Minor** | Works but wrong edge behavior, confusing UX, missing validation message. |
|
||||||
|
| **S4 – Cosmetic** | Visual/copy/alignment/dark-mode-contrast, non-blocking. |
|
||||||
|
| **IMP – Improvement** | Not a bug; enhancement or polish idea. |
|
||||||
|
|
||||||
|
### Appendix B — Exit / sign-off criteria
|
||||||
|
|
||||||
|
A cycle is release-ready when:
|
||||||
|
- [ ] All batches B0–B15 ✅ on the primary matrix (Chrome desktop + mobile, light + dark, `user` + `admin`).
|
||||||
|
- [ ] B15 smoke green on the **production build**.
|
||||||
|
- [ ] **Zero open S1/S2** in the Findings Log; S3/S4/IMP triaged.
|
||||||
|
- [ ] `npm run ci` green; no new console errors.
|
||||||
|
- [ ] Data export→import round-trip verified with no loss.
|
||||||
|
- [ ] Auth/authorization + data-isolation all pass.
|
||||||
|
- [ ] Money and date/period correctness verified vs hand-calculated examples.
|
||||||
|
- [ ] All fixes for the cycle archived to `HISTORY.md`; cycle summary recorded (date, build/commit, environment).
|
||||||
|
|
||||||
|
### Appendix C — Page ↔ route ↔ API quick map
|
||||||
|
|
||||||
|
| Page | Route | Primary API |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| Tracker | `/` | `/api/tracker`, `/api/bills`, `/api/payments`, `/api/monthly-starting-amounts` |
|
||||||
|
| Calendar | `/calendar` | `/api/calendar` |
|
||||||
|
| Summary | `/summary` | `/api/summary` |
|
||||||
|
| Bills | `/bills` | `/api/bills`, `/api/categories`, `/api/matches` |
|
||||||
|
| Subscriptions / Catalog | `/subscriptions`, `/subscriptions/catalog` | `/api/subscriptions` |
|
||||||
|
| Categories | `/categories` | `/api/categories` |
|
||||||
|
| Health | `/health` | `/api/analytics`, `/api/summary` |
|
||||||
|
| Analytics | `/analytics` | `/api/analytics` |
|
||||||
|
| Spending | `/spending` | `/api/spending` |
|
||||||
|
| Banking | `/bank-transactions` | `/api/transactions`, `/api/matches`, `/api/data-sources` |
|
||||||
|
| Snowball / Payoff | `/snowball`, `/payoff` | `/api/snowball` |
|
||||||
|
| Settings | `/settings` | `/api/settings`, `/api/notifications` |
|
||||||
|
| Profile | `/profile` | `/api/profile`, `/api/user` |
|
||||||
|
| Data | `/data` | `/api/import`, `/api/export`, `/api/data-sources` |
|
||||||
|
| Admin | `/admin`, `/admin/status` | `/api/admin`, `/api/status`, `/api/about-admin` |
|
||||||
|
| About / Privacy / Release Notes / Roadmap | `/about`, `/privacy`, `/release-notes`, `/roadmap` | `/api/about`, `/api/privacy`, `/api/version` |
|
||||||
|
|
||||||
|
### Appendix D — Reference docs
|
||||||
|
`SECURITY_AUDIT.md` (security depth) · `docs/UI_IMPROVEMENTS.md` (known UI issues) · `docs/cents-migration-plan.md` (money-as-cents) · `docs/SIMPLEFIN_CONSUMER_GUARDRAILS.md` (sync limits) · `docs/CSRF-SPA-Setup.md`, `docs/RATE_LIMITING_ENHANCEMENT.md` (security middleware) · `REVIEW.md`, `DEVELOPMENT_LOG.md`, `roadmap.md`, `FUTURE.md` (context/known gaps) · `HISTORY.md` (changelog / fix archive) · `playwright.config.js` + `e2e/` (automated E2E/visual/a11y harness, §4.4).
|
||||||
|
|
||||||
|
### Appendix E — Per-page control census
|
||||||
|
|
||||||
|
The completeness ledger behind "every button, textbox, slider is right." Fill one table **per page** during [B0](#b0--baseline-tooling--coverage-recon) and check every control off during that page's batch. A control not listed here is a control not tested. Build the starting list with `grep -rnoE "type=[\"'][a-z]+[\"']" client/pages/<Page>.jsx` + `grep -n "onClick=\|<Button\|<Select\|<Switch\|<Checkbox" client/pages/<Page>.jsx`.
|
||||||
|
|
||||||
|
**Template** (copy per page):
|
||||||
|
|
||||||
|
| Control | Type | Expected action | States checked (default/focus/disabled/error/loading) | Keyboard | Result |
|
||||||
|
|---------|------|-----------------|-------------------------------------------------------|----------|--------|
|
||||||
|
| *e.g.* Quick-pay button | button | marks bill paid, updates balance cards, undo available | default ✓ · disabled-while-saving ✓ | Enter ✓ | ✅ / finding id |
|
||||||
|
| *e.g.* Amount input | number | per-month override, cents only, no wheel-scroll change | default ✓ · error-on-letters ✓ | Tab/Esc ✓ | ✅ / finding id |
|
||||||
|
|
||||||
|
**Pages to census** (from `client/pages/`, keep in sync with [Appendix C](#appendix-c--page--route--api-quick-map)): Tracker, Calendar, Summary, Bills, Subscriptions, SubscriptionCatalog, Categories, Health, Analytics, Spending, Snowball, Payoff, BankTransactions, Data, Settings, Profile, Admin, Status, About, Privacy, ReleaseNotes, Roadmap, Login, NotFound — plus the shared **Sidebar/command-palette/header** chrome once.
|
||||||
|
</content>
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
# E2E harness (Playwright)
|
||||||
|
|
||||||
|
The automated regression net for the QA plan — real clicks in a real browser,
|
||||||
|
plus visual-regression and axe-a11y that re-run every cycle. See
|
||||||
|
[`docs/QA_PLAN.md` §4.4](../docs/QA_PLAN.md) and the **B-UI / B14 / B15** batches.
|
||||||
|
|
||||||
|
## Setup (one-time)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npx playwright install chromium # add: firefox webkit for cross-browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e # seed scratch DB, then run headless
|
||||||
|
npm run test:e2e:ui # seed scratch DB, then Playwright UI mode (watch/debug)
|
||||||
|
npm run test:e2e:update # re-baseline visual-regression screenshots
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safe by construction
|
||||||
|
|
||||||
|
Each command runs `e2e/setup/prepare-db.js` first, which builds a **fresh scratch
|
||||||
|
DB** (`db/e2e.db`, git-ignored) with a seeded regular user — it **refuses to touch
|
||||||
|
`db/bills.db`**. [`playwright.config.js`](../playwright.config.js) then boots the
|
||||||
|
app against that scratch DB on **dedicated ports** (UI `5199`, API `3099`) with
|
||||||
|
`reuseExistingServer: false`, so E2E never collides with or reuses your normal
|
||||||
|
dev server on `5173/3000`. You can keep `npm run dev` running while E2E runs.
|
||||||
|
|
||||||
|
Override defaults via env: `E2E_DB_PATH`, `E2E_USER`, `E2E_PASS`, `E2E_UI_PORT`,
|
||||||
|
`E2E_API_PORT` (see `e2e/constants.js`).
|
||||||
|
|
||||||
|
## What's here
|
||||||
|
|
||||||
|
| File | Covers |
|
||||||
|
|------|--------|
|
||||||
|
| `setup/prepare-db.js` | fresh scratch DB + seeded regular user (run automatically) |
|
||||||
|
| `auth.setup.js` | logs in via the UI once, saves `.auth/user.json` for reuse |
|
||||||
|
| `smoke.spec.js` | (logged-out) login renders, empty-submit doesn't crash, routing, login visual baseline |
|
||||||
|
| `a11y.spec.js` | axe-core scan of public pages — fails on critical/serious WCAG violations |
|
||||||
|
| `critical-path.spec.js` | (logged-in) tracker renders seeded data, quick-pay + undo, all authed pages render error-free, sidebar nav |
|
||||||
|
|
||||||
|
## Growing it (next steps)
|
||||||
|
|
||||||
|
1. **Numeric reconciliation** — assert Summary's paid/unpaid/remaining totals
|
||||||
|
match the Tracker for the same month (QA_PLAN B5); see the TODO in
|
||||||
|
`critical-path.spec.js`.
|
||||||
|
2. **Create-bill flow** — drive BillModal (create → appears on Tracker → delete).
|
||||||
|
3. **Primitive state matrix** — one spec per `client/components/ui/*` (B-UI).
|
||||||
|
4. **Fault injection** — `page.route()` to force 401/429/500/timeout per page and
|
||||||
|
assert a recoverable error, never a white screen (B14).
|
||||||
|
5. **Visual coverage** — a `toHaveScreenshot` per page × {desktop,mobile}.
|
||||||
|
6. **Cross-browser** — enable the firefox/webkit projects in the config.
|
||||||
|
|
||||||
|
Screenshot baselines live in `e2e/**/*-snapshots/` and **are committed** (that's
|
||||||
|
the point of visual regression). `test-results/`, `playwright-report/`, and
|
||||||
|
`e2e/.auth/` are not.
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
// Automated accessibility scan of the AUTHENTICATED app (QA_PLAN B14). Companion
|
||||||
|
// to a11y.spec.js (public pages). Runs axe-core on the main in-app pages using the
|
||||||
|
// logged-in state from auth.setup, and fails on critical/serious WCAG violations
|
||||||
|
// so contrast/label/role regressions are caught every cycle.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const AxeBuilder = require('@axe-core/playwright').default;
|
||||||
|
const { STORAGE_STATE } = require('./constants');
|
||||||
|
|
||||||
|
test.use({ storageState: STORAGE_STATE });
|
||||||
|
|
||||||
|
const PAGES = ['/', '/bills', '/summary', '/spending', '/analytics', '/categories', '/snowball'];
|
||||||
|
|
||||||
|
for (const path of PAGES) {
|
||||||
|
test(`no critical/serious a11y violations on ${path}`, async ({ page }) => {
|
||||||
|
await page.goto(path);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||||
|
const blocking = results.violations.filter((v) => v.impact === 'critical' || v.impact === 'serious');
|
||||||
|
const summary = blocking
|
||||||
|
.map((v) => `- [${v.impact}] ${v.id}: ${v.help} (${v.nodes.length})\n ${v.nodes.map((n) => (n.html || '').slice(0, 140)).join('\n ')}`)
|
||||||
|
.join('\n');
|
||||||
|
if (blocking.length) console.log(`[a11y] ${path}\n${summary}\n`);
|
||||||
|
|
||||||
|
expect.soft(blocking, `axe critical/serious on ${path}:\n${summary}`).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
// Automated accessibility scan (docs/QA_PLAN.md B14). Runs axe-core on each page
|
||||||
|
// and fails on critical/serious WCAG violations. Public pages are covered here;
|
||||||
|
// add authenticated pages once a login fixture exists (see e2e/README.md). This
|
||||||
|
// is the automated companion to the manual keyboard/SR a11y checks — it re-runs
|
||||||
|
// every cycle so contrast/role regressions can't sneak back in.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const AxeBuilder = require('@axe-core/playwright').default;
|
||||||
|
|
||||||
|
const PUBLIC_PAGES = ['/login', '/about', '/privacy'];
|
||||||
|
|
||||||
|
for (const path of PUBLIC_PAGES) {
|
||||||
|
test(`no critical/serious a11y violations on ${path}`, async ({ page }) => {
|
||||||
|
await page.goto(path);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const results = await new AxeBuilder({ page })
|
||||||
|
.withTags(['wcag2a', 'wcag2aa'])
|
||||||
|
.analyze();
|
||||||
|
|
||||||
|
const blocking = results.violations.filter(
|
||||||
|
(v) => v.impact === 'critical' || v.impact === 'serious',
|
||||||
|
);
|
||||||
|
const summary = blocking
|
||||||
|
.map((v) => `- [${v.impact}] ${v.id}: ${v.help} (${v.nodes.length} node(s))`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
expect(blocking, `axe violations on ${path}:\n${summary}`).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
// Adversarial API probe (QA_PLAN B13 + B1). Find-mode: drives the real API as an
|
||||||
|
// authenticated user (user A) and observes responses — logs everything, and
|
||||||
|
// soft-asserts the two invariants that would be S1/S2 if violated:
|
||||||
|
// 1. no request produces a 500 (must be a structured 4xx instead), and
|
||||||
|
// 2. user A cannot read/modify user B's resources (data-isolation / IDOR).
|
||||||
|
// Runs on one project only; the DB (with user B + fixture) is built by prepare-db.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const { STORAGE_STATE } = require('./constants');
|
||||||
|
|
||||||
|
test.use({ storageState: STORAGE_STATE });
|
||||||
|
|
||||||
|
const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, '.auth', 'fixture.json'), 'utf8'));
|
||||||
|
|
||||||
|
async function csrf(request) {
|
||||||
|
const r = await request.get('/api/auth/csrf-token');
|
||||||
|
return (await r.json()).token;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('validation & error shapes — reject bad input, never a raw 500 (B13)', async ({ request }) => {
|
||||||
|
const token = await csrf(request);
|
||||||
|
const post = (data) => request.post('/api/bills', { headers: { 'x-csrf-token': token }, data });
|
||||||
|
|
||||||
|
// [payload, expected: 'reject' (4xx) or 'accept' (2xx)]
|
||||||
|
const cases = {
|
||||||
|
'empty body': [{}, 'reject'],
|
||||||
|
'missing name': [{ expected_amount: 10, due_day: 5 }, 'reject'],
|
||||||
|
'negative amount': [{ name: 'Neg', expected_amount: -100, due_day: 5 }, 'reject'], // QA-B13-01
|
||||||
|
'huge amount': [{ name: 'Huge', expected_amount: 1e15, due_day: 5 }, 'reject'], // QA-B13-01
|
||||||
|
'non-numeric amount': [{ name: 'NaN', expected_amount: 'abc', due_day: 5 }, 'reject'], // QA-B13-01
|
||||||
|
'due_day 0': [{ name: 'D0', expected_amount: 10, due_day: 0 }, 'reject'],
|
||||||
|
'due_day 99': [{ name: 'D99', expected_amount: 10, due_day: 99 }, 'reject'],
|
||||||
|
'due_day negative': [{ name: 'Dn', expected_amount: 10, due_day: -3 }, 'reject'],
|
||||||
|
'zero amount (valid)': [{ name: 'Zero', expected_amount: 0, due_day: 5 }, 'accept'],
|
||||||
|
'large valid amount': [{ name: 'Big', expected_amount: 9999999.99, due_day: 5 }, 'accept'],
|
||||||
|
'xss name (stored inert)': [{ name: '<script>alert(1)</script>', expected_amount: 10, due_day: 5 }, 'accept'],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [label, [data, expected]] of Object.entries(cases)) {
|
||||||
|
const res = await post(data);
|
||||||
|
const body = await res.text();
|
||||||
|
console.log(`[validate] ${label.padEnd(24)} -> ${res.status()} ${body.slice(0, 120)}`);
|
||||||
|
expect.soft(res.status(), `"${label}" must never 500`).toBeLessThan(500);
|
||||||
|
if (expected === 'reject') {
|
||||||
|
expect.soft(res.status(), `"${label}" should be rejected (4xx)`).toBeGreaterThanOrEqual(400);
|
||||||
|
} else {
|
||||||
|
expect.soft(res.status(), `"${label}" should be accepted (2xx)`).toBeLessThan(300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data-isolation — cannot touch another user\'s bill (B1/IDOR)', async ({ request }) => {
|
||||||
|
const token = await csrf(request);
|
||||||
|
const id = fixture.userBBillId;
|
||||||
|
expect(id, 'fixture must provide a user B bill id').toBeTruthy();
|
||||||
|
|
||||||
|
const get = await request.get(`/api/bills/${id}`);
|
||||||
|
console.log(`[idor] GET /api/bills/${id} -> ${get.status()}`);
|
||||||
|
expect.soft(get.status(), 'GET user B bill must be blocked (>=400)').toBeGreaterThanOrEqual(400);
|
||||||
|
|
||||||
|
const put = await request.put(`/api/bills/${id}`, {
|
||||||
|
headers: { 'x-csrf-token': token },
|
||||||
|
data: { name: 'HACKED', expected_amount: 1, due_day: 1 },
|
||||||
|
});
|
||||||
|
console.log(`[idor] PUT /api/bills/${id} -> ${put.status()}`);
|
||||||
|
expect.soft(put.status(), 'PUT user B bill must be blocked (>=400)').toBeGreaterThanOrEqual(400);
|
||||||
|
|
||||||
|
const del = await request.delete(`/api/bills/${id}`, { headers: { 'x-csrf-token': token } });
|
||||||
|
console.log(`[idor] DELETE /api/bills/${id} -> ${del.status()}`);
|
||||||
|
expect.soft(del.status(), 'DELETE user B bill must be blocked (>=400)').toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bad / nonexistent id — structured, not a 500 (B13)', async ({ request }) => {
|
||||||
|
for (const id of ['99999999', 'not-a-number', '0', '-1']) {
|
||||||
|
const res = await request.get(`/api/bills/${id}`);
|
||||||
|
const body = await res.text();
|
||||||
|
console.log(`[errshape] GET /api/bills/${id} -> ${res.status()} ${body.slice(0, 100)}`);
|
||||||
|
expect.soft(res.status(), `id=${id} must not 500`).toBeLessThan(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CSRF — a state-changing request without a token is rejected (B13)', async ({ request }) => {
|
||||||
|
const res = await request.post('/api/bills', { data: { name: 'NoCSRF', expected_amount: 10, due_day: 5 } });
|
||||||
|
console.log(`[csrf] POST /api/bills (no token) -> ${res.status()}`);
|
||||||
|
expect.soft(res.status(), 'missing CSRF token must be rejected (403)').toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seed demo data stores amounts in the correct unit — cents, not dollars (B9)', async ({ request }) => {
|
||||||
|
// QA-B9-01: POST /api/user/seed-demo-data must produce realistic amounts. The
|
||||||
|
// seed inserts dollars into the integer-cents expected_amount column (regression
|
||||||
|
// since the v1.03 cents migration), so a seeded "$85" bill shows as $0.85.
|
||||||
|
// We assert the *total* obligation for the seeded month is sane (>$100), which
|
||||||
|
// fails while the 100x bug is present and passes once fixed.
|
||||||
|
const now = new Date();
|
||||||
|
const res = await request.get(`/api/tracker?year=${now.getFullYear()}&month=${now.getMonth() + 1}`);
|
||||||
|
const body = await res.json();
|
||||||
|
const totalExpected = body?.summary?.total_expected ?? 0;
|
||||||
|
console.log(`[seed] tracker total_expected = $${totalExpected} (seeded month)`);
|
||||||
|
expect
|
||||||
|
.soft(totalExpected, 'seeded monthly bills should total > $100, not cents (QA-B9-01)')
|
||||||
|
.toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
// Auth fixture — logs in once via the real UI and saves the browser state so
|
||||||
|
// authenticated specs can reuse it (docs/QA_PLAN.md §4.4). Runs as the `setup`
|
||||||
|
// project; browser projects depend on it. Driving the real form (not a crafted
|
||||||
|
// API call) means CSRF/cookies are handled exactly as a user experiences them.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { test: setup, expect } = require('@playwright/test');
|
||||||
|
const { E2E_USER, E2E_PASS, STORAGE_STATE } = require('./constants');
|
||||||
|
|
||||||
|
setup('authenticate', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
|
||||||
|
await page.locator('#username').fill(E2E_USER);
|
||||||
|
await page.locator('#password').fill(E2E_PASS);
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
|
||||||
|
// A regular user lands on the Tracker ('/'). Wait until we've left /login.
|
||||||
|
await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 });
|
||||||
|
|
||||||
|
// Force a fresh authenticated load so the version check runs — the "What's new"
|
||||||
|
// dialog shows on a real session fetch, not the in-SPA post-login transition.
|
||||||
|
// Dismiss it if present ("Got it" calls acknowledgeVersion(), persisted
|
||||||
|
// server-side) so it won't reappear in reused contexts. Best-effort: on a DB
|
||||||
|
// where this user already acknowledged the version, the dialog won't appear.
|
||||||
|
await page.goto('/');
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'Got it' })
|
||||||
|
.click({ timeout: 8000 })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Sanity: the authenticated Tracker shows its primary action.
|
||||||
|
await expect(page.getByRole('button', { name: 'Add Bill' })).toBeVisible();
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(STORAGE_STATE), { recursive: true });
|
||||||
|
await page.context().storageState({ path: STORAGE_STATE });
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
// Shared constants for the E2E harness — required by both the DB-prep script
|
||||||
|
// (plain node) and the Playwright config/specs. See docs/QA_PLAN.md §4.4.
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Known creds for the seeded scratch user (regular `user` role, no forced
|
||||||
|
// password change). Override via env if needed; defaults are fine locally.
|
||||||
|
const E2E_USER = process.env.E2E_USER || 'e2e_user';
|
||||||
|
const E2E_PASS = process.env.E2E_PASS || 'e2e_pass_1234';
|
||||||
|
|
||||||
|
// Saved logged-in browser state, produced by auth.setup.js and reused by
|
||||||
|
// authenticated specs. Lives outside git (see .gitignore).
|
||||||
|
const STORAGE_STATE = path.join(__dirname, '.auth', 'user.json');
|
||||||
|
|
||||||
|
// Throwaway SQLite DB for E2E. NEVER the real db/bills.db. Override with
|
||||||
|
// E2E_DB_PATH to relocate (e.g. an OS tmpdir on CI).
|
||||||
|
function scratchDbPath() {
|
||||||
|
return process.env.E2E_DB_PATH || path.join(__dirname, '..', 'db', 'e2e.db');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedicated ports so E2E never collides with (or reuses) your real dev server
|
||||||
|
// on 5173/3000 — a second safety layer on top of the scratch DB.
|
||||||
|
const API_PORT = process.env.E2E_API_PORT || '3099';
|
||||||
|
const UI_PORT = process.env.E2E_UI_PORT || '5199';
|
||||||
|
|
||||||
|
module.exports = { E2E_USER, E2E_PASS, STORAGE_STATE, scratchDbPath, API_PORT, UI_PORT };
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
// 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$/);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Deterministic scratch DB for the E2E suite (docs/QA_PLAN.md §4.4).
|
||||||
|
*
|
||||||
|
* Creates a throwaway SQLite DB, a regular `user` with known creds, and seeds
|
||||||
|
* realistic demo data for that user — so `npm run test:e2e` is safe to run:
|
||||||
|
* it NEVER touches the real db/bills.db. Runs fresh every invocation.
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { E2E_USER, E2E_PASS, scratchDbPath } = require('../constants');
|
||||||
|
|
||||||
|
const REAL_DB = path.join(__dirname, '..', '..', 'db', 'bills.db');
|
||||||
|
const dbPath = scratchDbPath();
|
||||||
|
|
||||||
|
// Safety rail: refuse to operate on the real database.
|
||||||
|
if (path.resolve(dbPath) === path.resolve(REAL_DB)) {
|
||||||
|
console.error(`[e2e] Refusing to use the real DB at ${REAL_DB}. Set E2E_DB_PATH to a scratch path.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start from a clean slate so every run is reproducible.
|
||||||
|
for (const suffix of ['', '-wal', '-shm']) {
|
||||||
|
const f = dbPath + suffix;
|
||||||
|
if (fs.existsSync(f)) fs.rmSync(f);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
|
|
||||||
|
// db/database.js reads DB_PATH at require-time — set it BEFORE requiring.
|
||||||
|
process.env.DB_PATH = dbPath;
|
||||||
|
const { getDb, ensureUserDefaultCategories } = require('../../db/database');
|
||||||
|
const { seedDemoData } = require('../../scripts/seedDemoData');
|
||||||
|
|
||||||
|
const db = getDb(); // initializes schema + runs migrations
|
||||||
|
|
||||||
|
// Regular `user` (role 'user', no forced password change) — mirrors the app's
|
||||||
|
// own INIT_REGULAR_USER seed path in server.js.
|
||||||
|
let user = db.prepare('SELECT id FROM users WHERE username = ?').get(E2E_USER);
|
||||||
|
if (!user) {
|
||||||
|
const hash = bcrypt.hashSync(E2E_PASS, 12);
|
||||||
|
const res = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (username, password_hash, role, first_login, must_change_password, is_default_admin)
|
||||||
|
VALUES (?, ?, 'user', 0, 0, 0)`,
|
||||||
|
)
|
||||||
|
.run(E2E_USER, hash);
|
||||||
|
user = { id: Number(res.lastInsertRowid) };
|
||||||
|
}
|
||||||
|
ensureUserDefaultCategories(user.id);
|
||||||
|
|
||||||
|
// Seed demo bills/categories for this user (idempotent).
|
||||||
|
const result = seedDemoData(user.id);
|
||||||
|
|
||||||
|
// Second user (role 'user') with their OWN seeded data — the IDOR target for the
|
||||||
|
// data-isolation probe (e2e/api.probe.spec.js). User A must not be able to
|
||||||
|
// read/modify any of user B's resources.
|
||||||
|
const E2E_USER_B = 'e2e_user_b';
|
||||||
|
let userB = db.prepare('SELECT id FROM users WHERE username = ?').get(E2E_USER_B);
|
||||||
|
if (!userB) {
|
||||||
|
const hashB = bcrypt.hashSync(E2E_PASS, 12);
|
||||||
|
const resB = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (username, password_hash, role, first_login, must_change_password, is_default_admin)
|
||||||
|
VALUES (?, ?, 'user', 0, 0, 0)`,
|
||||||
|
)
|
||||||
|
.run(E2E_USER_B, hashB);
|
||||||
|
userB = { id: Number(resB.lastInsertRowid) };
|
||||||
|
}
|
||||||
|
ensureUserDefaultCategories(userB.id);
|
||||||
|
seedDemoData(userB.id);
|
||||||
|
const billB = db.prepare('SELECT id FROM bills WHERE user_id = ? ORDER BY id LIMIT 1').get(userB.id);
|
||||||
|
|
||||||
|
// Fixture consumed by the probe spec (git-ignored under e2e/.auth/).
|
||||||
|
const fixturePath = path.join(__dirname, '..', '.auth', 'fixture.json');
|
||||||
|
fs.mkdirSync(path.dirname(fixturePath), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
fixturePath,
|
||||||
|
JSON.stringify({ userAId: user.id, userBId: userB.id, userBBillId: billB ? billB.id : null }, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[e2e] Scratch DB ready at ${dbPath}`);
|
||||||
|
console.log(`[e2e] User A '${E2E_USER}' (id=${user.id}) — ${JSON.stringify(result)}`);
|
||||||
|
console.log(`[e2e] User B '${E2E_USER_B}' (id=${userB.id}) — IDOR target bill id=${billB ? billB.id : 'none'}`);
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
// Critical-path smoke — the thin regression net described in docs/QA_PLAN.md §4.4.
|
||||||
|
// Keep these fast and stable; grow the suite whenever a manual QA pass finds a UI
|
||||||
|
// regression a click-test could have caught. Deeper flows (login -> pay bill ->
|
||||||
|
// reconcile) get their own spec once a seeded scratch DB + test creds are wired
|
||||||
|
// via a Playwright fixture — see e2e/README.md.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('login page', () => {
|
||||||
|
test('renders the sign-in form', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
|
||||||
|
await expect(page).toHaveTitle(/Bill Tracker/i);
|
||||||
|
await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible();
|
||||||
|
await expect(page.locator('#username')).toBeVisible();
|
||||||
|
await expect(page.locator('#password')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty submit without a crash', async ({ page }) => {
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(e));
|
||||||
|
await page.goto('/login');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
// Still on the login page, no uncaught exception, no white screen.
|
||||||
|
await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible();
|
||||||
|
expect(errors, `uncaught page errors: ${errors.map(String).join('\n')}`).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Visual-regression baseline. First run writes the snapshot; later runs diff
|
||||||
|
// against it. Re-baseline intentionally with `npm run test:e2e:update`.
|
||||||
|
test('login page matches visual baseline', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible();
|
||||||
|
await expect(page).toHaveScreenshot('login.png', { fullPage: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('routing', () => {
|
||||||
|
test('unauthenticated deep link lands on login, not a blank page', async ({ page }) => {
|
||||||
|
await page.goto('/bogus-does-not-exist');
|
||||||
|
// Protected shell redirects an unauthenticated user to /login; either way the
|
||||||
|
// page must render real content, never a white screen.
|
||||||
|
await expect(page.locator('body')).not.toBeEmpty();
|
||||||
|
await expect(page).toHaveURL(/\/(login|bogus-does-not-exist)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.39.0",
|
"version": "0.40.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.39.0",
|
"version": "0.40.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||||
|
|
@ -53,6 +53,8 @@
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.10.1",
|
||||||
|
"@playwright/test": "^1.50.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
|
@ -145,6 +147,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@axe-core/playwright": {
|
||||||
|
"version": "4.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz",
|
||||||
|
"integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"axe-core": "~4.12.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"playwright-core": ">= 1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
|
|
@ -2732,6 +2747,22 @@
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/number": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
|
|
@ -6120,6 +6151,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axe-core": {
|
||||||
|
"version": "4.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz",
|
||||||
|
"integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||||
"version": "0.4.17",
|
"version": "0.4.17",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz",
|
||||||
|
|
@ -11058,6 +11099,53 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/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/pngjs": {
|
"node_modules/pngjs": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@
|
||||||
"test": "node --test tests/*.test.js",
|
"test": "node --test tests/*.test.js",
|
||||||
"test:client": "vitest run",
|
"test:client": "vitest run",
|
||||||
"test:all": "npm run test && npm run test:client",
|
"test:all": "npm run test && npm run test:client",
|
||||||
|
"test:e2e": "node e2e/setup/prepare-db.js && playwright test --project=chromium-desktop --project=chromium-mobile",
|
||||||
|
"test:e2e:ui": "node e2e/setup/prepare-db.js && playwright test --ui",
|
||||||
|
"test:e2e:update": "node e2e/setup/prepare-db.js && playwright test --project=chromium-desktop --project=chromium-mobile --update-snapshots",
|
||||||
|
"test:e2e:probe": "node e2e/setup/prepare-db.js && playwright test --project=probe",
|
||||||
"ci": "npm run check:server && npm run test:all && npm run build",
|
"ci": "npm run check:server && npm run test:all && npm run build",
|
||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
|
|
@ -61,6 +65,8 @@
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.10.1",
|
||||||
|
"@playwright/test": "^1.50.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
// Playwright E2E / visual-regression / a11y harness for BillTracker.
|
||||||
|
// See docs/QA_PLAN.md §4.4 and the B-UI / B14 / B15 batches.
|
||||||
|
//
|
||||||
|
// Setup (one-time): npm install && npx playwright install chromium
|
||||||
|
// Run: npm run test:e2e (headless)
|
||||||
|
// npm run test:e2e:ui (watch/debug)
|
||||||
|
// npm run test:e2e:update (re-baseline screenshots)
|
||||||
|
//
|
||||||
|
// SAFETY: the two `webServer` entries boot the app against a SCRATCH DB
|
||||||
|
// (e2e/setup/prepare-db.js seeds it) on DEDICATED ports (5199 UI / 3099 API), so
|
||||||
|
// E2E never touches your real db/bills.db and never reuses a dev server running
|
||||||
|
// on 5173/3000. `reuseExistingServer` is false for the same reason.
|
||||||
|
const { defineConfig, devices } = require('@playwright/test');
|
||||||
|
const { scratchDbPath, API_PORT, UI_PORT } = require('./e2e/constants');
|
||||||
|
|
||||||
|
const baseURL = `http://localhost:${UI_PORT}`;
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: [['list'], ['html', { open: 'never' }]],
|
||||||
|
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Visual-regression tolerance. Screenshots are OS/font-sensitive, so baselines
|
||||||
|
// are committed per-platform (Playwright suffixes them with the platform).
|
||||||
|
expect: {
|
||||||
|
toHaveScreenshot: { maxDiffPixelRatio: 0.02, animations: 'disabled' },
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
// Logs in once and writes STORAGE_STATE; browser projects depend on it.
|
||||||
|
{ name: 'setup', testMatch: /auth\.setup\.js/ },
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'chromium-desktop',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
dependencies: ['setup'],
|
||||||
|
testIgnore: [/auth\.setup\.js/, /api\.probe\.spec\.js/, /a11y\.authed\.spec\.js/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'chromium-mobile',
|
||||||
|
use: { ...devices['Pixel 5'] },
|
||||||
|
dependencies: ['setup'],
|
||||||
|
testIgnore: [/auth\.setup\.js/, /api\.probe\.spec\.js/, /a11y\.authed\.spec\.js/],
|
||||||
|
},
|
||||||
|
// Find-mode diagnostics (adversarial API probe + authenticated a11y scan).
|
||||||
|
// Own project so they don't run concurrently with / pollute the UI specs, and
|
||||||
|
// so open findings here don't turn the default suite red. Run on demand:
|
||||||
|
// `npm run test:e2e:probe`. Fold a spec into the default projects once its
|
||||||
|
// findings are fixed (it becomes a passing regression guard).
|
||||||
|
{
|
||||||
|
name: 'probe',
|
||||||
|
testMatch: /(api\.probe|a11y\.authed)\.spec\.js/,
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
dependencies: ['setup'],
|
||||||
|
},
|
||||||
|
// Cross-browser (WebAuthn/Select behavior differs) — enable when ready:
|
||||||
|
// { name: 'firefox', use: { ...devices['Desktop Firefox'] }, dependencies: ['setup'], testIgnore: /auth\.setup\.js/ },
|
||||||
|
// { name: 'webkit', use: { ...devices['Desktop Safari'] }, dependencies: ['setup'], testIgnore: /auth\.setup\.js/ },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Two servers: the API (node) and the Vite UI that proxies /api to it. Both
|
||||||
|
// run against the scratch DB on dedicated ports. Playwright waits for each
|
||||||
|
// `url` to respond before starting tests.
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: 'node server.js',
|
||||||
|
url: `http://localhost:${API_PORT}/api/version`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
stdout: 'ignore',
|
||||||
|
stderr: 'pipe',
|
||||||
|
env: {
|
||||||
|
DB_PATH: scratchDbPath(),
|
||||||
|
PORT: String(API_PORT),
|
||||||
|
BIND_HOST: '127.0.0.1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: `npm run dev:ui -- --port ${UI_PORT} --strictPort`,
|
||||||
|
url: baseURL,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
stdout: 'ignore',
|
||||||
|
stderr: 'pipe',
|
||||||
|
env: {
|
||||||
|
API_PORT: String(API_PORT),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
@ -12,6 +12,9 @@ const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'db', 'bills.d
|
||||||
|
|
||||||
// Import database helper
|
// Import database helper
|
||||||
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
const { getDb, ensureUserDefaultCategories } = require('../db/database');
|
||||||
|
// Money columns (expected_amount, current_balance, minimum_payment) are stored as
|
||||||
|
// integer cents since migration v1.03 — convert the demo dollars before insert.
|
||||||
|
const { toCents } = require('../utils/money');
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
'Utilities',
|
'Utilities',
|
||||||
|
|
@ -416,11 +419,11 @@ function seedDemoData(userId = null) {
|
||||||
billData.dueDay || Math.floor(Math.random() * 28) + 1,
|
billData.dueDay || Math.floor(Math.random() * 28) + 1,
|
||||||
billData.cycle || 'monthly',
|
billData.cycle || 'monthly',
|
||||||
billData.cycleType || 'monthly',
|
billData.cycleType || 'monthly',
|
||||||
billData.amount,
|
toCents(billData.amount),
|
||||||
billData.autopay !== undefined ? (billData.autopay ? 1 : 0) : 0,
|
billData.autopay !== undefined ? (billData.autopay ? 1 : 0) : 0,
|
||||||
billData.interestRate ?? 0,
|
billData.interestRate ?? 0,
|
||||||
billData.currentBalance ?? null,
|
billData.currentBalance != null ? toCents(billData.currentBalance) : null,
|
||||||
billData.minPayment ?? null,
|
billData.minPayment != null ? toCents(billData.minPayment) : null,
|
||||||
billData.snowballOrder ?? null,
|
billData.snowballOrder ?? null,
|
||||||
billData.snowballInclude ?? 0,
|
billData.snowballInclude ?? 0,
|
||||||
billData.snowballExempt ?? 0,
|
billData.snowballExempt ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -291,8 +291,22 @@ function validateBillData(data, existingBill = null) {
|
||||||
// override_due_date
|
// override_due_date
|
||||||
normalized.override_due_date = data.override_due_date !== undefined ? (data.override_due_date || null) : (existingBill?.override_due_date || null);
|
normalized.override_due_date = data.override_due_date !== undefined ? (data.override_due_date || null) : (existingBill?.override_due_date || null);
|
||||||
|
|
||||||
// expected_amount (stored as integer cents)
|
// expected_amount (stored as integer cents). Validate: reject non-numeric,
|
||||||
normalized.expected_amount = data.expected_amount !== undefined ? (toCents(data.expected_amount) || 0) : (existingBill?.expected_amount || 0);
|
// negative, or absurd values (kept well under Number.MAX_SAFE_INTEGER cents).
|
||||||
|
// QA-B13-01: previously any coercible value was accepted ("abc"→$0, -100, 1e15).
|
||||||
|
const MAX_EXPECTED_CENTS = 100_000_000_00; // $100,000,000
|
||||||
|
if (data.expected_amount === undefined) {
|
||||||
|
normalized.expected_amount = existingBill?.expected_amount || 0;
|
||||||
|
} else if (data.expected_amount === null || data.expected_amount === '') {
|
||||||
|
normalized.expected_amount = 0;
|
||||||
|
} else {
|
||||||
|
const cents = toCents(data.expected_amount);
|
||||||
|
if (!Number.isInteger(cents) || cents < 0 || cents > MAX_EXPECTED_CENTS) {
|
||||||
|
errors.push({ field: 'expected_amount', message: 'expected_amount must be a number between 0 and 100000000' });
|
||||||
|
} else {
|
||||||
|
normalized.expected_amount = cents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// interest_rate
|
// interest_rate
|
||||||
if (data.interest_rate !== undefined) {
|
if (data.interest_rate !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,10 @@ function mulMoney(dollars, factor) {
|
||||||
|
|
||||||
const _usd = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
const _usd = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
/** Dollar number → "$1,234.56". null/undefined → "$0.00". */
|
/** Dollar number → "$1,234.56" (negatives as "-$1,234.56"). null/undefined → "$0.00". */
|
||||||
function formatUSD(dollars) {
|
function formatUSD(dollars) {
|
||||||
return '$' + _usd.format(Number(dollars) || 0);
|
const n = Number(dollars) || 0;
|
||||||
|
return (n < 0 ? '-' : '') + '$' + _usd.format(Math.abs(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Integer cents → "$1,234.56". */
|
/** Integer cents → "$1,234.56". */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue