Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 23 additions & 21 deletions src/services/todoist.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { HttpModule } from '@nestjs/axios'
import { Test } from '@nestjs/testing'
import { chunk } from 'lodash'
import { rest } from 'msw'

import { server } from '../../test/server'
Expand Down Expand Up @@ -37,17 +36,18 @@ describe('TodoistService', () => {
expect(httpServer).toHaveBeenCalledTimes(1)
})

test.each([
[0, 1],
[50, 1],
[99, 1],
[100, 2],
[101, 2],
[150, 2],
[199, 2],
[200, 3],
])('when task count is %i, should call %i times', async (taskCount, expectedCalls) => {
setupGetCompletedItems(Array(taskCount).fill({}) as SyncTask[])
type TestCase = [number, number, string | undefined]
test.each<TestCase>([
[0, 1, ''],
[50, 1, ''],
[99, 1, ''],
[100, 1, ''],
[101, 1, ''],
[150, 1, ''],
[199, 1, ''],
[200, 2, 'cursor'],
])('when task count is %i, should call %i times', async (taskCount, expectedCalls, cursor) => {
setupGetCompletedItems(Array(taskCount).fill({}) as SyncTask[], cursor)
const target = await getTarget()
const httpServer = jest.spyOn(target['httpService'], 'get')

Expand All @@ -58,20 +58,22 @@ describe('TodoistService', () => {
expect(httpServer).toHaveBeenCalledTimes(expectedCalls)
})

function setupGetCompletedItems(items: SyncTask[]) {
const allTasks = chunk(items, 100)

function setupGetCompletedItems(items: SyncTask[], cursor?: string) {
server.use(
rest.get('https://api.todoist.com/sync/v9/items/get_completed', (req, res, ctx) => {
const offset = getOffset(req.url)
const tasks = allTasks[offset / 100] ?? []
return res(ctx.json(tasks))
rest.get('https://api.todoist.com/sync/v9/tasks/archived', (req, res, ctx) => {
const requestedCursor = getCursor(req.url)
return res(
ctx.json({
...(cursor && !requestedCursor ? { next_cursor: cursor } : {}),
items,
}),
)
}),
)
}

function getOffset(url: URL) {
return parseInt(url.searchParams.get('offset') || '0', 10)
function getCursor(url: URL) {
return url.searchParams.get('cursor')
}

async function getTarget() {
Expand Down
30 changes: 20 additions & 10 deletions src/services/todoist.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { lastValueFrom } from 'rxjs'

import type { Task } from '../types'

const LIMIT = 100
const LIMIT = 200

type SyncDue = {
date: string
Expand Down Expand Up @@ -36,6 +36,12 @@ export type SyncTask = {
due?: SyncDue | null
}

type CompletedTasksResponse = {
items: SyncTask[]
total: number
next_cursor?: string
}

@Injectable()
export class TodoistService {
constructor(private readonly httpService: HttpService) {}
Expand All @@ -47,37 +53,41 @@ export class TodoistService {
token: string
projectId: string
}): Promise<Task[]> {
const completedTasks = await this.getCompletedTasksInternal({ token, offset: 0, projectId })
const completedTasks = await this.getCompletedTasksInternal({ token, projectId })

return completedTasks.map((task) => this.getTaskFromQuickAddResponse(task))
}

private async getCompletedTasksInternal({
offset,
cursor,
projectId,
token,
}: {
token: string
offset: number
cursor?: string
projectId: string
}): Promise<SyncTask[]> {
const response = await lastValueFrom(
this.httpService.get<SyncTask[]>(
// At time of writing (08/02/2023), this endpoint is undocumented and its stability is not guaranteed.
`https://api.todoist.com/sync/v9/items/get_completed?project_id=${projectId}&offset=${offset}&limit=${LIMIT}`,
this.httpService.get<CompletedTasksResponse>(
'https://api.todoist.com/sync/v9/tasks/archived',
{
params: {
project_id: projectId,
cursor,
limit: LIMIT,
},
headers: {
Authorization: `Bearer ${token}`,
},
},
),
)

const { data: tasks } = response
const { items: tasks, next_cursor } = response.data

if (tasks.length === LIMIT) {
if (next_cursor) {
return tasks.concat(
await this.getCompletedTasksInternal({ token, offset: offset + LIMIT, projectId }),
await this.getCompletedTasksInternal({ token, cursor: next_cursor, projectId }),
)
}

Expand Down
Loading