final code review 발견: F5 import 후 first user edit 시 import 시점 본문이 note_revisions 에 없어 history 에서 사라지는 회귀. importNote transaction 안 INSERT 추가 (createdAt = edited_at). 부수 작업: ImportNoteInput / importNote 의 "raw_text invariant guard" 주석을 v0.2.10 의 'fork-on-id-collision (sync determinism)' 정확한 의미로 갱신. 테스트 +2 — insert path / fork path 모두 capture revision 검증.
172 lines
6.7 KiB
TypeScript
172 lines
6.7 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { runMigrations } from '../../src/main/db/migrations/index.js';
|
|
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
|
|
|
|
describe('NoteRepository — note_revisions', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
db.pragma('foreign_keys = ON');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
});
|
|
|
|
afterEach(() => { db.close(); });
|
|
|
|
describe('updateRawText', () => {
|
|
it('notes.raw_text 갱신 + 새 user revision INSERT (single transaction)', () => {
|
|
const { id } = repo.create({ rawText: 'v1' });
|
|
const t = new Date('2026-05-10T00:00:00Z');
|
|
repo.updateRawText(id, 'v2', t);
|
|
|
|
const note = db.prepare(`SELECT raw_text, updated_at FROM notes WHERE id=?`).get(id) as {
|
|
raw_text: string;
|
|
updated_at: string;
|
|
};
|
|
expect(note.raw_text).toBe('v2');
|
|
expect(note.updated_at).toBe('2026-05-10T00:00:00.000Z');
|
|
|
|
const revs = db
|
|
.prepare(`SELECT raw_text, edited_by, edited_at FROM note_revisions WHERE note_id=? ORDER BY rev_id ASC`)
|
|
.all(id) as Array<{ raw_text: string; edited_by: string; edited_at: string }>;
|
|
expect(revs).toHaveLength(2); // capture + user
|
|
expect(revs.at(0)!.edited_by).toBe('capture');
|
|
expect(revs.at(0)!.raw_text).toBe('v1');
|
|
expect(revs.at(1)!.edited_by).toBe('user');
|
|
expect(revs.at(1)!.raw_text).toBe('v2');
|
|
expect(revs.at(1)!.edited_at).toBe('2026-05-10T00:00:00.000Z');
|
|
});
|
|
|
|
it('atomic: 두 번 호출 시 두 revision 모두 누적 (chain history)', () => {
|
|
const { id } = repo.create({ rawText: 'v1' });
|
|
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
|
|
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
|
|
const revs = db
|
|
.prepare(`SELECT raw_text FROM note_revisions WHERE note_id=? ORDER BY rev_id ASC`)
|
|
.all(id) as Array<{ raw_text: string }>;
|
|
expect(revs.map((r) => r.raw_text)).toEqual(['v1', 'v2', 'v3']);
|
|
});
|
|
});
|
|
|
|
describe('listRevisions', () => {
|
|
it('DESC 순서 + edited_by + camelCase hydrate', () => {
|
|
const { id } = repo.create({ rawText: 'v1' });
|
|
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
|
|
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
|
|
|
|
const revs = repo.listRevisions(id);
|
|
expect(revs).toHaveLength(3);
|
|
expect(revs.at(0)!.rawText).toBe('v3');
|
|
expect(revs.at(0)!.editedBy).toBe('user');
|
|
expect(revs.at(1)!.rawText).toBe('v2');
|
|
expect(revs.at(1)!.editedBy).toBe('user');
|
|
expect(revs.at(2)!.rawText).toBe('v1');
|
|
expect(revs.at(2)!.editedBy).toBe('capture');
|
|
expect(typeof revs.at(0)!.revId).toBe('number');
|
|
expect(revs.at(0)!.noteId).toBe(id);
|
|
expect(revs.at(0)!.editedAt).toBe('2026-05-11T00:00:00.000Z');
|
|
});
|
|
});
|
|
|
|
describe('restoreRevision', () => {
|
|
it('옛 raw_text 를 새 user revision 으로 INSERT + notes.raw_text 갱신', () => {
|
|
const { id } = repo.create({ rawText: 'v1' });
|
|
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
|
|
repo.updateRawText(id, 'v3', new Date('2026-05-11T00:00:00Z'));
|
|
|
|
const revs = repo.listRevisions(id);
|
|
const v1 = revs.find((r) => r.rawText === 'v1');
|
|
expect(v1).toBeDefined();
|
|
|
|
repo.restoreRevision(id, v1!.revId, new Date('2026-05-12T00:00:00Z'));
|
|
|
|
const note = db.prepare(`SELECT raw_text FROM notes WHERE id=?`).get(id) as { raw_text: string };
|
|
expect(note.raw_text).toBe('v1');
|
|
|
|
const after = repo.listRevisions(id);
|
|
expect(after).toHaveLength(4); // v1(capture) + v2 + v3 + v1 restored (user)
|
|
expect(after.at(0)!.rawText).toBe('v1');
|
|
expect(after.at(0)!.editedBy).toBe('user');
|
|
expect(after.at(0)!.editedAt).toBe('2026-05-12T00:00:00.000Z');
|
|
});
|
|
|
|
it('존재하지 않는 revId 는 throw', () => {
|
|
const { id } = repo.create({ rawText: 'v1' });
|
|
expect(() => repo.restoreRevision(id, 999_999, new Date())).toThrow(/not found/);
|
|
});
|
|
});
|
|
|
|
describe('AiWorker source 회귀', () => {
|
|
it('updateRawText 후 findById 가 latest raw_text 반환 (옛 revision 미노출)', () => {
|
|
const { id } = repo.create({ rawText: 'v1' });
|
|
repo.updateRawText(id, 'v2 corrected', new Date('2026-05-10T00:00:00Z'));
|
|
const note = repo.findById(id);
|
|
expect(note?.rawText).toBe('v2 corrected');
|
|
});
|
|
});
|
|
|
|
describe('importNote — capture revision 생성 (final review 보강)', () => {
|
|
it('insert path: imported note 가 capture revision (createdAt = edited_at) 을 함께 갖는다', () => {
|
|
const r = repo.importNote({
|
|
id: '00000000-0000-0000-0000-000000000001',
|
|
rawText: 'imported text',
|
|
createdAt: '2026-04-01T00:00:00Z',
|
|
updatedAt: '2026-04-02T00:00:00Z',
|
|
aiTitle: 't',
|
|
aiSummary: 's',
|
|
titleEditedByUser: false,
|
|
summaryEditedByUser: false,
|
|
aiProvider: 'p',
|
|
aiGeneratedAt: '2026-04-02T00:00:00Z',
|
|
userIntent: null,
|
|
intentPromptedAt: null,
|
|
tags: []
|
|
});
|
|
expect(r.status).toBe('inserted');
|
|
|
|
const revs = repo.listRevisions(r.id);
|
|
expect(revs).toHaveLength(1);
|
|
expect(revs[0]!.rawText).toBe('imported text');
|
|
expect(revs[0]!.editedBy).toBe('capture');
|
|
expect(revs[0]!.editedAt).toBe('2026-04-01T00:00:00Z');
|
|
});
|
|
|
|
it('fork path: id 충돌 시 fresh uuidv7 + 새 capture revision (옛 노트 revision 보존)', () => {
|
|
// 기존 노트 (capture 'v1' revision 자동 생성됨)
|
|
const existing = repo.create({ rawText: 'v1' });
|
|
// 동일 id 로 다른 raw_text 를 import → fork
|
|
const r = repo.importNote({
|
|
id: existing.id,
|
|
rawText: 'imported v2',
|
|
createdAt: '2026-04-01T00:00:00Z',
|
|
updatedAt: '2026-04-02T00:00:00Z',
|
|
aiTitle: null,
|
|
aiSummary: null,
|
|
titleEditedByUser: false,
|
|
summaryEditedByUser: false,
|
|
aiProvider: null,
|
|
aiGeneratedAt: null,
|
|
userIntent: null,
|
|
intentPromptedAt: null,
|
|
tags: []
|
|
});
|
|
expect(r.status).toBe('forked');
|
|
expect(r.id).not.toBe(existing.id);
|
|
|
|
// forked 노트에 capture revision
|
|
const forkRevs = repo.listRevisions(r.id);
|
|
expect(forkRevs).toHaveLength(1);
|
|
expect(forkRevs[0]!.rawText).toBe('imported v2');
|
|
expect(forkRevs[0]!.editedBy).toBe('capture');
|
|
|
|
// 기존 노트의 revision 은 그대로 보존
|
|
const existingRevs = repo.listRevisions(existing.id);
|
|
expect(existingRevs).toHaveLength(1);
|
|
expect(existingRevs[0]!.rawText).toBe('v1');
|
|
});
|
|
});
|
|
});
|