From 756648247293d3101a631ccb9cdf6a2124b53751 Mon Sep 17 00:00:00 2001 From: Amir Date: Tue, 9 Sep 2025 10:26:37 +0100 Subject: [PATCH 1/6] Implemented client.getUser function to retrieve current authenticated user information --- src/TodoistApi.ts | 28 +++++++++++++++++- src/consts/endpoints.ts | 1 + src/types/entities.ts | 63 ++++++++++++++++++++++++++++++++++++++++- src/utils/validators.ts | 6 ++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/TodoistApi.ts b/src/TodoistApi.ts index bdf8fc2..231e592 100644 --- a/src/TodoistApi.ts +++ b/src/TodoistApi.ts @@ -1,4 +1,12 @@ -import { PersonalProject, WorkspaceProject, Label, Section, Comment, Task } from './types/entities' +import { + PersonalProject, + WorkspaceProject, + Label, + Section, + Comment, + Task, + CurrentUser, +} from './types/entities' import { AddCommentArgs, AddLabelArgs, @@ -58,10 +66,12 @@ import { PROJECT_ARCHIVE, PROJECT_UNARCHIVE, ENDPOINT_REST_PROJECTS_ARCHIVED, + ENDPOINT_REST_USER, } from './consts/endpoints' import { validateComment, validateCommentArray, + validateCurrentUser, validateLabel, validateLabelArray, validateProject, @@ -128,6 +138,22 @@ export class TodoistApi { this.syncApiBase = getSyncBaseUri(baseUrl) } + /** + * Retrieves information about the authenticated user. + * + * @returns A promise that resolves to the current user's information. + */ + async getUser(): Promise { + const response = await request( + 'GET', + this.syncApiBase, + ENDPOINT_REST_USER, + this.authToken, + ) + + return validateCurrentUser(response.data) + } + /** * Retrieves a single active (non-completed) task by its ID. * diff --git a/src/consts/endpoints.ts b/src/consts/endpoints.ts index 339b6d0..9d2f20a 100644 --- a/src/consts/endpoints.ts +++ b/src/consts/endpoints.ts @@ -34,6 +34,7 @@ export const ENDPOINT_REST_TASK_REOPEN = 'reopen' export const ENDPOINT_REST_PROJECTS = 'projects' export const ENDPOINT_REST_PROJECTS_ARCHIVED = ENDPOINT_REST_PROJECTS + '/archived' export const ENDPOINT_REST_PROJECT_COLLABORATORS = 'collaborators' +export const ENDPOINT_REST_USER = 'user' export const PROJECT_ARCHIVE = 'archive' export const PROJECT_UNARCHIVE = 'unarchive' diff --git a/src/types/entities.ts b/src/types/entities.ts index ecf1d34..10632f5 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -262,11 +262,72 @@ export const UserSchema = z.object({ email: z.string(), }) /** - * Represents a user in Todoist. + * Represents a user in Todoist (simplified for collaborators). * @see https://todoist.com/api/v1/docs#tag/User */ export type User = z.infer +export const CurrentUserSchema = z + .object({ + id: z.string(), + email: z.string(), + full_name: z.string(), + avatar_big: z.string().nullable(), + avatar_medium: z.string().nullable(), + avatar_s640: z.string().nullable(), + avatar_small: z.string().nullable(), + business_account_id: z.string().nullable(), + is_premium: z.boolean(), + date_format: z.number().int(), + time_format: z.number().int(), + weekly_goal: z.number().int(), + daily_goal: z.number().int(), + completed_count: z.number().int(), + completed_today: z.number().int(), + karma: z.number(), + karma_trend: z.string(), + lang: z.string(), + next_week: z.number().int(), + start_day: z.number().int(), + start_page: z.string(), + timezone: z.string(), + inbox_project_id: z.string(), + days_off: z.array(z.number().int()), + weekend_start_day: z.number().int(), + }) + .transform((data) => ({ + id: data.id, + email: data.email, + fullName: data.full_name, + avatarBig: data.avatar_big, + avatarMedium: data.avatar_medium, + avatarS640: data.avatar_s640, + avatarSmall: data.avatar_small, + businessAccountId: data.business_account_id, + isPremium: data.is_premium, + dateFormat: data.date_format, + timeFormat: data.time_format, + weeklyGoal: data.weekly_goal, + dailyGoal: data.daily_goal, + completedCount: data.completed_count, + completedToday: data.completed_today, + karma: data.karma, + karmaTrend: data.karma_trend, + lang: data.lang, + nextWeek: data.next_week, + startDay: data.start_day, + startPage: data.start_page, + timezone: data.timezone, + inboxProjectId: data.inbox_project_id, + daysOff: data.days_off, + weekendStartDay: data.weekend_start_day, + })) +/** + * Represents the current authenticated user with detailed information. + * @see https://todoist.com/api/v1/docs#tag/User + */ +export type CurrentUser = z.infer + export const ColorSchema = z.object({ /** @deprecated No longer used */ id: z.number(), diff --git a/src/utils/validators.ts b/src/utils/validators.ts index 2c23ebc..211c157 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -3,12 +3,14 @@ import { LabelSchema, CommentSchema, UserSchema, + CurrentUserSchema, TaskSchema, type Task, type Section, type Label, type Comment, type User, + type CurrentUser, PersonalProjectSchema, WorkspaceProjectSchema, type WorkspaceProject, @@ -92,3 +94,7 @@ export function validateUser(input: unknown): User { export function validateUserArray(input: unknown[]): User[] { return input.map(validateUser) } + +export function validateCurrentUser(input: unknown): CurrentUser { + return CurrentUserSchema.parse(input) +} From 52668c8a65efed8ccbbb412b68b78164f5cd7500 Mon Sep 17 00:00:00 2001 From: Amir Date: Tue, 9 Sep 2025 10:26:37 +0100 Subject: [PATCH 2/6] Added a simple test suite --- src/TodoistApi.user.test.ts | 104 ++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/TodoistApi.user.test.ts diff --git a/src/TodoistApi.user.test.ts b/src/TodoistApi.user.test.ts new file mode 100644 index 0000000..7a71187 --- /dev/null +++ b/src/TodoistApi.user.test.ts @@ -0,0 +1,104 @@ +import { TodoistApi } from '.' +import { DEFAULT_AUTH_TOKEN } from './testUtils/testDefaults' +import { getSyncBaseUri, ENDPOINT_REST_USER } from './consts/endpoints' +import { setupRestClientMock } from './testUtils/mocks' +import { CurrentUser } from './types/entities' + +function getTarget(baseUrl = 'https://api.todoist.com') { + return new TodoistApi(DEFAULT_AUTH_TOKEN, baseUrl) +} + +const DEFAULT_CURRENT_USER_RESPONSE = { + id: '123456789', + email: 'test.user@example.com', + full_name: 'Test User', + avatar_big: 'https://example.com/avatars/test_user_big.jpg', + avatar_medium: 'https://example.com/avatars/test_user_medium.jpg', + avatar_s640: 'https://example.com/avatars/test_user_s640.jpg', + avatar_small: 'https://example.com/avatars/test_user_small.jpg', + business_account_id: null, + is_premium: true, + date_format: 0, + time_format: 0, + weekly_goal: 100, + daily_goal: 10, + completed_count: 102920, + completed_today: 12, + karma: 86394.0, + karma_trend: 'up', + lang: 'en', + next_week: 1, + start_day: 1, + start_page: 'project?id=test_project_123', + timezone: 'Europe/Madrid', + inbox_project_id: 'test_project_123', + days_off: [6, 7], + weekend_start_day: 6, +} + +describe('TodoistApi user endpoints', () => { + describe('getUser', () => { + test('calls get on restClient with expected parameters', async () => { + const requestMock = setupRestClientMock(DEFAULT_CURRENT_USER_RESPONSE) + const api = getTarget() + + await api.getUser() + + expect(requestMock).toHaveBeenCalledTimes(1) + expect(requestMock).toHaveBeenCalledWith( + 'GET', + getSyncBaseUri(), + ENDPOINT_REST_USER, + DEFAULT_AUTH_TOKEN, + ) + }) + + test('calls get on restClient with expected parameters against staging', async () => { + const requestMock = setupRestClientMock(DEFAULT_CURRENT_USER_RESPONSE) + const stagingBaseUrl = 'https://api.todoist-staging.com' + const api = getTarget(stagingBaseUrl) + + await api.getUser() + + expect(requestMock).toHaveBeenCalledTimes(1) + expect(requestMock).toHaveBeenCalledWith( + 'GET', + getSyncBaseUri(stagingBaseUrl), + ENDPOINT_REST_USER, + DEFAULT_AUTH_TOKEN, + ) + }) + + test('handles user with null business account id', async () => { + const responseWithNullBusinessAccount = { + ...DEFAULT_CURRENT_USER_RESPONSE, + business_account_id: null, + } + setupRestClientMock(responseWithNullBusinessAccount) + const api = getTarget() + + const actual = await api.getUser() + + expect(actual.businessAccountId).toBeNull() + }) + + test('handles user with null avatar fields', async () => { + const responseWithNullAvatars = { + ...DEFAULT_CURRENT_USER_RESPONSE, + avatar_big: null, + avatar_medium: null, + avatar_s640: null, + avatar_small: null, + } + setupRestClientMock(responseWithNullAvatars) + const api = getTarget() + + const actual = await api.getUser() + + expect(actual.avatarBig).toBeNull() + expect(actual.avatarMedium).toBeNull() + expect(actual.avatarS640).toBeNull() + expect(actual.avatarSmall).toBeNull() + }) + }) +}) From 3630c21fe8a671daf5a5d3ad544ce6122a92b016 Mon Sep 17 00:00:00 2001 From: Amir Date: Tue, 9 Sep 2025 10:26:37 +0100 Subject: [PATCH 3/6] Fix linting issues --- src/TodoistApi.user.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/TodoistApi.user.test.ts b/src/TodoistApi.user.test.ts index 7a71187..2f762b9 100644 --- a/src/TodoistApi.user.test.ts +++ b/src/TodoistApi.user.test.ts @@ -2,7 +2,6 @@ import { TodoistApi } from '.' import { DEFAULT_AUTH_TOKEN } from './testUtils/testDefaults' import { getSyncBaseUri, ENDPOINT_REST_USER } from './consts/endpoints' import { setupRestClientMock } from './testUtils/mocks' -import { CurrentUser } from './types/entities' function getTarget(baseUrl = 'https://api.todoist.com') { return new TodoistApi(DEFAULT_AUTH_TOKEN, baseUrl) From b6010fe068401fc3a22aa291bd9fa83ae9ac3e6c Mon Sep 17 00:00:00 2001 From: Amir Date: Tue, 9 Sep 2025 10:26:37 +0100 Subject: [PATCH 4/6] Address "the conversion from snake > camel is handled by our axios middleware" Also, adds the tzInfo --- src/TodoistApi.user.test.ts | 74 +++++++++++++++++++----------- src/types/entities.ts | 90 +++++++++++++++---------------------- 2 files changed, 83 insertions(+), 81 deletions(-) diff --git a/src/TodoistApi.user.test.ts b/src/TodoistApi.user.test.ts index 2f762b9..64e519f 100644 --- a/src/TodoistApi.user.test.ts +++ b/src/TodoistApi.user.test.ts @@ -10,29 +10,35 @@ function getTarget(baseUrl = 'https://api.todoist.com') { const DEFAULT_CURRENT_USER_RESPONSE = { id: '123456789', email: 'test.user@example.com', - full_name: 'Test User', - avatar_big: 'https://example.com/avatars/test_user_big.jpg', - avatar_medium: 'https://example.com/avatars/test_user_medium.jpg', - avatar_s640: 'https://example.com/avatars/test_user_s640.jpg', - avatar_small: 'https://example.com/avatars/test_user_small.jpg', - business_account_id: null, - is_premium: true, - date_format: 0, - time_format: 0, - weekly_goal: 100, - daily_goal: 10, - completed_count: 102920, - completed_today: 12, + fullName: 'Test User', + avatarBig: 'https://example.com/avatars/test_user_big.jpg', + avatarMedium: 'https://example.com/avatars/test_user_medium.jpg', + avatarS640: 'https://example.com/avatars/test_user_s640.jpg', + avatarSmall: 'https://example.com/avatars/test_user_small.jpg', + businessAccountId: null, + isPremium: true, + dateFormat: 0, + timeFormat: 0, + weeklyGoal: 100, + dailyGoal: 10, + completedCount: 102920, + completedToday: 12, karma: 86394.0, - karma_trend: 'up', + karmaTrend: 'up', lang: 'en', - next_week: 1, - start_day: 1, - start_page: 'project?id=test_project_123', - timezone: 'Europe/Madrid', - inbox_project_id: 'test_project_123', - days_off: [6, 7], - weekend_start_day: 6, + nextWeek: 1, + startDay: 1, + startPage: 'project?id=test_project_123', + tzInfo: { + gmtString: '+02:00', + hours: 2, + isDst: 1, + minutes: 0, + timezone: 'Europe/Madrid', + }, + inboxProjectId: 'test_project_123', + daysOff: [6, 7], + weekendStartDay: 6, } describe('TodoistApi user endpoints', () => { @@ -71,7 +77,7 @@ describe('TodoistApi user endpoints', () => { test('handles user with null business account id', async () => { const responseWithNullBusinessAccount = { ...DEFAULT_CURRENT_USER_RESPONSE, - business_account_id: null, + businessAccountId: null, } setupRestClientMock(responseWithNullBusinessAccount) const api = getTarget() @@ -84,10 +90,10 @@ describe('TodoistApi user endpoints', () => { test('handles user with null avatar fields', async () => { const responseWithNullAvatars = { ...DEFAULT_CURRENT_USER_RESPONSE, - avatar_big: null, - avatar_medium: null, - avatar_s640: null, - avatar_small: null, + avatarBig: null, + avatarMedium: null, + avatarS640: null, + avatarSmall: null, } setupRestClientMock(responseWithNullAvatars) const api = getTarget() @@ -99,5 +105,21 @@ describe('TodoistApi user endpoints', () => { expect(actual.avatarS640).toBeNull() expect(actual.avatarSmall).toBeNull() }) + + test('handles user with tzInfo field', async () => { + setupRestClientMock(DEFAULT_CURRENT_USER_RESPONSE) + const api = getTarget() + + const actual = await api.getUser() + + expect(actual.tzInfo).toEqual({ + gmtString: '+02:00', + hours: 2, + isDst: 1, + minutes: 0, + timezone: 'Europe/Madrid', + }) + expect(actual.tzInfo.timezone).toBe('Europe/Madrid') + }) }) }) diff --git a/src/types/entities.ts b/src/types/entities.ts index 10632f5..0a3aab8 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -267,61 +267,41 @@ export const UserSchema = z.object({ */ export type User = z.infer -export const CurrentUserSchema = z - .object({ - id: z.string(), - email: z.string(), - full_name: z.string(), - avatar_big: z.string().nullable(), - avatar_medium: z.string().nullable(), - avatar_s640: z.string().nullable(), - avatar_small: z.string().nullable(), - business_account_id: z.string().nullable(), - is_premium: z.boolean(), - date_format: z.number().int(), - time_format: z.number().int(), - weekly_goal: z.number().int(), - daily_goal: z.number().int(), - completed_count: z.number().int(), - completed_today: z.number().int(), - karma: z.number(), - karma_trend: z.string(), - lang: z.string(), - next_week: z.number().int(), - start_day: z.number().int(), - start_page: z.string(), - timezone: z.string(), - inbox_project_id: z.string(), - days_off: z.array(z.number().int()), - weekend_start_day: z.number().int(), - }) - .transform((data) => ({ - id: data.id, - email: data.email, - fullName: data.full_name, - avatarBig: data.avatar_big, - avatarMedium: data.avatar_medium, - avatarS640: data.avatar_s640, - avatarSmall: data.avatar_small, - businessAccountId: data.business_account_id, - isPremium: data.is_premium, - dateFormat: data.date_format, - timeFormat: data.time_format, - weeklyGoal: data.weekly_goal, - dailyGoal: data.daily_goal, - completedCount: data.completed_count, - completedToday: data.completed_today, - karma: data.karma, - karmaTrend: data.karma_trend, - lang: data.lang, - nextWeek: data.next_week, - startDay: data.start_day, - startPage: data.start_page, - timezone: data.timezone, - inboxProjectId: data.inbox_project_id, - daysOff: data.days_off, - weekendStartDay: data.weekend_start_day, - })) +export const TimezoneInfoSchema = z.object({ + gmtString: z.string(), + hours: z.number().int(), + isDst: z.number().int(), + minutes: z.number().int(), + timezone: z.string(), +}) + +export const CurrentUserSchema = z.object({ + id: z.string(), + email: z.string(), + fullName: z.string(), + avatarBig: z.string().nullable(), + avatarMedium: z.string().nullable(), + avatarS640: z.string().nullable(), + avatarSmall: z.string().nullable(), + businessAccountId: z.string().nullable(), + isPremium: z.boolean(), + dateFormat: z.number().int(), + timeFormat: z.number().int(), + weeklyGoal: z.number().int(), + dailyGoal: z.number().int(), + completedCount: z.number().int(), + completedToday: z.number().int(), + karma: z.number(), + karmaTrend: z.string(), + lang: z.string(), + nextWeek: z.number().int(), + startDay: z.number().int(), + startPage: z.string(), + tzInfo: TimezoneInfoSchema, + inboxProjectId: z.string(), + daysOff: z.array(z.number().int()), + weekendStartDay: z.number().int(), +}) /** * Represents the current authenticated user with detailed information. * @see https://todoist.com/api/v1/docs#tag/User From d34a0d9ab50da8c382ac645cf783d6684b73cd86 Mon Sep 17 00:00:00 2001 From: Amir Salihefendic Date: Tue, 9 Sep 2025 10:26:37 +0100 Subject: [PATCH 5/6] Update src/TodoistApi.user.test.ts Co-authored-by: Scott Lovegrove --- src/TodoistApi.user.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TodoistApi.user.test.ts b/src/TodoistApi.user.test.ts index 64e519f..e379e95 100644 --- a/src/TodoistApi.user.test.ts +++ b/src/TodoistApi.user.test.ts @@ -7,7 +7,7 @@ function getTarget(baseUrl = 'https://api.todoist.com') { return new TodoistApi(DEFAULT_AUTH_TOKEN, baseUrl) } -const DEFAULT_CURRENT_USER_RESPONSE = { +const DEFAULT_CURRENT_USER_RESPONSE: CurrentUser = { id: '123456789', email: 'test.user@example.com', fullName: 'Test User', From e5ff3941dd1dbfa873ddc38b917178c510009991 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Tue, 9 Sep 2025 10:27:14 +0100 Subject: [PATCH 6/6] chore: Import type --- src/TodoistApi.user.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TodoistApi.user.test.ts b/src/TodoistApi.user.test.ts index e379e95..4aa8681 100644 --- a/src/TodoistApi.user.test.ts +++ b/src/TodoistApi.user.test.ts @@ -1,4 +1,4 @@ -import { TodoistApi } from '.' +import { TodoistApi, type CurrentUser } from '.' import { DEFAULT_AUTH_TOKEN } from './testUtils/testDefaults' import { getSyncBaseUri, ENDPOINT_REST_USER } from './consts/endpoints' import { setupRestClientMock } from './testUtils/mocks'