@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.
- package/README.md +11 -0
- package/dist/index.d.ts +37 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/mcp-helpers.d.ts +8 -1
- package/dist/mcp-helpers.d.ts.map +1 -1
- package/dist/mcp-helpers.js +6 -3
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +5 -0
- package/dist/tool-helpers.d.ts +8 -1
- package/dist/tool-helpers.d.ts.map +1 -1
- package/dist/tool-helpers.js +13 -10
- package/dist/tools/__tests__/add-projects.test.js +5 -1
- package/dist/tools/__tests__/add-sections.test.js +5 -1
- package/dist/tools/__tests__/assignment-integration.test.js +1 -1
- package/dist/tools/__tests__/complete-tasks.test.js +6 -6
- package/dist/tools/__tests__/fetch.test.d.ts +2 -0
- package/dist/tools/__tests__/fetch.test.d.ts.map +1 -0
- package/dist/tools/__tests__/fetch.test.js +275 -0
- package/dist/tools/__tests__/find-comments.test.js +3 -9
- package/dist/tools/__tests__/find-projects.test.js +0 -2
- package/dist/tools/__tests__/find-sections.test.js +1 -5
- package/dist/tools/__tests__/find-tasks.test.js +3 -5
- package/dist/tools/__tests__/get-overview.test.js +7 -9
- package/dist/tools/__tests__/search.test.d.ts +2 -0
- package/dist/tools/__tests__/search.test.d.ts.map +1 -0
- package/dist/tools/__tests__/search.test.js +208 -0
- package/dist/tools/__tests__/update-comments.test.js +0 -2
- package/dist/tools/__tests__/update-projects.test.js +14 -2
- package/dist/tools/__tests__/update-sections.test.js +14 -2
- package/dist/tools/__tests__/update-tasks.test.js +1 -1
- package/dist/tools/add-tasks.d.ts.map +1 -1
- package/dist/tools/add-tasks.js +8 -2
- package/dist/tools/fetch.d.ts +26 -0
- package/dist/tools/fetch.d.ts.map +1 -0
- package/dist/tools/fetch.js +99 -0
- package/dist/tools/find-projects.d.ts +2 -2
- package/dist/tools/search.d.ts +26 -0
- package/dist/tools/search.d.ts.map +1 -0
- package/dist/tools/search.js +64 -0
- package/dist/tools/update-tasks.d.ts.map +1 -1
- package/dist/tools/update-tasks.js +8 -2
- package/dist/utils/constants.d.ts +1 -1
- package/dist/utils/constants.js +1 -1
- package/dist/utils/sanitize-data.d.ts +9 -0
- package/dist/utils/sanitize-data.d.ts.map +1 -0
- package/dist/utils/sanitize-data.js +37 -0
- package/dist/utils/sanitize-data.test.d.ts +2 -0
- package/dist/utils/sanitize-data.test.d.ts.map +1 -0
- package/dist/utils/sanitize-data.test.js +93 -0
- package/dist/utils/tool-names.d.ts +2 -0
- package/dist/utils/tool-names.d.ts.map +1 -1
- package/dist/utils/tool-names.js +3 -0
- 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(
|
|
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).
|
|
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(
|
|
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
|
|
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(
|
|
78
|
+
expect(structuredContent).toEqual({
|
|
79
79
|
type: 'account_overview',
|
|
80
|
-
|
|
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(
|
|
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 @@
|
|
|
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
|
+
});
|