feat(export): ExportService writing frontmatter tree + media + manifest
ExportService composes pure exportFormat layer + reads from
NoteRepository.listAll (new, asc-ordered) + MediaStore.absolutePath
(new helper). Writes notes/{date-id8-slug.md}, media/{id8__n.ext},
index.jsonl, manifest.json, README.md to user-picked dir.
6 unit tests against tmp dirs + :memory: DB.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
141
tests/unit/ExportService.test.ts
Normal file
141
tests/unit/ExportService.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
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(`}__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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user