import { describe, it, expect, beforeEach } from 'vitest'; import Database from 'better-sqlite3'; import { runMigrations } from '@main/db/migrations/index.js'; import { NoteRepository } from '@main/repository/NoteRepository.js'; function freshDb() { const db = new Database(':memory:'); runMigrations(db); return db; } describe('NoteRepository', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = freshDb(); repo = new NoteRepository(db); }); it('create stores raw_text, defaults edited flags to 0, intent fields NULL, enqueues pending job', () => { const { id } = repo.create({ rawText: '회의 메모' }); const note = repo.findById(id)!; expect(note.rawText).toBe('회의 메모'); expect(note.titleEditedByUser).toBe(false); expect(note.summaryEditedByUser).toBe(false); expect(note.userIntent).toBeNull(); expect(note.intentPromptedAt).toBeNull(); expect(note.aiStatus).toBe('pending'); const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); expect(job).toBeDefined(); }); it('updateAiResult does not overwrite user-edited title/summary', () => { const { id } = repo.create({ rawText: 'x' }); repo.updateAiResult(id, { title: 'AI 제목', summary: 'a\nb\nc', tags: [], provider: 'p' }); repo.updateUserAiFields(id, { title: '내 제목', summary: '내 요약\n둘\n셋' }); repo.updateAiResult(id, { title: 'AI 제목 2', summary: 'x\ny\nz', tags: [], provider: 'p' }); const note = repo.findById(id)!; expect(note.aiTitle).toBe('내 제목'); expect(note.aiSummary).toBe('내 요약\n둘\n셋'); expect(note.titleEditedByUser).toBe(true); expect(note.summaryEditedByUser).toBe(true); }); it('updateAiResult marks done, replaces ai tags, removes pending job', () => { const { id } = repo.create({ rawText: '원문' }); repo.updateAiResult(id, { title: '제목', summary: '1줄\n2줄\n3줄', tags: ['api-timeout', 'meeting'], provider: 'local-ollama/gemma4:e4b' }); const note = repo.findById(id)!; expect(note.aiStatus).toBe('done'); expect(note.tags.map((t) => t.name).sort()).toEqual(['api-timeout', 'meeting']); expect(note.tags.every((t) => t.source === 'ai')).toBe(true); expect(db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id)).toBeUndefined(); }); it('markAiFailed truncates and clears pending job', () => { const { id } = repo.create({ rawText: 'x' }); repo.markAiFailed(id, 'E'.repeat(600)); const note = repo.findById(id)!; expect(note.aiStatus).toBe('failed'); expect(note.aiError?.length).toBe(500); }); it('updateUserAiFields replaces user-sourced tags', () => { const { id } = repo.create({ rawText: 'x' }); repo.updateAiResult(id, { title: 'ai', summary: 'a\nb\nc', tags: ['ai-tag'], provider: 'p' }); repo.updateUserAiFields(id, { tags: ['user-tag'] }); const note = repo.findById(id)!; expect(note.tags).toEqual([{ name: 'user-tag', source: 'user' }]); }); it('setIntent stores user_intent, sets intent_prompted_at first time, preserves on subsequent', () => { const { id } = repo.create({ rawText: 'x' }); repo.setIntent(id, '내일의 나에게'); const a = repo.findById(id)!; expect(a.userIntent).toBe('내일의 나에게'); expect(a.intentPromptedAt).not.toBeNull(); const firstStamp = a.intentPromptedAt!; repo.setIntent(id, '수정'); const b = repo.findById(id)!; expect(b.userIntent).toBe('수정'); expect(b.intentPromptedAt).toBe(firstStamp); }); it('dismissIntent stamps intent_prompted_at without setting user_intent', () => { const { id } = repo.create({ rawText: 'x' }); repo.dismissIntent(id); const note = repo.findById(id)!; expect(note.userIntent).toBeNull(); expect(note.intentPromptedAt).not.toBeNull(); }); it('setIntent truncates to 200 chars', () => { const { id } = repo.create({ rawText: 'x' }); repo.setIntent(id, 'X'.repeat(300)); expect(repo.findById(id)!.userIntent?.length).toBe(200); }); it('delete cascades note_tags, media, pending_jobs', () => { const { id } = repo.create({ rawText: 'x' }); repo.insertMedia([{ noteId: id, kind: 'image', relPath: 'm/x.png', mime: 'image/png', bytes: 10 }]); repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['z'], provider: 'p' }); repo.delete(id); expect(repo.findById(id)).toBeNull(); expect(db.prepare('SELECT COUNT(*) AS c FROM media').get()).toEqual({ c: 0 }); expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags').get()).toEqual({ c: 0 }); }); it('list returns notes in descending created_at', () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; expect(repo.list({ limit: 10 }).map((n) => n.id)).toEqual([b, a]); }); it('getPendingCount counts pending notes', () => { repo.create({ rawText: 'a' }); const { id } = repo.create({ rawText: 'b' }); expect(repo.getPendingCount()).toBe(2); repo.markAiFailed(id, 'err'); expect(repo.getPendingCount()).toBe(1); }); it('incrementJobAttempt bumps attempts and stores last_error', () => { const { id } = repo.create({ rawText: 'x' }); repo.incrementJobAttempt(id, new Date().toISOString(), 'boom'); const row = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id) as any; expect(row.attempts).toBe(1); expect(row.last_error).toBe('boom'); }); it('hydrate returns dueDate=null + dueDateEditedByUser=false on new note', () => { const { id } = repo.create({ rawText: 'x' }); const note = repo.findById(id)!; expect(note.dueDate).toBeNull(); expect(note.dueDateEditedByUser).toBe(false); }); it('updateAiResult writes dueDate when edited flag is 0', () => { const { id } = repo.create({ rawText: 'x' }); repo.updateAiResult(id, { title: 'AI 제목', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: '2026-05-01' }); const note = repo.findById(id)!; expect(note.dueDate).toBe('2026-05-01'); expect(note.dueDateEditedByUser).toBe(false); }); it('updateAiResult does NOT overwrite dueDate when edited flag is 1', () => { const { id } = repo.create({ rawText: 'x' }); repo.updateAiResult(id, { title: 'AI', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: '2026-05-01' }); repo.setDueDate(id, '2026-05-15'); repo.updateAiResult(id, { title: 'AI 2', summary: 'd\ne\nf', tags: [], provider: 'p', dueDate: '2026-05-30' }); const note = repo.findById(id)!; expect(note.dueDate).toBe('2026-05-15'); expect(note.dueDateEditedByUser).toBe(true); }); it('setDueDate sets due_date and edited flag', () => { const { id } = repo.create({ rawText: 'x' }); repo.setDueDate(id, '2026-06-01'); const note = repo.findById(id)!; expect(note.dueDate).toBe('2026-06-01'); expect(note.dueDateEditedByUser).toBe(true); }); it('setDueDate(null) clears due_date but keeps edited flag', () => { const { id } = repo.create({ rawText: 'x' }); repo.setDueDate(id, '2026-06-01'); repo.setDueDate(id, null); const note = repo.findById(id)!; expect(note.dueDate).toBeNull(); expect(note.dueDateEditedByUser).toBe(true); }); it('updateAiResult without dueDate field treats it as null', () => { const { id } = repo.create({ rawText: 'x' }); repo.updateAiResult(id, { title: 'AI', summary: 'a\nb\nc', tags: [], provider: 'p' }); const note = repo.findById(id)!; expect(note.dueDate).toBeNull(); }); it('countToday returns 0 for empty DB', () => { expect(repo.countToday(new Date('2026-04-26T12:00:00Z'))).toBe(0); }); it('countToday counts notes created on the KST date of "now"', () => { // now = 2026-04-26 14:00 KST (= 2026-04-26T05:00:00Z UTC). // a, b: 2026-04-26 KST → counted. // c: 2026-04-25 KST → excluded. db.prepare( `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) VALUES (?, 'x', 'pending', ?, ?)` ).run('a', '2026-04-25T17:00:00Z', '2026-04-25T17:00:00Z'); // 04-26 02:00 KST db.prepare( `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) VALUES (?, 'x', 'pending', ?, ?)` ).run('b', '2026-04-25T18:00:00Z', '2026-04-25T18:00:00Z'); // 04-26 03:00 KST db.prepare( `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) VALUES (?, 'x', 'pending', ?, ?)` ).run('c', '2026-04-25T14:00:00Z', '2026-04-25T14:00:00Z'); // 04-25 23:00 KST expect(repo.countToday(new Date('2026-04-26T05:00:00Z'))).toBe(2); }); it('countToday handles KST midnight crossover', () => { // now = 2026-04-26 14:00 KST. A note at 2026-04-26T23:30Z = 2026-04-27 08:30 KST // belongs to "tomorrow" (KST), so MUST NOT be counted as "today". db.prepare( `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) VALUES (?, 'x', 'pending', ?, ?)` ).run('a', '2026-04-26T23:30:00Z', '2026-04-26T23:30:00Z'); expect(repo.countToday(new Date('2026-04-26T05:00:00Z'))).toBe(0); }); it('countToday default arg uses Date.now()', () => { const n = repo.countToday(); expect(typeof n).toBe('number'); expect(n).toBeGreaterThanOrEqual(0); }); }); describe('NoteRepository.trash', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('sets deleted_at and removes pending_jobs row atomically', () => { const { id } = repo.create({ rawText: 'x' }); expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 1 }); repo.trash(id, '2026-05-01T12:00:00.000Z'); const note = repo.findById(id)!; expect(note.deletedAt).toBe('2026-05-01T12:00:00.000Z'); expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 }); }); it('updates updated_at to deletedAt timestamp', () => { const { id } = repo.create({ rawText: 'x' }); repo.trash(id, '2026-05-01T12:00:00.000Z'); const note = repo.findById(id)!; expect(note.updatedAt).toBe('2026-05-01T12:00:00.000Z'); }); it('is no-op when note does not exist', () => { expect(() => repo.trash('nonexistent', '2026-05-01T12:00:00.000Z')).not.toThrow(); }); }); describe('NoteRepository.restore', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('clears deleted_at on a trashed note', () => { const { id } = repo.create({ rawText: 'x' }); repo.trash(id, '2026-05-01T12:00:00.000Z'); repo.restore(id); const note = repo.findById(id)!; expect(note.deletedAt).toBeNull(); }); it('updates updated_at', () => { const { id } = repo.create({ rawText: 'x' }); repo.trash(id, '2026-05-01T12:00:00.000Z'); const before = repo.findById(id)!.updatedAt; repo.restore(id); const after = repo.findById(id)!.updatedAt; expect(after).not.toBe(before); }); it('is no-op on already-active note', () => { const { id } = repo.create({ rawText: 'x' }); expect(() => repo.restore(id)).not.toThrow(); expect(repo.findById(id)!.deletedAt).toBeNull(); }); }); describe('NoteRepository.permanentDelete', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('removes notes row + cascades note_tags / pending_jobs', () => { const { id } = repo.create({ rawText: 'x' }); repo.updateAiResult(id, { title: 'T', summary: 'a\nb\nc', tags: ['tag-a'], provider: 'p', dueDate: null }); expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 1 }); repo.permanentDelete(id); expect(repo.findById(id)).toBeNull(); expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 0 }); expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 }); }); }); describe('NoteRepository.emptyTrash', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('hard-deletes all trashed notes and returns their ids', () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; const c = repo.create({ rawText: 'c' }).id; repo.trash(a, '2026-05-01T00:00:00.000Z'); repo.trash(c, '2026-05-01T01:00:00.000Z'); const r = repo.emptyTrash(); expect(r.noteIds.sort()).toEqual([a, c].sort()); expect(repo.findById(a)).toBeNull(); expect(repo.findById(b)).not.toBeNull(); expect(repo.findById(c)).toBeNull(); }); it('returns empty array when trash is empty', () => { expect(repo.emptyTrash()).toEqual({ noteIds: [] }); }); }); describe('NoteRepository.listTrashed', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('returns trashed notes ordered by deleted_at DESC', () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; const c = repo.create({ rawText: 'c' }).id; repo.trash(a, '2026-05-01T00:00:00.000Z'); repo.trash(c, '2026-05-01T02:00:00.000Z'); repo.trash(b, '2026-05-01T01:00:00.000Z'); const r = repo.listTrashed({ limit: 50 }); expect(r.map((n) => n.id)).toEqual([c, b, a]); }); it('excludes active notes', () => { repo.create({ rawText: 'active' }); const r = repo.listTrashed({ limit: 50 }); expect(r).toEqual([]); }); it('respects limit', () => { for (let i = 0; i < 5; i++) { const id = repo.create({ rawText: `n${i}` }).id; repo.trash(id, `2026-05-01T0${i}:00:00.000Z`); } const r = repo.listTrashed({ limit: 3 }); expect(r).toHaveLength(3); }); }); describe('Active queries exclude deleted notes', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('list() excludes trashed', () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; repo.trash(a, '2026-05-01T00:00:00.000Z'); const r = repo.list({ limit: 50 }); expect(r.map((n) => n.id)).toEqual([b]); }); it('listAll() excludes trashed', () => { const a = repo.create({ rawText: 'a' }).id; repo.create({ rawText: 'b' }); repo.trash(a, '2026-05-01T00:00:00.000Z'); const r = repo.listAll(); expect(r.map((n) => n.rawText)).toEqual(['b']); }); it('countToday() excludes trashed', () => { const a = repo.create({ rawText: 'a' }).id; repo.create({ rawText: 'b' }); repo.trash(a, new Date().toISOString()); expect(repo.countToday(new Date())).toBe(1); }); it('findById() returns trashed notes (does NOT filter)', () => { const { id } = repo.create({ rawText: 'a' }); repo.trash(id, '2026-05-01T00:00:00.000Z'); const note = repo.findById(id); expect(note).not.toBeNull(); expect(note!.deletedAt).toBe('2026-05-01T00:00:00.000Z'); }); it('getPendingCount() excludes trashed pending notes (drift guard)', () => { const a = repo.create({ rawText: 'a' }).id; // ai_status=pending repo.create({ rawText: 'b' }); // ai_status=pending expect(repo.getPendingCount()).toBe(2); // trash() 가 pending_jobs row 는 정리하지만 notes.ai_status 는 'pending' 그대로. // getPendingCount 가 deleted_at IS NOT NULL 노트 포함하면 영구 over-count. repo.trash(a, '2026-05-01T00:00:00.000Z'); expect(repo.getPendingCount()).toBe(1); }); });