Skip to content

Commit 4986e48

Browse files
committed
fix: use different UUIDs for each move task command
1 parent 93b1c9f commit 4986e48

File tree

2 files changed

+150
-2
lines changed

2 files changed

+150
-2
lines changed

src/TodoistApi.moveTasks.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { TodoistApi } from '.'
2+
import { DEFAULT_AUTH_TOKEN, DEFAULT_REQUEST_ID, DEFAULT_TASK } from './testUtils/testDefaults'
3+
import { getSyncBaseUri, ENDPOINT_SYNC } from './consts/endpoints'
4+
import { setupRestClientMock } from './testUtils/mocks'
5+
import { getTaskUrl } from './utils/urlHelpers'
6+
7+
function getTarget(baseUrl = 'https://api.todoist.com') {
8+
return new TodoistApi(DEFAULT_AUTH_TOKEN, baseUrl)
9+
}
10+
11+
describe('TodoistApi moveTasks', () => {
12+
const TASK_IDS = ['123', '456', '789']
13+
const MOVED_TASKS = TASK_IDS.map((id) => ({
14+
...DEFAULT_TASK,
15+
id,
16+
projectId: '999',
17+
url: getTaskUrl(id, DEFAULT_TASK.content),
18+
}))
19+
20+
test('moves multiple tasks to project with unique UUIDs', async () => {
21+
const requestMock = setupRestClientMock({
22+
items: MOVED_TASKS,
23+
sync_status: { [expect.any(String)]: 'ok' },
24+
})
25+
const api = getTarget()
26+
27+
const result = await api.moveTasks(TASK_IDS, { projectId: '999' }, DEFAULT_REQUEST_ID)
28+
29+
// Verify API call structure
30+
expect(requestMock).toBeCalledWith(
31+
'POST',
32+
getSyncBaseUri(),
33+
ENDPOINT_SYNC,
34+
DEFAULT_AUTH_TOKEN,
35+
expect.objectContaining({
36+
commands: expect.arrayContaining([
37+
expect.objectContaining({
38+
type: 'item_move',
39+
args: expect.objectContaining({ project_id: '999' }),
40+
}),
41+
]),
42+
resource_types: ['items'],
43+
}),
44+
DEFAULT_REQUEST_ID,
45+
true,
46+
)
47+
48+
// Verify return value
49+
expect(result).toEqual(MOVED_TASKS)
50+
51+
// Critical: Verify unique UUIDs (see https://github.com/Doist/todoist-api-typescript/issues/310)
52+
const sentRequest = (requestMock.mock.calls[0] as unknown[])[4] as {
53+
commands: Array<{ uuid: string }>
54+
}
55+
const uuids = sentRequest.commands.map((cmd) => cmd.uuid)
56+
const uniqueUuids = new Set(uuids)
57+
expect(uniqueUuids.size).toBe(TASK_IDS.length) // All UUIDs must be different
58+
})
59+
60+
test('supports section move', async () => {
61+
const requestMock = setupRestClientMock({
62+
items: [{ ...DEFAULT_TASK, id: '123', sectionId: '888' }],
63+
sync_status: { [expect.any(String)]: 'ok' },
64+
})
65+
const api = getTarget()
66+
67+
await api.moveTasks(['123'], { sectionId: '888' })
68+
69+
const sentRequest = (
70+
requestMock.mock.calls[0] as [
71+
string,
72+
string,
73+
string,
74+
string,
75+
{ commands: Array<{ args: Record<string, unknown> }> },
76+
]
77+
)[4]
78+
expect(sentRequest.commands[0].args).toEqual({
79+
id: '123',
80+
section_id: '888',
81+
})
82+
})
83+
84+
test('supports parent move', async () => {
85+
const requestMock = setupRestClientMock({
86+
items: [{ ...DEFAULT_TASK, id: '123', parentId: '777' }],
87+
sync_status: { [expect.any(String)]: 'ok' },
88+
})
89+
const api = getTarget()
90+
91+
await api.moveTasks(['123'], { parentId: '777' })
92+
93+
const sentRequest = (
94+
requestMock.mock.calls[0] as [
95+
string,
96+
string,
97+
string,
98+
string,
99+
{ commands: Array<{ args: Record<string, unknown> }> },
100+
]
101+
)[4]
102+
expect(sentRequest.commands[0].args).toEqual({
103+
id: '123',
104+
parent_id: '777',
105+
})
106+
})
107+
108+
test('handles error cases', async () => {
109+
const api = getTarget()
110+
111+
// Test maximum limit
112+
const manyTaskIds = Array.from({ length: 101 }, (_, i) => `task_${i}`)
113+
await expect(api.moveTasks(manyTaskIds, { projectId: '999' })).rejects.toThrow(
114+
'Maximum number of items is 100',
115+
)
116+
117+
// Test sync API error
118+
setupRestClientMock({
119+
items: [],
120+
sync_status: { uuid: { error: 'TASK_NOT_FOUND', http_code: 404, error_extra: {} } },
121+
})
122+
await expect(api.moveTasks(['123'], { projectId: '999' })).rejects.toThrow('TASK_NOT_FOUND')
123+
124+
// Test no tasks returned
125+
setupRestClientMock({ sync_status: { [expect.any(String)]: 'ok' } })
126+
await expect(api.moveTasks(['123'], { projectId: '999' })).rejects.toThrow(
127+
'Tasks not found',
128+
)
129+
})
130+
131+
test('filters returned tasks to requested IDs only', async () => {
132+
const extraTask = {
133+
...DEFAULT_TASK,
134+
id: '999',
135+
projectId: '999',
136+
url: getTaskUrl('999', DEFAULT_TASK.content),
137+
}
138+
setupRestClientMock({
139+
items: [...MOVED_TASKS, extraTask],
140+
sync_status: { [expect.any(String)]: 'ok' },
141+
})
142+
const api = getTarget()
143+
144+
const result = await api.moveTasks(TASK_IDS, { projectId: '999' })
145+
146+
expect(result).toEqual(MOVED_TASKS) // Should not include extraTask
147+
expect(result).not.toContainEqual(extraTask)
148+
})
149+
})

src/TodoistApi.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,10 +309,9 @@ export class TodoistApi {
309309
if (ids.length > MAX_COMMAND_COUNT) {
310310
throw new TodoistRequestError(`Maximum number of items is ${MAX_COMMAND_COUNT}`, 400)
311311
}
312-
const uuid = uuidv4()
313312
const commands: Command[] = ids.map((id) => ({
314313
type: 'item_move',
315-
uuid,
314+
uuid: uuidv4(),
316315
args: {
317316
id,
318317
...(args.projectId && { project_id: args.projectId }),

0 commit comments

Comments
 (0)