Files
inkling/tests/unit/CaptureService.test.ts
2026-05-01 21:16:26 +09:00

211 lines
7.8 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtempSync, existsSync } 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 soft-deletes (sets deletedAt, preserves row)', 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)!.deletedAt).not.toBeNull();
});
});
describe('CaptureService telemetry emit', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let events: Array<{ kind: string; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }>;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
store = new MediaStore(tmp);
events = [];
});
it('emits capture event with noteId/rawTextLength/hasMedia', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev as typeof events[number]); } }
});
await svc.submit({ text: '안녕하세요', images: [] });
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('capture');
expect(events[0]!.payload.rawTextLength).toBe('안녕하세요'.length);
expect(events[0]!.payload.hasMedia).toBe(false);
expect(typeof events[0]!.payload.noteId).toBe('string');
});
it('emits hasMedia=true when images present', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev as typeof events[number]); } }
});
const img = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]).buffer;
await svc.submit({ text: '이미지 메모', images: [img] });
expect(events).toHaveLength(1);
expect(events[0]!.payload.hasMedia).toBe(true);
});
it('does NOT emit when telemetry dep absent (backward compat)', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {}
});
const result = await svc.submit({ text: 'no telem', images: [] });
expect(typeof result.noteId).toBe('string');
expect(events).toHaveLength(0); // events array stays empty since no telemetry was wired
});
});
describe('CaptureService trash flow (v0.2.3 #4)', () => {
let db: Database.Database;
let repo: NoteRepository;
let store: MediaStore;
let tmp: string;
let events: Array<{ kind: string; payload: any }>;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
tmp = mkdtempSync(join(tmpdir(), 'inkling-trash-'));
store = new MediaStore(tmp);
events = [];
});
it('deleteNote sets deleted_at and emits trash event (no media cleanup)', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
events.length = 0; // clear capture event
await svc.deleteNote(noteId);
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('trash');
expect(events[0]!.payload.noteId).toBe(noteId);
// media 디렉터리 보존 확인 (restore 시 필요)
expect(existsSync(join(tmp, 'media', noteId))).toBe(true);
});
it('restoreNote clears deleted_at and emits restore event', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [] });
events.length = 0;
await svc.deleteNote(noteId);
events.length = 0;
await svc.restoreNote(noteId);
expect(repo.findById(noteId)!.deletedAt).toBeNull();
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('restore');
});
it('permanentDeleteNote hard-deletes + cleans media + emits permanent_delete', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
events.length = 0;
await svc.permanentDeleteNote(noteId);
expect(repo.findById(noteId)).toBeNull();
expect(existsSync(join(tmp, 'media', noteId))).toBe(false);
expect(events).toHaveLength(1);
expect(events[0]!.kind).toBe('permanent_delete');
});
it('emptyTrash deletes all trashed + cleans each media + emits empty_trash with count', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const a = (await svc.submit({ text: 'a', images: [new ArrayBuffer(8)] })).noteId;
const b = (await svc.submit({ text: 'b', images: [new ArrayBuffer(8)] })).noteId;
await svc.submit({ text: 'c (active)', images: [] });
await svc.deleteNote(a);
await svc.deleteNote(b);
events.length = 0;
const r = await svc.emptyTrash();
expect(r.count).toBe(2);
expect(repo.findById(a)).toBeNull();
expect(repo.findById(b)).toBeNull();
expect(existsSync(join(tmp, 'media', a))).toBe(false);
expect(existsSync(join(tmp, 'media', b))).toBe(false);
const empty = events.find((e) => e.kind === 'empty_trash')!;
expect(empty.payload.count).toBe(2);
});
it('emptyTrash returns count=0 when trash empty', async () => {
const svc = new CaptureService(repo, store, {
enqueue: async () => {},
celebrate: () => {},
telemetry: { emit: async (ev) => { events.push(ev); } }
});
const r = await svc.emptyTrash();
expect(r.count).toBe(0);
});
});