Files
Spendlight/server/test/backups.test.ts
Chris Olson c3cf0f3eb8 Milestones 0 and 1: foundation and money in (20261007.10)
Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite),
local sign-in with long-lived sessions and passkeys, roles (read-only, full
access, admin), nightly and pre-upgrade backups, light and dark themes,
date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED.

Money in: accounts with balances, CSV import with bank detection, a column
mapping pop-up for unknown layouts, duplicate checks, transfer pairing and
undo; one-tap review with learned rules; activity with search, splits, tags,
notes and cash entries. Statement files are blocked from commits by a
pre-commit hook; tests use synthetic fixtures only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:52:07 -04:00

97 lines
4.3 KiB
TypeScript

import Database from 'better-sqlite3';
import { execFileSync } from 'node:child_process';
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import { createBackup, listBackups, planRetention } from '../src/backups/backup';
import { makeApp, setUpAdmin, type TestApp } from './helpers';
const nightly = (y: number, m: number, d: number) => {
const date = `${y}${String(m).padStart(2, '0')}${String(d).padStart(2, '0')}`;
return {
name: `spendlight-nightly-${date}-023000.db`,
kind: 'nightly' as const,
createdAt: new Date(y, m - 1, d, 2, 30).toISOString(),
};
};
describe('backup retention', () => {
it('keeps 14 daily copies plus the newest of each of 12 months', () => {
const list = [];
// Every night from Jan 1 2025 to Oct 7 2026.
for (let d = new Date(2025, 0, 1); d <= new Date(2026, 9, 7); d.setDate(d.getDate() + 1)) {
list.push(nightly(d.getFullYear(), d.getMonth() + 1, d.getDate()));
}
const doomed = new Set(planRetention(list, { keepDaily: 14, keepMonthly: 12 }));
const kept = list.filter((b) => !doomed.has(b.name)).map((b) => b.name.slice(19, 27));
expect(kept.filter((d) => d >= '20260924')).toHaveLength(14); // the last 14 nights
const months = new Set(kept.map((d) => d.slice(0, 6)));
expect(months.size).toBe(12); // Nov 2025 through Oct 2026
expect(kept).toContain('20251130'); // newest of the oldest kept month
expect(kept).not.toContain('20251031');
});
it('keeps the newest 10 manual and 5 pre-upgrade copies', () => {
const make = (kind: 'manual' | 'pre-upgrade', i: number) => ({
name: `spendlight-${kind}-202610${String(i).padStart(2, '0')}-120000.db`,
kind,
createdAt: new Date(2026, 9, i, 12).toISOString(),
});
const list = [...Array.from({ length: 12 }, (_, i) => make('manual', i + 1)), ...Array.from({ length: 7 }, (_, i) => make('pre-upgrade', i + 1))];
const doomed = planRetention(list, { keepDaily: 14, keepMonthly: 12 });
expect(doomed.filter((n) => n.includes('manual'))).toEqual([
'spendlight-manual-20261002-120000.db',
'spendlight-manual-20261001-120000.db',
]);
expect(doomed.filter((n) => n.includes('pre-upgrade'))).toHaveLength(2);
});
});
describe('backup files', () => {
let t: TestApp;
afterEach(() => t.cleanup());
it('writes a complete, valid Spendlight database and lists it', async () => {
t = await makeApp();
await setUpAdmin(t);
const dir = t.ctx.config.backupDir;
const backup = await createBackup(t.ctx.sqlite, dir, 'manual');
expect(readdirSync(dir).filter((f) => f.endsWith('.partial'))).toHaveLength(0);
expect(listBackups(dir).map((b) => b.name)).toContain(backup.name);
const copy = new Database(join(dir, backup.name), { readonly: true });
expect((copy.prepare("SELECT value FROM setting WHERE key = 'app'").get() as { value: string }).value).toBe('"spendlight"');
expect((copy.prepare('SELECT username FROM user').get() as { username: string }).username).toBe('chris');
copy.close();
});
it('lets an admin back up from the app and download the file', async () => {
t = await makeApp();
const admin = await setUpAdmin(t);
const made = await admin.post('/api/v1/backups');
expect(made.status).toBe(200);
expect((await admin.get('/api/v1/backups')).json).toMatchObject({ nightlyAt: '02:30', keepDaily: 14, keepMonthly: 12 });
const download = await admin.get(`/api/v1/backups/${made.json.name}/download`);
expect(download.status).toBe(200);
expect(download.headers.get('content-disposition')).toContain(made.json.name);
expect((await admin.get('/api/v1/backups/..%2F..%2Fetc%2Fpasswd/download')).status).toBe(404);
});
});
describe('version stamps', () => {
const next = (previous: string, today: string) =>
execFileSync('bash', [fileURLToPath(new URL('../../scripts/next-version.sh', import.meta.url)), previous], { env: { ...process.env, TODAY: today } })
.toString()
.trim();
it('counts up through the day and restarts at .01 the next', () => {
expect(next('dev', '20261007')).toBe('20261007.01');
expect(next('20261007.01', '20261007')).toBe('20261007.02');
expect(next('20261007.09', '20261007')).toBe('20261007.10');
expect(next('20261007.14', '20261008')).toBe('20261008.01');
});
});