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') + }) + }) +}) +