import type { Task, TodoistApi } from '@doist/todoist-api-typescript'
import { type Mocked, vi } from 'vitest'
import { convertPriorityToNumber } from '../../utils/priorities.js'
import { createMockTask, createMockUser, TEST_IDS, TODAY } from '../../utils/test-helpers.js'
import { ToolNames } from '../../utils/tool-names.js'
import { addTasks } from '../add-tasks.js'
// Mock the Todoist API
const mockTodoistApi = {
addTask: vi.fn(),
getUser: vi.fn(),
} as unknown as Mocked<TodoistApi>
const { ADD_TASKS } = ToolNames
describe(`${ADD_TASKS} tool`, () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('adding multiple tasks', () => {
it('should add multiple tasks and return mapped results', async () => {
// Mock API responses extracted from recordings (Task type)
const mockApiResponse1: Task = createMockTask({
id: '8485093748',
content: 'First task content',
url: 'https://todoist.com/showTask?id=8485093748',
addedAt: '2025-08-13T22:09:56.123456Z',
})
const mockApiResponse2: Task = createMockTask({
id: '8485093749',
content: 'Second task content',
description: 'Task description',
labels: ['work', 'urgent'],
childOrder: 2,
priority: 'p3',
url: 'https://todoist.com/showTask?id=8485093749',
addedAt: '2025-08-13T22:09:57.123456Z',
due: {
date: '2025-08-15',
isRecurring: false,
lang: 'en',
string: 'Aug 15',
timezone: null,
},
})
mockTodoistApi.addTask
.mockResolvedValueOnce(mockApiResponse1)
.mockResolvedValueOnce(mockApiResponse2)
const result = await addTasks.execute(
{
tasks: [
{
content: 'First task content',
projectId: '6cfCcrrCFg2xP94Q',
},
{
content: 'Second task content',
description: 'Task description',
priority: 'p2',
dueString: 'Aug 15',
projectId: '6cfCcrrCFg2xP94Q',
},
],
},
mockTodoistApi,
)
// Verify API was called correctly for each task
expect(mockTodoistApi.addTask).toHaveBeenCalledTimes(2)
expect(mockTodoistApi.addTask).toHaveBeenNthCalledWith(1, {
content: 'First task content',
projectId: '6cfCcrrCFg2xP94Q',
sectionId: undefined,
parentId: undefined,
})
expect(mockTodoistApi.addTask).toHaveBeenNthCalledWith(2, {
content: 'Second task content',
description: 'Task description',
priority: convertPriorityToNumber('p2'),
dueString: 'Aug 15',
projectId: '6cfCcrrCFg2xP94Q',
sectionId: undefined,
parentId: undefined,
})
// Verify result is a concise summary
expect(result.textContent).toMatchSnapshot()
// Verify structured content
const structuredContent = result.structuredContent
expect(structuredContent.tasks).toHaveLength(2)
expect(structuredContent).toEqual(
expect.objectContaining({
totalCount: 2,
tasks: expect.arrayContaining([
expect.objectContaining({ id: '8485093748' }),
expect.objectContaining({ id: '8485093749' }),
]),
}),
)
})
it('should handle tasks with section and parent IDs', async () => {
const mockApiResponse: Task = createMockTask({
id: '8485093750',
content: 'Subtask content',
description: 'Subtask description',
priority: 'p2',
sectionId: 'section-123',
parentId: 'parent-task-456',
url: 'https://todoist.com/showTask?id=8485093750',
addedAt: '2025-08-13T22:09:58.123456Z',
})
mockTodoistApi.addTask.mockResolvedValue(mockApiResponse)
const result = await addTasks.execute(
{
tasks: [
{
content: 'Subtask content',
description: 'Subtask description',
priority: 'p3',
projectId: '6cfCcrrCFg2xP94Q',
sectionId: 'section-123',
parentId: 'parent-task-456',
},
],
},
mockTodoistApi,
)
expect(mockTodoistApi.addTask).toHaveBeenCalledWith({
content: 'Subtask content',
description: 'Subtask description',
priority: convertPriorityToNumber('p3'),
projectId: '6cfCcrrCFg2xP94Q',
sectionId: 'section-123',
parentId: 'parent-task-456',
})
// Verify result is a concise summary
expect(result.textContent).toMatchSnapshot()
// Verify structured content
const structuredContent = result.structuredContent
expect(structuredContent).toEqual(
expect.objectContaining({
totalCount: 1,
tasks: expect.arrayContaining([expect.objectContaining({ id: '8485093750' })]),
}),
)
})
it('should add tasks with duration', async () => {
const mockApiResponse1: Task = createMockTask({
id: '8485093752',
content: 'Task with 2 hour duration',
duration: { amount: 120, unit: 'minute' },
url: 'https://todoist.com/showTask?id=8485093752',
addedAt: '2025-08-13T22:09:56.123456Z',
})
const mockApiResponse2: Task = createMockTask({
id: '8485093753',
content: 'Task with 45 minute duration',
duration: { amount: 45, unit: 'minute' },
url: 'https://todoist.com/showTask?id=8485093753',
addedAt: '2025-08-13T22:09:57.123456Z',
})
mockTodoistApi.addTask
.mockResolvedValueOnce(mockApiResponse1)
.mockResolvedValueOnce(mockApiResponse2)
const result = await addTasks.execute(
{
tasks: [
{
content: 'Task with 2 hour duration',
duration: '2h',
projectId: '6cfCcrrCFg2xP94Q',
},
{
content: 'Task with 45 minute duration',
duration: '45m',
projectId: '6cfCcrrCFg2xP94Q',
},
],
},
mockTodoistApi,
)
// Verify API was called with parsed duration
expect(mockTodoistApi.addTask).toHaveBeenNthCalledWith(1, {
content: 'Task with 2 hour duration',
projectId: '6cfCcrrCFg2xP94Q',
sectionId: undefined,
parentId: undefined,
duration: 120,
durationUnit: 'minute',
})
expect(mockTodoistApi.addTask).toHaveBeenNthCalledWith(2, {
content: 'Task with 45 minute duration',
projectId: '6cfCcrrCFg2xP94Q',
sectionId: undefined,
parentId: undefined,
duration: 45,
durationUnit: 'minute',
})
// Verify result is a concise summary
expect(result.textContent).toMatchSnapshot()
// Verify structured content
const structuredContent = result.structuredContent
expect(structuredContent.tasks).toHaveLength(2)
expect(structuredContent).toEqual(
expect.objectContaining({
totalCount: 2,
tasks: expect.arrayContaining([
expect.objectContaining({ id: '8485093752' }),
expect.objectContaining({ id: '8485093753' }),
]),
}),
)
})
it('should handle various duration formats', async () => {
const mockApiResponse: Task = createMockTask({
id: '8485093754',
content: 'Task with combined duration',
duration: { amount: 150, unit: 'minute' },
url: 'https://todoist.com/showTask?id=8485093754',
addedAt: '2025-08-13T22:09:56.123456Z',
})
mockTodoistApi.addTask.mockResolvedValue(mockApiResponse)
// Test different duration formats
const testCases = [
{ input: '2h30m', expectedMinutes: 150 },
{ input: '1.5h', expectedMinutes: 90 },
{ input: ' 90m ', expectedMinutes: 90 },
{ input: '2H30M', expectedMinutes: 150 },
]
for (const testCase of testCases) {
mockTodoistApi.addTask.mockClear()
await addTasks.execute(
{
tasks: [
{
content: 'Test task',
duration: testCase.input,
projectId: '6cfCcrrCFg2xP94Q',
},
],
},
mockTodoistApi,
)
expect(mockTodoistApi.addTask).toHaveBeenCalledWith(
expect.objectContaining({
duration: testCase.expectedMinutes,
durationUnit: 'minute',
}),
)
}
})
it('should add task with deadline', async () => {
const mockApiResponse: Task = createMockTask({
id: '8485093756',
content: 'Task with deadline',
deadline: {
date: '2025-12-31',
lang: 'en',
},
url: 'https://todoist.com/showTask?id=8485093756',
addedAt: '2025-08-13T22:09:56.123456Z',
})
mockTodoistApi.addTask.mockResolvedValue(mockApiResponse)
const result = await addTasks.execute(
{
tasks: [
{
content: 'Task with deadline',
projectId: '6cfCcrrCFg2xP94Q',
deadlineDate: '2025-12-31',
},
],
},
mockTodoistApi,
)
// Verify API was called with deadline
expect(mockTodoistApi.addTask).toHaveBeenCalledWith({
content: 'Task with deadline',
projectId: '6cfCcrrCFg2xP94Q',
sectionId: undefined,
parentId: undefined,
deadlineDate: '2025-12-31',
})
// Verify result is a concise summary
expect(result.textContent).toMatchSnapshot()
// Verify structured content includes deadline
const structuredContent = result.structuredContent
expect(structuredContent.tasks).toHaveLength(1)
expect(structuredContent).toEqual(
expect.objectContaining({
totalCount: 1,
tasks: expect.arrayContaining([
expect.objectContaining({
id: '8485093756',
deadlineDate: '2025-12-31',
}),
]),
}),
)
})
it('should add task with labels', async () => {
const mockApiResponse: Task = createMockTask({
id: '8485093755',
content: 'Task with labels',
labels: ['urgent', 'work'],
url: 'https://todoist.com/showTask?id=8485093755',
addedAt: '2025-08-13T22:09:56.123456Z',
})
mockTodoistApi.addTask.mockResolvedValue(mockApiResponse)
const result = await addTasks.execute(
{
tasks: [
{
content: 'Task with labels',
labels: ['urgent', 'work'],
projectId: '6cfCcrrCFg2xP94Q',
},
],
},
mockTodoistApi,
)
expect(mockTodoistApi.addTask).toHaveBeenCalledWith({
content: 'Task with labels',
labels: ['urgent', 'work'],
projectId: '6cfCcrrCFg2xP94Q',
sectionId: undefined,
parentId: undefined,
})
// Verify structured content includes labels
const structuredContent = result.structuredContent
expect(structuredContent.tasks).toHaveLength(1)
expect(structuredContent.tasks).toEqual(
expect.arrayContaining([expect.objectContaining({ labels: ['urgent', 'work'] })]),
)
})
it('should add task with empty labels array', async () => {
const mockApiResponse: Task = createMockTask({
id: '8485093756',
content: 'Task with empty labels',
labels: [],
url: 'https://todoist.com/showTask?id=8485093756',
addedAt: '2025-08-13T22:09:56.123456Z',
})
mockTodoistApi.addTask.mockResolvedValue(mockApiResponse)
await addTasks.execute(
{
tasks: [
{
content: 'Task with empty labels',
labels: [],
projectId: '6cfCcrrCFg2xP94Q',
},
],
},
mockTodoistApi,
)
expect(mockTodoistApi.addTask).toHaveBeenCalledWith({
content: 'Task with empty labels',
labels: [],
projectId: '6cfCcrrCFg2xP94Q',
sectionId: undefined,
parentId: undefined,
})
})
it('should add task without labels field', async () => {
const mockApiResponse: Task = createMockTask({
id: '8485093757',
content: 'Task without labels',
url: 'https://todoist.com/showTask?id=8485093757',
addedAt: '2025-08-13T22:09:56.123456Z',
})
mockTodoistApi.addTask.mockResolvedValue(mockApiResponse)
await addTasks.execute(
{
tasks: [
{
content: 'Task without labels',
projectId: '6cfCcrrCFg2xP94Q',
},
],
},
mockTodoistApi,
)
expect(mockTodoistApi.addTask).toHaveBeenCalledWith({
content: 'Task without labels',
labels: undefined,
projectId: '6cfCcrrCFg2xP94Q',
sectionId: undefined,
parentId: undefined,
})
})
it('should add multiple tasks with different label configurations', async () => {
const mockApiResponse1: Task = createMockTask({
id: '8485093758',
content: 'Task with labels',
labels: ['personal'],
})
const mockApiResponse2: Task = createMockTask({
id: '8485093759',
content: 'Task without labels',
})
const mockApiResponse3: Task = createMockTask({
id: '8485093760',
content: 'Task with multiple labels',
labels: ['work', 'urgent', 'review'],
})
mockTodoistApi.addTask
.mockResolvedValueOnce(mockApiResponse1)
.mockResolvedValueOnce(mockApiResponse2)
.mockResolvedValueOnce(mockApiResponse3)
await addTasks.execute(
{
tasks: [
{
content: 'Task with labels',
labels: ['personal'],
projectId: '6cfCcrrCFg2xP94Q',
},
{
content: 'Task without labels',
projectId: '6cfCcrrCFg2xP94Q',
},
{
content: 'Task with multiple labels',
labels: ['work', 'urgent', 'review'],
projectId: '6cfCcrrCFg2xP94Q',
},
],
},
mockTodoistApi,
)
expect(mockTodoistApi.addTask).toHaveBeenCalledTimes(3)
expect(mockTodoistApi.addTask).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
labels: ['personal'],
}),
)
expect(mockTodoistApi.addTask).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
labels: undefined,
}),
)
expect(mockTodoistApi.addTask).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
labels: ['work', 'urgent', 'review'],
}),
)
})
})
describe('error handling', () => {
it('should throw error for invalid duration format', async () => {
await expect(
addTasks.execute(
{
tasks: [
{
content: 'Task with invalid duration',
duration: 'invalid',
projectId: '6cfCcrrCFg2xP94Q',
},
],
},
mockTodoistApi,
),
).rejects.toThrow(
'Task "Task with invalid duration": Invalid duration format "invalid"',
)
})
it('should throw error for duration exceeding 24 hours', async () => {
await expect(
addTasks.execute(
{
tasks: [
{
content: 'Task with too long duration',
duration: '25h',
projectId: '6cfCcrrCFg2xP94Q',
},
],
},
mockTodoistApi,
),
).rejects.toThrow(
'Task "Task with too long duration": Invalid duration format "25h": Duration cannot exceed 24 hours (1440 minutes)',
)
})
it('should propagate API errors', async () => {
const apiError = new Error('API Error: Task content is required')
mockTodoistApi.addTask.mockRejectedValue(apiError)
await expect(
addTasks.execute(
{ tasks: [{ content: '', projectId: '6cfCcrrCFg2xP94Q' }] },
mockTodoistApi,
),
).rejects.toThrow(apiError.message)
})
it('should handle partial failures when adding multiple tasks', async () => {
const mockApiResponse: Task = createMockTask({
id: '8485093751',
content: 'First task content',
url: 'https://todoist.com/showTask?id=8485093751',
addedAt: '2025-08-13T22:09:59.123456Z',
})
const apiError = new Error('API Error: Second task failed')
mockTodoistApi.addTask
.mockResolvedValueOnce(mockApiResponse)
.mockRejectedValueOnce(apiError)
await expect(
addTasks.execute(
{
tasks: [
{ content: 'First task content', projectId: '6cfCcrrCFg2xP94Q' },
{ content: 'Second task content', projectId: '6cfCcrrCFg2xP94Q' },
],
},
mockTodoistApi,
),
).rejects.toThrow('API Error: Second task failed')
// Verify first task was attempted
expect(mockTodoistApi.addTask).toHaveBeenCalledTimes(2)
})
})
describe('next steps logic', () => {
it('should suggest find-tasks-by-date for today when hasToday is true', async () => {
// Clear any leftover mocks from previous tests
mockTodoistApi.addTask.mockClear()
const mockApiResponse: Task = createMockTask({
id: '8485093755',
content: 'Task due today',
url: 'https://todoist.com/showTask?id=8485093755',
addedAt: '2025-08-13T22:09:56.123456Z',
due: {
date: TODAY,
isRecurring: false,
lang: 'en',
string: 'today',
timezone: null,
},
})
mockTodoistApi.addTask.mockResolvedValue(mockApiResponse)
const result = await addTasks.execute(
{
tasks: [
{
content: 'Task due today',
dueString: 'today',
projectId: '6cfCcrrCFg2xP94Q',
},
],
},
mockTodoistApi,
)
const textContent = result.textContent
expect(textContent).toMatchSnapshot()
})
it('should suggest overview tool when no hasToday context', async () => {
// Clear any leftover mocks from previous tests
mockTodoistApi.addTask.mockClear()
const mockApiResponse: Task = createMockTask({
id: '8485093756',
content: 'Regular task',
url: 'https://todoist.com/showTask?id=8485093756',
addedAt: '2025-08-13T22:09:56.123456Z',
})
mockTodoistApi.addTask.mockResolvedValue(mockApiResponse)
const result = await addTasks.execute(
{
tasks: [{ content: 'Regular task', projectId: '6cfCcrrCFg2xP94Q' }],
},
mockTodoistApi,
)
const textContent = result.textContent
expect(textContent).toMatchSnapshot()
})
})
describe('tasks without project context', () => {
it('should allow creating tasks with only content (goes to Inbox)', async () => {
const mockApiResponse: Task = createMockTask({
id: '8485093758',
content: 'Simple inbox task',
url: 'https://todoist.com/showTask?id=8485093758',
addedAt: '2025-08-13T22:09:56.123456Z',
})
mockTodoistApi.addTask.mockResolvedValue(mockApiResponse)
const result = await addTasks.execute(
{
tasks: [
{
content: 'Simple inbox task',
},
],
},
mockTodoistApi,
)
expect(mockTodoistApi.addTask).toHaveBeenCalledWith({
content: 'Simple inbox task',
labels: undefined,
projectId: undefined,
sectionId: undefined,
parentId: undefined,
})
const textContent = result.textContent
expect(textContent).toContain('Added 1 task')
expect(textContent).toContain('Simple inbox task')
})
it('should prevent assignment without project context', async () => {
await expect(
addTasks.execute(
{
tasks: [
{
content: 'Task with assignment but no project',
responsibleUser: 'user@example.com',
},
],
},
mockTodoistApi,
),
).rejects.toThrow(
'Task "Task with assignment but no project": Cannot assign tasks without specifying project context. Please specify a projectId, sectionId, or parentId.',
)
})
})
describe('inbox project ID resolution', () => {
it('should resolve "inbox" to actual inbox project ID', async () => {
const mockUser = createMockUser({
inboxProjectId: TEST_IDS.PROJECT_INBOX,
})
const mockApiResponse: Task = createMockTask({
id: '8485093760',
content: 'Task for inbox',
projectId: TEST_IDS.PROJECT_INBOX,
url: 'https://todoist.com/showTask?id=8485093760',
addedAt: '2025-08-13T22:09:56.123456Z',
})
// Mock the API calls
mockTodoistApi.getUser.mockResolvedValue(mockUser)
mockTodoistApi.addTask.mockResolvedValue(mockApiResponse)
const result = await addTasks.execute(
{
tasks: [
{
content: 'Task for inbox',
projectId: 'inbox',
},
],
},
mockTodoistApi,
)
// Verify getUser was called to resolve inbox
expect(mockTodoistApi.getUser).toHaveBeenCalledTimes(1)
// Verify addTask was called with resolved inbox project ID
expect(mockTodoistApi.addTask).toHaveBeenCalledWith({
content: 'Task for inbox',
projectId: TEST_IDS.PROJECT_INBOX,
sectionId: undefined,
parentId: undefined,
labels: undefined,
})
// Verify result contains the task
const structuredContent = result.structuredContent
expect(structuredContent.totalCount).toBe(1)
expect(structuredContent.tasks).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: '8485093760',
projectId: TEST_IDS.PROJECT_INBOX,
}),
]),
)
})
it('should not call getUser when projectId is not "inbox"', async () => {
const mockApiResponse: Task = createMockTask({
id: '8485093761',
content: 'Regular task',
projectId: '6cfCcrrCFg2xP94Q',
url: 'https://todoist.com/showTask?id=8485093761',
addedAt: '2025-08-13T22:09:56.123456Z',
})
mockTodoistApi.addTask.mockResolvedValue(mockApiResponse)
await addTasks.execute(
{
tasks: [
{
content: 'Regular task',
projectId: '6cfCcrrCFg2xP94Q',
},
],
},
mockTodoistApi,
)
// Verify getUser was NOT called for regular project ID
expect(mockTodoistApi.getUser).not.toHaveBeenCalled()
// Verify addTask was called with original project ID
expect(mockTodoistApi.addTask).toHaveBeenCalledWith({
content: 'Regular task',
projectId: '6cfCcrrCFg2xP94Q',
sectionId: undefined,
parentId: undefined,
labels: undefined,
})
})
})
describe('isUncompletable parameter', () => {
it('should pass isUncompletable parameter to SDK', async () => {
// Mock API response - minimal mock just to prevent errors
const mockApiResponse: Task = createMockTask({
id: '8485093999',
content: 'Project Header',
})
mockTodoistApi.addTask.mockResolvedValueOnce(mockApiResponse)
await addTasks.execute(
{
tasks: [
{
content: 'Project Header',
isUncompletable: true,
},
],
},
mockTodoistApi,
)
// Verify the parameter was passed to the SDK - this is the key test
expect(mockTodoistApi.addTask).toHaveBeenCalledWith({
content: 'Project Header',
projectId: undefined,
sectionId: undefined,
parentId: undefined,
labels: undefined,
isUncompletable: true,
})
})
})
})