Cut E v0.3.0 에서 ExportNote interface 에 status / statusChangedAt / moveReason / dueDate / dueDateEditedByUser 필드 추가했지만 ImportService.test 의 buildExportNote helper 갱신 누락 → composeFrontmatter 가 undefined moveReason 로 formatScalar 호출 시 null !== undefined 분기 통과 후 .includes throw. helper 에 5 필드 default (active / null / null / null / false) 추가. 회귀 fix.
320 lines
11 KiB
TypeScript
320 lines
11 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,
|
|
// v0.3.0 Cut E — frontmatter round-trip 5 필드 (Cut B status + Cut C dueDate).
|
|
status: 'active',
|
|
statusChangedAt: null,
|
|
moveReason: null,
|
|
dueDate: null,
|
|
dueDateEditedByUser: false,
|
|
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');
|
|
});
|
|
});
|