Files
inkling/tests/unit/NoteRepository.test.ts
altair823 df8a53aec1 feat(tag-vocab): NoteRepository — getTopUsedTags + getTagIdByName (#3 v0.2.3)
- getTopUsedTags(limit=20): top-N (count desc, id asc) + kebab-case 필터 + deleted_at 제외
- getTagIdByName(name): COLLATE NOCASE lookup
- AI+user source 통합 카운트 (Q1=C 결정)
- 단위 +7 cases (정렬, 필터, source 통합, deleted 제외, limit, getTagIdByName)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:10:36 +09:00

725 lines
28 KiB
TypeScript

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();
});
});