diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts new file mode 100644 index 0000000..dc78465 --- /dev/null +++ b/src/main/services/CaptureService.ts @@ -0,0 +1,51 @@ +import type { NoteRepository } from '../repository/NoteRepository.js'; +import type { MediaStore } from './MediaStore.js'; + +export interface CaptureDeps { + enqueue: (noteId: string) => Promise; + celebrate: (noteId: string) => void; +} + +export interface SubmitInput { + text: string; + images: ArrayBuffer[]; +} + +export class CaptureService { + constructor( + private repo: NoteRepository, + private store: MediaStore, + private deps: CaptureDeps + ) {} + + async submit(input: SubmitInput): Promise<{ noteId: string }> { + const trimmed = input.text.trim(); + if (trimmed.length === 0 && input.images.length === 0) { + throw new Error('empty submission'); + } + const { id } = this.repo.create({ rawText: input.text }); + if (input.images.length > 0) { + const rows = []; + for (const img of input.images) { + const buf = Buffer.from(img); + const saved = await this.store.saveImage(id, buf, 'image/png'); + rows.push({ + noteId: id, + kind: 'image' as const, + relPath: saved.relPath, + mime: saved.mime, + bytes: saved.bytes + }); + } + this.repo.insertMedia(rows); + } + await this.deps.enqueue(id); + this.deps.celebrate(id); + return { noteId: id }; + } + + async deleteNote(noteId: string): Promise { + this.repo.delete(noteId); + await this.store.deleteNoteDirectory(noteId); + } +} diff --git a/tests/unit/CaptureService.test.ts b/tests/unit/CaptureService.test.ts new file mode 100644 index 0000000..52fd902 --- /dev/null +++ b/tests/unit/CaptureService.test.ts @@ -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(); + }); +});