Skip to main content
Glama
todoist_labels.test.ts22.4 kB
import { describe, test, expect, beforeEach } from '@jest/globals'; import { TodoistLabelsTool } from '../../src/tools/todoist-labels.js'; import { CacheService } from '../../src/services/cache.js'; import { TodoistApiService } from '../../src/services/todoist-api.js'; import { TodoistLabel } from '../../src/types/todoist.js'; import { TodoistAPIError, TodoistErrorCode, RateLimitError, } from '../../src/types/errors.js'; import { createMockFn, toTodoistLabel, } from '../helpers/mockTodoistApiService.js'; import { mockLabels } from '../mocks/todoist-api-responses.js'; const mockApiConfig = { token: 'test_token_123456', base_url: 'https://api.todoist.com/rest/v1', timeout: 10000, retry_attempts: 3, }; describe('todoist_labels MCP Tool Contract', () => { let apiService: any; let todoistLabelsTool: TodoistLabelsTool; beforeEach(() => { const label1 = toTodoistLabel(mockLabels.label1); const label2 = toTodoistLabel(mockLabels.label2); apiService = { getLabels: createMockFn(), getLabel: createMockFn(), createLabel: createMockFn(), updateLabel: createMockFn(), deleteLabel: createMockFn(), renameSharedLabel: createMockFn(), removeSharedLabel: createMockFn(), getRateLimitStatus: createMockFn(), }; apiService.getLabels.mockResolvedValue({ results: [label1, label2], next_cursor: null, }); apiService.getLabel.mockResolvedValue(label1); apiService.createLabel.mockResolvedValue(label1); apiService.updateLabel.mockResolvedValue({ ...label1, color: 'green', }); apiService.deleteLabel.mockResolvedValue(undefined); apiService.renameSharedLabel.mockResolvedValue(undefined); apiService.removeSharedLabel.mockResolvedValue(undefined); apiService.getRateLimitStatus.mockReturnValue({ rest: { remaining: 999, resetTime: new Date('2025-10-01T12:15:00Z'), isLimited: false, }, sync: { remaining: 99, resetTime: new Date('2025-10-01T12:15:00Z'), isLimited: false, }, }); todoistLabelsTool = new TodoistLabelsTool(mockApiConfig, { apiService: apiService as unknown as TodoistApiService, cacheService: new CacheService(), }); }); describe('Tool definition', () => { test('exposes MCP metadata', () => { const definition = TodoistLabelsTool.getToolDefinition(); expect(definition.name).toBe('todoist_labels'); expect(definition.description).toContain('label management'); }); }); describe('Parameter validation', () => { test('rejects missing action', async () => { const result = await todoistLabelsTool.execute({} as any); expect(result.success).toBe(false); expect(result.error?.code).toBeDefined(); }); test('rejects invalid action', async () => { const result = await todoistLabelsTool.execute({ action: 'noop' }); expect(result.success).toBe(false); }); }); /** * T002: Contract test - create_personal_label * Creates a new personal label or returns existing if name already exists */ describe('CREATE action - create_personal_label', () => { test('creates new label with name, color, is_favorite', async () => { const workLabel = { id: '2156154810', name: 'Work', color: 'blue', order: 1, is_favorite: true, }; apiService.createLabel.mockResolvedValue(workLabel); const result = await todoistLabelsTool.execute({ action: 'create', name: 'Work', color: 'blue', is_favorite: true, }); expect(result.success).toBe(true); expect(result.data).toMatchObject({ id: '2156154810', name: 'Work', color: 'blue', order: 1, is_favorite: true, }); expect(result.message).toBe('Label created successfully'); expect(result.metadata).toBeDefined(); expect(result.metadata?.rate_limit_remaining).toBe(999); expect(result.metadata?.rate_limit_reset).toBe( '2025-10-01T12:15:00.000Z' ); expect(apiService.createLabel).toHaveBeenCalledWith( expect.objectContaining({ name: 'Work', color: 'blue', is_favorite: true, }) ); }); test('response schema matches contract specification', async () => { const result = await todoistLabelsTool.execute({ action: 'create', name: 'Work', color: 'blue', is_favorite: true, }); expect(result).toHaveProperty('success'); expect(result).toHaveProperty('data'); expect(result).toHaveProperty('message'); expect(result).toHaveProperty('metadata'); expect(result.metadata).toHaveProperty('operation_time'); expect(result.metadata).toHaveProperty('rate_limit_remaining'); expect(result.metadata).toHaveProperty('rate_limit_reset'); }); }); /** * T003: Contract test - create_duplicate_label_idempotent * Creating label with existing name returns existing label */ describe('CREATE action - create_duplicate_label_idempotent', () => { test('duplicate name returns existing label', async () => { const existingLabel = { id: '2156154810', name: 'Work', color: 'blue', order: 1, is_favorite: true, }; // Simulate duplicate detection by returning existing label apiService.getLabels.mockResolvedValue({ results: [existingLabel], next_cursor: null, }); const result = await todoistLabelsTool.execute({ action: 'create', name: 'Work', }); expect(result.success).toBe(true); expect(result.data).toMatchObject({ id: '2156154810', name: 'Work', color: 'blue', order: 1, is_favorite: true, }); // Message should indicate label already exists expect(result.message).toMatch(/already exists|created/i); expect(result.metadata).toBeDefined(); expect(result.metadata?.rate_limit_remaining).toBe(999); }); test('idempotent behavior - same ID returned', async () => { const existingLabel = { id: '2156154810', name: 'Work', color: 'blue', order: 1, is_favorite: true, }; apiService.getLabels.mockResolvedValue({ results: [existingLabel], next_cursor: null, }); const firstResult = await todoistLabelsTool.execute({ action: 'create', name: 'Work', }); const secondResult = await todoistLabelsTool.execute({ action: 'create', name: 'Work', }); expect(firstResult.success).toBe(true); expect(secondResult.success).toBe(true); expect((firstResult.data as TodoistLabel)?.id).toBe( (secondResult.data as TodoistLabel)?.id ); expect((firstResult.data as TodoistLabel)?.id).toBe('2156154810'); }); test('message indicates label already exists', async () => { const existingLabel = { id: '2156154810', name: 'Work', color: 'blue', order: 1, is_favorite: true, }; apiService.getLabels.mockResolvedValue({ results: [existingLabel], next_cursor: null, }); const result = await todoistLabelsTool.execute({ action: 'create', name: 'Work', }); expect(result.success).toBe(true); expect(result.message).toMatch(/already exists|created/i); }); }); /** * T004: Contract test - get_label_by_id * Retrieves a specific label by valid ID */ describe('GET action - get_label_by_id', () => { test('retrieves label by valid ID', async () => { const workLabel = { id: '2156154810', name: 'Work', color: 'blue', order: 1, is_favorite: true, }; apiService.getLabel.mockResolvedValue(workLabel); const result = await todoistLabelsTool.execute({ action: 'get', label_id: '2156154810', }); expect(result.success).toBe(true); expect(result.data).toMatchObject({ id: '2156154810', name: 'Work', color: 'blue', order: 1, is_favorite: true, }); expect(result.message).toBe('Label retrieved successfully'); expect(result.metadata).toBeDefined(); expect(result.metadata?.rate_limit_remaining).toBe(999); expect(result.metadata?.rate_limit_reset).toBe( '2025-10-01T12:15:00.000Z' ); expect(apiService.getLabel).toHaveBeenCalledWith('2156154810'); }); test('verifies all fields returned', async () => { const completeLabel = { id: '2156154810', name: 'Work', color: 'blue', order: 1, is_favorite: true, }; apiService.getLabel.mockResolvedValue(completeLabel); const result = await todoistLabelsTool.execute({ action: 'get', label_id: '2156154810', }); expect(result.success).toBe(true); expect(result.data).toHaveProperty('id'); expect(result.data).toHaveProperty('name'); expect(result.data).toHaveProperty('color'); expect(result.data).toHaveProperty('order'); expect(result.data).toHaveProperty('is_favorite'); }); }); /** * T005: Contract test - get_nonexistent_label * Returns LABEL_NOT_FOUND error for invalid label ID */ describe('GET action - get_nonexistent_label', () => { test('invalid label ID returns LABEL_NOT_FOUND', async () => { const error = new TodoistAPIError( TodoistErrorCode.LABEL_NOT_FOUND, 'Label with ID 9999999999 not found', {}, false, undefined, 404 ); apiService.getLabel.mockRejectedValue(error); const result = await todoistLabelsTool.execute({ action: 'get', label_id: '9999999999', }); expect(result.success).toBe(false); expect(result.error).toBeDefined(); expect(result.error?.code).toBe('LABEL_NOT_FOUND'); expect(result.error?.message).toContain('9999999999'); expect(result.error?.retryable).toBe(false); expect(apiService.getLabel).toHaveBeenCalledWith('9999999999'); }); test('error code and retryable=false', async () => { const error = new TodoistAPIError( TodoistErrorCode.LABEL_NOT_FOUND, 'Not found', {}, false, undefined, 404 ); apiService.getLabel.mockRejectedValue(error); const result = await todoistLabelsTool.execute({ action: 'get', label_id: '9999999999', }); expect(result.success).toBe(false); expect(result.error?.code).toBe('LABEL_NOT_FOUND'); expect(result.error?.retryable).toBe(false); }); }); /** * T006: Contract test - update_label * Updates label properties (color and is_favorite) */ describe('UPDATE action - update_label', () => { test('updates color and is_favorite', async () => { const updatedLabel = { id: '2156154810', name: 'Work', color: 'red', order: 1, is_favorite: false, }; apiService.updateLabel.mockResolvedValue(updatedLabel); const result = await todoistLabelsTool.execute({ action: 'update', label_id: '2156154810', color: 'red', is_favorite: false, }); expect(result.success).toBe(true); expect(result.data).toMatchObject({ id: '2156154810', name: 'Work', color: 'red', order: 1, is_favorite: false, }); expect(result.message).toBe('Label updated successfully'); expect(result.metadata).toBeDefined(); expect(result.metadata?.rate_limit_remaining).toBe(999); expect(apiService.updateLabel).toHaveBeenCalledWith( '2156154810', expect.objectContaining({ color: 'red', is_favorite: false, }) ); }); test('verifies name remains unchanged', async () => { const updatedLabel = { id: '2156154810', name: 'Work', color: 'red', order: 1, is_favorite: false, }; apiService.updateLabel.mockResolvedValue(updatedLabel); const result = await todoistLabelsTool.execute({ action: 'update', label_id: '2156154810', color: 'red', is_favorite: false, }); expect(result.success).toBe(true); expect((result.data as TodoistLabel)?.name).toBe('Work'); }); }); /** * T007: Contract test - delete_label * Tests label deletion returns success */ describe('DELETE action - delete_label', () => { test('label deletion returns success', async () => { apiService.deleteLabel.mockResolvedValue(undefined); const result = await todoistLabelsTool.execute({ action: 'delete', label_id: '2156154810', }); expect(result.success).toBe(true); expect(result.data).toBeNull(); expect(result.message).toContain('deleted'); expect(result.metadata).toBeDefined(); expect(result.metadata?.rate_limit_remaining).toBe(999); expect(apiService.deleteLabel).toHaveBeenCalledWith('2156154810'); }); test('data is null and message confirms deletion', async () => { apiService.deleteLabel.mockResolvedValue(undefined); const result = await todoistLabelsTool.execute({ action: 'delete', label_id: '2156154810', }); expect(result.success).toBe(true); expect(result.data).toBeNull(); expect(result.message).toMatch(/deleted|removed/i); }); }); /** * T008: Contract test - list_labels_default_pagination * Tests listing labels without limit parameter */ describe('LIST action - list_labels_default_pagination', () => { test('listing labels without limit parameter', async () => { const labels = [ { id: '2156154810', name: 'Work', color: 'blue', order: 1, is_favorite: true, }, { id: '2156154811', name: 'Personal', color: 'red', order: 2, is_favorite: false, }, ]; apiService.getLabels.mockResolvedValue({ results: labels, next_cursor: 'cursor_abc123', }); const result = await todoistLabelsTool.execute({ action: 'list', }); expect(result.success).toBe(true); expect(Array.isArray(result.data)).toBe(true); expect(result.data).toHaveLength(2); expect(result.message).toBe('Labels retrieved successfully'); expect(result.metadata).toBeDefined(); expect(result.metadata?.total_count).toBe(2); expect(result.metadata?.next_cursor).toBe('cursor_abc123'); expect(result.metadata?.rate_limit_remaining).toBe(999); }); test('metadata includes total_count and next_cursor', async () => { const labels = [ { id: '2156154810', name: 'Work', color: 'blue', order: 1, is_favorite: true, }, ]; apiService.getLabels.mockResolvedValue({ results: labels, next_cursor: 'cursor_xyz', }); const result = await todoistLabelsTool.execute({ action: 'list', }); expect(result.success).toBe(true); expect(result.metadata).toHaveProperty('total_count'); expect(result.metadata).toHaveProperty('next_cursor'); expect(result.metadata?.total_count).toBe(1); expect(result.metadata?.next_cursor).toBe('cursor_xyz'); }); }); /** * T009: Contract test - list_labels_with_pagination * Tests listing with limit=10 and cursor */ describe('LIST action - list_labels_with_pagination', () => { test('listing with limit=10 and cursor', async () => { const labels = Array.from({ length: 10 }, (_, i) => ({ id: `215615${4810 + i}`, name: `Label${i}`, color: 'blue', order: i + 1, is_favorite: false, })); apiService.getLabels.mockResolvedValue({ results: labels, next_cursor: 'cursor_page2', }); const result = await todoistLabelsTool.execute({ action: 'list', limit: 10, cursor: 'cursor_page1', }); expect(result.success).toBe(true); expect(Array.isArray(result.data)).toBe(true); expect(result.data).toHaveLength(10); expect(result.metadata).toBeDefined(); expect(result.metadata?.total_count).toBe(10); expect(result.metadata?.next_cursor).toBe('cursor_page2'); expect(apiService.getLabels).toHaveBeenCalledWith('cursor_page1', 10); }); test('pagination metadata present', async () => { const labels = Array.from({ length: 10 }, (_, i) => ({ id: `215615${4810 + i}`, name: `Label${i}`, color: 'blue', order: i + 1, is_favorite: false, })); apiService.getLabels.mockResolvedValue({ results: labels, next_cursor: 'cursor_next', }); const result = await todoistLabelsTool.execute({ action: 'list', limit: 10, }); expect(result.success).toBe(true); expect(result.metadata).toHaveProperty('total_count'); expect(result.metadata).toHaveProperty('next_cursor'); expect(result.metadata?.next_cursor).toBe('cursor_next'); }); }); /** * T010: Contract test - list_labels_invalid_limit * Tests limit=250 returns VALIDATION_ERROR */ describe('LIST action - list_labels_invalid_limit', () => { test('limit=250 returns VALIDATION_ERROR', async () => { const result = await todoistLabelsTool.execute({ action: 'list', limit: 250, }); expect(result.success).toBe(false); expect(result.error).toBeDefined(); expect(result.error?.code).toBe('VALIDATION_ERROR'); expect(result.error?.message).toContain('limit'); expect(result.error?.message).toMatch(/200|maximum/i); }); test('error details include field, value, constraint', async () => { const result = await todoistLabelsTool.execute({ action: 'list', limit: 250, }); expect(result.success).toBe(false); expect(result.error?.code).toBe('VALIDATION_ERROR'); expect(result.error?.details).toBeDefined(); expect(result.error?.message).toContain('limit'); }); }); /** * T011: Contract test - rename_shared_label * Tests renaming shared label with name and new_name */ describe('RENAME_SHARED action - rename_shared_label', () => { test('renaming shared label with name and new_name', async () => { apiService.renameSharedLabel.mockResolvedValue(undefined); const result = await todoistLabelsTool.execute({ action: 'rename_shared', name: 'TeamProject', new_name: 'Q1-Project', }); expect(result.success).toBe(true); expect(result.data).toBeNull(); expect(result.message).toMatch(/renamed/i); expect(result.metadata).toBeDefined(); expect(result.metadata?.rate_limit_remaining).toBe(99); // Sync API uses different rate limiter expect(apiService.renameSharedLabel).toHaveBeenCalledWith( 'TeamProject', 'Q1-Project' ); }); test('success message mentions "across all tasks"', async () => { apiService.renameSharedLabel.mockResolvedValue(undefined); const result = await todoistLabelsTool.execute({ action: 'rename_shared', name: 'TeamProject', new_name: 'Q1-Project', }); expect(result.success).toBe(true); expect(result.message).toMatch(/across all tasks|all tasks/i); }); }); /** * T012: Contract test - remove_shared_label * Tests removing shared label by name */ describe('REMOVE_SHARED action - remove_shared_label', () => { test('removing shared label by name', async () => { apiService.removeSharedLabel.mockResolvedValue(undefined); const result = await todoistLabelsTool.execute({ action: 'remove_shared', name: 'Deprecated', }); expect(result.success).toBe(true); expect(result.data).toBeNull(); expect(result.message).toMatch(/removed/i); expect(result.metadata).toBeDefined(); expect(result.metadata?.rate_limit_remaining).toBe(99); // Sync API uses different rate limiter expect(apiService.removeSharedLabel).toHaveBeenCalledWith('Deprecated'); }); test('success message confirms removal from all tasks', async () => { apiService.removeSharedLabel.mockResolvedValue(undefined); const result = await todoistLabelsTool.execute({ action: 'remove_shared', name: 'Deprecated', }); expect(result.success).toBe(true); expect(result.message).toMatch(/from all tasks|all tasks/i); }); }); /** * T013: Contract test - rate_limit_exceeded * Tests rate limit error response */ describe('Error Handling - rate_limit_exceeded', () => { test('rate limit error response', async () => { const error = new RateLimitError( 'API rate limit exceeded. Please wait before retrying.', 45, { limit: 1000, window: '15 minutes' } ); apiService.getLabels.mockRejectedValue(error); const result = await todoistLabelsTool.execute({ action: 'list', }); expect(result.success).toBe(false); expect(result.error).toBeDefined(); expect(result.error?.code).toBe('RATE_LIMIT_EXCEEDED'); expect(result.error?.retryable).toBe(true); }); test('retryable=true and retry_after field present', async () => { const error = new RateLimitError( 'API rate limit exceeded. Please wait before retrying.', 45, { limit: 1000, window: '15 minutes' } ); apiService.getLabels.mockRejectedValue(error); const result = await todoistLabelsTool.execute({ action: 'list', }); expect(result.success).toBe(false); expect(result.error?.retryable).toBe(true); expect(result.error?.retry_after).toBeDefined(); expect(typeof result.error?.retry_after).toBe('number'); }); }); });

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