import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import Database from 'better-sqlite3'; import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { runMigrations } from '@main/db/migrations/index.js'; import { NoteRepository } from '@main/repository/NoteRepository.js'; import { MediaStore } from '@main/services/MediaStore.js'; import { ImportService } from '@main/services/ImportService.js'; import { composeMarkdown, composeFilename, type ExportNote } from '@main/services/exportFormat.js'; function buildExportNote(overrides: Partial = {}): ExportNote { return { id: '014a3b9c-1234-7890-abcd-000000000001', createdAt: '2026-04-25T14:23:11.000Z', updatedAt: '2026-04-25T14:24:02.000Z', rawText: '회고 메모 본문', aiTitle: '주간 회고 PR 리뷰', aiSummary: '회고 양식 통일을 위한 메모.', titleEditedByUser: false, summaryEditedByUser: false, aiProvider: 'local-ollama/gemma4:e4b', aiGeneratedAt: '2026-04-25T14:23:34.000Z', userIntent: null, intentPromptedAt: null, tags: [{ name: 'pr', source: 'ai' }], media: [], ...overrides }; } function writeNote(sourceDir: string, note: ExportNote): string { const filename = composeFilename({ id: note.id, createdAt: note.createdAt, aiTitle: note.aiTitle }); const md = composeMarkdown(note); mkdirSync(join(sourceDir, 'notes'), { recursive: true }); const abs = join(sourceDir, 'notes', filename); writeFileSync(abs, md, 'utf8'); return abs; } function writeMedia(sourceDir: string, rel: string, bytes: Buffer): void { const dirIdx = rel.lastIndexOf('/'); const subdir = dirIdx === -1 ? '' : rel.slice(0, dirIdx); if (subdir) { mkdirSync(join(sourceDir, subdir), { recursive: true }); } writeFileSync(join(sourceDir, rel), bytes); } describe('ImportService', () => { let tmpRoot: string; let sourceDir: string; let profileDir: string; let db: Database.Database; let repo: NoteRepository; let mediaStore: MediaStore; let svc: ImportService; beforeEach(() => { tmpRoot = mkdtempSync(join(tmpdir(), 'inkling-import-')); sourceDir = join(tmpRoot, 'src'); profileDir = join(tmpRoot, 'profile'); mkdirSync(sourceDir, { recursive: true }); mkdirSync(join(profileDir, 'media'), { recursive: true }); db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); mediaStore = new MediaStore(profileDir); svc = new ImportService(repo, mediaStore); }); afterEach(() => { db.close(); rmSync(tmpRoot, { recursive: true, force: true }); }); it('preview() of empty notes/ directory → all zeros', async () => { mkdirSync(join(sourceDir, 'notes'), { recursive: true }); const plan = await svc.preview(sourceDir); expect(plan).toEqual({ total: 0, newCount: 0, unchangedCount: 0, forkedCount: 0, mediaCount: 0 }); }); it('preview() of single new note → newCount=1', async () => { writeNote(sourceDir, buildExportNote()); const plan = await svc.preview(sourceDir); expect(plan.total).toBe(1); expect(plan.newCount).toBe(1); expect(plan.unchangedCount).toBe(0); expect(plan.forkedCount).toBe(0); }); it('run() inserts a new note with tags + provenance', async () => { writeNote( sourceDir, buildExportNote({ tags: [ { name: 'pr', source: 'ai' }, { name: 'review', source: 'user' } ], titleEditedByUser: true }) ); const r = await svc.run(sourceDir); expect(r.newCount).toBe(1); expect(r.unchangedCount).toBe(0); expect(r.forkedCount).toBe(0); const note = repo.findById('014a3b9c-1234-7890-abcd-000000000001'); expect(note).not.toBeNull(); expect(note!.aiTitle).toBe('주간 회고 PR 리뷰'); expect(note!.aiStatus).toBe('done'); expect(note!.titleEditedByUser).toBe(true); expect(note!.aiProvider).toBe('local-ollama/gemma4:e4b'); const tagNames = note!.tags.map((t) => `${t.name}:${t.source}`).sort(); expect(tagNames).toEqual(['pr:ai', 'review:user']); }); it('run() with id collision + identical raw_text → status=skipped, no extra row', async () => { // Pre-seed DB. repo.importNote({ id: '014a3b9c-1234-7890-abcd-000000000001', rawText: '회고 메모 본문', createdAt: '2026-04-25T14:23:11.000Z', updatedAt: '2026-04-25T14:24:02.000Z', aiTitle: '기존 제목', aiSummary: null, titleEditedByUser: false, summaryEditedByUser: false, aiProvider: null, aiGeneratedAt: null, userIntent: null, intentPromptedAt: null, tags: [] }); writeNote(sourceDir, buildExportNote()); // same id, same rawText const r = await svc.run(sourceDir); expect(r.unchangedCount).toBe(1); expect(r.newCount).toBe(0); expect(r.forkedCount).toBe(0); const allRows = db.prepare('SELECT id FROM notes').all(); expect(allRows.length).toBe(1); // Original title preserved (skip means no overwrite). const note = repo.findById('014a3b9c-1234-7890-abcd-000000000001'); expect(note!.aiTitle).toBe('기존 제목'); }); it('run() with id collision + different raw_text → forked, new id, original untouched', async () => { // Pre-seed DB with raw_text "OLD". repo.importNote({ id: '014a3b9c-1234-7890-abcd-000000000001', rawText: 'OLD body', createdAt: '2026-04-25T14:23:11.000Z', updatedAt: '2026-04-25T14:24:02.000Z', aiTitle: '기존', aiSummary: null, titleEditedByUser: false, summaryEditedByUser: false, aiProvider: null, aiGeneratedAt: null, userIntent: null, intentPromptedAt: null, tags: [] }); // Export note with same id, different rawText. writeNote(sourceDir, buildExportNote({ rawText: 'NEW body' })); const r = await svc.run(sourceDir); expect(r.forkedCount).toBe(1); expect(r.newCount).toBe(0); // Two rows now. const allRows = db.prepare('SELECT id, raw_text FROM notes ORDER BY raw_text').all() as Array<{ id: string; raw_text: string; }>; expect(allRows.length).toBe(2); expect(allRows.map((r) => r.raw_text)).toEqual(['NEW body', 'OLD body']); // Original id still has OLD body (raw_text invariant). const original = repo.findById('014a3b9c-1234-7890-abcd-000000000001'); expect(original!.rawText).toBe('OLD body'); // Mapping records the rename. expect(r.finalNoteIds.get('014a3b9c-1234-7890-abcd-000000000001')).not.toBe( '014a3b9c-1234-7890-abcd-000000000001' ); }); it('run() copies media file to profileDir + inserts media row', async () => { const note = buildExportNote({ media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 7 }] }); writeNote(sourceDir, note); writeMedia(sourceDir, 'media/014a3b9c__1.png', Buffer.from('PNGDATA')); const r = await svc.run(sourceDir); expect(r.mediaCount).toBe(1); const finalId = r.finalNoteIds.get(note.id)!; const expectedAbs = join(profileDir, 'media', finalId, '1.png'); expect(existsSync(expectedAbs)).toBe(true); expect(readFileSync(expectedAbs).toString()).toBe('PNGDATA'); const dbNote = repo.findById(finalId); expect(dbNote!.media.length).toBe(1); expect(dbNote!.media[0]!.relPath).toBe(`media/${finalId}/1.png`); expect(dbNote!.media[0]!.mime).toBe('image/png'); expect(dbNote!.media[0]!.bytes).toBe(7); }); }); describe('ImportService — deletedAt preservation (v0.2.3 #4)', () => { it('id-collide skip: source deleted_at IS NOT NULL → dest deleted_at 갱신', () => { const db = new Database(':memory:'); runMigrations(db); const repo = new NoteRepository(db); const { id } = repo.create({ rawText: 'identical' }); const r = repo.importNote({ id, rawText: 'identical', createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', aiTitle: null, aiSummary: null, titleEditedByUser: false, summaryEditedByUser: false, aiProvider: null, aiGeneratedAt: null, userIntent: null, intentPromptedAt: null, tags: [], deletedAt: '2026-05-01T12:00:00.000Z' }); expect(r.status).toBe('skipped'); expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); }); it('id-collide skip: source deleted_at NULL + dest IS NOT NULL → dest 유지', () => { const db = new Database(':memory:'); runMigrations(db); const repo = new NoteRepository(db); const { id } = repo.create({ rawText: 'identical' }); repo.trash(id, '2026-05-01T00:00:00.000Z'); repo.importNote({ id, rawText: 'identical', createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', aiTitle: null, aiSummary: null, titleEditedByUser: false, summaryEditedByUser: false, aiProvider: null, aiGeneratedAt: null, userIntent: null, intentPromptedAt: null, tags: [], deletedAt: null }); expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T00:00:00.000Z'); }); it('id-new insert: source deletedAt 보존', () => { const db = new Database(':memory:'); runMigrations(db); const repo = new NoteRepository(db); const r = repo.importNote({ id: 'fresh-id', rawText: 'fresh', createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', aiTitle: null, aiSummary: null, titleEditedByUser: false, summaryEditedByUser: false, aiProvider: null, aiGeneratedAt: null, userIntent: null, intentPromptedAt: null, tags: [], deletedAt: '2026-05-01T12:00:00.000Z' }); expect(r.status).toBe('inserted'); expect(repo.findById('fresh-id')!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); }); it('id-collide forked: deletedAt 도 fork 노트에 보존', () => { const db = new Database(':memory:'); runMigrations(db); const repo = new NoteRepository(db); const { id } = repo.create({ rawText: 'original' }); const r = repo.importNote({ id, rawText: 'different', createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', aiTitle: null, aiSummary: null, titleEditedByUser: false, summaryEditedByUser: false, aiProvider: null, aiGeneratedAt: null, userIntent: null, intentPromptedAt: null, tags: [], deletedAt: '2026-05-01T12:00:00.000Z' }); expect(r.status).toBe('forked'); expect(r.id).not.toBe(id); expect(repo.findById(r.id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); }); });