1057 lines
42 KiB
TypeScript
1057 lines
42 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);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
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';
|
|
}): 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;
|
|
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, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
|
const note = repo.findById(id)!;
|
|
expect(note.status).toBe('archived');
|
|
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, 'archived', null, new Date('2026-05-10T00:00:00.000Z'));
|
|
|
|
const active = repo.listByStatus('active', { limit: 10 });
|
|
const archived = repo.listByStatus('archived', { limit: 10 });
|
|
expect(active.map((n) => n.id)).toContain(idA);
|
|
expect(active.map((n) => n.id)).not.toContain(idB);
|
|
expect(archived.map((n) => n.id)).toContain(idB);
|
|
expect(archived.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, 'archived', null, new Date(`2026-05-${10 + i}T00:00:00.000Z`));
|
|
}
|
|
expect(repo.listByStatus('archived', { limit: 3 })).toHaveLength(3);
|
|
expect(repo.listByStatus('archived', { 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"/"archived") 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, 'archived', 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(1);
|
|
expect(repo.countByStatus('archived')).toBe(1);
|
|
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);
|
|
});
|
|
});
|