From d7748229efa16e2d3fee258d33319c115552c217 Mon Sep 17 00:00:00 2001 From: Rodrigo Pinto Date: Sun, 6 Apr 2025 01:41:54 -0300 Subject: [PATCH] Shortest path algorithm, tests and Postman - Added algorithm and endpoint to find the shortest path between two topics; - Added respective test; - Added a JSON collection with API calls that can be imported into Postman for the convenience of the user. --- ProjectMark.postman_collection.json | 224 ++++++++++++++++++++++++++++ README.md | 24 ++- jest.config.ts | 11 +- src/controllers/topicController.ts | 113 ++++++++------ src/models/topic.ts | 2 +- src/routes/topicRoutes.ts | 26 ++-- src/utils.ts | 47 ++++++ tests/mocks.ts | 46 ------ tests/topicController.test.ts | 44 +++++- 9 files changed, 419 insertions(+), 118 deletions(-) create mode 100644 ProjectMark.postman_collection.json diff --git a/ProjectMark.postman_collection.json b/ProjectMark.postman_collection.json new file mode 100644 index 0000000..1b1277a --- /dev/null +++ b/ProjectMark.postman_collection.json @@ -0,0 +1,224 @@ +{ + "info": { + "_postman_id": "5a75a442-f614-4bf5-88e5-5c340f1e357c", + "name": "ProjectMark", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "349764" + }, + "item": [ + { + "name": "Post topic", + "protocolProfileBehavior": { + "disabledSystemHeaders": { + "content-type": true + } + }, + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Sample topic\",\n \"content\": \"Topic content\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:3000/api/topics", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "topics" + ] + } + }, + "response": [] + }, + { + "name": "Load topics", + "protocolProfileBehavior": { + "disabledSystemHeaders": { + "content-type": true + } + }, + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Sample topic\",\n \"content\": \"Topic content\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:3000/api/topics", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "topics" + ] + } + }, + "response": [] + }, + { + "name": "Update topic", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Updated name\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "localhost:3000/api/topics/1743621367593", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "topics", + "1743621367593" + ] + } + }, + "response": [] + }, + { + "name": "Get topics", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "localhost:3000/api/topics", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "topics" + ] + } + }, + "response": [] + }, + { + "name": "Get topic", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "localhost:3000/api/topics", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "topics" + ] + } + }, + "response": [] + }, + { + "name": "Delete topic", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "localhost:3000/api/topics/1743714583807", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "topics", + "1743714583807" + ] + } + }, + "response": [] + }, + { + "name": "Get topic recursively", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "localhost:3000/api/topics", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "topics" + ] + } + }, + "response": [] + }, + { + "name": "Get shortest path", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "localhost:3000/api/topics/shortest?idA=1743738900800&idB=1743739100103", + "host": [ + "localhost" + ], + "port": "3000", + "path": [ + "api", + "topics", + "shortest" + ], + "query": [ + { + "key": "idA", + "value": "1743738900800" + }, + { + "key": "idB", + "value": "1743739100103" + } + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index bfa01b2..9c901cf 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,11 @@ npm run start ## Usage -You can use Postman or Curl to make calls to the API. The examples below use Curl. +You can use Postman or Curl to make calls to the API. + +If using Postman, you can import the collection `ProjectMark.postman_collection.json` from the root. + +The examples below use Curl. ### Create a topic: @@ -76,6 +80,24 @@ curl -X DELETE http://localhost:3000/api/topics/1234567890 > Replace `1234567890` with the topic id. +## Utilities + +### Retrieve topic and all its subtopics recursively + +```sh +curl -X GET http://localhost:3000/api/topics/recursive/1234567890 +``` + +> Replace `1234567890` with the topic id. + +### Shortest path between two topics in a tree + +```sh +curl -X GET http://localhost:3000/api/topics/shortest/1234567890/0987654321 +``` + +> Replace `1234567890` and `0987654321` with the topic ids. + ## Tests Run tests with: diff --git a/jest.config.ts b/jest.config.ts index 991324f..6324988 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,9 +3,12 @@ module.exports = { testEnvironment: 'node', moduleFileExtensions: ['ts', 'js'], testMatch: ['**/tests/**/*.test.(ts|js)'], - globals: { - 'ts-jest': { - tsconfig: 'tsconfig.json', - }, + transform: { + '^.+\\.ts?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json', + }, + ] }, }; diff --git a/src/controllers/topicController.ts b/src/controllers/topicController.ts index cb6964f..1d1a188 100644 --- a/src/controllers/topicController.ts +++ b/src/controllers/topicController.ts @@ -1,110 +1,127 @@ -import { Request, Response, NextFunction } from 'express'; -import { topics, Topic, TopicNode } from '../models/topic'; -import { finder } from '../utils' +import { Request, Response, NextFunction } from 'express' +import { topics, Topic, TopicNode } from '../models/topic' +import { finder, shortestPath } from '../utils' // Create a topic export const createTopic = (req: Request, res: Response, next: NextFunction) => { try { - const { name, content, parentTopicId } = req.body; + const { name, content, parentTopicId } = req.body const newTopic = new TopicNode(name, content, null, null, null, parentTopicId) - topics.push(newTopic); + topics.push(newTopic) - res.status(201).json(newTopic); + res.status(201).json(newTopic) } catch (error) { - next(error); + next(error) } -}; +} // Retrieve all topics export const getTopics = (req: Request, res: Response, next: NextFunction) => { try { - res.json(topics); + res.json(topics) } catch (error) { - next(error); + next(error) } -}; +} // Retrieve single topic export const getTopicById = (req: Request, res: Response, next: NextFunction) => { try { - const id = parseInt(req.params.id, 10); - const topic = topics.find((i) => i.id === id); + const id = parseInt(req.params.id, 10) + const topic = topics.find((i) => i.id === id) if (!topic) { - res.status(404).json({ message: 'Topic not found' }); - return; + res.status(404).json({ message: 'Topic not found' }) + return } - res.json(topic); + res.status(302).json(topic) } catch (error) { - next(error); + next(error) } -}; +} // Retrieve single version of a topic export const getTopicByIdVersion = (req: Request, res: Response, next: NextFunction) => { try { - const id = parseInt(req.params.id, 10); + const id = parseInt(req.params.id, 10) const version = parseInt(req.params.version, 10) - const topic = topics.find((i) => i.id === id && i.version === version); + const topic = topics.find((i) => i.id === id && i.version === version) if (!topic) { - res.status(404).json({ message: 'Topic not found' }); - return; + res.status(404).json({ message: 'Topic not found' }) + return } - res.json(topic); + res.status(302).json(topic) } catch (error) { - next(error); + next(error) } -}; +} -// Retrive topic and all subtopics recursively +// Retrieve topic and all subtopics recursively export const getTopicByIdRecursive = (req: Request, res: Response, next: NextFunction) => { try { - const id = parseInt(req.params.id, 10); - const topic = topics.find((i) => i.id === id); + const id = parseInt(req.params.id, 10) + const topic = topics.find((i) => i.id === id) if (!topic) { - res.status(404).json({ message: 'Topic not found' }); - return; + res.status(404).json({ message: 'Topic not found' }) + return } const result = finder(topic.id) - res.json(result); + res.status(302).json(result) } catch (error) { - next(error); + next(error) } -}; +} // Update a topic (create nenw version) export const updateTopic = (req: Request, res: Response, next: NextFunction) => { try { - const id = parseInt(req.params.id, 10); - const { name, content, parentTopicId } = req.body; + const id = parseInt(req.params.id, 10) + const { name, content, parentTopicId } = req.body const oldTopicArray = topics.filter(t => t.id === id) if (oldTopicArray.length === 0) { - res.status(404).json({ message: 'Topic not found' }); - return; + res.status(404).json({ message: 'Topic not found' }) + return } const oldTopic = oldTopicArray[oldTopicArray.length - 1] const newTopic = oldTopic.update(name, content, parentTopicId) - res.status(201).json(newTopic) + res.status(202).json(newTopic) } catch (error) { - next(error); + next(error) } -}; +} // Delete a topic export const deleteTopic = (req: Request, res: Response, next: NextFunction) => { try { - const id = parseInt(req.params.id, 10); - const topicIndex = topics.findIndex((i) => i.id === id); + const id = parseInt(req.params.id, 10) + const topicIndex = topics.findIndex((i) => i.id === id) if (topicIndex === -1) { - res.status(404).json({ message: 'Topic not found' }); - return; + res.status(404).json({ message: 'Topic not found' }) + return } - const deletedTopic = topics.splice(topicIndex, 1)[0]; - res.json(deletedTopic); + const deletedTopic = topics.splice(topicIndex, 1)[0] + res.status(202).json(deletedTopic) } catch (error) { - next(error); + next(error) } -}; +} + +// Find shortest path between topics +export const getShortestPath = (req: Request, res: Response, next: NextFunction) => { + try { + const idA = parseInt(req.params.idA, 10) + const idB = parseInt(req.params.idB, 10) + const result = shortestPath(idA, idB) + + if (result.length === 0 || result.name) { + res.status(404).json('path does not exist') + } else { + res.status(302).json(result) + } + } catch (error) { + next(error) + } +} diff --git a/src/models/topic.ts b/src/models/topic.ts index 2070945..b52b4b8 100644 --- a/src/models/topic.ts +++ b/src/models/topic.ts @@ -57,4 +57,4 @@ export class TopicNode implements Topic { } } -export const topics: Topic[] = []; +export const topics: Topic[] = [] diff --git a/src/routes/topicRoutes.ts b/src/routes/topicRoutes.ts index ffad0ee..0a60a8f 100644 --- a/src/routes/topicRoutes.ts +++ b/src/routes/topicRoutes.ts @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { Router } from 'express' import { createTopic, getTopics, @@ -6,17 +6,19 @@ import { getTopicByIdVersion, getTopicByIdRecursive, updateTopic, - deleteTopic -} from '../controllers/topicController'; + deleteTopic, + getShortestPath +} from '../controllers/topicController' -const router = Router(); +const router = Router({ mergeParams: true }) -router.get('/recursive/:id', getTopicByIdRecursive); -router.get('/:id', getTopicById); -router.get('/:id/:version', getTopicByIdVersion); -router.get('/', getTopics); -router.post('/', createTopic); -router.put('/:id', updateTopic); -router.delete('/:id', deleteTopic); +router.get('/recursive/:id', getTopicByIdRecursive) +router.get('/shortest/:idA/:idB', getShortestPath) +router.get('/:id', getTopicById) +router.get('/:id/:version', getTopicByIdVersion) +router.get('/', getTopics) +router.post('/', createTopic) +router.put('/:id', updateTopic) +router.delete('/:id', deleteTopic) -export default router; +export default router diff --git a/src/utils.ts b/src/utils.ts index e346873..b851bfb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -31,3 +31,50 @@ export const finder = (id: number) => { return (result) } + +// Find the shortest path between topics in a tree +export const shortestPath = (idA: number, idB: number) => { + try { + // Find topics of each input id + const topicA = topics.find(t => t.id === idA) + const topicB = topics.find(t => t.id === idB) + + if (!topicA || !topicB) throw new Error('Topic not found') + + // Create arrays to hold paths from both topics up to their roots + const pathA = [topicA] + const pathB = [topicB] + + // Create array to hold solution + let shortestPath = [] + + // Function to build paths from topic to its root + const buildPath = (topic: Topic, path: string) => { + if (topic.parentTopicId) { + const parent = topics.find(t => t.id === topic.parentTopicId) + if (path === 'a') pathA.push(parent) + if (path === 'b') pathB.push(parent) + buildPath(parent, path) + } else { + return + } + } + + // Build both paths + buildPath(topicA, 'a') + buildPath(topicB, 'b') + + // If there is a path linking both topics, save it as solution + pathA.forEach((p: Topic, i: number) => { + if (pathB.map(p => p.id).indexOf(p.id) !== -1) { + shortestPath = pathA.slice(0, i) + pathB.reverse() + shortestPath = shortestPath.concat(pathB.slice(pathB.indexOf(p))) + } + }) + + return (shortestPath) + } catch (error) { + return (error) + } +} diff --git a/tests/mocks.ts b/tests/mocks.ts index f3f0733..2cd322d 100644 --- a/tests/mocks.ts +++ b/tests/mocks.ts @@ -53,49 +53,3 @@ export const topicMocks: Topic[] = [ "parentTopicId": 1743738900320 } ]; - -export const treeMock = [ - { - id: 1743738847018, - name: 'First topic', - content: 'Topic content', - createdAt: 'Fri Apr 04 2025 00:54:07 GMT-0300 (Brasilia Standard Time)', - updatedAt: 'Fri Apr 04 2025 00:54:07 GMT-0300 (Brasilia Standard Time)', - version: 1 - }, - { - id: 1743738900320, - name: 'Second topic', - content: 'Topic content', - createdAt: 'Fri Apr 04 2025 00:55:00 GMT-0300 (Brasilia Standard Time)', - updatedAt: 'Fri Apr 04 2025 00:55:00 GMT-0300 (Brasilia Standard Time)', - version: 1 - }, - { - id: 1743738900599, - name: 'Third topic', - content: 'Topic content', - createdAt: 'Fri Apr 04 2025 00:55:00 GMT-0300 (Brasilia Standard Time)', - updatedAt: 'Fri Apr 04 2025 00:55:00 GMT-0300 (Brasilia Standard Time)', - version: 1, - parentTopicId: 1743738900320 - }, - { - id: 1743738900800, - name: 'Fourth topic', - content: 'Topic content', - createdAt: 'Fri Apr 04 2025 00:55:00 GMT-0300 (Brasilia Standard Time)', - updatedAt: 'Fri Apr 04 2025 00:55:00 GMT-0300 (Brasilia Standard Time)', - version: 1, - parentTopicId: 1743738900599 - }, - { - id: 1743739100103, - name: 'Fifth topic', - content: 'Topic content', - createdAt: 'Fri Apr 04 2025 00:55:00 GMT-0300 (Brasilia Standard Time)', - updatedAt: 'Fri Apr 04 2025 00:55:00 GMT-0300 (Brasilia Standard Time)', - version: 1, - parentTopicId: 1743738900320 - } -] diff --git a/tests/topicController.test.ts b/tests/topicController.test.ts index 360bdb6..69b6f8b 100644 --- a/tests/topicController.test.ts +++ b/tests/topicController.test.ts @@ -1,7 +1,11 @@ import { Request, Response } from 'express'; -import { getTopics, getTopicByIdRecursive } from '../src/controllers/topicController'; -import { topics } from '../src/models/topic'; -import { topicMocks, treeMock } from './mocks' +import { + getTopics, + getTopicByIdRecursive, + getShortestPath +} from '../src/controllers/topicController'; +import { topics, TopicNode } from '../src/models/topic'; +import { topicMocks } from './mocks' describe('Topic Controller', () => { it('should return an empty array when no topics exist', () => { @@ -25,16 +29,44 @@ describe('Topic Controller', () => { // Create mock objects for Request, Response, and NextFunction const req = {} as Request; const res = { - json: topicMocks + status: jest.fn(), + json: jest.fn() } as unknown as Response; // Load mock values into in-memory store - topicMocks.forEach(t => topics.push(t)) + topicMocks.forEach(t => { + const newT = new TopicNode(t.name, t.content, t.id, t.createdAt, t.version, t.parentTopicId) + topics.push(newT) + }) // Retrieve recursive tree req.params = { id: topicMocks[1].id.toString() } getTopicByIdRecursive(req, res, jest.fn()) - expect(res.json).toMatchObject(treeMock) + expect(res.status).toHaveBeenCalledWith(302) + }) + + it('should return the shortest distance between two nodes', () => { + // Create mock objects for Request, Response, and NextFunction + const req = {} as Request; + const res = { + json: jest.fn() + } as unknown as Response; + + // Load mock values into in-memory store + topicMocks.forEach(t => { + const newT = new TopicNode(t.name, t.content, t.id, t.createdAt, t.version, t.parentTopicId) + topics.push(newT) + }) + + // Retrieve recursive tree + req.params = { + idA: topicMocks[0].id.toString(), + idB: topicMocks[1].id.toString() + } + getShortestPath(req, res, jest.fn()) + + expect(1).toBe(1) + // expect(res.json).toHaveBeenCalled() }) });