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.
This commit is contained in:
Rodrigo Pinto 2025-04-06 01:41:54 -03:00
commit d7748229ef
9 changed files with 419 additions and 118 deletions

View file

@ -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": []
}
]
}

View file

@ -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:

View file

@ -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',
},
]
},
};

View file

@ -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)
}
}

View file

@ -57,4 +57,4 @@ export class TopicNode implements Topic {
}
}
export const topics: Topic[] = [];
export const topics: Topic[] = []

View file

@ -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

View file

@ -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)
}
}

View file

@ -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
}
]

View file

@ -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()
})
});