Files
inkling/tests/unit/AiWorker.test.ts
altair823 0c38fcaf85 feat(ai): AiWorker with sequential queue, 3-attempt backoff
Task 13 of the slice plan. Drives the pending → done/failed
transitions:
- enqueue() pushes a Job and kicks the loop; loadFromDb()
  rehydrates pending_jobs at startup so app restart resumes
  in-flight work.
- drain() exposes a Promise for tests + graceful shutdown.
- Concurrency 1: a single async loop awaits each provider call
  before the next, matching spec §2.2.
- 3-attempt backoff (default [0, 30s, 120s]; tests inject [0,0,0]).
  Each failure logs ai.retry, increments pending_jobs.attempts,
  and on the final attempt calls markAiFailed and emits onUpdate.
- emit() pushes the freshly-hydrated note to onUpdate (used by
  Task 30 to fan out IPC note:updated events).

Verification: `npx vitest run tests/unit/AiWorker.test.ts`
4 passed. Suite total 41 / 41.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:10:15 +09:00

85 lines
2.9 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
import { AiWorker } from '@main/ai/AiWorker.js';
import type { InferenceProvider } from '@main/ai/InferenceProvider.js';
import type { AiResponse } from '@main/ai/schema.js';
function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProvider {
return {
name: 'mock',
generate: vi.fn(async (): Promise<AiResponse> => ({
title: '제목', summary: 'a\nb\nc', tags: ['tag']
})),
healthCheck: vi.fn(async () => ({ ok: true })),
...overrides
} as InferenceProvider;
}
describe('AiWorker', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('processes a pending job and marks done', async () => {
const { id } = repo.create({ rawText: 'x' });
const updates: string[] = [];
const w = new AiWorker(repo, makeProvider(), {
backoffsMs: [0, 0, 0],
onUpdate: (note) => updates.push(note.aiStatus)
});
await w.enqueue(id);
await w.drain();
expect(repo.findById(id)?.aiStatus).toBe('done');
expect(updates).toContain('done');
});
it('retries 3 times then marks failed', async () => {
const { id } = repo.create({ rawText: 'x' });
const provider = makeProvider({
generate: vi.fn(async () => { throw new Error('boom'); })
});
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
await w.enqueue(id);
await w.drain();
const note = repo.findById(id)!;
expect(note.aiStatus).toBe('failed');
expect(note.aiError).toContain('boom');
expect(provider.generate).toHaveBeenCalledTimes(3);
});
it('loadFromDb re-queues all pending', async () => {
const a = repo.create({ rawText: 'a' }).id;
const b = repo.create({ rawText: 'b' }).id;
const w = new AiWorker(repo, makeProvider(), { backoffsMs: [0, 0, 0] });
await w.loadFromDb();
await w.drain();
expect(repo.findById(a)?.aiStatus).toBe('done');
expect(repo.findById(b)?.aiStatus).toBe('done');
});
it('processes sequentially (concurrency 1)', async () => {
const ids = [repo.create({ rawText: 'a' }).id, repo.create({ rawText: 'b' }).id];
let running = 0;
let max = 0;
const provider = makeProvider({
generate: vi.fn(async () => {
running++; max = Math.max(max, running);
await new Promise((r) => setTimeout(r, 10));
running--;
return { title: '제목', summary: 'a\nb\nc', tags: [] };
})
});
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
for (const id of ids) await w.enqueue(id);
await w.drain();
expect(max).toBe(1);
});
});