Milestone 3: launch (20261008.05)
Backup and restore: restore from Settings (any backup in the folder, or an
uploaded file) or from the command line with scripts/restore.sh. Each restore
checks the file first, saves a copy of the current data so it can be undone,
and keeps the person restoring signed in. Backups are now single
self-contained files. Drilled end to end: last night's backup brings
everything back exactly, including onto an empty new machine.
Speed: a phone's first load is 147 KB compressed (target 150). The ideas box,
account menu and sign-in screens load on demand, a small built-in toast
replaces sonner, the Inter font is trimmed to the characters used, and files
and API answers are served compressed. Every screen's data comes back in
under 100 ms with 50,000 transactions (server/bench).
Accessibility: no WCAG 2.2 AA issues on any screen in either theme. Darker
status text colors, 44 px touch targets on phones, reduced motion honoured,
and focus returns after dialogs close.
Also: an in-app guide ("How Spendlight works"), OFX/QFX/QBO import, and tags
on individual parts of a split purchase.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
128
server/test/restore.test.ts
Normal file
128
server/test/restore.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { listBackups } from '../src/backups/backup';
|
||||
import { makeApp, setUpAdmin, type Client, type TestApp } from './helpers';
|
||||
import { discoverCsv } from './synthetic';
|
||||
|
||||
// The milestone 3 drill, automated: back up, change things, restore, and get
|
||||
// everything back exactly. All data here is made up.
|
||||
|
||||
let t: TestApp;
|
||||
let admin: Client;
|
||||
|
||||
const snapshot = async () => {
|
||||
const page = (await admin.get('/api/v1/transactions?limit=500')).json;
|
||||
return {
|
||||
total: page.total,
|
||||
sums: page.sums,
|
||||
accounts: ((await admin.get('/api/v1/accounts')).json as { name: string; balanceCents: number }[]).map((a) => [a.name, a.balanceCents]),
|
||||
budgets: (await admin.get('/api/v1/budgets?year=2026')).json,
|
||||
subscriptions: ((await admin.get('/api/v1/subscriptions')).json as { name: string; status: string }[]).map((s) => [s.name, s.status]),
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
t = await makeApp();
|
||||
admin = await setUpAdmin(t);
|
||||
const card = (await admin.post('/api/v1/accounts', { name: 'Discover', kind: 'credit_card', institution: 'Discover' })).json.id;
|
||||
const preview = await admin.upload('/api/v1/imports/files', 'Discover-2026.csv', discoverCsv());
|
||||
await admin.post('/api/v1/imports', { stagingId: preview.json.stagingId, accountId: card });
|
||||
await admin.post('/api/v1/budgets/fill', { month: '2026-10', source: 'average3' });
|
||||
});
|
||||
afterEach(() => t.cleanup());
|
||||
|
||||
describe('restoring a backup', () => {
|
||||
it('brings everything back, after saving a copy of how things were', async () => {
|
||||
const before = await snapshot();
|
||||
const backup = (await admin.post('/api/v1/backups')).json.name as string;
|
||||
|
||||
// Things go wrong: the import is undone and a budget is wiped.
|
||||
const imports = (await admin.get('/api/v1/imports')).json as { id: number }[];
|
||||
await admin.del(`/api/v1/imports/${imports[0]!.id}`);
|
||||
await admin.put('/api/v1/budgets', { categoryId: before.budgets.items[0].categoryId, year: 2026, month: 1, amountCents: 0, scope: 'year' });
|
||||
expect((await snapshot()).total).toBe(0);
|
||||
|
||||
const check = await admin.get(`/api/v1/backups/${backup}/check`);
|
||||
expect(check.json.counts).toMatchObject({ transactions: before.total, accounts: 1, people: 1 });
|
||||
|
||||
const restored = await admin.post(`/api/v1/backups/${backup}/restore`);
|
||||
expect(restored.status).toBe(200);
|
||||
expect(restored.json.safetyCopy).toMatch(/^spendlight-pre-restore-/);
|
||||
expect(await snapshot()).toEqual(before);
|
||||
|
||||
// The app keeps working on the restored data, and the restore can be undone.
|
||||
expect((await admin.post('/api/v1/subscriptions/scan')).status).toBe(200);
|
||||
const undo = await admin.post(`/api/v1/backups/${restored.json.safetyCopy}/restore`);
|
||||
expect(undo.status).toBe(200);
|
||||
expect((await snapshot()).total).toBe(0);
|
||||
expect(listBackups(t.ctx.config.backupDir).filter((b) => b.kind === 'pre-restore')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps whoever restored signed in, and leaves only whole files in the backup folder', async () => {
|
||||
const backup = (await admin.post('/api/v1/backups')).json.name as string;
|
||||
// A sign-in newer than the backup.
|
||||
const { Client } = await import('./helpers');
|
||||
const fresh = new Client(t);
|
||||
await fresh.post('/api/v1/auth/login', { username: 'Chris', password: 'correct-horse' });
|
||||
await fresh.get(`/api/v1/backups/${backup}/check`);
|
||||
expect((await fresh.post(`/api/v1/backups/${backup}/restore`)).status).toBe(200);
|
||||
expect((await fresh.get('/api/v1/me')).status).toBe(200);
|
||||
const names = (await import('node:fs')).readdirSync(t.ctx.config.backupDir);
|
||||
expect(names.every((n) => /^spendlight-[a-z-]+-\d{8}-\d{6}\.db$/.test(n))).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an uploaded backup, such as one from another machine', async () => {
|
||||
const before = await snapshot();
|
||||
const backup = (await admin.post('/api/v1/backups')).json.name as string;
|
||||
const imports = (await admin.get('/api/v1/imports')).json as { id: number }[];
|
||||
await admin.del(`/api/v1/imports/${imports[0]!.id}`);
|
||||
|
||||
const form = new FormData();
|
||||
const bytes = (await import('node:fs')).readFileSync(join(t.ctx.config.backupDir, backup));
|
||||
form.append('file', new File([bytes], backup));
|
||||
const res = await t.app.request('http://localhost/api/v1/backups/restore-upload', { method: 'POST', body: form, headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it('refuses files that aren’t a Spendlight backup', async () => {
|
||||
const dir = t.ctx.config.backupDir;
|
||||
const upload = async (name: string, data: Buffer | string) => {
|
||||
const form = new FormData();
|
||||
form.append('file', new File([data], name));
|
||||
const res = await t.app.request('http://localhost/api/v1/backups/restore-upload', { method: 'POST', body: form, headers: { Cookie: `sl_session=${admin.cookie('sl_session')}` } });
|
||||
return { status: res.status, json: (await res.json()) as { error: { code: string } } };
|
||||
};
|
||||
expect((await upload('notes.db', 'just some text')).json.error.code).toBe('not_sqlite');
|
||||
|
||||
// A SQLite file from some other app.
|
||||
const other = join(dir, 'other.db');
|
||||
const db = new Database(other);
|
||||
db.exec('CREATE TABLE things (id INTEGER)');
|
||||
db.close();
|
||||
expect((await upload('other.db', (await import('node:fs')).readFileSync(other))).json.error.code).toBe('not_spendlight');
|
||||
|
||||
// A Spendlight backup from a newer version, with a database upgrade this one doesn't know.
|
||||
const backup = (await admin.post('/api/v1/backups')).json.name as string;
|
||||
const newer = new Database(join(dir, backup));
|
||||
newer.prepare('INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)').run('future', Date.now());
|
||||
newer.close();
|
||||
const tooNew = await admin.post(`/api/v1/backups/${backup}/restore`);
|
||||
expect(tooNew.json.error.code).toBe('too_new');
|
||||
|
||||
// Nothing changed, and no safety copies were needed.
|
||||
expect(listBackups(dir).filter((b) => b.kind === 'pre-restore')).toHaveLength(0);
|
||||
expect((await admin.get('/api/v1/transactions?limit=1')).json.total).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('is for admins only, and only for names in the backup folder', async () => {
|
||||
await admin.post('/api/v1/people', { username: 'helper', displayName: 'Helper', access: 'full', isAdmin: false, password: 'helper-password-1' });
|
||||
const { Client } = await import('./helpers');
|
||||
const helper = new Client(t);
|
||||
await helper.post('/api/v1/auth/login', { username: 'helper', password: 'helper-password-1' });
|
||||
const backup = (await admin.post('/api/v1/backups')).json.name as string;
|
||||
expect((await helper.post(`/api/v1/backups/${backup}/restore`)).status).toBe(403);
|
||||
expect((await admin.post('/api/v1/backups/..%2Fdata%2Fspendlight.db/restore')).status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user