Files
inkling/tests/unit/NoteRepository.test.ts

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