loopwork 0.3.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/CHANGELOG.md +52 -0
- package/README.md +528 -0
- package/bin/loopwork +0 -0
- package/examples/README.md +70 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-001.md +22 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-002.md +23 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-003.md +37 -0
- package/examples/basic-json-backend/.specs/tasks/tasks.json +19 -0
- package/examples/basic-json-backend/README.md +32 -0
- package/examples/basic-json-backend/TESTING.md +184 -0
- package/examples/basic-json-backend/hello.test.ts +9 -0
- package/examples/basic-json-backend/hello.ts +3 -0
- package/examples/basic-json-backend/loopwork.config.js +35 -0
- package/examples/basic-json-backend/math.test.ts +29 -0
- package/examples/basic-json-backend/math.ts +3 -0
- package/examples/basic-json-backend/package.json +15 -0
- package/examples/basic-json-backend/quick-start.sh +80 -0
- package/loopwork.config.ts +164 -0
- package/package.json +26 -0
- package/src/backends/github.ts +426 -0
- package/src/backends/index.ts +86 -0
- package/src/backends/json.ts +598 -0
- package/src/backends/plugin.ts +317 -0
- package/src/backends/types.ts +19 -0
- package/src/commands/init.ts +100 -0
- package/src/commands/run.ts +365 -0
- package/src/contracts/backend.ts +127 -0
- package/src/contracts/config.ts +129 -0
- package/src/contracts/index.ts +43 -0
- package/src/contracts/plugin.ts +82 -0
- package/src/contracts/task.ts +78 -0
- package/src/core/cli.ts +275 -0
- package/src/core/config.ts +165 -0
- package/src/core/state.ts +154 -0
- package/src/core/utils.ts +125 -0
- package/src/dashboard/cli.ts +449 -0
- package/src/dashboard/index.ts +6 -0
- package/src/dashboard/kanban.tsx +226 -0
- package/src/dashboard/tui.tsx +372 -0
- package/src/index.ts +19 -0
- package/src/mcp/server.ts +451 -0
- package/src/monitor/index.ts +420 -0
- package/src/plugins/asana.ts +192 -0
- package/src/plugins/cost-tracking.ts +402 -0
- package/src/plugins/discord.ts +269 -0
- package/src/plugins/everhour.ts +335 -0
- package/src/plugins/index.ts +253 -0
- package/src/plugins/telegram/bot.ts +517 -0
- package/src/plugins/telegram/index.ts +6 -0
- package/src/plugins/telegram/notifications.ts +198 -0
- package/src/plugins/todoist.ts +261 -0
- package/test/backends.test.ts +929 -0
- package/test/cli.test.ts +145 -0
- package/test/config.test.ts +90 -0
- package/test/e2e.test.ts +458 -0
- package/test/github-tasks.test.ts +191 -0
- package/test/loopwork-config-types.test.ts +288 -0
- package/test/monitor.test.ts +123 -0
- package/test/plugins.test.ts +1175 -0
- package/test/state.test.ts +295 -0
- package/test/utils.test.ts +60 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { createAsanaPlugin, AsanaClient } from '../src/plugins/asana'
|
|
3
|
+
import { createEverhourPlugin, EverhourClient, asanaToEverhour, formatDuration } from '../src/plugins/everhour'
|
|
4
|
+
import { createTodoistPlugin, TodoistClient } from '../src/plugins/todoist'
|
|
5
|
+
import { createDiscordPlugin, DiscordClient } from '../src/plugins/discord'
|
|
6
|
+
import type { PluginTask } from '../src/plugins'
|
|
7
|
+
|
|
8
|
+
// Mock fetch globally
|
|
9
|
+
const originalFetch = global.fetch
|
|
10
|
+
let mockFetch: ReturnType<typeof mock>
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
mockFetch = mock(() =>
|
|
14
|
+
Promise.resolve({
|
|
15
|
+
ok: true,
|
|
16
|
+
status: 200,
|
|
17
|
+
json: () => Promise.resolve({ data: {} }),
|
|
18
|
+
text: () => Promise.resolve(''),
|
|
19
|
+
})
|
|
20
|
+
)
|
|
21
|
+
global.fetch = mockFetch as any
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
global.fetch = originalFetch
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('Asana Plugin', () => {
|
|
29
|
+
const mockTask: PluginTask = {
|
|
30
|
+
id: 'TASK-001',
|
|
31
|
+
title: 'Test task',
|
|
32
|
+
metadata: { asanaGid: '123456789' },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('AsanaClient', () => {
|
|
36
|
+
test('getTask makes correct API call', async () => {
|
|
37
|
+
mockFetch.mockImplementation(() =>
|
|
38
|
+
Promise.resolve({
|
|
39
|
+
ok: true,
|
|
40
|
+
json: () => Promise.resolve({ data: { gid: '123', name: 'Test' } }),
|
|
41
|
+
})
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const client = new AsanaClient('test-token')
|
|
45
|
+
const task = await client.getTask('123')
|
|
46
|
+
|
|
47
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
48
|
+
'https://app.asana.com/api/1.0/tasks/123',
|
|
49
|
+
expect.objectContaining({
|
|
50
|
+
method: 'GET',
|
|
51
|
+
headers: expect.objectContaining({
|
|
52
|
+
Authorization: 'Bearer test-token',
|
|
53
|
+
}),
|
|
54
|
+
})
|
|
55
|
+
)
|
|
56
|
+
expect(task.gid).toBe('123')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('completeTask updates task', async () => {
|
|
60
|
+
mockFetch.mockImplementation(() =>
|
|
61
|
+
Promise.resolve({
|
|
62
|
+
ok: true,
|
|
63
|
+
json: () => Promise.resolve({ data: { gid: '123', completed: true } }),
|
|
64
|
+
})
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const client = new AsanaClient('test-token')
|
|
68
|
+
const task = await client.completeTask('123')
|
|
69
|
+
|
|
70
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
71
|
+
'https://app.asana.com/api/1.0/tasks/123',
|
|
72
|
+
expect.objectContaining({
|
|
73
|
+
method: 'PUT',
|
|
74
|
+
body: JSON.stringify({ data: { completed: true } }),
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('handles API errors', async () => {
|
|
80
|
+
mockFetch.mockImplementation(() =>
|
|
81
|
+
Promise.resolve({
|
|
82
|
+
ok: false,
|
|
83
|
+
status: 401,
|
|
84
|
+
text: () => Promise.resolve('Unauthorized'),
|
|
85
|
+
})
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const client = new AsanaClient('bad-token')
|
|
89
|
+
await expect(client.getTask('123')).rejects.toThrow('Asana API error: 401')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('createAsanaPlugin', () => {
|
|
94
|
+
test('returns warning plugin when no credentials', () => {
|
|
95
|
+
const plugin = createAsanaPlugin({})
|
|
96
|
+
expect(plugin.name).toBe('asana')
|
|
97
|
+
|
|
98
|
+
// Should have onConfigLoad that warns
|
|
99
|
+
const result = plugin.onConfigLoad?.({ backend: { type: 'json' } } as any)
|
|
100
|
+
expect(result).toBeDefined()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('creates functional plugin with credentials', () => {
|
|
104
|
+
const plugin = createAsanaPlugin({
|
|
105
|
+
accessToken: 'test-token',
|
|
106
|
+
projectId: 'project-123',
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
expect(plugin.name).toBe('asana')
|
|
110
|
+
expect(plugin.onTaskStart).toBeDefined()
|
|
111
|
+
expect(plugin.onTaskComplete).toBeDefined()
|
|
112
|
+
expect(plugin.onTaskFailed).toBeDefined()
|
|
113
|
+
expect(plugin.onLoopEnd).toBeDefined()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('onTaskStart skips tasks without asanaGid', async () => {
|
|
117
|
+
const plugin = createAsanaPlugin({
|
|
118
|
+
accessToken: 'test-token',
|
|
119
|
+
projectId: 'project-123',
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const taskWithoutGid: PluginTask = { id: 'TASK-001', title: 'Test' }
|
|
123
|
+
await plugin.onTaskStart?.(taskWithoutGid)
|
|
124
|
+
|
|
125
|
+
// Should not have called fetch
|
|
126
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('onTaskComplete calls API with asanaGid', async () => {
|
|
130
|
+
mockFetch.mockImplementation(() =>
|
|
131
|
+
Promise.resolve({
|
|
132
|
+
ok: true,
|
|
133
|
+
json: () => Promise.resolve({ data: {} }),
|
|
134
|
+
})
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const plugin = createAsanaPlugin({
|
|
138
|
+
accessToken: 'test-token',
|
|
139
|
+
projectId: 'project-123',
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
await plugin.onTaskComplete?.(mockTask, { duration: 60 })
|
|
143
|
+
|
|
144
|
+
// Should have called API twice (complete + comment)
|
|
145
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('Everhour Plugin', () => {
|
|
151
|
+
const mockTask: PluginTask = {
|
|
152
|
+
id: 'TASK-001',
|
|
153
|
+
title: 'Test task',
|
|
154
|
+
metadata: { asanaGid: '123456789' },
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
describe('EverhourClient', () => {
|
|
158
|
+
test('startTimer makes correct API call', async () => {
|
|
159
|
+
mockFetch.mockImplementation(() =>
|
|
160
|
+
Promise.resolve({
|
|
161
|
+
ok: true,
|
|
162
|
+
text: () => Promise.resolve(JSON.stringify({ status: 'active' })),
|
|
163
|
+
})
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const client = new EverhourClient('test-key')
|
|
167
|
+
await client.startTimer('as:123')
|
|
168
|
+
|
|
169
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
170
|
+
'https://api.everhour.com/timers',
|
|
171
|
+
expect.objectContaining({
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: expect.objectContaining({
|
|
174
|
+
'X-Api-Key': 'test-key',
|
|
175
|
+
}),
|
|
176
|
+
})
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('checkDailyLimit calculates correctly', async () => {
|
|
181
|
+
mockFetch.mockImplementation(() =>
|
|
182
|
+
Promise.resolve({
|
|
183
|
+
ok: true,
|
|
184
|
+
text: () => Promise.resolve(JSON.stringify([{ time: 3600 }, { time: 7200 }])), // 3 hours
|
|
185
|
+
})
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
const client = new EverhourClient('test-key')
|
|
189
|
+
const limit = await client.checkDailyLimit(8)
|
|
190
|
+
|
|
191
|
+
expect(limit.hoursLogged).toBe(3)
|
|
192
|
+
expect(limit.remaining).toBe(5)
|
|
193
|
+
expect(limit.withinLimit).toBe(true)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('helpers', () => {
|
|
198
|
+
test('asanaToEverhour adds as: prefix', () => {
|
|
199
|
+
expect(asanaToEverhour('123456')).toBe('as:123456')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('formatDuration formats correctly', () => {
|
|
203
|
+
expect(formatDuration(30)).toBe('0m')
|
|
204
|
+
expect(formatDuration(90)).toBe('1m')
|
|
205
|
+
expect(formatDuration(3661)).toBe('1h 1m')
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('createEverhourPlugin', () => {
|
|
210
|
+
test('returns warning plugin when no API key', () => {
|
|
211
|
+
const plugin = createEverhourPlugin({})
|
|
212
|
+
expect(plugin.name).toBe('everhour')
|
|
213
|
+
expect(plugin.onConfigLoad).toBeDefined()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('derives everhourId from asanaGid', async () => {
|
|
217
|
+
mockFetch.mockImplementation(() =>
|
|
218
|
+
Promise.resolve({
|
|
219
|
+
ok: true,
|
|
220
|
+
text: () => Promise.resolve(JSON.stringify({})),
|
|
221
|
+
})
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
const plugin = createEverhourPlugin({ apiKey: 'test-key' })
|
|
225
|
+
await plugin.onTaskStart?.(mockTask)
|
|
226
|
+
|
|
227
|
+
// Should have started timer with as:123456789
|
|
228
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
229
|
+
'https://api.everhour.com/timers',
|
|
230
|
+
expect.objectContaining({
|
|
231
|
+
body: expect.stringContaining('as:123456789'),
|
|
232
|
+
})
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('uses explicit everhourId over asanaGid', async () => {
|
|
237
|
+
mockFetch.mockImplementation(() =>
|
|
238
|
+
Promise.resolve({
|
|
239
|
+
ok: true,
|
|
240
|
+
text: () => Promise.resolve(JSON.stringify({})),
|
|
241
|
+
})
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
const taskWithEverhourId: PluginTask = {
|
|
245
|
+
id: 'TASK-001',
|
|
246
|
+
title: 'Test',
|
|
247
|
+
metadata: { asanaGid: '123', everhourId: 'custom-id' },
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const plugin = createEverhourPlugin({ apiKey: 'test-key' })
|
|
251
|
+
await plugin.onTaskStart?.(taskWithEverhourId)
|
|
252
|
+
|
|
253
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
254
|
+
'https://api.everhour.com/timers',
|
|
255
|
+
expect.objectContaining({
|
|
256
|
+
body: expect.stringContaining('custom-id'),
|
|
257
|
+
})
|
|
258
|
+
)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe('Todoist Plugin', () => {
|
|
264
|
+
const mockTask: PluginTask = {
|
|
265
|
+
id: 'TASK-001',
|
|
266
|
+
title: 'Test task',
|
|
267
|
+
metadata: { todoistId: '999888777' },
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
describe('TodoistClient', () => {
|
|
271
|
+
test('completeTask makes correct API call', async () => {
|
|
272
|
+
mockFetch.mockImplementation(() =>
|
|
273
|
+
Promise.resolve({ ok: true, status: 204, text: () => Promise.resolve('') })
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
const client = new TodoistClient('test-token')
|
|
277
|
+
await client.completeTask('123')
|
|
278
|
+
|
|
279
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
280
|
+
'https://api.todoist.com/rest/v2/tasks/123/close',
|
|
281
|
+
expect.objectContaining({
|
|
282
|
+
method: 'POST',
|
|
283
|
+
headers: expect.objectContaining({
|
|
284
|
+
Authorization: 'Bearer test-token',
|
|
285
|
+
}),
|
|
286
|
+
})
|
|
287
|
+
)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('addComment makes correct API call', async () => {
|
|
291
|
+
mockFetch.mockImplementation(() =>
|
|
292
|
+
Promise.resolve({
|
|
293
|
+
ok: true,
|
|
294
|
+
json: () => Promise.resolve({ id: '1', task_id: '123', content: 'test' }),
|
|
295
|
+
})
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
const client = new TodoistClient('test-token')
|
|
299
|
+
await client.addComment('123', 'Test comment')
|
|
300
|
+
|
|
301
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
302
|
+
'https://api.todoist.com/rest/v2/comments',
|
|
303
|
+
expect.objectContaining({
|
|
304
|
+
method: 'POST',
|
|
305
|
+
body: JSON.stringify({ task_id: '123', content: 'Test comment' }),
|
|
306
|
+
})
|
|
307
|
+
)
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
describe('createTodoistPlugin', () => {
|
|
312
|
+
test('returns warning plugin when no token', () => {
|
|
313
|
+
const plugin = createTodoistPlugin({})
|
|
314
|
+
expect(plugin.name).toBe('todoist')
|
|
315
|
+
expect(plugin.onConfigLoad).toBeDefined()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('skips tasks without todoistId', async () => {
|
|
319
|
+
const plugin = createTodoistPlugin({ apiToken: 'test-token' })
|
|
320
|
+
const taskWithoutId: PluginTask = { id: 'TASK-001', title: 'Test' }
|
|
321
|
+
|
|
322
|
+
await plugin.onTaskStart?.(taskWithoutId)
|
|
323
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('onTaskComplete calls API', async () => {
|
|
327
|
+
mockFetch.mockImplementation(() =>
|
|
328
|
+
Promise.resolve({
|
|
329
|
+
ok: true,
|
|
330
|
+
status: 204,
|
|
331
|
+
json: () => Promise.resolve({}),
|
|
332
|
+
text: () => Promise.resolve(''),
|
|
333
|
+
})
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
const plugin = createTodoistPlugin({ apiToken: 'test-token' })
|
|
337
|
+
await plugin.onTaskComplete?.(mockTask, { duration: 45 })
|
|
338
|
+
|
|
339
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
describe('Discord Plugin', () => {
|
|
345
|
+
const mockTask: PluginTask = {
|
|
346
|
+
id: 'TASK-001',
|
|
347
|
+
title: 'Test task',
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
describe('DiscordClient', () => {
|
|
351
|
+
test('send makes correct API call', async () => {
|
|
352
|
+
mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
|
|
353
|
+
|
|
354
|
+
const client = new DiscordClient('https://discord.com/webhook')
|
|
355
|
+
await client.sendText('Hello')
|
|
356
|
+
|
|
357
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
358
|
+
'https://discord.com/webhook',
|
|
359
|
+
expect.objectContaining({
|
|
360
|
+
method: 'POST',
|
|
361
|
+
body: expect.stringContaining('Hello'),
|
|
362
|
+
})
|
|
363
|
+
)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('sendEmbed includes embed structure', async () => {
|
|
367
|
+
mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
|
|
368
|
+
|
|
369
|
+
const client = new DiscordClient('https://discord.com/webhook')
|
|
370
|
+
await client.sendEmbed({
|
|
371
|
+
title: 'Test',
|
|
372
|
+
description: 'Description',
|
|
373
|
+
color: 0x00ff00,
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
377
|
+
'https://discord.com/webhook',
|
|
378
|
+
expect.objectContaining({
|
|
379
|
+
body: expect.stringContaining('"embeds"'),
|
|
380
|
+
})
|
|
381
|
+
)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
test('handles API errors', async () => {
|
|
385
|
+
mockFetch.mockImplementation(() =>
|
|
386
|
+
Promise.resolve({
|
|
387
|
+
ok: false,
|
|
388
|
+
status: 400,
|
|
389
|
+
text: () => Promise.resolve('Bad Request'),
|
|
390
|
+
})
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
const client = new DiscordClient('https://discord.com/webhook')
|
|
394
|
+
await expect(client.sendText('test')).rejects.toThrow('Discord webhook error')
|
|
395
|
+
})
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
describe('createDiscordPlugin', () => {
|
|
399
|
+
test('returns warning plugin when no webhook URL', () => {
|
|
400
|
+
const plugin = createDiscordPlugin({})
|
|
401
|
+
expect(plugin.name).toBe('discord')
|
|
402
|
+
expect(plugin.onConfigLoad).toBeDefined()
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
test('respects notification settings', async () => {
|
|
406
|
+
mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
|
|
407
|
+
|
|
408
|
+
const plugin = createDiscordPlugin({
|
|
409
|
+
webhookUrl: 'https://discord.com/webhook',
|
|
410
|
+
notifyOnStart: false,
|
|
411
|
+
notifyOnComplete: false,
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
await plugin.onTaskStart?.(mockTask)
|
|
415
|
+
await plugin.onTaskComplete?.(mockTask, { duration: 30 })
|
|
416
|
+
|
|
417
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
test('sends notification on task failure', async () => {
|
|
421
|
+
mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
|
|
422
|
+
|
|
423
|
+
const plugin = createDiscordPlugin({
|
|
424
|
+
webhookUrl: 'https://discord.com/webhook',
|
|
425
|
+
notifyOnFail: true,
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
await plugin.onTaskFailed?.(mockTask, 'Test error')
|
|
429
|
+
|
|
430
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test('includes mention on failure', async () => {
|
|
434
|
+
mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
|
|
435
|
+
|
|
436
|
+
const plugin = createDiscordPlugin({
|
|
437
|
+
webhookUrl: 'https://discord.com/webhook',
|
|
438
|
+
mentionOnFail: '<@&123456>',
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
await plugin.onTaskFailed?.(mockTask, 'Test error')
|
|
442
|
+
|
|
443
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
444
|
+
'https://discord.com/webhook',
|
|
445
|
+
expect.objectContaining({
|
|
446
|
+
body: expect.stringContaining('<@&123456>'),
|
|
447
|
+
})
|
|
448
|
+
)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
test('sends loop end summary', async () => {
|
|
452
|
+
mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
|
|
453
|
+
|
|
454
|
+
const plugin = createDiscordPlugin({
|
|
455
|
+
webhookUrl: 'https://discord.com/webhook',
|
|
456
|
+
notifyOnLoopEnd: true,
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
await plugin.onLoopEnd?.({ completed: 5, failed: 1, duration: 300 })
|
|
460
|
+
|
|
461
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
462
|
+
})
|
|
463
|
+
})
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
// Additional tests for better coverage
|
|
467
|
+
|
|
468
|
+
describe('Asana Plugin - Additional Coverage', () => {
|
|
469
|
+
describe('AsanaClient additional methods', () => {
|
|
470
|
+
test('createTask makes correct API call', async () => {
|
|
471
|
+
mockFetch.mockImplementation(() =>
|
|
472
|
+
Promise.resolve({
|
|
473
|
+
ok: true,
|
|
474
|
+
json: () => Promise.resolve({ data: { gid: 'new-123', name: 'New Task' } }),
|
|
475
|
+
})
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
const client = new AsanaClient('test-token')
|
|
479
|
+
const task = await client.createTask('project-123', 'New Task', 'Description')
|
|
480
|
+
|
|
481
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
482
|
+
'https://app.asana.com/api/1.0/tasks',
|
|
483
|
+
expect.objectContaining({
|
|
484
|
+
method: 'POST',
|
|
485
|
+
body: expect.stringContaining('New Task'),
|
|
486
|
+
})
|
|
487
|
+
)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
test('addComment makes correct API call', async () => {
|
|
491
|
+
mockFetch.mockImplementation(() =>
|
|
492
|
+
Promise.resolve({
|
|
493
|
+
ok: true,
|
|
494
|
+
json: () => Promise.resolve({ data: {} }),
|
|
495
|
+
})
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
const client = new AsanaClient('test-token')
|
|
499
|
+
await client.addComment('123', 'Test comment')
|
|
500
|
+
|
|
501
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
502
|
+
'https://app.asana.com/api/1.0/tasks/123/stories',
|
|
503
|
+
expect.objectContaining({
|
|
504
|
+
method: 'POST',
|
|
505
|
+
body: expect.stringContaining('Test comment'),
|
|
506
|
+
})
|
|
507
|
+
)
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
test('getProjectTasks makes correct API call', async () => {
|
|
511
|
+
mockFetch.mockImplementation(() =>
|
|
512
|
+
Promise.resolve({
|
|
513
|
+
ok: true,
|
|
514
|
+
json: () => Promise.resolve({ data: [{ gid: '1' }, { gid: '2' }] }),
|
|
515
|
+
})
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
const client = new AsanaClient('test-token')
|
|
519
|
+
const tasks = await client.getProjectTasks('project-123')
|
|
520
|
+
|
|
521
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
522
|
+
expect.stringContaining('/projects/project-123/tasks'),
|
|
523
|
+
expect.anything()
|
|
524
|
+
)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
test('updateTask makes correct API call', async () => {
|
|
528
|
+
mockFetch.mockImplementation(() =>
|
|
529
|
+
Promise.resolve({
|
|
530
|
+
ok: true,
|
|
531
|
+
json: () => Promise.resolve({ data: { gid: '123', name: 'Updated' } }),
|
|
532
|
+
})
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
const client = new AsanaClient('test-token')
|
|
536
|
+
await client.updateTask('123', { name: 'Updated' })
|
|
537
|
+
|
|
538
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
539
|
+
'https://app.asana.com/api/1.0/tasks/123',
|
|
540
|
+
expect.objectContaining({
|
|
541
|
+
method: 'PUT',
|
|
542
|
+
})
|
|
543
|
+
)
|
|
544
|
+
})
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
describe('createAsanaPlugin additional coverage', () => {
|
|
548
|
+
test('onTaskStart adds comment', async () => {
|
|
549
|
+
mockFetch.mockImplementation(() =>
|
|
550
|
+
Promise.resolve({
|
|
551
|
+
ok: true,
|
|
552
|
+
json: () => Promise.resolve({ data: {} }),
|
|
553
|
+
})
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
const plugin = createAsanaPlugin({
|
|
557
|
+
accessToken: 'test-token',
|
|
558
|
+
projectId: 'project-123',
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
const task: PluginTask = {
|
|
562
|
+
id: 'TASK-001',
|
|
563
|
+
title: 'Test',
|
|
564
|
+
metadata: { asanaGid: '123456' },
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
await plugin.onTaskStart?.(task)
|
|
568
|
+
|
|
569
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
570
|
+
'https://app.asana.com/api/1.0/tasks/123456/stories',
|
|
571
|
+
expect.anything()
|
|
572
|
+
)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
test('onTaskFailed adds comment', async () => {
|
|
576
|
+
mockFetch.mockImplementation(() =>
|
|
577
|
+
Promise.resolve({
|
|
578
|
+
ok: true,
|
|
579
|
+
json: () => Promise.resolve({ data: {} }),
|
|
580
|
+
})
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
const plugin = createAsanaPlugin({
|
|
584
|
+
accessToken: 'test-token',
|
|
585
|
+
projectId: 'project-123',
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
const task: PluginTask = {
|
|
589
|
+
id: 'TASK-001',
|
|
590
|
+
title: 'Test',
|
|
591
|
+
metadata: { asanaGid: '123456' },
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
await plugin.onTaskFailed?.(task, 'Error message')
|
|
595
|
+
|
|
596
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
test('onLoopEnd logs summary', async () => {
|
|
600
|
+
const plugin = createAsanaPlugin({
|
|
601
|
+
accessToken: 'test-token',
|
|
602
|
+
projectId: 'project-123',
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
// Should not throw
|
|
606
|
+
await plugin.onLoopEnd?.({ completed: 5, failed: 1, duration: 300 })
|
|
607
|
+
})
|
|
608
|
+
})
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
describe('Everhour Plugin - Additional Coverage', () => {
|
|
612
|
+
describe('EverhourClient additional methods', () => {
|
|
613
|
+
test('stopTimer makes correct API call', async () => {
|
|
614
|
+
mockFetch.mockImplementation(() =>
|
|
615
|
+
Promise.resolve({
|
|
616
|
+
ok: true,
|
|
617
|
+
text: () => Promise.resolve(JSON.stringify({ status: 'stopped' })),
|
|
618
|
+
})
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
const client = new EverhourClient('test-key')
|
|
622
|
+
await client.stopTimer()
|
|
623
|
+
|
|
624
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
625
|
+
'https://api.everhour.com/timers/current',
|
|
626
|
+
expect.objectContaining({
|
|
627
|
+
method: 'DELETE',
|
|
628
|
+
})
|
|
629
|
+
)
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
test('addTime makes correct API call', async () => {
|
|
633
|
+
mockFetch.mockImplementation(() =>
|
|
634
|
+
Promise.resolve({
|
|
635
|
+
ok: true,
|
|
636
|
+
text: () => Promise.resolve(JSON.stringify({ id: 1, time: 3600 })),
|
|
637
|
+
})
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
const client = new EverhourClient('test-key')
|
|
641
|
+
await client.addTime('as:123', 3600, '2024-01-15')
|
|
642
|
+
|
|
643
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
644
|
+
'https://api.everhour.com/tasks/as:123/time',
|
|
645
|
+
expect.objectContaining({
|
|
646
|
+
method: 'POST',
|
|
647
|
+
body: expect.stringContaining('3600'),
|
|
648
|
+
})
|
|
649
|
+
)
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
test('getTaskTime makes correct API call', async () => {
|
|
653
|
+
mockFetch.mockImplementation(() =>
|
|
654
|
+
Promise.resolve({
|
|
655
|
+
ok: true,
|
|
656
|
+
text: () => Promise.resolve(JSON.stringify({ id: 'as:123', time: { total: 7200 } })),
|
|
657
|
+
})
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
const client = new EverhourClient('test-key')
|
|
661
|
+
const task = await client.getTaskTime('as:123')
|
|
662
|
+
|
|
663
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
664
|
+
'https://api.everhour.com/tasks/as:123',
|
|
665
|
+
expect.anything()
|
|
666
|
+
)
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
test('getCurrentTimer makes correct API call', async () => {
|
|
670
|
+
mockFetch.mockImplementation(() =>
|
|
671
|
+
Promise.resolve({
|
|
672
|
+
ok: true,
|
|
673
|
+
text: () => Promise.resolve(JSON.stringify({ status: 'active', duration: 1800 })),
|
|
674
|
+
})
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
const client = new EverhourClient('test-key')
|
|
678
|
+
const timer = await client.getCurrentTimer()
|
|
679
|
+
|
|
680
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
681
|
+
'https://api.everhour.com/timers/current',
|
|
682
|
+
expect.anything()
|
|
683
|
+
)
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
test('getCurrentTimer returns null on error', async () => {
|
|
687
|
+
mockFetch.mockImplementation(() =>
|
|
688
|
+
Promise.resolve({
|
|
689
|
+
ok: false,
|
|
690
|
+
status: 404,
|
|
691
|
+
text: () => Promise.resolve('Not found'),
|
|
692
|
+
})
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
const client = new EverhourClient('test-key')
|
|
696
|
+
const timer = await client.getCurrentTimer()
|
|
697
|
+
|
|
698
|
+
expect(timer).toBeNull()
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
test('getTodayTotal calculates correctly', async () => {
|
|
702
|
+
mockFetch.mockImplementation(() =>
|
|
703
|
+
Promise.resolve({
|
|
704
|
+
ok: true,
|
|
705
|
+
text: () => Promise.resolve(JSON.stringify([{ time: 1800 }, { time: 3600 }])),
|
|
706
|
+
})
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
const client = new EverhourClient('test-key')
|
|
710
|
+
const total = await client.getTodayTotal()
|
|
711
|
+
|
|
712
|
+
expect(total).toBe(5400)
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
test('getMe makes correct API call', async () => {
|
|
716
|
+
mockFetch.mockImplementation(() =>
|
|
717
|
+
Promise.resolve({
|
|
718
|
+
ok: true,
|
|
719
|
+
text: () => Promise.resolve(JSON.stringify({ id: 1, name: 'Test', email: 'test@test.com' })),
|
|
720
|
+
})
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
const client = new EverhourClient('test-key')
|
|
724
|
+
const user = await client.getMe()
|
|
725
|
+
|
|
726
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
727
|
+
'https://api.everhour.com/users/me',
|
|
728
|
+
expect.anything()
|
|
729
|
+
)
|
|
730
|
+
expect(user.email).toBe('test@test.com')
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
test('handles API errors', async () => {
|
|
734
|
+
mockFetch.mockImplementation(() =>
|
|
735
|
+
Promise.resolve({
|
|
736
|
+
ok: false,
|
|
737
|
+
status: 401,
|
|
738
|
+
text: () => Promise.resolve('Unauthorized'),
|
|
739
|
+
})
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
const client = new EverhourClient('bad-key')
|
|
743
|
+
await expect(client.startTimer('as:123')).rejects.toThrow('Everhour API error')
|
|
744
|
+
})
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
describe('createEverhourPlugin additional coverage', () => {
|
|
748
|
+
test('onLoopStart checks daily limit', async () => {
|
|
749
|
+
mockFetch.mockImplementation(() =>
|
|
750
|
+
Promise.resolve({
|
|
751
|
+
ok: true,
|
|
752
|
+
text: () => Promise.resolve(JSON.stringify([{ time: 28800 }])), // 8 hours
|
|
753
|
+
})
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
const plugin = createEverhourPlugin({ apiKey: 'test-key' })
|
|
757
|
+
await plugin.onLoopStart?.('test-namespace')
|
|
758
|
+
|
|
759
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
test('onTaskComplete stops timer', async () => {
|
|
763
|
+
mockFetch.mockImplementation(() =>
|
|
764
|
+
Promise.resolve({
|
|
765
|
+
ok: true,
|
|
766
|
+
text: () => Promise.resolve(JSON.stringify({})),
|
|
767
|
+
})
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
const plugin = createEverhourPlugin({ apiKey: 'test-key' })
|
|
771
|
+
const task: PluginTask = {
|
|
772
|
+
id: 'TASK-001',
|
|
773
|
+
title: 'Test',
|
|
774
|
+
metadata: { asanaGid: '123' },
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Start task first to register timer
|
|
778
|
+
await plugin.onTaskStart?.(task)
|
|
779
|
+
|
|
780
|
+
// Then complete it
|
|
781
|
+
await plugin.onTaskComplete?.(task, { duration: 60 })
|
|
782
|
+
|
|
783
|
+
// Should have called stop timer
|
|
784
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
test('onTaskFailed stops timer', async () => {
|
|
788
|
+
mockFetch.mockImplementation(() =>
|
|
789
|
+
Promise.resolve({
|
|
790
|
+
ok: true,
|
|
791
|
+
text: () => Promise.resolve(JSON.stringify({})),
|
|
792
|
+
})
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
const plugin = createEverhourPlugin({ apiKey: 'test-key' })
|
|
796
|
+
const task: PluginTask = {
|
|
797
|
+
id: 'TASK-002',
|
|
798
|
+
title: 'Test',
|
|
799
|
+
metadata: { everhourId: 'eh:456' },
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
await plugin.onTaskStart?.(task)
|
|
803
|
+
await plugin.onTaskFailed?.(task, 'Error')
|
|
804
|
+
|
|
805
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
test('onLoopEnd reports summary', async () => {
|
|
809
|
+
mockFetch.mockImplementation(() =>
|
|
810
|
+
Promise.resolve({
|
|
811
|
+
ok: true,
|
|
812
|
+
text: () => Promise.resolve(JSON.stringify([{ time: 3600 }])),
|
|
813
|
+
})
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
const plugin = createEverhourPlugin({ apiKey: 'test-key' })
|
|
817
|
+
await plugin.onLoopEnd?.({ completed: 5, failed: 1, duration: 300 })
|
|
818
|
+
|
|
819
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
test('skips tasks without everhour ID', async () => {
|
|
823
|
+
const plugin = createEverhourPlugin({ apiKey: 'test-key' })
|
|
824
|
+
const task: PluginTask = { id: 'TASK-001', title: 'Test' }
|
|
825
|
+
|
|
826
|
+
await plugin.onTaskStart?.(task)
|
|
827
|
+
|
|
828
|
+
// Should not have called timer start (only daily limit check in onLoopStart)
|
|
829
|
+
const timerCalls = mockFetch.mock.calls.filter(
|
|
830
|
+
(call: any) => call[0].includes('/timers') && !call[0].includes('current')
|
|
831
|
+
)
|
|
832
|
+
expect(timerCalls.length).toBe(0)
|
|
833
|
+
})
|
|
834
|
+
})
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
describe('Todoist Plugin - Additional Coverage', () => {
|
|
838
|
+
describe('TodoistClient additional methods', () => {
|
|
839
|
+
test('getTask makes correct API call', async () => {
|
|
840
|
+
mockFetch.mockImplementation(() =>
|
|
841
|
+
Promise.resolve({
|
|
842
|
+
ok: true,
|
|
843
|
+
json: () => Promise.resolve({ id: '123', content: 'Test task' }),
|
|
844
|
+
})
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
const client = new TodoistClient('test-token')
|
|
848
|
+
const task = await client.getTask('123')
|
|
849
|
+
|
|
850
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
851
|
+
'https://api.todoist.com/rest/v2/tasks/123',
|
|
852
|
+
expect.anything()
|
|
853
|
+
)
|
|
854
|
+
expect(task.content).toBe('Test task')
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
test('createTask makes correct API call', async () => {
|
|
858
|
+
mockFetch.mockImplementation(() =>
|
|
859
|
+
Promise.resolve({
|
|
860
|
+
ok: true,
|
|
861
|
+
json: () => Promise.resolve({ id: 'new-123', content: 'New task' }),
|
|
862
|
+
})
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
const client = new TodoistClient('test-token')
|
|
866
|
+
const task = await client.createTask('New task', {
|
|
867
|
+
description: 'Description',
|
|
868
|
+
projectId: 'project-123',
|
|
869
|
+
priority: 2,
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
873
|
+
'https://api.todoist.com/rest/v2/tasks',
|
|
874
|
+
expect.objectContaining({
|
|
875
|
+
method: 'POST',
|
|
876
|
+
body: expect.stringContaining('New task'),
|
|
877
|
+
})
|
|
878
|
+
)
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
test('updateTask makes correct API call', async () => {
|
|
882
|
+
mockFetch.mockImplementation(() =>
|
|
883
|
+
Promise.resolve({
|
|
884
|
+
ok: true,
|
|
885
|
+
json: () => Promise.resolve({ id: '123', content: 'Updated' }),
|
|
886
|
+
})
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
const client = new TodoistClient('test-token')
|
|
890
|
+
await client.updateTask('123', { content: 'Updated' })
|
|
891
|
+
|
|
892
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
893
|
+
'https://api.todoist.com/rest/v2/tasks/123',
|
|
894
|
+
expect.objectContaining({
|
|
895
|
+
method: 'POST',
|
|
896
|
+
})
|
|
897
|
+
)
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
test('reopenTask makes correct API call', async () => {
|
|
901
|
+
mockFetch.mockImplementation(() =>
|
|
902
|
+
Promise.resolve({ ok: true, status: 204, text: () => Promise.resolve('') })
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
const client = new TodoistClient('test-token')
|
|
906
|
+
await client.reopenTask('123')
|
|
907
|
+
|
|
908
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
909
|
+
'https://api.todoist.com/rest/v2/tasks/123/reopen',
|
|
910
|
+
expect.objectContaining({
|
|
911
|
+
method: 'POST',
|
|
912
|
+
})
|
|
913
|
+
)
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
test('deleteTask makes correct API call', async () => {
|
|
917
|
+
mockFetch.mockImplementation(() =>
|
|
918
|
+
Promise.resolve({ ok: true, status: 204, text: () => Promise.resolve('') })
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
const client = new TodoistClient('test-token')
|
|
922
|
+
await client.deleteTask('123')
|
|
923
|
+
|
|
924
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
925
|
+
'https://api.todoist.com/rest/v2/tasks/123',
|
|
926
|
+
expect.objectContaining({
|
|
927
|
+
method: 'DELETE',
|
|
928
|
+
})
|
|
929
|
+
)
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
test('getProjectTasks makes correct API call', async () => {
|
|
933
|
+
mockFetch.mockImplementation(() =>
|
|
934
|
+
Promise.resolve({
|
|
935
|
+
ok: true,
|
|
936
|
+
json: () => Promise.resolve([{ id: '1' }, { id: '2' }]),
|
|
937
|
+
})
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
const client = new TodoistClient('test-token')
|
|
941
|
+
const tasks = await client.getProjectTasks('project-123')
|
|
942
|
+
|
|
943
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
944
|
+
'https://api.todoist.com/rest/v2/tasks?project_id=project-123',
|
|
945
|
+
expect.anything()
|
|
946
|
+
)
|
|
947
|
+
expect(tasks).toHaveLength(2)
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
test('getComments makes correct API call', async () => {
|
|
951
|
+
mockFetch.mockImplementation(() =>
|
|
952
|
+
Promise.resolve({
|
|
953
|
+
ok: true,
|
|
954
|
+
json: () => Promise.resolve([{ id: '1', content: 'Comment' }]),
|
|
955
|
+
})
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
const client = new TodoistClient('test-token')
|
|
959
|
+
const comments = await client.getComments('123')
|
|
960
|
+
|
|
961
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
962
|
+
'https://api.todoist.com/rest/v2/comments?task_id=123',
|
|
963
|
+
expect.anything()
|
|
964
|
+
)
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
test('getProjects makes correct API call', async () => {
|
|
968
|
+
mockFetch.mockImplementation(() =>
|
|
969
|
+
Promise.resolve({
|
|
970
|
+
ok: true,
|
|
971
|
+
json: () => Promise.resolve([{ id: '1', name: 'Project' }]),
|
|
972
|
+
})
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
const client = new TodoistClient('test-token')
|
|
976
|
+
const projects = await client.getProjects()
|
|
977
|
+
|
|
978
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
979
|
+
'https://api.todoist.com/rest/v2/projects',
|
|
980
|
+
expect.anything()
|
|
981
|
+
)
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
test('handles API errors', async () => {
|
|
985
|
+
mockFetch.mockImplementation(() =>
|
|
986
|
+
Promise.resolve({
|
|
987
|
+
ok: false,
|
|
988
|
+
status: 403,
|
|
989
|
+
text: () => Promise.resolve('Forbidden'),
|
|
990
|
+
})
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
const client = new TodoistClient('bad-token')
|
|
994
|
+
await expect(client.getTask('123')).rejects.toThrow('Todoist API error')
|
|
995
|
+
})
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
describe('createTodoistPlugin additional coverage', () => {
|
|
999
|
+
test('onTaskStart adds comment', async () => {
|
|
1000
|
+
mockFetch.mockImplementation(() =>
|
|
1001
|
+
Promise.resolve({
|
|
1002
|
+
ok: true,
|
|
1003
|
+
json: () => Promise.resolve({ id: '1' }),
|
|
1004
|
+
})
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
const plugin = createTodoistPlugin({ apiToken: 'test-token' })
|
|
1008
|
+
const task: PluginTask = {
|
|
1009
|
+
id: 'TASK-001',
|
|
1010
|
+
title: 'Test',
|
|
1011
|
+
metadata: { todoistId: '123' },
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
await plugin.onTaskStart?.(task)
|
|
1015
|
+
|
|
1016
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
1017
|
+
'https://api.todoist.com/rest/v2/comments',
|
|
1018
|
+
expect.anything()
|
|
1019
|
+
)
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
test('onTaskFailed adds comment', async () => {
|
|
1023
|
+
mockFetch.mockImplementation(() =>
|
|
1024
|
+
Promise.resolve({
|
|
1025
|
+
ok: true,
|
|
1026
|
+
json: () => Promise.resolve({ id: '1' }),
|
|
1027
|
+
})
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
const plugin = createTodoistPlugin({ apiToken: 'test-token' })
|
|
1031
|
+
const task: PluginTask = {
|
|
1032
|
+
id: 'TASK-001',
|
|
1033
|
+
title: 'Test',
|
|
1034
|
+
metadata: { todoistId: '123' },
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
await plugin.onTaskFailed?.(task, 'Error message')
|
|
1038
|
+
|
|
1039
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
test('onLoopEnd logs summary', async () => {
|
|
1043
|
+
const plugin = createTodoistPlugin({ apiToken: 'test-token' })
|
|
1044
|
+
await plugin.onLoopEnd?.({ completed: 5, failed: 1, duration: 300 })
|
|
1045
|
+
// Should not throw
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
test('respects addComments=false', async () => {
|
|
1049
|
+
const plugin = createTodoistPlugin({
|
|
1050
|
+
apiToken: 'test-token',
|
|
1051
|
+
addComments: false,
|
|
1052
|
+
})
|
|
1053
|
+
const task: PluginTask = {
|
|
1054
|
+
id: 'TASK-001',
|
|
1055
|
+
title: 'Test',
|
|
1056
|
+
metadata: { todoistId: '123' },
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
await plugin.onTaskStart?.(task)
|
|
1060
|
+
|
|
1061
|
+
// Should not have added comment
|
|
1062
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
1063
|
+
})
|
|
1064
|
+
})
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
describe('Discord Plugin - Additional Coverage', () => {
|
|
1068
|
+
describe('DiscordClient additional methods', () => {
|
|
1069
|
+
test('notifyTaskStart sends embed', async () => {
|
|
1070
|
+
mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
|
|
1071
|
+
|
|
1072
|
+
const client = new DiscordClient('https://discord.com/webhook')
|
|
1073
|
+
await client.notifyTaskStart({ id: 'TASK-001', title: 'Test' })
|
|
1074
|
+
|
|
1075
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
1076
|
+
'https://discord.com/webhook',
|
|
1077
|
+
expect.objectContaining({
|
|
1078
|
+
body: expect.stringContaining('Task Started'),
|
|
1079
|
+
})
|
|
1080
|
+
)
|
|
1081
|
+
})
|
|
1082
|
+
|
|
1083
|
+
test('notifyTaskComplete sends embed', async () => {
|
|
1084
|
+
mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
|
|
1085
|
+
|
|
1086
|
+
const client = new DiscordClient('https://discord.com/webhook')
|
|
1087
|
+
await client.notifyTaskComplete({ id: 'TASK-001', title: 'Test' }, 120)
|
|
1088
|
+
|
|
1089
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
1090
|
+
'https://discord.com/webhook',
|
|
1091
|
+
expect.objectContaining({
|
|
1092
|
+
body: expect.stringContaining('Task Completed'),
|
|
1093
|
+
})
|
|
1094
|
+
)
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
test('notifyTaskFailed sends embed with mention', async () => {
|
|
1098
|
+
mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
|
|
1099
|
+
|
|
1100
|
+
const client = new DiscordClient('https://discord.com/webhook')
|
|
1101
|
+
await client.notifyTaskFailed({ id: 'TASK-001', title: 'Test' }, 'Error', '<@123>')
|
|
1102
|
+
|
|
1103
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
1104
|
+
'https://discord.com/webhook',
|
|
1105
|
+
expect.objectContaining({
|
|
1106
|
+
body: expect.stringContaining('<@123>'),
|
|
1107
|
+
})
|
|
1108
|
+
)
|
|
1109
|
+
})
|
|
1110
|
+
|
|
1111
|
+
test('notifyLoopEnd sends summary embed', async () => {
|
|
1112
|
+
mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
|
|
1113
|
+
|
|
1114
|
+
const client = new DiscordClient('https://discord.com/webhook')
|
|
1115
|
+
await client.notifyLoopEnd({ completed: 10, failed: 2, duration: 600 })
|
|
1116
|
+
|
|
1117
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
1118
|
+
'https://discord.com/webhook',
|
|
1119
|
+
expect.objectContaining({
|
|
1120
|
+
body: expect.stringContaining('Loop Summary'),
|
|
1121
|
+
})
|
|
1122
|
+
)
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
test('uses custom username and avatar', async () => {
|
|
1126
|
+
mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
|
|
1127
|
+
|
|
1128
|
+
const client = new DiscordClient('https://discord.com/webhook', {
|
|
1129
|
+
username: 'Custom Bot',
|
|
1130
|
+
avatarUrl: 'https://example.com/avatar.png',
|
|
1131
|
+
})
|
|
1132
|
+
await client.sendText('Hello')
|
|
1133
|
+
|
|
1134
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
1135
|
+
'https://discord.com/webhook',
|
|
1136
|
+
expect.objectContaining({
|
|
1137
|
+
body: expect.stringContaining('Custom Bot'),
|
|
1138
|
+
})
|
|
1139
|
+
)
|
|
1140
|
+
})
|
|
1141
|
+
})
|
|
1142
|
+
|
|
1143
|
+
describe('createDiscordPlugin additional coverage', () => {
|
|
1144
|
+
test('sends notification on task start when enabled', async () => {
|
|
1145
|
+
mockFetch.mockImplementation(() => Promise.resolve({ ok: true }))
|
|
1146
|
+
|
|
1147
|
+
const plugin = createDiscordPlugin({
|
|
1148
|
+
webhookUrl: 'https://discord.com/webhook',
|
|
1149
|
+
notifyOnStart: true,
|
|
1150
|
+
})
|
|
1151
|
+
|
|
1152
|
+
await plugin.onTaskStart?.({ id: 'TASK-001', title: 'Test' })
|
|
1153
|
+
|
|
1154
|
+
expect(mockFetch).toHaveBeenCalled()
|
|
1155
|
+
})
|
|
1156
|
+
|
|
1157
|
+
test('handles API errors gracefully', async () => {
|
|
1158
|
+
mockFetch.mockImplementation(() =>
|
|
1159
|
+
Promise.resolve({
|
|
1160
|
+
ok: false,
|
|
1161
|
+
status: 500,
|
|
1162
|
+
text: () => Promise.resolve('Server Error'),
|
|
1163
|
+
})
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
const plugin = createDiscordPlugin({
|
|
1167
|
+
webhookUrl: 'https://discord.com/webhook',
|
|
1168
|
+
notifyOnComplete: true,
|
|
1169
|
+
})
|
|
1170
|
+
|
|
1171
|
+
// Should not throw, just log warning
|
|
1172
|
+
await plugin.onTaskComplete?.({ id: 'TASK-001', title: 'Test' }, { duration: 30 })
|
|
1173
|
+
})
|
|
1174
|
+
})
|
|
1175
|
+
})
|