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..634ae8d341 100644 --- a/packages/nodes-base/nodes/Todoist/v2/OperationHandler.ts +++ b/packages/nodes-base/nodes/Todoist/v2/OperationHandler.ts @@ -1,11 +1,25 @@ -import type { IDataObject } from 'n8n-workflow'; -import { ApplicationError, jsonParse } from 'n8n-workflow'; +import type { IDataObject, INode } from 'n8n-workflow'; +import { + assertParamIsString, + assertParamIsNumber, + assertParamIsOfAnyTypes, + validateNodeParameters, +} from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; -import type { Section, TodoistResponse } from './Service'; +import type { TodoistResponse } from './Service'; import type { Context } from '../GenericFunctions'; import { FormatDueDatetime, todoistApiRequest, todoistSyncRequest } from '../GenericFunctions'; +// Helper function for string or number validation +function assertValidTodoistId( + parameterName: string, + value: unknown, + node: INode, +): asserts value is string | number { + assertParamIsOfAnyTypes(parameterName, value, ['string', 'number'] as const, node); +} + export interface OperationHandler { handleOperation(ctx: Context, itemIndex: number): Promise; } @@ -13,16 +27,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,22 +53,72 @@ 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; + item_id?: number | string; + due?: Record; + type?: string; + minute_offset?: number; + notify_uid?: string; }; } 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 +126,56 @@ 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); + assertParamIsString('content', content, ctx.getNode()); + const projectId = ctx.getNodeParameter('project', itemIndex, undefined, { extractValue: true, - }) as number; + }); + assertValidTodoistId('project', projectId, ctx.getNode()); + const labels = ctx.getNodeParameter('labels', itemIndex) as string[]; const options = ctx.getNodeParameter('options', itemIndex) as IDataObject; + validateNodeParameters( + options, + { + description: { type: 'string' }, + dueDateTime: { type: 'string' }, + dueString: { type: 'string' }, + section: { type: ['string', 'number'] }, + dueLang: { type: 'string' }, + parentId: { type: ['string', 'number'] }, + priority: { type: ['string', 'number'] }, + order: { type: 'number' }, + dueDate: { type: 'string' }, + assigneeId: { type: 'string' }, + duration: { type: 'number' }, + durationUnit: { type: 'string' }, + deadlineDate: { type: 'string' }, + }, + ctx.getNode(), + ); + 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 +183,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 +228,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); + assertValidTodoistId('taskId', id, ctx.getNode()); await todoistApiRequest.call(ctx, 'POST', `/tasks/${id}/close`); @@ -121,7 +241,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); + assertValidTodoistId('taskId', id, ctx.getNode()); await todoistApiRequest.call(ctx, 'DELETE', `/tasks/${id}`); @@ -133,7 +254,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); + assertValidTodoistId('taskId', id, ctx.getNode()); const responseData = await todoistApiRequest.call(ctx, 'GET', `/tasks/${id}`); return { @@ -147,31 +269,46 @@ 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; + + validateNodeParameters( + filters, + { + projectId: { type: ['string', 'number'] }, + sectionId: { type: ['string', 'number'] }, + labelId: { type: ['string', 'number'] }, + filter: { type: 'string' }, + lang: { type: 'string' }, + ids: { type: 'string' }, + }, + ctx.getNode(), + ); + 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); + assertParamIsNumber('limit', limit, ctx.getNode()); responseData = responseData.splice(0, limit); } @@ -181,21 +318,11 @@ export class GetAllHandler implements OperationHandler { } } -async function getSectionIds(ctx: Context, projectId: number): Promise> { - const sections: Section[] = await todoistApiRequest.call( - ctx, - 'GET', - '/sections', - {}, - { project_id: projectId }, - ); - return new Map(sections.map((s) => [s.name, s.id as unknown as number])); -} - export class ReopenHandler implements OperationHandler { async handleOperation(ctx: Context, itemIndex: 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); + assertValidTodoistId('taskId', id, ctx.getNode()); await todoistApiRequest.call(ctx, 'POST', `/tasks/${id}/reopen`); @@ -208,29 +335,50 @@ 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); + assertValidTodoistId('taskId', id, ctx.getNode()); + const updateFields = ctx.getNodeParameter('updateFields', itemIndex) as IDataObject; + validateNodeParameters( + updateFields, + { + content: { type: 'string' }, + priority: { type: ['number', 'string'] }, + description: { type: 'string' }, + dueDateTime: { type: 'string' }, + dueString: { type: 'string' }, + labels: { type: 'string[]' }, + dueLang: { type: 'string' }, + order: { type: 'number' }, + dueDate: { type: 'string' }, + assigneeId: { type: 'string' }, + duration: { type: 'number' }, + durationUnit: { type: 'string' }, + deadlineDate: { type: 'string' }, + }, + ctx.getNode(), + ); 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 +386,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 +426,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); + assertValidTodoistId('taskId', taskId, ctx.getNode()); + const projectId = ctx.getNodeParameter('project', itemIndex, undefined, { extractValue: true, - }) as number; + }); + assertValidTodoistId('project', projectId, ctx.getNode()); const nodeVersion = ctx.getNode().typeVersion; const body: SyncRequest = { @@ -269,7 +444,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); + assertValidTodoistId('section', section, ctx.getNode()); + return section; + })(), + } : {}), }, }, @@ -278,11 +459,20 @@ export class MoveHandler implements OperationHandler { if (nodeVersion >= 2.1) { const options = ctx.getNodeParameter('options', itemIndex, {}) as IDataObject; + validateNodeParameters( + options, + { + parent: { type: ['string', 'number'] }, + section: { type: ['string', 'number'] }, + }, + ctx.getNode(), + ); + // 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; } @@ -293,77 +483,519 @@ export class MoveHandler implements OperationHandler { } } -export class SyncHandler implements OperationHandler { +// Project Handlers +export class ProjectCreateHandler implements OperationHandler { async handleOperation(ctx: Context, itemIndex: number): Promise { - const commandsJson = ctx.getNodeParameter('commands', itemIndex) as string; - const projectId = ctx.getNodeParameter('project', itemIndex, undefined, { - extractValue: true, - }) as number; - const sections = await getSectionIds(ctx, projectId); - const commands: Command[] = jsonParse(commandsJson); - const tempIdMapping = new Map(); + const name = ctx.getNodeParameter('name', itemIndex); + assertParamIsString('name', name, ctx.getNode()); - for (let i = 0; i < commands.length; i++) { - const command = commands[i]; - this.enrichUUID(command); - this.enrichSection(command, sections); - this.enrichProjectId(command, projectId); - this.enrichTempId(command, tempIdMapping, projectId); + const options = ctx.getNodeParameter('projectOptions', itemIndex) as IDataObject; + validateNodeParameters( + options, + { + color: { type: 'string' }, + is_favorite: { type: 'boolean' }, + parent_id: { type: 'string' }, + view_style: { type: 'string' }, + }, + ctx.getNode(), + ); + + 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); + assertValidTodoistId('projectId', id, ctx.getNode()); + + 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); + assertValidTodoistId('projectId', id, ctx.getNode()); + + 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); + assertValidTodoistId('projectId', id, ctx.getNode()); + + const updateFields = ctx.getNodeParameter('projectUpdateFields', itemIndex) as IDataObject; + validateNodeParameters( + updateFields, + { + name: { type: 'string' }, + color: { type: 'string' }, + is_favorite: { type: 'boolean' }, + view_style: { type: 'string' }, + }, + ctx.getNode(), + ); + + 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); + assertValidTodoistId('projectId', id, ctx.getNode()); + + 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); + assertValidTodoistId('projectId', id, ctx.getNode()); + + 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); + assertValidTodoistId('projectId', id, ctx.getNode()); + + 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); + assertParamIsString('sectionName', name, ctx.getNode()); + + const projectId = ctx.getNodeParameter('sectionProject', itemIndex, undefined, { + extractValue: true, + }); + assertValidTodoistId('sectionProject', projectId, ctx.getNode()); + + const options = ctx.getNodeParameter('sectionOptions', itemIndex) as IDataObject; + validateNodeParameters( + options, + { + order: { type: 'number' }, + }, + ctx.getNode(), + ); + + 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); + assertValidTodoistId('sectionId', id, ctx.getNode()); + + 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); + assertValidTodoistId('sectionId', id, ctx.getNode()); + + 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) { + assertValidTodoistId('project_id', filters.project_id, ctx.getNode()); + 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); + assertValidTodoistId('sectionId', id, ctx.getNode()); + + const updateFields = ctx.getNodeParameter('sectionUpdateFields', itemIndex) as IDataObject; + validateNodeParameters( + updateFields, + { + name: { type: 'string' }, + }, + ctx.getNode(), + ); + + 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); + assertValidTodoistId('commentTaskId', taskId, ctx.getNode()); + + const content = ctx.getNodeParameter('commentContent', itemIndex); + assertParamIsString('commentContent', content, ctx.getNode()); + + 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); + assertValidTodoistId('commentId', id, ctx.getNode()); + + 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); + assertValidTodoistId('commentId', id, ctx.getNode()); + + 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) { + assertValidTodoistId('task_id', filters.task_id, ctx.getNode()); + qs.task_id = filters.task_id; + } + + if (filters.project_id) { + assertValidTodoistId('project_id', filters.project_id, ctx.getNode()); + 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); + assertValidTodoistId('commentId', id, ctx.getNode()); + + const updateFields = ctx.getNodeParameter('commentUpdateFields', itemIndex) as IDataObject; + validateNodeParameters( + updateFields, + { + content: { type: 'string' }, + }, + ctx.getNode(), + ); + + 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); + assertParamIsString('labelName', name, ctx.getNode()); + + const options = ctx.getNodeParameter('labelOptions', itemIndex) as IDataObject; + validateNodeParameters( + options, + { + color: { type: 'string' }, + order: { type: 'number' }, + is_favorite: { type: 'boolean' }, + }, + ctx.getNode(), + ); + + 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); + assertValidTodoistId('labelId', id, ctx.getNode()); + + 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); + assertValidTodoistId('labelId', id, ctx.getNode()); + + 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); + assertValidTodoistId('labelId', id, ctx.getNode()); + + const updateFields = ctx.getNodeParameter('labelUpdateFields', itemIndex) as IDataObject; + validateNodeParameters( + updateFields, + { + name: { type: 'string' }, + color: { type: 'string' }, + order: { type: 'number' }, + is_favorite: { type: 'boolean' }, + }, + ctx.getNode(), + ); + + 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); + assertParamIsString('text', text, ctx.getNode()); + + const options = ctx.getNodeParameter('options', itemIndex, {}) as IDataObject; + validateNodeParameters( + options, + { + note: { type: 'string' }, + reminder: { type: 'string' }, + auto_reminder: { type: 'boolean' }, + }, + ctx.getNode(), + ); + + 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, + }; + } +} + +// Reminder Handlers +export class ReminderCreateHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const itemId = ctx.getNodeParameter('itemId', itemIndex); + assertValidTodoistId('itemId', itemId, ctx.getNode()); + + const dueDateType = ctx.getNodeParameter('dueDateType', itemIndex) as string; + assertParamIsString('dueDateType', dueDateType, ctx.getNode()); + + const due: IDataObject = {}; + + if (dueDateType === 'natural_language') { + const naturalLanguageRep = ctx.getNodeParameter('natural_language_representation', itemIndex); + assertParamIsString('natural_language_representation', naturalLanguageRep, ctx.getNode()); + due.string = naturalLanguageRep; + } else if (dueDateType === 'full_day') { + const date = ctx.getNodeParameter('date', itemIndex); + assertParamIsString('date', date, ctx.getNode()); + due.date = date; + } else if (dueDateType === 'floating_time') { + const datetime = ctx.getNodeParameter('datetime', itemIndex); + assertParamIsString('datetime', datetime, ctx.getNode()); + due.datetime = datetime; + } else if (dueDateType === 'fixed_timezone') { + const datetime = ctx.getNodeParameter('datetime', itemIndex); + const timezone = ctx.getNodeParameter('timezone', itemIndex); + assertParamIsString('datetime', datetime, ctx.getNode()); + assertParamIsString('timezone', timezone, ctx.getNode()); + due.datetime = datetime; + due.timezone = timezone; + } + + const options = ctx.getNodeParameter('reminderOptions', itemIndex) as IDataObject; + validateNodeParameters( + options, + { + type: { type: 'string' }, + minute_offset: { type: 'number' }, + notify_uid: { type: 'string' }, + }, + ctx.getNode(), + ); + const body: SyncRequest = { - commands, - temp_id_mapping: this.convertToObject(tempIdMapping), + commands: [ + { + type: CommandTypes.REMINDER_ADD, + uuid: uuid(), + temp_id: uuid(), + args: { + item_id: itemId, + due, + ...options, + }, + }, + ], }; await todoistSyncRequest.call(ctx, body); - return { success: true }; } +} - private convertToObject(map: Map) { - return Array.from(map.entries()).reduce((o, [key, value]) => { - o[key] = value; - return o; - }, {} as IDataObject); - } +export class ReminderUpdateHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('reminderId', itemIndex); + assertValidTodoistId('reminderId', id, ctx.getNode()); - private enrichUUID(command: Command) { - command.uuid = uuid(); - } + const updateFields = ctx.getNodeParameter('reminderUpdateFields', itemIndex) as IDataObject; + validateNodeParameters( + updateFields, + { + due: { type: 'object' }, + type: { type: 'string' }, + minute_offset: { type: 'number' }, + notify_uid: { type: 'string' }, + }, + ctx.getNode(), + ); - private enrichSection(command: Command, sections: Map) { - if (command.args?.section !== undefined) { - const sectionId = sections.get(command.args.section); - if (sectionId) { - command.args.section_id = sectionId; - } else { - throw new ApplicationError( - 'Section ' + command.args.section + " doesn't exist on Todoist", - { level: 'warning' }, - ); - } - } - } + const body: SyncRequest = { + commands: [ + { + type: CommandTypes.REMINDER_UPDATE, + uuid: uuid(), + args: { + id, + ...updateFields, + }, + }, + ], + }; - private enrichProjectId(command: Command, projectId: number) { - if (this.requiresProjectId(command)) { - command.args.project_id = projectId; - } - } - - private requiresProjectId(command: Command) { - return command.type === CommandTypes.ITEM_ADD; - } - - private enrichTempId(command: Command, tempIdMapping: Map, projectId: number) { - if (this.requiresTempId(command)) { - command.temp_id = uuid(); - tempIdMapping.set(command.temp_id, projectId as unknown as string); - } - } - - private requiresTempId(command: Command) { - return command.type === CommandTypes.ITEM_ADD; + await todoistSyncRequest.call(ctx, body); + return { success: true }; + } +} + +export class ReminderDeleteHandler implements OperationHandler { + async handleOperation(ctx: Context, itemIndex: number): Promise { + const id = ctx.getNodeParameter('reminderId', itemIndex); + assertValidTodoistId('reminderId', id, ctx.getNode()); + + const body: SyncRequest = { + commands: [ + { + type: CommandTypes.REMINDER_DELETE, + uuid: uuid(), + args: { + id, + }, + }, + ], + }; + + await todoistSyncRequest.call(ctx, body); + return { success: true }; + } +} + +export class ReminderGetAllHandler implements OperationHandler { + async handleOperation(ctx: Context, _itemIndex: number): Promise { + const syncData = await todoistSyncRequest.call(ctx, { + sync_token: '*', + resource_types: ['reminders'], + }); + + return { + data: syncData.reminders || [], + }; } } diff --git a/packages/nodes-base/nodes/Todoist/v2/Service.ts b/packages/nodes-base/nodes/Todoist/v2/Service.ts index cd6fb5f1b9..ae6dbb5186 100644 --- a/packages/nodes-base/nodes/Todoist/v2/Service.ts +++ b/packages/nodes-base/nodes/Todoist/v2/Service.ts @@ -7,16 +7,48 @@ 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, + // Reminder handlers + ReminderCreateHandler, + ReminderDeleteHandler, + ReminderGetAllHandler, + ReminderUpdateHandler, } 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); @@ -31,20 +63,156 @@ export class TodoistService implements Service { reopen: new ReopenHandler(), 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(), + }; + + private reminderHandlers = { + create: new ReminderCreateHandler(), + delete: new ReminderDeleteHandler(), + getAll: new ReminderGetAllHandler(), + update: new ReminderUpdateHandler(), + }; + + 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); + } + + async executeReminder( + ctx: Context, + operation: ReminderOperationType, + itemIndex: number, + ): Promise { + return await this.reminderHandlers[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', + '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; + +const REMINDER_OPERATIONS = ['create', 'delete', '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]; +export type ReminderOperationType = (typeof REMINDER_OPERATIONS)[number]; + +// Type guards using the same arrays +export function isTaskOperationType(operation: string): operation is TaskOperationType { + return TASK_OPERATIONS.includes(operation as TaskOperationType); +} + +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 function isReminderOperationType(operation: string): operation is ReminderOperationType { + return REMINDER_OPERATIONS.includes(operation as ReminderOperationType); +} export interface Section { name: string; @@ -52,7 +220,36 @@ 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; + executeReminder( + ctx: Context, + operation: ReminderOperationType, + 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..e709ea7d1b 100644 --- a/packages/nodes-base/nodes/Todoist/v2/TodoistV2.node.ts +++ b/packages/nodes-base/nodes/Todoist/v2/TodoistV2.node.ts @@ -9,26 +9,43 @@ import { type INodeTypeBaseDescription, type INodeTypeDescription, NodeConnectionTypes, + NodeOperationError, } from 'n8n-workflow'; -import type { OperationType, TodoistProjectType } from './Service'; -import { TodoistService } from './Service'; +import type { TodoistProjectType } from './Service'; +import { + TodoistService, + isTaskOperationType, + isProjectOperationType, + isSectionOperationType, + isCommentOperationType, + isLabelOperationType, + isReminderOperationType, +} 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 +103,38 @@ 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', + }, + { + name: 'Reminder', + value: 'reminder', + description: 'Reminder resource', + }, ], default: 'task', required: true, @@ -144,17 +187,18 @@ 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', description: 'Reopen a task', action: 'Reopen a task', }, - // { - // name: 'Sync', - // value: 'sync', - // description: 'Sync a project', - // }, { name: 'Update', value: 'update', @@ -164,6 +208,248 @@ 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', + }, + // Reminder operations + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + required: true, + displayOptions: { + show: { + resource: ['reminder'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new reminder', + action: 'Create a reminder', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a reminder', + action: 'Delete a reminder', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Get many reminders', + action: 'Get many reminders', + }, + { + name: 'Update', + value: 'update', + description: 'Update a reminder', + action: 'Update a reminder', + }, + ], + default: 'create', + }, { displayName: 'Task ID', name: 'taskId', @@ -204,7 +490,7 @@ const versionDescription: INodeTypeDescription = { displayOptions: { show: { resource: ['task'], - operation: ['create', 'move', 'sync'], + operation: ['create', 'move'], }, }, description: 'The destination project. Choose from the list, or specify an ID.', @@ -271,7 +557,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: { @@ -305,18 +592,60 @@ const versionDescription: INodeTypeDescription = { description: 'Task content', }, { - displayName: 'Sync Commands', - name: 'commands', + displayName: 'Text', + name: 'text', type: 'string', + typeOptions: { + rows: 3, + }, displayOptions: { show: { resource: ['task'], - operation: ['sync'], + operation: ['quickAdd'], }, }, - default: '[]', - hint: 'See docs for possible commands: https://developer.todoist.com/sync/v8/#sync', - description: 'Sync body', + 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: 'Additional Fields', @@ -396,6 +725,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 +955,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,6 +977,810 @@ 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', + }, + ], + }, + // 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', + }, + ], + }, + // Reminder fields + { + displayName: 'Reminder ID', + name: 'reminderId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['reminder'], + operation: ['delete', 'update'], + }, + }, + }, + { + displayName: 'Task ID', + name: 'itemId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: ['reminder'], + operation: ['create'], + }, + }, + description: 'The ID of the task to attach reminder to', + }, + { + displayName: 'Due Date Type', + name: 'dueDateType', + type: 'options', + options: [ + { + name: 'Natural Language', + value: 'natural_language', + description: 'Human-readable date and time (e.g., "tomorrow 2pm")', + }, + { + name: 'Full-Day Date', + value: 'full_day', + description: 'Date without specific time (floating)', + }, + { + name: 'Floating Date with Time', + value: 'floating_time', + description: 'Date and time without timezone', + }, + { + name: 'Fixed Timezone Date with Time', + value: 'fixed_timezone', + description: 'Date and time with specific timezone', + }, + ], + default: 'natural_language', + required: true, + displayOptions: { + show: { + resource: ['reminder'], + operation: ['create'], + }, + }, + description: 'How to specify when the reminder should trigger', + }, + { + displayName: 'Natural Language Representation', + name: 'natural_language_representation', + type: 'string', + default: '', + placeholder: 'e.g., "tomorrow 2pm", "monday 10:45am"', + required: true, + displayOptions: { + show: { + resource: ['reminder'], + operation: ['create'], + dueDateType: ['natural_language'], + }, + }, + description: 'Human-readable date and time', + }, + { + displayName: 'Date', + name: 'date', + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + required: true, + displayOptions: { + show: { + resource: ['reminder'], + operation: ['create'], + dueDateType: ['full_day'], + }, + }, + description: 'Full-day date in YYYY-MM-DD format', + }, + { + displayName: 'Date Time', + name: 'datetime', + type: 'dateTime', + default: '', + required: true, + displayOptions: { + show: { + resource: ['reminder'], + operation: ['create'], + dueDateType: ['floating_time'], + }, + }, + description: 'Floating date and time (no timezone)', + }, + { + displayName: 'Date Time', + name: 'datetime', + type: 'dateTime', + default: '', + required: true, + displayOptions: { + show: { + resource: ['reminder'], + operation: ['create'], + dueDateType: ['fixed_timezone'], + }, + }, + description: 'Date and time with timezone', + }, + { + displayName: 'Timezone', + name: 'timezone', + type: 'string', + default: '', + placeholder: 'e.g., "America/New_York"', + required: true, + displayOptions: { + show: { + resource: ['reminder'], + operation: ['create'], + dueDateType: ['fixed_timezone'], + }, + }, + description: 'Timezone for the fixed timezone date', + }, + { + displayName: 'Additional Fields', + name: 'reminderOptions', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['reminder'], + operation: ['create'], + }, + }, + options: [ + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Absolute', + value: 'absolute', + }, + { + name: 'Relative', + value: 'relative', + }, + ], + default: 'absolute', + description: 'The reminder type', + }, + { + displayName: 'Minute Offset', + name: 'minute_offset', + type: 'number', + default: 0, + description: 'Minutes before the task due date', + }, + { + displayName: 'Notify User ID', + name: 'notify_uid', + type: 'string', + default: '', + description: 'User ID to notify (for shared tasks)', + }, + ], + }, + { + displayName: 'Update Fields', + name: 'reminderUpdateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: ['reminder'], + operation: ['update'], + }, + }, + options: [ + { + displayName: 'Due', + name: 'due', + type: 'collection', + placeholder: 'Add Due Date Option', + default: {}, + options: [ + { + displayName: 'Natural Language', + name: 'string', + type: 'string', + default: '', + placeholder: 'e.g., "tomorrow 2pm", "monday 10:45am"', + description: 'Human-readable date and time', + }, + { + displayName: 'Date', + name: 'date', + type: 'string', + default: '', + placeholder: 'YYYY-MM-DD', + description: 'Specific date in YYYY-MM-DD format', + }, + { + displayName: 'Date Time', + name: 'datetime', + type: 'dateTime', + default: '', + description: 'Specific date and time', + }, + { + displayName: 'Timezone', + name: 'timezone', + type: 'string', + default: '', + placeholder: 'e.g., "America/New_York"', + description: 'Timezone for the reminder', + }, + ], + description: 'When the reminder should trigger', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Absolute', + value: 'absolute', + }, + { + name: 'Relative', + value: 'relative', + }, + ], + default: 'absolute', + description: 'The reminder type', + }, + { + displayName: 'Minute Offset', + name: 'minute_offset', + type: 'number', + default: 0, + description: 'Minutes before the task due date', + }, + { + displayName: 'Notify User ID', + name: 'notify_uid', + type: 'string', + default: '', + description: 'User ID to notify (for shared tasks)', + }, ], }, ], @@ -737,11 +1925,57 @@ 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); for (let i = 0; i < length; i++) { try { if (resource === 'task') { - responseData = await service.execute(this, operation, i); + if (!isTaskOperationType(operation)) { + throw new NodeOperationError( + this.getNode(), + `Invalid operation '${operation}' for task resource`, + ); + } + 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); + } else if (resource === 'reminder') { + if (!isReminderOperationType(operation)) { + throw new NodeOperationError( + this.getNode(), + `Invalid operation '${operation}' for reminder resource`, + ); + } + responseData = await service.executeReminder(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..83c866267b --- /dev/null +++ b/packages/nodes-base/nodes/Todoist/v2/test/OperationHandler.test.ts @@ -0,0 +1,1180 @@ +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, + 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, _idx?: number, defaultValue?: any) => + key in params ? params[key] : defaultValue, + ), + 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('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..ee0a28b9f0 --- /dev/null +++ b/packages/nodes-base/nodes/Todoist/v2/test/TodoistV2.node.test.ts @@ -0,0 +1,351 @@ +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); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + 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/workflow/src/node-parameters/parameter-type-validation.ts b/packages/workflow/src/node-parameters/parameter-type-validation.ts index d2c40a4f4a..f82b257450 100644 --- a/packages/workflow/src/node-parameters/parameter-type-validation.ts +++ b/packages/workflow/src/node-parameters/parameter-type-validation.ts @@ -60,6 +60,25 @@ export function assertParamIsBoolean( assertParamIsType(parameterName, value, 'boolean', node); } +type TypeofMap = { + string: string; + number: number; + boolean: boolean; +}; + +export function assertParamIsOfAnyTypes>( + parameterName: string, + value: unknown, + types: T, + node: INode, +): asserts value is TypeofMap[T[number]] { + const isValid = types.some((type) => typeof value === type); + if (!isValid) { + const typeList = types.join(' or '); + assertUserInput(false, `Parameter "${parameterName}" must be ${typeList}`, node); + } +} + export function assertParamIsArray( parameterName: string, value: unknown, diff --git a/packages/workflow/test/node-parameters/parameter-type-validation.test.ts b/packages/workflow/test/node-parameters/parameter-type-validation.test.ts index 6e8d3d1d47..ab50708cc2 100644 --- a/packages/workflow/test/node-parameters/parameter-type-validation.test.ts +++ b/packages/workflow/test/node-parameters/parameter-type-validation.test.ts @@ -4,6 +4,7 @@ import { assertParamIsNumber, assertParamIsBoolean, assertParamIsArray, + assertParamIsOfAnyTypes, } from '../../src/node-parameters/parameter-type-validation'; import type { INode } from '../../src/interfaces'; @@ -437,6 +438,151 @@ describe('Type assertion functions', () => { }); }); + describe('assertParamIsOfAnyTypes', () => { + it('should pass for string value when string is in types array', () => { + expect(() => + assertParamIsOfAnyTypes('testParam', 'hello', ['string'], mockNode), + ).not.toThrow(); + }); + + it('should pass for number value when number is in types array', () => { + expect(() => assertParamIsOfAnyTypes('testParam', 42, ['number'], mockNode)).not.toThrow(); + }); + + it('should pass for boolean value when boolean is in types array', () => { + expect(() => assertParamIsOfAnyTypes('testParam', true, ['boolean'], mockNode)).not.toThrow(); + expect(() => + assertParamIsOfAnyTypes('testParam', false, ['boolean'], mockNode), + ).not.toThrow(); + }); + + it('should pass for string when multiple types include string', () => { + expect(() => + assertParamIsOfAnyTypes('testParam', 'hello', ['string', 'number'], mockNode), + ).not.toThrow(); + }); + + it('should pass for number when multiple types include number', () => { + expect(() => + assertParamIsOfAnyTypes('testParam', 42, ['string', 'number'], mockNode), + ).not.toThrow(); + }); + + it('should pass for boolean when multiple types include boolean', () => { + expect(() => + assertParamIsOfAnyTypes('testParam', true, ['string', 'boolean'], mockNode), + ).not.toThrow(); + }); + + it('should pass for value matching any of three types', () => { + expect(() => + assertParamIsOfAnyTypes('testParam', 'test', ['string', 'number', 'boolean'], mockNode), + ).not.toThrow(); + expect(() => + assertParamIsOfAnyTypes('testParam', 123, ['string', 'number', 'boolean'], mockNode), + ).not.toThrow(); + expect(() => + assertParamIsOfAnyTypes('testParam', false, ['string', 'number', 'boolean'], mockNode), + ).not.toThrow(); + }); + + it('should throw for string when types array does not include string', () => { + expect(() => assertParamIsOfAnyTypes('testParam', 'hello', ['number'], mockNode)).toThrow( + 'Parameter "testParam" must be number', + ); + }); + + it('should throw for number when types array does not include number', () => { + expect(() => assertParamIsOfAnyTypes('testParam', 42, ['string'], mockNode)).toThrow( + 'Parameter "testParam" must be string', + ); + }); + + it('should throw for boolean when types array does not include boolean', () => { + expect(() => assertParamIsOfAnyTypes('testParam', true, ['string'], mockNode)).toThrow( + 'Parameter "testParam" must be string', + ); + }); + + it('should throw for value that matches none of multiple types', () => { + expect(() => + assertParamIsOfAnyTypes('testParam', 'hello', ['number', 'boolean'], mockNode), + ).toThrow('Parameter "testParam" must be number or boolean'); + }); + + it('should throw for null value', () => { + expect(() => assertParamIsOfAnyTypes('testParam', null, ['string'], mockNode)).toThrow( + 'Parameter "testParam" must be string', + ); + expect(() => + assertParamIsOfAnyTypes('testParam', null, ['string', 'number'], mockNode), + ).toThrow('Parameter "testParam" must be string or number'); + }); + + it('should throw for undefined value', () => { + expect(() => assertParamIsOfAnyTypes('testParam', undefined, ['string'], mockNode)).toThrow( + 'Parameter "testParam" must be string', + ); + expect(() => + assertParamIsOfAnyTypes('testParam', undefined, ['boolean', 'number'], mockNode), + ).toThrow('Parameter "testParam" must be boolean or number'); + }); + + it('should throw for object when primitive types are expected', () => { + expect(() => + assertParamIsOfAnyTypes('testParam', {}, ['string', 'number'], mockNode), + ).toThrow('Parameter "testParam" must be string or number'); + expect(() => assertParamIsOfAnyTypes('testParam', [], ['boolean'], mockNode)).toThrow( + 'Parameter "testParam" must be boolean', + ); + }); + + it('should handle special number values correctly', () => { + expect(() => assertParamIsOfAnyTypes('testParam', NaN, ['number'], mockNode)).not.toThrow(); + expect(() => + assertParamIsOfAnyTypes('testParam', Infinity, ['number'], mockNode), + ).not.toThrow(); + expect(() => + assertParamIsOfAnyTypes('testParam', -Infinity, ['number'], mockNode), + ).not.toThrow(); + }); + + it('should handle empty string correctly', () => { + expect(() => assertParamIsOfAnyTypes('testParam', '', ['string'], mockNode)).not.toThrow(); + }); + + it('should handle zero correctly', () => { + expect(() => assertParamIsOfAnyTypes('testParam', 0, ['number'], mockNode)).not.toThrow(); + }); + + it('should format error message correctly for single type', () => { + expect(() => assertParamIsOfAnyTypes('myParam', 123, ['string'], mockNode)).toThrow( + 'Parameter "myParam" must be string', + ); + }); + + it('should format error message correctly for two types', () => { + expect(() => + assertParamIsOfAnyTypes('myParam', 'test', ['number', 'boolean'], mockNode), + ).toThrow('Parameter "myParam" must be number or boolean'); + }); + + it('should format error message correctly for three types', () => { + expect(() => + assertParamIsOfAnyTypes('myParam', {}, ['string', 'number', 'boolean'], mockNode), + ).toThrow('Parameter "myParam" must be string or number or boolean'); + }); + + it('should handle readonly array types correctly', () => { + const types = ['string', 'number'] as const; + expect(() => assertParamIsOfAnyTypes('testParam', 'hello', types, mockNode)).not.toThrow(); + expect(() => assertParamIsOfAnyTypes('testParam', 42, types, mockNode)).not.toThrow(); + expect(() => assertParamIsOfAnyTypes('testParam', true, types, mockNode)).toThrow( + 'Parameter "testParam" must be string or number', + ); + }); + }); + describe('Edge cases and additional scenarios', () => { describe('validateNodeParameters edge cases', () => { it('should handle NaN values correctly', () => {