feat(repo): NoteRepository with intent, edited flags, AI overwrite guard
Task 7 of the slice plan. Implements the full repository surface backing every IPC inbox/capture path: create (UUID v7 + atomic notes + pending_jobs insert), insertMedia, findById/list, updateAiResult (CASE WHEN guard against title/summary overwrite when *_edited_by_user flips), markAiFailed (truncates ai_error to 500 chars + clears pending job), updateUserAiFields (sets edited flags as a side effect, replaces user-source tags), setIntent + dismissIntent (intent_prompted_at uses COALESCE so the first stamp wins), delete, getPendingCount, getAllPendingJobs, incrementJobAttempt, and a private hydrate that joins notes with note_tags + media. Plan deviation: list/list-with-cursor query gets a secondary "id DESC" tiebreaker. Two notes created in the same millisecond shared created_at and reordered nondeterministically; UUID v7 sorts monotonically with creation order, so id DESC restores "newest first" within ties. Verification: `npx vitest run tests/unit/NoteRepository.test.ts` 12 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
129
tests/unit/NoteRepository.test.ts
Normal file
129
tests/unit/NoteRepository.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user