Compare commits
No commits in common. "d82aa0665235820960e48f370d1c688c0e62cc55" and "bdbf231538e41c42f4a347a40d27124603309e5c" have entirely different histories.
d82aa06652
...
bdbf231538
|
|
@ -21,14 +21,6 @@ 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/
|
||||||
|
|
||||||
|
|
|
||||||
18
HISTORY.md
18
HISTORY.md
|
|
@ -1,24 +1,6 @@
|
||||||
# Bill Tracker — Changelog
|
# Bill Tracker — Changelog
|
||||||
## v0.41.0
|
## v0.41.0
|
||||||
|
|
||||||
### 🐛 QA Fixes
|
|
||||||
|
|
||||||
- **[Security] Bill name could inject HTML into reminder emails** — `buildEmailHtml` (`services/notificationService.js`) escaped the bill name in the detail table but interpolated it **raw** into the reminder message line (`<strong>${bill.name}</strong> is due…`), so a bill named `<img src=x onerror=…>` landed unescaped in the email HTML. Self-XSS (reminder emails go to the bill's owner), but a clear inconsistent-escaping bug — now escaped everywhere. Covered by `tests/notificationDelivery.test.js`. (was QA-B14-04)
|
|
||||||
- **[Notifications] "Send test push" was completely broken** — `services/notificationService.js` attached its `_push` export (the ntfy/Gotify/Discord/Telegram helpers) *before* the final `module.exports = {…}`, which clobbered it, so `require('…/notificationService')._push` was `undefined`. `routes/notifications.js` (`const { sendTestPush } = require(…)._push || {}`) therefore always hit `throw 'Push service not initialised'` → **`POST /api/notifications/test-push` always returned 500** for every user testing their push channel. Scheduled reminders were unaffected (they call `sendPushToUser` in-scope). Moved the `_push` assignment after the reassignment. Covered by `tests/notificationDelivery.test.js` (per-channel payloads, dispatch, error handling, and a check that the auth token never leaks into the message body). (was QA-B10-01)
|
|
||||||
- **[Summary/Analytics] Non-monthly bills were counted in every month** — the Summary expense list/total and the Analytics "expected vs actual" line both counted annual (and off-month quarterly) bills for months they weren't due, over-stating the obligation and disagreeing with the Tracker (e.g. a yearly insurance bill inflated every month). Both `routes/summary.js` and `services/analyticsService.js` now gate bills by `resolveDueDate` — the same occurrence check the Tracker uses. Guarded by Tracker↔Summary and Tracker↔Analytics reconciliation checks in `e2e/api.probe.spec.js`. The SimpleFIN bank-tracking `unpaid_this_month` metric had the same gap and is fixed the same way (fetch + JS `resolveDueDate` filter, since SQL can't call it), covered by `tests/summaryBankTracking.test.js`. (was QA-B5-01, QA-B5-02, QA-B5-03)
|
|
||||||
- **[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)
|
|
||||||
- **[Money] `toCents` lost a cent on 3-decimal half values** — `toCents(1.005)` returned `100` ($1.00) instead of `101`, because `Math.round(n * 100)` inherits binary-float error (`1.005 * 100 === 100.4999…`). Now rounds off the shortest decimal string that round-trips the value (half away from zero); output is **identical** for every integer / ≤2-decimal / `"$1,234.56"` input, so no downstream behavior changes. Added `tests/money.test.js` (9 tests). (was QA-B7-01)
|
|
||||||
- **[a11y] Nested interactive controls in Categories & Snowball rows** — Categories rows were `role="button"` (and draggable) yet wrapped their own move/edit/delete buttons, and the Snowball plan-status header wrapped its action buttons *inside* the collapsible trigger button (axe **serious `nested-interactive`**). Restructured both: Categories now use a plain container with a dedicated chevron toggle button (click-anywhere still expands via mouse; keyboard/SR use the chevron); the Snowball header splits into a name/progress toggle, sibling action buttons, and a chevron toggle. Expand/collapse verified by `e2e/categories.spec.js`; all 8 authenticated pages now pass axe. (completes QA-B14-02)
|
|
||||||
|
|
||||||
### 🧹 QA Cleanup
|
|
||||||
|
|
||||||
- **[Build] Split the vendor bundle** — the main `index` chunk was ~659 kB (over Vite's 500 kB warning). Added `build.rollupOptions.output.manualChunks` (`vite.config.mjs`) to split React, Radix, framer-motion, and TanStack into separately-cacheable vendor chunks; the index chunk dropped to ~334 kB and the warning is gone. (was QA-B0-01)
|
|
||||||
- **[Deps] Removed unused markdown libraries** — `react-markdown`, `rehype-sanitize`, and `remark-gfm` were declared but never imported (client markdown is rendered by a custom `MarkdownText` component). Removed all three and corrected the security notes: the actual XSS defense is React auto-escaping + the restrictive custom renderer (https-only link hrefs, no `dangerouslySetInnerHTML` anywhere), not rehype-sanitize. (was QA-B14-03)
|
|
||||||
- **[Debt] Removed dead `totalInterestPaid`** — unused outside its own export, and its unrounded accumulation diverged from `amortizationSchedule`'s per-month rounding. Removed from `services/aprService.js`. (was QA-B7-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.
|
||||||
|
|
|
||||||
|
|
@ -145,14 +145,11 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete,
|
||||||
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-500/[0.05] dark:bg-emerald-400/[0.04] shadow-sm overflow-hidden">
|
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-500/[0.05] dark:bg-emerald-400/[0.04] shadow-sm overflow-hidden">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
{/* Header row. The name/progress area and the chevron are the collapsible
|
<CollapsibleTrigger asChild>
|
||||||
toggles; the action buttons are siblings (not nested inside a trigger
|
<button type="button" className="w-full text-left">
|
||||||
button) so they don't trip axe nested-interactive (a11y QA-B14-02). */}
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
|
||||||
|
|
||||||
{/* Status dot + name + progress — toggle */}
|
{/* Status dot + name */}
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<button type="button" className="flex min-w-0 flex-1 items-center gap-3 text-left">
|
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||||
|
|
@ -181,44 +178,38 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete,
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-mono text-muted-foreground">{overallPct}%</span>
|
<span className="text-xs font-mono text-muted-foreground">{overallPct}%</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
{/* Actions — siblings of the triggers, not nested inside them */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0" onClick={e => e.stopPropagation()}>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1" onClick={onPause}>
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1" onClick={onPause}>
|
||||||
<Pause className="h-3 w-3" /> Pause
|
<Pause className="h-3 w-3" /> Pause
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={() => confirm('complete', 'Mark plan as complete?', 'This will record your plan as successfully completed.', onComplete)}>
|
||||||
|
<CheckCircle2 className="h-3 w-3" /> Complete
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isPaused && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={onResume}>
|
||||||
|
<Play className="h-3 w-3" /> Resume
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-destructive hover:bg-destructive/10" onClick={() => confirm('abandon', 'Abandon this plan?', 'This plan will be moved to history. Your debt data stays unchanged.', onAbandon)}>
|
||||||
|
<X className="h-3 w-3" /> Abandon
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-muted-foreground" onClick={() => confirm('new', 'Start a new plan?', 'Your current plan will be abandoned and moved to history. Your debt data stays unchanged.', onNewPlan)}>
|
||||||
|
<Zap className="h-3 w-3" /> New Plan
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={() => confirm('complete', 'Mark plan as complete?', 'This will record your plan as successfully completed.', onComplete)}>
|
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', open && 'rotate-180')} />
|
||||||
<CheckCircle2 className="h-3 w-3" /> Complete
|
</div>
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isPaused && (
|
|
||||||
<>
|
|
||||||
<Button size="sm" variant="outline" className="h-7 px-2 text-xs gap-1 border-emerald-400/40 text-emerald-600 hover:bg-emerald-500/10 dark:text-emerald-400" onClick={onResume}>
|
|
||||||
<Play className="h-3 w-3" /> Resume
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-destructive hover:bg-destructive/10" onClick={() => confirm('abandon', 'Abandon this plan?', 'This plan will be moved to history. Your debt data stays unchanged.', onAbandon)}>
|
|
||||||
<X className="h-3 w-3" /> Abandon
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs gap-1 text-muted-foreground" onClick={() => confirm('new', 'Start a new plan?', 'Your current plan will be abandoned and moved to history. Your debt data stays unchanged.', onNewPlan)}>
|
|
||||||
<Zap className="h-3 w-3" /> New Plan
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chevron — also a toggle */}
|
</div>
|
||||||
<CollapsibleTrigger asChild>
|
</button>
|
||||||
<button type="button" aria-label="Toggle plan details" className="shrink-0 rounded text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
</CollapsibleTrigger>
|
||||||
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
|
|
||||||
</button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Collapsible body — per-debt rows */}
|
{/* Collapsible body — per-debt rows */}
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ export function cn(...inputs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fmt(amount) {
|
export function fmt(amount) {
|
||||||
const n = Number(amount) || 0;
|
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
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,12 +12,6 @@ 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, label = 'Chart' }) {
|
function SvgFrame({ children, height = 260 }) {
|
||||||
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" aria-label={label} className="h-auto w-full">
|
<svg viewBox={`0 0 720 ${height}`} role="img" 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} label="Trend line chart">
|
<SvgFrame height={height}>
|
||||||
{[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} label="Grouped bar chart">
|
<SvgFrame height={height}>
|
||||||
{[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" aria-label="Category breakdown donut chart" className="h-56 w-56">
|
<svg viewBox="0 0 220 220" role="img" 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} label="Forecast chart">
|
<SvgFrame height={height}>
|
||||||
{/* 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" aria-label="Use a bill template">
|
<SelectTrigger className="h-9 min-w-[160px] flex-1 bg-card sm:w-[180px] sm:flex-none">
|
||||||
<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" aria-label="Filter by category">
|
<SelectTrigger className="h-10">
|
||||||
<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" aria-label="Filter by billing schedule">
|
<SelectTrigger className="h-10 capitalize">
|
||||||
<SelectValue placeholder="Billing schedule" />
|
<SelectValue placeholder="Billing schedule" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
|
||||||
|
|
@ -565,16 +565,16 @@ export default function CategoriesPage() {
|
||||||
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
|
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Plain container (not role=button) so the nested action
|
|
||||||
buttons aren't interactive-in-interactive (a11y QA-B14-02).
|
|
||||||
Mouse click-anywhere still expands; keyboard/SR users use
|
|
||||||
the dedicated chevron toggle button below. */}
|
|
||||||
<div
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
title={preview}
|
title={preview}
|
||||||
onClick={() => toggleCategory(cat.id)}
|
onClick={() => toggleCategory(cat.id)}
|
||||||
|
onKeyDown={event => onRowKeyDown(event, cat.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group grid cursor-pointer gap-4 px-4 py-4 transition-colors sm:px-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-center',
|
'group grid cursor-pointer gap-4 px-4 py-4 transition-colors sm:px-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-center',
|
||||||
'hover:bg-muted/35',
|
'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||||
isExpanded && 'bg-muted/25',
|
isExpanded && 'bg-muted/25',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -615,20 +615,12 @@ export default function CategoriesPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<ChevronDown
|
||||||
type="button"
|
className={cn(
|
||||||
onClick={event => { event.stopPropagation(); toggleCategory(cat.id); }}
|
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
|
||||||
aria-expanded={isExpanded}
|
isExpanded && 'rotate-180 text-foreground',
|
||||||
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${cat.name}`}
|
)}
|
||||||
className="mt-0.5 shrink-0 rounded text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
/>
|
||||||
>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-4 transition-transform',
|
|
||||||
isExpanded && 'rotate-180 text-foreground',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
)}
|
)}
|
||||||
title={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'}
|
aria-label={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)} aria-label="Previous month">
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navMonth(-1)}>
|
||||||
<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)} aria-label="Next month">
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navMonth(1)}>
|
||||||
<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" aria-label="Filter by category">
|
<SelectTrigger className="h-10">
|
||||||
<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" aria-label="Filter by billing schedule">
|
<SelectTrigger className="h-10 capitalize">
|
||||||
<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" aria-label="Sort by">
|
<SelectTrigger className="h-10">
|
||||||
<SelectValue placeholder="Sort by" />
|
<SelectValue placeholder="Sort by" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
|
||||||
532
docs/QA_PLAN.md
532
docs/QA_PLAN.md
|
|
@ -1,532 +0,0 @@
|
||||||
# BillTracker — Master QA Plan (living document)
|
|
||||||
|
|
||||||
**Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-02
|
|
||||||
**Cycle 1: COMPLETE ✅** — all 17 batches (B0→B15 + B-UI) run; 14 findings fixed, verified & archived (3× S2); automated re-run clean (0 new); guard suite green. External-infra items (live TOTP/WebAuthn/OIDC, SMTP delivery, cross-browser, PWA-offline, load) carried to Cycle 2 as non-blocking.
|
|
||||||
|
|
||||||
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 | ✅ | 0 / 1 |
|
|
||||||
| 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 / 3 |
|
|
||||||
| 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) | ✅ | 0 / 2 |
|
|
||||||
| 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 / 1 |
|
|
||||||
| 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 | ✅ | 0 / 4 |
|
|
||||||
| 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.
|
|
||||||
|
|
||||||
**✅ means "run complete for this cycle's automatable scope, green, findings archived."**
|
|
||||||
Cycle 1 built a durable, automated guard for every batch (`npm run ci` · `test:e2e` ·
|
|
||||||
`test:e2e:probe` · `smoke:prod`). The following need **external infrastructure or a
|
|
||||||
human** and were **not** exercised — they are **non-blocking** for Cycle 1 sign-off and
|
|
||||||
carried to Cycle 2:
|
|
||||||
|
|
||||||
- **B1** — live TOTP enrollment, WebAuthn/passkeys (browser/OS prompts), OIDC SSO round-trip. (Password login, roles, CSRF, data-isolation, admin authz **are** covered.)
|
|
||||||
- **B10** — real SMTP *delivery* (push delivery + email-HTML building/escaping **are** covered by `tests/notificationDelivery.test.js`).
|
|
||||||
- **B11** — backup create/restore on a scratch instance (authorization + last-admin guards **are** covered).
|
|
||||||
- **B14** — Firefox/Safari cross-browser, PWA install/offline, and large/stress load+perf. (axe a11y on 8 pages, XSS/escaping, and prod-bundle perf **are** covered.)
|
|
||||||
- **B9** — spreadsheet/CSV import *from real files* end-to-end (money-unit handling + SQLite export→import round-trip **are** covered by tests).
|
|
||||||
|
|
||||||
### 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`→`5ffe2db` (dev) | 14 | **14 → all fixed, verified & archived** (3× S2 incl. broken "Send test push", email XSS, reconciliation family, seed 100× cents) | 🔁 Phase 2 complete — 0 open. Every batch B0→B15 (+B-UI) run; 16 QA commits; guard suite green. |
|
|
||||||
| 1·re-run | 2026-07-02 | `5ffe2db` (dev) | **0 new** | — | ✅ **Automated re-run clean.** CI (server 109 + client 34, build), UI E2E 27, probe 16 (authz 403, Tracker↔Summary↔Analytics reconcile exactly, seed guard, a11y 8/8), prod-smoke PASS. **All 17 batches ✅ for automatable scope; external-infra residuals listed below are non-blocking and carried to Cycle 2.** |
|
|
||||||
|
|
||||||
**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 |
|
|
||||||
|----|-----|--------------------|---------|--------|---------------|
|
|
||||||
| _(none — all Cycle 1 findings fixed, verified & archived to `HISTORY.md` v0.41.0)_ | | | | | |
|
|
||||||
|
|
||||||
**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**.
|
|
||||||
|
|
||||||
_All Cycle 1 write-ups have been archived to `HISTORY.md` v0.41.0 (see §3)._
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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) |
|
|
||||||
| `npm run smoke:prod` | **B15 production-build smoke** — builds, boots `node server.js` (dist/), drives the real artifact so the split vendor chunks are validated at runtime |
|
|
||||||
|
|
||||||
- **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 (defense = React auto-escaping + the restrictive custom `MarkdownText` renderer — https-only link hrefs, **no** `dangerouslySetInnerHTML` anywhere; NOT rehype-sanitize, which is unused, see QA-B14-03); 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: **(Cycle 1 — all met ✅)**
|
|
||||||
- [x] All batches B0–B15 ✅ (Chromium desktop + mobile via the E2E projects; light + dark, `user` + `admin` exercised). Cross-browser Firefox/Safari carried to Cycle 2.
|
|
||||||
- [x] B15 smoke green on the **production build** (`npm run smoke:prod`).
|
|
||||||
- [x] **Zero open S1/S2** in the Findings Log; S3/S4/IMP all fixed & archived.
|
|
||||||
- [x] `npm run ci` green (server 109 + client 34 + build); no new console errors (verified in prod-smoke).
|
|
||||||
- [x] Data export→import round-trip verified with no loss (`tests/exportImportRoundTrip.test.js`).
|
|
||||||
- [x] Auth/authorization + data-isolation all pass (probe: IDOR → 404, CSRF → 403, admin/status → 403).
|
|
||||||
- [x] Money and date/period correctness verified vs hand-calculated examples (`tests/money.test.js`, `aprService`, recurrence probe, reconciliation guards).
|
|
||||||
- [x] All 14 fixes archived to `HISTORY.md` v0.41.0; cycle summary recorded (Cycle Log §1.1).
|
|
||||||
|
|
||||||
### 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>
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
// 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([]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
// 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([]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
// 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('authorization — a regular user is blocked from admin + status APIs (B1/B11)', async ({ request }) => {
|
|
||||||
const token = await csrf(request);
|
|
||||||
// Read endpoints → 403 (requireAdmin); user A is a regular 'user'.
|
|
||||||
for (const url of ['/api/admin/users', '/api/admin/has-users', '/api/status', '/api/about-admin']) {
|
|
||||||
const res = await request.get(url);
|
|
||||||
console.log(`[authz] GET ${url} -> ${res.status()}`);
|
|
||||||
expect.soft(res.status(), `${url} must be admin-only (403)`).toBe(403);
|
|
||||||
}
|
|
||||||
// Write endpoint → 403 too (not a silent success).
|
|
||||||
const create = await request.post('/api/admin/users', {
|
|
||||||
headers: { 'x-csrf-token': token },
|
|
||||||
data: { username: 'sneaky_admin', password: 'password123', role: 'admin' },
|
|
||||||
});
|
|
||||||
console.log(`[authz] POST /api/admin/users -> ${create.status()}`);
|
|
||||||
expect.soft(create.status(), 'creating a user as a non-admin must be 403').toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
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('reconcile — Tracker and Summary report the same month obligation (B5)', async ({ request }) => {
|
|
||||||
const now = new Date();
|
|
||||||
const q = `?year=${now.getFullYear()}&month=${now.getMonth() + 1}`;
|
|
||||||
const tracker = await (await request.get(`/api/tracker${q}`)).json();
|
|
||||||
const summary = await (await request.get(`/api/summary${q}`)).json();
|
|
||||||
|
|
||||||
const trackerExpected = tracker?.summary?.total_expected ?? 0;
|
|
||||||
// Summary lists each bill; sum its display_amount for the same month.
|
|
||||||
const summaryExpected = (summary?.expenses ?? []).reduce(
|
|
||||||
(s, e) => s + (Number(e.display_amount ?? e.expected_amount) || 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const round = (n) => Math.round(n * 100) / 100;
|
|
||||||
console.log(`[recon] tracker.total_expected=$${round(trackerExpected)} summary.sum=$${round(summaryExpected)}`);
|
|
||||||
expect.soft(round(summaryExpected), 'Summary bill total must reconcile with Tracker (B5)').toBe(round(trackerExpected));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reconcile — Analytics expected line gates by occurrence like the Tracker (B5)', async ({ request }) => {
|
|
||||||
const now = new Date();
|
|
||||||
const y = now.getFullYear();
|
|
||||||
const m = now.getMonth() + 1;
|
|
||||||
const tracker = await (await request.get(`/api/tracker?year=${y}&month=${m}`)).json();
|
|
||||||
const analytics = await (await request.get(`/api/analytics/summary?year=${y}&month=${m}&months=12`)).json();
|
|
||||||
|
|
||||||
const key = `${y}-${String(m).padStart(2, '0')}`;
|
|
||||||
const row = (analytics?.expected_vs_actual ?? []).find((r) => r.month === key);
|
|
||||||
const trackerExpected = Math.round((tracker?.summary?.total_expected ?? 0) * 100) / 100;
|
|
||||||
const analyticsExpected = Math.round((row?.expected ?? 0) * 100) / 100;
|
|
||||||
console.log(`[recon] analytics.expected[${key}]=$${analyticsExpected} tracker.total_expected=$${trackerExpected}`);
|
|
||||||
expect.soft(analyticsExpected, 'Analytics expected must gate by occurrence (QA-B5-01 family)').toBe(trackerExpected);
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
// 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 });
|
|
||||||
});
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
// B-UI: functional behavior of shared primitives that axe/a11y can't assert —
|
|
||||||
// dialogs cancel without side effects, Selects open by mouse and keyboard,
|
|
||||||
// disabled controls stay inert. All checks are READ-ONLY (Esc/Cancel/reopen), so
|
|
||||||
// they're safe alongside the other UI specs in the parallel suite.
|
|
||||||
const { test, expect } = require('@playwright/test');
|
|
||||||
const { STORAGE_STATE } = require('./constants');
|
|
||||||
|
|
||||||
test.use({ storageState: STORAGE_STATE });
|
|
||||||
|
|
||||||
test('dialog: Add Bill opens, Esc closes it, and creates nothing (Cancel = no side effect)', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
const badges = page.locator('button[title="Click to mark paid"]:visible, button[title="Click to mark unpaid"]:visible');
|
|
||||||
await expect(badges.first()).toBeVisible(); // wait for bills to load before counting
|
|
||||||
const before = await badges.count();
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add Bill' }).click();
|
|
||||||
const dialog = page.getByRole('dialog');
|
|
||||||
await expect(dialog).toBeVisible();
|
|
||||||
// Focus moved into the dialog (focus trap).
|
|
||||||
await expect(dialog.locator(':focus')).toHaveCount(1);
|
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expect(dialog).toBeHidden();
|
|
||||||
// No bill was created and the page is still functional.
|
|
||||||
await expect(page.getByRole('button', { name: 'Add Bill' })).toBeVisible();
|
|
||||||
await expect.poll(() => badges.count()).toBe(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('select: category filter opens by mouse and by keyboard and lists options', async ({ page, isMobile }) => {
|
|
||||||
test.skip(isMobile, 'filter panel layout differs on mobile');
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
// Radix Select trigger exposes role="combobox".
|
|
||||||
const trigger = page.getByRole('combobox', { name: 'Filter by category' });
|
|
||||||
await expect(trigger).toBeVisible();
|
|
||||||
|
|
||||||
// Mouse: opens a listbox with the "All categories" option.
|
|
||||||
await trigger.click();
|
|
||||||
const listbox = page.getByRole('listbox');
|
|
||||||
await expect(listbox).toBeVisible();
|
|
||||||
await expect(page.getByRole('option', { name: 'All categories' })).toBeVisible();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expect(listbox).toBeHidden();
|
|
||||||
|
|
||||||
// Keyboard: focus the trigger and open with Enter.
|
|
||||||
await trigger.focus();
|
|
||||||
await page.keyboard.press('Enter');
|
|
||||||
await expect(page.getByRole('listbox')).toBeVisible();
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('disabled control: sort-direction button is inert in Custom order', async ({ page, isMobile }) => {
|
|
||||||
test.skip(isMobile, 'filter panel layout differs on mobile');
|
|
||||||
await page.goto('/');
|
|
||||||
// Default sort is "Custom order", for which the asc/desc toggle is disabled.
|
|
||||||
const dir = page.getByRole('button', { name: 'Asc' });
|
|
||||||
await expect(dir).toBeVisible();
|
|
||||||
await expect(dir).toBeDisabled();
|
|
||||||
// Clicking a disabled control must be a no-op (force past the actionability guard).
|
|
||||||
await dir.click({ force: true });
|
|
||||||
await expect(dir).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
// Regression for the QA-B14-02 a11y refactor: category rows are now plain
|
|
||||||
// containers with a dedicated chevron toggle button (instead of role=button rows
|
|
||||||
// that nested action buttons). Verify expand/collapse still works both ways.
|
|
||||||
const { test, expect } = require('@playwright/test');
|
|
||||||
const { STORAGE_STATE } = require('./constants');
|
|
||||||
|
|
||||||
test.use({ storageState: STORAGE_STATE });
|
|
||||||
|
|
||||||
test('category expands via the chevron toggle and via row click (QA-B14-02)', async ({ page }) => {
|
|
||||||
await page.goto('/categories');
|
|
||||||
|
|
||||||
// Dedicated toggle button, collapsed initially.
|
|
||||||
const expandBtn = page.getByRole('button', { name: /^Expand / }).first();
|
|
||||||
await expect(expandBtn).toBeVisible();
|
|
||||||
await expect(expandBtn).toHaveAttribute('aria-expanded', 'false');
|
|
||||||
|
|
||||||
// Clicking it expands the row (button flips to "Collapse …").
|
|
||||||
await expandBtn.click();
|
|
||||||
await expect(page.getByRole('button', { name: /^Collapse / })).not.toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
// 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 };
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
// 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$/);
|
|
||||||
});
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
#!/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'}`);
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
// 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.
|
Before Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB |
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
|
@ -13,11 +13,6 @@
|
||||||
"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",
|
|
||||||
"smoke:prod": "bash scripts/prod-smoke.sh",
|
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -56,15 +51,16 @@
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
// 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),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
@ -172,11 +172,9 @@ router.get('/user-excel', (req, res) => {
|
||||||
res.send(buffer);
|
res.send(buffer);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build the SQLite user-DB export to a temp file and return its path. Extracted
|
router.get('/user-db', (req, res) => {
|
||||||
// so the export→import round-trip can be regression-tested (exportImportRoundTrip).
|
const data = getUserExportData(req.user.id);
|
||||||
function buildUserDbExportFile(userId) {
|
const file = path.join(os.tmpdir(), `bill-tracker-user-${req.user.id}-${Date.now()}.sqlite`);
|
||||||
const data = getUserExportData(userId);
|
|
||||||
const file = path.join(os.tmpdir(), `bill-tracker-user-${userId}-${Date.now()}.sqlite`);
|
|
||||||
const out = new Database(file);
|
const out = new Database(file);
|
||||||
try {
|
try {
|
||||||
out.exec(`
|
out.exec(`
|
||||||
|
|
@ -216,15 +214,9 @@ function buildUserDbExportFile(userId) {
|
||||||
} finally {
|
} finally {
|
||||||
out.close();
|
out.close();
|
||||||
}
|
}
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/user-db', (req, res) => {
|
|
||||||
const file = buildUserDbExportFile(req.user.id);
|
|
||||||
res.download(file, 'bill-tracker-user-export.sqlite', () => {
|
res.download(file, 'bill-tracker-user-export.sqlite', () => {
|
||||||
try { fs.unlinkSync(file); } catch {}
|
try { fs.unlinkSync(file); } catch {}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.buildUserDbExportFile = buildUserDbExportFile;
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { getCycleRange, resolveDueDate } = require('../services/statusService');
|
const { getCycleRange } = require('../services/statusService');
|
||||||
const { getUserSettings } = require('../services/userSettings');
|
const { getUserSettings } = require('../services/userSettings');
|
||||||
const { accountingActiveSql } = require('../services/paymentAccountingService');
|
const { accountingActiveSql } = require('../services/paymentAccountingService');
|
||||||
const { toCents, fromCents } = require('../utils/money');
|
const { toCents, fromCents } = require('../utils/money');
|
||||||
|
|
@ -48,16 +48,15 @@ function buildBankTrackingSummary(db, userId, year, month) {
|
||||||
`).get(userId, effectivePendingDays)
|
`).get(userId, effectivePendingDays)
|
||||||
: { pending_total: 0 };
|
: { pending_total: 0 };
|
||||||
|
|
||||||
// Unpaid bills remaining this month (not skipped, not yet paid), occurrence-gated
|
// Unpaid bills remaining this month (not skipped, not yet paid)
|
||||||
// in JS so annual / off-month quarterly bills don't inflate the total (QA-B5-02,
|
|
||||||
// same root as QA-B5-01 — SQL can't call resolveDueDate).
|
|
||||||
const { start, end } = getCycleRange(year, month);
|
const { start, end } = getCycleRange(year, month);
|
||||||
const unpaidCandidates = db.prepare(`
|
const unpaidRow = db.prepare(`
|
||||||
SELECT b.due_day, b.billing_cycle, b.cycle_type, b.cycle_day,
|
SELECT COALESCE(SUM(
|
||||||
CASE
|
CASE
|
||||||
WHEN m.actual_amount IS NOT NULL THEN m.actual_amount
|
WHEN m.actual_amount IS NOT NULL THEN m.actual_amount
|
||||||
ELSE COALESCE(b.expected_amount, 0)
|
ELSE COALESCE(b.expected_amount, 0)
|
||||||
END AS amount_cents
|
END
|
||||||
|
), 0) AS unpaid_total
|
||||||
FROM bills b
|
FROM bills b
|
||||||
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
|
LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
|
|
@ -73,15 +72,12 @@ function buildBankTrackingSummary(db, userId, year, month) {
|
||||||
AND b.deleted_at IS NULL
|
AND b.deleted_at IS NULL
|
||||||
AND COALESCE(m.is_skipped, 0) = 0
|
AND COALESCE(m.is_skipped, 0) = 0
|
||||||
AND COALESCE(pay.paid_sum, 0) = 0
|
AND COALESCE(pay.paid_sum, 0) = 0
|
||||||
`).all(year, month, start, end, userId);
|
`).get(year, month, start, end, userId);
|
||||||
const unpaidTotalCents = unpaidCandidates
|
|
||||||
.filter(row => resolveDueDate(row, year, month))
|
|
||||||
.reduce((sum, row) => sum + (row.amount_cents || 0), 0);
|
|
||||||
|
|
||||||
const balanceDollars = money(account.balance / 100);
|
const balanceDollars = money(account.balance / 100);
|
||||||
const pendingDollars = fromCents(pendingRow.pending_total);
|
const pendingDollars = fromCents(pendingRow.pending_total);
|
||||||
const effectiveDollars = money(balanceDollars - pendingDollars);
|
const effectiveDollars = money(balanceDollars - pendingDollars);
|
||||||
const unpaidDollars = fromCents(unpaidTotalCents);
|
const unpaidDollars = fromCents(unpaidRow.unpaid_total);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -248,9 +244,6 @@ function buildSummary(db, userId, year, month) {
|
||||||
b.name,
|
b.name,
|
||||||
b.expected_amount,
|
b.expected_amount,
|
||||||
b.due_day,
|
b.due_day,
|
||||||
b.billing_cycle,
|
|
||||||
b.cycle_type,
|
|
||||||
b.cycle_day,
|
|
||||||
c.name AS category_name,
|
c.name AS category_name,
|
||||||
m.actual_amount,
|
m.actual_amount,
|
||||||
m.is_skipped,
|
m.is_skipped,
|
||||||
|
|
@ -269,11 +262,7 @@ function buildSummary(db, userId, year, month) {
|
||||||
b.sort_order ASC,
|
b.sort_order ASC,
|
||||||
b.due_day ASC,
|
b.due_day ASC,
|
||||||
b.name ASC
|
b.name ASC
|
||||||
`).all(year, month, userId)
|
`).all(year, month, userId);
|
||||||
// QA-B5-01: only bills that actually occur in this month (matches the Tracker's
|
|
||||||
// resolveDueDate gating) — otherwise annual / off-month quarterly bills inflate
|
|
||||||
// the monthly expense list and total, disagreeing with the Tracker.
|
|
||||||
.filter(row => resolveDueDate(row, year, month));
|
|
||||||
|
|
||||||
const billIds = billRows.map(row => row.bill_id);
|
const billIds = billRows.map(row => row.bill_id);
|
||||||
const paymentMap = new Map();
|
const paymentMap = new Map();
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Production-build smoke (QA_PLAN B15). Drives the REAL built artifact — `node
|
|
||||||
* server.js` serving dist/ — not the Vite dev server, so it validates that the
|
|
||||||
* split vendor chunks (QA-B0-01) actually load and the app boots in production.
|
|
||||||
*
|
|
||||||
* Usage: node scripts/prod-smoke.js (expects `npm run build` already run and a
|
|
||||||
* server already listening on PROD_SMOKE_URL — the shell wrapper handles both).
|
|
||||||
*/
|
|
||||||
const { chromium } = require('@playwright/test');
|
|
||||||
|
|
||||||
const URL = process.env.PROD_SMOKE_URL || 'http://localhost:3098';
|
|
||||||
const USER = process.env.E2E_USER || 'e2e_user';
|
|
||||||
const PASS = process.env.E2E_PASS || 'e2e_pass_1234';
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const browser = await chromium.launch();
|
|
||||||
const page = await browser.newPage();
|
|
||||||
const errors = [];
|
|
||||||
const failed = [];
|
|
||||||
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
|
|
||||||
page.on('pageerror', (e) => errors.push(String(e)));
|
|
||||||
page.on('requestfailed', (r) => failed.push(`${r.url()} ${r.failure()?.errorText || ''}`));
|
|
||||||
|
|
||||||
let ok = true;
|
|
||||||
const fail = (msg) => { ok = false; console.error(' ✖', msg); };
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. App shell + split chunks load; login page renders.
|
|
||||||
await page.goto(URL + '/login', { waitUntil: 'networkidle' });
|
|
||||||
const heading = page.getByRole('heading', { name: /sign in/i });
|
|
||||||
if (await heading.isVisible().catch(() => false)) console.log(' ✔ login page renders (vendor chunks loaded)');
|
|
||||||
else fail('login page did not render — a chunk may have failed to load');
|
|
||||||
|
|
||||||
// 2. Log in and reach the authenticated app (lazy route chunks load).
|
|
||||||
await page.locator('#username').fill(USER);
|
|
||||||
await page.locator('#password').fill(PASS);
|
|
||||||
await page.getByRole('button', { name: /sign in/i }).click();
|
|
||||||
await page.waitForURL((u) => !u.pathname.startsWith('/login'), { timeout: 15000 });
|
|
||||||
const gotIt = page.getByRole('button', { name: 'Got it' });
|
|
||||||
await gotIt.click({ timeout: 8000 }).catch(() => {});
|
|
||||||
if (await page.getByRole('button', { name: 'Add Bill' }).isVisible().catch(() => false)) {
|
|
||||||
console.log(' ✔ authenticated Tracker renders on the production build');
|
|
||||||
} else fail('authenticated Tracker did not render');
|
|
||||||
|
|
||||||
// 3. A couple of lazy-loaded routes render (their chunks resolve).
|
|
||||||
for (const path of ['/bills', '/analytics', '/spending']) {
|
|
||||||
await page.goto(URL + path, { waitUntil: 'networkidle' });
|
|
||||||
const empty = await page.locator('body').innerText().catch(() => '');
|
|
||||||
if (empty && empty.trim().length > 0) console.log(` ✔ ${path} rendered`);
|
|
||||||
else fail(`${path} rendered blank (lazy chunk failure?)`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
fail('exception: ' + e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunkErrors = failed.filter((f) => /\.js|\.css|assets/.test(f));
|
|
||||||
if (chunkErrors.length) fail('failed asset requests:\n ' + chunkErrors.join('\n '));
|
|
||||||
// The pre-login page probes /api/auth/session and gets 401 by design (then shows
|
|
||||||
// login) — that's expected, not a defect. Everything else is a real error.
|
|
||||||
const benign = /session check failed|Not authenticated|auth\/session|status of 401/i;
|
|
||||||
const realErrors = errors.filter((e) => !benign.test(e));
|
|
||||||
if (realErrors.length) fail('console/page errors:\n ' + realErrors.slice(0, 5).join('\n '));
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
console.log(ok ? '\nPROD SMOKE: PASS' : '\nPROD SMOKE: FAIL');
|
|
||||||
process.exit(ok ? 0 : 1);
|
|
||||||
})();
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Production-build smoke (QA_PLAN B15): builds the app, boots `node server.js`
|
|
||||||
# serving dist/ against a scratch DB, and drives the real artifact with Playwright
|
|
||||||
# to confirm the split vendor chunks load and the app works in production.
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
PORT="${PROD_SMOKE_PORT:-3098}"
|
|
||||||
export PROD_SMOKE_URL="http://localhost:${PORT}"
|
|
||||||
|
|
||||||
echo "[prod-smoke] building…"
|
|
||||||
npm run build >/dev/null
|
|
||||||
|
|
||||||
echo "[prod-smoke] preparing scratch DB…"
|
|
||||||
node e2e/setup/prepare-db.js >/dev/null 2>&1
|
|
||||||
|
|
||||||
echo "[prod-smoke] starting production server on :${PORT}…"
|
|
||||||
DB_PATH="db/e2e.db" PORT="${PORT}" BIND_HOST=127.0.0.1 node server.js >/tmp/prod-smoke-server.log 2>&1 &
|
|
||||||
SRV=$!
|
|
||||||
trap 'kill "${SRV}" 2>/dev/null || true' EXIT
|
|
||||||
|
|
||||||
for _ in $(seq 1 40); do
|
|
||||||
curl -sf "http://localhost:${PORT}/api/version" >/dev/null 2>&1 && break
|
|
||||||
sleep 0.5
|
|
||||||
done
|
|
||||||
|
|
||||||
node scripts/prod-smoke.js
|
|
||||||
|
|
@ -12,9 +12,6 @@ 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',
|
||||||
|
|
@ -419,11 +416,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',
|
||||||
toCents(billData.amount),
|
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 ? toCents(billData.currentBalance) : null,
|
billData.currentBalance ?? null,
|
||||||
billData.minPayment != null ? toCents(billData.minPayment) : null,
|
billData.minPayment ?? null,
|
||||||
billData.snowballOrder ?? null,
|
billData.snowballOrder ?? null,
|
||||||
billData.snowballInclude ?? 0,
|
billData.snowballInclude ?? 0,
|
||||||
billData.snowballExempt ?? 0,
|
billData.snowballExempt ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
const { accountingActiveSql } = require('./paymentAccountingService');
|
const { accountingActiveSql } = require('./paymentAccountingService');
|
||||||
const { sumMoney, fromCents } = require('../utils/money');
|
const { sumMoney, fromCents } = require('../utils/money');
|
||||||
const { resolveDueDate } = require('./statusService');
|
|
||||||
|
|
||||||
function parseInteger(value, fallback) {
|
function parseInteger(value, fallback) {
|
||||||
if (value === undefined || value === null || value === '') return fallback;
|
if (value === undefined || value === null || value === '') return fallback;
|
||||||
|
|
@ -135,7 +134,6 @@ function getAnalyticsSummary(userId, query = {}) {
|
||||||
|
|
||||||
const bills = db.prepare(`
|
const bills = db.prepare(`
|
||||||
SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at,
|
SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at,
|
||||||
b.due_day, b.billing_cycle, b.cycle_type, b.cycle_day,
|
|
||||||
c.name AS category_name
|
c.name AS category_name
|
||||||
FROM bills b
|
FROM bills b
|
||||||
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
|
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
|
||||||
|
|
@ -189,7 +187,6 @@ function getAnalyticsSummary(userId, query = {}) {
|
||||||
}).filter(row => row.total > 0);
|
}).filter(row => row.total > 0);
|
||||||
|
|
||||||
const expected_vs_actual = rangeMonths.map(m => {
|
const expected_vs_actual = rangeMonths.map(m => {
|
||||||
const [mYear, mMonth] = m.key.split('-').map(Number);
|
|
||||||
let expected = 0;
|
let expected = 0;
|
||||||
let actual = 0;
|
let actual = 0;
|
||||||
let skipped_count = 0;
|
let skipped_count = 0;
|
||||||
|
|
@ -200,10 +197,7 @@ function getAnalyticsSummary(userId, query = {}) {
|
||||||
if (!skipped || parsed.includeSkipped) {
|
if (!skipped || parsed.includeSkipped) {
|
||||||
actual += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
|
actual += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
|
||||||
}
|
}
|
||||||
// QA-B5-01 family: only add "expected" in months the bill actually occurs,
|
if (!skipped) {
|
||||||
// so annual / off-month quarterly bills don't inflate the expected line
|
|
||||||
// every month (matches the Tracker / Summary occurrence gate).
|
|
||||||
if (!skipped && resolveDueDate(bill, mYear, mMonth)) {
|
|
||||||
expected += state?.actual_amount ?? bill.expected_amount ?? 0;
|
expected += state?.actual_amount ?? bill.expected_amount ?? 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,30 @@ function monthsToPayoff(balance, annualRatePct, monthlyPayment) {
|
||||||
return months >= MAX_MONTHS ? null : months;
|
return months >= MAX_MONTHS ? null : months;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total interest paid over the life of a single debt at a fixed monthly payment.
|
||||||
|
* Returns null if the payment never overcomes interest.
|
||||||
|
*/
|
||||||
|
function totalInterestPaid(balance, annualRatePct, monthlyPayment) {
|
||||||
|
const rate = Math.max(0, Number(annualRatePct) || 0) / 100 / 12;
|
||||||
|
let bal = Number(balance);
|
||||||
|
const pmt = Number(monthlyPayment);
|
||||||
|
|
||||||
|
if (!Number.isFinite(bal) || bal <= 0) return 0;
|
||||||
|
if (!Number.isFinite(pmt) || pmt <= 0) return null;
|
||||||
|
if (rate > 0 && pmt <= bal * rate) return null;
|
||||||
|
|
||||||
|
let totalInterest = 0;
|
||||||
|
let months = 0;
|
||||||
|
while (bal > 0.005 && months < MAX_MONTHS) {
|
||||||
|
months++;
|
||||||
|
const interest = bal * rate;
|
||||||
|
totalInterest += interest;
|
||||||
|
bal = Math.max(0, bal + interest - pmt);
|
||||||
|
}
|
||||||
|
return months >= MAX_MONTHS ? null : round2(totalInterest);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full month-by-month amortization schedule for a single debt.
|
* Full month-by-month amortization schedule for a single debt.
|
||||||
* Each row: { month, payment, principal, interest, balance }
|
* Each row: { month, payment, principal, interest, balance }
|
||||||
|
|
@ -221,6 +245,7 @@ function round2(n) {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
monthlyInterest,
|
monthlyInterest,
|
||||||
monthsToPayoff,
|
monthsToPayoff,
|
||||||
|
totalInterestPaid,
|
||||||
amortizationSchedule,
|
amortizationSchedule,
|
||||||
calculateMinimumOnly,
|
calculateMinimumOnly,
|
||||||
debtAprSnapshot,
|
debtAprSnapshot,
|
||||||
|
|
|
||||||
|
|
@ -291,22 +291,8 @@ 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). Validate: reject non-numeric,
|
// expected_amount (stored as integer cents)
|
||||||
// negative, or absurd values (kept well under Number.MAX_SAFE_INTEGER cents).
|
normalized.expected_amount = data.expected_amount !== undefined ? (toCents(data.expected_amount) || 0) : (existingBill?.expected_amount || 0);
|
||||||
// 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) {
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,7 @@ async function sendTestPush(user) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: the `_push` export is attached AFTER the final `module.exports = {…}`
|
module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret };
|
||||||
// below — assigning it here would be clobbered by that reassignment (QA-B10-01).
|
|
||||||
|
|
||||||
// ── SMTP transport ────────────────────────────────────────────────────────────
|
// ── SMTP transport ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -165,14 +164,11 @@ function buildEmailHtml(bill, type, dueDate) {
|
||||||
return `${parseInt(m)}/${parseInt(day)}/${y}`;
|
return `${parseInt(m)}/${parseInt(day)}/${y}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// QA-B14-04: escape the bill name everywhere it lands in the HTML, including
|
|
||||||
// these message strings (previously raw — an XSS vector via the bill name).
|
|
||||||
const name = esc(bill.name);
|
|
||||||
const messages = {
|
const messages = {
|
||||||
due_3d: `<strong>${name}</strong> is due in 3 days.`,
|
due_3d: `<strong>${bill.name}</strong> is due in 3 days.`,
|
||||||
due_1d: `<strong>${name}</strong> is due <strong>tomorrow</strong>.`,
|
due_1d: `<strong>${bill.name}</strong> is due <strong>tomorrow</strong>.`,
|
||||||
due_today: `<strong>${name}</strong> is due <strong>today</strong>.`,
|
due_today: `<strong>${bill.name}</strong> is due <strong>today</strong>.`,
|
||||||
overdue: `<strong>${name}</strong> was due on ${fmt(dueDate)} and has not been marked as paid.`,
|
overdue: `<strong>${bill.name}</strong> was due on ${fmt(dueDate)} and has not been marked as paid.`,
|
||||||
};
|
};
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
|
|
@ -550,8 +546,3 @@ async function runDriftNotifications() {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { runNotifications, runDriftNotifications, sendTestEmail, createTransport };
|
module.exports = { runNotifications, runDriftNotifications, sendTestEmail, createTransport };
|
||||||
// Push helpers, exposed for the test-push route + tests. Assigned AFTER the line
|
|
||||||
// above so it isn't clobbered by the reassignment (QA-B10-01: previously set
|
|
||||||
// before it, leaving `_push` undefined → "Send test push" always 500'd).
|
|
||||||
module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret };
|
|
||||||
module.exports._email = { buildEmailHtml, esc };
|
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// B9: export → import round-trip must preserve money exactly. The export writes
|
|
||||||
// dollars (fromCents) into REAL columns and has no schema_migrations table, so the
|
|
||||||
// import treats it as dollars and applies toCents — a symmetric conversion. This
|
|
||||||
// guards against that symmetry breaking (which would 100x amounts on import).
|
|
||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const os = require('node:os');
|
|
||||||
const path = require('node:path');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
|
|
||||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-roundtrip-test-${process.pid}.sqlite`);
|
|
||||||
process.env.DB_PATH = dbPath;
|
|
||||||
|
|
||||||
const { getDb, closeDb } = require('../db/database');
|
|
||||||
const { buildUserDbExportFile } = require('../routes/export');
|
|
||||||
const { previewUserDbImport, applyUserDbImport } = require('../services/userDbImportService');
|
|
||||||
|
|
||||||
const mkUser = (db, name) =>
|
|
||||||
db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES (?, 'x', 'user', 1)").run(name).lastInsertRowid;
|
|
||||||
|
|
||||||
test('export → import round-trip preserves money in cents (no 100x drift)', async () => {
|
|
||||||
const db = getDb();
|
|
||||||
const userA = mkUser(db, 'rt-a');
|
|
||||||
const userB = mkUser(db, 'rt-b');
|
|
||||||
|
|
||||||
const catA = db.prepare("INSERT INTO categories (user_id, name) VALUES (?, 'Utilities')").run(userA).lastInsertRowid;
|
|
||||||
const billA = db.prepare(`
|
|
||||||
INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle, cycle_type, expected_amount, active)
|
|
||||||
VALUES (?, 'Electric Company', ?, 15, 'monthly', 'monthly', 8500, 1)
|
|
||||||
`).run(userA, catA).lastInsertRowid; // $85.00
|
|
||||||
db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, 8500, '2026-06-15', 'manual')").run(billA);
|
|
||||||
db.prepare('INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount) VALUES (?, 2026, 7, 5000)').run(billA); // $50 override
|
|
||||||
|
|
||||||
// Export user A, import into fresh user B.
|
|
||||||
const file = buildUserDbExportFile(userA);
|
|
||||||
try {
|
|
||||||
const buffer = fs.readFileSync(file);
|
|
||||||
const preview = await previewUserDbImport(userB, buffer, {});
|
|
||||||
assert.ok(preview.import_session_id, 'preview returns an import session id');
|
|
||||||
await applyUserDbImport(userB, preview.import_session_id, {});
|
|
||||||
} finally {
|
|
||||||
try { fs.unlinkSync(file); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const billB = db.prepare("SELECT expected_amount FROM bills WHERE user_id = ? AND name = 'Electric Company'").get(userB);
|
|
||||||
assert.ok(billB, 'bill imported for user B');
|
|
||||||
assert.equal(billB.expected_amount, 8500, 'bill expected_amount preserved as cents');
|
|
||||||
|
|
||||||
const payB = db.prepare('SELECT p.amount FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ?').get(userB);
|
|
||||||
assert.equal(payB.amount, 8500, 'payment amount preserved as cents');
|
|
||||||
|
|
||||||
const stateB = db.prepare('SELECT m.actual_amount FROM monthly_bill_state m JOIN bills b ON b.id = m.bill_id WHERE b.user_id = ?').get(userB);
|
|
||||||
assert.equal(stateB.actual_amount, 5000, 'per-month override preserved as cents');
|
|
||||||
});
|
|
||||||
|
|
||||||
test.after(() => {
|
|
||||||
closeDb();
|
|
||||||
for (const suffix of ['', '-wal', '-shm']) {
|
|
||||||
try { fs.rmSync(dbPath + suffix); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { test } = require('node:test');
|
|
||||||
const assert = require('node:assert');
|
|
||||||
const { toCents, fromCents, roundMoney, sumMoney, mulMoney, formatUSD, formatCentsUSD } = require('../utils/money');
|
|
||||||
|
|
||||||
test('toCents — unchanged for integer / ≤2-decimal / formatted inputs', () => {
|
|
||||||
assert.strictEqual(toCents(0), 0);
|
|
||||||
assert.strictEqual(toCents(85), 8500);
|
|
||||||
assert.strictEqual(toCents(19.99), 1999);
|
|
||||||
assert.strictEqual(toCents(100.001), 10000); // 3rd decimal <5 rounds down (10000.1)
|
|
||||||
assert.strictEqual(toCents('$1,234.56'), 123456);
|
|
||||||
assert.strictEqual(toCents('9,999,999.99'), 999999999);
|
|
||||||
assert.strictEqual(toCents(0.1), 10);
|
|
||||||
assert.strictEqual(toCents(0.1 + 0.2), 30); // 0.30000000000000004 → 30, not 30.000…
|
|
||||||
});
|
|
||||||
|
|
||||||
test('toCents — QA-B7-01: fractional half-cents round half away from zero (was buggy)', () => {
|
|
||||||
assert.strictEqual(toCents(1.005), 101); // was 100 with Math.round(n*100)
|
|
||||||
assert.strictEqual(toCents(2.675), 268); // was 267
|
|
||||||
assert.strictEqual(toCents(0.005), 1);
|
|
||||||
assert.strictEqual(toCents('1.005'), 101);
|
|
||||||
assert.strictEqual(toCents(1.004), 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('toCents — negatives and nullish/invalid', () => {
|
|
||||||
assert.strictEqual(toCents(-50), -5000);
|
|
||||||
assert.strictEqual(toCents('-12.34'), -1234);
|
|
||||||
assert.strictEqual(toCents(null), null);
|
|
||||||
assert.strictEqual(toCents(undefined), null);
|
|
||||||
assert.strictEqual(toCents(''), null);
|
|
||||||
assert.ok(Number.isNaN(toCents('abc')));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('toCents — round-trips through fromCents for money values', () => {
|
|
||||||
for (const v of [0, 85, 19.99, 1234.56, 0.01]) {
|
|
||||||
assert.strictEqual(fromCents(toCents(v)), v);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('fromCents', () => {
|
|
||||||
assert.strictEqual(fromCents(8500), 85);
|
|
||||||
assert.strictEqual(fromCents(null), null);
|
|
||||||
assert.strictEqual(fromCents(undefined), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sumMoney — cent-exact, no float drift', () => {
|
|
||||||
assert.strictEqual(sumMoney([0.1, 0.2]), 0.3);
|
|
||||||
assert.strictEqual(sumMoney([{ a: 1.11 }, { a: 2.22 }], (r) => r.a), 3.33);
|
|
||||||
assert.strictEqual(sumMoney([]), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mulMoney — rounds to the cent', () => {
|
|
||||||
assert.strictEqual(mulMoney(100, 0.1), 10);
|
|
||||||
assert.strictEqual(mulMoney(19.99, 2), 39.98);
|
|
||||||
assert.strictEqual(mulMoney('bad', 2), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('roundMoney', () => {
|
|
||||||
assert.strictEqual(roundMoney(1.005), 1.01); // benefits from the toCents fix
|
|
||||||
assert.strictEqual(roundMoney(19.994), 19.99);
|
|
||||||
assert.strictEqual(roundMoney('abc'), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatUSD / formatCentsUSD — negative sign before the symbol (QA-B6-01)', () => {
|
|
||||||
assert.strictEqual(formatUSD(50), '$50.00');
|
|
||||||
assert.strictEqual(formatUSD(-50), '-$50.00');
|
|
||||||
assert.strictEqual(formatUSD(-1234.5), '-$1,234.50');
|
|
||||||
assert.strictEqual(formatUSD(0), '$0.00');
|
|
||||||
assert.strictEqual(formatUSD(null), '$0.00');
|
|
||||||
assert.strictEqual(formatCentsUSD(123456), '$1,234.56');
|
|
||||||
assert.strictEqual(formatCentsUSD(-5000), '-$50.00');
|
|
||||||
});
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// B10: push-notification DELIVERY — verifies the message-building + HTTP send path
|
|
||||||
// for each channel against a local sink (no external network), plus error handling,
|
|
||||||
// dispatch, and that the auth token never leaks into the message body.
|
|
||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const http = require('node:http');
|
|
||||||
|
|
||||||
const { _push } = require('../services/notificationService');
|
|
||||||
const { sendNtfy, sendGotify, sendDiscord, sendPushToUser } = _push;
|
|
||||||
|
|
||||||
// Local HTTP sink: records requests, status configurable.
|
|
||||||
function startSink() {
|
|
||||||
const requests = [];
|
|
||||||
let status = 200;
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
let body = '';
|
|
||||||
req.on('data', (c) => (body += c));
|
|
||||||
req.on('end', () => {
|
|
||||||
requests.push({ method: req.method, url: req.url, headers: req.headers, body });
|
|
||||||
res.statusCode = status;
|
|
||||||
res.end(status === 200 ? 'ok' : 'err');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
server.listen(0, '127.0.0.1', () => {
|
|
||||||
resolve({
|
|
||||||
url: `http://127.0.0.1:${server.address().port}`,
|
|
||||||
requests,
|
|
||||||
setStatus: (c) => { status = c; },
|
|
||||||
close: () => new Promise((r) => server.close(r)),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('ntfy delivery: posts body + Title header; token stays in Authorization, not the body', async () => {
|
|
||||||
const sink = await startSink();
|
|
||||||
try {
|
|
||||||
await sendNtfy(sink.url, 'secret-token-xyz', 'Bill due', 'Electric $85 due tomorrow', 'overdue');
|
|
||||||
assert.equal(sink.requests.length, 1);
|
|
||||||
const r = sink.requests[0];
|
|
||||||
assert.equal(r.method, 'POST');
|
|
||||||
assert.equal(r.body, 'Electric $85 due tomorrow');
|
|
||||||
assert.equal(r.headers['title'], 'Bill due');
|
|
||||||
assert.ok(r.headers['priority'], 'priority header present');
|
|
||||||
assert.equal(r.headers['authorization'], 'Bearer secret-token-xyz');
|
|
||||||
assert.ok(!r.body.includes('secret-token-xyz'), 'token must never appear in the message body');
|
|
||||||
} finally { await sink.close(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gotify delivery: JSON {title, message, priority} to /message?token=', async () => {
|
|
||||||
const sink = await startSink();
|
|
||||||
try {
|
|
||||||
await sendGotify(sink.url, 'gtoken', 'Reminder', 'Rent due in 3 days', 'today');
|
|
||||||
const r = sink.requests[0];
|
|
||||||
assert.match(r.url, /\/message\?token=gtoken/);
|
|
||||||
const json = JSON.parse(r.body);
|
|
||||||
assert.equal(json.title, 'Reminder');
|
|
||||||
assert.equal(json.message, 'Rent due in 3 days');
|
|
||||||
assert.equal(json.priority, 7); // 'today'
|
|
||||||
} finally { await sink.close(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('discord delivery: webhook embed with title/description', async () => {
|
|
||||||
const sink = await startSink();
|
|
||||||
try {
|
|
||||||
await sendDiscord(sink.url, 'Overdue bill', 'Water bill is 2 days overdue', 'overdue');
|
|
||||||
const json = JSON.parse(sink.requests[0].body);
|
|
||||||
assert.equal(json.embeds[0].title, 'Overdue bill');
|
|
||||||
assert.equal(json.embeds[0].description, 'Water bill is 2 days overdue');
|
|
||||||
assert.ok(typeof json.embeds[0].color === 'number');
|
|
||||||
} finally { await sink.close(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dispatch: sendPushToUser routes to the configured channel', async () => {
|
|
||||||
const sink = await startSink();
|
|
||||||
try {
|
|
||||||
const user = { notify_push_enabled: 1, push_channel: 'ntfy', push_url: sink.url, push_token: '' };
|
|
||||||
await sendPushToUser(user, 'Title', 'Body', 'soon');
|
|
||||||
assert.equal(sink.requests.length, 1);
|
|
||||||
assert.equal(sink.requests[0].body, 'Body');
|
|
||||||
} finally { await sink.close(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dispatch: disabled/unconfigured user sends nothing (no throw)', async () => {
|
|
||||||
await assert.doesNotReject(sendPushToUser({ notify_push_enabled: 0 }, 'T', 'B', 'soon'));
|
|
||||||
await assert.doesNotReject(sendPushToUser({ notify_push_enabled: 1, push_channel: 'ntfy' }, 'T', 'B', 'soon'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('error handling: an unreachable/erroring channel throws a clear, channel-named error', async () => {
|
|
||||||
const sink = await startSink();
|
|
||||||
sink.setStatus(500);
|
|
||||||
try {
|
|
||||||
await assert.rejects(
|
|
||||||
sendNtfy(sink.url, '', 'T', 'B', 'soon'),
|
|
||||||
/ntfy returned 500/,
|
|
||||||
);
|
|
||||||
} finally { await sink.close(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dispatch: an unknown channel throws instead of silently doing nothing', async () => {
|
|
||||||
await assert.rejects(
|
|
||||||
sendPushToUser({ notify_push_enabled: 1, push_channel: 'carrier-pigeon', push_url: 'http://x' }, 'T', 'B', 'soon'),
|
|
||||||
/Unknown push channel: carrier-pigeon/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('email HTML escapes the bill name everywhere — no XSS via the name (QA-B14-04)', () => {
|
|
||||||
const { buildEmailHtml } = require('../services/notificationService')._email;
|
|
||||||
const evil = '<img src=x onerror=alert(1)>';
|
|
||||||
for (const type of ['due_3d', 'due_1d', 'due_today', 'overdue']) {
|
|
||||||
const html = buildEmailHtml({ name: evil, expected_amount: 8500 }, type, '2026-07-15');
|
|
||||||
assert.ok(!html.includes(evil), `raw payload must not appear in the ${type} email HTML`);
|
|
||||||
assert.ok(html.includes('<img src=x onerror='), `bill name must be HTML-escaped in the ${type} email (message + detail row)`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// QA-B5-02 regression: the SimpleFIN bank-tracking `unpaid_this_month` metric must
|
|
||||||
// only count bills that actually occur in the requested month — annual / off-month
|
|
||||||
// quarterly bills should not inflate it (same occurrence gate as Tracker/Summary).
|
|
||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const os = require('node:os');
|
|
||||||
const path = require('node:path');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
|
|
||||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-summary-bt-test-${process.pid}.sqlite`);
|
|
||||||
process.env.DB_PATH = dbPath;
|
|
||||||
|
|
||||||
const { getDb, closeDb } = require('../db/database');
|
|
||||||
|
|
||||||
function callSummary(userId, year, month) {
|
|
||||||
const router = require('../routes/summary');
|
|
||||||
const layer = router.stack.find(item => item.route?.path === '/' && item.route.methods.get);
|
|
||||||
assert.ok(layer, 'GET /api/summary route should exist');
|
|
||||||
const handler = layer.route.stack[0].handle;
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const req = { query: { year: String(year), month: String(month) }, user: { id: userId, role: 'user' } };
|
|
||||||
const res = {
|
|
||||||
statusCode: 200,
|
|
||||||
status(code) { this.statusCode = code; return this; },
|
|
||||||
json(data) { resolve({ status: this.statusCode, data }); },
|
|
||||||
};
|
|
||||||
handler(req, res);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('bank-tracking unpaid_this_month excludes bills not due this month (QA-B5-02)', async () => {
|
|
||||||
const db = getDb();
|
|
||||||
const userId = db.prepare(
|
|
||||||
"INSERT INTO users (username, password_hash, role, active) VALUES ('bt-user', 'x', 'user', 1)",
|
|
||||||
).run().lastInsertRowid;
|
|
||||||
|
|
||||||
const insertBill = db.prepare(`
|
|
||||||
INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, cycle_day, expected_amount, active)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
||||||
`);
|
|
||||||
// Due every June (query month): counts.
|
|
||||||
insertBill.run(userId, 'Monthly Bill', 15, 'monthly', 'monthly', null, 10000); // $100
|
|
||||||
// Annual, anchored to January: NOT due in June — must be excluded.
|
|
||||||
insertBill.run(userId, 'Annual Bill', 1, 'annually', 'annual', '1', 50000); // $500
|
|
||||||
|
|
||||||
// Enable bank tracking pointed at a financial account with a balance.
|
|
||||||
const acctId = db.prepare(`
|
|
||||||
INSERT INTO financial_accounts (user_id, name, org_name, account_type, balance, available_balance, monitored)
|
|
||||||
VALUES (?, 'Checking', 'Test Bank', 'checking', 200000, 200000, 1)
|
|
||||||
`).run(userId).lastInsertRowid;
|
|
||||||
const setSetting = db.prepare('INSERT INTO user_settings (user_id, key, value) VALUES (?, ?, ?)');
|
|
||||||
setSetting.run(userId, 'bank_tracking_enabled', 'true');
|
|
||||||
setSetting.run(userId, 'bank_tracking_account_id', String(acctId));
|
|
||||||
|
|
||||||
const { data } = await callSummary(userId, 2026, 6); // June 2026
|
|
||||||
|
|
||||||
assert.ok(data.bank_tracking, 'bank_tracking summary should be present when enabled');
|
|
||||||
// Only the $100 monthly bill is due in June; the $500 annual bill (Jan) is excluded.
|
|
||||||
assert.equal(data.bank_tracking.unpaid_this_month, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.after(() => {
|
|
||||||
closeDb();
|
|
||||||
for (const suffix of ['', '-wal', '-shm']) {
|
|
||||||
try { fs.rmSync(dbPath + suffix); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// B2/B5: the Summary must honour per-month monthly_bill_state modifiers — skipped
|
|
||||||
// bills excluded from the total, per-month amount overrides applied — alongside the
|
|
||||||
// occurrence gate added for QA-B5-01. Guards against regressions in either.
|
|
||||||
const test = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const os = require('node:os');
|
|
||||||
const path = require('node:path');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
|
|
||||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-summary-skip-test-${process.pid}.sqlite`);
|
|
||||||
process.env.DB_PATH = dbPath;
|
|
||||||
|
|
||||||
const { getDb, closeDb } = require('../db/database');
|
|
||||||
|
|
||||||
function callSummary(userId, year, month) {
|
|
||||||
const router = require('../routes/summary');
|
|
||||||
const layer = router.stack.find(item => item.route?.path === '/' && item.route.methods.get);
|
|
||||||
const handler = layer.route.stack[0].handle;
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const req = { query: { year: String(year), month: String(month) }, user: { id: userId, role: 'user' } };
|
|
||||||
const res = {
|
|
||||||
statusCode: 200,
|
|
||||||
status(c) { this.statusCode = c; return this; },
|
|
||||||
json(data) { resolve({ status: this.statusCode, data }); },
|
|
||||||
};
|
|
||||||
handler(req, res);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('summary honours skip (exclude) and per-month amount override', async () => {
|
|
||||||
const db = getDb();
|
|
||||||
const userId = db.prepare(
|
|
||||||
"INSERT INTO users (username, password_hash, role, active) VALUES ('skip-user', 'x', 'user', 1)",
|
|
||||||
).run().lastInsertRowid;
|
|
||||||
|
|
||||||
const insertBill = db.prepare(`
|
|
||||||
INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, expected_amount, active)
|
|
||||||
VALUES (?, ?, ?, 'monthly', 'monthly', ?, 1)
|
|
||||||
`);
|
|
||||||
const a = insertBill.run(userId, 'Bill A', 5, 10000).lastInsertRowid; // $100
|
|
||||||
const b = insertBill.run(userId, 'Bill B', 10, 20000).lastInsertRowid; // $200, skipped
|
|
||||||
const c = insertBill.run(userId, 'Bill C', 15, 30000).lastInsertRowid; // $300, overridden to $50
|
|
||||||
|
|
||||||
const setState = db.prepare(
|
|
||||||
'INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount, is_skipped) VALUES (?, 2026, 6, ?, ?)',
|
|
||||||
);
|
|
||||||
setState.run(b, null, 1); // skip B
|
|
||||||
setState.run(c, 5000, 0); // override C → $50
|
|
||||||
|
|
||||||
const { data } = await callSummary(userId, 2026, 6);
|
|
||||||
const find = (id) => data.expenses.find((e) => e.bill_id === id);
|
|
||||||
|
|
||||||
assert.equal(find(a).display_amount, 100, 'Bill A shows base amount');
|
|
||||||
assert.equal(find(b).is_skipped, true, 'Bill B is flagged skipped');
|
|
||||||
assert.equal(find(c).display_amount, 50, 'Bill C shows the per-month override');
|
|
||||||
assert.equal(find(c).actual_amount, 50, 'Bill C override surfaced as actual_amount');
|
|
||||||
|
|
||||||
// Counted total excludes the skipped bill and uses the override: 100 + 50 = 150.
|
|
||||||
const countedTotal = data.expenses
|
|
||||||
.filter((e) => !e.is_skipped)
|
|
||||||
.reduce((sum, e) => sum + e.display_amount, 0);
|
|
||||||
assert.equal(countedTotal, 150, 'monthly obligation excludes skipped, applies override');
|
|
||||||
});
|
|
||||||
|
|
||||||
test.after(() => {
|
|
||||||
closeDb();
|
|
||||||
for (const suffix of ['', '-wal', '-shm']) {
|
|
||||||
try { fs.rmSync(dbPath + suffix); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -19,29 +19,14 @@
|
||||||
/**
|
/**
|
||||||
* Dollars (number or string like "$1,234.56") → integer cents.
|
* Dollars (number or string like "$1,234.56") → integer cents.
|
||||||
* null/undefined/'' → null. Unparseable input → NaN (caller validates).
|
* null/undefined/'' → null. Unparseable input → NaN (caller validates).
|
||||||
*
|
|
||||||
* Rounds off the decimal string (via the shortest round-trip representation for
|
|
||||||
* numbers) rather than `Math.round(n * 100)`, whose binary-float error rounds
|
|
||||||
* e.g. 1.005 down to 100 instead of 101 (QA-B7-01). Output is identical to the
|
|
||||||
* old helper for all integer / ≤2-decimal / "$1,234.56" inputs; only 3+-decimal
|
|
||||||
* half-cent values change, and they now round half away from zero.
|
|
||||||
*/
|
*/
|
||||||
function toCents(dollars) {
|
function toCents(dollars) {
|
||||||
if (dollars === null || dollars === undefined || dollars === '') return null;
|
if (dollars === null || dollars === undefined || dollars === '') return null;
|
||||||
const cleaned = typeof dollars === 'string' ? dollars.replace(/[$,\s]/g, '') : dollars;
|
const n = typeof dollars === 'string'
|
||||||
const n = Number(cleaned);
|
? Number(dollars.replace(/[$,\s]/g, ''))
|
||||||
|
: Number(dollars);
|
||||||
if (!Number.isFinite(n)) return NaN;
|
if (!Number.isFinite(n)) return NaN;
|
||||||
|
return Math.round(n * 100);
|
||||||
// The shortest decimal string that round-trips to this float (e.g. 1.005 for
|
|
||||||
// the number 1.005, not 1.00499999…). Scientific notation → float fallback.
|
|
||||||
const decimal = typeof cleaned === 'string' ? cleaned : n.toString();
|
|
||||||
if (/[eE]/.test(decimal)) return Math.round(n * 100);
|
|
||||||
|
|
||||||
const negative = decimal.trim().startsWith('-');
|
|
||||||
const [intPart, fracPart = ''] = decimal.replace('-', '').split('.');
|
|
||||||
const frac3 = (fracPart + '000').slice(0, 3);
|
|
||||||
const cents = Number(intPart || '0') * 100 + Number(frac3.slice(0, 2)) + (Number(frac3[2]) >= 5 ? 1 : 0);
|
|
||||||
return negative ? -cents : cents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Integer cents → dollar number (for API payloads). null/undefined → null. */
|
/** Integer cents → dollar number (for API payloads). null/undefined → null. */
|
||||||
|
|
@ -90,10 +75,9 @@ 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" (negatives as "-$1,234.56"). null/undefined → "$0.00". */
|
/** Dollar number → "$1,234.56". null/undefined → "$0.00". */
|
||||||
function formatUSD(dollars) {
|
function formatUSD(dollars) {
|
||||||
const n = Number(dollars) || 0;
|
return '$' + _usd.format(Number(dollars) || 0);
|
||||||
return (n < 0 ? '-' : '') + '$' + _usd.format(Math.abs(n));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Integer cents → "$1,234.56". */
|
/** Integer cents → "$1,234.56". */
|
||||||
|
|
|
||||||
|
|
@ -63,21 +63,6 @@ export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
// QA-B0-01: split the shared vendor code out of the ~659 kB index chunk
|
|
||||||
// so large libs load/cache independently and the main bundle shrinks.
|
|
||||||
manualChunks: {
|
|
||||||
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
|
||||||
'vendor-radix': [
|
|
||||||
'@radix-ui/react-dialog', '@radix-ui/react-select', '@radix-ui/react-dropdown-menu',
|
|
||||||
'@radix-ui/react-tabs', '@radix-ui/react-tooltip', '@radix-ui/react-alert-dialog',
|
|
||||||
],
|
|
||||||
'vendor-motion': ['framer-motion'],
|
|
||||||
'vendor-query': ['@tanstack/react-query', '@tanstack/react-virtual'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// Vitest — client-side unit tests (pure logic in client/lib).
|
// Vitest — client-side unit tests (pure logic in client/lib).
|
||||||
// Server tests stay on node:test (`npm run test`); client tests run with
|
// Server tests stay on node:test (`npm run test`); client tests run with
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue