@doist/todoist-ai 4.9.3 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +11 -0
  2. package/dist/index.d.ts +37 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +8 -1
  5. package/dist/mcp-helpers.d.ts +8 -1
  6. package/dist/mcp-helpers.d.ts.map +1 -1
  7. package/dist/mcp-helpers.js +6 -3
  8. package/dist/mcp-server.d.ts.map +1 -1
  9. package/dist/mcp-server.js +5 -0
  10. package/dist/tool-helpers.d.ts +8 -1
  11. package/dist/tool-helpers.d.ts.map +1 -1
  12. package/dist/tool-helpers.js +13 -10
  13. package/dist/tools/__tests__/add-projects.test.js +5 -1
  14. package/dist/tools/__tests__/add-sections.test.js +5 -1
  15. package/dist/tools/__tests__/assignment-integration.test.js +1 -1
  16. package/dist/tools/__tests__/complete-tasks.test.js +6 -6
  17. package/dist/tools/__tests__/fetch.test.d.ts +2 -0
  18. package/dist/tools/__tests__/fetch.test.d.ts.map +1 -0
  19. package/dist/tools/__tests__/fetch.test.js +275 -0
  20. package/dist/tools/__tests__/find-comments.test.js +3 -9
  21. package/dist/tools/__tests__/find-projects.test.js +0 -2
  22. package/dist/tools/__tests__/find-sections.test.js +1 -5
  23. package/dist/tools/__tests__/find-tasks.test.js +3 -5
  24. package/dist/tools/__tests__/get-overview.test.js +7 -9
  25. package/dist/tools/__tests__/search.test.d.ts +2 -0
  26. package/dist/tools/__tests__/search.test.d.ts.map +1 -0
  27. package/dist/tools/__tests__/search.test.js +208 -0
  28. package/dist/tools/__tests__/update-comments.test.js +0 -2
  29. package/dist/tools/__tests__/update-projects.test.js +14 -2
  30. package/dist/tools/__tests__/update-sections.test.js +14 -2
  31. package/dist/tools/__tests__/update-tasks.test.js +1 -1
  32. package/dist/tools/add-tasks.d.ts.map +1 -1
  33. package/dist/tools/add-tasks.js +8 -2
  34. package/dist/tools/fetch.d.ts +26 -0
  35. package/dist/tools/fetch.d.ts.map +1 -0
  36. package/dist/tools/fetch.js +99 -0
  37. package/dist/tools/find-projects.d.ts +2 -2
  38. package/dist/tools/search.d.ts +26 -0
  39. package/dist/tools/search.d.ts.map +1 -0
  40. package/dist/tools/search.js +64 -0
  41. package/dist/tools/update-tasks.d.ts.map +1 -1
  42. package/dist/tools/update-tasks.js +8 -2
  43. package/dist/utils/constants.d.ts +1 -1
  44. package/dist/utils/constants.js +1 -1
  45. package/dist/utils/sanitize-data.d.ts +9 -0
  46. package/dist/utils/sanitize-data.d.ts.map +1 -0
  47. package/dist/utils/sanitize-data.js +37 -0
  48. package/dist/utils/sanitize-data.test.d.ts +2 -0
  49. package/dist/utils/sanitize-data.test.d.ts.map +1 -0
  50. package/dist/utils/sanitize-data.test.js +93 -0
  51. package/dist/utils/tool-names.d.ts +2 -0
  52. package/dist/utils/tool-names.d.ts.map +1 -1
  53. package/dist/utils/tool-names.js +3 -0
  54. package/package.json +2 -2
@@ -0,0 +1,275 @@
1
+ import { jest } from '@jest/globals';
2
+ import { createMockProject, createMockTask, TEST_IDS } from '../../utils/test-helpers.js';
3
+ import { ToolNames } from '../../utils/tool-names.js';
4
+ import { fetch } from '../fetch.js';
5
+ // Mock the Todoist API
6
+ const mockTodoistApi = {
7
+ getTask: jest.fn(),
8
+ getProject: jest.fn(),
9
+ };
10
+ const { FETCH } = ToolNames;
11
+ describe(`${FETCH} tool`, () => {
12
+ beforeEach(() => {
13
+ jest.clearAllMocks();
14
+ });
15
+ describe('fetching tasks', () => {
16
+ it('should fetch a task by composite ID and return full content', async () => {
17
+ const mockTask = createMockTask({
18
+ id: TEST_IDS.TASK_1,
19
+ content: 'Important meeting with team',
20
+ description: 'Discuss project roadmap and timeline',
21
+ labels: ['work', 'urgent'],
22
+ priority: 2,
23
+ projectId: TEST_IDS.PROJECT_WORK,
24
+ sectionId: TEST_IDS.SECTION_1,
25
+ due: {
26
+ date: '2025-10-15',
27
+ isRecurring: false,
28
+ datetime: null,
29
+ string: '2025-10-15',
30
+ timezone: null,
31
+ lang: 'en',
32
+ },
33
+ });
34
+ mockTodoistApi.getTask.mockResolvedValue(mockTask);
35
+ const result = await fetch.execute({ id: `task:${TEST_IDS.TASK_1}` }, mockTodoistApi);
36
+ // Verify API was called correctly
37
+ expect(mockTodoistApi.getTask).toHaveBeenCalledWith(TEST_IDS.TASK_1);
38
+ // Verify result structure
39
+ expect(result.content).toHaveLength(1);
40
+ expect(result.content[0]?.type).toBe('text');
41
+ // Parse the JSON response
42
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
43
+ expect(jsonResponse).toEqual({
44
+ id: `task:${TEST_IDS.TASK_1}`,
45
+ title: 'Important meeting with team',
46
+ text: 'Important meeting with team\n\nDescription: Discuss project roadmap and timeline\nDue: 2025-10-15\nLabels: work, urgent',
47
+ url: `https://app.todoist.com/app/task/${TEST_IDS.TASK_1}`,
48
+ metadata: {
49
+ priority: 2,
50
+ projectId: TEST_IDS.PROJECT_WORK,
51
+ sectionId: TEST_IDS.SECTION_1,
52
+ parentId: null,
53
+ recurring: false,
54
+ duration: null,
55
+ responsibleUid: null,
56
+ assignedByUid: null,
57
+ },
58
+ });
59
+ });
60
+ it('should fetch a task without optional fields', async () => {
61
+ const mockTask = createMockTask({
62
+ id: TEST_IDS.TASK_2,
63
+ content: 'Simple task',
64
+ description: '',
65
+ labels: [],
66
+ due: null,
67
+ });
68
+ mockTodoistApi.getTask.mockResolvedValue(mockTask);
69
+ const result = await fetch.execute({ id: `task:${TEST_IDS.TASK_2}` }, mockTodoistApi);
70
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
71
+ expect(jsonResponse.title).toBe('Simple task');
72
+ expect(jsonResponse.text).toBe('Simple task');
73
+ expect(jsonResponse.metadata).toEqual({
74
+ priority: 1,
75
+ projectId: TEST_IDS.PROJECT_TEST,
76
+ sectionId: null,
77
+ parentId: null,
78
+ recurring: false,
79
+ duration: null,
80
+ responsibleUid: null,
81
+ assignedByUid: null,
82
+ });
83
+ });
84
+ it('should handle tasks with recurring due dates', async () => {
85
+ const mockTask = createMockTask({
86
+ id: TEST_IDS.TASK_3,
87
+ content: 'Weekly meeting',
88
+ due: {
89
+ date: '2025-10-15',
90
+ isRecurring: true,
91
+ datetime: null,
92
+ string: 'every monday',
93
+ timezone: null,
94
+ lang: 'en',
95
+ },
96
+ });
97
+ mockTodoistApi.getTask.mockResolvedValue(mockTask);
98
+ const result = await fetch.execute({ id: `task:${TEST_IDS.TASK_3}` }, mockTodoistApi);
99
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
100
+ expect(jsonResponse.metadata.recurring).toBe('every monday');
101
+ });
102
+ it('should handle tasks with duration', async () => {
103
+ const mockTask = createMockTask({
104
+ id: TEST_IDS.TASK_1,
105
+ content: 'Task with duration',
106
+ duration: {
107
+ amount: 90,
108
+ unit: 'minute',
109
+ },
110
+ });
111
+ mockTodoistApi.getTask.mockResolvedValue(mockTask);
112
+ const result = await fetch.execute({ id: `task:${TEST_IDS.TASK_1}` }, mockTodoistApi);
113
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
114
+ expect(jsonResponse.metadata.duration).toBe('1h30m');
115
+ });
116
+ it('should handle tasks with assignments', async () => {
117
+ const mockTask = createMockTask({
118
+ id: TEST_IDS.TASK_1,
119
+ content: 'Assigned task',
120
+ responsibleUid: 'user-123',
121
+ assignedByUid: 'user-456',
122
+ });
123
+ mockTodoistApi.getTask.mockResolvedValue(mockTask);
124
+ const result = await fetch.execute({ id: `task:${TEST_IDS.TASK_1}` }, mockTodoistApi);
125
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
126
+ expect(jsonResponse.metadata.responsibleUid).toBe('user-123');
127
+ expect(jsonResponse.metadata.assignedByUid).toBe('user-456');
128
+ });
129
+ });
130
+ describe('fetching projects', () => {
131
+ it('should fetch a project by composite ID and return full content', async () => {
132
+ const mockProject = createMockProject({
133
+ id: TEST_IDS.PROJECT_WORK,
134
+ name: 'Work Project',
135
+ color: 'blue',
136
+ isFavorite: true,
137
+ isShared: true,
138
+ viewStyle: 'board',
139
+ parentId: null,
140
+ inboxProject: false,
141
+ });
142
+ mockTodoistApi.getProject.mockResolvedValue(mockProject);
143
+ const result = await fetch.execute({ id: `project:${TEST_IDS.PROJECT_WORK}` }, mockTodoistApi);
144
+ // Verify API was called correctly
145
+ expect(mockTodoistApi.getProject).toHaveBeenCalledWith(TEST_IDS.PROJECT_WORK);
146
+ // Verify result structure
147
+ expect(result.content).toHaveLength(1);
148
+ expect(result.content[0]?.type).toBe('text');
149
+ // Parse the JSON response
150
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
151
+ expect(jsonResponse).toEqual({
152
+ id: `project:${TEST_IDS.PROJECT_WORK}`,
153
+ title: 'Work Project',
154
+ text: 'Work Project\n\nShared project\nFavorite: Yes',
155
+ url: `https://app.todoist.com/app/project/${TEST_IDS.PROJECT_WORK}`,
156
+ metadata: {
157
+ color: 'blue',
158
+ isFavorite: true,
159
+ isShared: true,
160
+ parentId: null,
161
+ inboxProject: false,
162
+ viewStyle: 'board',
163
+ },
164
+ });
165
+ });
166
+ it('should fetch a project without optional flags', async () => {
167
+ const mockProject = createMockProject({
168
+ id: TEST_IDS.PROJECT_TEST,
169
+ name: 'Simple Project',
170
+ isFavorite: false,
171
+ isShared: false,
172
+ });
173
+ mockTodoistApi.getProject.mockResolvedValue(mockProject);
174
+ const result = await fetch.execute({ id: `project:${TEST_IDS.PROJECT_TEST}` }, mockTodoistApi);
175
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
176
+ expect(jsonResponse.title).toBe('Simple Project');
177
+ expect(jsonResponse.text).toBe('Simple Project');
178
+ expect(jsonResponse.metadata.isFavorite).toBe(false);
179
+ expect(jsonResponse.metadata.isShared).toBe(false);
180
+ });
181
+ it('should fetch inbox project', async () => {
182
+ const mockProject = createMockProject({
183
+ id: TEST_IDS.PROJECT_INBOX,
184
+ name: 'Inbox',
185
+ inboxProject: true,
186
+ });
187
+ mockTodoistApi.getProject.mockResolvedValue(mockProject);
188
+ const result = await fetch.execute({ id: `project:${TEST_IDS.PROJECT_INBOX}` }, mockTodoistApi);
189
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
190
+ expect(jsonResponse.metadata.inboxProject).toBe(true);
191
+ });
192
+ it('should fetch project with parent ID', async () => {
193
+ const mockProject = createMockProject({
194
+ id: 'sub-project-id',
195
+ name: 'Sub Project',
196
+ parentId: TEST_IDS.PROJECT_WORK,
197
+ });
198
+ mockTodoistApi.getProject.mockResolvedValue(mockProject);
199
+ const result = await fetch.execute({ id: 'project:sub-project-id' }, mockTodoistApi);
200
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
201
+ expect(jsonResponse.metadata.parentId).toBe(TEST_IDS.PROJECT_WORK);
202
+ });
203
+ });
204
+ describe('error handling', () => {
205
+ it('should return error response for invalid ID format (missing colon)', async () => {
206
+ const result = await fetch.execute({ id: 'invalid-id' }, mockTodoistApi);
207
+ expect(result.isError).toBe(true);
208
+ expect(result.content[0]?.text).toContain('Invalid ID format');
209
+ });
210
+ it('should return error response for invalid ID format (missing type)', async () => {
211
+ const result = await fetch.execute({ id: ':8485093748' }, mockTodoistApi);
212
+ expect(result.isError).toBe(true);
213
+ expect(result.content[0]?.text).toContain('Invalid ID format');
214
+ });
215
+ it('should return error response for invalid ID format (missing object ID)', async () => {
216
+ const result = await fetch.execute({ id: 'task:' }, mockTodoistApi);
217
+ expect(result.isError).toBe(true);
218
+ expect(result.content[0]?.text).toContain('Invalid ID format');
219
+ });
220
+ it('should return error response for invalid type', async () => {
221
+ const result = await fetch.execute({ id: 'section:123' }, mockTodoistApi);
222
+ expect(result.isError).toBe(true);
223
+ expect(result.content[0]?.text).toContain('Invalid ID format');
224
+ });
225
+ it('should return error response for task fetch failure', async () => {
226
+ mockTodoistApi.getTask.mockRejectedValue(new Error('Task not found'));
227
+ const result = await fetch.execute({ id: `task:${TEST_IDS.TASK_1}` }, mockTodoistApi);
228
+ expect(result.isError).toBe(true);
229
+ expect(result.content[0]?.text).toBe('Task not found');
230
+ });
231
+ it('should return error response for project fetch failure', async () => {
232
+ mockTodoistApi.getProject.mockRejectedValue(new Error('Project not found'));
233
+ const result = await fetch.execute({ id: `project:${TEST_IDS.PROJECT_WORK}` }, mockTodoistApi);
234
+ expect(result.isError).toBe(true);
235
+ expect(result.content[0]?.text).toBe('Project not found');
236
+ });
237
+ });
238
+ describe('OpenAI MCP spec compliance', () => {
239
+ it('should return exactly one content item with type "text"', async () => {
240
+ const mockTask = createMockTask({ id: TEST_IDS.TASK_1, content: 'Test' });
241
+ mockTodoistApi.getTask.mockResolvedValue(mockTask);
242
+ const result = await fetch.execute({ id: `task:${TEST_IDS.TASK_1}` }, mockTodoistApi);
243
+ expect(result.content).toHaveLength(1);
244
+ expect(result.content[0]?.type).toBe('text');
245
+ });
246
+ it('should return valid JSON string in text field', async () => {
247
+ const mockTask = createMockTask({ id: TEST_IDS.TASK_1, content: 'Test' });
248
+ mockTodoistApi.getTask.mockResolvedValue(mockTask);
249
+ const result = await fetch.execute({ id: `task:${TEST_IDS.TASK_1}` }, mockTodoistApi);
250
+ expect(() => JSON.parse(result.content[0]?.text ?? '{}')).not.toThrow();
251
+ });
252
+ it('should include all required fields (id, title, text, url)', async () => {
253
+ const mockTask = createMockTask({ id: TEST_IDS.TASK_1, content: 'Test' });
254
+ mockTodoistApi.getTask.mockResolvedValue(mockTask);
255
+ const result = await fetch.execute({ id: `task:${TEST_IDS.TASK_1}` }, mockTodoistApi);
256
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
257
+ expect(jsonResponse).toHaveProperty('id');
258
+ expect(jsonResponse).toHaveProperty('title');
259
+ expect(jsonResponse).toHaveProperty('text');
260
+ expect(jsonResponse).toHaveProperty('url');
261
+ expect(typeof jsonResponse.id).toBe('string');
262
+ expect(typeof jsonResponse.title).toBe('string');
263
+ expect(typeof jsonResponse.text).toBe('string');
264
+ expect(typeof jsonResponse.url).toBe('string');
265
+ });
266
+ it('should include optional metadata field', async () => {
267
+ const mockTask = createMockTask({ id: TEST_IDS.TASK_1, content: 'Test' });
268
+ mockTodoistApi.getTask.mockResolvedValue(mockTask);
269
+ const result = await fetch.execute({ id: `task:${TEST_IDS.TASK_1}` }, mockTodoistApi);
270
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
271
+ expect(jsonResponse).toHaveProperty('metadata');
272
+ expect(typeof jsonResponse.metadata).toBe('object');
273
+ });
274
+ });
275
+ });
@@ -55,7 +55,6 @@ describe(`${FIND_COMMENTS} tool`, () => {
55
55
  searchType: 'task',
56
56
  searchId: 'task123',
57
57
  hasMore: false,
58
- nextCursor: null,
59
58
  totalCount: 2,
60
59
  }));
61
60
  });
@@ -128,7 +127,6 @@ describe(`${FIND_COMMENTS} tool`, () => {
128
127
  searchType: 'project',
129
128
  searchId: 'project456',
130
129
  hasMore: false,
131
- nextCursor: null,
132
130
  totalCount: 1,
133
131
  }));
134
132
  });
@@ -155,13 +153,11 @@ describe(`${FIND_COMMENTS} tool`, () => {
155
153
  id: 'comment789',
156
154
  content: 'Single comment content',
157
155
  taskId: 'task123',
158
- fileAttachment: null,
159
156
  }),
160
157
  ]),
161
158
  searchType: 'single',
162
159
  searchId: 'comment789',
163
160
  hasMore: false,
164
- nextCursor: null,
165
161
  totalCount: 1,
166
162
  }));
167
163
  });
@@ -200,7 +196,6 @@ describe(`${FIND_COMMENTS} tool`, () => {
200
196
  searchType: 'single',
201
197
  searchId: 'comment789',
202
198
  hasMore: false,
203
- nextCursor: null,
204
199
  totalCount: 1,
205
200
  }));
206
201
  });
@@ -229,14 +224,13 @@ describe(`${FIND_COMMENTS} tool`, () => {
229
224
  expect(extractTextContent(result)).toMatchSnapshot();
230
225
  // Verify structured content
231
226
  const structuredContent = extractStructuredContent(result);
232
- expect(structuredContent).toEqual(expect.objectContaining({
233
- comments: [],
227
+ expect(structuredContent).toEqual({
234
228
  searchType: 'task',
235
229
  searchId: 'task123',
236
230
  hasMore: false,
237
- nextCursor: null,
238
231
  totalCount: 0,
239
- }));
232
+ // comments array is removed when empty
233
+ });
240
234
  });
241
235
  });
242
236
  });
@@ -53,7 +53,6 @@ describe(`${FIND_PROJECTS} tool`, () => {
53
53
  projects: expect.any(Array),
54
54
  totalCount: 3,
55
55
  hasMore: false,
56
- nextCursor: null,
57
56
  appliedFilters: {
58
57
  search: undefined,
59
58
  limit: 50,
@@ -112,7 +111,6 @@ describe(`${FIND_PROJECTS} tool`, () => {
112
111
  expect(structuredContent.projects).toHaveLength(2); // Should match filtered results
113
112
  expect(structuredContent.totalCount).toBe(2);
114
113
  expect(structuredContent.hasMore).toBe(false);
115
- expect(structuredContent.nextCursor).toBeNull();
116
114
  expect(structuredContent.appliedFilters).toEqual({
117
115
  search: 'work',
118
116
  limit: 50,
@@ -77,12 +77,8 @@ describe(`${FIND_SECTIONS} tool`, () => {
77
77
  expect(textContent).toContain(`Use ${ADD_SECTIONS} to create sections`);
78
78
  // Verify structured content
79
79
  const structuredContent = extractStructuredContent(result);
80
- expect(structuredContent.sections).toHaveLength(0);
80
+ expect(structuredContent.sections).toBeUndefined(); // Empty arrays are removed
81
81
  expect(structuredContent.totalCount).toBe(0);
82
- expect(structuredContent.appliedFilters).toEqual({
83
- projectId: 'empty-project-id',
84
- search: undefined,
85
- });
86
82
  });
87
83
  });
88
84
  describe('searching sections by name', () => {
@@ -117,7 +117,6 @@ describe(`${FIND_TASKS} tool`, () => {
117
117
  tasks: expect.any(Array),
118
118
  totalCount: 1,
119
119
  hasMore: false,
120
- nextCursor: null,
121
120
  appliedFilters: expect.objectContaining({
122
121
  searchText: params.searchText,
123
122
  limit: expectedLimit,
@@ -144,15 +143,14 @@ describe(`${FIND_TASKS} tool`, () => {
144
143
  expect(extractTextContent(result)).toMatchSnapshot();
145
144
  // Verify structured content for empty results
146
145
  const structuredContent = extractStructuredContent(result);
147
- expect(structuredContent).toEqual(expect.objectContaining({
148
- tasks: [],
146
+ expect(structuredContent).toEqual({
147
+ // tasks array is removed when empty
149
148
  totalCount: 0,
150
149
  hasMore: false,
151
- nextCursor: null,
152
150
  appliedFilters: expect.objectContaining({
153
151
  searchText: searchText,
154
152
  }),
155
- }));
153
+ });
156
154
  });
157
155
  });
158
156
  describe('validation', () => {
@@ -59,7 +59,7 @@ describe(`${GET_OVERVIEW} tool`, () => {
59
59
  inbox: expect.objectContaining({
60
60
  id: TEST_IDS.PROJECT_INBOX,
61
61
  name: 'Inbox',
62
- sections: expect.any(Array),
62
+ // sections array removed if empty
63
63
  }),
64
64
  projects: expect.any(Array),
65
65
  totalProjects: 2,
@@ -75,14 +75,13 @@ describe(`${GET_OVERVIEW} tool`, () => {
75
75
  expect(extractTextContent(result)).toMatchSnapshot();
76
76
  // Test structured content sanity checks
77
77
  const structuredContent = extractStructuredContent(result);
78
- expect(structuredContent).toEqual(expect.objectContaining({
78
+ expect(structuredContent).toEqual({
79
79
  type: 'account_overview',
80
- inbox: null,
81
- projects: [],
80
+ // projects array is removed when empty
82
81
  totalProjects: 0,
83
82
  totalSections: 0,
84
83
  hasNestedProjects: false,
85
- }));
84
+ });
86
85
  });
87
86
  });
88
87
  describe('project overview (with projectId)', () => {
@@ -188,20 +187,19 @@ describe(`${GET_OVERVIEW} tool`, () => {
188
187
  expect(extractTextContent(result)).toMatchSnapshot();
189
188
  // Test structured content sanity checks
190
189
  const structuredContent = extractStructuredContent(result);
191
- expect(structuredContent).toEqual(expect.objectContaining({
190
+ expect(structuredContent).toEqual({
192
191
  type: 'project_overview',
193
192
  project: expect.objectContaining({
194
193
  id: 'empty-project-id',
195
194
  name: 'Empty Project',
196
195
  }),
197
- sections: [],
198
- tasks: [],
196
+ // sections and tasks arrays are removed when empty
199
197
  stats: expect.objectContaining({
200
198
  totalTasks: 0,
201
199
  totalSections: 0,
202
200
  tasksWithoutSection: 0,
203
201
  }),
204
- }));
202
+ });
205
203
  });
206
204
  });
207
205
  describe('error handling', () => {
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=search.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search.test.d.ts","sourceRoot":"","sources":["../../../src/tools/__tests__/search.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,208 @@
1
+ import { jest } from '@jest/globals';
2
+ import { getTasksByFilter } from '../../tool-helpers.js';
3
+ import { createMappedTask, createMockApiResponse, createMockProject, TEST_IDS, } from '../../utils/test-helpers.js';
4
+ import { ToolNames } from '../../utils/tool-names.js';
5
+ import { search } from '../search.js';
6
+ jest.mock('../../tool-helpers', () => {
7
+ const actual = jest.requireActual('../../tool-helpers');
8
+ return {
9
+ getTasksByFilter: jest.fn(),
10
+ buildTodoistUrl: actual.buildTodoistUrl,
11
+ };
12
+ });
13
+ const { SEARCH } = ToolNames;
14
+ const mockGetTasksByFilter = getTasksByFilter;
15
+ // Mock the Todoist API
16
+ const mockTodoistApi = {
17
+ getProjects: jest.fn(),
18
+ };
19
+ describe(`${SEARCH} tool`, () => {
20
+ beforeEach(() => {
21
+ jest.clearAllMocks();
22
+ });
23
+ describe('searching tasks and projects', () => {
24
+ it('should search both tasks and projects and return combined results', async () => {
25
+ const mockTasks = [
26
+ createMappedTask({
27
+ id: TEST_IDS.TASK_1,
28
+ content: 'Important meeting task',
29
+ }),
30
+ createMappedTask({
31
+ id: TEST_IDS.TASK_2,
32
+ content: 'Another important item',
33
+ }),
34
+ ];
35
+ const mockProjects = [
36
+ createMockProject({
37
+ id: TEST_IDS.PROJECT_WORK,
38
+ name: 'Important Work Project',
39
+ }),
40
+ createMockProject({
41
+ id: TEST_IDS.PROJECT_TEST,
42
+ name: 'Test Project',
43
+ }),
44
+ ];
45
+ mockGetTasksByFilter.mockResolvedValue({ tasks: mockTasks, nextCursor: null });
46
+ mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects));
47
+ const result = await search.execute({ query: 'important' }, mockTodoistApi);
48
+ // Verify both API calls were made
49
+ expect(mockGetTasksByFilter).toHaveBeenCalledWith({
50
+ client: mockTodoistApi,
51
+ query: 'search: important',
52
+ limit: 100, // TASKS_MAX
53
+ cursor: undefined,
54
+ });
55
+ expect(mockTodoistApi.getProjects).toHaveBeenCalledWith({
56
+ limit: 100, // PROJECTS_MAX
57
+ });
58
+ // Verify result structure
59
+ expect(result.content).toHaveLength(1);
60
+ expect(result.content[0]?.type).toBe('text');
61
+ // Parse the JSON response
62
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
63
+ expect(jsonResponse).toHaveProperty('results');
64
+ expect(jsonResponse.results).toHaveLength(3); // 2 tasks + 1 project matching "important"
65
+ // Verify task results
66
+ expect(jsonResponse.results[0]).toEqual({
67
+ id: `task:${TEST_IDS.TASK_1}`,
68
+ title: 'Important meeting task',
69
+ url: `https://app.todoist.com/app/task/${TEST_IDS.TASK_1}`,
70
+ });
71
+ expect(jsonResponse.results[1]).toEqual({
72
+ id: `task:${TEST_IDS.TASK_2}`,
73
+ title: 'Another important item',
74
+ url: `https://app.todoist.com/app/task/${TEST_IDS.TASK_2}`,
75
+ });
76
+ // Verify project result (only "Important Work Project" matches)
77
+ expect(jsonResponse.results[2]).toEqual({
78
+ id: `project:${TEST_IDS.PROJECT_WORK}`,
79
+ title: 'Important Work Project',
80
+ url: `https://app.todoist.com/app/project/${TEST_IDS.PROJECT_WORK}`,
81
+ });
82
+ });
83
+ it('should return only matching tasks when no projects match', async () => {
84
+ const mockTasks = [
85
+ createMappedTask({
86
+ id: TEST_IDS.TASK_1,
87
+ content: 'Unique task content',
88
+ }),
89
+ ];
90
+ const mockProjects = [
91
+ createMockProject({
92
+ id: TEST_IDS.PROJECT_WORK,
93
+ name: 'Work Project',
94
+ }),
95
+ ];
96
+ mockGetTasksByFilter.mockResolvedValue({ tasks: mockTasks, nextCursor: null });
97
+ mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects));
98
+ const result = await search.execute({ query: 'unique' }, mockTodoistApi);
99
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
100
+ expect(jsonResponse.results).toHaveLength(1);
101
+ expect(jsonResponse.results[0].id).toBe(`task:${TEST_IDS.TASK_1}`);
102
+ });
103
+ it('should return only matching projects when no tasks match', async () => {
104
+ const mockProjects = [
105
+ createMockProject({
106
+ id: TEST_IDS.PROJECT_WORK,
107
+ name: 'Special Project Name',
108
+ }),
109
+ createMockProject({
110
+ id: TEST_IDS.PROJECT_TEST,
111
+ name: 'Another Project',
112
+ }),
113
+ ];
114
+ mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
115
+ mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects));
116
+ const result = await search.execute({ query: 'special' }, mockTodoistApi);
117
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
118
+ expect(jsonResponse.results).toHaveLength(1);
119
+ expect(jsonResponse.results[0]).toEqual({
120
+ id: `project:${TEST_IDS.PROJECT_WORK}`,
121
+ title: 'Special Project Name',
122
+ url: `https://app.todoist.com/app/project/${TEST_IDS.PROJECT_WORK}`,
123
+ });
124
+ });
125
+ it('should return empty results when nothing matches', async () => {
126
+ mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
127
+ mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse([]));
128
+ const result = await search.execute({ query: 'nonexistent' }, mockTodoistApi);
129
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
130
+ expect(jsonResponse.results).toHaveLength(0);
131
+ });
132
+ it('should perform case-insensitive project filtering', async () => {
133
+ const mockProjects = [
134
+ createMockProject({
135
+ id: TEST_IDS.PROJECT_WORK,
136
+ name: 'Important Work',
137
+ }),
138
+ ];
139
+ mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
140
+ mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects));
141
+ const result = await search.execute({ query: 'IMPORTANT' }, mockTodoistApi);
142
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
143
+ expect(jsonResponse.results).toHaveLength(1);
144
+ expect(jsonResponse.results[0].title).toBe('Important Work');
145
+ });
146
+ it('should handle partial matches in project names', async () => {
147
+ const mockProjects = [
148
+ createMockProject({ id: 'project-1', name: 'Development Tasks' }),
149
+ createMockProject({ id: 'project-2', name: 'Developer Resources' }),
150
+ createMockProject({ id: 'project-3', name: 'Marketing' }),
151
+ ];
152
+ mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
153
+ mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects));
154
+ const result = await search.execute({ query: 'develop' }, mockTodoistApi);
155
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
156
+ expect(jsonResponse.results).toHaveLength(2);
157
+ expect(jsonResponse.results[0].title).toBe('Development Tasks');
158
+ expect(jsonResponse.results[1].title).toBe('Developer Resources');
159
+ });
160
+ });
161
+ describe('error handling', () => {
162
+ it('should return error response for task search failure', async () => {
163
+ mockGetTasksByFilter.mockRejectedValue(new Error('Task search failed'));
164
+ mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse([]));
165
+ const result = await search.execute({ query: 'test' }, mockTodoistApi);
166
+ expect(result.isError).toBe(true);
167
+ expect(result.content[0]?.text).toBe('Task search failed');
168
+ });
169
+ it('should return error response for project search failure', async () => {
170
+ mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
171
+ mockTodoistApi.getProjects.mockRejectedValue(new Error('Project search failed'));
172
+ const result = await search.execute({ query: 'test' }, mockTodoistApi);
173
+ expect(result.isError).toBe(true);
174
+ expect(result.content[0]?.text).toBe('Project search failed');
175
+ });
176
+ });
177
+ describe('OpenAI MCP spec compliance', () => {
178
+ it('should return exactly one content item with type "text"', async () => {
179
+ mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
180
+ mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse([]));
181
+ const result = await search.execute({ query: 'test' }, mockTodoistApi);
182
+ expect(result.content).toHaveLength(1);
183
+ expect(result.content[0]?.type).toBe('text');
184
+ });
185
+ it('should return valid JSON string in text field', async () => {
186
+ mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
187
+ mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse([]));
188
+ const result = await search.execute({ query: 'test' }, mockTodoistApi);
189
+ expect(() => JSON.parse(result.content[0]?.text ?? '{}')).not.toThrow();
190
+ });
191
+ it('should include required fields (id, title, url) in each result', async () => {
192
+ const mockTasks = [createMappedTask({ id: TEST_IDS.TASK_1, content: 'Test' })];
193
+ const mockProjects = [createMockProject({ id: TEST_IDS.PROJECT_WORK, name: 'Test' })];
194
+ mockGetTasksByFilter.mockResolvedValue({ tasks: mockTasks, nextCursor: null });
195
+ mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects));
196
+ const result = await search.execute({ query: 'test' }, mockTodoistApi);
197
+ const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
198
+ for (const item of jsonResponse.results) {
199
+ expect(item).toHaveProperty('id');
200
+ expect(item).toHaveProperty('title');
201
+ expect(item).toHaveProperty('url');
202
+ expect(typeof item.id).toBe('string');
203
+ expect(typeof item.title).toBe('string');
204
+ expect(typeof item.url).toBe('string');
205
+ }
206
+ });
207
+ });
208
+ });