Compare commits
No commits in common. "2050e134079554ccd1d96848f6a94c2e91c072ce" and "127b69ffc28fb3de060ba2abf3cec558df2d3190" have entirely different histories.
2050e13407
...
127b69ffc2
|
|
@ -3,14 +3,10 @@
|
||||||
|
|
||||||
### 🐛 QA Fixes
|
### 🐛 QA Fixes
|
||||||
|
|
||||||
- **[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)
|
- **[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)
|
- **[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)
|
- **[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)
|
- **[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
|
### 🧹 QA Cleanup
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,11 +178,9 @@ 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}>
|
||||||
|
|
@ -209,17 +204,13 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete,
|
||||||
<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)}>
|
<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
|
<Zap className="h-3 w-3" /> New Plan
|
||||||
</Button>
|
</Button>
|
||||||
|
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', open && 'rotate-180')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chevron — also a toggle */}
|
</div>
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<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">
|
|
||||||
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
|
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Collapsible body — per-debt rows */}
|
{/* Collapsible body — per-debt rows */}
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="border-t border-emerald-400/15 divide-y divide-border/30">
|
<div className="border-t border-emerald-400/15 divide-y divide-border/30">
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
type="button"
|
|
||||||
onClick={event => { event.stopPropagation(); toggleCategory(cat.id); }}
|
|
||||||
aria-expanded={isExpanded}
|
|
||||||
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
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-4 w-4 transition-transform',
|
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
|
||||||
isExpanded && 'rotate-180 text-foreground',
|
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>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# BillTracker — Master QA Plan (living document)
|
# BillTracker — Master QA Plan (living document)
|
||||||
|
|
||||||
**Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-02 (Cycle 1: 13 findings fixed & archived, 0 open — incl. a broken "Send test push")
|
**Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-02
|
||||||
|
|
||||||
This is a **living, operational** QA document, not a static spec. Claude runs it,
|
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
|
in **batches**, actively hunting for bugs/errors/rough edges, **fixing** them, and
|
||||||
|
|
@ -87,22 +87,22 @@ before cross-cutting; regression last). Update **Status** and **Findings** every
|
||||||
| # | Batch | Primary surface | Data state | Status | Open / Fixed |
|
| # | 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| B5 | Reporting reconciliation | `/summary`, `/calendar`, `/analytics`, `/health` cross-check totals | seeded + large | ⬜ | 0 / 0 |
|
||||||
| B6 | Spending | `/spending` YNAB view, averages, cover-overspending, safe-to-spend | seeded + edge months | 🔄 | 0 / 1 |
|
| 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 |
|
| B7 | Debt planning (math) | `/snowball`, `/payoff` APR/amortization vs hand-calc | edge (APR=0, $0 debt) | 🔄 | 1 / 1 |
|
||||||
| B8 | Banking & bank sync | `/bank-transactions`, SimpleFIN sync, matching, merchant/store, advisory filter | seeded txns | ⬜ | 0 / 0 |
|
| B8 | Banking & bank sync | `/bank-transactions`, SimpleFIN sync, matching, merchant/store, advisory filter | seeded txns | ⬜ | 0 / 0 |
|
||||||
| B9 | Data lifecycle | `/data` import (XLSX/CSV/SQLite), export, ICS feed, backups round-trip | empty + seeded | 🔄 | 0 / 1 |
|
| 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 |
|
| B10 | Notifications & workers | email + ntfy/Gotify/Discord/Telegram, reminders, cron workers | seeded | ⬜ | 0 / 0 |
|
||||||
| B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | 🔄 | 0 / 0 |
|
| 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 |
|
| 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 |
|
| 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 / 3 |
|
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | 🔄 | 1 / 2 |
|
||||||
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | 🔄 | 0 / 0 |
|
| 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
|
> 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.
|
> cycle from B0 against the next build/version.
|
||||||
|
|
@ -115,7 +115,7 @@ until you get a clean cycle.
|
||||||
|
|
||||||
| Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result |
|
| Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result |
|
||||||
|-------|---------|----------------|-----------------|------------------|--------|
|
|-------|---------|----------------|-----------------|------------------|--------|
|
||||||
| 1 | 2026-07-02 | `bdbf231`→(dev) | 13 | **13 → all fixed, verified & archived** (…, +B10-01 broken "Send test push") | ✅ **0 open.** Post seed-fix reconciliation caught the **occurrence-gating family** — Summary (S2), Analytics, and SimpleFIN bank-tracking all counted non-monthly bills every month; all fixed via `resolveDueDate` and guarded (probe reconciliation + `tests/summaryBankTracking.test.js`). Probed B0/B1/B3/B4/B5/B6/B7/B8/B9/B13/B14; solid: auth/isolation, CSRF, payment/date validation, recurrence, matching/dedup, subscription+spending math, XSS, calendar gating. **A full re-run (B0→B15) is still required to declare the cycle clean per exit criteria.** |
|
| 1 | 2026-07-02 | `bdbf231` (dev) | 9 (find pass ongoing) | 7 → HISTORY v0.41.0 (B9-01, B13-01, B6-01, B14-01, B14-03, B0-01, B7-02; + a11y svg/aria of B14-02) | 🔄 in progress — B0/B1/B3/B4/B6/B7/B8/B9/B13/B14 probed. Solid: auth-isolation, CSRF, payment/date validation, **recurrence (quarterly/annual gating, Feb-31 clamp, leap year — all correct)**, **transaction matching/dedup guards**, subscription+spending math, XSS. **Fixed: seed 100× cents (S2), bill-amount validation, negative-money format, a11y labels, vendor-bundle split, unused-dep removal, dead-code removal.** Open: 2 (B7-01 rounding S3 [float-inherent], B14-02 nested-interactive S3 [architectural]) |
|
||||||
|
|
||||||
**Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete)
|
**Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete)
|
||||||
|
|
||||||
|
|
@ -134,7 +134,8 @@ fixing. Keep only **Open / Fixing / Fixed** rows here. Once a finding is
|
||||||
|
|
||||||
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
|
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
|
||||||
|----|-----|--------------------|---------|--------|---------------|
|
|----|-----|--------------------|---------|--------|---------------|
|
||||||
| _(none — all Cycle 1 findings fixed, verified & archived to `HISTORY.md` v0.41.0)_ | | | | | |
|
| QA-B7-01 | S3 | `utils/money.js:29` | `toCents` mis-rounds fractional cents: `toCents(1.005)` → 100 (`$1.00`) not 101 | 🔴 Open | see write-up (deferred — float-inherent) |
|
||||||
|
| QA-B14-02 | S3 | `/categories` (8), `/snowball` (1) | axe **serious** `nested-interactive`: draggable/expandable rows are `role=button` yet contain nested buttons | 🔴 Open | see write-up (deferred — architectural) |
|
||||||
|
|
||||||
**Finding template** (paste a new row above; keep the full write-up here until archived):
|
**Finding template** (paste a new row above; keep the full write-up here until archived):
|
||||||
|
|
||||||
|
|
@ -155,7 +156,44 @@ Fix: (what changed, commit) — Verified by: (repro re-run + ci)
|
||||||
Log console errors, failed network requests, and unhandled rejections as findings
|
Log console errors, failed network requests, and unhandled rejections as findings
|
||||||
**even if the UI looks fine**.
|
**even if the UI looks fine**.
|
||||||
|
|
||||||
_All Cycle 1 write-ups have been archived to `HISTORY.md` v0.41.0 (see §3)._
|
### Cycle 1 — logged write-ups
|
||||||
|
|
||||||
|
```
|
||||||
|
ID: QA-B7-01
|
||||||
|
Severity: S3 (minor — wrong edge behavior in money core that advertises exactness)
|
||||||
|
Environment: server-side money math
|
||||||
|
Area: utils/money.js:29 (toCents → Math.round(n * 100))
|
||||||
|
Steps to reproduce:
|
||||||
|
1. toCents(1.005) → 100 (i.e. $1.00), not 101 ($1.01).
|
||||||
|
2. Round-trip fromCents(toCents(1.005)) → 1 (a cent silently lost).
|
||||||
|
Expected: "cent-exact" per the file's own docstring — 1.005 → 101.
|
||||||
|
Actual: float multiply (1.005*100 = 100.4999…) rounds down before Math.round.
|
||||||
|
Evidence: node probe. Other 3-decimal inputs also affected (values near .xx5).
|
||||||
|
Impact: bounded to sub-cent, and only when a 3+ decimal dollar value reaches the
|
||||||
|
boundary (proration/interest), so low severity — but it contradicts the exactness
|
||||||
|
guarantee and is the plan's named "fractional cents" adversarial case.
|
||||||
|
Fix (deferred): round on a string/scaled-integer basis, or add epsilon before round.
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
ID: QA-B14-02 (partially fixed — nested-interactive remains)
|
||||||
|
Severity: S3 (accessibility — WCAG 2 A/AA, axe "serious")
|
||||||
|
Environment: authenticated app; found/verified via e2e/a11y.authed.spec.js
|
||||||
|
FIXED this cycle (archived to HISTORY.md, was part of B14-02 + all of B14-01):
|
||||||
|
- button-name (critical) → Tracker/Bills Select triggers + Spending month-nav +
|
||||||
|
Analytics chart svgs now have aria-labels. All 4 pages pass.
|
||||||
|
- svg-img-alt → /analytics chart svgs got aria-labels (SvgFrame `label` prop). Pass.
|
||||||
|
- aria-prohibited-attr → /snowball drag-handle div aria-label → title. Pass.
|
||||||
|
REMAINING (deferred — architectural, needs manual drag/keyboard/SR verification):
|
||||||
|
- nested-interactive → /categories (8): each row is <div role="button" tabindex=0
|
||||||
|
aria-expanded> AND draggable AND contains nested edit/delete/drag buttons.
|
||||||
|
- nested-interactive → /snowball (1): a <button class="w-full text-left"
|
||||||
|
aria-controls aria-expanded> accordion trigger wraps other interactive controls.
|
||||||
|
Fix (deferred): don't make the whole row/trigger a button — use a dedicated
|
||||||
|
expand/collapse control and keep the row a plain container, so interactive
|
||||||
|
children aren't nested inside an interactive ancestor. Re-verify drag + keyboard
|
||||||
|
after. Guard: e2e/a11y.authed.spec.js (currently red on these two pages by design).
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -231,7 +269,6 @@ Manual passes prove a button works **once**; they don't stop it regressing next
|
||||||
| `npm run test:e2e` | run the E2E suite headless (boots the app via `webServer`) |
|
| `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:ui` | Playwright UI mode — watch/debug interactively |
|
||||||
| `npm run test:e2e:update` | re-baseline visual-regression screenshots (review the diff before committing) |
|
| `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/`.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -71,23 +71,6 @@ test('data-isolation — cannot touch another user\'s bill (B1/IDOR)', async ({
|
||||||
expect.soft(del.status(), 'DELETE user B bill must be blocked (>=400)').toBeGreaterThanOrEqual(400);
|
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 }) => {
|
test('bad / nonexistent id — structured, not a 500 (B13)', async ({ request }) => {
|
||||||
for (const id of ['99999999', 'not-a-number', '0', '-1']) {
|
for (const id of ['99999999', 'not-a-number', '0', '-1']) {
|
||||||
const res = await request.get(`/api/bills/${id}`);
|
const res = await request.get(`/api/bills/${id}`);
|
||||||
|
|
@ -103,38 +86,6 @@ test('CSRF — a state-changing request without a token is rejected (B13)', asyn
|
||||||
expect.soft(res.status(), 'missing CSRF token must be rejected (403)').toBe(403);
|
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 }) => {
|
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
|
// 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
|
// seed inserts dollars into the integer-cents expected_amount column (regression
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
"test:e2e:ui": "node e2e/setup/prepare-db.js && playwright test --ui",
|
"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: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",
|
"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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -547,7 +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 };
|
|
||||||
|
|
|
||||||
|
|
@ -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,108 +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/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue