diff --git a/packages/nodes-base/nodes/Todoist/GenericFunctions.ts b/packages/nodes-base/nodes/Todoist/GenericFunctions.ts index fb8cfc9bba..b24861dd6f 100644 --- a/packages/nodes-base/nodes/Todoist/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Todoist/GenericFunctions.ts @@ -51,6 +51,7 @@ export async function todoistSyncRequest( this: Context, body: any = {}, qs: IDataObject = {}, + endpoint: string = '/sync', ): Promise { const authentication = this.getNodeParameter('authentication', 0, 'oAuth2'); @@ -58,7 +59,7 @@ export async function todoistSyncRequest( headers: {}, method: 'POST', qs, - uri: 'https://api.todoist.com/sync/v9/sync', + uri: `https://api.todoist.com/sync/v9${endpoint}`, json: true, }; diff --git a/packages/nodes-base/nodes/Todoist/v2/OperationHandler.ts b/packages/nodes-base/nodes/Todoist/v2/OperationHandler.ts index b5c0393510..267a7ac5d9 100644 --- a/packages/nodes-base/nodes/Todoist/v2/OperationHandler.ts +++ b/packages/nodes-base/nodes/Todoist/v2/OperationHandler.ts @@ -1,6 +1,12 @@ import type { IDataObject } from 'n8n-workflow'; import { ApplicationError, jsonParse } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; +import { + assertIsString, + assertIsNumber, + assertIsStringOrNumber, + assertIsNodeParameters, +} from '../../../utils/types'; import type { Section, TodoistResponse } from './Service'; import type { Context } from '../GenericFunctions'; @@ -13,16 +19,20 @@ export interface OperationHandler { export interface CreateTaskRequest { content?: string; description?: string; - project_id?: number; - section_id?: number; - parent_id?: string; + project_id?: number | string; + section_id?: number | string; + parent_id?: number | string; order?: number; labels?: string[]; - priority?: number; + priority?: number | string; due_string?: string; due_datetime?: string; due_date?: string; due_lang?: string; + assignee_id?: string; + duration?: number; + duration_unit?: string; + deadline_date?: string; } export interface SyncRequest { @@ -35,9 +45,9 @@ export interface Command { uuid: string; temp_id?: string; args: { - parent_id?: string; - id?: number; - section_id?: number; + parent_id?: number | string; + id?: number | string; + section_id?: number | string; project_id?: number | string; section?: string; content?: string; @@ -45,12 +55,57 @@ export interface Command { } export const CommandTypes = { + // Item/Task commands ITEM_MOVE: 'item_move', ITEM_ADD: 'item_add', ITEM_UPDATE: 'item_update', ITEM_REORDER: 'item_reorder', ITEM_DELETE: 'item_delete', ITEM_COMPLETE: 'item_complete', + ITEM_UNCOMPLETE: 'item_uncomplete', + ITEM_CLOSE: 'item_close', + // Project commands + PROJECT_ADD: 'project_add', + PROJECT_UPDATE: 'project_update', + PROJECT_DELETE: 'project_delete', + PROJECT_ARCHIVE: 'project_archive', + PROJECT_UNARCHIVE: 'project_unarchive', + PROJECT_REORDER: 'project_reorder', + // Section commands + SECTION_ADD: 'section_add', + SECTION_UPDATE: 'section_update', + SECTION_DELETE: 'section_delete', + SECTION_ARCHIVE: 'section_archive', + SECTION_UNARCHIVE: 'section_unarchive', + SECTION_MOVE: 'section_move', + SECTION_REORDER: 'section_reorder', + // Label commands + LABEL_ADD: 'label_add', + LABEL_UPDATE: 'label_update', + LABEL_DELETE: 'label_delete', + LABEL_UPDATE_ORDERS: 'label_update_orders', + // Filter commands + FILTER_ADD: 'filter_add', + FILTER_UPDATE: 'filter_update', + FILTER_DELETE: 'filter_delete', + FILTER_UPDATE_ORDERS: 'filter_update_orders', + // Reminder commands + REMINDER_ADD: 'reminder_add', + REMINDER_UPDATE: 'reminder_update', + REMINDER_DELETE: 'reminder_delete', + // Note commands + NOTE_ADD: 'note_add', + NOTE_UPDATE: 'note_update', + NOTE_DELETE: 'note_delete', + // Sharing commands + SHARE_PROJECT: 'share_project', + DELETE_COLLABORATOR: 'delete_collaborator', + ACCEPT_INVITATION: 'accept_invitation', + REJECT_INVITATION: 'reject_invitation', + DELETE_INVITATION: 'delete_invitation', + // User settings + USER_UPDATE: 'user_update', + USER_UPDATE_GOALS: 'user_update_goals', } as const; export type CommandType = (typeof CommandTypes)[keyof typeof CommandTypes]; @@ -58,29 +113,66 @@ export type CommandType = (typeof CommandTypes)[keyof typeof CommandTypes]; export class CreateHandler implements OperationHandler { async handleOperation(ctx: Context, itemIndex: number): Promise { //https://developer.todoist.com/rest/v2/#create-a-new-task - const content = ctx.getNodeParameter('content', itemIndex) as string; + const content = ctx.getNodeParameter('content', itemIndex); + assertIsString('content', content); + const projectId = ctx.getNodeParameter('project', itemIndex, undefined, { extractValue: true, - }) as number; + }); + assertIsStringOrNumber('project', projectId); + const labels = ctx.getNodeParameter('labels', itemIndex) as string[]; const options = ctx.getNodeParameter('options', itemIndex) as IDataObject; + assertIsNodeParameters<{ + description?: string; + dueDateTime?: string; + dueString?: string; + section?: string | number; + dueLang?: string; + parentId?: string | number; + priority?: string | number; + order?: number; + dueDate?: string; + assigneeId?: string; + duration?: number; + durationUnit?: string; + deadlineDate?: string; + }>(options, { + description: { type: 'string', optional: true }, + dueDateTime: { type: 'string', optional: true }, + dueString: { type: 'string', optional: true }, + section: { type: ['string', 'number'], optional: true }, + dueLang: { type: 'string', optional: true }, + parentId: { type: ['string', 'number'], optional: true }, + priority: { type: ['string', 'number'], optional: true }, + order: { type: 'number', optional: true }, + dueDate: { type: 'string', optional: true }, + assigneeId: { type: 'string', optional: true }, + duration: { type: 'number', optional: true }, + durationUnit: { type: 'string', optional: true }, + deadlineDate: { type: 'string', optional: true }, + }); + const body: CreateTaskRequest = { content, project_id: projectId, - priority: options.priority! ? parseInt(options.priority as string, 10) : 1, + priority: + typeof options.priority === 'string' + ? parseInt(options.priority, 10) + : (options.priority ?? 1), }; if (options.description) { - body.description = options.description as string; + body.description = options.description; } if (options.dueDateTime) { - body.due_datetime = FormatDueDatetime(options.dueDateTime as string); + body.due_datetime = FormatDueDatetime(options.dueDateTime); } if (options.dueString) { - body.due_string = options.dueString as string; + body.due_string = options.dueString; } if (labels !== undefined && labels.length !== 0) { @@ -88,15 +180,39 @@ export class CreateHandler implements OperationHandler { } if (options.section) { - body.section_id = options.section as number; + body.section_id = options.section; } if (options.dueLang) { - body.due_lang = options.dueLang as string; + body.due_lang = options.dueLang; } if (options.parentId) { - body.parent_id = options.parentId as string; + body.parent_id = options.parentId; + } + + if (options.order) { + body.order = options.order; + } + + if (options.dueDate) { + body.due_date = options.dueDate; + } + + if (options.assigneeId) { + body.assignee_id = options.assigneeId; + } + + if (options.duration) { + body.duration = options.duration; + } + + if (options.durationUnit) { + body.duration_unit = options.durationUnit; + } + + if (options.deadlineDate) { + body.deadline_date = options.deadlineDate; } const data = await todoistApiRequest.call(ctx, 'POST', '/tasks', body as IDataObject); @@ -109,7 +225,8 @@ export class CreateHandler implements OperationHandler { export class CloseHandler implements OperationHandler { async handleOperation(ctx: Context, itemIndex: number): Promise { - const id = ctx.getNodeParameter('taskId', itemIndex) as string; + const id = ctx.getNodeParameter('taskId', itemIndex); + assertIsStringOrNumber('taskId', id); await todoistApiRequest.call(ctx, 'POST', `/tasks/${id}/close`); @@ -121,7 +238,8 @@ export class CloseHandler implements OperationHandler { export class DeleteHandler implements OperationHandler { async handleOperation(ctx: Context, itemIndex: number): Promise { - const id = ctx.getNodeParameter('taskId', itemIndex) as string; + const id = ctx.getNodeParameter('taskId', itemIndex); + assertIsStringOrNumber('taskId', id); await todoistApiRequest.call(ctx, 'DELETE', `/tasks/${id}`); @@ -133,7 +251,8 @@ export class DeleteHandler implements OperationHandler { export class GetHandler implements OperationHandler { async handleOperation(ctx: Context, itemIndex: number): Promise { - const id = ctx.getNodeParameter('taskId', itemIndex) as string; + const id = ctx.getNodeParameter('taskId', itemIndex); + assertIsStringOrNumber('taskId', id); const responseData = await todoistApiRequest.call(ctx, 'GET', `/tasks/${id}`); return { @@ -147,31 +266,49 @@ export class GetAllHandler implements OperationHandler { //https://developer.todoist.com/rest/v2/#get-active-tasks const returnAll = ctx.getNodeParameter('returnAll', itemIndex) as boolean; const filters = ctx.getNodeParameter('filters', itemIndex) as IDataObject; + + assertIsNodeParameters<{ + projectId?: string | number; + sectionId?: string | number; + labelId?: string | number; + filter?: string; + lang?: string; + ids?: string; + }>(filters, { + projectId: { type: ['string', 'number'], optional: true }, + sectionId: { type: ['string', 'number'], optional: true }, + labelId: { type: ['string', 'number'], optional: true }, + filter: { type: 'string', optional: true }, + lang: { type: 'string', optional: true }, + ids: { type: 'string', optional: true }, + }); + const qs: IDataObject = {}; if (filters.projectId) { - qs.project_id = filters.projectId as string; + qs.project_id = filters.projectId; } if (filters.sectionId) { - qs.section_id = filters.sectionId as string; + qs.section_id = filters.sectionId; } if (filters.labelId) { - qs.label = filters.labelId as string; + qs.label = filters.labelId; } if (filters.filter) { - qs.filter = filters.filter as string; + qs.filter = filters.filter; } if (filters.lang) { - qs.lang = filters.lang as string; + qs.lang = filters.lang; } if (filters.ids) { - qs.ids = filters.ids as string; + qs.ids = filters.ids; } let responseData = await todoistApiRequest.call(ctx, 'GET', '/tasks', {}, qs); if (!returnAll) { - const limit = ctx.getNodeParameter('limit', itemIndex) as number; + const limit = ctx.getNodeParameter('limit', itemIndex); + assertIsNumber('limit', limit); responseData = responseData.splice(0, limit); } @@ -181,7 +318,10 @@ export class GetAllHandler implements OperationHandler { } } -async function getSectionIds(ctx: Context, projectId: number): Promise> { +async function getSectionIds( + ctx: Context, + projectId: string | number, +): Promise> { const sections: Section[] = await todoistApiRequest.call( ctx, 'GET', @@ -195,7 +335,8 @@ async function getSectionIds(ctx: Context, projectId: number): Promise { //https://developer.todoist.com/rest/v2/#get-an-active-task - const id = ctx.getNodeParameter('taskId', itemIndex) as string; + const id = ctx.getNodeParameter('taskId', itemIndex); + assertIsStringOrNumber('taskId', id); await todoistApiRequest.call(ctx, 'POST', `/tasks/${id}/reopen`); @@ -208,29 +349,60 @@ export class ReopenHandler implements OperationHandler { export class UpdateHandler implements OperationHandler { async handleOperation(ctx: Context, itemIndex: number): Promise { //https://developer.todoist.com/rest/v2/#update-a-task - const id = ctx.getNodeParameter('taskId', itemIndex) as string; + const id = ctx.getNodeParameter('taskId', itemIndex); + assertIsStringOrNumber('taskId', id); + const updateFields = ctx.getNodeParameter('updateFields', itemIndex) as IDataObject; + assertIsNodeParameters<{ + content?: string; + priority?: number | string; + description?: string; + dueDateTime?: string; + dueString?: string; + labels?: string[]; + dueLang?: string; + order?: number; + dueDate?: string; + assigneeId?: string; + duration?: number; + durationUnit?: string; + deadlineDate?: string; + }>(updateFields, { + content: { type: 'string', optional: true }, + priority: { type: ['number', 'string'], optional: true }, + description: { type: 'string', optional: true }, + dueDateTime: { type: 'string', optional: true }, + dueString: { type: 'string', optional: true }, + labels: { type: 'string[]', optional: true }, + dueLang: { type: 'string', optional: true }, + order: { type: 'number', optional: true }, + dueDate: { type: 'string', optional: true }, + assigneeId: { type: 'string', optional: true }, + duration: { type: 'number', optional: true }, + durationUnit: { type: 'string', optional: true }, + deadlineDate: { type: 'string', optional: true }, + }); const body: CreateTaskRequest = {}; if (updateFields.content) { - body.content = updateFields.content as string; + body.content = updateFields.content; } if (updateFields.priority) { - body.priority = parseInt(updateFields.priority as string, 10); + body.priority = updateFields.priority; } if (updateFields.description) { - body.description = updateFields.description as string; + body.description = updateFields.description; } if (updateFields.dueDateTime) { - body.due_datetime = FormatDueDatetime(updateFields.dueDateTime as string); + body.due_datetime = FormatDueDatetime(updateFields.dueDateTime); } if (updateFields.dueString) { - body.due_string = updateFields.dueString as string; + body.due_string = updateFields.dueString; } if ( @@ -238,11 +410,35 @@ export class UpdateHandler implements OperationHandler { Array.isArray(updateFields.labels) && updateFields.labels.length !== 0 ) { - body.labels = updateFields.labels as string[]; + body.labels = updateFields.labels; } if (updateFields.dueLang) { - body.due_lang = updateFields.dueLang as string; + body.due_lang = updateFields.dueLang; + } + + if (updateFields.order) { + body.order = updateFields.order; + } + + if (updateFields.dueDate) { + body.due_date = updateFields.dueDate; + } + + if (updateFields.assigneeId) { + body.assignee_id = updateFields.assigneeId; + } + + if (updateFields.duration) { + body.duration = updateFields.duration; + } + + if (updateFields.durationUnit) { + body.duration_unit = updateFields.durationUnit; + } + + if (updateFields.deadlineDate) { + body.deadline_date = updateFields.deadlineDate; } await todoistApiRequest.call(ctx, 'POST', `/tasks/${id}`, body as IDataObject); @@ -254,10 +450,13 @@ export class UpdateHandler implements OperationHandler { export class MoveHandler implements OperationHandler { async handleOperation(ctx: Context, itemIndex: number): Promise { //https://api.todoist.com/sync/v9/sync - const taskId = ctx.getNodeParameter('taskId', itemIndex) as number; + const taskId = ctx.getNodeParameter('taskId', itemIndex); + assertIsStringOrNumber('taskId', taskId); + const projectId = ctx.getNodeParameter('project', itemIndex, undefined, { extractValue: true, - }) as number; + }); + assertIsStringOrNumber('project', projectId); const nodeVersion = ctx.getNode().typeVersion; const body: SyncRequest = { @@ -269,7 +468,13 @@ export class MoveHandler implements OperationHandler { id: taskId, // Set section_id only if node version is below 2.1 ...(nodeVersion < 2.1 - ? { section_id: ctx.getNodeParameter('section', itemIndex) as number } + ? { + section_id: (() => { + const section = ctx.getNodeParameter('section', itemIndex); + assertIsStringOrNumber('section', section); + return section; + })(), + } : {}), }, }, @@ -278,11 +483,19 @@ export class MoveHandler implements OperationHandler { if (nodeVersion >= 2.1) { const options = ctx.getNodeParameter('options', itemIndex, {}) as IDataObject; + assertIsNodeParameters<{ + parent?: string | number; + section?: string | number; + }>(options, { + parent: { type: ['string', 'number'], optional: true }, + section: { type: ['string', 'number'], optional: true }, + }); + // Only one of parent_id, section_id, or project_id must be set to move the task if (options.parent) { - body.commands[0].args.parent_id = options.parent as string; + body.commands[0].args.parent_id = options.parent; } else if (options.section) { - body.commands[0].args.section_id = options.section as number; + body.commands[0].args.section_id = options.section; } else { body.commands[0].args.project_id = projectId; } @@ -295,10 +508,13 @@ export class MoveHandler implements OperationHandler { export class SyncHandler implements OperationHandler { async handleOperation(ctx: Context, itemIndex: number): Promise { - const commandsJson = ctx.getNodeParameter('commands', itemIndex) as string; + const commandsJson = ctx.getNodeParameter('commands', itemIndex); + assertIsString('commands', commandsJson); + const projectId = ctx.getNodeParameter('project', itemIndex, undefined, { extractValue: true, - }) as number; + }); + assertIsStringOrNumber('project', projectId); const sections = await getSectionIds(ctx, projectId); const commands: Command[] = jsonParse(commandsJson); const tempIdMapping = new Map(); @@ -346,17 +562,22 @@ export class SyncHandler implements OperationHandler { } } - private enrichProjectId(command: Command, projectId: number) { + private enrichProjectId(command: Command, projectId: number | string) { if (this.requiresProjectId(command)) { command.args.project_id = projectId; } } private requiresProjectId(command: Command) { - return command.type === CommandTypes.ITEM_ADD; + const commands: CommandType[] = [CommandTypes.ITEM_ADD, CommandTypes.SECTION_ADD]; + return commands.includes(command.type); } - private enrichTempId(command: Command, tempIdMapping: Map, projectId: number) { + private enrichTempId( + command: Command, + tempIdMapping: Map, + projectId: string | number, + ) { if (this.requiresTempId(command)) { command.temp_id = uuid(); tempIdMapping.set(command.temp_id, projectId as unknown as string); @@ -364,6 +585,396 @@ export class SyncHandler implements OperationHandler { } private requiresTempId(command: Command) { - return command.type === CommandTypes.ITEM_ADD; + const commands: CommandType[] = [ + CommandTypes.ITEM_ADD, + CommandTypes.PROJECT_ADD, + CommandTypes.SECTION_ADD, + CommandTypes.LABEL_ADD, + CommandTypes.FILTER_ADD, + CommandTypes.REMINDER_ADD, + CommandTypes.NOTE_ADD, + ]; + return commands.includes(command.type); + } +} + +// Project Handlers +export class ProjectCreateHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const name = ctx.getNodeParameter('name', itemIndex); + assertIsString('name', name); + + const options = ctx.getNodeParameter('projectOptions', itemIndex) as IDataObject; + assertIsNodeParameters<{ + color?: string; + is_favorite?: boolean; + parent_id?: string; + view_style?: string; + }>(options, { + color: { type: 'string', optional: true }, + is_favorite: { type: 'boolean', optional: true }, + parent_id: { type: 'string', optional: true }, + view_style: { type: 'string', optional: true }, + }); + + const body: IDataObject = { + name, + ...options, + }; + + const data = await todoistApiRequest.call(ctx, 'POST', '/projects', body); + return { data }; + } +} + +export class ProjectDeleteHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('projectId', itemIndex); + assertIsStringOrNumber('projectId', id); + + await todoistApiRequest.call(ctx, 'DELETE', `/projects/${id}`); + return { success: true }; + } +} + +export class ProjectGetHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('projectId', itemIndex); + assertIsStringOrNumber('projectId', id); + + const data = await todoistApiRequest.call(ctx, 'GET', `/projects/${id}`); + return { data }; + } +} + +export class ProjectGetAllHandler implements OperationHandler { + async handleOperation(ctx: Context, _itemIndex: number): Promise { + const data = await todoistApiRequest.call(ctx, 'GET', '/projects'); + return { data }; + } +} + +export class ProjectUpdateHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('projectId', itemIndex); + assertIsStringOrNumber('projectId', id); + + const updateFields = ctx.getNodeParameter('projectUpdateFields', itemIndex) as IDataObject; + assertIsNodeParameters<{ + name?: string; + color?: string; + is_favorite?: boolean; + view_style?: string; + }>(updateFields, { + name: { type: 'string', optional: true }, + color: { type: 'string', optional: true }, + is_favorite: { type: 'boolean', optional: true }, + view_style: { type: 'string', optional: true }, + }); + + await todoistApiRequest.call(ctx, 'POST', `/projects/${id}`, updateFields); + return { success: true }; + } +} + +export class ProjectArchiveHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('projectId', itemIndex); + assertIsStringOrNumber('projectId', id); + + await todoistApiRequest.call(ctx, 'POST', `/projects/${id}/archive`); + return { success: true }; + } +} + +export class ProjectUnarchiveHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('projectId', itemIndex); + assertIsStringOrNumber('projectId', id); + + await todoistApiRequest.call(ctx, 'POST', `/projects/${id}/unarchive`); + return { success: true }; + } +} + +export class ProjectGetCollaboratorsHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('projectId', itemIndex); + assertIsStringOrNumber('projectId', id); + + const data = await todoistApiRequest.call(ctx, 'GET', `/projects/${id}/collaborators`); + return { data }; + } +} + +// Section Handlers +export class SectionCreateHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const name = ctx.getNodeParameter('sectionName', itemIndex); + assertIsString('sectionName', name); + + const projectId = ctx.getNodeParameter('sectionProject', itemIndex, undefined, { + extractValue: true, + }); + assertIsStringOrNumber('sectionProject', projectId); + + const options = ctx.getNodeParameter('sectionOptions', itemIndex) as IDataObject; + assertIsNodeParameters<{ + order?: number; + }>(options, { + order: { type: 'number', optional: true }, + }); + + const body: IDataObject = { + name, + project_id: projectId, + ...options, + }; + + const data = await todoistApiRequest.call(ctx, 'POST', '/sections', body); + return { data }; + } +} + +export class SectionDeleteHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('sectionId', itemIndex); + assertIsStringOrNumber('sectionId', id); + + await todoistApiRequest.call(ctx, 'DELETE', `/sections/${id}`); + return { success: true }; + } +} + +export class SectionGetHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('sectionId', itemIndex); + assertIsStringOrNumber('sectionId', id); + + const data = await todoistApiRequest.call(ctx, 'GET', `/sections/${id}`); + return { data }; + } +} + +export class SectionGetAllHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const filters = ctx.getNodeParameter('sectionFilters', itemIndex) as IDataObject; + const qs: IDataObject = {}; + + if (filters.project_id) { + assertIsStringOrNumber('project_id', filters.project_id); + qs.project_id = filters.project_id; + } + + const data = await todoistApiRequest.call(ctx, 'GET', '/sections', {}, qs); + return { data }; + } +} + +export class SectionUpdateHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('sectionId', itemIndex); + assertIsStringOrNumber('sectionId', id); + + const updateFields = ctx.getNodeParameter('sectionUpdateFields', itemIndex) as IDataObject; + assertIsNodeParameters<{ + name?: string; + }>(updateFields, { + name: { type: 'string', optional: true }, + }); + + await todoistApiRequest.call(ctx, 'POST', `/sections/${id}`, updateFields); + return { success: true }; + } +} + +// Comment Handlers +export class CommentCreateHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const taskId = ctx.getNodeParameter('commentTaskId', itemIndex); + assertIsStringOrNumber('commentTaskId', taskId); + + const content = ctx.getNodeParameter('commentContent', itemIndex); + assertIsString('commentContent', content); + + const body: IDataObject = { + task_id: taskId, + content, + }; + + const data = await todoistApiRequest.call(ctx, 'POST', '/comments', body); + return { data }; + } +} + +export class CommentDeleteHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('commentId', itemIndex); + assertIsStringOrNumber('commentId', id); + + await todoistApiRequest.call(ctx, 'DELETE', `/comments/${id}`); + return { success: true }; + } +} + +export class CommentGetHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('commentId', itemIndex); + assertIsStringOrNumber('commentId', id); + + const data = await todoistApiRequest.call(ctx, 'GET', `/comments/${id}`); + return { data }; + } +} + +export class CommentGetAllHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const filters = ctx.getNodeParameter('commentFilters', itemIndex) as IDataObject; + const qs: IDataObject = {}; + + if (filters.task_id) { + assertIsStringOrNumber('task_id', filters.task_id); + qs.task_id = filters.task_id; + } + + if (filters.project_id) { + assertIsStringOrNumber('project_id', filters.project_id); + qs.project_id = filters.project_id; + } + + const data = await todoistApiRequest.call(ctx, 'GET', '/comments', {}, qs); + return { data }; + } +} + +export class CommentUpdateHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('commentId', itemIndex); + assertIsStringOrNumber('commentId', id); + + const updateFields = ctx.getNodeParameter('commentUpdateFields', itemIndex) as IDataObject; + assertIsNodeParameters<{ + content?: string; + }>(updateFields, { + content: { type: 'string', optional: true }, + }); + + await todoistApiRequest.call(ctx, 'POST', `/comments/${id}`, updateFields); + return { success: true }; + } +} + +// Label Handlers +export class LabelCreateHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const name = ctx.getNodeParameter('labelName', itemIndex); + assertIsString('labelName', name); + + const options = ctx.getNodeParameter('labelOptions', itemIndex) as IDataObject; + assertIsNodeParameters<{ + color?: string; + order?: number; + is_favorite?: boolean; + }>(options, { + color: { type: 'string', optional: true }, + order: { type: 'number', optional: true }, + is_favorite: { type: 'boolean', optional: true }, + }); + + const body: IDataObject = { + name, + ...options, + }; + + const data = await todoistApiRequest.call(ctx, 'POST', '/labels', body); + return { data }; + } +} + +export class LabelDeleteHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('labelId', itemIndex); + assertIsStringOrNumber('labelId', id); + + await todoistApiRequest.call(ctx, 'DELETE', `/labels/${id}`); + return { success: true }; + } +} + +export class LabelGetHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('labelId', itemIndex); + assertIsStringOrNumber('labelId', id); + + const data = await todoistApiRequest.call(ctx, 'GET', `/labels/${id}`); + return { data }; + } +} + +export class LabelGetAllHandler implements OperationHandler { + async handleOperation(ctx: Context, _itemIndex: number): Promise { + const data = await todoistApiRequest.call(ctx, 'GET', '/labels'); + return { data }; + } +} + +export class LabelUpdateHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('labelId', itemIndex); + assertIsStringOrNumber('labelId', id); + + const updateFields = ctx.getNodeParameter('labelUpdateFields', itemIndex) as IDataObject; + assertIsNodeParameters<{ + name?: string; + color?: string; + order?: number; + is_favorite?: boolean; + }>(updateFields, { + name: { type: 'string', optional: true }, + color: { type: 'string', optional: true }, + order: { type: 'number', optional: true }, + is_favorite: { type: 'boolean', optional: true }, + }); + + await todoistApiRequest.call(ctx, 'POST', `/labels/${id}`, updateFields); + return { success: true }; + } +} + +export class QuickAddHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const text = ctx.getNodeParameter('text', itemIndex); + assertIsString('text', text); + + const options = ctx.getNodeParameter('options', itemIndex, {}) as IDataObject; + assertIsNodeParameters<{ + note?: string; + reminder?: string; + auto_reminder?: boolean; + }>(options, { + note: { type: 'string', optional: true }, + reminder: { type: 'string', optional: true }, + auto_reminder: { type: 'boolean', optional: true }, + }); + + const body: IDataObject = { text }; + + if (options.note) { + body.note = options.note; + } + + if (options.reminder) { + body.reminder = options.reminder; + } + + if (options.auto_reminder) { + body.auto_reminder = options.auto_reminder; + } + + const data = await todoistSyncRequest.call(ctx, body, {}, '/quick/add'); + + return { + data, + }; } } diff --git a/packages/nodes-base/nodes/Todoist/v2/Service.ts b/packages/nodes-base/nodes/Todoist/v2/Service.ts index cd6fb5f1b9..aefe62542b 100644 --- a/packages/nodes-base/nodes/Todoist/v2/Service.ts +++ b/packages/nodes-base/nodes/Todoist/v2/Service.ts @@ -7,16 +7,44 @@ import { GetAllHandler, GetHandler, MoveHandler, + QuickAddHandler, ReopenHandler, SyncHandler, UpdateHandler, + // Project handlers + ProjectCreateHandler, + ProjectDeleteHandler, + ProjectGetHandler, + ProjectGetAllHandler, + ProjectUpdateHandler, + ProjectArchiveHandler, + ProjectUnarchiveHandler, + ProjectGetCollaboratorsHandler, + // Section handlers + SectionCreateHandler, + SectionDeleteHandler, + SectionGetHandler, + SectionGetAllHandler, + SectionUpdateHandler, + // Comment handlers + CommentCreateHandler, + CommentDeleteHandler, + CommentGetHandler, + CommentGetAllHandler, + CommentUpdateHandler, + // Label handlers + LabelCreateHandler, + LabelDeleteHandler, + LabelGetHandler, + LabelGetAllHandler, + LabelUpdateHandler, } from './OperationHandler'; import type { Context } from '../GenericFunctions'; export class TodoistService implements Service { - async execute( + async executeTask( ctx: Context, - operation: OperationType, + operation: TaskOperationType, itemIndex: number, ): Promise { return await this.handlers[operation].handleOperation(ctx, itemIndex); @@ -32,19 +60,131 @@ export class TodoistService implements Service { update: new UpdateHandler(), move: new MoveHandler(), sync: new SyncHandler(), + quickAdd: new QuickAddHandler(), }; + + private projectHandlers = { + create: new ProjectCreateHandler(), + delete: new ProjectDeleteHandler(), + get: new ProjectGetHandler(), + getAll: new ProjectGetAllHandler(), + update: new ProjectUpdateHandler(), + archive: new ProjectArchiveHandler(), + unarchive: new ProjectUnarchiveHandler(), + getCollaborators: new ProjectGetCollaboratorsHandler(), + }; + + private sectionHandlers = { + create: new SectionCreateHandler(), + delete: new SectionDeleteHandler(), + get: new SectionGetHandler(), + getAll: new SectionGetAllHandler(), + update: new SectionUpdateHandler(), + }; + + private commentHandlers = { + create: new CommentCreateHandler(), + delete: new CommentDeleteHandler(), + get: new CommentGetHandler(), + getAll: new CommentGetAllHandler(), + update: new CommentUpdateHandler(), + }; + + private labelHandlers = { + create: new LabelCreateHandler(), + delete: new LabelDeleteHandler(), + get: new LabelGetHandler(), + getAll: new LabelGetAllHandler(), + update: new LabelUpdateHandler(), + }; + + async executeProject( + ctx: Context, + operation: ProjectOperationType, + itemIndex: number, + ): Promise { + return await this.projectHandlers[operation].handleOperation(ctx, itemIndex); + } + + async executeSection( + ctx: Context, + operation: SectionOperationType, + itemIndex: number, + ): Promise { + return await this.sectionHandlers[operation].handleOperation(ctx, itemIndex); + } + + async executeComment( + ctx: Context, + operation: CommentOperationType, + itemIndex: number, + ): Promise { + return await this.commentHandlers[operation].handleOperation(ctx, itemIndex); + } + + async executeLabel( + ctx: Context, + operation: LabelOperationType, + itemIndex: number, + ): Promise { + return await this.labelHandlers[operation].handleOperation(ctx, itemIndex); + } } -export type OperationType = - | 'create' - | 'close' - | 'delete' - | 'get' - | 'getAll' - | 'reopen' - | 'update' - | 'move' - | 'sync'; +// Define operations as const arrays - source of truth +const TASK_OPERATIONS = [ + 'create', + 'close', + 'delete', + 'get', + 'getAll', + 'reopen', + 'update', + 'move', + 'sync', + 'quickAdd', +] as const; + +const PROJECT_OPERATIONS = [ + 'create', + 'delete', + 'get', + 'getAll', + 'update', + 'archive', + 'unarchive', + 'getCollaborators', +] as const; + +const SECTION_OPERATIONS = ['create', 'delete', 'get', 'getAll', 'update'] as const; + +const COMMENT_OPERATIONS = ['create', 'delete', 'get', 'getAll', 'update'] as const; + +const LABEL_OPERATIONS = ['create', 'delete', 'get', 'getAll', 'update'] as const; + +// Derive types from arrays +export type TaskOperationType = (typeof TASK_OPERATIONS)[number]; +export type ProjectOperationType = (typeof PROJECT_OPERATIONS)[number]; +export type SectionOperationType = (typeof SECTION_OPERATIONS)[number]; +export type CommentOperationType = (typeof COMMENT_OPERATIONS)[number]; +export type LabelOperationType = (typeof LABEL_OPERATIONS)[number]; + +// Type guards using the same arrays +export function isProjectOperationType(operation: string): operation is ProjectOperationType { + return PROJECT_OPERATIONS.includes(operation as ProjectOperationType); +} + +export function isSectionOperationType(operation: string): operation is SectionOperationType { + return SECTION_OPERATIONS.includes(operation as SectionOperationType); +} + +export function isCommentOperationType(operation: string): operation is CommentOperationType { + return COMMENT_OPERATIONS.includes(operation as CommentOperationType); +} + +export function isLabelOperationType(operation: string): operation is LabelOperationType { + return LABEL_OPERATIONS.includes(operation as LabelOperationType); +} export interface Section { name: string; @@ -52,7 +192,31 @@ export interface Section { } export interface Service { - execute(ctx: Context, operation: OperationType, itemIndex: number): Promise; + executeTask( + ctx: Context, + operation: TaskOperationType, + itemIndex: number, + ): Promise; + executeProject( + ctx: Context, + operation: ProjectOperationType, + itemIndex: number, + ): Promise; + executeSection( + ctx: Context, + operation: SectionOperationType, + itemIndex: number, + ): Promise; + executeComment( + ctx: Context, + operation: CommentOperationType, + itemIndex: number, + ): Promise; + executeLabel( + ctx: Context, + operation: LabelOperationType, + itemIndex: number, + ): Promise; } export interface TodoistProjectType { diff --git a/packages/nodes-base/nodes/Todoist/v2/TodoistV2.node.ts b/packages/nodes-base/nodes/Todoist/v2/TodoistV2.node.ts index df77fa46f0..ca1c9b19b2 100644 --- a/packages/nodes-base/nodes/Todoist/v2/TodoistV2.node.ts +++ b/packages/nodes-base/nodes/Todoist/v2/TodoistV2.node.ts @@ -9,26 +9,41 @@ import { type INodeTypeBaseDescription, type INodeTypeDescription, NodeConnectionTypes, + NodeOperationError, } from 'n8n-workflow'; -import type { OperationType, TodoistProjectType } from './Service'; -import { TodoistService } from './Service'; +import type { TaskOperationType, TodoistProjectType } from './Service'; +import { + TodoistService, + isProjectOperationType, + isSectionOperationType, + isCommentOperationType, + isLabelOperationType, +} from './Service'; import { todoistApiRequest } from '../GenericFunctions'; -// interface IBodyCreateTask { -// content?: string; -// description?: string; -// project_id?: number; -// section_id?: number; -// parent_id?: number; -// order?: number; -// label_ids?: number[]; -// priority?: number; -// due_string?: string; -// due_datetime?: string; -// due_date?: string; -// due_lang?: string; -// } +const TODOIST_COLOR_OPTIONS: INodePropertyOptions[] = [ + { name: 'Berry Red', value: 'berry_red' }, + { name: 'Red', value: 'red' }, + { name: 'Orange', value: 'orange' }, + { name: 'Yellow', value: 'yellow' }, + { name: 'Olive Green', value: 'olive_green' }, + { name: 'Lime Green', value: 'lime_green' }, + { name: 'Green', value: 'green' }, + { name: 'Mint Green', value: 'mint_green' }, + { name: 'Teal', value: 'teal' }, + { name: 'Sky Blue', value: 'sky_blue' }, + { name: 'Light Blue', value: 'light_blue' }, + { name: 'Blue', value: 'blue' }, + { name: 'Grape', value: 'grape' }, + { name: 'Violet', value: 'violet' }, + { name: 'Lavender', value: 'lavender' }, + { name: 'Magenta', value: 'magenta' }, + { name: 'Salmon', value: 'salmon' }, + { name: 'Charcoal', value: 'charcoal' }, + { name: 'Grey', value: 'grey' }, + { name: 'Taupe', value: 'taupe' }, +]; const versionDescription: INodeTypeDescription = { displayName: 'Todoist', @@ -86,12 +101,33 @@ const versionDescription: INodeTypeDescription = { name: 'resource', type: 'options', noDataExpression: true, + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items options: [ { name: 'Task', value: 'task', description: 'Task resource', }, + { + name: 'Project', + value: 'project', + description: 'Project resource', + }, + { + name: 'Section', + value: 'section', + description: 'Section resource', + }, + { + name: 'Comment', + value: 'comment', + description: 'Comment resource', + }, + { + name: 'Label', + value: 'label', + description: 'Label resource', + }, ], default: 'task', required: true, @@ -144,6 +180,12 @@ const versionDescription: INodeTypeDescription = { description: 'Move a task', action: 'Move a task', }, + { + name: 'Quick Add', + value: 'quickAdd', + description: 'Quick add a task using natural language', + action: 'Quick add a task', + }, { name: 'Reopen', value: 'reopen', @@ -164,6 +206,208 @@ const versionDescription: INodeTypeDescription = { ], default: 'create', }, + // Project operations + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + required: true, + displayOptions: { + show: { + resource: ['project'], + }, + }, + options: [ + { + name: 'Archive', + value: 'archive', + description: 'Archive a project', + action: 'Archive a project', + }, + { + name: 'Create', + value: 'create', + description: 'Create a new project', + action: 'Create a project', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a project', + action: 'Delete a project', + }, + { + name: 'Get', + value: 'get', + description: 'Get a project', + action: 'Get a project', + }, + { + name: 'Get Collaborators', + value: 'getCollaborators', + description: 'Get project collaborators', + action: 'Get project collaborators', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many projects', + action: 'Get many projects', + }, + { + name: 'Unarchive', + value: 'unarchive', + description: 'Unarchive a project', + action: 'Unarchive a project', + }, + { + name: 'Update', + value: 'update', + description: 'Update a project', + action: 'Update a project', + }, + ], + default: 'create', + }, + // Section operations + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + required: true, + displayOptions: { + show: { + resource: ['section'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new section', + action: 'Create a section', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a section', + action: 'Delete a section', + }, + { + name: 'Get', + value: 'get', + description: 'Get a section', + action: 'Get a section', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many sections', + action: 'Get many sections', + }, + { + name: 'Update', + value: 'update', + description: 'Update a section', + action: 'Update a section', + }, + ], + default: 'create', + }, + // Comment operations + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + required: true, + displayOptions: { + show: { + resource: ['comment'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new comment', + action: 'Create a comment', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a comment', + action: 'Delete a comment', + }, + { + name: 'Get', + value: 'get', + description: 'Get a comment', + action: 'Get a comment', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many comments', + action: 'Get many comments', + }, + { + name: 'Update', + value: 'update', + description: 'Update a comment', + action: 'Update a comment', + }, + ], + default: 'create', + }, + // Label operations + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + required: true, + displayOptions: { + show: { + resource: ['label'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new label', + action: 'Create a label', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a label', + action: 'Delete a label', + }, + { + name: 'Get', + value: 'get', + description: 'Get a label', + action: 'Get a label', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many labels', + action: 'Get many labels', + }, + { + name: 'Update', + value: 'update', + description: 'Update a label', + action: 'Update a label', + }, + ], + default: 'create', + }, { displayName: 'Task ID', name: 'taskId', @@ -271,7 +515,8 @@ const versionDescription: INodeTypeDescription = { ], }, { - displayName: 'Label Names or IDs', + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options + displayName: 'Label Names', name: 'labels', type: 'multiOptions', typeOptions: { @@ -304,6 +549,62 @@ const versionDescription: INodeTypeDescription = { required: true, description: 'Task content', }, + { + displayName: 'Text', + name: 'text', + type: 'string', + typeOptions: { + rows: 3, + }, + displayOptions: { + show: { + resource: ['task'], + operation: ['quickAdd'], + }, + }, + default: '', + required: true, + description: + 'Natural language text for quick adding task (e.g., "Buy milk @Grocery #shopping tomorrow"). It can include a due date in free form text, a project name starting with the "#" character (without spaces), a label starting with the "@" character, an assignee starting with the "+" character, a priority (e.g., p1), a deadline between "{}" (e.g. {in 3 days}), or a description starting from "//" until the end of the text.', + }, + { + displayName: 'Additional Fields', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['task'], + operation: ['quickAdd'], + }, + }, + options: [ + { + displayName: 'Note', + name: 'note', + type: 'string', + default: '', + description: 'The content of the note', + }, + { + displayName: 'Reminder', + name: 'reminder', + type: 'string', + default: '', + description: 'The date of the reminder, added in free form text', + }, + { + displayName: 'Auto Reminder', + name: 'auto_reminder', + type: 'boolean', + default: false, + // eslint-disable-next-line n8n-nodes-base/node-param-description-boolean-without-whether + description: + 'When this option is enabled, the default reminder will be added to the new item if it has a due date with time set', + }, + ], + }, { displayName: 'Sync Commands', name: 'commands', @@ -396,6 +697,60 @@ const versionDescription: INodeTypeDescription = { description: 'The section you want to operate on. Choose from the list, or specify an ID using an expression.', }, + { + displayName: 'Order', + name: 'order', + type: 'number', + default: 0, + description: 'Non-zero integer used to sort tasks under the same parent', + }, + { + displayName: 'Due Date', + name: 'dueDate', + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + description: 'Specific date in YYYY-MM-DD format', + }, + { + displayName: 'Assignee ID', + name: 'assigneeId', + type: 'string', + default: '', + description: 'Responsible user ID (for shared tasks)', + }, + { + displayName: 'Duration', + name: 'duration', + type: 'number', + default: 0, + description: 'Positive integer for task duration (must be used with Duration Unit)', + }, + { + displayName: 'Duration Unit', + name: 'durationUnit', + type: 'options', + options: [ + { + name: 'Minute', + value: 'minute', + }, + { + name: 'Day', + value: 'day', + }, + ], + default: 'minute', + description: 'Unit of time for duration (must be used with Duration)', + }, + { + displayName: 'Deadline Date', + name: 'deadlineDate', + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + description: 'Specific deadline date in YYYY-MM-DD format', + }, ], }, { @@ -572,7 +927,8 @@ const versionDescription: INodeTypeDescription = { '2-letter code specifying language in case due_string is not written in English', }, { - displayName: 'Label Names or IDs', + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-wrong-for-dynamic-multi-options + displayName: 'Label Names', name: 'labels', type: 'multiOptions', description: @@ -593,31 +949,560 @@ const versionDescription: INodeTypeDescription = { default: 1, description: 'Task priority from 1 (normal) to 4 (urgent)', }, + { + displayName: 'Order', + name: 'order', + type: 'number', + default: 0, + description: 'Non-zero integer used to sort tasks under the same parent', + }, + { + displayName: 'Due Date', + name: 'dueDate', + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + description: 'Specific date in YYYY-MM-DD format', + }, + { + displayName: 'Assignee ID', + name: 'assigneeId', + type: 'string', + default: '', + description: 'Responsible user ID (for shared tasks)', + }, + { + displayName: 'Duration', + name: 'duration', + type: 'number', + default: 0, + description: 'Positive integer for task duration (must be used with Duration Unit)', + }, + { + displayName: 'Duration Unit', + name: 'durationUnit', + type: 'options', + options: [ + { + name: 'Minute', + value: 'minute', + }, + { + name: 'Day', + value: 'day', + }, + ], + default: 'minute', + description: 'Unit of time for duration (must be used with Duration)', + }, + { + displayName: 'Deadline Date', + name: 'deadlineDate', + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + description: 'Specific deadline date in YYYY-MM-DD format', + }, ], }, - ], -}; - -export class TodoistV2 implements INodeType { - description: INodeTypeDescription; - - constructor(baseDescription: INodeTypeBaseDescription) { - this.description = { - ...baseDescription, - ...versionDescription, - }; - } - - methods = { - listSearch: { - async searchProjects( - this: ILoadOptionsFunctions, - filter?: string, - ): Promise { - const projects: TodoistProjectType[] = await todoistApiRequest.call( - this, - 'GET', - '/projects', + // Project fields + { + displayName: 'Project ID', + name: 'projectId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['project'], + operation: ['archive', 'delete', 'get', 'getCollaborators', 'unarchive', 'update'], + }, + }, + description: 'The project ID - can be either a string or number', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + }, + }, + description: 'Name of the project', + }, + { + displayName: 'Additional Fields', + name: 'projectOptions', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['project'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Color', + name: 'color', + type: 'options', + options: TODOIST_COLOR_OPTIONS, + default: '', + description: 'The color of the project', + }, + { + displayName: 'Is Favorite', + name: 'is_favorite', + type: 'boolean', + default: false, + description: 'Whether the project is a favorite', + }, + { + displayName: 'Parent ID', + name: 'parent_id', + type: 'string', + default: '', + description: 'Parent project ID', + }, + { + displayName: 'View Style', + name: 'view_style', + type: 'options', + options: [ + { + name: 'List', + value: 'list', + }, + { + name: 'Board', + value: 'board', + }, + ], + default: 'list', + description: 'The default view style of the project', + }, + ], + }, + { + displayName: 'Update Fields', + name: 'projectUpdateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['project'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Name of the project', + }, + { + displayName: 'Color', + name: 'color', + type: 'options', + options: TODOIST_COLOR_OPTIONS, + default: '', + description: 'The color of the project', + }, + { + displayName: 'Is Favorite', + name: 'is_favorite', + type: 'boolean', + default: false, + description: 'Whether the project is a favorite', + }, + { + displayName: 'View Style', + name: 'view_style', + type: 'options', + options: [ + { + name: 'List', + value: 'list', + }, + { + name: 'Board', + value: 'board', + }, + ], + default: 'list', + description: 'The default view style of the project', + }, + ], + }, + // Section fields + { + displayName: 'Section ID', + name: 'sectionId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['section'], + operation: ['delete', 'get', 'update'], + }, + }, + }, + { + displayName: 'Project Name or ID', + name: 'sectionProject', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a project...', + typeOptions: { + searchListMethod: 'searchProjects', + searchable: true, + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + placeholder: '2302163813', + }, + ], + displayOptions: { + show: { + resource: ['section'], + operation: ['create'], + }, + }, + description: 'The project to add the section to', + }, + { + displayName: 'Name', + name: 'sectionName', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['section'], + operation: ['create'], + }, + }, + description: 'Name of the section', + }, + { + displayName: 'Additional Fields', + name: 'sectionOptions', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['section'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Order', + name: 'order', + type: 'number', + default: 0, + description: 'The order of the section', + }, + ], + }, + { + displayName: 'Update Fields', + name: 'sectionUpdateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['section'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Name of the section', + }, + ], + }, + { + displayName: 'Filters', + name: 'sectionFilters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: ['section'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Project Name or ID', + name: 'project_id', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getProjects', + }, + default: '', + description: + 'Filter sections by project. Choose from the list, or specify an ID using an expression.', + }, + ], + }, + // Comment fields + { + displayName: 'Comment ID', + name: 'commentId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['comment'], + operation: ['delete', 'get', 'update'], + }, + }, + }, + { + displayName: 'Task ID', + name: 'commentTaskId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['comment'], + operation: ['create'], + }, + }, + description: 'The ID of the task to comment on', + }, + { + displayName: 'Content', + name: 'commentContent', + type: 'string', + typeOptions: { + rows: 3, + }, + default: '', + required: true, + displayOptions: { + show: { + resource: ['comment'], + operation: ['create'], + }, + }, + description: 'Comment content', + }, + { + displayName: 'Update Fields', + name: 'commentUpdateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['comment'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Content', + name: 'content', + type: 'string', + typeOptions: { + rows: 3, + }, + default: '', + description: 'Comment content', + }, + ], + }, + { + displayName: 'Filters', + name: 'commentFilters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: ['comment'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Task ID', + name: 'task_id', + type: 'string', + default: '', + description: 'Filter comments by task ID', + }, + { + displayName: 'Project ID', + name: 'project_id', + type: 'string', + default: '', + description: 'Filter comments by project ID', + }, + ], + }, + // Label fields + { + displayName: 'Label ID', + name: 'labelId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['label'], + operation: ['delete', 'get', 'update'], + }, + }, + }, + { + displayName: 'Name', + name: 'labelName', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['label'], + operation: ['create'], + }, + }, + description: 'Name of the label', + }, + { + displayName: 'Additional Fields', + name: 'labelOptions', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['label'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Color', + name: 'color', + type: 'options', + options: TODOIST_COLOR_OPTIONS, + default: '', + description: 'The color of the label', + }, + { + displayName: 'Order', + name: 'order', + type: 'number', + default: 0, + description: 'Label order', + }, + { + displayName: 'Is Favorite', + name: 'is_favorite', + type: 'boolean', + default: false, + description: 'Whether the label is a favorite', + }, + ], + }, + { + displayName: 'Update Fields', + name: 'labelUpdateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['label'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Name of the label', + }, + { + displayName: 'Color', + name: 'color', + type: 'options', + options: TODOIST_COLOR_OPTIONS, + default: '', + description: 'The color of the label', + }, + { + displayName: 'Order', + name: 'order', + type: 'number', + default: 0, + description: 'Label order', + }, + { + displayName: 'Is Favorite', + name: 'is_favorite', + type: 'boolean', + default: false, + description: 'Whether the label is a favorite', + }, + ], + }, + ], +}; + +export class TodoistV2 implements INodeType { + description: INodeTypeDescription; + + constructor(baseDescription: INodeTypeBaseDescription) { + this.description = { + ...baseDescription, + ...versionDescription, + }; + } + + methods = { + listSearch: { + async searchProjects( + this: ILoadOptionsFunctions, + filter?: string, + ): Promise { + const projects: TodoistProjectType[] = await todoistApiRequest.call( + this, + 'GET', + '/projects', ); return { results: projects @@ -737,11 +1622,43 @@ export class TodoistV2 implements INodeType { const service = new TodoistService(); let responseData; const resource = this.getNodeParameter('resource', 0); - const operation = this.getNodeParameter('operation', 0) as OperationType; + const operation = this.getNodeParameter('operation', 0) as TaskOperationType; for (let i = 0; i < length; i++) { try { if (resource === 'task') { - responseData = await service.execute(this, operation, i); + responseData = await service.executeTask(this, operation, i); + } else if (resource === 'project') { + if (!isProjectOperationType(operation)) { + throw new NodeOperationError( + this.getNode(), + `Invalid operation '${operation}' for project resource`, + ); + } + responseData = await service.executeProject(this, operation, i); + } else if (resource === 'section') { + if (!isSectionOperationType(operation)) { + throw new NodeOperationError( + this.getNode(), + `Invalid operation '${operation}' for section resource`, + ); + } + responseData = await service.executeSection(this, operation, i); + } else if (resource === 'comment') { + if (!isCommentOperationType(operation)) { + throw new NodeOperationError( + this.getNode(), + `Invalid operation '${operation}' for comment resource`, + ); + } + responseData = await service.executeComment(this, operation, i); + } else if (resource === 'label') { + if (!isLabelOperationType(operation)) { + throw new NodeOperationError( + this.getNode(), + `Invalid operation '${operation}' for label resource`, + ); + } + responseData = await service.executeLabel(this, operation, i); } if (responseData !== undefined && Array.isArray(responseData?.data)) { diff --git a/packages/nodes-base/nodes/Todoist/v2/test/OperationHandler.test.ts b/packages/nodes-base/nodes/Todoist/v2/test/OperationHandler.test.ts new file mode 100644 index 0000000000..2c643c1015 --- /dev/null +++ b/packages/nodes-base/nodes/Todoist/v2/test/OperationHandler.test.ts @@ -0,0 +1,1232 @@ +import { mock } from 'jest-mock-extended'; +import type { IExecuteFunctions, INode } from 'n8n-workflow'; + +import { todoistApiRequest, todoistSyncRequest } from '../../GenericFunctions'; +import { + CreateHandler, + CloseHandler, + DeleteHandler, + GetHandler, + GetAllHandler, + ReopenHandler, + UpdateHandler, + MoveHandler, + SyncHandler, + ProjectCreateHandler, + ProjectDeleteHandler, + ProjectGetHandler, + ProjectGetAllHandler, + ProjectUpdateHandler, + ProjectArchiveHandler, + ProjectUnarchiveHandler, + ProjectGetCollaboratorsHandler, + SectionCreateHandler, + SectionDeleteHandler, + SectionGetHandler, + SectionGetAllHandler, + SectionUpdateHandler, + CommentCreateHandler, + CommentDeleteHandler, + CommentGetHandler, + CommentGetAllHandler, + CommentUpdateHandler, + LabelCreateHandler, + LabelDeleteHandler, + LabelGetHandler, + LabelGetAllHandler, + LabelUpdateHandler, + QuickAddHandler, +} from '../OperationHandler'; + +// Mock the GenericFunctions +jest.mock('../../GenericFunctions', () => ({ + todoistApiRequest: jest.fn(), + todoistSyncRequest: jest.fn(), + FormatDueDatetime: jest.fn((dateTime: string) => dateTime), +})); + +// Mock uuid +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'mock-uuid-123'), +})); + +const mockTodoistApiRequest = todoistApiRequest as jest.MockedFunction; +const mockTodoistSyncRequest = todoistSyncRequest as jest.MockedFunction; + +// Mock Context interface +const createMockContext = (params: Record = {}) => + mock({ + getNodeParameter: jest.fn((key: string) => params[key]), + getNode: jest.fn(() => mock({ typeVersion: 2.1 })), + }); + +describe('OperationHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Task Handlers', () => { + describe('CreateHandler', () => { + it('should create a task successfully', async () => { + const handler = new CreateHandler(); + const mockCtx = createMockContext({ + content: 'Test task', + project: '123456', + labels: ['work', 'urgent'], + options: { + description: 'Test description', + priority: 3, + }, + }); + + const expectedResponse = { + id: '789', + content: 'Test task', + project_id: '123456', + }; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'POST', + '/tasks', + expect.objectContaining({ + content: 'Test task', + project_id: '123456', + description: 'Test description', + priority: 3, + labels: ['work', 'urgent'], + }), + ); + expect(result).toEqual({ data: expectedResponse }); + }); + + it('should handle task creation with due date', async () => { + const handler = new CreateHandler(); + const mockCtx = createMockContext({ + content: 'Task with due date', + project: '123456', + options: { + dueDate: '2025-12-31', + dueDateTime: '2025-12-31T15:30:00', + dueString: 'tomorrow', + dueLang: 'en', + }, + }); + + mockTodoistApiRequest.mockResolvedValue({ id: '789' }); + + await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'POST', + '/tasks', + expect.objectContaining({ + content: 'Task with due date', + project_id: '123456', + due_date: '2025-12-31', + due_datetime: '2025-12-31T15:30:00', + due_string: 'tomorrow', + due_lang: 'en', + }), + ); + }); + }); + + describe('CloseHandler', () => { + it('should close a task successfully', async () => { + const handler = new CloseHandler(); + const mockCtx = createMockContext({ + taskId: '123456', + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('POST', '/tasks/123456/close'); + expect(result).toEqual({ success: true }); + }); + }); + + describe('DeleteHandler', () => { + it('should delete a task successfully', async () => { + const handler = new DeleteHandler(); + const mockCtx = createMockContext({ + taskId: '123456', + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('DELETE', '/tasks/123456'); + expect(result).toEqual({ success: true }); + }); + }); + + describe('GetHandler', () => { + it('should get a task successfully', async () => { + const handler = new GetHandler(); + const mockCtx = createMockContext({ + taskId: '123456', + }); + + const expectedResponse = { + id: '123456', + content: 'Test task', + project_id: '789', + }; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('GET', '/tasks/123456'); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('GetAllHandler', () => { + it('should get all tasks successfully', async () => { + const handler = new GetAllHandler(); + const mockCtx = createMockContext({ + returnAll: false, + limit: 10, + filters: { + projectId: '123456', + labelId: '789', + }, + }); + + const mockApiResponse = [ + { id: '1', content: 'Task 1' }, + { id: '2', content: 'Task 2' }, + ]; + + mockTodoistApiRequest.mockResolvedValue([...mockApiResponse]); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'GET', + '/tasks', + {}, + expect.objectContaining({ + project_id: '123456', + label: '789', + }), + ); + // splice(0, 10) on a 2-element array removes and returns all 2 elements + expect(result).toEqual({ data: mockApiResponse }); + }); + + it('should return all tasks when returnAll is true', async () => { + const handler = new GetAllHandler(); + const mockCtx = createMockContext({ + returnAll: true, + filters: {}, + }); + + const expectedResponse = Array.from({ length: 150 }, (_, i) => ({ id: i.toString() })); + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('ReopenHandler', () => { + it('should reopen a task successfully', async () => { + const handler = new ReopenHandler(); + const mockCtx = createMockContext({ + taskId: '123456', + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('POST', '/tasks/123456/reopen'); + expect(result).toEqual({ success: true }); + }); + }); + + describe('UpdateHandler', () => { + it('should update a task successfully', async () => { + const handler = new UpdateHandler(); + const mockCtx = createMockContext({ + taskId: '123456', + updateFields: { + content: 'Updated task', + description: 'Updated description', + priority: 2, + labels: ['updated'], + }, + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'POST', + '/tasks/123456', + expect.objectContaining({ + content: 'Updated task', + description: 'Updated description', + priority: 2, + labels: ['updated'], + }), + ); + expect(result).toEqual({ success: true }); + }); + }); + + describe('MoveHandler', () => { + it('should move a task successfully', async () => { + const handler = new MoveHandler(); + const mockCtx = createMockContext({ + taskId: '123456', + project: '789', + options: {}, + }); + + mockTodoistSyncRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistSyncRequest).toHaveBeenCalledWith( + expect.objectContaining({ + commands: [ + expect.objectContaining({ + type: 'item_move', + uuid: 'mock-uuid-123', + args: expect.objectContaining({ + id: '123456', + project_id: '789', + }), + }), + ], + }), + ); + expect(result).toEqual({ success: true }); + }); + + it('should move a task to a section', async () => { + const handler = new MoveHandler(); + const mockCtx = createMockContext({ + taskId: '123456', + project: '789', + options: { + section: '456', + }, + }); + + mockTodoistSyncRequest.mockResolvedValue(undefined); + + await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistSyncRequest).toHaveBeenCalledWith( + expect.objectContaining({ + commands: [ + expect.objectContaining({ + args: expect.objectContaining({ + id: '123456', + section_id: '456', + }), + }), + ], + }), + ); + }); + }); + }); + + describe('Project Handlers', () => { + describe('ProjectCreateHandler', () => { + it('should create a project successfully', async () => { + const handler = new ProjectCreateHandler(); + const mockCtx = createMockContext({ + name: 'Test Project', + projectOptions: { + color: 'blue', + is_favorite: true, + view_style: 'board', + }, + }); + + const expectedResponse = { + id: '123456', + name: 'Test Project', + color: 'blue', + is_favorite: true, + view_style: 'board', + }; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'POST', + '/projects', + expect.objectContaining({ + name: 'Test Project', + color: 'blue', + is_favorite: true, + view_style: 'board', + }), + ); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('ProjectGetHandler', () => { + it('should get a project successfully', async () => { + const handler = new ProjectGetHandler(); + const mockCtx = createMockContext({ + projectId: '123456', + }); + + const expectedResponse = { + id: '123456', + name: 'Test Project', + color: 'blue', + }; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('GET', '/projects/123456'); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('ProjectGetAllHandler', () => { + it('should get all projects successfully', async () => { + const handler = new ProjectGetAllHandler(); + const mockCtx = createMockContext({}); + + const expectedResponse = [ + { id: '1', name: 'Project 1' }, + { id: '2', name: 'Project 2' }, + ]; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('GET', '/projects'); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('ProjectUpdateHandler', () => { + it('should update a project successfully', async () => { + const handler = new ProjectUpdateHandler(); + const mockCtx = createMockContext({ + projectId: '123456', + projectUpdateFields: { + name: 'Updated Project', + color: 'red', + is_favorite: false, + }, + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'POST', + '/projects/123456', + expect.objectContaining({ + name: 'Updated Project', + color: 'red', + is_favorite: false, + }), + ); + expect(result).toEqual({ success: true }); + }); + }); + + describe('ProjectDeleteHandler', () => { + it('should delete a project successfully', async () => { + const handler = new ProjectDeleteHandler(); + const mockCtx = createMockContext({ + projectId: '123456', + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('DELETE', '/projects/123456'); + expect(result).toEqual({ success: true }); + }); + }); + + describe('ProjectArchiveHandler', () => { + it('should archive a project successfully', async () => { + const handler = new ProjectArchiveHandler(); + const mockCtx = createMockContext({ + projectId: '123456', + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('POST', '/projects/123456/archive'); + expect(result).toEqual({ success: true }); + }); + }); + + describe('ProjectUnarchiveHandler', () => { + it('should unarchive a project successfully', async () => { + const handler = new ProjectUnarchiveHandler(); + const mockCtx = createMockContext({ + projectId: '123456', + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('POST', '/projects/123456/unarchive'); + expect(result).toEqual({ success: true }); + }); + }); + + describe('ProjectGetCollaboratorsHandler', () => { + it('should get project collaborators successfully', async () => { + const handler = new ProjectGetCollaboratorsHandler(); + const mockCtx = createMockContext({ + projectId: '123456', + }); + + const expectedResponse = [ + { id: '1', name: 'User 1', email: 'user1@example.com' }, + { id: '2', name: 'User 2', email: 'user2@example.com' }, + ]; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('GET', '/projects/123456/collaborators'); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + }); + + describe('Section Handlers', () => { + describe('SectionCreateHandler', () => { + it('should create a section successfully', async () => { + const handler = new SectionCreateHandler(); + const mockCtx = createMockContext({ + sectionName: 'Test Section', + sectionProject: '123456', + sectionOptions: { + order: 1, + }, + }); + + const expectedResponse = { + id: '789', + name: 'Test Section', + project_id: '123456', + order: 1, + }; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'POST', + '/sections', + expect.objectContaining({ + name: 'Test Section', + project_id: '123456', + order: 1, + }), + ); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('SectionGetHandler', () => { + it('should get a section successfully', async () => { + const handler = new SectionGetHandler(); + const mockCtx = createMockContext({ + sectionId: '123456', + }); + + const expectedResponse = { + id: '123456', + name: 'Test Section', + project_id: '789', + }; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('GET', '/sections/123456'); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('SectionGetAllHandler', () => { + it('should get all sections successfully', async () => { + const handler = new SectionGetAllHandler(); + const mockCtx = createMockContext({ + sectionFilters: { + project_id: '123456', + }, + }); + + const expectedResponse = [ + { id: '1', name: 'Section 1', project_id: '123456' }, + { id: '2', name: 'Section 2', project_id: '123456' }, + ]; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'GET', + '/sections', + {}, + expect.objectContaining({ + project_id: '123456', + }), + ); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('SectionUpdateHandler', () => { + it('should update a section successfully', async () => { + const handler = new SectionUpdateHandler(); + const mockCtx = createMockContext({ + sectionId: '123456', + sectionUpdateFields: { + name: 'Updated Section', + }, + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'POST', + '/sections/123456', + expect.objectContaining({ + name: 'Updated Section', + }), + ); + expect(result).toEqual({ success: true }); + }); + }); + + describe('SectionDeleteHandler', () => { + it('should delete a section successfully', async () => { + const handler = new SectionDeleteHandler(); + const mockCtx = createMockContext({ + sectionId: '123456', + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('DELETE', '/sections/123456'); + expect(result).toEqual({ success: true }); + }); + }); + }); + + describe('Label Handlers', () => { + describe('LabelCreateHandler', () => { + it('should create a label successfully', async () => { + const handler = new LabelCreateHandler(); + const mockCtx = createMockContext({ + labelName: 'Test Label', + labelOptions: { + color: 'red', + order: 1, + is_favorite: true, + }, + }); + + const expectedResponse = { + id: '123456', + name: 'Test Label', + color: 'red', + order: 1, + is_favorite: true, + }; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'POST', + '/labels', + expect.objectContaining({ + name: 'Test Label', + color: 'red', + order: 1, + is_favorite: true, + }), + ); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('LabelGetHandler', () => { + it('should get a label successfully', async () => { + const handler = new LabelGetHandler(); + const mockCtx = createMockContext({ + labelId: '123456', + }); + + const expectedResponse = { + id: '123456', + name: 'Test Label', + color: 'red', + }; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('GET', '/labels/123456'); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('LabelGetAllHandler', () => { + it('should get all labels successfully', async () => { + const handler = new LabelGetAllHandler(); + const mockCtx = createMockContext({}); + + const expectedResponse = [ + { id: '1', name: 'Label 1', color: 'red' }, + { id: '2', name: 'Label 2', color: 'blue' }, + ]; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('GET', '/labels'); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('LabelUpdateHandler', () => { + it('should update a label successfully', async () => { + const handler = new LabelUpdateHandler(); + const mockCtx = createMockContext({ + labelId: '123456', + labelUpdateFields: { + name: 'Updated Label', + color: 'green', + is_favorite: false, + }, + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'POST', + '/labels/123456', + expect.objectContaining({ + name: 'Updated Label', + color: 'green', + is_favorite: false, + }), + ); + expect(result).toEqual({ success: true }); + }); + }); + + describe('LabelDeleteHandler', () => { + it('should delete a label successfully', async () => { + const handler = new LabelDeleteHandler(); + const mockCtx = createMockContext({ + labelId: '123456', + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('DELETE', '/labels/123456'); + expect(result).toEqual({ success: true }); + }); + }); + }); + + describe('Comment Handlers', () => { + describe('CommentCreateHandler', () => { + it('should create a comment successfully', async () => { + const handler = new CommentCreateHandler(); + const mockCtx = createMockContext({ + commentTaskId: '123456', + commentContent: 'Test comment', + }); + + const expectedResponse = { + id: '789', + task_id: '123456', + content: 'Test comment', + posted_at: '2025-08-03T12:00:00Z', + }; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'POST', + '/comments', + expect.objectContaining({ + task_id: '123456', + content: 'Test comment', + }), + ); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('CommentGetHandler', () => { + it('should get a comment successfully', async () => { + const handler = new CommentGetHandler(); + const mockCtx = createMockContext({ + commentId: '123456', + }); + + const expectedResponse = { + id: '123456', + task_id: '789', + content: 'Test comment', + }; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('GET', '/comments/123456'); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('CommentGetAllHandler', () => { + it('should get all comments with task filter successfully', async () => { + const handler = new CommentGetAllHandler(); + const mockCtx = createMockContext({ + commentFilters: { + task_id: '123456', + }, + }); + + const expectedResponse = [ + { id: '1', task_id: '123456', content: 'Comment 1' }, + { id: '2', task_id: '123456', content: 'Comment 2' }, + ]; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'GET', + '/comments', + {}, + expect.objectContaining({ + task_id: '123456', + }), + ); + expect(result).toEqual({ data: expectedResponse }); + }); + + it('should get all comments with project filter successfully', async () => { + const handler = new CommentGetAllHandler(); + const mockCtx = createMockContext({ + commentFilters: { + project_id: '789', + }, + }); + + const expectedResponse = [{ id: '1', project_id: '789', content: 'Comment 1' }]; + + mockTodoistApiRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'GET', + '/comments', + {}, + expect.objectContaining({ + project_id: '789', + }), + ); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('CommentUpdateHandler', () => { + it('should update a comment successfully', async () => { + const handler = new CommentUpdateHandler(); + const mockCtx = createMockContext({ + commentId: '123456', + commentUpdateFields: { + content: 'Updated comment', + }, + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith( + 'POST', + '/comments/123456', + expect.objectContaining({ + content: 'Updated comment', + }), + ); + expect(result).toEqual({ success: true }); + }); + }); + + describe('CommentDeleteHandler', () => { + it('should delete a comment successfully', async () => { + const handler = new CommentDeleteHandler(); + const mockCtx = createMockContext({ + commentId: '123456', + }); + + mockTodoistApiRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistApiRequest).toHaveBeenCalledWith('DELETE', '/comments/123456'); + expect(result).toEqual({ success: true }); + }); + }); + }); + + describe('Special Operations', () => { + describe('QuickAddHandler', () => { + it('should quick add a task successfully', async () => { + const handler = new QuickAddHandler(); + const mockCtx = createMockContext({ + text: 'Buy milk tomorrow @shopping', + options: {}, + }); + + const expectedResponse = { + id: '123456', + content: 'Buy milk', + project_id: '789', + labels: ['shopping'], + due: { + date: '2025-08-04', + string: 'tomorrow', + }, + }; + + mockTodoistSyncRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistSyncRequest).toHaveBeenCalledWith( + { text: 'Buy milk tomorrow @shopping' }, + {}, + '/quick/add', + ); + expect(result).toEqual({ data: expectedResponse }); + }); + + it('should quick add a task with all optional parameters', async () => { + const handler = new QuickAddHandler(); + const mockCtx = createMockContext({ + text: 'Meeting with team tomorrow at 2pm', + options: { + note: 'Discuss project roadmap and priorities', + reminder: 'tomorrow at 1:30pm', + auto_reminder: true, + }, + }); + + const expectedResponse = { + id: '789123', + content: 'Meeting with team', + project_id: '456', + due: { + date: '2025-08-04', + datetime: '2025-08-04T14:00:00', + string: 'tomorrow at 2pm', + }, + }; + + mockTodoistSyncRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistSyncRequest).toHaveBeenCalledWith( + { + text: 'Meeting with team tomorrow at 2pm', + note: 'Discuss project roadmap and priorities', + reminder: 'tomorrow at 1:30pm', + auto_reminder: true, + }, + {}, + '/quick/add', + ); + expect(result).toEqual({ data: expectedResponse }); + }); + + it('should quick add a task with note only', async () => { + const handler = new QuickAddHandler(); + const mockCtx = createMockContext({ + text: 'Review documents', + options: { + note: 'Check the quarterly reports', + }, + }); + + const expectedResponse = { + id: '456789', + content: 'Review documents', + project_id: '123', + }; + + mockTodoistSyncRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistSyncRequest).toHaveBeenCalledWith( + { + text: 'Review documents', + note: 'Check the quarterly reports', + }, + {}, + '/quick/add', + ); + expect(result).toEqual({ data: expectedResponse }); + }); + + it('should quick add a task with reminder only', async () => { + const handler = new QuickAddHandler(); + const mockCtx = createMockContext({ + text: 'Call dentist', + options: { + reminder: 'next Monday at 9am', + }, + }); + + const expectedResponse = { + id: '321654', + content: 'Call dentist', + project_id: '456', + }; + + mockTodoistSyncRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistSyncRequest).toHaveBeenCalledWith( + { + text: 'Call dentist', + reminder: 'next Monday at 9am', + }, + {}, + '/quick/add', + ); + expect(result).toEqual({ data: expectedResponse }); + }); + + it('should quick add a task with auto_reminder only', async () => { + const handler = new QuickAddHandler(); + const mockCtx = createMockContext({ + text: 'Presentation due Friday at 5pm', + options: { + auto_reminder: true, + }, + }); + + const expectedResponse = { + id: '987654', + content: 'Presentation due', + project_id: '789', + due: { + date: '2025-08-08', + datetime: '2025-08-08T17:00:00', + string: 'Friday at 5pm', + }, + }; + + mockTodoistSyncRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistSyncRequest).toHaveBeenCalledWith( + { + text: 'Presentation due Friday at 5pm', + auto_reminder: true, + }, + {}, + '/quick/add', + ); + expect(result).toEqual({ data: expectedResponse }); + }); + + it('should handle empty optional parameters correctly', async () => { + const handler = new QuickAddHandler(); + const mockCtx = createMockContext({ + text: 'Simple task', + options: { + note: '', + reminder: '', + auto_reminder: false, + }, + }); + + const expectedResponse = { + id: '111222', + content: 'Simple task', + project_id: '333', + }; + + mockTodoistSyncRequest.mockResolvedValue(expectedResponse); + + const result = await handler.handleOperation(mockCtx, 0); + + // Should only include text since other options are empty/false + expect(mockTodoistSyncRequest).toHaveBeenCalledWith( + { text: 'Simple task' }, + {}, + '/quick/add', + ); + expect(result).toEqual({ data: expectedResponse }); + }); + }); + + describe('SyncHandler', () => { + it('should execute sync commands successfully', async () => { + const handler = new SyncHandler(); + const mockCtx = createMockContext({ + commands: JSON.stringify([ + { + type: 'item_add', + temp_id: 'temp123', + uuid: 'uuid123', + args: { + content: 'New task', + project_id: '789', + }, + }, + ]), + project: '789', + }); + + mockTodoistApiRequest.mockResolvedValue([]); + mockTodoistSyncRequest.mockResolvedValue(undefined); + + const result = await handler.handleOperation(mockCtx, 0); + + expect(mockTodoistSyncRequest).toHaveBeenCalledWith( + expect.objectContaining({ + commands: [ + expect.objectContaining({ + type: 'item_add', + temp_id: expect.any(String), + uuid: 'mock-uuid-123', + args: expect.objectContaining({ + content: 'New task', + project_id: '789', + }), + }), + ], + temp_id_mapping: expect.any(Object), + }), + ); + expect(result).toEqual({ success: true }); + }); + + it('should throw error for invalid JSON commands', async () => { + const handler = new SyncHandler(); + const mockCtx = createMockContext({ + commands: 'invalid-json', + project: '789', + }); + + await expect(handler.handleOperation(mockCtx, 0)).rejects.toThrow('Unexpected token'); + }); + }); + }); + + describe('Error Handling', () => { + describe('Type Validation', () => { + it('should throw error for invalid task ID type', async () => { + const handler = new GetHandler(); + const mockCtx = createMockContext({ + taskId: null, + }); + + await expect(handler.handleOperation(mockCtx, 0)).rejects.toThrow(); + }); + + it('should throw error for invalid project ID type', async () => { + const handler = new ProjectGetHandler(); + const mockCtx = createMockContext({ + projectId: {}, + }); + + await expect(handler.handleOperation(mockCtx, 0)).rejects.toThrow(); + }); + + it('should throw error for invalid content type in task creation', async () => { + const handler = new CreateHandler(); + const mockCtx = createMockContext({ + content: 123, + project: '789', + options: {}, + }); + + await expect(handler.handleOperation(mockCtx, 0)).rejects.toThrow(); + }); + }); + + describe('API Error Handling', () => { + it('should propagate API errors', async () => { + const handler = new GetHandler(); + const mockCtx = createMockContext({ + taskId: '123456', + }); + + const apiError = new Error('API Error'); + mockTodoistApiRequest.mockRejectedValue(apiError); + + await expect(handler.handleOperation(mockCtx, 0)).rejects.toThrow('API Error'); + }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Todoist/v2/test/TodoistV2.node.test.ts b/packages/nodes-base/nodes/Todoist/v2/test/TodoistV2.node.test.ts new file mode 100644 index 0000000000..30dd470040 --- /dev/null +++ b/packages/nodes-base/nodes/Todoist/v2/test/TodoistV2.node.test.ts @@ -0,0 +1,347 @@ +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import type { WorkflowTestData } from 'n8n-workflow'; +import nock from 'nock'; + +// Mock data with randomized IDs and generic names +const projectData = { + id: '1234567890', + parent_id: null, + order: 31, + color: 'charcoal', + name: 'Sample Project', + comment_count: 0, + is_shared: false, + is_favorite: false, + is_inbox_project: false, + is_team_inbox: false, + url: 'https://app.todoist.com/app/project/abc123def456', + view_style: 'list', + description: '', +}; + +const sectionData = { + id: '987654321', + v2_id: 'sec123abc456', + project_id: '1234567890', + v2_project_id: 'abc123def456', + order: 0, + name: 'Sample Section', +}; + +const taskData = { + id: '5555666677', + assigner_id: null, + assignee_id: null, + project_id: '1234567890', + section_id: null, + parent_id: null, + order: 1, + content: 'Sample task content', + description: 'Sample task description', + is_completed: false, + labels: [], + priority: 1, + comment_count: 0, + creator_id: '9876543', + created_at: '2025-08-03T12:55:25.534632Z', + due: { + date: '2025-08-30', + string: 'Next monday', + lang: 'en', + is_recurring: false, + datetime: '2025-08-30T00:00:00', + }, + url: 'https://app.todoist.com/app/task/5555666677', + duration: null, + deadline: null, +}; + +const taskData2 = { + id: '8888999900', + assigner_id: null, + assignee_id: null, + project_id: '1234567890', + section_id: null, + parent_id: null, + order: 3, + content: 'Another sample task', + description: '', + is_completed: false, + labels: [], + priority: 1, + comment_count: 0, + creator_id: '9876543', + created_at: '2025-08-03T12:55:31.855475Z', + due: { + date: '2029-03-03', + string: '2029-03-03', + lang: 'en', + is_recurring: false, + }, + url: 'https://app.todoist.com/app/task/8888999900', + duration: { + amount: 100, + unit: 'minute', + }, + deadline: { + date: '2025-03-05', + lang: 'en', + }, +}; + +const labelData = { + id: '1111222233', + name: 'sample-label', + color: 'red', + order: 1, + is_favorite: true, +}; + +const commentData = { + id: '4444555566', + task_id: '5555666677', + project_id: null, + content: 'Sample comment', + posted_at: '2025-08-03T12:55:30.205676Z', + posted_by_id: '9876543', + updated_at: '2025-08-03T12:55:30.187423Z', + attachment: null, + upload_id: null, + reactions: {}, + uids_to_notify: [], +}; + +const collaboratorData = { + id: '9876543', + name: 'Sample User', + email: 'sample@example.com', +}; + +const quickAddTaskData = { + added_at: '2025-08-03T12:55:24.953387Z', + added_by_uid: '9876543', + assigned_by_uid: null, + checked: false, + child_order: 393, + collapsed: false, + completed_at: null, + content: 'Sample quick task', + day_order: -1, + deadline: null, + description: '', + due: null, + duration: null, + id: '7777888899', + is_deleted: false, + labels: [], + note_count: 0, + parent_id: null, + priority: 1, + project_id: '1111111111', + responsible_uid: null, + section_id: null, + sync_id: null, + updated_at: '2025-08-03T12:55:24.953399Z', + user_id: '9876543', + v2_id: 'quick123abc', + v2_parent_id: null, + v2_project_id: 'inbox123abc', + v2_section_id: null, +}; + +const projectsListData = [ + { + id: '1111111111', + parent_id: null, + order: 0, + color: 'grey', + name: 'Inbox', + comment_count: 0, + is_shared: false, + is_favorite: false, + is_inbox_project: true, + is_team_inbox: false, + url: 'https://app.todoist.com/app/project/inbox123abc', + view_style: 'list', + description: '', + }, + { + id: '2222222222', + parent_id: null, + order: 1, + color: 'blue', + name: 'Work Projects', + comment_count: 0, + is_shared: false, + is_favorite: true, + is_inbox_project: false, + is_team_inbox: false, + url: 'https://app.todoist.com/app/project/work123abc', + view_style: 'board', + description: '', + }, +]; + +const tasksListData = [ + { + id: '3333444455', + assigner_id: null, + assignee_id: null, + project_id: '1111111111', + section_id: '987654321', + parent_id: null, + order: -13, + content: 'Sample task 1', + description: '', + is_completed: false, + labels: ['work'], + priority: 1, + comment_count: 0, + creator_id: '9876543', + created_at: '2025-06-25T18:52:23.989765Z', + due: null, + url: 'https://app.todoist.com/app/task/3333444455', + duration: null, + deadline: null, + }, + { + id: '6666777788', + assigner_id: null, + assignee_id: null, + project_id: '1111111111', + section_id: '987654321', + parent_id: null, + order: -12, + content: 'Sample task 2', + description: '', + is_completed: false, + labels: ['personal'], + priority: 1, + comment_count: 0, + creator_id: '9876543', + created_at: '2025-06-22T09:58:35.471124Z', + due: null, + url: 'https://app.todoist.com/app/task/6666777788', + duration: null, + deadline: null, + }, +]; + +const labelsListData = [ + { + id: '1111222233', + name: 'work', + color: 'blue', + order: 1, + is_favorite: true, + }, + { + id: '4444555566', + name: 'personal', + color: 'green', + order: 2, + is_favorite: false, + }, +]; + +const successResponse = { success: true }; + +describe('Execute TodoistV2 Node', () => { + const testHarness = new NodeTestHarness(); + + beforeEach(() => { + const todoistNock = nock('https://api.todoist.com'); + + // Project operations + todoistNock.post('/rest/v2/projects').reply(200, projectData); + todoistNock.get('/rest/v2/projects/1234567890').reply(200, projectData); + todoistNock.post('/rest/v2/projects/1234567890/archive').reply(200, successResponse); + todoistNock.post('/rest/v2/projects/1234567890/unarchive').reply(200, successResponse); + todoistNock.post('/rest/v2/projects/1234567890').reply(200, successResponse); + todoistNock.get('/rest/v2/projects/1234567890/collaborators').reply(200, [collaboratorData]); + todoistNock.delete('/rest/v2/projects/1234567890').reply(200, successResponse); + todoistNock.get('/rest/v2/projects').reply(200, projectsListData); + + // Section operations + todoistNock.post('/rest/v2/sections').reply(200, sectionData); + todoistNock.get('/rest/v2/sections/987654321').reply(200, sectionData); + todoistNock.post('/rest/v2/sections/987654321').reply(200, successResponse); + todoistNock.delete('/rest/v2/sections/987654321').reply(200, successResponse); + todoistNock + .get('/rest/v2/sections') + .query({ project_id: '1234567890' }) + .reply(200, [sectionData]); + + // Task operations + todoistNock.post('/rest/v2/tasks').reply(200, taskData); + todoistNock.post('/rest/v2/tasks').reply(200, taskData2); + todoistNock.post('/rest/v2/tasks/8888999900').reply(200, successResponse); + todoistNock.post('/rest/v2/tasks/8888999900/close').reply(200, successResponse); + todoistNock.post('/rest/v2/tasks/8888999900/reopen').reply(200, successResponse); + todoistNock.delete('/rest/v2/tasks/8888999900').reply(200, successResponse); + todoistNock.get('/rest/v2/tasks').query(true).reply(200, tasksListData); + + // Move task uses sync API + todoistNock.post('/sync/v9/sync').reply(200, { sync_status: { '8888999900': 'ok' } }); + + // Label operations + todoistNock.post('/rest/v2/labels').reply(200, labelData); + todoistNock.get('/rest/v2/labels/1111222233').reply(200, labelData); + todoistNock.post('/rest/v2/labels/1111222233').reply(200, successResponse); + todoistNock.delete('/rest/v2/labels/1111222233').reply(200, successResponse); + todoistNock.get('/rest/v2/labels').reply(200, labelsListData); + + // Comment operations + todoistNock.post('/rest/v2/comments').reply(200, commentData); + todoistNock.get('/rest/v2/comments/4444555566').reply(200, commentData); + todoistNock.post('/rest/v2/comments/4444555566').reply(200, successResponse); + todoistNock.get('/rest/v2/comments').query({ task_id: '5555666677' }).reply(200, [commentData]); + + // Quick add operation + todoistNock.post('/sync/v9/quick/add').reply(200, quickAddTaskData); + }); + + const testData: WorkflowTestData = { + description: 'Execute operations', + input: { + workflowData: testHarness.readWorkflowJSON('workflow.json'), + }, + output: { + nodeData: { + 'Create a project1': [[{ json: projectData }]], + 'Get a project': [[{ json: projectData }]], + 'Archive a project': [[{ json: successResponse }]], + 'Unarchive a project': [[{ json: successResponse }]], + 'Update a project': [[{ json: successResponse }]], + 'Get project collaborators': [[{ json: collaboratorData }]], + 'Delete a project': [[{ json: successResponse }]], + 'Get many projects': [projectsListData.map((project) => ({ json: project }))], + 'Create a section': [[{ json: sectionData }]], + 'Get a section': [[{ json: sectionData }]], + 'Update a section': [[{ json: successResponse }]], + 'Delete a section': [[{ json: successResponse }]], + 'Get many sections': [[{ json: sectionData }]], + 'Create a task': [[{ json: taskData }]], + 'Create a task1': [[{ json: taskData2 }]], + 'Update a task': [[{ json: successResponse }]], + 'Move a task': [[{ json: successResponse }]], + 'Close a task': [[{ json: successResponse }]], + 'Reopen a task': [[{ json: successResponse }]], + 'Delete a task': [[{ json: successResponse }]], + 'Get many tasks': [tasksListData.map((task) => ({ json: task }))], + 'Create a label': [[{ json: labelData }]], + 'Get a label': [[{ json: labelData }]], + 'Update a label': [[{ json: successResponse }]], + 'Delete a label': [[{ json: successResponse }]], + 'Get many labels': [labelsListData.map((label) => ({ json: label }))], + 'Create a comment': [[{ json: commentData }]], + 'Get a comment': [[{ json: commentData }]], + 'Update a comment': [[{ json: successResponse }]], + 'Get many comments': [[{ json: commentData }]], + 'Quick add a task': [[{ json: quickAddTaskData }]], + }, + }, + }; + + testHarness.setupTest(testData, { credentials: { todoistApi: {} } }); +}); diff --git a/packages/nodes-base/nodes/Todoist/v2/test/workflow.json b/packages/nodes-base/nodes/Todoist/v2/test/workflow.json new file mode 100644 index 0000000000..d6440e9a5b --- /dev/null +++ b/packages/nodes-base/nodes/Todoist/v2/test/workflow.json @@ -0,0 +1,947 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, -112], + "id": "ba3ea0f4-81ec-46d4-9705-7fffc01cf0df", + "name": "When clicking ‘Execute workflow’" + }, + { + "parameters": { + "resource": "project", + "operation": "get", + "projectId": "={{ $json.id }}" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [448, 80], + "id": "d9bea9ce-cbc3-4a91-83fe-8f497aeb57d0", + "name": "Get a project", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "project", + "operation": "archive", + "projectId": "={{ $json.id }}" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [672, 80], + "id": "a4793b6f-1c03-4648-a750-2123fda14abd", + "name": "Archive a project", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "project", + "operation": "unarchive", + "projectId": "={{ $('Get a project').item.json.id }}" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [896, 80], + "id": "68a4b65b-514c-4879-807a-ff4693548f4c", + "name": "Unarchive a project", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "project", + "operation": "update", + "projectId": "={{ $('Get a project').item.json.id }}", + "projectUpdateFields": { + "name": "Hello world", + "color": "red", + "is_favorite": true, + "view_style": "board" + } + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1120, 80], + "id": "442f5e3a-e0d3-41e5-b087-90c37efc50ff", + "name": "Update a project", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "project", + "operation": "getCollaborators", + "projectId": "={{ $('Get a project').item.json.id }}" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1344, 80], + "id": "8719feca-b43b-4143-a0f1-694918e159e3", + "name": "Get project collaborators", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "project", + "operation": "delete", + "projectId": "={{ $('Get a project').item.json.id }}" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1568, 80], + "id": "b8d56d72-eb9f-4e94-9405-cadb1d4e1851", + "name": "Delete a project", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "project", + "operation": "getAll" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1792, 80], + "id": "07a60756-c0b3-4f50-b4da-82630cbdf6f6", + "name": "Get many projects", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "project", + "name": "Test", + "projectOptions": {} + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [224, -112], + "id": "e5c3ba6f-1a4f-46ee-a9cb-78a106a1f57a", + "name": "Create a project1", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "section", + "sectionProject": { + "__rl": true, + "value": "={{ $json.id }}", + "mode": "id" + }, + "sectionName": "Section ", + "sectionOptions": { + "order": 0 + } + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [448, -592], + "id": "1f661708-8f3b-4cf8-b422-5d4a6ec02891", + "name": "Create a section", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "project": { + "__rl": true, + "value": "={{ $json.project_id }}", + "mode": "id" + }, + "content": "test content", + "options": { + "description": "test description", + "dueDateTime": "2025-08-30T00:00:00", + "dueLang": "EN", + "dueString": "Next monday", + "priority": 1 + } + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [672, -592], + "id": "692f5b29-77f2-4750-99fa-7d9a9f62a339", + "name": "Create a task", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "section", + "operation": "get", + "sectionId": "={{ $json.id }}" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [896, -112], + "id": "08b90997-595b-44f0-be49-7cb4d5d641f1", + "name": "Get a section", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "section", + "operation": "update", + "sectionId": "={{ $json.id }}", + "sectionUpdateFields": { + "name": "hello section" + } + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1120, -112], + "id": "0446c635-e9d6-491e-8bed-b0463f99192d", + "name": "Update a section", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "section", + "operation": "delete", + "sectionId": "={{ $('Get a section').item.json.id }}" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1344, -112], + "id": "c396cb2f-d2a1-40d1-a478-5896ff6f5c16", + "name": "Delete a section", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "section", + "operation": "getAll", + "sectionFilters": { + "project_id": "={{ $json.id }}" + } + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [624, -112], + "id": "59ae95fd-93b4-42e4-9c11-c177b34422c4", + "name": "Get many sections", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "label", + "labelName": "hot", + "labelOptions": { + "color": "red", + "order": 1, + "is_favorite": true + } + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [896, -688], + "id": "028ca51f-6b0b-4200-b236-92aed48bffc3", + "name": "Create a label", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "label", + "operation": "get", + "labelId": "={{ $json.id }}" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1120, -688], + "id": "0cc74ce5-6295-421c-b252-a44d354c3723", + "name": "Get a label", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "project": { + "__rl": true, + "value": "={{ $('Create a project1').item.json.id }}", + "mode": "id" + }, + "content": "sub test content", + "options": { + "order": 3, + "dueDate": "2029-03-03", + "assigneeId": "={{ $json.creator_id }}", + "duration": 100, + "durationUnit": "minute", + "deadlineDate": "2025-03-05" + } + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [672, -304], + "id": "c3971f85-6ed1-4028-becd-34a66d18846d", + "name": "Create a task1", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "operation": "update", + "taskId": "={{ $json.id }}", + "updateFields": { + "content": "Hello world", + "description": "my world", + "dueDateTime": "2025-08-03T11:43:45", + "priority": "={{ \"3\" }}", + "duration": 100, + "durationUnit": "day", + "deadlineDate": "2026-03-03" + } + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [896, -304], + "id": "886f2a8a-5110-408b-b932-d1ac58281000", + "name": "Update a task", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "operation": "move", + "taskId": "={{ $('Create a task1').item.json.id }}", + "project": { + "__rl": true, + "value": "={{ $('Create a task1').item.json.project_id }}", + "mode": "id" + }, + "options": {} + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1120, -304], + "id": "206840f5-f7e4-48bc-b75a-62ada06d9edd", + "name": "Move a task", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "operation": "close", + "taskId": "={{ $('Create a task1').item.json.id }}" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1344, -304], + "id": "6e3f776a-70b5-4f8a-956b-eab674be806a", + "name": "Close a task", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "operation": "reopen", + "taskId": "={{ $('Create a task1').item.json.id }}" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1568, -304], + "id": "09e514ed-556e-4869-bb1d-d537122c6f16", + "name": "Reopen a task", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "operation": "delete", + "taskId": "={{ $('Create a task1').item.json.id }}" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1792, -304], + "id": "796f82f8-681c-4f53-aaf8-213ddce86b38", + "name": "Delete a task", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "label", + "operation": "update", + "labelId": "={{ $json.id }}", + "labelUpdateFields": { + "name": "test", + "color": "orange", + "order": 10, + "is_favorite": false + } + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1344, -688], + "id": "5515991b-831c-4f22-b6cb-76f4f2763634", + "name": "Update a label", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "label", + "operation": "delete", + "labelId": "={{ $('Create a label').item.json.id }}" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1568, -688], + "id": "be79dbb3-5b35-44d3-a99b-4954375b9dd2", + "name": "Delete a label", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "label", + "operation": "getAll" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1792, -688], + "id": "2e1eabe1-1322-488d-b2de-2cdd2a839d16", + "name": "Get many labels", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "operation": "getAll", + "limit": 10, + "filters": {} + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [2016, -304], + "id": "6694b9d6-7e2f-442d-be53-dcae97b2b59c", + "name": "Get many tasks", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "comment", + "commentTaskId": "={{ $json.id }}", + "commentContent": "my comment" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [896, -496], + "id": "eef98ea0-c28b-49a5-b155-4bd62bebb85c", + "name": "Create a comment", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "comment", + "operation": "get", + "commentId": "={{ $json.id }}" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1120, -496], + "id": "01d4e22d-e2de-49d7-8551-cdc58016b5d5", + "name": "Get a comment", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "comment", + "operation": "update", + "commentId": "={{ $json.id }}", + "commentUpdateFields": { + "content": "change my comment" + } + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1344, -496], + "id": "344608b2-8f2f-49e1-8b55-35cbbc50afe5", + "name": "Update a comment", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "resource": "comment", + "operation": "getAll", + "commentFilters": { + "task_id": "={{ $('Create a task').item.json.id }}" + } + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [1568, -496], + "id": "b98749e6-a153-47d5-8f27-dbcc5d4f158c", + "name": "Get many comments", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + }, + { + "parameters": { + "operation": "quickAdd", + "text": "hello world!!!" + }, + "type": "n8n-nodes-base.todoist", + "typeVersion": 2.1, + "position": [672, -784], + "id": "acb416b1-2ff2-49de-9793-3e535cd61ede", + "name": "Quick add a task", + "credentials": { + "todoistApi": { + "id": "I8WGOzhOQTmj9nfz", + "name": "Todoist account" + } + } + } + ], + "connections": { + "When clicking ‘Execute workflow’": { + "main": [ + [ + { + "node": "Create a project1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get a project": { + "main": [ + [ + { + "node": "Archive a project", + "type": "main", + "index": 0 + } + ] + ] + }, + "Archive a project": { + "main": [ + [ + { + "node": "Unarchive a project", + "type": "main", + "index": 0 + } + ] + ] + }, + "Unarchive a project": { + "main": [ + [ + { + "node": "Update a project", + "type": "main", + "index": 0 + } + ] + ] + }, + "Update a project": { + "main": [ + [ + { + "node": "Get project collaborators", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get project collaborators": { + "main": [ + [ + { + "node": "Delete a project", + "type": "main", + "index": 0 + } + ] + ] + }, + "Delete a project": { + "main": [ + [ + { + "node": "Get many projects", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create a project1": { + "main": [ + [ + { + "node": "Get a project", + "type": "main", + "index": 0 + }, + { + "node": "Get many sections", + "type": "main", + "index": 0 + }, + { + "node": "Create a section", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create a section": { + "main": [ + [ + { + "node": "Create a task", + "type": "main", + "index": 0 + }, + { + "node": "Quick add a task", + "type": "main", + "index": 0 + }, + { + "node": "Create a task1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create a task": { + "main": [ + [ + { + "node": "Create a label", + "type": "main", + "index": 0 + }, + { + "node": "Create a comment", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get a section": { + "main": [ + [ + { + "node": "Update a section", + "type": "main", + "index": 0 + } + ] + ] + }, + "Update a section": { + "main": [ + [ + { + "node": "Delete a section", + "type": "main", + "index": 0 + } + ] + ] + }, + "Delete a section": { + "main": [[]] + }, + "Get many sections": { + "main": [ + [ + { + "node": "Get a section", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create a label": { + "main": [ + [ + { + "node": "Get a label", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get a label": { + "main": [ + [ + { + "node": "Update a label", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create a task1": { + "main": [ + [ + { + "node": "Update a task", + "type": "main", + "index": 0 + } + ] + ] + }, + "Update a task": { + "main": [ + [ + { + "node": "Move a task", + "type": "main", + "index": 0 + } + ] + ] + }, + "Move a task": { + "main": [ + [ + { + "node": "Close a task", + "type": "main", + "index": 0 + } + ] + ] + }, + "Close a task": { + "main": [ + [ + { + "node": "Reopen a task", + "type": "main", + "index": 0 + } + ] + ] + }, + "Reopen a task": { + "main": [ + [ + { + "node": "Delete a task", + "type": "main", + "index": 0 + } + ] + ] + }, + "Delete a task": { + "main": [ + [ + { + "node": "Get many tasks", + "type": "main", + "index": 0 + } + ] + ] + }, + "Update a label": { + "main": [ + [ + { + "node": "Delete a label", + "type": "main", + "index": 0 + } + ] + ] + }, + "Delete a label": { + "main": [ + [ + { + "node": "Get many labels", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create a comment": { + "main": [ + [ + { + "node": "Get a comment", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get a comment": { + "main": [ + [ + { + "node": "Update a comment", + "type": "main", + "index": 0 + } + ] + ] + }, + "Update a comment": { + "main": [ + [ + { + "node": "Get many comments", + "type": "main", + "index": 0 + } + ] + ] + } + } +} diff --git a/packages/nodes-base/utils/__tests__/types.test.ts b/packages/nodes-base/utils/__tests__/types.test.ts new file mode 100644 index 0000000000..97ca2faf2e --- /dev/null +++ b/packages/nodes-base/utils/__tests__/types.test.ts @@ -0,0 +1,388 @@ +import { assertIsNodeParameters, assertIsString, assertIsNumber, assertIsArray } from '../types'; + +describe('Type assertion functions', () => { + describe('assertIsNodeParameters', () => { + it('should pass for valid object with all required parameters', () => { + const value = { + name: 'test', + age: 25, + active: true, + }; + + const parameters = { + name: { type: 'string' as const }, + age: { type: 'number' as const }, + active: { type: 'boolean' as const }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).not.toThrow(); + }); + + it('should pass for valid object with optional parameters present', () => { + const value = { + name: 'test', + description: 'optional description', + }; + + const parameters = { + name: { type: 'string' as const }, + description: { type: 'string' as const, optional: true }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).not.toThrow(); + }); + + it('should pass for valid object with optional parameters missing', () => { + const value = { + name: 'test', + }; + + const parameters = { + name: { type: 'string' as const }, + description: { type: 'string' as const, optional: true }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).not.toThrow(); + }); + + it('should pass for valid array parameters', () => { + const value = { + tags: ['tag1', 'tag2'], + numbers: [1, 2, 3], + flags: [true, false], + }; + + const parameters = { + tags: { type: 'string[]' as const }, + numbers: { type: 'number[]' as const }, + flags: { type: 'boolean[]' as const }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).not.toThrow(); + }); + + it('should pass for valid resource-locator parameter', () => { + const value = { + resource: { + __rl: true, + mode: 'list', + value: 'some-value', + }, + }; + + const parameters = { + resource: { type: 'resource-locator' as const }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).not.toThrow(); + }); + + it('should pass for valid object parameter', () => { + const value = { + config: { + setting1: 'value1', + setting2: 42, + }, + }; + + const parameters = { + config: { type: 'object' as const }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).not.toThrow(); + }); + + it('should pass for parameter with multiple allowed types', () => { + const value = { + multiType: 'string value', + }; + + const parameters = { + multiType: { type: ['string', 'number'] as Array<'string' | 'number'> }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).not.toThrow(); + + // Test with number value + const value2 = { + multiType: 42, + }; + + expect(() => assertIsNodeParameters(value2, parameters)).not.toThrow(); + }); + + it('should throw for null value', () => { + const parameters = { + name: { type: 'string' as const }, + }; + + expect(() => assertIsNodeParameters(null, parameters)).toThrow('Value is not a valid object'); + }); + + it('should throw for non-object value', () => { + const parameters = { + name: { type: 'string' as const }, + }; + + expect(() => assertIsNodeParameters('not an object', parameters)).toThrow( + 'Value is not a valid object', + ); + expect(() => assertIsNodeParameters(123, parameters)).toThrow('Value is not a valid object'); + expect(() => assertIsNodeParameters(true, parameters)).toThrow('Value is not a valid object'); + }); + + it('should throw for missing required parameter', () => { + const value = { + // name is missing + }; + + const parameters = { + name: { type: 'string' as const }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).toThrow( + 'Required parameter "name" is missing', + ); + }); + + it('should throw for parameter with wrong type', () => { + const value = { + name: 123, // should be string + }; + + const parameters = { + name: { type: 'string' as const }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).toThrow( + 'Parameter "name" does not match any of the expected types: string', + ); + }); + + it('should throw for invalid array parameter', () => { + const value = { + tags: 'not an array', + }; + + const parameters = { + tags: { type: 'string[]' as const }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).toThrow( + 'Parameter "tags" does not match any of the expected types: string[]', + ); + }); + + it('should throw for array with wrong element type', () => { + const value = { + tags: ['valid', 123, 'also valid'], // 123 is not a string + }; + + const parameters = { + tags: { type: 'string[]' as const }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).toThrow( + 'Parameter "tags" does not match any of the expected types: string[]', + ); + }); + + it('should throw for invalid resource-locator parameter', () => { + const value = { + resource: { + // missing required properties + mode: 'list', + }, + }; + + const parameters = { + resource: { type: 'resource-locator' as const }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).toThrow( + 'Parameter "resource" does not match any of the expected types: resource-locator', + ); + }); + + it('should throw for invalid object parameter', () => { + const value = { + config: 'not an object', + }; + + const parameters = { + config: { type: 'object' as const }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).toThrow( + 'Parameter "config" does not match any of the expected types: object', + ); + }); + + it('should throw for parameter that matches none of the allowed types', () => { + const value = { + multiType: true, // should be string or number + }; + + const parameters = { + multiType: { type: ['string', 'number'] as Array<'string' | 'number'> }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).toThrow( + 'Parameter "multiType" does not match any of the expected types: string or number', + ); + }); + + it('should handle empty parameter definition', () => { + const value = { + extra: 'should be ignored', + }; + + const parameters = {}; + + expect(() => assertIsNodeParameters(value, parameters)).not.toThrow(); + }); + + it('should handle complex nested scenarios', () => { + const value = { + name: 'test', + tags: ['tag1', 'tag2'], + config: { + enabled: true, + timeout: 5000, + }, + resource: { + __rl: true, + mode: 'id', + value: '12345', + }, + optionalField: undefined, + }; + + const parameters = { + name: { type: 'string' as const }, + tags: { type: 'string[]' as const }, + config: { type: 'object' as const }, + resource: { type: 'resource-locator' as const }, + optionalField: { type: 'string' as const, optional: true }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).not.toThrow(); + }); + + it('should handle empty arrays', () => { + const value = { + emptyTags: [], + }; + + const parameters = { + emptyTags: { type: 'string[]' as const }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).not.toThrow(); + }); + + it('should handle null values for optional parameters', () => { + const value = { + name: 'test', + optionalField: null, + }; + + const parameters = { + name: { type: 'string' as const }, + optionalField: { type: 'string' as const, optional: true }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).toThrow( + 'Parameter "optionalField" does not match any of the expected types: string', + ); + }); + + it('should handle resource-locator with additional properties', () => { + const value = { + resource: { + __rl: true, + mode: 'list', + value: 'some-value', + extraProperty: 'ignored', + }, + }; + + const parameters = { + resource: { type: 'resource-locator' as const }, + }; + + expect(() => assertIsNodeParameters(value, parameters)).not.toThrow(); + }); + }); + + describe('assertIsString', () => { + it('should pass for valid string', () => { + expect(() => assertIsString('testParam', 'hello')).not.toThrow(); + }); + + it('should throw for non-string values', () => { + expect(() => assertIsString('testParam', 123)).toThrow('Parameter "testParam" is not string'); + expect(() => assertIsString('testParam', true)).toThrow( + 'Parameter "testParam" is not string', + ); + expect(() => assertIsString('testParam', null)).toThrow( + 'Parameter "testParam" is not string', + ); + expect(() => assertIsString('testParam', undefined)).toThrow( + 'Parameter "testParam" is not string', + ); + }); + }); + + describe('assertIsNumber', () => { + it('should pass for valid number', () => { + expect(() => assertIsNumber('testParam', 123)).not.toThrow(); + expect(() => assertIsNumber('testParam', 0)).not.toThrow(); + expect(() => assertIsNumber('testParam', -5.5)).not.toThrow(); + }); + + it('should throw for non-number values', () => { + expect(() => assertIsNumber('testParam', '123')).toThrow( + 'Parameter "testParam" is not number', + ); + expect(() => assertIsNumber('testParam', true)).toThrow( + 'Parameter "testParam" is not number', + ); + expect(() => assertIsNumber('testParam', null)).toThrow( + 'Parameter "testParam" is not number', + ); + expect(() => assertIsNumber('testParam', undefined)).toThrow( + 'Parameter "testParam" is not number', + ); + }); + }); + + describe('assertIsArray', () => { + const isString = (val: unknown): val is string => typeof val === 'string'; + const isNumber = (val: unknown): val is number => typeof val === 'number'; + + it('should pass for valid array with correct element types', () => { + expect(() => assertIsArray('testParam', ['a', 'b', 'c'], isString)).not.toThrow(); + expect(() => assertIsArray('testParam', [1, 2, 3], isNumber)).not.toThrow(); + expect(() => assertIsArray('testParam', [], isString)).not.toThrow(); // empty array + }); + + it('should throw for non-array values', () => { + expect(() => assertIsArray('testParam', 'not array', isString)).toThrow( + 'Parameter "testParam" is not an array', + ); + expect(() => assertIsArray('testParam', { length: 3 }, isString)).toThrow( + 'Parameter "testParam" is not an array', + ); + }); + + it('should throw for array with incorrect element types', () => { + expect(() => assertIsArray('testParam', ['a', 1, 'c'], isString)).toThrow( + 'Parameter "testParam" has elements that don\'t match expected types', + ); + expect(() => assertIsArray('testParam', [1, 'b', 3], isNumber)).toThrow( + 'Parameter "testParam" has elements that don\'t match expected types', + ); + }); + }); +}); diff --git a/packages/nodes-base/utils/types.ts b/packages/nodes-base/utils/types.ts new file mode 100644 index 0000000000..3cbb9f793a --- /dev/null +++ b/packages/nodes-base/utils/types.ts @@ -0,0 +1,167 @@ +import { assert } from 'n8n-workflow'; + +function assertIsType( + parameterName: string, + value: unknown, + type: 'string' | 'number' | 'boolean', +): asserts value is T { + assert(typeof value === type, `Parameter "${parameterName}" is not ${type}`); +} + +export function assertIsNumber(parameterName: string, value: unknown): asserts value is number { + assertIsType(parameterName, value, 'number'); +} + +export function assertIsString(parameterName: string, value: unknown): asserts value is string { + assertIsType(parameterName, value, 'string'); +} + +export function assertIsStringOrNumber( + parameterName: string, + value: unknown, +): asserts value is string | number { + assert( + typeof value === 'string' || typeof value === 'number', + `Parameter "${parameterName}" must be a string or number`, + ); +} + +export function assertIsArray( + parameterName: string, + value: unknown, + validator: (val: unknown) => val is T, +): asserts value is T[] { + assert(Array.isArray(value), `Parameter "${parameterName}" is not an array`); + assert( + value.every(validator), + `Parameter "${parameterName}" has elements that don't match expected types`, + ); +} + +type ParameterType = + | 'string' + | 'boolean' + | 'number' + | 'resource-locator' + | 'string[]' + | 'number[]' + | 'boolean[]' + | 'object'; + +function assertIsValidObject(value: unknown): asserts value is Record { + assert(typeof value === 'object' && value !== null, 'Value is not a valid object'); +} + +function assertIsRequiredParameter( + parameterName: string, + value: unknown, + isOptional: boolean, +): void { + if (!isOptional && value === undefined) { + assert(false, `Required parameter "${parameterName}" is missing`); + } +} + +function assertIsResourceLocator(parameterName: string, value: unknown): void { + assert( + typeof value === 'object' && + value !== null && + '__rl' in value && + 'mode' in value && + 'value' in value, + `Parameter "${parameterName}" is not a valid resource locator object`, + ); +} + +function assertIsObjectType(parameterName: string, value: unknown): void { + assert( + typeof value === 'object' && value !== null, + `Parameter "${parameterName}" is not a valid object`, + ); +} + +function createElementValidator(elementType: 'string' | 'number' | 'boolean') { + return (val: unknown): val is string | number | boolean => typeof val === elementType; +} + +function assertIsArrayType(parameterName: string, value: unknown, arrayType: string): void { + const baseType = arrayType.slice(0, -2); + const elementType = + baseType === 'string' || baseType === 'number' || baseType === 'boolean' ? baseType : 'string'; + + const validator = createElementValidator(elementType as 'string' | 'number' | 'boolean'); + assertIsArray(parameterName, value, validator); +} + +function assertIsPrimitiveType(parameterName: string, value: unknown, type: string): void { + assert(typeof value === type, `Parameter "${parameterName}" is not a valid ${type}`); +} + +function validateParameterType( + parameterName: string, + value: unknown, + type: ParameterType, +): boolean { + try { + if (type === 'resource-locator') { + assertIsResourceLocator(parameterName, value); + } else if (type === 'object') { + assertIsObjectType(parameterName, value); + } else if (type.endsWith('[]')) { + assertIsArrayType(parameterName, value, type); + } else { + assertIsPrimitiveType(parameterName, value, type); + } + return true; + } catch { + return false; + } +} + +function validateParameterAgainstTypes( + parameterName: string, + value: unknown, + types: ParameterType[], +): void { + let isValid = false; + + for (const type of types) { + if (validateParameterType(parameterName, value, type)) { + isValid = true; + break; + } + } + + if (!isValid) { + const typeList = types.join(' or '); + assert( + false, + `Parameter "${parameterName}" does not match any of the expected types: ${typeList}`, + ); + } +} + +export function assertIsNodeParameters( + value: unknown, + parameters: Record< + string, + { + type: ParameterType | ParameterType[]; + optional?: boolean; + } + >, +): asserts value is T { + assertIsValidObject(value); + + Object.keys(parameters).forEach((key) => { + const param = parameters[key]; + const paramValue = value[key]; + + assertIsRequiredParameter(key, paramValue, param.optional ?? false); + + if (paramValue !== undefined) { + const types = Array.isArray(param.type) ? param.type : [param.type]; + validateParameterAgainstTypes(key, paramValue, types); + } + }); +}