Code review I1: wrap snapshot's backup+rename in try/catch, unlink orphan tmp file on failure so the next run does not encounter a confusing 'existing file is not a database' error from sqlite. Code review I2: JSDoc note that snapshot() is not safe for concurrent calls — callers should serialize via runDaily()'s marker. New unit test injects a fake db whose backup() rejects after a partial write, asserts no .tmp / .sqlite remains. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
99 lines
4.0 KiB
TypeScript
99 lines
4.0 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { mkdtempSync, rmSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { runMigrations } from '@main/db/migrations/index.js';
|
|
import { BackupService } from '@main/services/BackupService.js';
|
|
|
|
describe('BackupService.snapshot', () => {
|
|
let dir: string;
|
|
let db: Database.Database;
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), 'inkling-backup-'));
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
db.prepare(
|
|
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
|
|
VALUES (?, ?, 'pending', ?, ?)`
|
|
).run('n1', 'hello', '2026-04-26T00:00:00Z', '2026-04-26T00:00:00Z');
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('writes inkling-YYYY-MM-DD.sqlite (KST date) to backupDir', async () => {
|
|
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z')); // 21:00 KST
|
|
const r = await svc.snapshot();
|
|
expect(r.path).toBe(join(dir, 'inkling-2026-04-26.sqlite'));
|
|
expect(existsSync(r.path)).toBe(true);
|
|
expect(r.bytes).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('uses KST date even when UTC date differs (around midnight)', async () => {
|
|
// 2026-04-26 23:30 UTC = 2026-04-27 08:30 KST
|
|
const svc = new BackupService(db, dir, () => new Date('2026-04-26T23:30:00Z'));
|
|
const r = await svc.snapshot();
|
|
expect(r.path).toBe(join(dir, 'inkling-2026-04-27.sqlite'));
|
|
});
|
|
|
|
it('overwrites same-day backup atomically (no partial files left)', async () => {
|
|
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
|
|
await svc.snapshot();
|
|
await svc.snapshot();
|
|
const files = readdirSync(dir).filter((f) => f.startsWith('inkling-'));
|
|
expect(files).toEqual(['inkling-2026-04-26.sqlite']);
|
|
// No leftover .tmp files
|
|
expect(readdirSync(dir).some((f) => f.endsWith('.tmp'))).toBe(false);
|
|
});
|
|
|
|
it('snapshot file is a valid SQLite DB containing the source row', async () => {
|
|
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
|
|
const r = await svc.snapshot();
|
|
const restored = new Database(r.path, { readonly: true });
|
|
const row = restored.prepare('SELECT id, raw_text FROM notes').get() as
|
|
| { id: string; raw_text: string }
|
|
| undefined;
|
|
expect(row?.id).toBe('n1');
|
|
expect(row?.raw_text).toBe('hello');
|
|
restored.close();
|
|
});
|
|
|
|
it('creates backupDir if it does not exist', async () => {
|
|
const fresh = join(dir, 'nested', 'backups');
|
|
expect(existsSync(fresh)).toBe(false);
|
|
const svc = new BackupService(db, fresh, () => new Date('2026-04-26T12:00:00Z'));
|
|
await svc.snapshot();
|
|
expect(existsSync(fresh)).toBe(true);
|
|
});
|
|
|
|
it('snapshot file is not zero bytes (regression: empty backup)', async () => {
|
|
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
|
|
const r = await svc.snapshot();
|
|
expect(statSync(r.path).size).toBeGreaterThan(100);
|
|
});
|
|
|
|
it('cleans up orphan .tmp file when db.backup() fails', async () => {
|
|
// Inject a fake db whose backup() rejects to simulate disk-full / IO-error.
|
|
const fakeDb = {
|
|
backup: async (tmpPath: string) => {
|
|
// Simulate a partial write before the failure (real failures may leave bytes).
|
|
await import('node:fs/promises').then((fs) =>
|
|
fs.writeFile(tmpPath, 'partial-bytes')
|
|
);
|
|
throw new Error('simulated disk full');
|
|
}
|
|
} as unknown as Database.Database;
|
|
const svc = new BackupService(fakeDb, dir, () => new Date('2026-04-26T12:00:00Z'));
|
|
await expect(svc.snapshot()).rejects.toThrow('simulated disk full');
|
|
// No leftover .tmp file
|
|
const remaining = readdirSync(dir);
|
|
expect(remaining.filter((f) => f.endsWith('.tmp'))).toEqual([]);
|
|
// No final file either (write never completed)
|
|
expect(remaining.filter((f) => f.endsWith('.sqlite'))).toEqual([]);
|
|
});
|
|
});
|