feat(capture): CaptureService with enqueue + celebrate hooks

Task 14 of the slice plan. Orchestrates a Quick Capture submit:
trims/validates input, creates the note row + pending_jobs row
in one repo.create transaction, persists each pasted PNG via
MediaStore (relPath stored in media table), then awaits the
injected enqueue() (AiWorker.enqueue at runtime) and fires
celebrate() (NotificationService.celebrate). deleteNote drops
the db row (cascading note_tags / media / pending_jobs) and
removes the on-disk media directory afterwards.

Verification: `npx vitest run tests/unit/CaptureService.test.ts`
4 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-25 12:11:43 +09:00
parent 0c38fcaf85
commit 38a54a83b8
2 changed files with 111 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { runMigrations } from '@main/db/migrations/index.js';
import { NoteRepository } from '@main/repository/NoteRepository.js';
import { MediaStore } from '@main/services/MediaStore.js';
import { CaptureService } from '@main/services/CaptureService.js';
describe('CaptureService', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let enqueued: string[];
let celebrated: string[];
let svc: CaptureService;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
store = new MediaStore(tmp);
enqueued = [];
celebrated = [];
svc = new CaptureService(repo, store, {
enqueue: async (id) => { enqueued.push(id); },
celebrate: (id) => { celebrated.push(id); }
});
});
it('persists text-only and triggers enqueue + celebrate', async () => {
const { noteId } = await svc.submit({ text: '안녕', images: [] });
expect(repo.findById(noteId)?.rawText).toBe('안녕');
expect(enqueued).toEqual([noteId]);
expect(celebrated).toEqual([noteId]);
});
it('saves images under media/{noteId}/', async () => {
const img = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]).buffer;
const { noteId } = await svc.submit({ text: 'x', images: [img] });
const note = repo.findById(noteId)!;
expect(note.media).toHaveLength(1);
expect(note.media[0]!.relPath.startsWith(`media/${noteId}/`)).toBe(true);
});
it('rejects empty submit', async () => {
await expect(svc.submit({ text: ' ', images: [] })).rejects.toThrow(/empty/i);
expect(celebrated).toHaveLength(0);
});
it('deleteNote removes db row + media dir', async () => {
const img = new Uint8Array([0, 1, 2, 3]).buffer;
const { noteId } = await svc.submit({ text: 't', images: [img] });
await svc.deleteNote(noteId);
expect(repo.findById(noteId)).toBeNull();
});
});