diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index 1342603..d42188a 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -4,6 +4,10 @@ import type { MediaStore } from './MediaStore.js'; export interface TelemetryEmitter { emit(input: | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } } + | { kind: 'trash'; payload: { noteId: string } } + | { kind: 'restore'; payload: { noteId: string } } + | { kind: 'permanent_delete'; payload: { noteId: string } } + | { kind: 'empty_trash'; payload: { count: number } } ): Promise; } @@ -54,7 +58,7 @@ export class CaptureService { rawTextLength: input.text.length, hasMedia: input.images.length > 0 } - }); + }).catch(() => {}); } await this.deps.enqueue(id); this.deps.celebrate(id); @@ -62,7 +66,37 @@ export class CaptureService { } async deleteNote(noteId: string): Promise { - this.repo.delete(noteId); + // v0.2.3 #4: hard delete → soft delete. media 보존 (restore 시 필요). + this.repo.trash(noteId, new Date().toISOString()); + if (this.deps.telemetry) { + await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {}); + } + } + + async restoreNote(noteId: string): Promise { + this.repo.restore(noteId); + if (this.deps.telemetry) { + await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {}); + } + } + + async permanentDeleteNote(noteId: string): Promise { + this.repo.permanentDelete(noteId); await this.store.deleteNoteDirectory(noteId); + if (this.deps.telemetry) { + await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {}); + } + } + + async emptyTrash(): Promise<{ count: number }> { + const { noteIds } = this.repo.emptyTrash(); + for (const id of noteIds) { + try { await this.store.deleteNoteDirectory(id); } + catch { /* best-effort */ } + } + if (this.deps.telemetry) { + await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {}); + } + return { count: noteIds.length }; } } diff --git a/tests/unit/CaptureService.test.ts b/tests/unit/CaptureService.test.ts index f992cc5..fefcd0a 100644 --- a/tests/unit/CaptureService.test.ts +++ b/tests/unit/CaptureService.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { mkdtempSync } from 'node:fs'; +import { mkdtempSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Database from 'better-sqlite3'; @@ -51,11 +51,11 @@ describe('CaptureService', () => { expect(celebrated).toHaveLength(0); }); - it('deleteNote removes db row + media dir', async () => { + 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)).toBeNull(); + expect(repo.findById(noteId)!.deletedAt).not.toBeNull(); }); }); @@ -111,3 +111,100 @@ describe('CaptureService telemetry emit', () => { 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); + }); +});