Files
inkling/tests/unit/ImportService.test.ts
altair823 d76cca68df feat(import): ImportService with conflict policy + media copy
Three-state outcome per note: inserted (new id), skipped (id+rawText
match), forked (id match but rawText differs → new uuidv7 to preserve
raw_text invariant from slice §1.1). Media files copied into
MediaStore convention <profileDir>/media/{noteId}/{n}.{ext} with
new media DB rows.

NoteRepository.importNote handles full provenance: ai_status='done',
ai_provider, ai_generated_at, edited flags, intent fields, tags
with source preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:55:13 +09:00

236 lines
7.5 KiB
TypeScript

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> = {}): 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);
});
});