Files
minabot/src/ops/backups.test.ts

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 }); }
});