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/TodoistApi.user.test.ts b/src/TodoistApi.user.test.ts new file mode 100644 index 0000000..4aa8681 --- /dev/null +++ b/src/TodoistApi.user.test.ts @@ -0,0 +1,125 @@ +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' + +function getTarget(baseUrl = 'https://api.todoist.com') { + return new TodoistApi(DEFAULT_AUTH_TOKEN, baseUrl) +} + +const DEFAULT_CURRENT_USER_RESPONSE: CurrentUser = { + id: '123456789', + email: 'test.user@example.com', + 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, + karmaTrend: 'up', + lang: 'en', + 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', () => { + 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, + businessAccountId: 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, + avatarBig: null, + avatarMedium: null, + avatarS640: null, + avatarSmall: 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() + }) + + 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/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..0a3aab8 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -262,11 +262,52 @@ 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 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 + */ +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) +}