150 lines
8.5 KiB
TypeScript
150 lines
8.5 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { Database } from "bun:sqlite";
|
|
import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { fixture } from "../habits/test-fixture";
|
|
import { backupConfig, createBackup, latestBackupTime, restoreBackup, startBackups } from "./backups";
|
|
|
|
test("WAL snapshots restore committed progress, revoke sessions, replicate, and retain only owned snapshots", async () => {
|
|
const directory = mkdtempSync(join(tmpdir(), "minabot-backup-"));
|
|
const f = fixture(join(directory, "live.sqlite"));
|
|
try {
|
|
f.sqlite.exec("PRAGMA journal_mode=WAL");
|
|
const config = backupConfig(join(directory, "live.sqlite"), { BACKUP_RETAIN: "2", BACKUP_REPLICA_DIR: "replica" });
|
|
mkdirSync(config.replica!);
|
|
const habit = await f.json("/habits", "POST", { name: "Water", method: "count", target: 8, unit: "cups" }, 201);
|
|
await f.json(`/habits/${habit.id}/days/2026-09-04/progress`, "PUT", { count: 6 });
|
|
f.sqlite.exec("INSERT INTO reminder_settings (user_id, enabled, time, quiet_start, quiet_end, updated_at) VALUES ('alice', 1, '20:00', '22:00', '08:00', 0)");
|
|
const first = createBackup(f.sqlite, config);
|
|
expect(statSync(first).mode & 0o777).toBe(0o600);
|
|
expect(latestBackupTime(config)).toBeGreaterThan(0);
|
|
const destination = join(directory, "restored.sqlite");
|
|
restoreBackup(first, destination);
|
|
const restored = new Database(destination, { readonly: true });
|
|
try {
|
|
expect(restored.query("SELECT count FROM habit_days").get()).toEqual({ count: 6 });
|
|
expect(restored.query("SELECT * FROM sessions").all()).toEqual([]);
|
|
expect(restored.query("SELECT enabled FROM reminder_settings").get()).toEqual({ enabled: 0 });
|
|
expect(restored.query("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" });
|
|
} finally { restored.close(); }
|
|
expect(() => restoreBackup(first, destination)).toThrow("new path");
|
|
writeFileSync(join(config.directory, "keep.txt"), "unrelated");
|
|
createBackup(f.sqlite, config); createBackup(f.sqlite, config);
|
|
expect(readdirSync(config.directory).filter(name => name.endsWith(".sqlite"))).toHaveLength(2);
|
|
expect(readdirSync(config.replica!)).toHaveLength(2);
|
|
expect(readdirSync(config.directory)).toContain("keep.txt");
|
|
const runner = startBackups(f.sqlite, config);
|
|
await runner.ready;
|
|
expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop();
|
|
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test("invalid backups and missing replica mounts fail without overwriting data", () => {
|
|
const directory = mkdtempSync(join(tmpdir(), "minabot-backup-failure-"));
|
|
const f = fixture();
|
|
try {
|
|
const bad = join(directory, "bad.sqlite"); writeFileSync(bad, "not a database");
|
|
expect(() => restoreBackup(bad, join(directory, "target.sqlite"))).toThrow();
|
|
const config = backupConfig(join(directory, "live.sqlite"), { BACKUP_REPLICA_DIR: "missing" });
|
|
expect(() => createBackup(f.sqlite, config)).toThrow();
|
|
expect(latestBackupTime(config)).toBe(0);
|
|
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test("backup and restore CLIs work against a live database without overwriting it", async () => {
|
|
const directory = mkdtempSync(join(tmpdir(), "minabot-backup-cli-"));
|
|
const path = join(directory, "live.sqlite");
|
|
const f = fixture(path);
|
|
try {
|
|
f.sqlite.exec("PRAGMA journal_mode=WAL");
|
|
await f.json('/habits', 'POST', { name: 'CLI recovery', method: 'manual' }, 201);
|
|
const backup = Bun.spawnSync([process.execPath, 'scripts/backup.ts'], {
|
|
env: { ...process.env, DATABASE_PATH: path, BACKUP_DIR: 'backups', BACKUP_REPLICA_DIR: '', BACKUP_RETAIN: '7', BACKUP_INTERVAL_HOURS: '24' },
|
|
});
|
|
expect(backup.exitCode).toBe(0);
|
|
const snapshot = backup.stdout.toString().trim();
|
|
const restored = join(directory, 'recovered.sqlite');
|
|
const result = Bun.spawnSync([process.execPath, 'scripts/restore.ts', snapshot, restored]);
|
|
expect(result.exitCode).toBe(0);
|
|
const check = new Database(restored, { readonly: true });
|
|
try { expect(check.query('SELECT count(*) AS n FROM habits').get()).toEqual({ n: 1 }); } finally { check.close(); }
|
|
expect(f.sqlite.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 });
|
|
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('scheduled backups yield to the event loop, retain WAL data, and verify replicas', async () => {
|
|
const directory = mkdtempSync(join(tmpdir(), 'minabot-async-backup-'));
|
|
const path = join(directory, 'live.sqlite'), f = fixture(path);
|
|
let runner: ReturnType<typeof startBackups> | undefined;
|
|
try {
|
|
f.sqlite.exec('PRAGMA journal_mode=WAL');
|
|
await f.json('/habits', 'POST', { name: 'Async snapshot', method: 'manual' }, 201);
|
|
const config = backupConfig(path, { BACKUP_REPLICA_DIR: 'replica' });
|
|
mkdirSync(config.replica!);
|
|
let yielded = false;
|
|
const timer = new Promise<void>(resolve => setTimeout(() => { yielded = true; resolve(); }, 0));
|
|
runner = startBackups(path, config);
|
|
expect(runner.state.lastSuccess).toBe(0);
|
|
await runner.ready;
|
|
expect(yielded).toBe(true); await timer;
|
|
expect(runner.state.failed).toBe(false);
|
|
expect(runner.state.lastSuccess).toBeGreaterThan(0);
|
|
const snapshots = readdirSync(config.directory).filter(file => file.endsWith('.sqlite'));
|
|
expect(snapshots).toHaveLength(1);
|
|
const check = new Database(join(config.replica!, snapshots[0]!), { readonly: true });
|
|
try { expect(check.query('SELECT count(*) AS n FROM habits').get()).toEqual({ n: 1 }); } finally { check.close(); }
|
|
await runner.tick();
|
|
expect(readdirSync(config.directory).filter(file => file.endsWith('.sqlite'))).toHaveLength(1);
|
|
} finally { runner?.stop(); f.close(); rmSync(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('scheduled backup ticks coalesce, expose failure and recovery, and stop admitting work', async () => {
|
|
const config = backupConfig('/tmp/test-only.sqlite', {});
|
|
let calls = 0, release!: (value: number) => void;
|
|
const run = async () => { calls++; return new Promise<number>(resolve => { release = resolve; }); };
|
|
const runner = startBackups('/tmp/test-only.sqlite', config, run);
|
|
expect(runner.tick()).toBe(runner.ready);
|
|
expect(calls).toBe(1);
|
|
release(123); await runner.ready;
|
|
expect(runner.state.lastSuccess).toBe(123);
|
|
const second = runner.tick(); expect(calls).toBe(2);
|
|
runner.stop(); release(456); await second;
|
|
await runner.tick(); expect(calls).toBe(2);
|
|
const failed = startBackups('/tmp/test-only.sqlite', config, async () => { throw new Error('test failure'); });
|
|
await failed.ready; expect(failed.state.failed).toBe(true); failed.stop();
|
|
const disabled = startBackups('/tmp/test-only.sqlite', { ...config, enabled: false }, run);
|
|
await disabled.ready; expect(calls).toBe(2); disabled.stop();
|
|
});
|
|
|
|
|
|
test('the first scheduled snapshot initializes cold WAL bookkeeping without changing source rows', async () => {
|
|
const directory = mkdtempSync(join(tmpdir(), 'minabot-cold-wal-'));
|
|
const path = join(directory, 'live.sqlite'), f = fixture(path);
|
|
f.sqlite.exec('PRAGMA journal_mode=DELETE'); f.close();
|
|
const db = new Database(path, { readwrite: true, create: false, strict: true });
|
|
let runner: ReturnType<typeof startBackups> | undefined;
|
|
try {
|
|
db.exec('PRAGMA journal_mode=WAL');
|
|
expect(existsSync(`${path}-shm`)).toBe(false);
|
|
runner = startBackups(db, backupConfig(path, {}));
|
|
await runner.ready;
|
|
expect(runner.state.failed).toBe(false);
|
|
expect(runner.state.lastSuccess).toBeGreaterThan(0);
|
|
expect(db.query('SELECT count(*) AS n FROM users').get()).toEqual({ n: 2 });
|
|
expect(db.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 });
|
|
expect(db.query('SELECT total_changes() AS n').get()).toEqual({ n: 0 });
|
|
} finally { runner?.stop(); db.close(); rmSync(directory, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('the scheduled snapshot never creates a missing source database', async () => {
|
|
const directory = mkdtempSync(join(tmpdir(), 'minabot-missing-source-'));
|
|
const path = join(directory, 'missing.sqlite');
|
|
const runner = startBackups(path, backupConfig(path, {}));
|
|
try {
|
|
await runner.ready;
|
|
expect(runner.state.failed).toBe(true);
|
|
expect(existsSync(path)).toBe(false);
|
|
} finally { runner.stop(); rmSync(directory, { recursive: true, force: true }); }
|
|
});
|