Skip to content

Commit 6d21564

Browse files
amixscottlovegrove
andauthored
feat: Add getUser() API method to retrieve current authenticated user information (#329)
Co-authored-by: Scott Lovegrove <[email protected]>
1 parent 814fbf1 commit 6d21564

File tree

5 files changed

+201
-2
lines changed

5 files changed

+201
-2
lines changed

src/TodoistApi.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { PersonalProject, WorkspaceProject, Label, Section, Comment, Task } from './types/entities'
1+
import {
2+
PersonalProject,
3+
WorkspaceProject,
4+
Label,
5+
Section,
6+
Comment,
7+
Task,
8+
CurrentUser,
9+
} from './types/entities'
210
import {
311
AddCommentArgs,
412
AddLabelArgs,
@@ -58,10 +66,12 @@ import {
5866
PROJECT_ARCHIVE,
5967
PROJECT_UNARCHIVE,
6068
ENDPOINT_REST_PROJECTS_ARCHIVED,
69+
ENDPOINT_REST_USER,
6170
} from './consts/endpoints'
6271
import {
6372
validateComment,
6473
validateCommentArray,
74+
validateCurrentUser,
6575
validateLabel,
6676
validateLabelArray,
6777
validateProject,
@@ -128,6 +138,22 @@ export class TodoistApi {
128138
this.syncApiBase = getSyncBaseUri(baseUrl)
129139
}
130140

141+
/**
142+
* Retrieves information about the authenticated user.
143+
*
144+
* @returns A promise that resolves to the current user's information.
145+
*/
146+
async getUser(): Promise<CurrentUser> {
147+
const response = await request<CurrentUser>(
148+
'GET',
149+
this.syncApiBase,
150+
ENDPOINT_REST_USER,
151+
this.authToken,
152+
)
153+
154+
return validateCurrentUser(response.data)
155+
}
156+
131157
/**
132158
* Retrieves a single active (non-completed) task by its ID.
133159
*

src/TodoistApi.user.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { TodoistApi, type CurrentUser } from '.'
2+
import { DEFAULT_AUTH_TOKEN } from './testUtils/testDefaults'
3+
import { getSyncBaseUri, ENDPOINT_REST_USER } from './consts/endpoints'
4+
import { setupRestClientMock } from './testUtils/mocks'
5+
6+
function getTarget(baseUrl = 'https://api.todoist.com') {
7+
return new TodoistApi(DEFAULT_AUTH_TOKEN, baseUrl)
8+
}
9+
10+
const DEFAULT_CURRENT_USER_RESPONSE: CurrentUser = {
11+
id: '123456789',
12+
13+
fullName: 'Test User',
14+
avatarBig: 'https://example.com/avatars/test_user_big.jpg',
15+
avatarMedium: 'https://example.com/avatars/test_user_medium.jpg',
16+
avatarS640: 'https://example.com/avatars/test_user_s640.jpg',
17+
avatarSmall: 'https://example.com/avatars/test_user_small.jpg',
18+
businessAccountId: null,
19+
isPremium: true,
20+
dateFormat: 0,
21+
timeFormat: 0,
22+
weeklyGoal: 100,
23+
dailyGoal: 10,
24+
completedCount: 102920,
25+
completedToday: 12,
26+
karma: 86394.0,
27+
karmaTrend: 'up',
28+
lang: 'en',
29+
nextWeek: 1,
30+
startDay: 1,
31+
startPage: 'project?id=test_project_123',
32+
tzInfo: {
33+
gmtString: '+02:00',
34+
hours: 2,
35+
isDst: 1,
36+
minutes: 0,
37+
timezone: 'Europe/Madrid',
38+
},
39+
inboxProjectId: 'test_project_123',
40+
daysOff: [6, 7],
41+
weekendStartDay: 6,
42+
}
43+
44+
describe('TodoistApi user endpoints', () => {
45+
describe('getUser', () => {
46+
test('calls get on restClient with expected parameters', async () => {
47+
const requestMock = setupRestClientMock(DEFAULT_CURRENT_USER_RESPONSE)
48+
const api = getTarget()
49+
50+
await api.getUser()
51+
52+
expect(requestMock).toHaveBeenCalledTimes(1)
53+
expect(requestMock).toHaveBeenCalledWith(
54+
'GET',
55+
getSyncBaseUri(),
56+
ENDPOINT_REST_USER,
57+
DEFAULT_AUTH_TOKEN,
58+
)
59+
})
60+
61+
test('calls get on restClient with expected parameters against staging', async () => {
62+
const requestMock = setupRestClientMock(DEFAULT_CURRENT_USER_RESPONSE)
63+
const stagingBaseUrl = 'https://api.todoist-staging.com'
64+
const api = getTarget(stagingBaseUrl)
65+
66+
await api.getUser()
67+
68+
expect(requestMock).toHaveBeenCalledTimes(1)
69+
expect(requestMock).toHaveBeenCalledWith(
70+
'GET',
71+
getSyncBaseUri(stagingBaseUrl),
72+
ENDPOINT_REST_USER,
73+
DEFAULT_AUTH_TOKEN,
74+
)
75+
})
76+
77+
test('handles user with null business account id', async () => {
78+
const responseWithNullBusinessAccount = {
79+
...DEFAULT_CURRENT_USER_RESPONSE,
80+
businessAccountId: null,
81+
}
82+
setupRestClientMock(responseWithNullBusinessAccount)
83+
const api = getTarget()
84+
85+
const actual = await api.getUser()
86+
87+
expect(actual.businessAccountId).toBeNull()
88+
})
89+
90+
test('handles user with null avatar fields', async () => {
91+
const responseWithNullAvatars = {
92+
...DEFAULT_CURRENT_USER_RESPONSE,
93+
avatarBig: null,
94+
avatarMedium: null,
95+
avatarS640: null,
96+
avatarSmall: null,
97+
}
98+
setupRestClientMock(responseWithNullAvatars)
99+
const api = getTarget()
100+
101+
const actual = await api.getUser()
102+
103+
expect(actual.avatarBig).toBeNull()
104+
expect(actual.avatarMedium).toBeNull()
105+
expect(actual.avatarS640).toBeNull()
106+
expect(actual.avatarSmall).toBeNull()
107+
})
108+
109+
test('handles user with tzInfo field', async () => {
110+
setupRestClientMock(DEFAULT_CURRENT_USER_RESPONSE)
111+
const api = getTarget()
112+
113+
const actual = await api.getUser()
114+
115+
expect(actual.tzInfo).toEqual({
116+
gmtString: '+02:00',
117+
hours: 2,
118+
isDst: 1,
119+
minutes: 0,
120+
timezone: 'Europe/Madrid',
121+
})
122+
expect(actual.tzInfo.timezone).toBe('Europe/Madrid')
123+
})
124+
})
125+
})

src/consts/endpoints.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const ENDPOINT_REST_TASK_REOPEN = 'reopen'
3434
export const ENDPOINT_REST_PROJECTS = 'projects'
3535
export const ENDPOINT_REST_PROJECTS_ARCHIVED = ENDPOINT_REST_PROJECTS + '/archived'
3636
export const ENDPOINT_REST_PROJECT_COLLABORATORS = 'collaborators'
37+
export const ENDPOINT_REST_USER = 'user'
3738
export const PROJECT_ARCHIVE = 'archive'
3839
export const PROJECT_UNARCHIVE = 'unarchive'
3940

src/types/entities.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,11 +262,52 @@ export const UserSchema = z.object({
262262
email: z.string(),
263263
})
264264
/**
265-
* Represents a user in Todoist.
265+
* Represents a user in Todoist (simplified for collaborators).
266266
* @see https://todoist.com/api/v1/docs#tag/User
267267
*/
268268
export type User = z.infer<typeof UserSchema>
269269

270+
export const TimezoneInfoSchema = z.object({
271+
gmtString: z.string(),
272+
hours: z.number().int(),
273+
isDst: z.number().int(),
274+
minutes: z.number().int(),
275+
timezone: z.string(),
276+
})
277+
278+
export const CurrentUserSchema = z.object({
279+
id: z.string(),
280+
email: z.string(),
281+
fullName: z.string(),
282+
avatarBig: z.string().nullable(),
283+
avatarMedium: z.string().nullable(),
284+
avatarS640: z.string().nullable(),
285+
avatarSmall: z.string().nullable(),
286+
businessAccountId: z.string().nullable(),
287+
isPremium: z.boolean(),
288+
dateFormat: z.number().int(),
289+
timeFormat: z.number().int(),
290+
weeklyGoal: z.number().int(),
291+
dailyGoal: z.number().int(),
292+
completedCount: z.number().int(),
293+
completedToday: z.number().int(),
294+
karma: z.number(),
295+
karmaTrend: z.string(),
296+
lang: z.string(),
297+
nextWeek: z.number().int(),
298+
startDay: z.number().int(),
299+
startPage: z.string(),
300+
tzInfo: TimezoneInfoSchema,
301+
inboxProjectId: z.string(),
302+
daysOff: z.array(z.number().int()),
303+
weekendStartDay: z.number().int(),
304+
})
305+
/**
306+
* Represents the current authenticated user with detailed information.
307+
* @see https://todoist.com/api/v1/docs#tag/User
308+
*/
309+
export type CurrentUser = z.infer<typeof CurrentUserSchema>
310+
270311
export const ColorSchema = z.object({
271312
/** @deprecated No longer used */
272313
id: z.number(),

src/utils/validators.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import {
33
LabelSchema,
44
CommentSchema,
55
UserSchema,
6+
CurrentUserSchema,
67
TaskSchema,
78
type Task,
89
type Section,
910
type Label,
1011
type Comment,
1112
type User,
13+
type CurrentUser,
1214
PersonalProjectSchema,
1315
WorkspaceProjectSchema,
1416
type WorkspaceProject,
@@ -92,3 +94,7 @@ export function validateUser(input: unknown): User {
9294
export function validateUserArray(input: unknown[]): User[] {
9395
return input.map(validateUser)
9496
}
97+
98+
export function validateCurrentUser(input: unknown): CurrentUser {
99+
return CurrentUserSchema.parse(input)
100+
}

0 commit comments

Comments
 (0)