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); }); it('findRecallCandidate returns null for empty db', () => { expect(repo.findRecallCandidate()).toBeNull(); }); it('findRecallCandidate excludes notes recalled within 7 days', () => { const id = repo.create({ rawText: 'x' }).id; repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); // 5일 전 본 노트 → 제외 const fiveDaysAgo = new Date(Date.now() - 5 * 86_400_000).toISOString(); repo.markRecallOpened(id, fiveDaysAgo); expect(repo.findRecallCandidate()).toBeNull(); }); it('findRecallCandidate includes notes recalled 8+ days ago', () => { const id = repo.create({ rawText: 'x' }).id; repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); const eightDaysAgo = new Date(Date.now() - 8 * 86_400_000).toISOString(); repo.markRecallOpened(id, eightDaysAgo); expect(repo.findRecallCandidate()?.id).toBe(id); }); it('findRecallCandidate respects dismiss expiry (25일 제외, 35일 후보)', () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); const twentyFiveDaysAgo = new Date(Date.now() - 25 * 86_400_000).toISOString(); const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 86_400_000).toISOString(); repo.dismissRecall(a, twentyFiveDaysAgo); // 25일 — 아직 dismiss 만료 안 됨 repo.dismissRecall(b, thirtyFiveDaysAgo); // 35일 — dismiss 만료, 재추천 가능 const candidate = repo.findRecallCandidate(); expect(candidate?.id).toBe(b); }); it('findRecallCandidate excludes deleted/pending/imminent due', () => { const todayKst = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10); const yesterdayKst = new Date(Date.now() + 9 * 60 * 60 * 1000 - 86_400_000).toISOString().slice(0, 10); // (a) deleted const a = repo.create({ rawText: 'a' }).id; repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); repo.trash(a, new Date().toISOString()); // (b) pending (no AI) repo.create({ rawText: 'b' }); // (c) due_date 어제 const c = repo.create({ rawText: 'c' }).id; repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: yesterdayKst, provider: 'p' }); expect(repo.findRecallCandidate()).toBeNull(); // (d) due_date today 는 OK (>=today 통과) const d = repo.create({ rawText: 'd' }).id; repo.updateAiResult(d, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: todayKst, provider: 'p' }); expect(repo.findRecallCandidate()?.id).toBe(d); }); it('restoreNote re-enqueues failed note (ai_status reset to pending + pending_jobs INSERT)', () => { const id = repo.create({ rawText: 'x' }).id; repo.markAiFailed(id, 'unreachable'); repo.trash(id, new Date().toISOString()); expect(repo.findById(id)!.aiStatus).toBe('failed'); repo.restoreNote(id); const after = repo.findById(id)!; expect(after.deletedAt).toBeNull(); expect(after.aiStatus).toBe('pending'); expect(after.aiError).toBeNull(); const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); expect(job).toBeDefined(); }); it('restoreNote does not re-enqueue done note', () => { const id = repo.create({ rawText: 'x' }).id; repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); repo.trash(id, new Date().toISOString()); expect(repo.findById(id)!.aiStatus).toBe('done'); repo.restoreNote(id); expect(repo.findById(id)!.aiStatus).toBe('done'); const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); expect(job).toBeUndefined(); }); it('restoreNote re-enqueues pending note (defensive)', () => { const id = repo.create({ rawText: 'x' }).id; // 인공적으로 pending_jobs 비운 후 trash db.prepare('DELETE FROM pending_jobs WHERE note_id=?').run(id); repo.trash(id, new Date().toISOString()); expect(repo.findById(id)!.aiStatus).toBe('pending'); repo.restoreNote(id); expect(repo.findById(id)!.aiStatus).toBe('pending'); const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); expect(job).toBeDefined(); }); it('create accepts explicit now param', () => { const fixed = new Date('2026-05-09T10:00:00.000Z'); const { id } = repo.create({ rawText: 'hello' }, fixed); const note = repo.findById(id)!; expect(note.createdAt).toBe('2026-05-09T10:00:00.000Z'); expect(note.updatedAt).toBe('2026-05-09T10:00:00.000Z'); }); it('create defaults now to new Date when omitted', () => { const before = Date.now(); const { id } = repo.create({ rawText: 'hello' }); const after = Date.now(); const note = repo.findById(id)!; const ts = new Date(note.createdAt).getTime(); expect(ts).toBeGreaterThanOrEqual(before); expect(ts).toBeLessThanOrEqual(after); }); }); 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('NoteRepository.countTrashed', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('returns 0 when no trash', () => { repo.create({ rawText: 'active' }); expect(repo.countTrashed()).toBe(0); }); it('counts only trashed notes', () => { const a = repo.create({ rawText: 'a' }).id; repo.create({ rawText: 'b (active)' }); 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'); expect(repo.countTrashed()).toBe(2); }); it('returns count beyond listTrashed limit (no 200 cap drift)', () => { // listTrashed limit cap is 200; countTrashed must reflect actual count. for (let i = 0; i < 10; i++) { const id = repo.create({ rawText: `n${i}` }).id; repo.trash(id, `2026-05-01T${String(i).padStart(2, '0')}:00:00.000Z`); } expect(repo.countTrashed()).toBe(10); expect(repo.listTrashed({ limit: 5 })).toHaveLength(5); }); it('countTrashed returns accurate count (>200 not capped)', () => { const now = new Date().toISOString(); for (let i = 0; i < 250; i++) { const id = repo.create({ rawText: `n${i}` }).id; repo.trash(id, now); } expect(repo.countTrashed()).toBe(250); }); it('countTrashed returns 0 for empty trash', () => { expect(repo.countTrashed()).toBe(0); }); }); 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); }); }); describe('NoteRepository.findExpiredCandidates', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); function makeDone(opts: { rawText: string; dueDate: string | null; edited?: boolean; deletedAt?: string | null; aiStatus?: 'pending' | 'done' | 'failed'; status?: 'active' | 'completed' | 'archived' | 'trashed'; }): string { const { id } = repo.create({ rawText: opts.rawText }); db.prepare( `UPDATE notes SET due_date = ?, due_date_edited_by_user = ?, ai_status = ?, deleted_at = ?, status = ? WHERE id = ?` ).run( opts.dueDate, opts.edited ? 1 : 0, opts.aiStatus ?? 'done', opts.deletedAt ?? null, opts.status ?? 'active', id ); return id; } it('returns notes with due_date <= today (KST), ORDER BY due_date DESC then created_at DESC', () => { const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' }); db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T10:00:00Z', a); const b = makeDone({ rawText: 'b', dueDate: '2026-04-25' }); db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T11:00:00Z', b); const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); expect(r.map((n) => n.id)).toEqual([b, a]); }); it('includes notes with due_date == today (오늘 당일 우선 표시)', () => { const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' }); const todayNote = makeDone({ rawText: 'b', dueDate: '2026-05-01' }); const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); // 오늘 당일이 먼저, 그 다음 지난 메모. expect(r.map((n) => n.id)).toEqual([todayNote, past]); }); it('includes both AI-extracted and user-edited due_date (Q1=B 회귀 가드)', () => { const ai = makeDone({ rawText: 'a', dueDate: '2026-04-20', edited: false }); const manual = makeDone({ rawText: 'b', dueDate: '2026-04-22', edited: true }); const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); expect(r.map((n) => n.id).sort()).toEqual([ai, manual].sort()); }); it('excludes trashed notes (deleted_at IS NOT NULL)', () => { const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' }); makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z', status: 'trashed' }); const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); expect(r.map((n) => n.id)).toEqual([a]); }); it('excludes pending / failed notes (ai_status != done)', () => { const done = makeDone({ rawText: 'a', dueDate: '2026-04-20' }); makeDone({ rawText: 'b', dueDate: '2026-04-20', aiStatus: 'pending' }); makeDone({ rawText: 'c', dueDate: '2026-04-20', aiStatus: 'failed' }); const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); expect(r.map((n) => n.id)).toEqual([done]); }); it('excludes notes with NULL due_date (NULL < string 평가 가드)', () => { const dated = makeDone({ rawText: 'a', dueDate: '2026-04-20' }); makeDone({ rawText: 'b', dueDate: null }); const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); expect(r.map((n) => n.id)).toEqual([dated]); }); it('excludes completed / archived notes (inbox 만 — 사용자 의도: 완료/보관은 알림 제외)', () => { const active = makeDone({ rawText: 'a', dueDate: '2026-04-20' }); makeDone({ rawText: 'b', dueDate: '2026-04-20', status: 'completed' }); makeDone({ rawText: 'c', dueDate: '2026-04-20', status: 'archived' }); const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z')); expect(r.map((n) => n.id)).toEqual([active]); }); }); describe('NoteRepository.trashBatch', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('atomically trashes all valid ids and returns trashedCount', () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; const c = repo.create({ rawText: 'c' }).id; const r = repo.trashBatch([a, b, c], '2026-05-01T12:00:00.000Z'); expect(r.trashedCount).toBe(3); expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); expect(repo.findById(b)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); expect(repo.findById(c)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id IN (?,?,?)').get(a, b, c)) .toMatchObject({ c: 0 }); }); it('returns trashedCount=0 for empty array (no-op)', () => { const r = repo.trashBatch([], '2026-05-01T12:00:00.000Z'); expect(r.trashedCount).toBe(0); }); it('skips ids that are already trashed (idempotent — count = 0 transitions)', () => { const a = repo.create({ rawText: 'a' }).id; repo.trash(a, '2026-04-30T00:00:00.000Z'); const r = repo.trashBatch([a], '2026-05-01T12:00:00.000Z'); expect(r.trashedCount).toBe(0); expect(repo.findById(a)!.deletedAt).toBe('2026-04-30T00:00:00.000Z'); }); it('counts only the valid active ids (mix of valid + invalid + already-trashed)', () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; repo.trash(b, '2026-04-30T00:00:00.000Z'); const r = repo.trashBatch([a, b, 'nonexistent-id'], '2026-05-01T12:00:00.000Z'); expect(r.trashedCount).toBe(1); expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z'); }); }); describe('NoteRepository — failed retry helpers', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); function makeFailed(rawText: string, deletedAt: string | null = null): string { const { id } = repo.create({ rawText }); db.prepare( `UPDATE notes SET ai_status='failed', ai_error='boom', deleted_at=? WHERE id=?` ).run(deletedAt, id); db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id); return id; } it('findFailedIds returns ai_status=failed AND deleted_at IS NULL only', () => { const a = makeFailed('a'); makeFailed('b', '2026-04-30T00:00:00Z'); // trashed repo.create({ rawText: 'pending' }); // pending status expect(repo.findFailedIds().sort()).toEqual([a].sort()); }); it('countFailed counts active failed notes only', () => { makeFailed('a'); makeFailed('b'); makeFailed('c', '2026-04-30T00:00:00Z'); expect(repo.countFailed()).toBe(2); }); it('retryAllFailed atomic — ai_status reset + pending_jobs 재투입', () => { const a = makeFailed('a'); const b = makeFailed('b'); const r = repo.retryAllFailed('2026-05-01T12:00:00.000Z'); expect(r.ids.sort()).toEqual([a, b].sort()); expect(repo.findById(a)!.aiStatus).toBe('pending'); expect(repo.findById(b)!.aiStatus).toBe('pending'); expect(repo.findById(a)!.aiError).toBeNull(); const jobs = repo.getAllPendingJobs(); expect(jobs.map((j) => j.noteId).sort()).toEqual([a, b].sort()); for (const j of jobs) { expect(j.attempts).toBe(0); expect(j.nextRunAt).toBe('2026-05-01T12:00:00.000Z'); } }); it('retryAllFailed empty — { ids: [] }', () => { expect(repo.retryAllFailed('2026-05-01T12:00:00.000Z')).toEqual({ ids: [] }); }); it('retryAllFailed — pending_jobs 이미 존재 시 OR IGNORE (race 안전)', () => { // failed 노트인데 pending_jobs row 가 이미 존재하는 비정상 race 상태 시뮬레이션. // attempts=2, next_run_at=과거 — retryAllFailed 가 INSERT OR IGNORE 라 그대로 보존되어야. const id = makeFailed('a'); db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 2, ?)`) .run(id, '2026-04-30T00:00:00.000Z'); const r = repo.retryAllFailed('2026-05-01T12:00:00.000Z'); expect(r.ids).toEqual([id]); const jobs = repo.getAllPendingJobs().filter((j) => j.noteId === id); expect(jobs).toHaveLength(1); // duplicate 안 됨 // OR IGNORE 라 기존 row 보존 — attempts=2, nextRunAt 그대로 expect(jobs[0]!.attempts).toBe(2); expect(jobs[0]!.nextRunAt).toBe('2026-04-30T00:00:00.000Z'); }); it('v0.3.9 — retryOneFailed: failed → pending + pending_jobs INSERT', () => { const a = makeFailed('a'); const b = makeFailed('b'); const r = repo.retryOneFailed(a, '2026-05-01T12:00:00.000Z'); expect(r).toEqual({ ok: true }); expect(repo.findById(a)!.aiStatus).toBe('pending'); expect(repo.findById(a)!.aiError).toBeNull(); expect(repo.findById(b)!.aiStatus).toBe('failed'); // 다른 노트 영향 없음 const jobs = repo.getAllPendingJobs(); expect(jobs.find((j) => j.noteId === a)).toBeDefined(); }); it('v0.3.9 — retryOneFailed: non-failed status 면 no-op', () => { const { id } = repo.create({ rawText: 'pending note' }); const r = repo.retryOneFailed(id, '2026-05-01T12:00:00.000Z'); expect(r).toEqual({ ok: false }); }); it('v0.3.9 — cancelPending: pending → disabled + pending_jobs DELETE', () => { const { id } = repo.create({ rawText: 'x' }); // ai_status=pending expect(repo.findById(id)!.aiStatus).toBe('pending'); const r = repo.cancelPending(id, '2026-05-01T12:00:00.000Z'); expect(r).toEqual({ ok: true }); expect(repo.findById(id)!.aiStatus).toBe('disabled'); const jobs = repo.getAllPendingJobs().filter((j) => j.noteId === id); expect(jobs).toHaveLength(0); }); it('v0.3.9 — cancelPending: non-pending status 면 no-op', () => { const id = makeFailed('a'); const r = repo.cancelPending(id, '2026-05-01T12:00:00.000Z'); expect(r).toEqual({ ok: false }); expect(repo.findById(id)!.aiStatus).toBe('failed'); // 변경 없음 }); it('setNextRunAt — attempts 변경 없이 next_run_at + last_error 갱신', () => { const { id } = repo.create({ rawText: 'x' }); repo.incrementJobAttempt(id, '2026-05-01T11:00:00.000Z', 'first error'); // attempts 가 1 이 됨 repo.setNextRunAt(id, '2026-05-01T12:00:00.000Z', 'unreachable'); const job = repo.getAllPendingJobs().find((j) => j.noteId === id)!; expect(job.attempts).toBe(1); // 변화 없음 expect(job.nextRunAt).toBe('2026-05-01T12:00:00.000Z'); }); it('getTopUsedTags returns [] when no notes', () => { expect(repo.getTopUsedTags()).toEqual([]); }); it('getTopUsedTags orders by count desc, id asc tiebreaker', () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; const c = repo.create({ rawText: 'c' }).id; repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting'], provider: 'p' }); repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' }); repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' }); // counts: design=3, meeting=2, qa=1 expect(repo.getTopUsedTags()).toEqual(['design', 'meeting', 'qa']); }); it('getTopUsedTags filters non-kebab-case (한글/대문자/공백)', () => { const a = repo.create({ rawText: 'a' }).id; // user route 가 한글/공백 태그 들어올 수 있음 → vocab 에서 제외 검증 repo.updateUserAiFields(a, { tags: ['design', '회의', 'Meeting', 'two words', 'api-timeout'] }); expect(repo.getTopUsedTags()).toEqual(expect.arrayContaining(['design', 'api-timeout'])); expect(repo.getTopUsedTags()).not.toContain('회의'); expect(repo.getTopUsedTags()).not.toContain('Meeting'); expect(repo.getTopUsedTags()).not.toContain('two words'); }); it('getTopUsedTags counts AI + user sources together', () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; const c = repo.create({ rawText: 'c' }).id; // design: 1 AI (a) + 1 user (b) = 2 total; meeting: 1 AI (c) = 1 total // → design must rank first (proves source merging, not AI-only count) // Note: updateUserAiFields REPLACES tags (DELETE+reinsert), so each note // gets exactly the tags passed in the call. repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['design'], provider: 'p' }); repo.updateUserAiFields(b, { tags: ['design'] }); repo.updateAiResult(c, { title: 't', summary: 'x\ny\nz', tags: ['meeting'], provider: 'p' }); const top = repo.getTopUsedTags(); expect(top[0]).toBe('design'); // 2 (AI+user) > 1 (AI only) expect(top.indexOf('meeting')).toBeGreaterThan(0); }); it('getTopUsedTags excludes tags from deleted notes', () => { const a = repo.create({ rawText: 'a' }).id; repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['lonely'], provider: 'p' }); repo.trash(a, new Date().toISOString()); expect(repo.getTopUsedTags()).not.toContain('lonely'); }); it('getTopUsedTags respects LIMIT parameter', () => { const ids: string[] = []; for (let i = 0; i < 5; i++) { const id = repo.create({ rawText: `n${i}` }).id; ids.push(id); repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: [`tag-${i}`], provider: 'p' }); } expect(repo.getTopUsedTags(3)).toHaveLength(3); expect(repo.getTopUsedTags(10)).toHaveLength(5); }); it('getTopUsedTags result may be shorter than limit when top-N includes non-kebab tags', () => { // 비-kebab 1개 (한글) + kebab 2개 → top-3 으로 SQL 가져온 후 regex 필터로 한글 제외 // 결과 length = 2 (limit=3 보다 작음) const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; const c = repo.create({ rawText: 'c' }).id; repo.updateUserAiFields(a, { tags: ['회의'] }); // 한글 — SQL top 에 포함될 수 있지만 regex 통과 X repo.updateUserAiFields(b, { tags: ['design'] }); repo.updateUserAiFields(c, { tags: ['meeting'] }); const top = repo.getTopUsedTags(3); expect(top.length).toBeLessThan(3); // SQL 은 3개 가져왔지만 regex 가 1개 제거 expect(top).not.toContain('회의'); expect(top).toEqual(expect.arrayContaining(['design', 'meeting'])); }); it('getTagIdByName returns id when present, null when absent', () => { const a = repo.create({ rawText: 'a' }).id; repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['hello'], provider: 'p' }); const id = repo.getTagIdByName('hello'); expect(typeof id).toBe('number'); expect(id).toBeGreaterThan(0); // case-insensitive expect(repo.getTagIdByName('HELLO')).toBe(id); // absent expect(repo.getTagIdByName('nothere')).toBeNull(); }); }); describe('NoteRepository — setStatus + listByStatus', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('setStatus updates status + reason + status_changed_at + updated_at', () => { const { id } = repo.create({ rawText: 'test' }); repo.setStatus(id, 'completed', '결재 끝', new Date('2026-05-10T00:00:00.000Z')); const note = repo.findById(id)!; expect(note.status).toBe('completed'); expect(note.moveReason).toBe('결재 끝'); expect(note.statusChangedAt).toBe('2026-05-10T00:00:00.000Z'); expect(note.updatedAt).toBe('2026-05-10T00:00:00.000Z'); }); it('setStatus accepts null reason', () => { const { id } = repo.create({ rawText: 'test' }); repo.setStatus(id, 'completed', null, new Date('2026-05-10T00:00:00.000Z')); const note = repo.findById(id)!; expect(note.status).toBe('completed'); expect(note.moveReason).toBeNull(); }); it('setStatus default now uses Date.now()', () => { const { id } = repo.create({ rawText: 'test' }); const before = Date.now(); repo.setStatus(id, 'completed', null); const after = Date.now(); const note = repo.findById(id)!; const ts = new Date(note.statusChangedAt!).getTime(); expect(ts).toBeGreaterThanOrEqual(before); expect(ts).toBeLessThanOrEqual(after); }); it('listByStatus filters correctly', () => { const idA = repo.create({ rawText: 'a' }).id; const idB = repo.create({ rawText: 'b' }).id; repo.setStatus(idB, 'completed', null, new Date('2026-05-10T00:00:00.000Z')); const active = repo.listByStatus('active', { limit: 10 }); const completed = repo.listByStatus('completed', { limit: 10 }); expect(active.map((n) => n.id)).toContain(idA); expect(active.map((n) => n.id)).not.toContain(idB); expect(completed.map((n) => n.id)).toContain(idB); expect(completed.map((n) => n.id)).not.toContain(idA); }); it('listByStatus orders by status_changed_at DESC (NULL falls back to created_at)', () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; const c = repo.create({ rawText: 'c' }).id; repo.setStatus(a, 'completed', null, new Date('2026-05-10T00:00:00.000Z')); repo.setStatus(b, 'completed', null, new Date('2026-05-12T00:00:00.000Z')); repo.setStatus(c, 'completed', null, new Date('2026-05-11T00:00:00.000Z')); const r = repo.listByStatus('completed', { limit: 10 }); expect(r.map((n) => n.id)).toEqual([b, c, a]); }); it('listByStatus respects limit (cap 200)', () => { for (let i = 0; i < 5; i++) { const id = repo.create({ rawText: `n${i}` }).id; repo.setStatus(id, 'completed', null, new Date(`2026-05-${10 + i}T00:00:00.000Z`)); } expect(repo.listByStatus('completed', { limit: 3 })).toHaveLength(3); expect(repo.listByStatus('completed', { limit: 100 })).toHaveLength(5); }); it('listByStatus default limit 200', () => { repo.create({ rawText: 'a' }); expect(repo.listByStatus('active')).toHaveLength(1); }); it('setStatus("trashed") syncs deleted_at (backward compat)', () => { const { id } = repo.create({ rawText: 't' }); repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z')); const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as { deleted_at: string; }; expect(row.deleted_at).toBe('2026-05-15T00:00:00.000Z'); expect(repo.findById(id)!.deletedAt).toBe('2026-05-15T00:00:00.000Z'); }); it('setStatus("active") clears deleted_at (restore from trash)', () => { const { id } = repo.create({ rawText: 'r' }); repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z')); repo.setStatus(id, 'active', null, new Date('2026-05-16T00:00:00.000Z')); const row = db.prepare(`SELECT deleted_at FROM notes WHERE id=?`).get(id) as { deleted_at: string | null; }; expect(row.deleted_at).toBeNull(); expect(repo.findById(id)!.deletedAt).toBeNull(); }); it('setStatus("completed") also clears deleted_at', () => { const { id } = repo.create({ rawText: 'r' }); repo.setStatus(id, 'trashed', null, new Date('2026-05-15T00:00:00.000Z')); repo.setStatus(id, 'completed', null, new Date('2026-05-16T00:00:00.000Z')); expect(repo.findById(id)!.deletedAt).toBeNull(); }); it('newly created note hydrates as status=active', () => { const { id } = repo.create({ rawText: 'fresh' }); const note = repo.findById(id)!; expect(note.status).toBe('active'); expect(note.statusChangedAt).toBeNull(); expect(note.moveReason).toBeNull(); }); it('countByStatus returns accurate count per status', () => { const a = repo.create({ rawText: 'a' }).id; // active repo.create({ rawText: 'b' }); // active const c = repo.create({ rawText: 'c' }).id; repo.setStatus(c, 'completed', null, new Date('2026-05-10T00:00:00.000Z')); const d = repo.create({ rawText: 'd' }).id; repo.setStatus(d, 'completed', null, new Date('2026-05-10T00:00:00.000Z')); const e = repo.create({ rawText: 'e' }).id; repo.setStatus(e, 'trashed', null, new Date('2026-05-10T00:00:00.000Z')); expect(repo.countByStatus('active')).toBe(2); expect(repo.countByStatus('completed')).toBe(2); expect(repo.countByStatus('trashed')).toBe(1); // sanity — a 가 여전히 active. expect(repo.findById(a)!.status).toBe('active'); }); it('restoreNote sets status=active + clears moveReason', () => { const { id } = repo.create({ rawText: 'r' }); repo.setStatus(id, 'trashed', '실수', new Date('2026-05-15T00:00:00.000Z')); expect(repo.findById(id)!.status).toBe('trashed'); expect(repo.findById(id)!.moveReason).toBe('실수'); repo.restoreNote(id); const after = repo.findById(id)!; expect(after.status).toBe('active'); expect(after.moveReason).toBeNull(); expect(after.deletedAt).toBeNull(); }); }); // v0.2.9 Cut B Task 16 — settings.ai_enabled OFF→ON 전환 시 disabled 메모 일괄 재투입. describe('NoteRepository.requeueDisabled', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('changes ai_status="disabled" → "pending" + INSERT pending_jobs', () => { const { id } = repo.create({ rawText: 't', aiStatus: 'disabled' }); const count = repo.requeueDisabled(new Date('2026-05-09T00:00:00Z')); expect(count).toBe(1); const note = repo.findById(id); expect(note?.aiStatus).toBe('pending'); const job = db.prepare(`SELECT * FROM pending_jobs WHERE note_id=?`).get(id); expect(job).toBeDefined(); }); it('does not affect non-disabled notes', () => { const idP = repo.create({ rawText: 'p', aiStatus: 'pending' }).id; const idC = repo.create({ rawText: 'c' }).id; repo.updateAiResult(idC, { title: 't', summary: 'a\nb\nc', tags: [], provider: 'p' }); repo.requeueDisabled(new Date()); expect(repo.findById(idP)?.aiStatus).toBe('pending'); expect(repo.findById(idC)?.aiStatus).toBe('done'); }); it('returns 0 when no disabled notes', () => { const count = repo.requeueDisabled(new Date()); expect(count).toBe(0); }); }); describe('NoteRepository.countByAiStatus', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('returns count per ai_status', () => { repo.create({ rawText: 'a', aiStatus: 'disabled' }); repo.create({ rawText: 'b', aiStatus: 'disabled' }); repo.create({ rawText: 'c', aiStatus: 'pending' }); expect(repo.countByAiStatus('disabled')).toBe(2); expect(repo.countByAiStatus('pending')).toBe(1); expect(repo.countByAiStatus('done')).toBe(0); }); }); describe('NoteRepository — note_revisions', () => { let db: Database.Database; let repo: NoteRepository; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); }); it('create() 가 첫 revision (edited_by=capture) 을 INSERT 한다', () => { const { id } = repo.create({ rawText: 'hello' }); const rows = db .prepare(`SELECT raw_text, edited_by FROM note_revisions WHERE note_id=?`) .all(id) as Array<{ raw_text: string; edited_by: string }>; expect(rows).toHaveLength(1); expect(rows[0]).toEqual({ raw_text: 'hello', edited_by: 'capture' }); }); }); describe('NoteRepository — notes_fts tags sync (v0.2.11 Cut D)', () => { let db: Database.Database; beforeEach(() => { db = new Database(':memory:'); runMigrations(db); }); it('updateAiResult 후 notes_fts.tags 가 csv 로 sync', () => { const repo = new NoteRepository(db); const { id } = repo.create({ rawText: '회의 본문' }); repo.updateAiResult(id, { title: '제목', summary: '요약', tags: ['기획', '회의'], provider: 'p' }); const row = db .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`) .get(id) as { tags: string }; expect(row.tags.split(' ').sort()).toEqual(['기획', '회의']); }); it('updateUserAiFields tags 갱신 후 notes_fts.tags 동기', () => { const repo = new NoteRepository(db); const { id } = repo.create({ rawText: '본문' }); repo.updateAiResult(id, { title: 't', summary: 's', tags: ['old'], provider: 'p' }); repo.updateUserAiFields(id, { tags: ['new1', 'new2'] }); const row = db .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`) .get(id) as { tags: string }; expect(row.tags.split(' ').sort()).toEqual(['new1', 'new2']); }); it('importNote insert path: notes_fts.tags 가 csv 로 sync (final review fix)', () => { const repo = new NoteRepository(db); const r = repo.importNote({ id: '00000000-0000-0000-0000-000000000010', rawText: 'imported with tags', createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z', aiTitle: 'imported title', aiSummary: 'imported summary', titleEditedByUser: false, summaryEditedByUser: false, aiProvider: 'p', aiGeneratedAt: '2026-04-01T00:00:00Z', userIntent: null, intentPromptedAt: null, tags: [ { name: '기획', source: 'ai' }, { name: '회의', source: 'user' } ] }); expect(r.status).toBe('inserted'); const row = db .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`) .get(r.id) as { tags: string }; expect(row.tags.split(' ').sort()).toEqual(['기획', '회의']); }); it('importNote fork path: forked 노트의 notes_fts.tags 동기 (final review fix)', () => { const repo = new NoteRepository(db); const existing = repo.create({ rawText: 'v1' }); const r = repo.importNote({ id: existing.id, rawText: 'imported v2 with tags', 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: [{ name: '결재', source: 'user' }] }); expect(r.status).toBe('forked'); expect(r.id).not.toBe(existing.id); const row = db .prepare(`SELECT tags FROM notes_fts WHERE note_id=?`) .get(r.id) as { tags: string }; expect(row.tags).toBe('결재'); }); }); describe('NoteRepository.create with notebook', () => { let db: Database.Database; let repo: NoteRepository; let defaultId: string; beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); runMigrations(db); repo = new NoteRepository(db); defaultId = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id; }); it('notebook_id 미지정 시 default notebook 으로 들어감', () => { const { id } = repo.create({ rawText: 'hello' }); const r = repo.findById(id); expect(r?.notebookId).toBe(defaultId); }); it('notebook_id 지정 시 그 값 보존', () => { db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('nb-other','회사','2026-05-14','2026-05-14')`).run(); const { id } = repo.create({ rawText: 'hi', notebookId: 'nb-other' }); expect(repo.findById(id)?.notebookId).toBe('nb-other'); }); it('hydrate 가 notebookId 필드 반환', () => { const { id } = repo.create({ rawText: 'hi' }); const r = repo.findById(id); expect(typeof r?.notebookId).toBe('string'); expect(r?.notebookId).toBe(defaultId); }); }); describe('NoteRepository.findPromotionCandidates', () => { let db: Database.Database; let repo: NoteRepository; let defaultId: string; beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); runMigrations(db); repo = new NoteRepository(db); defaultId = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id; }); function insertWithTag(rawText: string, tagName: string, notebookId?: string): string { const { id } = repo.create({ rawText, notebookId }); repo.updateAiResult(id, { title: rawText, summary: 'a\nb\nc', tags: [tagName], provider: 'test', dueDate: null }); return id; } it('threshold 미만: 빈 결과', () => { insertWithTag('n1', 'mlx-ops'); insertWithTag('n2', 'mlx-ops'); expect(repo.findPromotionCandidates(defaultId)).toEqual([]); }); it('threshold 도달: tag 와 noteIds 반환', () => { const a = insertWithTag('n1', 'mlx-ops'); const b = insertWithTag('n2', 'mlx-ops'); const c = insertWithTag('n3', 'mlx-ops'); const r = repo.findPromotionCandidates(defaultId); expect(r).toHaveLength(1); expect(r[0]!.tag).toBe('mlx-ops'); expect(r[0]!.noteIds.sort()).toEqual([a, b, c].sort()); }); it('default 가 아닌 notebook 의 노트는 제외', () => { db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES('nb-x','회사','2099-01-01','2099-01-01')`).run(); insertWithTag('n1', 'mlx-ops'); insertWithTag('n2', 'mlx-ops'); insertWithTag('n3', 'mlx-ops', 'nb-x'); expect(repo.findPromotionCandidates(defaultId)).toEqual([]); }); it('completed 제외 — active 만', () => { insertWithTag('n1', 'mlx-ops'); insertWithTag('n2', 'mlx-ops'); const c = insertWithTag('n3', 'mlx-ops'); repo.setStatus(c, 'completed', null); expect(repo.findPromotionCandidates(defaultId)).toEqual([]); }); it('threshold 인자로 cap 조절 가능', () => { insertWithTag('n1', 'mlx-ops'); insertWithTag('n2', 'mlx-ops'); expect(repo.findPromotionCandidates(defaultId, 2)).toHaveLength(1); }); it('여러 tag cluster 가 모두 반환', () => { insertWithTag('a1', 'mlx-ops'); insertWithTag('a2', 'mlx-ops'); insertWithTag('a3', 'mlx-ops'); insertWithTag('b1', 'keycloak'); insertWithTag('b2', 'keycloak'); insertWithTag('b3', 'keycloak'); const r = repo.findPromotionCandidates(defaultId); expect(r).toHaveLength(2); expect(r.map((c) => c.tag).sort()).toEqual(['keycloak', 'mlx-ops']); }); }); describe('NoteRepository.list / countByStatus with notebookId', () => { let db: Database.Database; let repo: NoteRepository; let nbA: string, nbB: string; beforeEach(() => { db = new Database(':memory:'); db.pragma('foreign_keys = ON'); runMigrations(db); repo = new NoteRepository(db); nbA = (db.prepare(`SELECT id FROM notebooks`).get() as { id: string }).id; nbB = 'nb-b'; db.prepare(`INSERT INTO notebooks(id,name,created_at,updated_at) VALUES(?,?,?,?)`).run(nbB,'회사','2099-01-01','2099-01-01'); }); it('list 가 notebookId 필터로 노트 분리', () => { repo.create({ rawText: 'in-default' }); repo.create({ rawText: 'in-B', notebookId: nbB }); expect(repo.list({ limit: 10, notebookId: nbA }).map((n) => n.rawText)).toEqual(['in-default']); expect(repo.list({ limit: 10, notebookId: nbB }).map((n) => n.rawText)).toEqual(['in-B']); }); it('list 의 notebookId 미지정 시 모든 notebook 의 노트', () => { repo.create({ rawText: 'in-default' }); repo.create({ rawText: 'in-B', notebookId: nbB }); expect(repo.list({ limit: 10 })).toHaveLength(2); }); it('countByStatus(notebookId) — 각 notebook 의 active 갯수', () => { repo.create({ rawText: 'a1' }); repo.create({ rawText: 'a2' }); repo.create({ rawText: 'b1', notebookId: nbB }); expect(repo.countByStatus('active', { notebookId: nbA })).toBe(2); expect(repo.countByStatus('active', { notebookId: nbB })).toBe(1); expect(repo.countByStatus('active')).toBe(3); // 옵션 미지정 시 전체 }); it('listByStatus(notebookId) — 같은 status 라도 notebook 별 분리', () => { const { id: a1 } = repo.create({ rawText: 'a1' }); const { id: b1 } = repo.create({ rawText: 'b1', notebookId: nbB }); repo.setStatus(a1, 'completed', null); repo.setStatus(b1, 'completed', null); expect(repo.listByStatus('completed', { notebookId: nbA }).map((n) => n.id)).toEqual([a1]); expect(repo.listByStatus('completed', { notebookId: nbB }).map((n) => n.id)).toEqual([b1]); }); });