- AiStatus enum 'disabled' 추가 — settings.ai_enabled=false 일 때 새 노트의 초기 status.
- m005 migration: ai_status CHECK 제약을 ('pending','done','failed','disabled') 로 relax.
SQLite 가 ALTER COLUMN CHECK 미지원 → table recreate (notes_new INSERT SELECT DROP RENAME).
기존 인덱스 (idx_notes_created_at, idx_notes_ai_status, idx_notes_deleted_at) 재생성.
- SettingsService schema 에 ai_enabled / onboarding_completed (optional) 추가 +
isAiEnabled / setAiEnabled / isOnboardingCompleted / setOnboardingCompleted accessor.
기본 fallback (ai_enabled=true, onboarding_completed=false) — 기존 settings.json 무영향.
- NoteRepository.create 가 optional aiStatus 받도록 — 'pending' 외 값일 때 pending_jobs skip.
기존 caller (rawText 만 전달) 무영향.
- CaptureService deps 에 settings (좁은 AiEnabledSource 인터페이스) 추가.
submit() 가 ai_enabled 조회 → false 면 ai_status='disabled' insert + enqueue skip.
settings 미주입 시 기존 동작 (항상 enabled) 보존 — 테스트 케이스 무영향.
- main/index.ts wiring: settings: settingsSvc 주입.
Tests: 489 → 494 (CaptureService ai_enabled 2건 + m005 migration 3건). typecheck 0.
532 lines
19 KiB
TypeScript
532 lines
19 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { mkdtempSync, existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import Database from 'better-sqlite3';
|
|
import { runMigrations } from '@main/db/migrations/index.js';
|
|
import { NoteRepository } from '@main/repository/NoteRepository.js';
|
|
import { MediaStore } from '@main/services/MediaStore.js';
|
|
import { CaptureService } from '@main/services/CaptureService.js';
|
|
|
|
describe('CaptureService', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
let store: MediaStore;
|
|
let tmp: string;
|
|
let enqueued: string[];
|
|
let celebrated: string[];
|
|
let svc: CaptureService;
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
|
|
store = new MediaStore(tmp);
|
|
enqueued = [];
|
|
celebrated = [];
|
|
svc = new CaptureService(repo, store, {
|
|
enqueue: async (id) => { enqueued.push(id); },
|
|
celebrate: (id) => { celebrated.push(id); }
|
|
});
|
|
});
|
|
|
|
it('persists text-only and triggers enqueue + celebrate', async () => {
|
|
const { noteId } = await svc.submit({ text: '안녕', images: [] });
|
|
expect(repo.findById(noteId)?.rawText).toBe('안녕');
|
|
expect(enqueued).toEqual([noteId]);
|
|
expect(celebrated).toEqual([noteId]);
|
|
});
|
|
|
|
it('saves images under media/{noteId}/', async () => {
|
|
const img = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]).buffer;
|
|
const { noteId } = await svc.submit({ text: 'x', images: [img] });
|
|
const note = repo.findById(noteId)!;
|
|
expect(note.media).toHaveLength(1);
|
|
expect(note.media[0]!.relPath.startsWith(`media/${noteId}/`)).toBe(true);
|
|
});
|
|
|
|
it('rejects empty submit', async () => {
|
|
await expect(svc.submit({ text: ' ', images: [] })).rejects.toThrow(/empty/i);
|
|
expect(celebrated).toHaveLength(0);
|
|
});
|
|
|
|
it('deleteNote soft-deletes (sets deletedAt, preserves row)', async () => {
|
|
const img = new Uint8Array([0, 1, 2, 3]).buffer;
|
|
const { noteId } = await svc.submit({ text: 't', images: [img] });
|
|
await svc.deleteNote(noteId);
|
|
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('CaptureService telemetry emit', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
let store: MediaStore;
|
|
let tmp: string;
|
|
let events: Array<{ kind: string; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }>;
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
|
|
store = new MediaStore(tmp);
|
|
events = [];
|
|
});
|
|
|
|
it('emits capture event with noteId/rawTextLength/hasMedia', async () => {
|
|
const svc = new CaptureService(repo, store, {
|
|
enqueue: async () => {},
|
|
celebrate: () => {},
|
|
telemetry: { emit: async (ev) => { events.push(ev as typeof events[number]); } }
|
|
});
|
|
await svc.submit({ text: '안녕하세요', images: [] });
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]!.kind).toBe('capture');
|
|
expect(events[0]!.payload.rawTextLength).toBe('안녕하세요'.length);
|
|
expect(events[0]!.payload.hasMedia).toBe(false);
|
|
expect(typeof events[0]!.payload.noteId).toBe('string');
|
|
});
|
|
|
|
it('emits hasMedia=true when images present', async () => {
|
|
const svc = new CaptureService(repo, store, {
|
|
enqueue: async () => {},
|
|
celebrate: () => {},
|
|
telemetry: { emit: async (ev) => { events.push(ev as typeof events[number]); } }
|
|
});
|
|
const img = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]).buffer;
|
|
await svc.submit({ text: '이미지 메모', images: [img] });
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]!.payload.hasMedia).toBe(true);
|
|
});
|
|
|
|
it('does NOT emit when telemetry dep absent (backward compat)', async () => {
|
|
const svc = new CaptureService(repo, store, {
|
|
enqueue: async () => {},
|
|
celebrate: () => {}
|
|
});
|
|
const result = await svc.submit({ text: 'no telem', images: [] });
|
|
expect(typeof result.noteId).toBe('string');
|
|
expect(events).toHaveLength(0); // events array stays empty since no telemetry was wired
|
|
});
|
|
});
|
|
|
|
describe('CaptureService trash flow (v0.2.3 #4)', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
let store: MediaStore;
|
|
let tmp: string;
|
|
let events: Array<{ kind: string; payload: any }>;
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
tmp = mkdtempSync(join(tmpdir(), 'inkling-trash-'));
|
|
store = new MediaStore(tmp);
|
|
events = [];
|
|
});
|
|
|
|
it('deleteNote sets deleted_at and emits trash event (no media cleanup)', async () => {
|
|
const svc = new CaptureService(repo, store, {
|
|
enqueue: async () => {},
|
|
celebrate: () => {},
|
|
telemetry: { emit: async (ev) => { events.push(ev); } }
|
|
});
|
|
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
|
|
events.length = 0; // clear capture event
|
|
await svc.deleteNote(noteId);
|
|
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]!.kind).toBe('trash');
|
|
expect(events[0]!.payload.noteId).toBe(noteId);
|
|
// media 디렉터리 보존 확인 (restore 시 필요)
|
|
expect(existsSync(join(tmp, 'media', noteId))).toBe(true);
|
|
});
|
|
|
|
it('restoreNote clears deleted_at and emits restore event', async () => {
|
|
const svc = new CaptureService(repo, store, {
|
|
enqueue: async () => {},
|
|
celebrate: () => {},
|
|
telemetry: { emit: async (ev) => { events.push(ev); } }
|
|
});
|
|
const { noteId } = await svc.submit({ text: 'hi', images: [] });
|
|
events.length = 0;
|
|
await svc.deleteNote(noteId);
|
|
events.length = 0;
|
|
await svc.restoreNote(noteId);
|
|
expect(repo.findById(noteId)!.deletedAt).toBeNull();
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]!.kind).toBe('restore');
|
|
});
|
|
|
|
it('permanentDeleteNote hard-deletes + cleans media + emits permanent_delete', async () => {
|
|
const svc = new CaptureService(repo, store, {
|
|
enqueue: async () => {},
|
|
celebrate: () => {},
|
|
telemetry: { emit: async (ev) => { events.push(ev); } }
|
|
});
|
|
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
|
|
events.length = 0;
|
|
await svc.permanentDeleteNote(noteId);
|
|
expect(repo.findById(noteId)).toBeNull();
|
|
expect(existsSync(join(tmp, 'media', noteId))).toBe(false);
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]!.kind).toBe('permanent_delete');
|
|
});
|
|
|
|
it('emptyTrash deletes all trashed + cleans each media + emits empty_trash with count', async () => {
|
|
const svc = new CaptureService(repo, store, {
|
|
enqueue: async () => {},
|
|
celebrate: () => {},
|
|
telemetry: { emit: async (ev) => { events.push(ev); } }
|
|
});
|
|
const a = (await svc.submit({ text: 'a', images: [new ArrayBuffer(8)] })).noteId;
|
|
const b = (await svc.submit({ text: 'b', images: [new ArrayBuffer(8)] })).noteId;
|
|
await svc.submit({ text: 'c (active)', images: [] });
|
|
await svc.deleteNote(a);
|
|
await svc.deleteNote(b);
|
|
events.length = 0;
|
|
const r = await svc.emptyTrash();
|
|
expect(r.count).toBe(2);
|
|
expect(repo.findById(a)).toBeNull();
|
|
expect(repo.findById(b)).toBeNull();
|
|
expect(existsSync(join(tmp, 'media', a))).toBe(false);
|
|
expect(existsSync(join(tmp, 'media', b))).toBe(false);
|
|
const empty = events.find((e) => e.kind === 'empty_trash')!;
|
|
expect(empty.payload.count).toBe(2);
|
|
});
|
|
|
|
it('emptyTrash returns count=0 when trash empty', async () => {
|
|
const svc = new CaptureService(repo, store, {
|
|
enqueue: async () => {},
|
|
celebrate: () => {},
|
|
telemetry: { emit: async (ev) => { events.push(ev); } }
|
|
});
|
|
const r = await svc.emptyTrash();
|
|
expect(r.count).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('CaptureService.listExpired (dedup signature)', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
let store: MediaStore;
|
|
let tmp: string;
|
|
let calls: Array<{ kind: string; payload: any }>;
|
|
let svc: CaptureService;
|
|
|
|
function addExpired(id: string, dueDate: string, createdAt: string = '2026-04-30T10:00:00Z'): void {
|
|
db.prepare(
|
|
`INSERT INTO notes
|
|
(id, raw_text, ai_status, due_date, created_at, updated_at)
|
|
VALUES (?, ?, 'done', ?, ?, ?)`
|
|
).run(id, id, dueDate, createdAt, createdAt);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
|
|
store = new MediaStore(tmp);
|
|
calls = [];
|
|
svc = new CaptureService(repo, store, {
|
|
enqueue: async () => {},
|
|
celebrate: () => {},
|
|
telemetry: { emit: async (input) => { calls.push(input as any); } }
|
|
});
|
|
});
|
|
|
|
it('emits expired_banner_shown on first call when candidates > 0', async () => {
|
|
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
|
|
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
|
|
const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
|
expect(r).toHaveLength(2);
|
|
expect(calls).toContainEqual(
|
|
expect.objectContaining({ kind: 'expired_banner_shown', payload: { candidateCount: 2 } })
|
|
);
|
|
});
|
|
|
|
it('does NOT re-emit on second call with identical candidate set (dedup)', async () => {
|
|
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
|
|
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
|
|
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
|
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
|
const showns = calls.filter((c) => c.kind === 'expired_banner_shown');
|
|
expect(showns).toHaveLength(1);
|
|
});
|
|
|
|
it('re-emits when candidate set changes (count or first-3-ids)', async () => {
|
|
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
|
|
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
|
|
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
|
addExpired('n3', '2026-04-23', '2026-04-30T12:00:00Z');
|
|
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
|
const showns = calls.filter((c) => c.kind === 'expired_banner_shown');
|
|
expect(showns).toHaveLength(2);
|
|
expect(showns[1]!.payload).toMatchObject({ candidateCount: 3 });
|
|
});
|
|
|
|
it('does NOT emit when candidates is empty', async () => {
|
|
const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
|
expect(r).toEqual([]);
|
|
expect(calls.filter((c) => c.kind === 'expired_banner_shown')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('CaptureService.trashExpiredBatch', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
let store: MediaStore;
|
|
let tmp: string;
|
|
let calls: Array<{ kind: string; payload: any }>;
|
|
let svc: CaptureService;
|
|
|
|
function addExpired(id: string, dueDate: string): void {
|
|
db.prepare(
|
|
`INSERT INTO notes
|
|
(id, raw_text, ai_status, due_date, created_at, updated_at)
|
|
VALUES (?, ?, 'done', ?, ?, ?)`
|
|
).run(id, id, dueDate, '2026-04-30T10:00:00Z', '2026-04-30T10:00:00Z');
|
|
}
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
|
|
store = new MediaStore(tmp);
|
|
calls = [];
|
|
svc = new CaptureService(repo, store, {
|
|
enqueue: async () => {},
|
|
celebrate: () => {},
|
|
telemetry: { emit: async (input) => { calls.push(input as any); } }
|
|
});
|
|
});
|
|
|
|
it('emits expired_batch_trash with trashedCount + no per-id trash emit', async () => {
|
|
addExpired('n1', '2026-04-20');
|
|
addExpired('n2', '2026-04-22');
|
|
const r = await svc.trashExpiredBatch(['n1', 'n2']);
|
|
expect(r.trashedCount).toBe(2);
|
|
expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([
|
|
expect.objectContaining({ kind: 'expired_batch_trash', payload: { count: 2 } })
|
|
]);
|
|
expect(calls.filter((c) => c.kind === 'trash')).toEqual([]);
|
|
});
|
|
|
|
it('returns trashedCount=0 for empty array (no emit)', async () => {
|
|
const r = await svc.trashExpiredBatch([]);
|
|
expect(r.trashedCount).toBe(0);
|
|
expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('CaptureService.restoreNote — enqueue on failed/pending (#10 production path)', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
let store: MediaStore;
|
|
let tmp: string;
|
|
let enqueued: string[];
|
|
let svc: CaptureService;
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
tmp = mkdtempSync(join(tmpdir(), 'inkling-restore-'));
|
|
store = new MediaStore(tmp);
|
|
enqueued = [];
|
|
svc = new CaptureService(repo, store, {
|
|
enqueue: async (id) => { enqueued.push(id); },
|
|
celebrate: () => {}
|
|
});
|
|
});
|
|
|
|
it('restoreNote calls worker.enqueue when restoring failed note', async () => {
|
|
const { id } = repo.create({ rawText: 'x' });
|
|
repo.markAiFailed(id, 'unreachable');
|
|
repo.trash(id, new Date().toISOString());
|
|
enqueued.length = 0; // reset
|
|
|
|
await svc.restoreNote(id);
|
|
|
|
expect(repo.findById(id)!.aiStatus).toBe('pending');
|
|
expect(enqueued).toContain(id);
|
|
});
|
|
|
|
it('restoreNote does not enqueue done note', async () => {
|
|
const { id } = repo.create({ rawText: 'x' });
|
|
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
|
repo.trash(id, new Date().toISOString());
|
|
enqueued.length = 0; // reset
|
|
|
|
await svc.restoreNote(id);
|
|
|
|
expect(repo.findById(id)!.aiStatus).toBe('done');
|
|
expect(enqueued).not.toContain(id);
|
|
});
|
|
});
|
|
|
|
describe('CaptureService.retryAllFailed', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
let store: MediaStore;
|
|
let tmp: string;
|
|
let calls: Array<{ kind: string; payload: any }>;
|
|
let enqueued: string[];
|
|
let svc: CaptureService;
|
|
|
|
function makeFailed(rawText: string): string {
|
|
const { id } = repo.create({ rawText });
|
|
db.prepare(`UPDATE notes SET ai_status='failed', ai_error='boom' WHERE id=?`).run(id);
|
|
db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
|
|
return id;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
|
|
store = new MediaStore(tmp);
|
|
calls = [];
|
|
enqueued = [];
|
|
svc = new CaptureService(repo, store, {
|
|
enqueue: async (id) => { enqueued.push(id); },
|
|
celebrate: () => {},
|
|
telemetry: { emit: async (input) => { calls.push(input as any); } }
|
|
});
|
|
});
|
|
|
|
it('retryAllFailed — enqueue per id + ai_retry_manual emit', async () => {
|
|
const a = makeFailed('a');
|
|
const b = makeFailed('b');
|
|
const r = await svc.retryAllFailed();
|
|
expect(r.count).toBe(2);
|
|
expect(enqueued.sort()).toEqual([a, b].sort());
|
|
expect(calls).toContainEqual(
|
|
expect.objectContaining({ kind: 'ai_retry_manual', payload: { failedCount: 2 } })
|
|
);
|
|
});
|
|
|
|
it('retryAllFailed empty — count=0, no emit', async () => {
|
|
const r = await svc.retryAllFailed();
|
|
expect(r.count).toBe(0);
|
|
expect(enqueued).toEqual([]);
|
|
expect(calls.filter((c) => c.kind === 'ai_retry_manual')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('CaptureService ai_enabled toggle (v0.2.9 Cut B)', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
let store: MediaStore;
|
|
let tmp: string;
|
|
let enqueued: string[];
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
tmp = mkdtempSync(join(tmpdir(), 'inkling-aitoggle-'));
|
|
store = new MediaStore(tmp);
|
|
enqueued = [];
|
|
});
|
|
|
|
it('ai_enabled=false → ai_status=disabled, no enqueue, no pending_jobs row', async () => {
|
|
const settings = { isAiEnabled: async () => false };
|
|
const svc = new CaptureService(repo, store, {
|
|
enqueue: async (id) => { enqueued.push(id); },
|
|
celebrate: () => {},
|
|
settings
|
|
});
|
|
const { noteId } = await svc.submit({ text: 'no-ai', images: [] });
|
|
expect(repo.findById(noteId)?.aiStatus).toBe('disabled');
|
|
expect(enqueued).toEqual([]);
|
|
const row = db.prepare('SELECT note_id FROM pending_jobs WHERE note_id=?').get(noteId);
|
|
expect(row).toBeUndefined();
|
|
});
|
|
|
|
it('ai_enabled=true → default pending + enqueue (parity with no settings dep)', async () => {
|
|
const settings = { isAiEnabled: async () => true };
|
|
const svc = new CaptureService(repo, store, {
|
|
enqueue: async (id) => { enqueued.push(id); },
|
|
celebrate: () => {},
|
|
settings
|
|
});
|
|
const { noteId } = await svc.submit({ text: 'with-ai', images: [] });
|
|
expect(repo.findById(noteId)?.aiStatus).toBe('pending');
|
|
expect(enqueued).toEqual([noteId]);
|
|
const row = db.prepare('SELECT note_id FROM pending_jobs WHERE note_id=?').get(noteId);
|
|
expect(row).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('CaptureService recall methods (v0.2.3 #6)', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
let store: MediaStore;
|
|
let tmp: string;
|
|
let emits: Array<{ kind: string; payload: any }>;
|
|
let service: CaptureService;
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
tmp = mkdtempSync(join(tmpdir(), 'inkling-recall-'));
|
|
store = new MediaStore(tmp);
|
|
emits = [];
|
|
service = new CaptureService(repo, store, {
|
|
enqueue: async () => {},
|
|
celebrate: () => {},
|
|
telemetry: { emit: async (ev) => { emits.push(ev as any); } }
|
|
});
|
|
});
|
|
|
|
it('listRecallCandidate delegates to repo.findRecallCandidate', async () => {
|
|
const id = repo.create({ rawText: 'old' }).id;
|
|
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
|
// No last_recalled_at → eligible immediately
|
|
const candidate = await service.listRecallCandidate();
|
|
expect(candidate?.id).toBe(id);
|
|
});
|
|
|
|
it('markRecallOpened updates last_recalled_at and emits recall_opened', async () => {
|
|
const id = repo.create({ rawText: 'x' }).id;
|
|
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
|
const before = repo.findById(id)!.lastRecalledAt;
|
|
expect(before).toBeNull();
|
|
await service.markRecallOpened(id);
|
|
expect(repo.findById(id)!.lastRecalledAt).not.toBeNull();
|
|
expect(emits.find((e) => e.kind === 'recall_opened')).toBeDefined();
|
|
});
|
|
|
|
it('dismissRecall updates recall_dismissed_at and emits recall_dismissed', async () => {
|
|
const id = repo.create({ rawText: 'x' }).id;
|
|
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
|
expect(repo.findById(id)!.recallDismissedAt).toBeNull();
|
|
await service.dismissRecall(id);
|
|
expect(repo.findById(id)!.recallDismissedAt).not.toBeNull();
|
|
expect(emits.find((e) => e.kind === 'recall_dismissed')).toBeDefined();
|
|
});
|
|
|
|
it('emitRecallShown emits with ageDays from createdAt when never recalled', async () => {
|
|
const id = repo.create({ rawText: 'x' }).id;
|
|
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
|
// Backdate created_at to 14 days ago
|
|
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`)
|
|
.run(new Date(Date.now() - 14 * 86_400_000).toISOString(), id);
|
|
await service.emitRecallShown(id);
|
|
const shown = emits.find((e) => e.kind === 'recall_shown');
|
|
expect(shown).toBeDefined();
|
|
const payload = shown!.payload as { noteId: string; ageDays: number };
|
|
expect(payload.noteId).toBe(id);
|
|
expect(payload.ageDays).toBeGreaterThanOrEqual(13);
|
|
expect(payload.ageDays).toBeLessThanOrEqual(15);
|
|
});
|
|
});
|