2026-06-14 19:21:34 -05:00
|
|
|
const test = require('node:test');
|
|
|
|
|
const assert = require('node:assert/strict');
|
|
|
|
|
const fs = require('node:fs');
|
|
|
|
|
const os = require('node:os');
|
|
|
|
|
const path = require('node:path');
|
|
|
|
|
|
|
|
|
|
const dbPath = path.join(os.tmpdir(), `bill-tracker-spending-summary-test-${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(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 10:35:29 -05:00
|
|
|
const { getSpendingSummary, setSpendingBudget } = require('../services/spendingService.cts');
|
2026-06-14 19:21:34 -05:00
|
|
|
|
|
|
|
|
function createUser(db, suffix) {
|
2026-07-06 14:23:53 -05:00
|
|
|
return db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-06-14 19:21:34 -05:00
|
|
|
INSERT INTO users (username, password_hash, role, active, email, created_at, updated_at)
|
|
|
|
|
VALUES (?, 'x', 'user', 1, ?, datetime('now'), datetime('now'))
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.run(`spending-summary-${suffix}`, `spending-summary-${suffix}@local`).lastInsertRowid;
|
2026-06-14 19:21:34 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createCategory(db, userId, name, { spendingEnabled = 1, groupId = null } = {}) {
|
2026-07-06 14:23:53 -05:00
|
|
|
return db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-06-14 19:21:34 -05:00
|
|
|
INSERT INTO categories (user_id, name, spending_enabled, group_id)
|
|
|
|
|
VALUES (?, ?, ?, ?)
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.run(userId, name, spendingEnabled, groupId).lastInsertRowid;
|
2026-06-14 19:21:34 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function createTransaction(
|
|
|
|
|
db,
|
|
|
|
|
userId,
|
|
|
|
|
{ amountCents, postedDate, categoryId = null, matchStatus = 'unmatched' },
|
|
|
|
|
) {
|
|
|
|
|
return db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-06-14 19:21:34 -05:00
|
|
|
INSERT INTO transactions (user_id, source_type, posted_date, amount, payee, match_status, ignored, spending_category_id)
|
|
|
|
|
VALUES (?, 'manual', ?, ?, 'Test Payee', ?, 0, ?)
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.run(userId, postedDate, amountCents, matchStatus, categoryId).lastInsertRowid;
|
2026-06-14 19:21:34 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test.after(() => {
|
|
|
|
|
closeDb();
|
|
|
|
|
for (const suffix of ['', '-wal', '-shm']) {
|
|
|
|
|
fs.rmSync(`${dbPath}${suffix}`, { force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a budgeted category with no activity still appears with its budget', () => {
|
|
|
|
|
const db = getDb();
|
|
|
|
|
const userId = createUser(db, 'zero-activity');
|
|
|
|
|
const groceries = createCategory(db, userId, 'Groceries');
|
|
|
|
|
setSpendingBudget(db, userId, groceries, 2026, 1, 300);
|
|
|
|
|
|
|
|
|
|
const summary = getSpendingSummary(db, userId, 2026, 1);
|
2026-07-06 14:23:53 -05:00
|
|
|
const row = summary.by_category.find((c) => c.category_id === groceries);
|
2026-06-14 19:21:34 -05:00
|
|
|
|
|
|
|
|
assert.ok(row, 'budgeted category should appear even with $0 activity');
|
|
|
|
|
assert.equal(row.amount, 0);
|
|
|
|
|
assert.equal(row.budget, 300);
|
|
|
|
|
assert.equal(row.tx_count, 0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('outflows assigned to a non-spending category are bundled into "Other categories"', () => {
|
|
|
|
|
const db = getDb();
|
|
|
|
|
const userId = createUser(db, 'other-bucket');
|
|
|
|
|
const nonSpending = createCategory(db, userId, 'Transfers', { spendingEnabled: 0 });
|
2026-07-06 14:23:53 -05:00
|
|
|
createTransaction(db, userId, {
|
|
|
|
|
amountCents: -1500,
|
|
|
|
|
postedDate: '2026-02-05',
|
|
|
|
|
categoryId: nonSpending,
|
|
|
|
|
});
|
2026-06-14 19:21:34 -05:00
|
|
|
|
|
|
|
|
const summary = getSpendingSummary(db, userId, 2026, 2);
|
2026-07-06 14:23:53 -05:00
|
|
|
const other = summary.by_category.find((c) => c.category_id === 'other');
|
2026-06-14 19:21:34 -05:00
|
|
|
|
|
|
|
|
assert.ok(other, '"Other categories" row should appear');
|
|
|
|
|
assert.equal(other.amount, 15);
|
|
|
|
|
assert.equal(other.tx_count, 1);
|
|
|
|
|
assert.equal(summary.total_spending, 15);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('avg_3mo reflects the average spend over the prior 3 months', () => {
|
|
|
|
|
const db = getDb();
|
|
|
|
|
const userId = createUser(db, 'avg-3mo');
|
|
|
|
|
const dining = createCategory(db, userId, 'Dining');
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
createTransaction(db, userId, {
|
|
|
|
|
amountCents: -1000,
|
|
|
|
|
postedDate: '2026-03-10',
|
|
|
|
|
categoryId: dining,
|
|
|
|
|
});
|
|
|
|
|
createTransaction(db, userId, {
|
|
|
|
|
amountCents: -2000,
|
|
|
|
|
postedDate: '2026-04-10',
|
|
|
|
|
categoryId: dining,
|
|
|
|
|
});
|
|
|
|
|
createTransaction(db, userId, {
|
|
|
|
|
amountCents: -3000,
|
|
|
|
|
postedDate: '2026-05-10',
|
|
|
|
|
categoryId: dining,
|
|
|
|
|
});
|
2026-06-14 19:21:34 -05:00
|
|
|
|
|
|
|
|
const summary = getSpendingSummary(db, userId, 2026, 6);
|
2026-07-06 14:23:53 -05:00
|
|
|
const row = summary.by_category.find((c) => c.category_id === dining);
|
2026-06-14 19:21:34 -05:00
|
|
|
|
|
|
|
|
assert.ok(row);
|
|
|
|
|
assert.equal(row.avg_3mo, 20); // (10 + 20 + 30) / 3
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('category_groups are returned and group_id/group_name are attached to categories', () => {
|
|
|
|
|
const db = getDb();
|
|
|
|
|
const userId = createUser(db, 'groups');
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const groupRow = db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
2026-06-14 19:21:34 -05:00
|
|
|
INSERT INTO category_groups (user_id, name, sort_order) VALUES (?, 'Bills', 0)
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.run(userId);
|
2026-06-14 19:21:34 -05:00
|
|
|
const groupId = groupRow.lastInsertRowid;
|
|
|
|
|
|
|
|
|
|
const rent = createCategory(db, userId, 'Rent', { groupId });
|
2026-07-06 14:23:53 -05:00
|
|
|
createTransaction(db, userId, {
|
|
|
|
|
amountCents: -120000,
|
|
|
|
|
postedDate: '2026-07-01',
|
|
|
|
|
categoryId: rent,
|
|
|
|
|
});
|
2026-06-14 19:21:34 -05:00
|
|
|
|
|
|
|
|
const summary = getSpendingSummary(db, userId, 2026, 7);
|
2026-07-06 14:23:53 -05:00
|
|
|
const row = summary.by_category.find((c) => c.category_id === rent);
|
2026-06-14 19:21:34 -05:00
|
|
|
|
|
|
|
|
assert.deepEqual(summary.category_groups, [{ id: groupId, name: 'Bills', sort_order: 0 }]);
|
|
|
|
|
assert.equal(row.group_id, groupId);
|
|
|
|
|
assert.equal(row.group_name, 'Bills');
|
|
|
|
|
});
|