diff --git a/packages/nodes-base/nodes/Discord/test/v2/node/message/sendAndWait.test.ts b/packages/nodes-base/nodes/Discord/test/v2/node/message/sendAndWait.test.ts index 2e9fbdd4d11..a342cf29c5d 100644 --- a/packages/nodes-base/nodes/Discord/test/v2/node/message/sendAndWait.test.ts +++ b/packages/nodes-base/nodes/Discord/test/v2/node/message/sendAndWait.test.ts @@ -94,4 +94,51 @@ describe('Test DiscordV2, message => sendAndWait', () => { }, ); }); + + const setupErrorParameters = () => { + mockExecuteFunctions.getNodeParameter.mockImplementation((key: string) => { + if (key === 'operation') return SEND_AND_WAIT_OPERATION; + if (key === 'resource') return 'message'; + if (key === 'authentication') return 'botToken'; + if (key === 'message') return 'my message'; + if (key === 'subject') return ''; + if (key === 'approvalOptions.values') return {}; + if (key === 'responseType') return 'approval'; + if (key === 'sendTo') return 'channel'; + if (key === 'channelId') return 'channelID'; + if (key === 'options.limitWaitTime.values') return {}; + }); + mockExecuteFunctions.getInputData.mockReturnValue([{ json: { data: 'test' } }]); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&token=abc', + ); + }; + + it('should route API errors to error output and skip putExecutionToWait when continueOnFail is true', async () => { + setupErrorParameters(); + mockExecuteFunctions.continueOnFail.mockReturnValue(true); + + (transport.discordApiRequest as jest.Mock).mockRejectedValueOnce( + Object.assign(new Error('channel_not_found'), { description: 'channel_not_found' }), + ); + + const result = await discord.execute.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: { error: expect.any(String) } }]]); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + expect((result?.[0]?.[0] as { json: { error: string } }).json.error).toBeTruthy(); + }); + + it('should rethrow API errors and skip putExecutionToWait when continueOnFail is false', async () => { + setupErrorParameters(); + mockExecuteFunctions.continueOnFail.mockReturnValue(false); + + (transport.discordApiRequest as jest.Mock).mockRejectedValueOnce( + Object.assign(new Error('channel_not_found'), { description: 'channel_not_found' }), + ); + + await expect(discord.execute.call(mockExecuteFunctions)).rejects.toThrow(); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); }); diff --git a/packages/nodes-base/nodes/Discord/v2/actions/message/sendAndWait.operation.ts b/packages/nodes-base/nodes/Discord/v2/actions/message/sendAndWait.operation.ts index 41aab79226f..324660d65ee 100644 --- a/packages/nodes-base/nodes/Discord/v2/actions/message/sendAndWait.operation.ts +++ b/packages/nodes-base/nodes/Discord/v2/actions/message/sendAndWait.operation.ts @@ -5,11 +5,11 @@ import type { INodeProperties, } from 'n8n-workflow'; +import { configureWaitTillDate } from '../../../../../utils/sendAndWait/configureWaitTillDate.util'; import { getSendAndWaitProperties } from '../../../../../utils/sendAndWait/utils'; import { createSendAndWaitMessageBody, parseDiscordError, - prepareErrorData, sendDiscordMessage, } from '../../helpers/utils'; import { sendToProperties } from '../common.description'; @@ -45,11 +45,14 @@ export async function execute( const err = parseDiscordError.call(this, error, 0); if (this.continueOnFail()) { - return prepareErrorData.call(this, err, 0); + return [{ json: { error: err.message } }]; } throw err; } + const waitTill = configureWaitTillDate(this); + + await this.putExecutionToWait(waitTill); return items; } diff --git a/packages/nodes-base/nodes/Discord/v2/actions/router.ts b/packages/nodes-base/nodes/Discord/v2/actions/router.ts index d0dd30037f7..e42467ebf0d 100644 --- a/packages/nodes-base/nodes/Discord/v2/actions/router.ts +++ b/packages/nodes-base/nodes/Discord/v2/actions/router.ts @@ -6,7 +6,6 @@ import * as member from './member'; import * as message from './message'; import type { Discord } from './node.type'; import * as webhook from './webhook'; -import { configureWaitTillDate } from '../../../../utils/sendAndWait/configureWaitTillDate.util'; import { checkAccessToGuild } from '../helpers/utils'; import { discordApiRequest } from '../transport'; @@ -48,11 +47,7 @@ export async function router(this: IExecuteFunctions) { } as Discord; if (discord.resource === 'message' && discord.operation === SEND_AND_WAIT_OPERATION) { - returnData = await message[discord.operation].execute.call(this, guildId, userGuilds); - - const waitTill = configureWaitTillDate(this); - - await this.putExecutionToWait(waitTill); + returnData = await message.sendAndWait.execute.call(this, guildId, userGuilds); return [returnData]; } diff --git a/packages/nodes-base/nodes/EmailSend/test/v2/sendAndWait.operation.test.ts b/packages/nodes-base/nodes/EmailSend/test/v2/sendAndWait.operation.test.ts index 8f30ff36016..0fa36b26e3d 100644 --- a/packages/nodes-base/nodes/EmailSend/test/v2/sendAndWait.operation.test.ts +++ b/packages/nodes-base/nodes/EmailSend/test/v2/sendAndWait.operation.test.ts @@ -69,4 +69,56 @@ describe('Test EmailSendV2, email => sendAndWait', () => { to: 'to@mail.com', }); }); + + it('should route SMTP errors to error output when continueOnFail is true', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(SEND_AND_WAIT_OPERATION); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('from@mail.com'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('to@mail.com'); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + mockExecuteFunctions.getCredentials.mockResolvedValue({}); + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject'); + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc', + ); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval'); + mockExecuteFunctions.continueOnFail.mockReturnValue(true); + + transporter.sendMail.mockRejectedValueOnce(new Error('smtp_connection_refused')); + + const result = await emailSendV2.execute.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: { error: 'smtp_connection_refused' } }]]); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); + + it('should rethrow SMTP errors when continueOnFail is false', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(SEND_AND_WAIT_OPERATION); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('from@mail.com'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('to@mail.com'); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + mockExecuteFunctions.getCredentials.mockResolvedValue({}); + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject'); + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc', + ); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval'); + mockExecuteFunctions.continueOnFail.mockReturnValue(false); + + transporter.sendMail.mockRejectedValueOnce(new Error('smtp_connection_refused')); + + await expect(emailSendV2.execute.call(mockExecuteFunctions)).rejects.toThrow( + 'smtp_connection_refused', + ); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); }); diff --git a/packages/nodes-base/nodes/EmailSend/v2/sendAndWait.operation.ts b/packages/nodes-base/nodes/EmailSend/v2/sendAndWait.operation.ts index b28d40bf38a..a551959eda7 100644 --- a/packages/nodes-base/nodes/EmailSend/v2/sendAndWait.operation.ts +++ b/packages/nodes-base/nodes/EmailSend/v2/sendAndWait.operation.ts @@ -3,6 +3,7 @@ import type { IExecuteFunctions, INodeExecutionData, INodeProperties, + JsonObject, } from 'n8n-workflow'; import { fromEmailProperty, toEmailProperty } from './descriptions'; @@ -52,7 +53,14 @@ export async function execute(this: IExecuteFunctions): Promise sendAndWait', () => { text: 'my message\n\n\n**\n\n_This_ _message_ _was_ _sent_ _automatically_ _with_ __', }); }); + + it('should route API errors to error output when continueOnFail is true', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(SEND_AND_WAIT_OPERATION); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('spaceID'); + mockExecuteFunctions.getNode.mockReturnValue(mock()); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject'); + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc', + ); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval'); + mockExecuteFunctions.continueOnFail.mockReturnValue(true); + + (genericFunctions.googleApiRequest as jest.Mock).mockRejectedValueOnce( + new Error('space_not_found'), + ); + + const result = await googleChat.execute.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: { error: 'space_not_found' } }]]); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); + + it('should rethrow API errors when continueOnFail is false', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(SEND_AND_WAIT_OPERATION); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('spaceID'); + mockExecuteFunctions.getNode.mockReturnValue(mock()); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject'); + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc', + ); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval'); + mockExecuteFunctions.continueOnFail.mockReturnValue(false); + + (genericFunctions.googleApiRequest as jest.Mock).mockRejectedValueOnce( + new Error('space_not_found'), + ); + + await expect(googleChat.execute.call(mockExecuteFunctions)).rejects.toThrow('space_not_found'); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); }); diff --git a/packages/nodes-base/nodes/Google/Gmail/test/v2/sendAndWait.operation.test.ts b/packages/nodes-base/nodes/Google/Gmail/test/v2/sendAndWait.operation.test.ts new file mode 100644 index 00000000000..b79c5e59785 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Gmail/test/v2/sendAndWait.operation.test.ts @@ -0,0 +1,83 @@ +import type { MockProxy } from 'jest-mock-extended'; +import { mock } from 'jest-mock-extended'; +import { + type INode, + type INodeTypeBaseDescription, + SEND_AND_WAIT_OPERATION, + type IExecuteFunctions, +} from 'n8n-workflow'; + +import * as genericFunctions from '../../GenericFunctions'; +import { GmailV2 } from '../../v2/GmailV2.node'; + +jest.mock('../../GenericFunctions', () => { + const originalModule = jest.requireActual('../../GenericFunctions'); + return { + ...originalModule, + googleApiRequest: jest.fn(), + }; +}); + +describe('Test GmailV2, message => sendAndWait', () => { + let gmail: GmailV2; + let mockExecuteFunctions: MockProxy; + + beforeEach(() => { + gmail = new GmailV2(mock()); + mockExecuteFunctions = mock(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const setupParameters = () => { + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); // resource + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(SEND_AND_WAIT_OPERATION); // operation + mockExecuteFunctions.getNode.mockReturnValue(mock({ typeVersion: 2.2 })); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + + // createEmail + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('to@mail.com'); // sendTo + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message'); // message + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject'); // subject + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc', + ); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); // approvalOptions + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); // options + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval'); // responseType + + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); // configureWaitTillDate + }; + + it('should route API errors to error output when continueOnFail is true', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + setupParameters(); + mockExecuteFunctions.continueOnFail.mockReturnValue(true); + + (genericFunctions.googleApiRequest as jest.Mock).mockRejectedValueOnce( + new Error('invalid_recipient'), + ); + + const result = await gmail.execute.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: { error: 'invalid_recipient' } }]]); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); + + it('should rethrow API errors when continueOnFail is false', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + setupParameters(); + mockExecuteFunctions.continueOnFail.mockReturnValue(false); + + (genericFunctions.googleApiRequest as jest.Mock).mockRejectedValueOnce( + new Error('invalid_recipient'), + ); + + await expect(gmail.execute.call(mockExecuteFunctions)).rejects.toThrow('invalid_recipient'); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts b/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts index e1d2dbb0f1b..61cfda6170d 100644 --- a/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts +++ b/packages/nodes-base/nodes/Google/Gmail/v2/GmailV2.node.ts @@ -5,6 +5,7 @@ import type { INodeType, INodeTypeBaseDescription, INodeTypeDescription, + JsonObject, } from 'n8n-workflow'; import { NodeConnectionTypes, NodeOperationError, SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; @@ -181,9 +182,16 @@ export class GmailV2 implements INodeType { if (resource === 'message' && operation === SEND_AND_WAIT_OPERATION) { const email: IEmail = createEmail(this); - await googleApiRequest.call(this, 'POST', '/gmail/v1/users/me/messages/send', { - raw: await encodeEmail(email), - }); + try { + await googleApiRequest.call(this, 'POST', '/gmail/v1/users/me/messages/send', { + raw: await encodeEmail(email), + }); + } catch (error) { + if (this.continueOnFail()) { + return [[{ json: { error: (error as JsonObject).message } }]]; + } + throw error; + } const waitTill = configureWaitTillDate(this); diff --git a/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/node/message/sendAndWait.test.ts b/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/node/message/sendAndWait.test.ts index 06e10558529..c6dd5cb9083 100644 --- a/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/node/message/sendAndWait.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/node/message/sendAndWait.test.ts @@ -75,4 +75,58 @@ describe('Test MicrosoftOutlookV2, message => sendAndWait', () => { }, }); }); + + it('should route API errors to error output when continueOnFail is true', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(SEND_AND_WAIT_OPERATION); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my@outlook.com'); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject'); + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc', + ); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval'); + mockExecuteFunctions.continueOnFail.mockReturnValue(true); + + (transport.microsoftApiRequest as jest.Mock).mockRejectedValueOnce( + new Error('recipient_not_found'), + ); + + const result = await microsoftOutlook.execute.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: { error: 'recipient_not_found' } }]]); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); + + it('should rethrow API errors when continueOnFail is false', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(SEND_AND_WAIT_OPERATION); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my@outlook.com'); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject'); + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc', + ); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval'); + mockExecuteFunctions.continueOnFail.mockReturnValue(false); + + (transport.microsoftApiRequest as jest.Mock).mockRejectedValueOnce( + new Error('recipient_not_found'), + ); + + await expect(microsoftOutlook.execute.call(mockExecuteFunctions)).rejects.toThrow( + 'recipient_not_found', + ); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); }); diff --git a/packages/nodes-base/nodes/Microsoft/Outlook/v2/actions/router.ts b/packages/nodes-base/nodes/Microsoft/Outlook/v2/actions/router.ts index edaf04d01dd..03e45220fa8 100644 --- a/packages/nodes-base/nodes/Microsoft/Outlook/v2/actions/router.ts +++ b/packages/nodes-base/nodes/Microsoft/Outlook/v2/actions/router.ts @@ -1,4 +1,4 @@ -import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import type { IExecuteFunctions, INodeExecutionData, JsonObject } from 'n8n-workflow'; import { NodeApiError, NodeOperationError, SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; import * as calendar from './calendar'; @@ -30,7 +30,14 @@ export async function router(this: IExecuteFunctions) { microsoftOutlook.resource === 'message' && microsoftOutlook.operation === SEND_AND_WAIT_OPERATION ) { - await message[microsoftOutlook.operation].execute.call(this, 0, items); + try { + await message[microsoftOutlook.operation].execute.call(this, 0, items); + } catch (error) { + if (this.continueOnFail()) { + return [[{ json: { error: (error as JsonObject).message } }]]; + } + throw error; + } const waitTill = configureWaitTillDate(this); diff --git a/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/chatMessage/sendAndWait.test.ts b/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/chatMessage/sendAndWait.test.ts index 15bc4079059..0ee02eba88d 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/chatMessage/sendAndWait.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/test/v2/node/chatMessage/sendAndWait.test.ts @@ -67,4 +67,60 @@ describe('Test MicrosoftTeamsV2, chatMessage => sendAndWait', () => { }, ); }); + + it('should route API errors to error output when continueOnFail is true', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getNodeParameter.mockImplementation((key: string) => { + if (key === 'operation') return SEND_AND_WAIT_OPERATION; + if (key === 'resource') return 'chatMessage'; + if (key === 'chatId') return 'chatID'; + if (key === 'message') return 'my message'; + if (key === 'subject') return ''; + if (key === 'approvalOptions.values') return {}; + if (key === 'responseType') return 'approval'; + if (key === 'options.limitWaitTime.values') return {}; + }); + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + mockExecuteFunctions.getNode.mockReturnValue(mock({ typeVersion: 2 })); + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc', + ); + mockExecuteFunctions.continueOnFail.mockReturnValue(true); + + (transport.microsoftApiRequest as jest.Mock).mockRejectedValueOnce(new Error('chat_not_found')); + + const result = await microsoftTeamsV2.execute.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: { error: 'chat_not_found' } }]]); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); + + it('should rethrow API errors when continueOnFail is false', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getNodeParameter.mockImplementation((key: string) => { + if (key === 'operation') return SEND_AND_WAIT_OPERATION; + if (key === 'resource') return 'chatMessage'; + if (key === 'chatId') return 'chatID'; + if (key === 'message') return 'my message'; + if (key === 'subject') return ''; + if (key === 'approvalOptions.values') return {}; + if (key === 'responseType') return 'approval'; + if (key === 'options.limitWaitTime.values') return {}; + }); + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + mockExecuteFunctions.getNode.mockReturnValue(mock({ typeVersion: 2 })); + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc', + ); + mockExecuteFunctions.continueOnFail.mockReturnValue(false); + + (transport.microsoftApiRequest as jest.Mock).mockRejectedValueOnce(new Error('chat_not_found')); + + await expect(microsoftTeamsV2.execute.call(mockExecuteFunctions)).rejects.toThrow( + 'chat_not_found', + ); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); }); diff --git a/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/router.ts b/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/router.ts index d7154f14a2c..7919d0c7a20 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/router.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/v2/actions/router.ts @@ -2,6 +2,7 @@ import { type IExecuteFunctions, type IDataObject, type INodeExecutionData, + type JsonObject, NodeOperationError, SEND_AND_WAIT_OPERATION, } from 'n8n-workflow'; @@ -33,7 +34,14 @@ export async function router(this: IExecuteFunctions): Promise sendAndWait', () => { text: 'my message\n\n_This message was sent automatically with _[n8n](https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=n8n-nodes-base.telegram_instanceId)', }); }); + + it('should route API errors to error output when continueOnFail is true', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(SEND_AND_WAIT_OPERATION); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false); + mockExecuteFunctions.getNode.mockReturnValue(mock()); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('chatID'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject'); + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc', + ); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval'); + mockExecuteFunctions.continueOnFail.mockReturnValue(true); + + (genericFunctions.apiRequest as jest.Mock).mockRejectedValueOnce(new Error('chat_not_found')); + + const result = await telegram.execute.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: { error: 'chat_not_found' } }]]); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); + + it('should rethrow API errors when continueOnFail is false', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(SEND_AND_WAIT_OPERATION); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false); + mockExecuteFunctions.getNode.mockReturnValue(mock()); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('chatID'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject'); + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc', + ); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval'); + mockExecuteFunctions.continueOnFail.mockReturnValue(false); + + (genericFunctions.apiRequest as jest.Mock).mockRejectedValueOnce(new Error('chat_not_found')); + + await expect(telegram.execute.call(mockExecuteFunctions)).rejects.toThrow('chat_not_found'); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); }); diff --git a/packages/nodes-base/nodes/WhatsApp/WhatsApp.node.ts b/packages/nodes-base/nodes/WhatsApp/WhatsApp.node.ts index 7a7cd34992c..951125b3fe2 100644 --- a/packages/nodes-base/nodes/WhatsApp/WhatsApp.node.ts +++ b/packages/nodes-base/nodes/WhatsApp/WhatsApp.node.ts @@ -78,29 +78,32 @@ export class WhatsApp implements INodeType { customOperations = { message: { async [SEND_AND_WAIT_OPERATION](this: IExecuteFunctions) { + const phoneNumberId = this.getNodeParameter('phoneNumberId', 0) as string; + + const recipientPhoneNumber = sanitizePhoneNumber( + this.getNodeParameter('recipientPhoneNumber', 0) as string, + ); + + const config = getSendAndWaitConfig(this); + const instanceId = this.getInstanceId(); + try { - const phoneNumberId = this.getNodeParameter('phoneNumberId', 0) as string; - - const recipientPhoneNumber = sanitizePhoneNumber( - this.getNodeParameter('recipientPhoneNumber', 0) as string, - ); - - const config = getSendAndWaitConfig(this); - const instanceId = this.getInstanceId(); - await this.helpers.httpRequestWithAuthentication.call( this, WHATSAPP_CREDENTIALS_TYPE, createMessage(config, phoneNumberId, recipientPhoneNumber, instanceId), ); - - const waitTill = configureWaitTillDate(this); - - await this.putExecutionToWait(waitTill); - return [this.getInputData()]; } catch (error) { - throw new NodeOperationError(this.getNode(), error); + if (this.continueOnFail()) { + return [[{ json: { error: (error as Error).message } }]]; + } + throw new NodeOperationError(this.getNode(), error as Error); } + + const waitTill = configureWaitTillDate(this); + + await this.putExecutionToWait(waitTill); + return [this.getInputData()]; }, }, }; diff --git a/packages/nodes-base/nodes/WhatsApp/tests/node/sendAndWait.test.ts b/packages/nodes-base/nodes/WhatsApp/tests/node/sendAndWait.test.ts index 720e155f892..dee3d059289 100644 --- a/packages/nodes-base/nodes/WhatsApp/tests/node/sendAndWait.test.ts +++ b/packages/nodes-base/nodes/WhatsApp/tests/node/sendAndWait.test.ts @@ -66,4 +66,64 @@ describe('Test WhatsApp Business Cloud, sendAndWait operation', () => { }, ); }); + + it('should route API errors to error output when continueOnFail is true', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getNodeParameter.mockImplementation((key: string) => { + if (key === 'phoneNumberId') return '11111'; + if (key === 'recipientPhoneNumber') return '22222'; + if (key === 'message') return 'my message'; + if (key === 'subject') return ''; + if (key === 'approvalOptions.values') return {}; + if (key === 'responseType') return 'approval'; + if (key === 'sendTo') return 'channel'; + if (key === 'channelId') return 'channelID'; + if (key === 'options.limitWaitTime.values') return {}; + }); + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc', + ); + mockExecuteFunctions.continueOnFail.mockReturnValue(true); + + (mockExecuteFunctions.helpers.httpRequestWithAuthentication as jest.Mock).mockRejectedValueOnce( + new Error('invalid_recipient'), + ); + + const result = await whatsApp.customOperations.message.sendAndWait.call(mockExecuteFunctions); + + expect(result).toEqual([[{ json: { error: 'invalid_recipient' } }]]); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); + + it('should throw NodeOperationError when continueOnFail is false', async () => { + const items = [{ json: { data: 'test' } }]; + mockExecuteFunctions.getNodeParameter.mockImplementation((key: string) => { + if (key === 'phoneNumberId') return '11111'; + if (key === 'recipientPhoneNumber') return '22222'; + if (key === 'message') return 'my message'; + if (key === 'subject') return ''; + if (key === 'approvalOptions.values') return {}; + if (key === 'responseType') return 'approval'; + if (key === 'sendTo') return 'channel'; + if (key === 'channelId') return 'channelID'; + if (key === 'options.limitWaitTime.values') return {}; + }); + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + mockExecuteFunctions.getSignedResumeUrl.mockReturnValue( + 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc', + ); + mockExecuteFunctions.continueOnFail.mockReturnValue(false); + + (mockExecuteFunctions.helpers.httpRequestWithAuthentication as jest.Mock).mockRejectedValueOnce( + new Error('invalid_recipient'), + ); + + await expect( + whatsApp.customOperations.message.sendAndWait.call(mockExecuteFunctions), + ).rejects.toThrow('invalid_recipient'); + expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled(); + }); });