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>
This commit is contained in:
96
server/test/backups.test.ts
Normal file
96
server/test/backups.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user