From a1a8e4d023f694ab1879bbfc32a417a023c7966b Mon Sep 17 00:00:00 2001 From: Dave Hampton Date: Fri, 7 Nov 2025 13:30:40 -0800 Subject: [PATCH 1/2] feat: add Zendesk document loader and credential integration Introduce a new Zendesk document loader and corresponding credential class to facilitate the extraction of articles from the Zendesk Knowledge Base. This includes configuration validation, error handling, and support for chunking large articles. Additionally, a new SVG icon for Zendesk is added, along with comprehensive tests to ensure functionality. --- .../credentials/ZendeskApi.credential.ts | 34 + .../nodes/documentloaders/Zendesk/Zendesk.ts | 465 +++++++++++++ .../nodes/documentloaders/Zendesk/zendesk.svg | 8 + .../documentloaders/Zendesk/zendesk.test.ts | 650 ++++++++++++++++++ 4 files changed, 1157 insertions(+) create mode 100644 packages/components/credentials/ZendeskApi.credential.ts create mode 100644 packages/components/nodes/documentloaders/Zendesk/Zendesk.ts create mode 100644 packages/components/nodes/documentloaders/Zendesk/zendesk.svg create mode 100644 packages/components/nodes/documentloaders/Zendesk/zendesk.test.ts diff --git a/packages/components/credentials/ZendeskApi.credential.ts b/packages/components/credentials/ZendeskApi.credential.ts new file mode 100644 index 00000000000..d2be833e2d5 --- /dev/null +++ b/packages/components/credentials/ZendeskApi.credential.ts @@ -0,0 +1,34 @@ +import { INodeParams, INodeCredential } from '../src/Interface' + +class ZendeskApi implements INodeCredential { + label: string + name: string + version: number + description: string + inputs: INodeParams[] + + constructor() { + this.label = 'Zendesk API' + this.name = 'zendeskApi' + this.version = 1.0 + this.description = + 'Refer to official guide on how to get API token from Zendesk' + this.inputs = [ + { + label: 'User Name', + name: 'user', + type: 'string', + placeholder: 'user@example.com' + }, + { + label: 'API Token', + name: 'token', + type: 'password', + placeholder: '' + } + ] + } +} + +module.exports = { credClass: ZendeskApi } + diff --git a/packages/components/nodes/documentloaders/Zendesk/Zendesk.ts b/packages/components/nodes/documentloaders/Zendesk/Zendesk.ts new file mode 100644 index 00000000000..d5ce4f9a701 --- /dev/null +++ b/packages/components/nodes/documentloaders/Zendesk/Zendesk.ts @@ -0,0 +1,465 @@ +import { omit } from 'lodash' +import axios from 'axios' +import { ICommonObject, IDocument, INode, INodeData, INodeParams, INodeOutputsValue } from '../../../src/Interface' +import { getCredentialData, getCredentialParam, handleEscapeCharacters } from '../../../src' + +interface ZendeskConfig { + zendeskDomain: string + user?: string + token?: string + brandId?: string + publishedArticlesOnly: boolean + locales: string[] + charsPerToken: number + api: { + protocol: string + helpCenterPath: string + articlesEndpoint: string + publicPath: string + } + defaultTitle: string + chunking: { + maxTokens: number + chunkSize: number + overlap: number + } +} + +interface Chunk { + content: string + index: number + tokenSize: number +} + +interface ZendeskArticle { + id: number + name?: string + title?: string + body?: string +} + +interface ZendeskArticlesResponse { + articles?: ZendeskArticle[] + next_page?: string +} + +class Zendesk_DocumentLoaders implements INode { + label: string + name: string + version: number + description: string + type: string + icon: string + category: string + baseClasses: string[] + credential: INodeParams + inputs: INodeParams[] + outputs: INodeOutputsValue[] + + constructor() { + this.label = 'Zendesk' + this.name = 'zendesk' + this.version = 1.0 + this.type = 'Document' + this.icon = 'zendesk.svg' + this.category = 'Document Loaders' + this.description = `Load articles from Zendesk Knowledge Base` + this.baseClasses = [this.type] + this.credential = { + label: 'Connect Credential', + name: 'credential', + type: 'credential', + description: 'Zendesk API Credential', + credentialNames: ['zendeskApi'] + } + this.inputs = [ + { + label: 'Zendesk Domain', + name: 'zendeskDomain', + type: 'string', + placeholder: 'example.zendesk.com', + description: 'Your Zendesk domain (e.g., example.zendesk.com)' + }, + { + label: 'Brand ID', + name: 'brandId', + type: 'string', + optional: true, + placeholder: '123456', + description: 'Optional brand ID to filter articles' + }, + { + label: 'Locale', + name: 'locale', + type: 'string', + placeholder: 'en-us', + description: 'Locale code for articles (e.g., en-us, en-gb)' + }, + { + label: 'Published Articles Only', + name: 'publishedArticlesOnly', + type: 'boolean', + default: true, + optional: true, + description: 'Only load published articles' + }, + { + label: 'Characters Per Token', + name: 'charsPerToken', + type: 'number', + default: 4, + optional: true, + description: 'Approximate characters per token for size estimation', + step: 1 + }, + { + label: 'Additional Metadata', + name: 'metadata', + type: 'json', + description: 'Additional metadata to be added to the extracted documents', + optional: true, + additionalParams: true + }, + { + label: 'Omit Metadata Keys', + name: 'omitMetadataKeys', + type: 'string', + rows: 4, + description: + 'Each document loader comes with a default set of metadata keys that are extracted from the document. You can use this field to omit some of the default metadata keys. The value should be a list of keys, seperated by comma. Use * to omit all metadata keys except the ones you specify in the Additional Metadata field', + placeholder: 'key1, key2, key3.nestedKey1', + optional: true, + additionalParams: true + } + ] + this.outputs = [ + { + label: 'Document', + name: 'document', + description: 'Array of document objects containing metadata and pageContent', + baseClasses: [...this.baseClasses, 'json'] + }, + { + label: 'Text', + name: 'text', + description: 'Concatenated string from pageContent of documents', + baseClasses: ['string', 'json'] + } + ] + } + + /** + * Validate configuration + */ + private validateConfig(config: Partial): void { + const errors: string[] = [] + + if (!config.zendeskDomain) { + errors.push('Zendesk domain is required') + } else if (!config.zendeskDomain.match(/^.+\.zendesk\.com$/)) { + errors.push('Zendesk domain must be a valid zendesk.com domain (e.g., example.zendesk.com)') + } + + if (!config.token) { + errors.push('Zendesk auth token is required') + } + + if (config.user && !config.user.includes('@')) { + errors.push('Zendesk auth user must be a valid email address') + } + + if (config.brandId && !/^\d+$/.test(config.brandId)) { + errors.push('Brand ID must be a numeric string') + } + + if (errors.length > 0) { + const errorMessage = 'Configuration validation failed:\n • ' + errors.join('\n • ') + throw new Error(errorMessage) + } + } + + /** + * Helper to fetch all articles with pagination + */ + private async fetchAllArticles( + locale: string, + brandId: string | undefined, + config: ZendeskConfig, + axiosHeaders: Record + ): Promise { + const allArticles: ZendeskArticle[] = [] + let page = 1 + let hasMore = true + const baseUri = `${config.api.protocol}${config.zendeskDomain}${config.api.helpCenterPath}` + + while (hasMore) { + let articlesUri = `${baseUri}${config.api.articlesEndpoint}?locale=${locale}&page=${page}` + + // Add status filter if publishedOnly is true + if (config.publishedArticlesOnly) { + articlesUri += '&status=published' + } + + if (brandId) { + articlesUri += `&brand_id=${brandId}` + } + + try { + const resp = await axios.get(articlesUri, { headers: axiosHeaders }) + const data = resp.data + + if (data.articles && data.articles.length > 0) { + allArticles.push(...data.articles) + page++ + hasMore = !!data.next_page + } else { + hasMore = false + } + } catch (error: any) { + if (error.response) { + const status = error.response.status + const statusText = error.response.statusText + + if (status === 401) { + throw new Error(`Authentication failed (${status}): Please check your Zendesk credentials`) + } else if (status === 403) { + throw new Error( + `Access forbidden (${status}): You don't have permission to access this Zendesk instance` + ) + } else if (status === 404) { + throw new Error(`Not found (${status}): The Zendesk URL or brand ID may be incorrect`) + } else if (status >= 500) { + throw new Error(`Zendesk server error (${status}): ${statusText}. Please try again later`) + } else { + throw new Error(`HTTP error (${status}): ${statusText}`) + } + } else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { + throw new Error( + `Network error: Cannot connect to Zendesk. Please check the domain: ${config.zendeskDomain}` + ) + } else { + throw new Error(`Request failed: ${error.message}`) + } + } + } + + return allArticles + } + + /** + * Build article URL from domain and article details + */ + private buildArticleUrl(config: ZendeskConfig, locale: string, articleId: number): string | null { + if (!config.zendeskDomain || !articleId) { + return null + } + + return `${config.api.protocol}${config.zendeskDomain}${config.api.publicPath}${locale}/articles/${articleId}` + } + + /** + * Chunk text content based on token limits + */ + private chunkContent(content: string, chunkSize: number, overlap: number, charsPerToken: number): Chunk[] { + const chunks: Chunk[] = [] + const contentLength = content.length + const chunkCharSize = chunkSize * charsPerToken + const overlapCharSize = overlap * charsPerToken + + let start = 0 + let chunkIndex = 0 + + while (start < contentLength) { + const end = Math.min(start + chunkCharSize, contentLength) + const chunk = content.substring(start, end) + + chunks.push({ + content: chunk, + index: chunkIndex, + tokenSize: Math.ceil(chunk.length / charsPerToken) + }) + + chunkIndex++ + + // Move start position, accounting for overlap + if (end >= contentLength) break + start = end - overlapCharSize + } + + return chunks + } + + /** + * Transform article to required format with chunking support + */ + private transformArticle(article: ZendeskArticle, config: ZendeskConfig, locale: string): IDocument[] { + const articleUrl = this.buildArticleUrl(config, locale, article.id) + const content = article.body || '' + const tokenSize = Math.ceil(content.length / config.charsPerToken) + const title = article.name || article.title || config.defaultTitle + const articleId = String(article.id) + + // If article is small enough, return as single document + if (tokenSize <= config.chunking.maxTokens) { + return [ + { + pageContent: content, + metadata: { + title: title, + url: articleUrl, + id: articleId + } + } + ] + } + + // Article needs chunking + const chunks = this.chunkContent( + content, + config.chunking.chunkSize, + config.chunking.overlap, + config.charsPerToken + ) + + return chunks.map((chunk) => ({ + pageContent: chunk.content, + metadata: { + title: title, + url: articleUrl, + id: `${articleId}-${chunk.index + 1}` + } + })) + } + + /** + * Extract all articles from Zendesk + */ + private async extractAllArticles(config: ZendeskConfig): Promise { + const allTransformedArticles: IDocument[] = [] + + // Setup authentication headers + let axiosHeaders: Record = {} + if (config.user && config.token) { + const authString = `${config.user}/token:${config.token}` + const encoded = Buffer.from(authString).toString('base64') + axiosHeaders = { + Authorization: `Basic ${encoded}` + } + } + + // Process each locale + for (const locale of config.locales) { + try { + const articles = await this.fetchAllArticles(locale, config.brandId || undefined, config, axiosHeaders) + // Transform each article to the required format + for (const article of articles) { + const transformedChunks = this.transformArticle(article, config, locale) + // Process each chunk (will be 1 chunk for small articles) + for (const chunk of transformedChunks) { + allTransformedArticles.push(chunk) + } + } + } catch (error: any) { + // Continue with other locales if one fails + throw new Error(`Error processing locale ${locale}: ${error.message}`) + } + } + + return allTransformedArticles + } + + async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { + const zendeskDomain = nodeData.inputs?.zendeskDomain as string + const brandId = nodeData.inputs?.brandId as string + const locale = nodeData.inputs?.locale as string + const publishedArticlesOnly = (nodeData.inputs?.publishedArticlesOnly as boolean) ?? true + const charsPerToken = (nodeData.inputs?.charsPerToken as number) ?? 4 + const metadata = nodeData.inputs?.metadata + const _omitMetadataKeys = nodeData.inputs?.omitMetadataKeys as string + const output = nodeData.outputs?.output as string + + let omitMetadataKeys: string[] = [] + if (_omitMetadataKeys) { + omitMetadataKeys = _omitMetadataKeys.split(',').map((key) => key.trim()) + } + + const credentialData = await getCredentialData(nodeData.credential ?? '', options) + const user = getCredentialParam('user', credentialData, nodeData) + const token = getCredentialParam('token', credentialData, nodeData) + + // Build configuration + const config: ZendeskConfig = { + zendeskDomain, + user, + token, + brandId, + publishedArticlesOnly, + locales: [locale], + charsPerToken, + api: { + protocol: 'https://', + helpCenterPath: '/api/v2/help_center/', + articlesEndpoint: 'articles.json', + publicPath: '/hc/' + }, + defaultTitle: 'Untitled', + chunking: { + maxTokens: 3000, + chunkSize: 1000, + overlap: 200 + } + } + + // Validate configuration + this.validateConfig(config) + + // Extract articles + let docs: IDocument[] = await this.extractAllArticles(config) + + // Apply metadata handling + if (metadata) { + const parsedMetadata = typeof metadata === 'object' ? metadata : JSON.parse(metadata) + docs = docs.map((doc) => ({ + ...doc, + metadata: + _omitMetadataKeys === '*' + ? { + ...parsedMetadata + } + : omit( + { + ...doc.metadata, + ...parsedMetadata + }, + omitMetadataKeys + ) + })) + } else { + docs = docs.map((doc) => ({ + ...doc, + metadata: + _omitMetadataKeys === '*' + ? {} + : omit( + { + ...doc.metadata + }, + omitMetadataKeys + ) + })) + } + + if (output === 'document') { + return docs + } else { + let finaltext = '' + for (const doc of docs) { + finaltext += `${doc.pageContent}\n` + } + return handleEscapeCharacters(finaltext, false) + } + } +} + +module.exports = { nodeClass: Zendesk_DocumentLoaders } + diff --git a/packages/components/nodes/documentloaders/Zendesk/zendesk.svg b/packages/components/nodes/documentloaders/Zendesk/zendesk.svg new file mode 100644 index 00000000000..cc7edc68ce2 --- /dev/null +++ b/packages/components/nodes/documentloaders/Zendesk/zendesk.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/components/nodes/documentloaders/Zendesk/zendesk.test.ts b/packages/components/nodes/documentloaders/Zendesk/zendesk.test.ts new file mode 100644 index 00000000000..1d4fbfc5189 --- /dev/null +++ b/packages/components/nodes/documentloaders/Zendesk/zendesk.test.ts @@ -0,0 +1,650 @@ +const { nodeClass: Zendesk_DocumentLoaders } = require('./Zendesk') +import { INodeData } from '../../../src/Interface' +import axios from 'axios' + +// Mock axios +jest.mock('axios') +const mockedAxios = axios as jest.Mocked + +// Mock credential helpers +jest.mock('../../../src', () => ({ + getCredentialData: jest.fn(), + getCredentialParam: jest.fn(), + handleEscapeCharacters: jest.fn((str: string) => str) +})) + +const { getCredentialData, getCredentialParam } = require('../../../src') + +// Helper function to create a valid INodeData object +function createNodeData(id: string, inputs: any, credential?: string): INodeData { + return { + id: id, + label: 'Zendesk', + name: 'zendesk', + type: 'Document', + icon: 'zendesk.svg', + version: 1.0, + category: 'Document Loaders', + baseClasses: ['Document'], + inputs: inputs, + credential: credential, + outputs: { + output: 'document' + } + } +} + +describe('Zendesk', () => { + let nodeClass: any + + beforeEach(() => { + nodeClass = new Zendesk_DocumentLoaders() + jest.clearAllMocks() + + // Default credential mocks + ;(getCredentialData as jest.Mock).mockResolvedValue({}) + ;(getCredentialParam as jest.Mock).mockImplementation((param: string) => { + if (param === 'user') return 'user@example.com' + if (param === 'token') return 'test-token' + return undefined + }) + }) + + describe('Configuration Validation', () => { + it('should throw error when zendeskDomain is not provided', async () => { + const nodeData = createNodeData('test-1', { + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Configuration validation failed' + ) + }) + + it('should throw error when locale is not provided', async () => { + const nodeData = createNodeData('test-2', { + zendeskDomain: 'example.zendesk.com' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow() + }) + + it('should throw error when zendeskDomain is invalid', async () => { + const nodeData = createNodeData('test-3', { + zendeskDomain: 'invalid-domain.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Zendesk domain must be a valid zendesk.com domain' + ) + }) + + it('should throw error when token is not provided', async () => { + ;(getCredentialParam as jest.Mock).mockImplementation((param: string) => { + if (param === 'user') return 'user@example.com' + return undefined + }) + + const nodeData = createNodeData('test-4', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Zendesk auth token is required' + ) + }) + + it('should throw error when user is not a valid email', async () => { + ;(getCredentialParam as jest.Mock).mockImplementation((param: string) => { + if (param === 'user') return 'invalid-user' + if (param === 'token') return 'test-token' + return undefined + }) + + const nodeData = createNodeData('test-5', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Zendesk auth user must be a valid email address' + ) + }) + + it('should throw error when brandId is not numeric', async () => { + const nodeData = createNodeData('test-6', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + brandId: 'invalid-id' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Brand ID must be a numeric string' + ) + }) + + it('should accept valid configuration', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Test Article', + body: 'Test content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-7', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + }) + }) + + describe('Article Fetching', () => { + it('should fetch articles successfully', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Article 1', + body: 'Content 1' + }, + { + id: 2, + name: 'Article 2', + body: 'Content 2' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-8', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result).toHaveLength(2) + expect(result[0].pageContent).toBe('Content 1') + expect(result[0].metadata.title).toBe('Article 1') + expect(result[0].metadata.id).toBe('1') + }) + + it('should handle pagination', async () => { + const firstPage = { + data: { + articles: [ + { + id: 1, + name: 'Article 1', + body: 'Content 1' + } + ], + next_page: 'https://example.zendesk.com/api/v2/help_center/articles.json?page=2' + } + } + + const secondPage = { + data: { + articles: [ + { + id: 2, + name: 'Article 2', + body: 'Content 2' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage) + + const nodeData = createNodeData('test-9', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result).toHaveLength(2) + expect(mockedAxios.get).toHaveBeenCalledTimes(2) + }) + + it('should add status filter when publishedArticlesOnly is true', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Article 1', + body: 'Content 1' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-10', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + publishedArticlesOnly: true + }) + + await nodeClass.init(nodeData, '', {}) + const callUrl = mockedAxios.get.mock.calls[0][0] as string + expect(callUrl).toContain('status=published') + }) + + it('should add brand_id filter when brandId is provided', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Article 1', + body: 'Content 1' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-11', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + brandId: '123456' + }) + + await nodeClass.init(nodeData, '', {}) + const callUrl = mockedAxios.get.mock.calls[0][0] as string + expect(callUrl).toContain('brand_id=123456') + }) + }) + + describe('Error Handling', () => { + it('should handle 401 authentication error', async () => { + const error = { + response: { + status: 401, + statusText: 'Unauthorized' + } + } + + mockedAxios.get.mockRejectedValueOnce(error) + + const nodeData = createNodeData('test-12', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Authentication failed (401)' + ) + }) + + it('should handle 403 forbidden error', async () => { + const error = { + response: { + status: 403, + statusText: 'Forbidden' + } + } + + mockedAxios.get.mockRejectedValueOnce(error) + + const nodeData = createNodeData('test-13', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Access forbidden (403)' + ) + }) + + it('should handle 404 not found error', async () => { + const error = { + response: { + status: 404, + statusText: 'Not Found' + } + } + + mockedAxios.get.mockRejectedValueOnce(error) + + const nodeData = createNodeData('test-14', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Not found (404)' + ) + }) + + it('should handle 500 server error', async () => { + const error = { + response: { + status: 500, + statusText: 'Internal Server Error' + } + } + + mockedAxios.get.mockRejectedValueOnce(error) + + const nodeData = createNodeData('test-15', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Zendesk server error (500)' + ) + }) + + it('should handle network error (ENOTFOUND)', async () => { + const error = { + code: 'ENOTFOUND', + message: 'getaddrinfo ENOTFOUND' + } + + mockedAxios.get.mockRejectedValueOnce(error) + + const nodeData = createNodeData('test-16', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Network error: Cannot connect to Zendesk' + ) + }) + + it('should handle network error (ECONNREFUSED)', async () => { + const error = { + code: 'ECONNREFUSED', + message: 'Connection refused' + } + + mockedAxios.get.mockRejectedValueOnce(error) + + const nodeData = createNodeData('test-17', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow( + 'Network error: Cannot connect to Zendesk' + ) + }) + }) + + describe('Chunking Logic', () => { + it('should not chunk small articles', async () => { + const smallContent = 'a'.repeat(1000) // Small content + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Small Article', + body: smallContent + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-18', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + charsPerToken: 4 + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result).toHaveLength(1) + expect(result[0].metadata.id).toBe('1') + }) + + it('should chunk large articles', async () => { + // Create content that exceeds maxTokens (3000 * 4 = 12000 chars) + const largeContent = 'a'.repeat(15000) + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Large Article', + body: largeContent + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-19', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + charsPerToken: 4 + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result.length).toBeGreaterThan(1) + expect(result[0].metadata.id).toContain('1-') + }) + + it('should maintain article title across chunks', async () => { + const largeContent = 'a'.repeat(15000) + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Large Article Title', + body: largeContent + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-20', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + charsPerToken: 4 + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result.length).toBeGreaterThan(1) + result.forEach((doc: any) => { + expect(doc.metadata.title).toBe('Large Article Title') + }) + }) + }) + + describe('Metadata Handling', () => { + it('should include article URL in metadata', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 123, + name: 'Test Article', + body: 'Content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-21', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result[0].metadata.url).toBe('https://example.zendesk.com/hc/en-us/articles/123') + }) + + it('should handle additional metadata', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Test Article', + body: 'Content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-22', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + metadata: JSON.stringify({ customKey: 'customValue' }) + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result[0].metadata.customKey).toBe('customValue') + expect(result[0].metadata.title).toBe('Test Article') + }) + + it('should omit specified metadata keys', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Test Article', + body: 'Content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-23', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us', + omitMetadataKeys: 'url' + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result[0].metadata.url).toBeUndefined() + expect(result[0].metadata.title).toBeDefined() + expect(result[0].metadata.id).toBeDefined() + }) + }) + + describe('Output Modes', () => { + it('should return documents array when output is document', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Test Article', + body: 'Content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-24', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + nodeData.outputs = { output: 'document' } + + const result = await nodeClass.init(nodeData, '', {}) + expect(Array.isArray(result)).toBe(true) + expect(result[0]).toHaveProperty('pageContent') + expect(result[0]).toHaveProperty('metadata') + }) + + it('should return concatenated text when output is text', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Article 1', + body: 'Content 1' + }, + { + id: 2, + name: 'Article 2', + body: 'Content 2' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-25', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + nodeData.outputs = { output: 'text' } + + const result = await nodeClass.init(nodeData, '', {}) + expect(typeof result).toBe('string') + expect(result).toContain('Content 1') + expect(result).toContain('Content 2') + }) + }) + + describe('Authentication', () => { + it('should set Authorization header with Basic auth', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Test Article', + body: 'Content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + + const nodeData = createNodeData('test-26', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us' + }) + + await nodeClass.init(nodeData, '', {}) + const callConfig = mockedAxios.get.mock.calls[0][1] + expect(callConfig?.headers?.Authorization).toBeDefined() + expect(callConfig?.headers?.Authorization).toContain('Basic') + }) + }) +}) + From 7d909863c25a56fdd6e68172f8dee3980565e5fe Mon Sep 17 00:00:00 2001 From: Dave Hampton Date: Fri, 7 Nov 2025 14:52:18 -0800 Subject: [PATCH 2/2] feat: enhance Zendesk document loader with default locale and multi-locale support Updated the Zendesk document loader to use a default locale ('en-us') when none is provided and to support comma-separated locales for fetching articles. Improved test cases to validate these changes, ensuring proper handling of multiple locales and default behavior. Enhanced error handling for locale validation and metadata processing. --- .../nodes/documentloaders/Zendesk/Zendesk.ts | 93 ++++++++++--------- .../documentloaders/Zendesk/zendesk.test.ts | 80 ++++++++++++++-- 2 files changed, 123 insertions(+), 50 deletions(-) diff --git a/packages/components/nodes/documentloaders/Zendesk/Zendesk.ts b/packages/components/nodes/documentloaders/Zendesk/Zendesk.ts index d5ce4f9a701..2841ffb6ba3 100644 --- a/packages/components/nodes/documentloaders/Zendesk/Zendesk.ts +++ b/packages/components/nodes/documentloaders/Zendesk/Zendesk.ts @@ -92,8 +92,10 @@ class Zendesk_DocumentLoaders implements INode { label: 'Locale', name: 'locale', type: 'string', + default: 'en-us', + optional: true, placeholder: 'en-us', - description: 'Locale code for articles (e.g., en-us, en-gb)' + description: 'Locale code(s) for articles. Can be a single locale (e.g., en-us) or comma-separated list (e.g., en-us, en-gb, fr-fr). Defaults to en-us if not provided.' }, { label: 'Published Articles Only', @@ -172,6 +174,10 @@ class Zendesk_DocumentLoaders implements INode { errors.push('Brand ID must be a numeric string') } + if (!config.locales || !config.locales.length || !config.locales[0]) { + errors.push('Locale is required') + } + if (errors.length > 0) { const errorMessage = 'Configuration validation failed:\n • ' + errors.join('\n • ') throw new Error(errorMessage) @@ -349,19 +355,14 @@ class Zendesk_DocumentLoaders implements INode { // Process each locale for (const locale of config.locales) { - try { - const articles = await this.fetchAllArticles(locale, config.brandId || undefined, config, axiosHeaders) - // Transform each article to the required format - for (const article of articles) { - const transformedChunks = this.transformArticle(article, config, locale) - // Process each chunk (will be 1 chunk for small articles) - for (const chunk of transformedChunks) { - allTransformedArticles.push(chunk) - } + const articles = await this.fetchAllArticles(locale, config.brandId || undefined, config, axiosHeaders) + // Transform each article to the required format + for (const article of articles) { + const transformedChunks = this.transformArticle(article, config, locale) + // Process each chunk (will be 1 chunk for small articles) + for (const chunk of transformedChunks) { + allTransformedArticles.push(chunk) } - } catch (error: any) { - // Continue with other locales if one fails - throw new Error(`Error processing locale ${locale}: ${error.message}`) } } @@ -371,7 +372,8 @@ class Zendesk_DocumentLoaders implements INode { async init(nodeData: INodeData, _: string, options: ICommonObject): Promise { const zendeskDomain = nodeData.inputs?.zendeskDomain as string const brandId = nodeData.inputs?.brandId as string - const locale = nodeData.inputs?.locale as string + const localeInputRaw = nodeData.inputs?.locale as string + const localeInput = (localeInputRaw && localeInputRaw.trim()) || 'en-us' const publishedArticlesOnly = (nodeData.inputs?.publishedArticlesOnly as boolean) ?? true const charsPerToken = (nodeData.inputs?.charsPerToken as number) ?? 4 const metadata = nodeData.inputs?.metadata @@ -383,6 +385,17 @@ class Zendesk_DocumentLoaders implements INode { omitMetadataKeys = _omitMetadataKeys.split(',').map((key) => key.trim()) } + // Parse comma-separated locales + const locales = localeInput + .split(',') + .map((loc) => loc.trim()) + .filter((loc) => loc.length > 0) + + // Ensure at least one locale + if (locales.length === 0) { + locales.push('en-us') + } + const credentialData = await getCredentialData(nodeData.credential ?? '', options) const user = getCredentialParam('user', credentialData, nodeData) const token = getCredentialParam('token', credentialData, nodeData) @@ -394,7 +407,7 @@ class Zendesk_DocumentLoaders implements INode { token, brandId, publishedArticlesOnly, - locales: [locale], + locales, charsPerToken, api: { protocol: 'https://', @@ -417,38 +430,30 @@ class Zendesk_DocumentLoaders implements INode { let docs: IDocument[] = await this.extractAllArticles(config) // Apply metadata handling + let parsedMetadata = {} + if (metadata) { - const parsedMetadata = typeof metadata === 'object' ? metadata : JSON.parse(metadata) - docs = docs.map((doc) => ({ - ...doc, - metadata: - _omitMetadataKeys === '*' - ? { - ...parsedMetadata - } - : omit( - { - ...doc.metadata, - ...parsedMetadata - }, - omitMetadataKeys - ) - })) - } else { - docs = docs.map((doc) => ({ - ...doc, - metadata: - _omitMetadataKeys === '*' - ? {} - : omit( - { - ...doc.metadata - }, - omitMetadataKeys - ) - })) + try { + parsedMetadata = typeof metadata === 'object' ? metadata : JSON.parse(metadata) + } catch (error) { + throw new Error(`Error parsing Additional Metadata: ${error.message}`) + } } + docs = docs.map((doc) => ({ + ...doc, + metadata: + _omitMetadataKeys === '*' + ? { ...parsedMetadata } + : omit( + { + ...doc.metadata, + ...parsedMetadata + }, + omitMetadataKeys + ) + })) + if (output === 'document') { return docs } else { diff --git a/packages/components/nodes/documentloaders/Zendesk/zendesk.test.ts b/packages/components/nodes/documentloaders/Zendesk/zendesk.test.ts index 1d4fbfc5189..021ba4a96b2 100644 --- a/packages/components/nodes/documentloaders/Zendesk/zendesk.test.ts +++ b/packages/components/nodes/documentloaders/Zendesk/zendesk.test.ts @@ -61,12 +61,31 @@ describe('Zendesk', () => { ) }) - it('should throw error when locale is not provided', async () => { + it('should use default locale (en-us) when locale is not provided', async () => { + const mockArticles = { + data: { + articles: [ + { + id: 1, + name: 'Test Article', + body: 'Test content' + } + ] + } + } + + mockedAxios.get.mockResolvedValueOnce(mockArticles) + const nodeData = createNodeData('test-2', { zendeskDomain: 'example.zendesk.com' }) - await expect(nodeClass.init(nodeData, '', {})).rejects.toThrow() + const result = await nodeClass.init(nodeData, '', {}) + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + // Verify the API was called with en-us locale + const callUrl = mockedAxios.get.mock.calls[0][0] as string + expect(callUrl).toContain('locale=en-us') }) it('should throw error when zendeskDomain is invalid', async () => { @@ -273,6 +292,49 @@ describe('Zendesk', () => { const callUrl = mockedAxios.get.mock.calls[0][0] as string expect(callUrl).toContain('brand_id=123456') }) + + it('should handle comma-separated locales', async () => { + const mockArticlesEn = { + data: { + articles: [ + { + id: 1, + name: 'English Article', + body: 'English content' + } + ] + } + } + + const mockArticlesFr = { + data: { + articles: [ + { + id: 2, + name: 'French Article', + body: 'French content' + } + ] + } + } + + mockedAxios.get + .mockResolvedValueOnce(mockArticlesEn) + .mockResolvedValueOnce(mockArticlesFr) + + const nodeData = createNodeData('test-11b', { + zendeskDomain: 'example.zendesk.com', + locale: 'en-us, fr-fr' + }) + + const result = await nodeClass.init(nodeData, '', {}) + expect(result).toHaveLength(2) + expect(mockedAxios.get).toHaveBeenCalledTimes(2) + // Verify both locales were called + const callUrls = mockedAxios.get.mock.calls.map((call) => call[0] as string) + expect(callUrls[0]).toContain('locale=en-us') + expect(callUrls[1]).toContain('locale=fr-fr') + }) }) describe('Error Handling', () => { @@ -481,11 +543,12 @@ describe('Zendesk', () => { describe('Metadata Handling', () => { it('should include article URL in metadata', async () => { + const articleId = 123 const mockArticles = { data: { articles: [ { - id: 123, + id: articleId, name: 'Test Article', body: 'Content' } @@ -501,7 +564,9 @@ describe('Zendesk', () => { }) const result = await nodeClass.init(nodeData, '', {}) - expect(result[0].metadata.url).toBe('https://example.zendesk.com/hc/en-us/articles/123') + expect(result[0].metadata.url).toBe(`https://example.zendesk.com/hc/en-us/articles/${articleId}`) + expect(result[0].metadata.id).toBe(String(articleId)) + expect(result[0].pageContent).toBe('Content') }) it('should handle additional metadata', async () => { @@ -614,8 +679,11 @@ describe('Zendesk', () => { const result = await nodeClass.init(nodeData, '', {}) expect(typeof result).toBe('string') - expect(result).toContain('Content 1') - expect(result).toContain('Content 2') + // Check that both article contents are present in the concatenated text + // The result should be "Content 1\nContent 2\n" (or similar with escape handling) + const normalizedResult = result.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() + expect(normalizedResult).toContain('Content 1') + expect(normalizedResult).toContain('Content 2') }) })