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); }); }); describe('CaptureService.listExpired (dedup signature)', () => { let db: Database.Database; let repo: NoteRepository; let store: MediaStore; let tmp: string; let calls: Array<{ kind: string; payload: any }>; let svc: CaptureService; function addExpired(id: string, dueDate: string, createdAt: string = '2026-04-30T10:00:00Z'): void { db.prepare( `INSERT INTO notes (id, raw_text, ai_status, due_date, created_at, updated_at) VALUES (?, ?, 'done', ?, ?, ?)` ).run(id, id, dueDate, createdAt, createdAt); } beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-')); store = new MediaStore(tmp); calls = []; svc = new CaptureService(repo, store, { enqueue: async () => {}, celebrate: () => {}, telemetry: { emit: async (input) => { calls.push(input as any); } } }); }); it('emits expired_banner_shown on first call when candidates > 0', async () => { addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z'); addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z'); const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z')); expect(r).toHaveLength(2); expect(calls).toContainEqual( expect.objectContaining({ kind: 'expired_banner_shown', payload: { candidateCount: 2 } }) ); }); it('does NOT re-emit on second call with identical candidate set (dedup)', async () => { addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z'); addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z'); await svc.listExpired(new Date('2026-05-01T12:00:00Z')); await svc.listExpired(new Date('2026-05-01T12:00:00Z')); const showns = calls.filter((c) => c.kind === 'expired_banner_shown'); expect(showns).toHaveLength(1); }); it('re-emits when candidate set changes (count or first-3-ids)', async () => { addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z'); addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z'); await svc.listExpired(new Date('2026-05-01T12:00:00Z')); addExpired('n3', '2026-04-23', '2026-04-30T12:00:00Z'); await svc.listExpired(new Date('2026-05-01T12:00:00Z')); const showns = calls.filter((c) => c.kind === 'expired_banner_shown'); expect(showns).toHaveLength(2); expect(showns[1]!.payload).toMatchObject({ candidateCount: 3 }); }); it('does NOT emit when candidates is empty', async () => { const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z')); expect(r).toEqual([]); expect(calls.filter((c) => c.kind === 'expired_banner_shown')).toEqual([]); }); }); describe('CaptureService.trashExpiredBatch', () => { let db: Database.Database; let repo: NoteRepository; let store: MediaStore; let tmp: string; let calls: Array<{ kind: string; payload: any }>; let svc: CaptureService; function addExpired(id: string, dueDate: string): void { db.prepare( `INSERT INTO notes (id, raw_text, ai_status, due_date, created_at, updated_at) VALUES (?, ?, 'done', ?, ?, ?)` ).run(id, id, dueDate, '2026-04-30T10:00:00Z', '2026-04-30T10:00:00Z'); } beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-')); store = new MediaStore(tmp); calls = []; svc = new CaptureService(repo, store, { enqueue: async () => {}, celebrate: () => {}, telemetry: { emit: async (input) => { calls.push(input as any); } } }); }); it('emits expired_batch_trash with trashedCount + no per-id trash emit', async () => { addExpired('n1', '2026-04-20'); addExpired('n2', '2026-04-22'); const r = await svc.trashExpiredBatch(['n1', 'n2']); expect(r.trashedCount).toBe(2); expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([ expect.objectContaining({ kind: 'expired_batch_trash', payload: { count: 2 } }) ]); expect(calls.filter((c) => c.kind === 'trash')).toEqual([]); }); it('returns trashedCount=0 for empty array (no emit)', async () => { const r = await svc.trashExpiredBatch([]); expect(r.trashedCount).toBe(0); expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([]); }); }); describe('CaptureService.retryAllFailed', () => { let db: Database.Database; let repo: NoteRepository; let store: MediaStore; let tmp: string; let calls: Array<{ kind: string; payload: any }>; let enqueued: string[]; let svc: CaptureService; function makeFailed(rawText: string): string { const { id } = repo.create({ rawText }); db.prepare(`UPDATE notes SET ai_status='failed', ai_error='boom' WHERE id=?`).run(id); db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id); return id; } beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-')); store = new MediaStore(tmp); calls = []; enqueued = []; svc = new CaptureService(repo, store, { enqueue: async (id) => { enqueued.push(id); }, celebrate: () => {}, telemetry: { emit: async (input) => { calls.push(input as any); } } }); }); it('retryAllFailed — enqueue per id + ai_retry_manual emit', async () => { const a = makeFailed('a'); const b = makeFailed('b'); const r = await svc.retryAllFailed(); expect(r.count).toBe(2); expect(enqueued.sort()).toEqual([a, b].sort()); expect(calls).toContainEqual( expect.objectContaining({ kind: 'ai_retry_manual', payload: { failedCount: 2 } }) ); }); it('retryAllFailed empty — count=0, no emit', async () => { const r = await svc.retryAllFailed(); expect(r.count).toBe(0); expect(enqueued).toEqual([]); expect(calls.filter((c) => c.kind === 'ai_retry_manual')).toEqual([]); }); });