diff --git a/scripts/tools/tools-object.js b/scripts/tools/tools-object.js index e5a30334f7d..1d8c73f8074 100644 --- a/scripts/tools/tools-object.js +++ b/scripts/tools/tools-object.js @@ -9,7 +9,6 @@ addFormats(ajv, ["uri"]) const validate = ajv.compile(schema) const { convertToJson } = require('../utils'); - // Config options set for the Fuse object const options = { includeScore: true, @@ -25,8 +24,8 @@ 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='') => { - let resultantObject = { +const createToolObject = async (toolFile, repositoryUrl = '', repoDescription = '', isAsyncAPIrepo = '') => { + const resultantObject = { title: toolFile.title, description: toolFile?.description ? toolFile.description : repoDescription, links: { @@ -47,67 +46,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; - - // 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: [] - }; - } + try { + let finalToolsObject = {}; + const dataArray = data.items; - 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}`; + // initialising finalToolsObject with all categories inside it with proper elements in each category + finalToolsObject = Object.fromEntries( + categoryList.map((category) => [ + category.name, + { + description: category.description, + toolsList: [] + } + ]) + ); - const { data: toolFileContent } = await axios.get(download_url); + await Promise.all(dataArray.map(async (tool) => { + try { + if (tool.name.startsWith('.asyncapi-tool')) { + const referenceId = tool.url.split('=')[1]; + const downloadUrl = `https://raw.githubusercontent.com/${tool.repository.full_name}/${referenceId}/${tool.path}`; - //some stuff can be YAML - const jsonToolFileContent = await convertToJson(toolFileContent) + const { data: toolFileContent } = await axios.get(downloadUrl); - //validating against JSON Schema for tools file - const isValid = await validate(jsonToolFileContent) + //some stuff can be YAML + const jsonToolFileContent = await convertToJson(toolFileContent) - 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); + //validating against JSON Schema for tools file + const isValid = await validate(jsonToolFileContent) - // 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 (isValid) { + const repositoryUrl = tool.repository.html_url; + const repoDescription = tool.repository.description; + const isAsyncAPIrepo = tool.repository.owner.login === 'asyncapi'; + const toolObject = await createToolObject( + jsonToolFileContent, + repositoryUrl, + repoDescription, + isAsyncAPIrepo + ); - 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)); + // Tool Object is appended to each category array according to Fuse search for categories inside Tool Object + await Promise.all(jsonToolFileContent.filters.categories.map(async (category) => { + const categorySearch = await fuse.search(category); + const targetCategory = categorySearch.length ? categorySearch[0].item.name : 'Others'; + const { toolsList } = finalToolsObject[targetCategory]; + if (!toolsList.includes(toolObject)) { + 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 } diff --git a/tests/helper/toolsObjectData.js b/tests/helper/toolsObjectData.js new file mode 100644 index 00000000000..929d985f977 --- /dev/null +++ b/tests/helper/toolsObjectData.js @@ -0,0 +1,79 @@ +const createToolRepositoryData = ({ + name = '.asyncapi-tool', + refId = '61855e7365a881e98c2fe667a658a0005753d873', + owner = 'asyncapi', + repoName = 'example-repo', + description = 'Example repository', + path = '.asyncapi-tool' +} = {}) => ({ + name, + url: `https://api.github.com/repositories/351453552/contents/${path}?ref=${refId}`, + repository: { + full_name: `${owner}/${repoName}`, + html_url: `https://github.com/${owner}/${repoName}`, + description, + owner: { login: owner } + }, + path +}); + +const createToolFileContent = ({ + title = 'Example Tool', + description = 'This is an example tool.', + repoUrl = null, + categories = ['Category1'], + hasCommercial = false, + additionalLinks = {}, + additionalFilters = {} +} = {}) => ({ + title, + description, + links: { + repoUrl: repoUrl || `https://github.com/asyncapi/${encodeURIComponent(title.toLowerCase().replace(/\s+/g, '-'))}`, + ...additionalLinks + }, + filters: { categories, hasCommercial, ...additionalFilters } +}); + +const createExpectedToolObject = ({ + title = 'Example Tool', + description = 'This is an example tool.', + repoUrl = null, + categories = ['Category1'], + hasCommercial = false, + isAsyncAPIOwner = true, + additionalLinks = {}, + additionalFilters = {} +} = {}) => + createToolFileContent({ + title, + description, + repoUrl, + categories, + hasCommercial, + additionalLinks, + additionalFilters: { isAsyncAPIOwner, ...additionalFilters } + }); + +const createMockData = (tools = []) => ({ + items: tools.map((tool) => + typeof tool === 'string' + ? createToolRepositoryData({ name: `.asyncapi-tool-${tool}`, repoName: tool }) + : createToolRepositoryData(tool) + ) +}); + +const createMalformedYAML = ({ + title = 'Malformed Tool', + description = 'This tool has malformed YAML.', + repoUrl = 'https://github.com/asyncapi/malformed-repo' } = {}) => ` + title: ${title} + description: ${description} + links: + repoUrl: ${repoUrl} + filters: + categories: + - Category1 +`; + +module.exports = { createToolFileContent, createExpectedToolObject, createMockData, createMalformedYAML }; diff --git a/tests/tools/tools-object.test.js b/tests/tools/tools-object.test.js new file mode 100644 index 00000000000..2577a2a27d9 --- /dev/null +++ b/tests/tools/tools-object.test.js @@ -0,0 +1,161 @@ +const axios = require('axios'); +const { convertTools, createToolObject } = require('../../scripts/tools/tools-object'); +const { + createToolFileContent, + createExpectedToolObject, + createMockData, + createMalformedYAML +} = require('../helper/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(); + }); + + const mockToolData = (toolContent, toolNames = ['valid-tool']) => { + const mockData = createMockData(toolNames.map((name) => ({ name: `.asyncapi-tool-${name}`, repoName: name }))); + axios.get.mockResolvedValue({ data: toolContent }); + return mockData; + }; + + it('should create a tool object with provided parameters', async () => { + const toolFile = createToolFileContent({ + title: 'Test Tool', + description: 'Test Description', + hasCommercial: true, + additionalLinks: { docsUrl: 'https://docs.example.com' } + }); + + const expected = createExpectedToolObject({ + title: 'Test Tool', + description: 'Test Description', + hasCommercial: true, + additionalLinks: { docsUrl: 'https://docs.example.com' } + }); + + const result = await createToolObject( + toolFile, + expected.links.repoUrl, + 'Repository Description', + true + ); + + expect(result).toEqual(expected); + }); + + it('should convert tools data correctly', async () => { + const toolContent = createToolFileContent({ title: 'Valid Tool', categories: ['Category1'] }); + const mockData = mockToolData(toolContent); + + const result = await convertTools(mockData); + + expect(result.Category1.toolsList).toHaveLength(1); + expect(result.Category1.toolsList[0].title).toBe('Valid Tool'); + }); + + it('should assign tool to Others category if no matching category is found', async () => { + const toolContent = createToolFileContent({ title: 'Unknown Category Tool', categories: ['UnknownCategory'] }); + const mockData = mockToolData(toolContent); + + const result = await convertTools(mockData); + + expect(result.Others.toolsList).toHaveLength(1); + expect(result.Others.toolsList[0].title).toBe('Unknown Category Tool'); + }); + + it('should log errors for invalid .asyncapi-tool file', async () => { + const invalidContent = createToolFileContent({ + title: 'Invalid Tool', + additionalFilters: { invalidField: true } + }); + const mockData = mockToolData(invalidContent); + + await convertTools(mockData); + + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Script is not failing')); + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Invalid .asyncapi-tool file')); + }); + + it('should add duplicate tool objects to the same category', async () => { + const toolContent = createToolFileContent({ + title: 'Duplicate Tool', + categories: ['Category1'] + }); + + const mockData = createMockData([ + { name: '.asyncapi-tool-dup1', repoName: 'dup1' }, + { name: '.asyncapi-tool-dup2', repoName: 'dup2' } + ]); + + axios.get.mockResolvedValue({ data: toolContent }); + + const result = await convertTools(mockData); + + 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 () => { + const toolContent = createToolFileContent({ + title: 'Duplicate Tool in Others', + categories: ['UnknownCategory'] + }); + + const mockData = mockToolData(toolContent); + + const result = await convertTools(mockData); + + expect(result.Others.toolsList).toHaveLength(1); + expect(result.Others.toolsList[0].title).toBe('Duplicate Tool in Others'); + }); + + it('should throw an error if axios.get fails', async () => { + const mockData = createMockData([{ + name: '.asyncapi-tool-error', + repoName: 'error-tool' + }]); + + axios.get.mockRejectedValue(new Error('Network Error')); + + await expect(convertTools(mockData)).rejects.toThrow('Network Error'); + }); + + it('should handle malformed JSON in tool file', async () => { + const malformedContent = createMalformedYAML(); + await expect(convertTools(malformedContent)).rejects.toThrow(); + }); + + it('should use repository description when tool description is missing', async () => { + const toolFile = createToolFileContent({ + title: 'No Description Tool', + description: '', + }); + + const repositoryDescription = 'Fallback Repository Description'; + const mockData = createMockData([{ + name: '.asyncapi-tool-no-description', + repoName: 'no-description', + description: repositoryDescription + }]); + + axios.get.mockResolvedValue({ data: toolFile }); + + const result = await convertTools(mockData); + + const toolObject = result.Category1.toolsList[0]; + + expect(toolObject.description).toBe(repositoryDescription); + expect(toolObject.title).toBe('No Description Tool'); + }); + +});