diff --git a/scripts/build-newsroom-videos.js b/scripts/build-newsroom-videos.js index b67ee0378cf2..f4cf83066925 100644 --- a/scripts/build-newsroom-videos.js +++ b/scripts/build-newsroom-videos.js @@ -1,9 +1,15 @@ -const { writeFileSync } = require('fs'); -const { resolve } = require('path'); +const { writeFileSync, mkdirSync, existsSync } = require('fs'); +const { resolve, dirname } = require('path'); const fetch = require('node-fetch-2'); async function buildNewsroomVideos(writePath) { try { + const dir = dirname(writePath); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const response = await fetch('https://youtube.googleapis.com/youtube/v3/search?' + new URLSearchParams({ key: process.env.YOUTUBE_TOKEN, part: 'snippet', @@ -19,7 +25,6 @@ async function buildNewsroomVideos(writePath) { } const data = await response.json(); - console.log(data) if (!data.items || !Array.isArray(data.items)) { throw new Error('Invalid data structure received from YouTube API'); @@ -35,7 +40,7 @@ async function buildNewsroomVideos(writePath) { const videoData = JSON.stringify(videoDataItems, null, ' '); console.log('The following are the Newsroom Youtube videos: ', videoData); - writeFileSync(writePath, videoData); + await retryWriteFile(writePath, videoData); return videoData; } catch (err) { @@ -43,9 +48,26 @@ async function buildNewsroomVideos(writePath) { } } +async function retryWriteFile(filePath, data, retries = 3, delay = 1000) { + for (let attempt = 0; attempt < retries; attempt++) { + try { + writeFileSync(filePath, data); + console.log(`File written successfully to ${filePath}`); + break; + } catch (err) { + if (err.code === 'ENOENT') { + console.error(`ENOENT error on attempt ${attempt + 1}. Retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } else { + throw err; + } + } + } +} + /* istanbul ignore next */ if (require.main === module) { - buildNewsroomVideos(resolve(__dirname, '../config', 'newsroom_videos.json')) + buildNewsroomVideos(resolve(__dirname, '../config', 'newsroom_videos.json')); } module.exports = { buildNewsroomVideos }; diff --git a/scripts/build-tools.js b/scripts/build-tools.js index 84965815dcc4..2b7c47768f78 100644 --- a/scripts/build-tools.js +++ b/scripts/build-tools.js @@ -2,17 +2,20 @@ const { getData } = require('./tools/extract-tools-github'); const { convertTools } = require('./tools/tools-object'); const { combineTools } = require('./tools/combine-tools'); const fs = require('fs'); -const { resolve } = require('path'); +const { resolve, dirname } = require('path'); const buildTools = async (automatedToolsPath, manualToolsPath, toolsPath, tagsPath) => { try { let githubExtractData = await getData(); let automatedTools = await convertTools(githubExtractData); + + const automatedDir = dirname(automatedToolsPath); - fs.writeFileSync( - automatedToolsPath, - JSON.stringify(automatedTools, null, ' ') - ); + if (!fs.existsSync(automatedDir)) { + fs.mkdirSync(automatedDir, { recursive: true }); + } + + await retryWriteFile(automatedToolsPath, JSON.stringify(automatedTools, null, ' ')); await combineTools(automatedTools, require(manualToolsPath), toolsPath, tagsPath); } catch (err) { @@ -20,6 +23,23 @@ const buildTools = async (automatedToolsPath, manualToolsPath, toolsPath, tagsPa } }; +async function retryWriteFile(filePath, data, retries = 3, delay = 1000) { + for (let attempt = 0; attempt < retries; attempt++) { + try { + fs.writeFileSync(filePath, data); + console.log(`File written successfully to ${filePath}`); + break; + } catch (err) { + if (err.code === 'ENOENT') { + console.error(`ENOENT error on attempt ${attempt + 1}. Retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } else { + throw err; + } + } + } +} + /* istanbul ignore next */ if (require.main === module) { const automatedToolsPath = resolve(__dirname, '../config', 'tools-automated.json'); diff --git a/scripts/tools/tools-object.js b/scripts/tools/tools-object.js index e5a30334f7d3..b89d72b3f560 100644 --- a/scripts/tools/tools-object.js +++ b/scripts/tools/tools-object.js @@ -25,7 +25,7 @@ const fuse = new Fuse(categoryList, options) // isAsyncAPIrepo boolean variable to define whether the tool repository is under // AsyncAPI organization or not, to create a JSON tool object as required in the frontend // side to show ToolCard. -const createToolObject = async (toolFile, repositoryUrl='', repoDescription='', isAsyncAPIrepo='') => { +const createToolObject = async (toolFile, repositoryUrl = '', repoDescription = '', isAsyncAPIrepo = '') => { let resultantObject = { title: toolFile.title, description: toolFile?.description ? toolFile.description : repoDescription, @@ -47,67 +47,71 @@ const createToolObject = async (toolFile, repositoryUrl='', repoDescription='', // and creating a JSON tool object in which all the tools are listed in defined // categories order, which is then updated in `automated-tools.json` file. async function convertTools(data) { - let finalToolsObject = {}; - const dataArray = data.items; + try { + let finalToolsObject = {}; + const dataArray = data.items; - // initialising finalToolsObject with all categories inside it with proper elements in each category - for (var index in categoryList) { - finalToolsObject[categoryList[index].name] = { - description: categoryList[index].description, - toolsList: [] - }; - } + // initialising finalToolsObject with all categories inside it with proper elements in each category + for (var index in categoryList) { + finalToolsObject[categoryList[index].name] = { + description: categoryList[index].description, + toolsList: [] + }; + } - for (let tool of dataArray) { - try { - if (tool.name.startsWith('.asyncapi-tool')) { - // extracting the reference id of the repository which will be used to extract the path of the .asyncapi-tool file in the Tools repository - // ex: for a url = "https://api.github.com/repositories/351453552/contents/.asyncapi-tool?ref=61855e7365a881e98c2fe667a658a0005753d873" - // the text (id) present after '=' gives us a reference id for the repo - let reference_id = tool.url.split("=")[1]; - let download_url = `https://raw.githubusercontent.com/${tool.repository.full_name}/${reference_id}/${tool.path}`; + for (let tool of dataArray) { + try { + if (tool.name.startsWith('.asyncapi-tool')) { + // extracting the reference id of the repository which will be used to extract the path of the .asyncapi-tool file in the Tools repository + // ex: for a url = "https://api.github.com/repositories/351453552/contents/.asyncapi-tool?ref=61855e7365a881e98c2fe667a658a0005753d873" + // the text (id) present after '=' gives us a reference id for the repo + let reference_id = tool.url.split("=")[1]; + let download_url = `https://raw.githubusercontent.com/${tool.repository.full_name}/${reference_id}/${tool.path}`; - const { data: toolFileContent } = await axios.get(download_url); + const { data: toolFileContent } = await axios.get(download_url); - //some stuff can be YAML - const jsonToolFileContent = await convertToJson(toolFileContent) + //some stuff can be YAML + const jsonToolFileContent = await convertToJson(toolFileContent) - //validating against JSON Schema for tools file - const isValid = await validate(jsonToolFileContent) + //validating against JSON Schema for tools file + const isValid = await validate(jsonToolFileContent) - if (isValid) { - let repositoryUrl = tool.repository.html_url; - let repoDescription = tool.repository.description; - let isAsyncAPIrepo = tool.repository.owner.login === "asyncapi"; - let toolObject = await createToolObject(jsonToolFileContent, repositoryUrl, repoDescription, isAsyncAPIrepo); + if (isValid) { + let repositoryUrl = tool.repository.html_url; + let repoDescription = tool.repository.description; + let isAsyncAPIrepo = tool.repository.owner.login === "asyncapi"; + let toolObject = await createToolObject(jsonToolFileContent, repositoryUrl, repoDescription, isAsyncAPIrepo); - // Tool Object is appended to each category array according to Fuse search for categories inside Tool Object - jsonToolFileContent.filters.categories.forEach(async (category) => { - const categorySearch = await fuse.search(category); + // Tool Object is appended to each category array according to Fuse search for categories inside Tool Object + jsonToolFileContent.filters.categories.forEach(async (category) => { + const categorySearch = await fuse.search(category); - if (categorySearch.length) { - let searchedCategoryName = categorySearch[0].item.name - if (!finalToolsObject[searchedCategoryName].toolsList.find((element => element === toolObject))) - finalToolsObject[searchedCategoryName].toolsList.push(toolObject); - } else { - // if Tool object has a category, not defined in our categorylist, then this provides a `other` category to the tool. - if (!finalToolsObject['Others'].toolsList.find((element => element === toolObject))) - finalToolsObject['Others'].toolsList.push(toolObject); - } - }); - } else { - console.error('Script is not failing, it is just dropping errors for further investigation'); - console.error('Invalid .asyncapi-tool file.'); - console.error(`Located in: ${tool.html_url}`); - console.error('Validation errors:', JSON.stringify(validate.errors, null, 2)); + if (categorySearch.length) { + let searchedCategoryName = categorySearch[0].item.name + if (!finalToolsObject[searchedCategoryName].toolsList.find((element => element === toolObject))) + finalToolsObject[searchedCategoryName].toolsList.push(toolObject); + } else { + // if Tool object has a category, not defined in our categorylist, then this provides a `other` category to the tool. + if (!finalToolsObject['Others'].toolsList.find((element => element === toolObject))) + finalToolsObject['Others'].toolsList.push(toolObject); + } + }); + } else { + console.error('Script is not failing, it is just dropping errors for further investigation'); + console.error('Invalid .asyncapi-tool file.'); + console.error(`Located in: ${tool.html_url}`); + console.error('Validation errors:', JSON.stringify(validate.errors, null, 2)); + } } + } catch (err) { + console.error(err) + throw err; } - } catch (err) { - console.error(err) - throw err; } + return finalToolsObject; + } catch (err) { + throw new Error(`Error processing tool: ${err.message}`) } - return finalToolsObject; } -module.exports = {convertTools, createToolObject} \ No newline at end of file +module.exports = { convertTools, createToolObject } \ No newline at end of file diff --git a/tests/build-newsroom-videos.test.js b/tests/build-newsroom-videos.test.js index 63f571466944..704b479fa0e3 100644 --- a/tests/build-newsroom-videos.test.js +++ b/tests/build-newsroom-videos.test.js @@ -1,4 +1,4 @@ -const { readFileSync, rmSync, mkdirSync } = require('fs'); +const { readFileSync, rmSync, mkdirSync, existsSync } = require('fs'); const { resolve } = require('path'); const { buildNewsroomVideos } = require('../scripts/build-newsroom-videos'); const { mockApiResponse, expectedResult } = require('./fixtures/newsroomData'); @@ -11,15 +11,19 @@ describe('buildNewsroomVideos', () => { const testFilePath = resolve(testDir, 'newsroom_videos.json'); beforeAll(() => { - mkdirSync(testDir, { recursive: true }); process.env.YOUTUBE_TOKEN = 'testkey'; }); afterAll(() => { - rmSync(testDir, { recursive: true, force: true }); + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } }); beforeEach(() => { + if (!existsSync(testDir)) { + mkdirSync(testDir, { recursive: true }); + } fetch.mockClear(); }); @@ -29,6 +33,10 @@ describe('buildNewsroomVideos', () => { json: jest.fn().mockResolvedValue(mockApiResponse), }); + if (!existsSync(testDir)) { + mkdirSync(testDir, { recursive: true }); + } + const result = await buildNewsroomVideos(testFilePath); const expectedUrl = new URL('https://youtube.googleapis.com/youtube/v3/search'); @@ -41,6 +49,7 @@ describe('buildNewsroomVideos', () => { expectedUrl.searchParams.set('maxResults', '5'); expect(fetch).toHaveBeenCalledWith(expectedUrl.toString()); + const response = readFileSync(testFilePath, 'utf8'); expect(response).toEqual(expectedResult); expect(result).toEqual(expectedResult); @@ -97,5 +106,4 @@ describe('buildNewsroomVideos', () => { expect(err.message).toMatch(/ENOENT|EACCES/); } }); - }); diff --git a/tests/build-tools.test.js b/tests/build-tools.test.js index 2bc4592e8e11..c4feff26227c 100644 --- a/tests/build-tools.test.js +++ b/tests/build-tools.test.js @@ -3,6 +3,7 @@ const { resolve } = require('path'); const { buildTools } = require('../scripts/build-tools'); const { tagsData, manualTools, mockConvertedData, mockExtractData } = require('../tests/fixtures/buildToolsData'); const fs = require('fs'); +const { beforeEach, afterEach } = require('node:test'); jest.mock('axios'); jest.mock('../scripts/tools/categorylist', () => ({ @@ -30,22 +31,41 @@ describe('buildTools', () => { const automatedToolsPath = resolve(testDir, 'tools-automated.json'); const manualToolsPath = resolve(testDir, 'tools-manual.json'); - beforeAll(() => { + if (!fs.existsSync(testDir)) { fs.mkdirSync(testDir, { recursive: true }); + } + if (!fs.existsSync(manualToolsPath)) { fs.writeFileSync(manualToolsPath, JSON.stringify(manualTools)); + } + + let consoleErrorMock; + + beforeAll(() => { + consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {}); }); afterAll(() => { - fs.rmSync(testDir, { recursive: true, force: true }); + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + consoleErrorMock.mockRestore(); }); beforeEach(() => { jest.clearAllMocks(); + + if (!fs.existsSync(manualToolsPath)) { + fs.writeFileSync(manualToolsPath, JSON.stringify(manualTools)); + } }); it('should extract, convert, combine tools, and write to file', async () => { axios.get.mockResolvedValue({ data: mockExtractData }); + if (!fs.existsSync(testDir)) { + fs.mkdirSync(testDir, { recursive: true }); + } + await buildTools(automatedToolsPath, manualToolsPath, toolsPath, tagsPath); const automatedToolsContent = JSON.parse(fs.readFileSync(automatedToolsPath, 'utf8')); @@ -62,7 +82,6 @@ describe('buildTools', () => { expect(combinedToolsContent["Category2"].description).toEqual(mockConvertedData["Category2"].description); expect(tagsContent).toEqual(tagsData); - }); it('should handle getData error', async () => { diff --git a/tests/fixtures/toolsObjectData.js b/tests/fixtures/toolsObjectData.js new file mode 100644 index 000000000000..24865e0065d8 --- /dev/null +++ b/tests/fixtures/toolsObjectData.js @@ -0,0 +1,280 @@ +const mockData = { + items: [ + { + name: '.asyncapi-tool-example', + url: 'https://api.github.com/repositories/351453552/contents/.asyncapi-tool?ref=61855e7365a881e98c2fe667a658a0005753d873', + repository: { + full_name: 'asyncapi/example-repo', + html_url: 'https://github.com/asyncapi/example-repo', + description: 'Example repository', + owner: { + login: 'asyncapi' + } + }, + path: '.asyncapi-tool' + } + ] +}; + +const mockToolFileContent = { + title: "Example Tool", + description: "This is an example tool.", + links: { + repoUrl: "https://github.com/asyncapi/example-repo" + }, + filters: { + categories: ["Category1"], + hasCommercial: true + } +}; + +const toolFileT1 = { + title: 'Example Tool', + description: 'This is an example tool.', + links: { + repoUrl: 'https://github.com/asyncapi/example-repo' + }, + filters: { + categories: ['Category1'], + hasCommercial: true + } +}; + +const expectedObjectT1 = { + title: 'Example Tool', + description: 'This is an example tool.', + links: { + repoUrl: 'https://github.com/asyncapi/example-repo' + }, + filters: { + categories: ['Category1'], + hasCommercial: true, + isAsyncAPIOwner: true + } +}; + +const repositoryUrl = 'https://github.com/asyncapi/example-repo'; +const repoDescription = 'Example repository'; +const isAsyncAPIOwner = true; + +const toolFileT2 = { + title: 'Example Tool', + links: { + repoUrl: 'https://github.com/asyncapi/example-repo' + }, + filters: { + categories: ['Category1'] + } +}; + +const expectedObjectT2 = { + title: 'Example Tool', + description: 'Example repository', + links: { + repoUrl: 'https://github.com/asyncapi/example-repo' + }, + filters: { + categories: ['Category1'], + hasCommercial: false, + isAsyncAPIOwner: true + } +}; + +const expectedObjectT3 = { + Category1: { + description: 'Description for Category1', + toolsList: [ + { + title: 'Example Tool', + description: 'This is an example tool.', + links: { + repoUrl: 'https://github.com/asyncapi/example-repo' + }, + filters: { + categories: ['Category1'], + hasCommercial: true, + isAsyncAPIOwner: true + } + } + ] + }, + Others: { + description: 'Other tools category', + toolsList: [] + } +}; + +const dataWithUnknownCategory = { + items: [ + { + name: '.asyncapi-tool-unknown', + url: 'https://api.github.com/repositories/351453552/contents/.asyncapi-tool?ref=61855e7365a881e98c2fe667a658a0005753d873', + repository: { + full_name: 'asyncapi/unknown-repo', + html_url: 'https://github.com/asyncapi/unknown-repo', + description: 'Unknown repository', + owner: { + login: 'asyncapi' + } + }, + path: '.asyncapi-tool' + } + ] +}; + +const toolFileContent = { + title: "Unknown Tool", + description: "This tool has an unknown category.", + links: { + repoUrl: "https://github.com/asyncapi/unknown-repo" + }, + filters: { + categories: ["UnknownCategory"] + } +}; + +const invalidToolFileContent = { + title: "Invalid Tool", + description: "This tool has invalid schema.", + links: { + repoUrl: "https://github.com/asyncapi/invalid-repo" + }, + filters: { + categories: ["Category1"], + invalidField: true + } +}; + +const invalidToolData = { + items: [ + { + name: '.asyncapi-tool-invalid', + url: 'https://api.github.com/repositories/351453552/contents/.asyncapi-tool?ref=invalidref', + repository: { + full_name: 'asyncapi/invalid-repo', + html_url: 'https://github.com/asyncapi/invalid-repo', + description: 'Invalid repository', + owner: { + login: 'asyncapi' + } + }, + path: '.asyncapi-tool' + } + ] +}; + +const duplicateToolData = { + items: [ + { + name: '.asyncapi-tool-duplicate', + url: 'https://api.github.com/repositories/351453552/contents/.asyncapi-tool?ref=duplicate1', + repository: { + full_name: 'asyncapi/duplicate-repo', + html_url: 'https://github.com/asyncapi/duplicate-repo', + description: 'Duplicate repository', + owner: { + login: 'asyncapi' + } + }, + path: '.asyncapi-tool' + }, + { + name: '.asyncapi-tool-duplicate', + url: 'https://api.github.com/repositories/351453552/contents/.asyncapi-tool?ref=duplicate2', + repository: { + full_name: 'asyncapi/duplicate-repo', + html_url: 'https://github.com/asyncapi/duplicate-repo', + description: 'Duplicate repository', + owner: { + login: 'asyncapi' + } + }, + path: '.asyncapi-tool' + } + ] +}; + +const duplicateToolFileContent = { + title: "Duplicate Tool", + description: "This is a duplicate tool.", + links: { + repoUrl: "https://github.com/asyncapi/duplicate-repo" + }, + filters: { + categories: ["Category1"] + } +}; + +const dataWithUnknownCategoryOnce = { + items: [ + { + name: '.asyncapi-tool-unknown', + url: 'https://api.github.com/repositories/351453552/contents/.asyncapi-tool?ref=unknown1', + repository: { + full_name: 'asyncapi/unknown-repo', + html_url: 'https://github.com/asyncapi/unknown-repo', + description: 'Unknown repository', + owner: { + login: 'asyncapi' + } + }, + path: '.asyncapi-tool' + }, + { + name: '.asyncapi-tool-unknown', + url: 'https://api.github.com/repositories/351453552/contents/.asyncapi-tool?ref=unknown2', + repository: { + full_name: 'asyncapi/unknown-repo', + html_url: 'https://github.com/asyncapi/unknown-repo', + description: 'Unknown repository', + owner: { + login: 'asyncapi' + } + }, + path: '.asyncapi-tool' + } + ] +}; + +const unknownToolFileContent = { + title: "Unknown Tool", + description: "This tool has an unknown category.", + links: { + repoUrl: "https://github.com/asyncapi/unknown-repo" + }, + filters: { + categories: ["UnknownCategory"] + } +}; + +const toolFileMalformedJSON = ` + title: Malformed Tool + description: This tool has malformed JSON. + links: + repoUrl: https://github.com/asyncapi/malformed-repo + filters + categories: + - Category1 + `; + +module.exports = { + mockData, + mockToolFileContent, + toolFileT1, + expectedObjectT1, + repoDescription, + repositoryUrl, + isAsyncAPIOwner, + toolFileT2, + expectedObjectT2, + expectedObjectT3, + dataWithUnknownCategory, + toolFileContent, + invalidToolFileContent, + invalidToolData, + duplicateToolData, + duplicateToolFileContent, + dataWithUnknownCategoryOnce, + unknownToolFileContent, + toolFileMalformedJSON, +}; diff --git a/tests/tools/tools-object.test.js b/tests/tools/tools-object.test.js new file mode 100644 index 000000000000..7706e24572fd --- /dev/null +++ b/tests/tools/tools-object.test.js @@ -0,0 +1,148 @@ +const { convertTools, createToolObject } = require('../../scripts/tools/tools-object'); +const axios = require('axios'); + +const { mockData, + mockToolFileContent, + toolFileT1, + expectedObjectT1, + repoDescription, + repositoryUrl, + isAsyncAPIOwner, + toolFileT2, + expectedObjectT2, + expectedObjectT3, + dataWithUnknownCategory, + toolFileContent, + invalidToolFileContent, + invalidToolData, + duplicateToolData, + duplicateToolFileContent, + dataWithUnknownCategoryOnce, + unknownToolFileContent, + toolFileMalformedJSON, +} = require("../fixtures/toolsObjectData") + +jest.mock('axios'); + +jest.mock('../../scripts/tools/categorylist', () => ({ + categoryList: [ + { name: 'Category1', tag: 'Category1', description: 'Description for Category1' }, + { name: 'Others', tag: 'Others', description: 'Other tools category' }, + ] +})); + +describe('Tools Object', () => { + + beforeEach(() => { + axios.get.mockClear(); + console.error = jest.fn(); + }); + + it('should create a tool object with provided parameters', async () => { + + const result = await createToolObject(toolFileT1, repositoryUrl, repoDescription, isAsyncAPIOwner); + expect(result).toEqual(expectedObjectT1); + }); + + it('should use repoDescription when toolFile.description is not provided', async () => { + + const result = await createToolObject(toolFileT2, repositoryUrl, repoDescription, isAsyncAPIOwner); + expect(result).toEqual(expectedObjectT2); + }); + + it('should convert tools data correctly', async () => { + axios.get.mockResolvedValue({ data: mockToolFileContent }); + + const result = await convertTools(mockData); + + expect(result).toEqual(expect.objectContaining(expectedObjectT3)); + expect(axios.get).toHaveBeenCalledTimes(1); + }); + + it('should assign tool to Others category if no matching category is found', async () => { + + axios.get.mockResolvedValue({ data: toolFileContent }); + + const result = await convertTools(dataWithUnknownCategory); + + expect(result.Others.toolsList).toHaveLength(1); + expect(result.Others.toolsList[0].title).toBe('Unknown Tool'); + }); + + it('should log errors for invalid .asyncapi-tool file', async () => { + + axios.get.mockResolvedValue({ data: invalidToolFileContent }); + + let error; + try { + await convertTools(invalidToolData); + } catch (err) { + error = err; + } + + expect(error).toBeUndefined(); + + const allErrorMessages = console.error.mock.calls.flat(); + expect(allErrorMessages).toEqual( + expect.arrayContaining([ + expect.stringContaining('Script is not failing, it is just dropping errors for further investigation'), + expect.stringContaining('Invalid .asyncapi-tool file'), + expect.stringContaining('Located in:'), + expect.stringContaining('Validation errors:') + ]) + ); + + }); + + it('should add duplicate tool objects to the same category', async () => { + + axios.get.mockResolvedValue({ data: duplicateToolFileContent }); + + const result = await convertTools(duplicateToolData); + + expect(result.Category1.toolsList).toHaveLength(2); + expect(result.Category1.toolsList[0].title).toBe('Duplicate Tool'); + expect(result.Category1.toolsList[1].title).toBe('Duplicate Tool'); + }); + + it('should add tool to Others category only once', async () => { + + axios.get.mockResolvedValue({ data: unknownToolFileContent }); + + const result = await convertTools(dataWithUnknownCategoryOnce); + + const uniqueTools = result.Others.toolsList.filter((tool, index, self) => + index === self.findIndex((t) => t.title === tool.title) + ); + + expect(uniqueTools).toHaveLength(1); + expect(uniqueTools[0].title).toBe('Unknown Tool'); + }); + + it('should throw an error if axios.get fails', async () => { + let error; + axios.get.mockRejectedValue(new Error('Network Error')); + + try { + await convertTools(mockData) + } catch (err) { + error = err; + expect(err.message).toContain("Network Error") + } + expect(error).toBeDefined(); + }); + + it('should handle malformed JSON in tool file', async () => { + axios.get.mockResolvedValue({ data: toolFileMalformedJSON }); + + let error; + try { + await convertTools(mockData); + } catch (err) { + error = err; + } + expect(error).toBeDefined(); + expect(error.message).toContain('Unexpected token'); + }); + +}); \ No newline at end of file