import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask, } from '../src/core/services/taskService.js'; import { init } from '../src/cli/commands/init.js'; import { Index } from '../src/core/index.js'; describe('taskService', () => { let cwd: string; beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'ah-task-')); }); afterEach(() => { rmSync(cwd, { recursive: true, force: true }); }); it('creates a task', () => { const task = createTask(cwd, { title: 'Test', role: 'implementer' }); expect(task.id).toBe('TSK-0001'); expect(task.title).toBe('Test'); expect(task.status).toBe('open'); }); it('lists tasks', () => { createTask(cwd, { title: 'A', role: 'implementer' }); expect(listTasks(cwd)).toHaveLength(1); }); it('claims and completes a task', () => { const task = createTask(cwd, { title: 'B', role: 'implementer' }); const claimed = claimTask(cwd, task.id, 'codex'); expect(claimed.status).toBe('in_progress'); expect(claimed.assignedTo).toBe('codex'); const done = doneTask(cwd, task.id); expect(done.status).toBe('done'); }); it('transitions a task to review', () => { const task = createTask(cwd, { title: 'C', role: 'implementer' }); const inReview = reviewTask(cwd, task.id); expect(inReview.status).toBe('review'); // Verify index is updated const listed = listTasks(cwd, { status: 'review' }); expect(listed).toHaveLength(1); expect(listed[0].id).toBe(task.id); }); it('records an explicit reviewer separately from the assignee', () => { const task = createTask(cwd, { title: 'Review me', role: 'implementer', assignedTo: 'codex' }); const inReview = reviewTask(cwd, task.id, 'claude'); expect(inReview.status).toBe('review'); expect(inReview.assignedTo).toBe('codex'); expect(inReview.reviewer).toBe('claude'); const listed = listTasks(cwd, { status: 'review' }); expect(listed[0].assignedTo).toBe('codex'); expect(listed[0].reviewer).toBe('claude'); }); it('uses the preferred reviewer when reviewTask is called without one', () => { init(cwd, { projectName: 'reviewer-test', yes: true }); const task = createTask(cwd, { title: 'Review default', role: 'implementer', assignedTo: 'codex' }); const inReview = reviewTask(cwd, task.id); expect(inReview.reviewer).toBe('claude'); }); it('cancels a task', () => { const task = createTask(cwd, { title: 'D', role: 'implementer' }); const cancelled = cancelTask(cwd, task.id); expect(cancelled.status).toBe('cancelled'); const listed = listTasks(cwd, { status: 'cancelled' }); expect(listed).toHaveLength(1); }); it('reopens a task (any status → open)', () => { const task = createTask(cwd, { title: 'E', role: 'implementer' }); cancelTask(cwd, task.id); const reopened = reopenTask(cwd, task.id); expect(reopened.status).toBe('open'); const listed = listTasks(cwd, { status: 'open' }); expect(listed).toHaveLength(1); }); it('getTask reads back the correct task', () => { const task = createTask(cwd, { title: 'F', role: 'architect' }); const { task: read } = getTask(cwd, task.id); expect(read.title).toBe('F'); expect(read.role).toBe('architect'); }); it('deletes a task: gone from the list, file, and search index', () => { const keep = createTask(cwd, { title: 'keep me', role: 'implementer' }); const ghost = createTask(cwd, { title: 'ghost purge me', role: 'implementer' }); expect(listTasks(cwd)).toHaveLength(2); const res = deleteTask(cwd, ghost.id); expect(res.id).toBe(ghost.id); // Removed from the index list… const remaining = listTasks(cwd); expect(remaining).toHaveLength(1); expect(remaining[0].id).toBe(keep.id); // …its markdown file is gone (getTask throws)… expect(() => getTask(cwd, ghost.id)).toThrow(); // …and it no longer surfaces in the FTS search index. const index = new Index(cwd); const hits = index.search('purge').map((h) => h.id); index.close(); expect(hits).not.toContain(ghost.id); }); it('deleteTask is idempotent (deleting a missing task does not throw)', () => { expect(() => deleteTask(cwd, 'TSK-9999')).not.toThrow(); }); });