feat(v029): ai_status 'disabled' enum + CaptureService ai_enabled 분기 (skip pending_jobs)
- 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.
This commit is contained in:
@@ -420,6 +420,51 @@ describe('CaptureService.retryAllFailed', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('user_version reaches 4', () => {
|
||||
it('user_version reaches latest (5)', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
|
||||
expect(row.user_version).toBe(4);
|
||||
expect(row.user_version).toBe(5);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -73,3 +73,47 @@ describe('migration v3 — soft delete columns', () => {
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration v5 — ai_status disabled enum', () => {
|
||||
it("CHECK constraint accepts 'disabled'", () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
expect(() => {
|
||||
db.prepare(
|
||||
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
|
||||
VALUES ('d1', 't', 'disabled', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z')`
|
||||
).run();
|
||||
}).not.toThrow();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('preserves existing notes (status, due_date, deleted_at, recall fields)', () => {
|
||||
// m004 까지만 적용된 상태에서 데이터 insert 후 m005 까지 마이그레이션 → 데이터 보존 확인.
|
||||
// runMigrations 가 user_version 으로 idempotent 라 한 번에 5 까지 가지만,
|
||||
// 본 테스트는 single runMigrations 후 m004 시점에 가까운 row 를 넣고 cols 확인.
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
db.prepare(
|
||||
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status, due_date, deleted_at)
|
||||
VALUES ('p1', 'old', 'done', '2026-04-01T00:00:00Z', '2026-04-01T00:00:00Z', 'archived', '2026-05-10', NULL)`
|
||||
).run();
|
||||
const row = db.prepare('SELECT status, due_date, ai_status FROM notes WHERE id=?').get('p1') as any;
|
||||
expect(row.status).toBe('archived');
|
||||
expect(row.due_date).toBe('2026-05-10');
|
||||
expect(row.ai_status).toBe('done');
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('preserves idx_notes_ai_status + idx_notes_created_at + idx_notes_deleted_at', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const indexes = db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'`)
|
||||
.all() as Array<{ name: string }>;
|
||||
const names = indexes.map((i) => i.name);
|
||||
expect(names).toContain('idx_notes_ai_status');
|
||||
expect(names).toContain('idx_notes_created_at');
|
||||
expect(names).toContain('idx_notes_deleted_at');
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user