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([]); }); });