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('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); }); }); 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'; }): string { const { id } = repo.create({ rawText: opts.rawText }); db.prepare( `UPDATE notes SET due_date = ?, due_date_edited_by_user = ?, ai_status = ?, deleted_at = ? WHERE id = ?` ).run( opts.dueDate, opts.edited ? 1 : 0, opts.aiStatus ?? 'done', opts.deletedAt ?? null, id ); return id; } it('returns notes with due_date < today (KST), ORDER BY 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 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' }); 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 notes with due_date == today (boundary, not expired)', () => { const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' }); 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([past]); }); }); 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('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; repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['design'], provider: 'p' }); repo.updateUserAiFields(b, { tags: ['design'] }); // design count 는 AI 1 + user 1 = 2 const top = repo.getTopUsedTags(); expect(top).toContain('design'); }); 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('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(); }); });