From 01cc906ebdd1815fae2d5e8cc8576e9b4a39fbac Mon Sep 17 00:00:00 2001 From: Hammad Khan Date: Tue, 2 Jun 2026 14:03:28 +0500 Subject: [PATCH] fix(Zulip Node): Normalize multiOptions recipients when expression returns a string (#31492) Co-authored-by: Dawid Myslak --- .../nodes/Zulip/GenericFunctions.ts | 22 +++++++++ packages/nodes-base/nodes/Zulip/Zulip.node.ts | 4 +- .../nodes/Zulip/test/GenericFunctions.test.ts | 46 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 packages/nodes-base/nodes/Zulip/test/GenericFunctions.test.ts diff --git a/packages/nodes-base/nodes/Zulip/GenericFunctions.ts b/packages/nodes-base/nodes/Zulip/GenericFunctions.ts index 12ef28ab5b3..c5009558ba0 100644 --- a/packages/nodes-base/nodes/Zulip/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Zulip/GenericFunctions.ts @@ -56,6 +56,28 @@ export async function zulipApiRequest( } } +// A multiOptions parameter normally resolves to an array. When its value comes +// from an expression that is wrapped in surrounding text/whitespace, n8n +// switches to string interpolation and the array is coerced to a comma-joined +// string. Accept both shapes so the node degrades gracefully instead of +// throwing a low-level "join is not a function" TypeError. +export function toMultiOptionsCsv(value: unknown): string { + if (Array.isArray(value)) { + return value + .map((entry) => String(entry).trim()) + .filter((entry) => entry.length > 0) + .join(','); + } + if (typeof value === 'string') { + return value + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .join(','); + } + return ''; +} + export function validateJSON(json: string | undefined): any { let result; try { diff --git a/packages/nodes-base/nodes/Zulip/Zulip.node.ts b/packages/nodes-base/nodes/Zulip/Zulip.node.ts index 59ee8ac8aba..88e8ae1f843 100644 --- a/packages/nodes-base/nodes/Zulip/Zulip.node.ts +++ b/packages/nodes-base/nodes/Zulip/Zulip.node.ts @@ -10,7 +10,7 @@ import type { } from 'n8n-workflow'; import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; -import { validateJSON, zulipApiRequest } from './GenericFunctions'; +import { toMultiOptionsCsv, validateJSON, zulipApiRequest } from './GenericFunctions'; import { messageFields, messageOperations } from './MessageDescription'; import type { IMessage } from './MessageInterface'; import { streamFields, streamOperations } from './StreamDescription'; @@ -138,7 +138,7 @@ export class Zulip implements INodeType { if (resource === 'message') { //https://zulipchat.com/api/send-message if (operation === 'sendPrivate') { - const to = (this.getNodeParameter('to', i) as string[]).join(','); + const to = toMultiOptionsCsv(this.getNodeParameter('to', i)); const content = this.getNodeParameter('content', i) as string; const body: IMessage = { type: 'private', diff --git a/packages/nodes-base/nodes/Zulip/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Zulip/test/GenericFunctions.test.ts new file mode 100644 index 00000000000..bbd80dc33b3 --- /dev/null +++ b/packages/nodes-base/nodes/Zulip/test/GenericFunctions.test.ts @@ -0,0 +1,46 @@ +import { toMultiOptionsCsv } from '../GenericFunctions'; + +describe('Zulip > GenericFunctions', () => { + describe('toMultiOptionsCsv', () => { + it('joins array values', () => { + expect(toMultiOptionsCsv(['user1@example.com', 'user2@example.com'])).toBe( + 'user1@example.com,user2@example.com', + ); + }); + + it('trims entries inside an array (interpolated array elements)', () => { + expect(toMultiOptionsCsv(['user1@example.com ', ' user2@example.com'])).toBe( + 'user1@example.com,user2@example.com', + ); + }); + + it('coerces non-string array entries via String()', () => { + expect(toMultiOptionsCsv([1, 2, 3])).toBe('1,2,3'); + }); + + it('accepts a comma-joined string (the whitespace-expression coercion case)', () => { + expect(toMultiOptionsCsv('user1@example.com,user2@example.com ')).toBe( + 'user1@example.com,user2@example.com', + ); + }); + + it('trims whitespace around each entry in a comma-string', () => { + expect(toMultiOptionsCsv(' user1@example.com , user2@example.com ')).toBe( + 'user1@example.com,user2@example.com', + ); + }); + + it('drops empty entries', () => { + expect(toMultiOptionsCsv(['user1@example.com', '', ' ', 'user2@example.com'])).toBe( + 'user1@example.com,user2@example.com', + ); + }); + + it('returns an empty string for undefined/null/empty values', () => { + expect(toMultiOptionsCsv(undefined)).toBe(''); + expect(toMultiOptionsCsv(null)).toBe(''); + expect(toMultiOptionsCsv('')).toBe(''); + expect(toMultiOptionsCsv([])).toBe(''); + }); + }); +});