249 lines
8.7 KiB
TypeScript
249 lines
8.7 KiB
TypeScript
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<void>>;
|
|
}
|
|
|
|
let mockWatcher: MockFSWatcher;
|
|
let capturedWatchPaths: string[] = [];
|
|
let capturedWatchOptions: Record<string, unknown> = {};
|
|
|
|
vi.mock('chokidar', () => ({
|
|
default: {
|
|
watch: (paths: string[], options: Record<string, unknown>) => {
|
|
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<unknown[]>> };
|
|
}>;
|
|
update: MockedFunction<(table: unknown) => {
|
|
set: (values: unknown) => { where: MockedFunction<() => Promise<void>> };
|
|
}>;
|
|
delete: MockedFunction<(table: unknown) => { where: MockedFunction<() => Promise<void>> }>;
|
|
};
|
|
|
|
function makeSelectChain(rows: unknown[]): ReturnType<MockDb['select']> {
|
|
const whereSelect = vi.fn().mockResolvedValue(rows);
|
|
return {
|
|
from: (_table: unknown) => ({ where: whereSelect }),
|
|
};
|
|
}
|
|
|
|
function makeUpdateChain(): ReturnType<MockDb['update']> {
|
|
const whereUpdate = vi.fn().mockResolvedValue(undefined);
|
|
return {
|
|
set: (_values: unknown) => ({ where: whereUpdate }),
|
|
};
|
|
}
|
|
|
|
function makeDeleteChain(): ReturnType<MockDb['delete']> {
|
|
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<void> {
|
|
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();
|
|
});
|
|
});
|
|
});
|