Skip to main content
Glama
bulk-tasks.test.ts25.5 kB
import { describe, test, expect, beforeEach, jest } from '@jest/globals'; import { TodoistBulkTasksTool } from '../../src/tools/bulk-tasks.js'; import { TodoistApiService } from '../../src/services/todoist-api.js'; import { BulkTasksResponse, SyncCommand, SyncResponse, } from '../../src/types/bulk-operations.js'; import { MOCK_API_CONFIG } from '../helpers/test-token-config.js'; // Type guard for BulkTasksResponse function isBulkTasksResponse( response: | BulkTasksResponse | { success: false; error: { code: string; message: string } } ): response is BulkTasksResponse { return response.success === true; } // Type guard for error response function isErrorResponse( response: | BulkTasksResponse | { success: false; error: { code: string; message: string } } ): response is { success: false; error: { code: string; message: string } } { return response.success === false; } const mockApiConfig = MOCK_API_CONFIG; describe('todoist_bulk_tasks MCP Tool Contract', () => { let apiService: jest.Mocked<TodoistApiService>; let bulkTasksTool: TodoistBulkTasksTool; beforeEach(() => { // Create mock API service apiService = { executeBatch: jest.fn(), deleteTask: jest.fn(), moveTask: jest.fn(), } as unknown as jest.Mocked<TodoistApiService>; bulkTasksTool = new TodoistBulkTasksTool(mockApiConfig, { apiService: apiService as unknown as TodoistApiService, }); // Mock executeBatch for successful operations apiService.executeBatch = jest.fn(async (commands: SyncCommand[]) => { const syncStatus: Record<string, 'ok'> = {}; commands.forEach(cmd => { syncStatus[cmd.uuid] = 'ok'; }); return { sync_status: syncStatus, temp_id_mapping: {}, full_sync: false, } as SyncResponse; }); // Mock deleteTask for successful operations apiService.deleteTask = jest.fn(async () => { return Promise.resolve(); }); // Mock moveTask for successful operations apiService.moveTask = jest.fn(async () => { return Promise.resolve(); }); }); describe('Tool Registration', () => { test('should provide MCP tool metadata', () => { const definition = TodoistBulkTasksTool.getToolDefinition(); expect(definition.name).toBe('todoist_bulk_tasks'); expect(definition.description).toContain('bulk operations'); expect(definition.inputSchema).toBeDefined(); expect(definition.inputSchema.properties).toHaveProperty('action'); expect(definition.inputSchema.properties).toHaveProperty('task_ids'); }); }); // T003: Bulk update 5 tasks with due_string describe('T003: Bulk update 5 tasks with due_string', () => { test('should successfully update 5 tasks with due_string="tomorrow"', async () => { const params = { action: 'update' as const, task_ids: ['7654321', '7654322', '7654323', '7654324', '7654325'], due_string: 'tomorrow', }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(true); if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.data).toBeDefined(); expect(result.data.total_tasks).toBe(5); expect(result.data.successful).toBe(5); expect(result.data.failed).toBe(0); expect(result.data.results).toHaveLength(5); // Verify all results are successful result.data.results.forEach(r => { expect(r.success).toBe(true); expect(r.error).toBeNull(); expect(r.resource_uri).toMatch(/^todoist:\/\/task\//); }); // Verify executeBatch was called with correct commands expect(apiService.executeBatch).toHaveBeenCalledTimes(1); const commands = (apiService.executeBatch as jest.Mock).mock .calls[0][0] as Array<{ type: string; args: Record<string, unknown> }>; expect(commands).toHaveLength(5); expect(commands[0].type).toBe('item_update'); expect(commands[0].args).toHaveProperty('id'); expect(commands[0].args).toHaveProperty('due'); }); test('should match BulkTasksResponse schema structure', async () => { const params = { action: 'update' as const, task_ids: ['7654321', '7654322', '7654323', '7654324', '7654325'], due_string: 'tomorrow', }; const result = await bulkTasksTool.execute(params); // Verify response structure expect(result).toHaveProperty('success'); expect(result).toHaveProperty('data'); if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.data).toHaveProperty('total_tasks'); expect(result.data).toHaveProperty('successful'); expect(result.data).toHaveProperty('failed'); expect(result.data).toHaveProperty('results'); // Verify metadata if (result.metadata) { expect(result.metadata).toHaveProperty('deduplication_applied'); expect(result.metadata).toHaveProperty('execution_time_ms'); } }); }); // T004: Bulk complete 10 tasks describe('T004: Bulk complete 10 tasks', () => { test('should successfully complete 10 tasks', async () => { const taskIds = Array.from({ length: 10 }, (_, i) => `765432${i}`); const params = { action: 'complete' as const, task_ids: taskIds, }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(true); if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.data.total_tasks).toBe(10); expect(result.data.failed).toBe(0); expect(result.data.results).toHaveLength(10); // Verify all results have success=true result.data.results.forEach(r => { expect(r.success).toBe(true); expect(r.error).toBeNull(); }); }); test('should send item_complete commands without field updates', async () => { const taskIds = Array.from({ length: 10 }, (_, i) => `765432${i}`); const params = { action: 'complete' as const, task_ids: taskIds, }; await bulkTasksTool.execute(params); const commands = (apiService.executeBatch as jest.Mock).mock .calls[0][0] as Array<{ type: string; args: Record<string, unknown> }>; expect(commands).toHaveLength(10); commands.forEach(cmd => { expect(cmd.type).toBe('item_complete'); expect(cmd.args).toHaveProperty('id'); // Should only have id, no other fields expect(Object.keys(cmd.args)).toEqual(['id']); }); }); }); // T005: Reject >50 tasks (51 task IDs) describe('T005: Reject >50 tasks', () => { test('should reject request with 51 task IDs', async () => { const taskIds = Array.from({ length: 51 }, (_, i) => `task-${i}`); const params = { action: 'update' as const, task_ids: taskIds, due_string: 'tomorrow', }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(false); if (!isErrorResponse(result)) { throw new Error('Expected error response'); } expect(result.error).toBeDefined(); expect(result.error?.code).toBe('INVALID_PARAMS'); expect(result.error?.message).toContain('Maximum 50 tasks allowed'); expect(result.error?.message).toContain('received 51'); // Should not call API expect(apiService.executeBatch).not.toHaveBeenCalled(); }); test('should accept exactly 50 task IDs', async () => { const taskIds = Array.from({ length: 50 }, (_, i) => `task-${i}`); const params = { action: 'update' as const, task_ids: taskIds, due_string: 'tomorrow', }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(true); if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.data.total_tasks).toBe(50); }); }); // T006: Deduplicate task IDs [1,2,1,3] → 3 unique describe('T006: Deduplicate task IDs', () => { test('should deduplicate [1,2,1,3] to 3 unique tasks', async () => { const params = { action: 'update' as const, task_ids: ['7654321', '7654322', '7654321', '7654323'], due_string: 'tomorrow', }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(true); if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.data.total_tasks).toBe(3); // Not 4 // Verify metadata shows deduplication expect(result.metadata).toBeDefined(); expect(result.metadata?.deduplication_applied).toBe(true); expect(result.metadata?.original_count).toBe(4); expect(result.metadata?.deduplicated_count).toBe(3); // Verify unique task IDs const returnedIds = result.data.results.map(r => r.task_id); const uniqueIds = new Set(returnedIds); expect(uniqueIds.size).toBe(3); expect(uniqueIds).toContain('7654321'); expect(uniqueIds).toContain('7654322'); expect(uniqueIds).toContain('7654323'); }); test('should handle no deduplication needed', async () => { const params = { action: 'update' as const, task_ids: ['7654321', '7654322', '7654323'], due_string: 'tomorrow', }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(true); if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.data.total_tasks).toBe(3); // Metadata should show no deduplication expect(result.metadata?.deduplication_applied).toBe(false); expect(result.metadata?.original_count).toBe(3); expect(result.metadata?.deduplicated_count).toBe(3); }); }); // T007: Partial failure (3 valid, 2 invalid IDs) describe('T007: Partial failure', () => { test('should handle 3 successful, 2 failed tasks', async () => { // Mock executeBatch to return partial success apiService.executeBatch = jest.fn(async (commands: SyncCommand[]) => { const syncStatus: Record< string, 'ok' | { error: string; error_message: string; error_code: number } > = {}; syncStatus[commands[0].uuid] = 'ok'; syncStatus[commands[1].uuid] = { error: 'TASK_NOT_FOUND', error_message: 'Task not found', error_code: 404, }; syncStatus[commands[2].uuid] = 'ok'; syncStatus[commands[3].uuid] = { error: 'TASK_NOT_FOUND', error_message: 'Task not found', error_code: 404, }; syncStatus[commands[4].uuid] = 'ok'; return { sync_status: syncStatus, temp_id_mapping: {}, full_sync: false, } as SyncResponse; }); const params = { action: 'update' as const, task_ids: ['7654321', '9999991', '7654323', '9999992', '7654325'], priority: 2, }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(true); // Overall operation succeeds if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.data.total_tasks).toBe(5); expect(result.data.successful).toBe(3); expect(result.data.failed).toBe(2); // Verify failed results have error messages const failedResults = result.data.results.filter(r => !r.success); expect(failedResults).toHaveLength(2); failedResults.forEach(r => { expect(r.error).toContain('not found'); }); // Verify successful results const successfulResults = result.data.results.filter(r => r.success); expect(successfulResults).toHaveLength(3); successfulResults.forEach(r => { expect(r.error).toBeNull(); }); }); }); // T008: Reject content field update describe('T008: Reject content field update', () => { test('should reject request with content field', async () => { const params = { action: 'update' as const, task_ids: ['7654321', '7654322'], content: 'New title', // Disallowed field }; const result = await bulkTasksTool.execute(params as any); expect(result.success).toBe(false); if (!isErrorResponse(result)) { throw new Error('Expected error response'); } expect(result.error).toBeDefined(); expect(result.error?.message).toContain( 'Cannot modify content, description, or comments' ); // Should not call API expect(apiService.executeBatch).not.toHaveBeenCalled(); }); test('should reject request with description field', async () => { const params = { action: 'update' as const, task_ids: ['7654321', '7654322'], description: 'New description', // Disallowed field }; const result = await bulkTasksTool.execute(params as any); expect(result.success).toBe(false); if (!isErrorResponse(result)) { throw new Error('Expected error response'); } expect(result.error?.code).toBe('INVALID_PARAMS'); expect(result.error?.message).toContain( 'Cannot modify content, description, or comments' ); }); test('should reject request with comments field', async () => { const params = { action: 'update' as const, task_ids: ['7654321', '7654322'], comments: 'New comment', // Disallowed field }; const result = await bulkTasksTool.execute(params as any); expect(result.success).toBe(false); if (!isErrorResponse(result)) { throw new Error('Expected error response'); } expect(result.error?.code).toBe('INVALID_PARAMS'); expect(result.error?.message).toContain( 'Cannot modify content, description, or comments' ); }); test('should allow all other supported fields', async () => { const params = { action: 'update' as const, task_ids: ['7654321', '7654322'], project_id: '220474322', section_id: '12345', priority: 2, labels: ['urgent'], due_string: 'tomorrow', }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(true); expect(apiService.executeBatch).toHaveBeenCalled(); }); }); // T009: Reject mixed actions describe('T009: Reject mixed actions', () => { test('should only support single action type per request', async () => { // The schema enforces single action type through enum const params = { action: 'update' as const, task_ids: ['7654321', '7654322'], due_string: 'tomorrow', }; const result = await bulkTasksTool.execute(params); if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.success).toBe(true); // Verify all commands have same type const commands = (apiService.executeBatch as jest.Mock).mock .calls[0][0] as Array<{ type: string }>; const commandTypes = new Set(commands.map(cmd => cmd.type)); expect(commandTypes.size).toBe(1); }); test('should reject invalid action type', async () => { const params = { action: 'invalid_action' as any, task_ids: ['7654321', '7654322'], }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(false); if (!isErrorResponse(result)) { throw new Error('Expected error response'); } expect(result.error).toBeDefined(); }); test('should validate action is one of: update, complete, uncomplete, move, delete', async () => { const validActions = [ 'update', 'complete', 'uncomplete', 'move', 'delete', ]; for (const action of validActions) { const params = { action: action as any, task_ids: ['7654321'], }; const result = await bulkTasksTool.execute(params); // Should at least pass validation (might fail for other reasons) if (isErrorResponse(result)) { expect(result.error.code).not.toBe('INVALID_PARAMS'); } } }); }); // T010: Bulk delete tasks describe('T010: Bulk delete tasks', () => { test('should successfully delete 5 tasks', async () => { const params = { action: 'delete' as const, task_ids: ['7654321', '7654322', '7654323', '7654324', '7654325'], }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(true); if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.data.total_tasks).toBe(5); expect(result.data.successful).toBe(5); expect(result.data.failed).toBe(0); expect(result.data.results).toHaveLength(5); // Verify all results are successful result.data.results.forEach(r => { expect(r.success).toBe(true); expect(r.error).toBeNull(); expect(r.resource_uri).toMatch(/^todoist:\/\/task\//); }); // Verify deleteTask was called 5 times (once per task) expect(apiService.deleteTask).toHaveBeenCalledTimes(5); }); test('should handle partial failure during delete', async () => { // Mock deleteTask to fail for specific task IDs apiService.deleteTask = jest.fn(async (taskId: string) => { if (taskId === '9999991' || taskId === '9999992') { throw new Error('Task not found'); } return Promise.resolve(); }); const params = { action: 'delete' as const, task_ids: ['7654321', '9999991', '7654323', '9999992', '7654325'], }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(true); // Overall operation succeeds if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.data.total_tasks).toBe(5); expect(result.data.successful).toBe(3); expect(result.data.failed).toBe(2); // Verify failed results have error messages const failedResults = result.data.results.filter(r => !r.success); expect(failedResults).toHaveLength(2); failedResults.forEach(r => { expect(r.error).toContain('not found'); }); // Verify successful results const successfulResults = result.data.results.filter(r => r.success); expect(successfulResults).toHaveLength(3); successfulResults.forEach(r => { expect(r.error).toBeNull(); }); }); test('should use individual deleteTask calls (not batch)', async () => { const params = { action: 'delete' as const, task_ids: ['7654321', '7654322', '7654323'], }; await bulkTasksTool.execute(params); // Should call deleteTask for each task individually expect(apiService.deleteTask).toHaveBeenCalledTimes(3); expect(apiService.deleteTask).toHaveBeenCalledWith('7654321'); expect(apiService.deleteTask).toHaveBeenCalledWith('7654322'); expect(apiService.deleteTask).toHaveBeenCalledWith('7654323'); // Should NOT use executeBatch for delete expect(apiService.executeBatch).not.toHaveBeenCalled(); }); test('should handle all delete failures gracefully', async () => { // Mock deleteTask to always fail apiService.deleteTask = jest.fn(async () => { throw new Error('Delete operation failed'); }); const params = { action: 'delete' as const, task_ids: ['7654321', '7654322'], }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(true); // Overall operation succeeds if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.data.total_tasks).toBe(2); expect(result.data.successful).toBe(0); expect(result.data.failed).toBe(2); // All results should show failure result.data.results.forEach(r => { expect(r.success).toBe(false); expect(r.error).toBe('Delete operation failed'); }); }); }); // T011: Bulk move tasks with new implementation describe('T011: Bulk move tasks', () => { test('should successfully move 5 tasks to a project', async () => { const params = { action: 'move' as const, task_ids: ['7654321', '7654322', '7654323', '7654324', '7654325'], project_id: '220474322', }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(true); if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.data.total_tasks).toBe(5); expect(result.data.successful).toBe(5); expect(result.data.failed).toBe(0); // Verify moveTask was called 5 times with correct destination expect(apiService.moveTask).toHaveBeenCalledTimes(5); expect(apiService.moveTask).toHaveBeenCalledWith('7654321', { project_id: '220474322', }); }); test('should successfully move tasks to a section', async () => { const params = { action: 'move' as const, task_ids: ['7654321', '7654322'], section_id: '12345', }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(true); if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.data.successful).toBe(2); // Verify moveTask was called with section_id expect(apiService.moveTask).toHaveBeenCalledWith('7654321', { section_id: '12345', }); expect(apiService.moveTask).toHaveBeenCalledWith('7654322', { section_id: '12345', }); }); test('should use individual moveTask calls (not batch)', async () => { const params = { action: 'move' as const, task_ids: ['7654321', '7654322', '7654323'], project_id: '220474322', }; await bulkTasksTool.execute(params); // Should call moveTask for each task individually expect(apiService.moveTask).toHaveBeenCalledTimes(3); // Should NOT use executeBatch for move expect(apiService.executeBatch).not.toHaveBeenCalled(); }); test('should handle partial failure during move', async () => { // Mock moveTask to fail for specific task IDs apiService.moveTask = jest.fn(async (taskId: string) => { if (taskId === '9999991') { throw new Error('Task not found'); } return Promise.resolve(); }); const params = { action: 'move' as const, task_ids: ['7654321', '9999991', '7654323'], project_id: '220474322', }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(true); if (!isBulkTasksResponse(result)) { throw new Error('Expected BulkTasksResponse'); } expect(result.data.total_tasks).toBe(3); expect(result.data.successful).toBe(2); expect(result.data.failed).toBe(1); const failedResults = result.data.results.filter(r => !r.success); expect(failedResults).toHaveLength(1); expect(failedResults[0].error).toContain('not found'); }); }); describe('Additional Schema Validation', () => { test('should reject empty task_ids array', async () => { const params = { action: 'update' as const, task_ids: [], }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(false); if (!isErrorResponse(result)) { throw new Error('Expected error response'); } expect(result.error?.code).toBe('INVALID_PARAMS'); }); test('should reject missing action parameter', async () => { const params = { task_ids: ['7654321'], }; const result = await bulkTasksTool.execute(params as any); expect(result.success).toBe(false); if (!isErrorResponse(result)) { throw new Error('Expected error response'); } expect(result.error?.code).toBe('INVALID_PARAMS'); }); test('should reject missing task_ids parameter', async () => { const params = { action: 'update' as const, }; const result = await bulkTasksTool.execute(params as any); expect(result.success).toBe(false); if (!isErrorResponse(result)) { throw new Error('Expected error response'); } expect(result.error?.code).toBe('INVALID_PARAMS'); }); test('should reject invalid priority value', async () => { const params = { action: 'update' as const, task_ids: ['7654321'], priority: 5, // Invalid: must be 1-4 }; const result = await bulkTasksTool.execute(params); expect(result.success).toBe(false); if (!isErrorResponse(result)) { throw new Error('Expected error response'); } expect(result.error?.code).toBe('INVALID_PARAMS'); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/shayonpal/mcp-todoist'

If you have feedback or need assistance with the MCP directory API, please join our Discord server