import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import Database from 'better-sqlite3'; import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } 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 { ExportService } from '@main/services/ExportService.js'; describe('ExportService', () => { let tmpRoot: string; let outDir: string; let profileDir: string; let db: Database.Database; let repo: NoteRepository; let mediaStore: MediaStore; let svc: ExportService; const NOW = () => new Date('2026-04-26T00:00:00.000Z'); beforeEach(() => { tmpRoot = mkdtempSync(join(tmpdir(), 'inkling-export-')); outDir = join(tmpRoot, 'out'); profileDir = join(tmpRoot, 'profile'); mkdirSync(join(profileDir, 'media'), { recursive: true }); db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); mediaStore = new MediaStore(profileDir); svc = new ExportService(repo, mediaStore, NOW); }); afterEach(() => { db.close(); rmSync(tmpRoot, { recursive: true, force: true }); }); it('empty DB exports manifest with note_count=0', async () => { const r = await svc.export(outDir, { includeMedia: true }); expect(r.noteCount).toBe(0); expect(r.mediaCount).toBe(0); const manifest = JSON.parse(readFileSync(join(outDir, 'manifest.json'), 'utf8')); expect(manifest.note_count).toBe(0); expect(manifest.media_count).toBe(0); expect(manifest.inkling_export_version).toBe(1); }); it('single note creates one notes/*.md and one-line index.jsonl', async () => { const { id } = repo.create({ rawText: '회고 메모' }); repo.updateAiResult(id, { title: '주간 회고', summary: '한 줄', tags: ['pr'], provider: 'local-ollama/x' }); const r = await svc.export(outDir, { includeMedia: true }); expect(r.noteCount).toBe(1); const noteFiles = readdirSync(join(outDir, 'notes')); expect(noteFiles.length).toBe(1); expect(noteFiles[0]).toMatch(/^\d{4}-\d{2}-\d{2}-[0-9a-f]{8}-주간-회고\.md$/); const md = readFileSync(join(outDir, 'notes', noteFiles[0]!), 'utf8'); expect(md).toContain('# 주간 회고'); expect(md).toContain('> 한 줄'); expect(md).toContain('회고 메모'); const jsonl = readFileSync(join(outDir, 'index.jsonl'), 'utf8'); const lines = jsonl.trimEnd().split('\n'); expect(lines.length).toBe(1); const obj = JSON.parse(lines[0]!); expect(obj.id).toBe(id); expect(obj.embedding_text).toBe('회고 메모'); }); it('two notes produce two-line jsonl ordered ascending by created_at', async () => { const { id: id1 } = repo.create({ rawText: 'first' }); // tiny delay to ensure created_at differs await new Promise((r) => setTimeout(r, 5)); const { id: id2 } = repo.create({ rawText: 'second' }); const r = await svc.export(outDir, { includeMedia: true }); expect(r.noteCount).toBe(2); const lines = readFileSync(join(outDir, 'index.jsonl'), 'utf8').trimEnd().split('\n'); expect(lines.length).toBe(2); const a = JSON.parse(lines[0]!); const b = JSON.parse(lines[1]!); expect(a.id).toBe(id1); expect(b.id).toBe(id2); }); it('note with image: media file copied with id8__n naming', async () => { const { id } = repo.create({ rawText: 'with image' }); // Create a fake media file in profile + DB row const noteMediaDir = join(profileDir, 'media', id); mkdirSync(noteMediaDir, { recursive: true }); writeFileSync(join(noteMediaDir, 'orig.png'), Buffer.from('PNGDATA')); repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/orig.png`, mime: 'image/png', bytes: 7 }]); const r = await svc.export(outDir, { includeMedia: true }); expect(r.mediaCount).toBe(1); const id8 = id.slice(0, 8); const expectedFlat = join(outDir, 'media', `${id8}__1.png`); expect(existsSync(expectedFlat)).toBe(true); expect(readFileSync(expectedFlat).toString()).toBe('PNGDATA'); }); it('includeMedia=false: no media dir, but frontmatter still references images', async () => { const { id } = repo.create({ rawText: 'with image' }); const noteMediaDir = join(profileDir, 'media', id); mkdirSync(noteMediaDir, { recursive: true }); writeFileSync(join(noteMediaDir, 'orig.png'), Buffer.from('XX')); repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/orig.png`, mime: 'image/png', bytes: 2 }]); const r = await svc.export(outDir, { includeMedia: false }); expect(r.mediaCount).toBe(0); expect(existsSync(join(outDir, 'media'))).toBe(false); const noteFiles = readdirSync(join(outDir, 'notes')); const md = readFileSync(join(outDir, 'notes', noteFiles[0]!), 'utf8'); expect(md).toContain('images:'); expect(md).toContain(`![](media/${id.slice(0, 8)}__1.png)`); }); it('writes README.md mentioning RAG and inkling_export_version', async () => { await svc.export(outDir, { includeMedia: true }); const readme = readFileSync(join(outDir, 'README.md'), 'utf8'); expect(readme).toContain('RAG'); expect(readme).toContain('inkling_export_version'); }); it('does NOT export trashed notes (listAll filter — v0.2.3 #4 회귀 가드)', async () => { const a = repo.create({ rawText: 'active note' }).id; const t = repo.create({ rawText: 'trashed note' }).id; repo.updateAiResult(a, { title: '활성', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null }); repo.updateAiResult(t, { title: '버려짐', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null }); repo.trash(t, '2026-05-01T00:00:00.000Z'); const r = await svc.export(outDir, { includeMedia: false }); expect(r.noteCount).toBe(1); // index.jsonl 도 trash 미포함 const indexPath = join(outDir, 'index.jsonl'); const lines = readFileSync(indexPath, 'utf8').trim().split('\n'); expect(lines).toHaveLength(1); }); });