From dc88da0bee18b9e79b5bb69cd9dcbce8764e3dd5 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 25 Apr 2026 12:14:31 +0900 Subject: [PATCH] feat(intent): IntentService for set/dismiss with validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 20 of the slice plan. Thin wrapper over the repo's intent methods that adds two preconditions: setIntent rejects empty trimmed text, both methods throw "note not found" when the note id doesn't exist. Repo-level COALESCE on intent_prompted_at preserves the first-prompt invariant (spec §3.3); IntentService's job is just input guarding so the IPC handler stays a one-liner. Verification: `npx vitest run tests/unit/IntentService.test.ts` 4 passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/services/IntentService.ts | 16 ++++++++++++ tests/unit/IntentService.test.ts | 42 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/main/services/IntentService.ts create mode 100644 tests/unit/IntentService.test.ts diff --git a/src/main/services/IntentService.ts b/src/main/services/IntentService.ts new file mode 100644 index 0000000..0b4e409 --- /dev/null +++ b/src/main/services/IntentService.ts @@ -0,0 +1,16 @@ +import type { NoteRepository } from '../repository/NoteRepository.js'; + +export class IntentService { + constructor(private repo: NoteRepository) {} + + setIntent(noteId: string, text: string): void { + if (text.trim().length === 0) throw new Error('empty intent text'); + if (!this.repo.findById(noteId)) throw new Error('note not found'); + this.repo.setIntent(noteId, text); + } + + dismissIntent(noteId: string): void { + if (!this.repo.findById(noteId)) throw new Error('note not found'); + this.repo.dismissIntent(noteId); + } +} diff --git a/tests/unit/IntentService.test.ts b/tests/unit/IntentService.test.ts new file mode 100644 index 0000000..8b0e44f --- /dev/null +++ b/tests/unit/IntentService.test.ts @@ -0,0 +1,42 @@ +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'; +import { IntentService } from '@main/services/IntentService.js'; + +describe('IntentService', () => { + let db: Database.Database; + let repo: NoteRepository; + let svc: IntentService; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + svc = new IntentService(repo); + }); + + it('setIntent stores text on note', () => { + const { id } = repo.create({ rawText: 'x' }); + svc.setIntent(id, '내일의 나에게'); + expect(repo.findById(id)?.userIntent).toBe('내일의 나에게'); + }); + + it('dismissIntent stamps prompted_at without setting intent', () => { + const { id } = repo.create({ rawText: 'x' }); + svc.dismissIntent(id); + const note = repo.findById(id)!; + expect(note.userIntent).toBeNull(); + expect(note.intentPromptedAt).not.toBeNull(); + }); + + it('rejects empty intent text', () => { + const { id } = repo.create({ rawText: 'x' }); + expect(() => svc.setIntent(id, ' ')).toThrow(/empty/i); + }); + + it('throws if note does not exist', () => { + expect(() => svc.setIntent('nonexistent', 'x')).toThrow(/not found/i); + expect(() => svc.dismissIntent('nonexistent')).toThrow(/not found/i); + }); +});