import { describe, it, expect, vi, beforeEach, afterEach, type MockedFunction } from 'vitest'; // ── Chokidar mock ──────────────────────────────────────────────────────────── interface MockFSWatcher { on: MockedFunction<(event: string, handler: () => void) => MockFSWatcher>; close: MockedFunction<() => Promise>; } let mockWatcher: MockFSWatcher; let capturedWatchPaths: string[] = []; let capturedWatchOptions: Record = {}; vi.mock('chokidar', () => ({ default: { watch: (paths: string[], options: Record) => { capturedWatchPaths = paths; capturedWatchOptions = options; return mockWatcher; }, }, })); // ── Imports (after mocks) ──────────────────────────────────────────────────── import { NotificationWatcher, type WatchableEngines, } from '../../src/main/engine/NotificationWatcher'; // ── DB mock helpers ───────────────────────────────────────────────────────── type MockDb = { select: MockedFunction<() => { from: (table: unknown) => { where: MockedFunction<() => Promise> }; }>; update: MockedFunction<(table: unknown) => { set: (values: unknown) => { where: MockedFunction<() => Promise> }; }>; delete: MockedFunction<(table: unknown) => { where: MockedFunction<() => Promise> }>; }; function makeSelectChain(rows: unknown[]): ReturnType { const whereSelect = vi.fn().mockResolvedValue(rows); return { from: (_table: unknown) => ({ where: whereSelect }), }; } function makeUpdateChain(): ReturnType { const whereUpdate = vi.fn().mockResolvedValue(undefined); return { set: (_values: unknown) => ({ where: whereUpdate }), }; } function makeDeleteChain(): ReturnType { return { where: vi.fn().mockResolvedValue(undefined) }; } // ── Test suite ─────────────────────────────────────────────────────────────── describe('NotificationWatcher', () => { const DB_PATH = '/home/user/.config/bDS/bds.db'; let db: MockDb; let engines: WatchableEngines; let mockSend: MockedFunction<(channel: string, payload: unknown) => void>; let mainWindow: { webContents: { send: typeof mockSend } }; let watcher: NotificationWatcher; beforeEach(() => { vi.useFakeTimers(); mockWatcher = { on: vi.fn().mockReturnThis(), close: vi.fn().mockResolvedValue(undefined), }; capturedWatchPaths = []; capturedWatchOptions = {}; db = { select: vi.fn().mockReturnValue(makeSelectChain([])), update: vi.fn().mockReturnValue(makeUpdateChain()), delete: vi.fn().mockReturnValue(makeDeleteChain()), }; engines = { post: { invalidate: vi.fn() }, media: { invalidate: vi.fn() }, script: { invalidate: vi.fn() }, template: { invalidate: vi.fn() }, }; mockSend = vi.fn(); mainWindow = { webContents: { send: mockSend } }; watcher = new NotificationWatcher(DB_PATH, db as any, engines, mainWindow as any, 100); }); afterEach(() => { vi.useRealTimers(); }); // ── start() ─────────────────────────────────────────────────────────────── describe('start()', () => { it('watches both db and wal paths', () => { watcher.start(); expect(capturedWatchPaths).toEqual([DB_PATH, `${DB_PATH}-wal`]); }); it('sets persistent:false and ignoreInitial:true', () => { watcher.start(); expect(capturedWatchOptions.persistent).toBe(false); expect(capturedWatchOptions.ignoreInitial).toBe(true); }); it('registers change and add handlers', () => { watcher.start(); const events = mockWatcher.on.mock.calls.map((c) => c[0]); expect(events).toContain('change'); expect(events).toContain('add'); }); it('debounces rapid file-change events', async () => { watcher.start(); const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1]; db.select.mockReturnValue(makeSelectChain([])); changeHandler(); changeHandler(); changeHandler(); expect(db.select).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(100); // Only one process() call despite three change events expect(db.select).toHaveBeenCalledTimes(1); }); }); // ── process() ───────────────────────────────────────────────────────────── describe('process()', () => { async function triggerProcess(rows: unknown[] = []): Promise { db.select.mockReturnValue(makeSelectChain(rows)); watcher.start(); const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1]; changeHandler(); await vi.advanceTimersByTimeAsync(100); } it('queries db_notifications for unprocessed CLI rows', async () => { await triggerProcess([]); expect(db.select).toHaveBeenCalledTimes(1); }); it('calls invalidate on the matching engine for each row', async () => { const rows = [ { id: 1, entity: 'post', entityId: 'p1', action: 'created', fromCli: 1, seenAt: null, createdAt: Date.now() }, { id: 2, entity: 'media', entityId: 'm1', action: 'updated', fromCli: 1, seenAt: null, createdAt: Date.now() }, ]; await triggerProcess(rows); expect(engines.post.invalidate).toHaveBeenCalledWith('p1'); expect(engines.media.invalidate).toHaveBeenCalledWith('m1'); }); it('sends entity:changed IPC event for each row', async () => { const rows = [ { id: 1, entity: 'post', entityId: 'p1', action: 'created', fromCli: 1, seenAt: null, createdAt: Date.now() }, { id: 2, entity: 'script', entityId: 's1', action: 'deleted', fromCli: 1, seenAt: null, createdAt: Date.now() }, ]; await triggerProcess(rows); expect(mockSend).toHaveBeenCalledWith('entity:changed', { entity: 'post', entityId: 'p1', action: 'created', }); expect(mockSend).toHaveBeenCalledWith('entity:changed', { entity: 'script', entityId: 's1', action: 'deleted', }); }); it('stamps seenAt on each processed row', async () => { const rows = [ { id: 42, entity: 'post', entityId: 'p1', action: 'updated', fromCli: 1, seenAt: null, createdAt: Date.now() }, ]; db.select.mockReturnValue(makeSelectChain(rows)); const updateChain = makeUpdateChain(); db.update.mockReturnValue(updateChain); watcher.start(); const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1]; changeHandler(); await vi.advanceTimersByTimeAsync(100); expect(db.update).toHaveBeenCalledTimes(1); }); it('prunes old seen rows (>1h) and old unprocessed rows (>24h)', async () => { await triggerProcess([]); // delete is called twice: once for seenAt > 1h, once for unprocessed > 24h expect(db.delete).toHaveBeenCalledTimes(2); }); it('skips unknown entity types gracefully', async () => { const rows = [ { id: 1, entity: 'unknown_entity', entityId: 'x1', action: 'created', fromCli: 1, seenAt: null, createdAt: Date.now() }, ]; await expect(triggerProcess(rows)).resolves.not.toThrow(); // No IPC send for unknown entities, but the watcher finishes without error }); }); // ── stop() ──────────────────────────────────────────────────────────────── describe('stop()', () => { it('closes the file watcher', () => { watcher.start(); watcher.stop(); expect(mockWatcher.close).toHaveBeenCalled(); }); it('cancels a pending debounce timer', async () => { watcher.start(); const changeHandler = mockWatcher.on.mock.calls.find((c) => c[0] === 'change')![1]; changeHandler(); watcher.stop(); await vi.advanceTimersByTimeAsync(200); // process() must NOT have run after stop expect(db.select).not.toHaveBeenCalled(); }); it('does not throw if called before start()', () => { expect(() => watcher.stop()).not.toThrow(); }); }); });