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..2841ffb6ba3
--- /dev/null
+++ b/packages/components/nodes/documentloaders/Zendesk/Zendesk.ts
@@ -0,0 +1,470 @@
+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',
+ default: 'en-us',
+ optional: true,
+ placeholder: 'en-us',
+ 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',
+ 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 (!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)
+ }
+ }
+
+ /**
+ * 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) {
+ 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)
+ }
+ }
+ }
+
+ 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 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
+ 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())
+ }
+
+ // 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)
+
+ // Build configuration
+ const config: ZendeskConfig = {
+ zendeskDomain,
+ user,
+ token,
+ brandId,
+ publishedArticlesOnly,
+ locales,
+ 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
+ let parsedMetadata = {}
+
+ if (metadata) {
+ 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 {
+ 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..021ba4a96b2
--- /dev/null
+++ b/packages/components/nodes/documentloaders/Zendesk/zendesk.test.ts
@@ -0,0 +1,718 @@
+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 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'
+ })
+
+ 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 () => {
+ 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')
+ })
+
+ 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', () => {
+ 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 articleId = 123
+ const mockArticles = {
+ data: {
+ articles: [
+ {
+ id: articleId,
+ 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/${articleId}`)
+ expect(result[0].metadata.id).toBe(String(articleId))
+ expect(result[0].pageContent).toBe('Content')
+ })
+
+ 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')
+ // 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')
+ })
+ })
+
+ 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')
+ })
+ })
+})
+