refactor(snowball): consolidate plan endpoints, standardize errors, fix N+1 (Snowball #4,#6,#7)
- The four plan-lifecycle routes (pause/resume/complete/abandon) were near
-duplicate copies returning a plain {error} shape; folded into one
transitionPlan(req,res,{allowedFrom,setSql,action,past}) helper that returns
standardizeError {message, code}, keeps the state guards and ownership scoping.
- Standardized the remaining plan endpoints' error responses (start/list/active/
patch) to standardizeError too.
- enrichPlanWithProgress fetched each snapshot bill one-by-one and wasn't user
-scoped; now a single WHERE id IN (…) AND user_id = ? batch.
Test: tests/snowballPlanRoute.test.js (transitions, INVALID_PLAN_STATE guard,
ownership 404, dollar-denominated current_debts). Server 154 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 17:04:31 -05:00
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
// Snowball review #4/#6/#7: plan lifecycle endpoints — consolidated transitionPlan
|
|
|
|
|
// handler (state guards + standardized errors + ownership) and the batched,
|
|
|
|
|
// user-scoped enrichPlanWithProgress.
|
|
|
|
|
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-snowball-plan-${process.pid}.sqlite`);
|
|
|
|
|
process.env.DB_PATH = dbPath;
|
|
|
|
|
|
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 11:34:08 -05:00
|
|
|
const { getDb, closeDb } = require('../db/database.cts');
|
refactor(routes): throw-pattern for summary, matches, snowball, dataSources
Standards-unification batch 1 (CODE_STANDARDS 'Error responses', exemplar
categories.cts): inline res.status().json(standardizeError(...)) bodies and
try/catch-500 wrappers replaced with thrown ApiError factories; the terminal
handler formats 4xx and masks+logs 5xx. Wire shapes preserved, including
client-visible codes (ALREADY_MATCHED, DUPLICATE_MATCH, NOT_MATCHED,
NO_DEBTS, INVALID_PLAN_STATE, BANK_SYNC_DISABLED).
Also kills three more err.message-on-500 leaks in dataSources.cts (DB_ERROR
wrappers) that the QA-B13-02 grep missed; SimpleFIN errors keep their
deliberate sanitized pass-through via ApiError wraps (tokens/URLs stripped).
snowballPlanRoute.test.js harness now mirrors the terminal handler for
thrown errors (same wire shape as production). Server suite 252/252.
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-10 16:44:19 -05:00
|
|
|
const { formatError } = require('../utils/apiError.cts');
|
2026-07-06 11:26:50 -05:00
|
|
|
const router = require('../routes/snowball.cts');
|
refactor(snowball): consolidate plan endpoints, standardize errors, fix N+1 (Snowball #4,#6,#7)
- The four plan-lifecycle routes (pause/resume/complete/abandon) were near
-duplicate copies returning a plain {error} shape; folded into one
transitionPlan(req,res,{allowedFrom,setSql,action,past}) helper that returns
standardizeError {message, code}, keeps the state guards and ownership scoping.
- Standardized the remaining plan endpoints' error responses (start/list/active/
patch) to standardizeError too.
- enrichPlanWithProgress fetched each snapshot bill one-by-one and wasn't user
-scoped; now a single WHERE id IN (…) AND user_id = ? batch.
Test: tests/snowballPlanRoute.test.js (transitions, INVALID_PLAN_STATE guard,
ownership 404, dollar-denominated current_debts). Server 154 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 17:04:31 -05:00
|
|
|
|
|
|
|
|
function handler(method, routePath) {
|
2026-07-06 14:23:53 -05:00
|
|
|
const layer = router.stack.find((l) => l.route?.path === routePath && l.route.methods[method]);
|
refactor(snowball): consolidate plan endpoints, standardize errors, fix N+1 (Snowball #4,#6,#7)
- The four plan-lifecycle routes (pause/resume/complete/abandon) were near
-duplicate copies returning a plain {error} shape; folded into one
transitionPlan(req,res,{allowedFrom,setSql,action,past}) helper that returns
standardizeError {message, code}, keeps the state guards and ownership scoping.
- Standardized the remaining plan endpoints' error responses (start/list/active/
patch) to standardizeError too.
- enrichPlanWithProgress fetched each snapshot bill one-by-one and wasn't user
-scoped; now a single WHERE id IN (…) AND user_id = ? batch.
Test: tests/snowballPlanRoute.test.js (transitions, INVALID_PLAN_STATE guard,
ownership 404, dollar-denominated current_debts). Server 154 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 17:04:31 -05:00
|
|
|
assert.ok(layer, `${method.toUpperCase()} ${routePath} exists`);
|
|
|
|
|
return layer.route.stack[layer.route.stack.length - 1].handle;
|
|
|
|
|
}
|
|
|
|
|
function call(method, routePath, { userId, params = {}, body = {} } = {}) {
|
|
|
|
|
const h = handler(method, routePath);
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const req = { params, body, query: {}, user: { id: userId, role: 'user' } };
|
|
|
|
|
const res = {
|
|
|
|
|
statusCode: 200,
|
2026-07-06 14:23:53 -05:00
|
|
|
status(c) {
|
|
|
|
|
this.statusCode = c;
|
|
|
|
|
return this;
|
|
|
|
|
},
|
|
|
|
|
json(d) {
|
|
|
|
|
resolve({ status: this.statusCode, data: d });
|
|
|
|
|
},
|
refactor(snowball): consolidate plan endpoints, standardize errors, fix N+1 (Snowball #4,#6,#7)
- The four plan-lifecycle routes (pause/resume/complete/abandon) were near
-duplicate copies returning a plain {error} shape; folded into one
transitionPlan(req,res,{allowedFrom,setSql,action,past}) helper that returns
standardizeError {message, code}, keeps the state guards and ownership scoping.
- Standardized the remaining plan endpoints' error responses (start/list/active/
patch) to standardizeError too.
- enrichPlanWithProgress fetched each snapshot bill one-by-one and wasn't user
-scoped; now a single WHERE id IN (…) AND user_id = ? batch.
Test: tests/snowballPlanRoute.test.js (transitions, INVALID_PLAN_STATE guard,
ownership 404, dollar-denominated current_debts). Server 154 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 17:04:31 -05:00
|
|
|
};
|
refactor(routes): throw-pattern for summary, matches, snowball, dataSources
Standards-unification batch 1 (CODE_STANDARDS 'Error responses', exemplar
categories.cts): inline res.status().json(standardizeError(...)) bodies and
try/catch-500 wrappers replaced with thrown ApiError factories; the terminal
handler formats 4xx and masks+logs 5xx. Wire shapes preserved, including
client-visible codes (ALREADY_MATCHED, DUPLICATE_MATCH, NOT_MATCHED,
NO_DEBTS, INVALID_PLAN_STATE, BANK_SYNC_DISABLED).
Also kills three more err.message-on-500 leaks in dataSources.cts (DB_ERROR
wrappers) that the QA-B13-02 grep missed; SimpleFIN errors keep their
deliberate sanitized pass-through via ApiError wraps (tokens/URLs stripped).
snowballPlanRoute.test.js harness now mirrors the terminal handler for
thrown errors (same wire shape as production). Server suite 252/252.
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-10 16:44:19 -05:00
|
|
|
// Routes follow the throw-pattern: mirror the app's terminal error handler
|
|
|
|
|
// (server.cts) so thrown ApiErrors resolve to the same wire shape.
|
|
|
|
|
try {
|
|
|
|
|
h(req, res);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
resolve({ status: err.status || 500, data: formatError(err) });
|
|
|
|
|
}
|
refactor(snowball): consolidate plan endpoints, standardize errors, fix N+1 (Snowball #4,#6,#7)
- The four plan-lifecycle routes (pause/resume/complete/abandon) were near
-duplicate copies returning a plain {error} shape; folded into one
transitionPlan(req,res,{allowedFrom,setSql,action,past}) helper that returns
standardizeError {message, code}, keeps the state guards and ownership scoping.
- Standardized the remaining plan endpoints' error responses (start/list/active/
patch) to standardizeError too.
- enrichPlanWithProgress fetched each snapshot bill one-by-one and wasn't user
-scoped; now a single WHERE id IN (…) AND user_id = ? batch.
Test: tests/snowballPlanRoute.test.js (transitions, INVALID_PLAN_STATE guard,
ownership 404, dollar-denominated current_debts). Server 154 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 17:04:31 -05:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let userA, userB;
|
|
|
|
|
test.before(() => {
|
|
|
|
|
const db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
userA = db
|
|
|
|
|
.prepare(
|
|
|
|
|
"INSERT INTO users (username, password_hash, role, active) VALUES ('sb-a','x','user',1)",
|
|
|
|
|
)
|
|
|
|
|
.run().lastInsertRowid;
|
|
|
|
|
userB = db
|
|
|
|
|
.prepare(
|
|
|
|
|
"INSERT INTO users (username, password_hash, role, active) VALUES ('sb-b','x','user',1)",
|
|
|
|
|
)
|
|
|
|
|
.run().lastInsertRowid;
|
refactor(snowball): consolidate plan endpoints, standardize errors, fix N+1 (Snowball #4,#6,#7)
- The four plan-lifecycle routes (pause/resume/complete/abandon) were near
-duplicate copies returning a plain {error} shape; folded into one
transitionPlan(req,res,{allowedFrom,setSql,action,past}) helper that returns
standardizeError {message, code}, keeps the state guards and ownership scoping.
- Standardized the remaining plan endpoints' error responses (start/list/active/
patch) to standardizeError too.
- enrichPlanWithProgress fetched each snapshot bill one-by-one and wasn't user
-scoped; now a single WHERE id IN (…) AND user_id = ? batch.
Test: tests/snowballPlanRoute.test.js (transitions, INVALID_PLAN_STATE guard,
ownership 404, dollar-denominated current_debts). Server 154 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 17:04:31 -05:00
|
|
|
const insBill = db.prepare(
|
2026-07-06 14:23:53 -05:00
|
|
|
'INSERT INTO bills (user_id, name, due_day, expected_amount, current_balance, minimum_payment, interest_rate, snowball_include, active) VALUES (?, ?, 1, 0, ?, ?, 0, 1, 1)',
|
refactor(snowball): consolidate plan endpoints, standardize errors, fix N+1 (Snowball #4,#6,#7)
- The four plan-lifecycle routes (pause/resume/complete/abandon) were near
-duplicate copies returning a plain {error} shape; folded into one
transitionPlan(req,res,{allowedFrom,setSql,action,past}) helper that returns
standardizeError {message, code}, keeps the state guards and ownership scoping.
- Standardized the remaining plan endpoints' error responses (start/list/active/
patch) to standardizeError too.
- enrichPlanWithProgress fetched each snapshot bill one-by-one and wasn't user
-scoped; now a single WHERE id IN (…) AND user_id = ? batch.
Test: tests/snowballPlanRoute.test.js (transitions, INVALID_PLAN_STATE guard,
ownership 404, dollar-denominated current_debts). Server 154 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 17:04:31 -05:00
|
|
|
);
|
|
|
|
|
insBill.run(userA, 'Card A', 100000, 20000); // $1000 bal, $200 min
|
|
|
|
|
insBill.run(userA, 'Card B', 300000, 20000); // $3000 bal, $200 min
|
|
|
|
|
});
|
|
|
|
|
test.after(() => {
|
|
|
|
|
closeDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
for (const s of ['', '-wal', '-shm']) {
|
|
|
|
|
try {
|
|
|
|
|
fs.unlinkSync(dbPath + s);
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
refactor(snowball): consolidate plan endpoints, standardize errors, fix N+1 (Snowball #4,#6,#7)
- The four plan-lifecycle routes (pause/resume/complete/abandon) were near
-duplicate copies returning a plain {error} shape; folded into one
transitionPlan(req,res,{allowedFrom,setSql,action,past}) helper that returns
standardizeError {message, code}, keeps the state guards and ownership scoping.
- Standardized the remaining plan endpoints' error responses (start/list/active/
patch) to standardizeError too.
- enrichPlanWithProgress fetched each snapshot bill one-by-one and wasn't user
-scoped; now a single WHERE id IN (…) AND user_id = ? batch.
Test: tests/snowballPlanRoute.test.js (transitions, INVALID_PLAN_STATE guard,
ownership 404, dollar-denominated current_debts). Server 154 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 17:04:31 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('start plan → enriched with user-scoped current_debts (dollars)', async () => {
|
2026-07-06 14:23:53 -05:00
|
|
|
const { status, data } = await call('post', '/plans', {
|
|
|
|
|
userId: userA,
|
|
|
|
|
body: { method: 'snowball' },
|
|
|
|
|
});
|
refactor(snowball): consolidate plan endpoints, standardize errors, fix N+1 (Snowball #4,#6,#7)
- The four plan-lifecycle routes (pause/resume/complete/abandon) were near
-duplicate copies returning a plain {error} shape; folded into one
transitionPlan(req,res,{allowedFrom,setSql,action,past}) helper that returns
standardizeError {message, code}, keeps the state guards and ownership scoping.
- Standardized the remaining plan endpoints' error responses (start/list/active/
patch) to standardizeError too.
- enrichPlanWithProgress fetched each snapshot bill one-by-one and wasn't user
-scoped; now a single WHERE id IN (…) AND user_id = ? batch.
Test: tests/snowballPlanRoute.test.js (transitions, INVALID_PLAN_STATE guard,
ownership 404, dollar-denominated current_debts). Server 154 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 17:04:31 -05:00
|
|
|
assert.equal(status, 201);
|
|
|
|
|
assert.equal(data.status, 'active');
|
|
|
|
|
assert.ok(Array.isArray(data.current_debts) && data.current_debts.length === 2);
|
2026-07-06 14:23:53 -05:00
|
|
|
const cardA = data.current_debts.find((d) => d.name === 'Card A');
|
refactor(snowball): consolidate plan endpoints, standardize errors, fix N+1 (Snowball #4,#6,#7)
- The four plan-lifecycle routes (pause/resume/complete/abandon) were near
-duplicate copies returning a plain {error} shape; folded into one
transitionPlan(req,res,{allowedFrom,setSql,action,past}) helper that returns
standardizeError {message, code}, keeps the state guards and ownership scoping.
- Standardized the remaining plan endpoints' error responses (start/list/active/
patch) to standardizeError too.
- enrichPlanWithProgress fetched each snapshot bill one-by-one and wasn't user
-scoped; now a single WHERE id IN (…) AND user_id = ? batch.
Test: tests/snowballPlanRoute.test.js (transitions, INVALID_PLAN_STATE guard,
ownership 404, dollar-denominated current_debts). Server 154 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 17:04:31 -05:00
|
|
|
assert.equal(cardA.current_balance, 1000, 'balance in dollars (fromCents)');
|
|
|
|
|
assert.equal(cardA.starting_balance, 1000);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pause / resume / complete transitions + state guards + standardized errors', async () => {
|
|
|
|
|
const plan = (await call('get', '/plans/active', { userId: userA })).data;
|
|
|
|
|
const id = String(plan.id);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
assert.equal(
|
|
|
|
|
(await call('post', '/plans/:id/pause', { userId: userA, params: { id } })).data.status,
|
|
|
|
|
'paused',
|
|
|
|
|
);
|
refactor(snowball): consolidate plan endpoints, standardize errors, fix N+1 (Snowball #4,#6,#7)
- The four plan-lifecycle routes (pause/resume/complete/abandon) were near
-duplicate copies returning a plain {error} shape; folded into one
transitionPlan(req,res,{allowedFrom,setSql,action,past}) helper that returns
standardizeError {message, code}, keeps the state guards and ownership scoping.
- Standardized the remaining plan endpoints' error responses (start/list/active/
patch) to standardizeError too.
- enrichPlanWithProgress fetched each snapshot bill one-by-one and wasn't user
-scoped; now a single WHERE id IN (…) AND user_id = ? batch.
Test: tests/snowballPlanRoute.test.js (transitions, INVALID_PLAN_STATE guard,
ownership 404, dollar-denominated current_debts). Server 154 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 17:04:31 -05:00
|
|
|
|
|
|
|
|
// pausing an already-paused plan → 400 with a standardized {message, code}
|
|
|
|
|
const bad = await call('post', '/plans/:id/pause', { userId: userA, params: { id } });
|
|
|
|
|
assert.equal(bad.status, 400);
|
|
|
|
|
assert.equal(bad.data.code, 'INVALID_PLAN_STATE');
|
|
|
|
|
assert.match(bad.data.message, /Only active plans can be paused/);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
assert.equal(
|
|
|
|
|
(await call('post', '/plans/:id/resume', { userId: userA, params: { id } })).data.status,
|
|
|
|
|
'active',
|
|
|
|
|
);
|
|
|
|
|
assert.equal(
|
|
|
|
|
(await call('post', '/plans/:id/complete', { userId: userA, params: { id } })).data.status,
|
|
|
|
|
'completed',
|
|
|
|
|
);
|
refactor(snowball): consolidate plan endpoints, standardize errors, fix N+1 (Snowball #4,#6,#7)
- The four plan-lifecycle routes (pause/resume/complete/abandon) were near
-duplicate copies returning a plain {error} shape; folded into one
transitionPlan(req,res,{allowedFrom,setSql,action,past}) helper that returns
standardizeError {message, code}, keeps the state guards and ownership scoping.
- Standardized the remaining plan endpoints' error responses (start/list/active/
patch) to standardizeError too.
- enrichPlanWithProgress fetched each snapshot bill one-by-one and wasn't user
-scoped; now a single WHERE id IN (…) AND user_id = ? batch.
Test: tests/snowballPlanRoute.test.js (transitions, INVALID_PLAN_STATE guard,
ownership 404, dollar-denominated current_debts). Server 154 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 17:04:31 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('another user cannot transition my plan (ownership)', async () => {
|
|
|
|
|
const start = await call('post', '/plans', { userId: userA, body: {} });
|
|
|
|
|
const id = String(start.data.id);
|
|
|
|
|
const foreign = await call('post', '/plans/:id/abandon', { userId: userB, params: { id } });
|
|
|
|
|
assert.equal(foreign.status, 404);
|
|
|
|
|
assert.equal(foreign.data.code, 'NOT_FOUND');
|
|
|
|
|
// still active for the real owner
|
|
|
|
|
assert.equal((await call('get', '/plans/active', { userId: userA })).data.status, 'active');
|
|
|
|
|
});
|