UNPKG

n8n-nodes-duckduckgo-search

Version:

A powerful and comprehensive n8n community node that seamlessly integrates DuckDuckGo search capabilities into your workflows. Search the web, find images, discover news, and explore videos - all with privacy-focused, reliable results.

580 lines (579 loc) 28.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); const DuckDuckGo_node_1 = require("../DuckDuckGo.node"); const duckDuckScrape = __importStar(require("duck-duck-scrape")); const cache = __importStar(require("../cache")); const directSearch = __importStar(require("../directSearch")); jest.mock('duck-duck-scrape', () => ({ search: jest.fn(), searchNews: jest.fn(), searchImages: jest.fn(), searchVideos: jest.fn(), SafeSearchType: { STRICT: 'strict', MODERATE: 'moderate', OFF: 'off', }, SearchTimeType: { DAY: 'd', WEEK: 'w', MONTH: 'm', YEAR: 'y', ALL: 'a', }, VideoDefinition: { HIGH: 'high', STANDARD: 'standard', ALL: 'all', }, VideoDuration: { SHORT: 'short', MEDIUM: 'medium', LONG: 'long', ALL: 'all', }, VideoLicense: { CREATIVE_COMMONS: 'creativeCommons', YOUTUBE: 'youtube', ALL: 'all', }, })); jest.mock('../cache', () => ({ getCached: jest.fn(), setCache: jest.fn(), clearCache: jest.fn(), getCacheSize: jest.fn(), pruneExpiredEntries: jest.fn(), })); jest.mock('../directSearch', () => ({ directWebSearch: jest.fn(), directImageSearch: jest.fn(), getSafeSearchString: jest.fn((value) => { switch (value) { case 2: return 'strict'; case 1: return 'moderate'; default: return 'off'; } }), })); jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('mock-uuid-1234'), })); describe('DuckDuckGo Node', () => { let duckDuckGoNode; let mockExecuteFunction; let mockGetNodeParameter; beforeEach(() => { duckDuckGoNode = new DuckDuckGo_node_1.DuckDuckGo(); mockGetNodeParameter = jest.fn(); mockExecuteFunction = { getInputData: jest.fn().mockReturnValue([{ json: {} }]), getNodeParameter: mockGetNodeParameter, getNode: jest.fn().mockReturnValue({ name: 'DuckDuckGo', type: 'n8n-nodes-base.duckDuckGo', typeVersion: 1 }), helpers: { returnJsonArray: jest.fn((items) => items.map((item, index) => ({ json: item, pairedItem: { item: index } }))), }, continueOnFail: jest.fn().mockReturnValue(false), getCredentials: jest.fn().mockResolvedValue({ apiKey: 'test-api-key' }), logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), }, }; jest.clearAllMocks(); }); const setupNodeParameters = (operation, query, options = {}, additionalParams = {}) => { mockGetNodeParameter.mockImplementation((parameter, _itemIndex, fallback) => { switch (parameter) { case 'operation': return operation; case 'query': case 'imageQuery': case 'newsQuery': case 'videoQuery': return query; case 'webSearchOptions': case 'imageSearchOptions': case 'newsSearchOptions': case 'videoSearchOptions': return { maxResults: options.maxResults || 99, region: options.region || 'us-en', safeSearch: options.safeSearch !== undefined ? options.safeSearch : 1, timePeriod: options.timePeriod || '', ...options }; case 'locale': return options.locale || 'en-us'; case 'useCache': return options.useCache !== undefined ? options.useCache : true; case 'cacheTtl': return options.cacheTtl || 300; case 'debugMode': return options.debugMode || false; case 'useApiKey': return options.useApiKey || false; case 'errorHandling': return options.errorHandling || 'continueOnFail'; case 'enableTelemetry': return options.enableTelemetry || false; case 'cacheSettings': return options.cacheSettings || { enableCache: options.useCache !== undefined ? options.useCache : false }; default: if (additionalParams[parameter] !== undefined) { return additionalParams[parameter]; } return fallback; } }); }; describe('Web Search Operation', () => { const mockWebSearchResults = { results: [ { title: 'Test Result 1', description: 'Description for test result 1', url: 'https://example.com/1', hostname: 'example.com', icon: 'https://example.com/favicon.ico', }, { title: 'Test Result 2', description: 'Description for test result 2', url: 'https://example.com/2', hostname: 'example.com', icon: 'https://example.com/favicon.ico', }, ], }; it('should return web search results successfully', async () => { setupNodeParameters('search', 'test query'); const mockDirectResults = { results: mockWebSearchResults.results.map(r => ({ title: r.title, url: r.url, description: r.description, })) }; directSearch.directWebSearch.mockResolvedValue(mockDirectResults); const result = await duckDuckGoNode.execute.call(mockExecuteFunction); expect(directSearch.directWebSearch).toHaveBeenCalledWith('"test query"', expect.objectContaining({ locale: 'us-en', safeSearch: 'moderate', })); expect(result).toHaveLength(1); expect(result[0]).toHaveLength(2); expect(result[0][0].json).toHaveProperty('url', 'https://example.com/1'); expect(result[0][0].json).toHaveProperty('title', 'Test Result 1'); expect(result[0][0].json).toHaveProperty('description', 'Description for test result 1'); expect(result[0][0].json).toHaveProperty('sourceType', 'web'); expect(result[0][1].json).toHaveProperty('url', 'https://example.com/2'); expect(result[0][1].json).toHaveProperty('title', 'Test Result 2'); }); it('should use cache when available', async () => { setupNodeParameters('search', 'cached query', { useCache: true, cacheTtl: 300 }); const cachedResults = { results: [ { title: 'Cached Result', description: 'This is a cached result', url: 'https://example.com/cached', hostname: 'example.com', }, ], }; cache.getCached.mockReturnValue(cachedResults); const result = await duckDuckGoNode.execute.call(mockExecuteFunction); expect(cache.getCached).toHaveBeenCalled(); expect(directSearch.directWebSearch).not.toHaveBeenCalled(); expect(result).toHaveLength(1); expect(result[0]).toHaveLength(1); expect(result[0][0].json).toHaveProperty('title', 'Cached Result'); expect(result[0][0].json).toHaveProperty('url', 'https://example.com/cached'); }); it('should store results in cache when cache is enabled', async () => { setupNodeParameters('search', 'query to cache', { useCache: true, cacheTtl: 300 }); cache.getCached.mockReturnValue(undefined); const mockDirectResults = { results: mockWebSearchResults.results.map(r => ({ title: r.title, url: r.url, description: r.description, })) }; directSearch.directWebSearch.mockResolvedValue(mockDirectResults); await duckDuckGoNode.execute.call(mockExecuteFunction); expect(cache.getCached).toHaveBeenCalled(); expect(directSearch.directWebSearch).toHaveBeenCalled(); expect(cache.setCache).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ results: expect.any(Array), noResults: false, }), 300); }); it('should handle API errors gracefully', async () => { setupNodeParameters('search', 'error query'); const apiError = new Error('API request failed'); apiError.name = 'FetchError'; apiError.stack = 'Error stack trace'; directSearch.directWebSearch.mockRejectedValue(apiError); const result = await duckDuckGoNode.execute.call(mockExecuteFunction); expect(directSearch.directWebSearch).toHaveBeenCalledWith('error query 2025', expect.any(Object)); expect(result).toHaveLength(1); expect(result[0]).toHaveLength(1); expect(result[0][0].json).toHaveProperty('success', false); expect(result[0][0].json).toHaveProperty('error'); expect(typeof result[0][0].json.error).toBe('string'); }); it('should handle empty search results', async () => { setupNodeParameters('search', 'no results query'); const emptyResults = { results: [] }; directSearch.directWebSearch.mockResolvedValue(emptyResults); const result = await duckDuckGoNode.execute.call(mockExecuteFunction); expect(directSearch.directWebSearch).toHaveBeenCalledWith('no results query', expect.any(Object)); expect(result).toHaveLength(1); expect(result[0]).toHaveLength(1); expect(result[0][0].json).toHaveProperty('success', true); expect(result[0][0].json).toHaveProperty('results'); expect(result[0][0].json.results).toHaveLength(0); }); it('should throw an error when query is missing', async () => { mockGetNodeParameter.mockImplementation((parameter) => { if (parameter === 'operation') return 'search'; if (parameter === 'query') return ''; if (parameter === 'webSearchOptions') return { useSearchOperators: false, safeSearch: 1, locale: 'us-en', timePeriod: '', useCache: false, cacheTtl: 300, debugMode: false }; return null; }); await expect(duckDuckGoNode.execute.call(mockExecuteFunction)) .rejects .toThrow(/query is required/i); }); }); describe('Image Search Operation', () => { const mockImageSearchResults = { results: [ { title: 'Image 1', image: 'https://example.com/image1.jpg', thumbnail: 'https://example.com/thumb1.jpg', url: 'https://example.com/page1', width: 800, height: 600, source: 'example.com' }, { title: 'Image 2', image: 'https://example.com/image2.jpg', thumbnail: 'https://example.com/thumb2.jpg', url: 'https://example.com/page2', width: 1024, height: 768, source: 'example.com' } ] }; it('should return image search results successfully', async () => { setupNodeParameters('searchImages', 'cat pictures'); const mockDirectImageResults = { results: mockImageSearchResults.results.map(r => ({ title: r.title, url: r.image, thumbnail: r.thumbnail, width: r.width, height: r.height, source: r.url, })) }; directSearch.directImageSearch.mockResolvedValue(mockDirectImageResults); const result = await duckDuckGoNode.execute.call(mockExecuteFunction); expect(directSearch.directImageSearch).toHaveBeenCalledWith('cat pictures', expect.objectContaining({ locale: 'us-en', safeSearch: 'moderate', })); expect(result).toHaveLength(1); expect(result[0]).toHaveLength(2); expect(result[0][0].json).toHaveProperty('imageUrl', 'https://example.com/image1.jpg'); expect(result[0][0].json).toHaveProperty('thumbnailUrl', 'https://example.com/thumb1.jpg'); expect(result[0][0].json).toHaveProperty('title', 'Image 1'); expect(result[0][0].json).toHaveProperty('width', 800); expect(result[0][0].json).toHaveProperty('height', 600); expect(result[0][0].json).toHaveProperty('sourceType', 'image'); }); it('should handle error in image search', async () => { setupNodeParameters('searchImages', 'error query'); const apiError = new Error('Image search failed'); apiError.name = 'HTTPError'; const errorWithCode = Object.assign(apiError, { httpCode: 500, code: 'INTERNAL_SERVER_ERROR', message: 'Image search failed', }); directSearch.directImageSearch.mockRejectedValue(errorWithCode); const result = await duckDuckGoNode.execute.call(mockExecuteFunction); expect(result).toHaveLength(1); expect(result[0]).toHaveLength(1); expect(result[0][0].json).toHaveProperty('success', false); expect(result[0][0].json).toHaveProperty('error'); expect(result[0][0].json.error).toContain('image search'); }); }); describe('News Search Operation', () => { const mockNewsSearchResults = { results: [ { title: 'News Article 1', excerpt: 'First news article excerpt', url: 'https://news.example.com/article1', date: 1625097600000, relativeTime: '2 hours ago', image: 'https://news.example.com/image1.jpg', syndicate: 'Example News' }, { title: 'News Article 2', excerpt: 'Second news article excerpt', url: 'https://news.example.com/article2', date: 1625094000000, relativeTime: '3 hours ago', image: 'https://news.example.com/image2.jpg', syndicate: 'Example News' } ] }; it('should return news search results successfully', async () => { setupNodeParameters('searchNews', 'latest tech news', { timePeriod: 'd' }); duckDuckScrape.searchNews.mockResolvedValue(mockNewsSearchResults); const result = await duckDuckGoNode.execute.call(mockExecuteFunction); expect(duckDuckScrape.searchNews).toHaveBeenCalledWith('latest tech news', expect.objectContaining({ time: 'd' })); expect(result).toHaveLength(1); expect(result[0]).toHaveLength(2); expect(result[0][0].json).toHaveProperty('title', 'News Article 1'); expect(result[0][0].json).toHaveProperty('description', 'First news article excerpt'); expect(result[0][0].json).toHaveProperty('url', 'https://news.example.com/article1'); expect(result[0][0].json).toHaveProperty('relativeTime', '2 hours ago'); expect(result[0][0].json).toHaveProperty('imageUrl', 'https://news.example.com/image1.jpg'); expect(result[0][0].json).toHaveProperty('sourceType', 'news'); }); it('should cache news search results when enabled', async () => { setupNodeParameters('searchNews', 'cached news query', { useCache: true, cacheTtl: 300 }); cache.getCached.mockReturnValue(undefined); duckDuckScrape.searchNews.mockResolvedValue(mockNewsSearchResults); await duckDuckGoNode.execute.call(mockExecuteFunction); expect(cache.setCache).toHaveBeenCalledWith(expect.any(String), mockNewsSearchResults, 300); }); }); describe('Video Search Operation', () => { const mockVideoSearchResults = { results: [ { title: 'Video 1', description: 'Description for video 1', url: 'https://videos.example.com/video1', image: 'https://videos.example.com/thumb1.jpg', duration: '10:15', published: '2023-01-15', publishedOn: 'Example Videos', publisher: 'Example Publisher', viewCount: '1.2M views' }, { title: 'Video 2', description: 'Description for video 2', url: 'https://videos.example.com/video2', image: 'https://videos.example.com/thumb2.jpg', duration: '5:30', published: '2023-01-10', publishedOn: 'Example Videos', publisher: 'Example Publisher', viewCount: '562K views' } ] }; it('should return video search results successfully', async () => { setupNodeParameters('searchVideos', 'tutorial videos'); duckDuckScrape.searchVideos.mockResolvedValue(mockVideoSearchResults); const result = await duckDuckGoNode.execute.call(mockExecuteFunction); expect(duckDuckScrape.searchVideos).toHaveBeenCalledWith('tutorial videos', expect.any(Object)); expect(result).toHaveLength(1); expect(result[0]).toHaveLength(2); expect(result[0][0].json).toHaveProperty('title', 'Video 1'); expect(result[0][0].json).toHaveProperty('description', 'Description for video 1'); expect(result[0][0].json).toHaveProperty('url', 'https://videos.example.com/video1'); expect(result[0][0].json).toHaveProperty('duration', '10:15'); expect(result[0][0].json).toHaveProperty('imageUrl', 'https://videos.example.com/thumb1.jpg'); expect(result[0][0].json).toHaveProperty('viewCount', '1.2M views'); expect(result[0][0].json).toHaveProperty('sourceType', 'video'); }); it('should handle server error (500) in video search', async () => { setupNodeParameters('searchVideos', 'server error', { debugMode: true }); const serverError = new Error('Internal Server Error'); serverError.name = 'ServerError'; const errorWithCode = Object.assign(serverError, { httpCode: 500, code: 'INTERNAL_SERVER_ERROR', message: 'Internal Server Error', }); duckDuckScrape.searchVideos.mockRejectedValue(errorWithCode); const result = await duckDuckGoNode.execute.call(mockExecuteFunction); expect(duckDuckScrape.searchVideos).toHaveBeenCalled(); expect(result).toHaveLength(1); expect(result[0]).toHaveLength(1); expect(result[0][0].json).toHaveProperty('success', false); expect(result[0][0].json).toHaveProperty('error'); expect(result[0][0].json).toHaveProperty('errorDetails'); expect(result[0][0].json.error).toContain('Internal Server Error'); }); }); describe('API Key Authentication', () => { it('should include API key when authentication is enabled', async () => { setupNodeParameters('search', 'api auth query', { useApiKey: true }); directSearch.directWebSearch.mockResolvedValue({ results: [] }); await duckDuckGoNode.execute.call(mockExecuteFunction); expect(directSearch.directWebSearch).toHaveBeenCalledWith('api auth query', expect.any(Object)); }); }); describe('Error Handling', () => { it('should handle network timeout errors', async () => { setupNodeParameters('search', 'timeout query'); const timeoutError = new Error('Network request timed out'); timeoutError.name = 'TimeoutError'; directSearch.directWebSearch.mockRejectedValue(timeoutError); const result = await duckDuckGoNode.execute.call(mockExecuteFunction); expect(result).toHaveLength(1); expect(result[0]).toHaveLength(1); expect(result[0][0].json).toHaveProperty('success', false); expect(result[0][0].json).toHaveProperty('error'); expect(result[0][0].json.error).toContain('Network request timed out'); }); it('should handle rate limit errors (429)', async () => { setupNodeParameters('search', 'rate limited', { debugMode: true }); const rateLimitError = new Error('Rate limit exceeded'); rateLimitError.name = 'RateLimitError'; const errorWithCode = Object.assign(rateLimitError, { httpCode: 429, code: 'TOO_MANY_REQUESTS', message: 'Rate limit exceeded', }); directSearch.directWebSearch.mockRejectedValue(errorWithCode); const result = await duckDuckGoNode.execute.call(mockExecuteFunction); expect(result).toHaveLength(1); expect(result[0]).toHaveLength(1); expect(result[0][0].json).toHaveProperty('success', false); expect(result[0][0].json).toHaveProperty('error'); expect(result[0][0].json.error).toContain('Rate limit exceeded'); expect(result[0][0].json).toHaveProperty('errorDetails'); }); it('should throw NodeApiError when validation fails and continueOnFail is false', async () => { mockGetNodeParameter.mockImplementation((parameter) => { if (parameter === 'operation') return 'search'; if (parameter === 'query') return ''; if (parameter === 'webSearchOptions') return { useSearchOperators: false, safeSearch: 1, locale: 'us-en', timePeriod: '', useCache: false, cacheTtl: 300, debugMode: false }; return null; }); mockExecuteFunction.continueOnFail = jest.fn().mockReturnValue(false); await expect(duckDuckGoNode.execute.call(mockExecuteFunction)) .rejects .toThrow(); }); }); describe('Input Validation', () => { it('should validate maxResults is within acceptable range', async () => { setupNodeParameters('search', 'test query', { debugMode: true }); directSearch.directWebSearch.mockResolvedValue({ results: [] }); await duckDuckGoNode.execute.call(mockExecuteFunction); expect(directSearch.directWebSearch).toHaveBeenCalledWith('"test query"', expect.not.objectContaining({ maxResults: expect.any(Number), })); }); it('should validate and handle invalid time period', async () => { setupNodeParameters('search', 'time period test', { timePeriod: 'invalidValue' }); directSearch.directWebSearch.mockResolvedValue({ results: [] }); await duckDuckGoNode.execute.call(mockExecuteFunction); expect(directSearch.directWebSearch).toHaveBeenCalledWith('time period test', expect.not.objectContaining({ timePeriod: 'invalidValue' })); }); }); describe('Multiple Input Items', () => { it('should process multiple input items correctly', async () => { mockExecuteFunction.getInputData = jest.fn().mockReturnValue([ { json: { query: 'first query' } }, { json: { query: 'second query' } } ]); mockGetNodeParameter.mockImplementation((parameter, itemIndex) => { if (parameter === 'operation') return 'search'; if (parameter === 'query') return `query ${itemIndex + 1}`; if (parameter === 'webSearchOptions') return { maxResults: 5 }; return null; }); directSearch.directWebSearch .mockResolvedValueOnce({ results: [{ title: 'Result 1', url: 'https://example.com/1', description: 'Desc 1' }] }) .mockResolvedValueOnce({ results: [{ title: 'Result 2', url: 'https://example.com/2', description: 'Desc 2' }] }); const result = await duckDuckGoNode.execute.call(mockExecuteFunction); expect(directSearch.directWebSearch).toHaveBeenCalledTimes(2); expect(directSearch.directWebSearch).toHaveBeenNthCalledWith(1, '"query 1"', expect.any(Object)); expect(directSearch.directWebSearch).toHaveBeenNthCalledWith(2, '"query 2"', expect.any(Object)); expect(result).toHaveLength(1); expect(result[0]).toHaveLength(2); expect(result[0][0].json).toHaveProperty('title', 'Result 1'); expect(result[0][1].json).toHaveProperty('title', 'Result 2'); expect(result[0][0].pairedItem).toEqual({ item: 0 }); expect(result[0][1].pairedItem).toEqual({ item: 1 }); }); }); });