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 v1At = new Date('2026-05-09T00:00:00Z'); const { id } = repo.create({ rawText: 'v1' }, v1At); 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 v1At = new Date('2026-05-09T00:00:00Z'); const { id } = repo.create({ rawText: 'v1' }, v1At); 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 v1At = new Date('2026-05-09T00:00:00Z'); const { id } = repo.create({ rawText: 'v1' }, v1At); 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 v1At = new Date('2026-05-09T00:00:00Z'); const { id } = repo.create({ rawText: 'v1' }, v1At); 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 v1At = new Date('2026-05-09T00:00:00Z'); const { id } = repo.create({ rawText: 'v1' }, v1At); 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'); }); }); });