From 913d0b541f468f4fb21a1ea6795fce8ec9b071d4 Mon Sep 17 00:00:00 2001 From: Akriti Chauhan Date: Sun, 16 Aug 2026 18:56:46 +0530 Subject: [PATCH] Complete take-home API testing and assignment feature --- task-api/BUG_REPORT.md | 50 ++++ task-api/src/routes/tasks.js | 21 ++ task-api/src/services/taskService.js | 25 +- task-api/tests/taskService.test.js | 204 +++++++++++++ task-api/tests/tasks.integration.test.js | 357 +++++++++++++++++++++++ 5 files changed, 655 insertions(+), 2 deletions(-) create mode 100644 task-api/BUG_REPORT.md create mode 100644 task-api/tests/taskService.test.js create mode 100644 task-api/tests/tasks.integration.test.js diff --git a/task-api/BUG_REPORT.md b/task-api/BUG_REPORT.md new file mode 100644 index 00000000..4dd32c8e --- /dev/null +++ b/task-api/BUG_REPORT.md @@ -0,0 +1,50 @@ +# Bug Report + +## Bug: Incorrect Pagination Offset + +### Expected Behavior + +When requesting page 1 with a limit of 2: + +`GET /tasks?page=1&limit=2` + +the API should return the first two tasks. + +For example: + +- Task 1 +- Task 2 + +Page 2 should then return: + +- Task 3 +- Task 4 + +### Actual Behavior + +Page 1 with a limit of 2 skips the first two tasks and starts from Task 3. + +The API returned only Task 3 when three tasks were available. + +### How It Was Discovered + +The issue was discovered through automated unit and integration tests. + +The unit test for `taskService.getPaginated()` expected page 1 to return Task 1 and Task 2 but received Task 3. + +The integration test for `GET /tasks?page=1&limit=2` reproduced the same issue through the API. + +The test suite currently reports: + +- 33 tests +- 31 passing +- 2 failing + +Both failures are caused by the same pagination calculation. + +### Root Cause + +The pagination offset is calculated as: + +```javascript +const offset = page * limit; \ No newline at end of file diff --git a/task-api/src/routes/tasks.js b/task-api/src/routes/tasks.js index e8c370fe..e3947e03 100644 --- a/task-api/src/routes/tasks.js +++ b/task-api/src/routes/tasks.js @@ -68,5 +68,26 @@ router.patch('/:id/complete', (req, res) => { res.json(task); }); +router.patch('/:id/assign', (req, res) => { + const { assignee } = req.body; + + if (typeof assignee !== 'string' || assignee.trim() === '') { + return res.status(400).json({ + error: 'assignee must be a non-empty string', + }); + } + + const task = taskService.assignTask(req.params.id, assignee.trim()); + + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + if (task.error) { + return res.status(400).json({ error: task.error }); + } + + res.json(task); +}); module.exports = router; diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index f8e89189..cf6e6bba 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -9,7 +9,7 @@ const findById = (id) => tasks.find((t) => t.id === id); const getByStatus = (status) => tasks.filter((t) => t.status.includes(status)); const getPaginated = (page, limit) => { - const offset = page * limit; + const offset = (page - 1) * limit; return tasks.slice(offset, offset + limit); }; @@ -80,6 +80,26 @@ const _reset = () => { tasks = []; }; + +const assignTask = (id, assignee) => { + const task = findById(id); + + if (!task) return null; + + if (task.assignee) { + return { error: 'Task is already assigned' }; + } + + const updated = { + ...task, + assignee, + }; + + const index = tasks.findIndex((t) => t.id === id); + tasks[index] = updated; + + return updated; +}; module.exports = { getAll, findById, @@ -91,4 +111,5 @@ module.exports = { remove, completeTask, _reset, -}; + assignTask, +}; \ No newline at end of file diff --git a/task-api/tests/taskService.test.js b/task-api/tests/taskService.test.js new file mode 100644 index 00000000..476d0052 --- /dev/null +++ b/task-api/tests/taskService.test.js @@ -0,0 +1,204 @@ +const taskService = require('../src/services/taskService'); + +describe('taskService', () => { + beforeEach(() => { + taskService._reset(); + }); + + describe('create', () => { + test('should create a task with default values', () => { + const task = taskService.create({ + title: 'Learn Jest', + }); + + expect(task).toHaveProperty('id'); + expect(task.title).toBe('Learn Jest'); + expect(task.description).toBe(''); + expect(task.status).toBe('todo'); + expect(task.priority).toBe('medium'); + expect(task.dueDate).toBeNull(); + expect(task.completedAt).toBeNull(); + expect(task).toHaveProperty('createdAt'); + }); + + test('should create a task with provided values', () => { + const task = taskService.create({ + title: 'Complete assignment', + description: 'Write tests', + status: 'in_progress', + priority: 'high', + dueDate: '2026-08-20T00:00:00.000Z', + }); + + expect(task.title).toBe('Complete assignment'); + expect(task.description).toBe('Write tests'); + expect(task.status).toBe('in_progress'); + expect(task.priority).toBe('high'); + expect(task.dueDate).toBe('2026-08-20T00:00:00.000Z'); + }); + }); + + describe('getAll', () => { + test('should return all tasks', () => { + taskService.create({ title: 'Task 1' }); + taskService.create({ title: 'Task 2' }); + + const tasks = taskService.getAll(); + + expect(tasks).toHaveLength(2); + expect(tasks[0].title).toBe('Task 1'); + expect(tasks[1].title).toBe('Task 2'); + }); + + test('should return an empty array when there are no tasks', () => { + expect(taskService.getAll()).toEqual([]); + }); + }); + + describe('findById', () => { + test('should find a task by id', () => { + const created = taskService.create({ title: 'Find me' }); + + const task = taskService.findById(created.id); + + expect(task).toEqual(created); + }); + + test('should return undefined for a non-existent id', () => { + expect(taskService.findById('does-not-exist')).toBeUndefined(); + }); + }); + + describe('getByStatus', () => { + test('should return tasks matching the status', () => { + taskService.create({ title: 'Todo task', status: 'todo' }); + taskService.create({ title: 'Done task', status: 'done' }); + + const tasks = taskService.getByStatus('todo'); + + expect(tasks).toHaveLength(1); + expect(tasks[0].status).toBe('todo'); + }); + + test('should return empty array when no tasks match', () => { + taskService.create({ title: 'Todo task', status: 'todo' }); + + expect(taskService.getByStatus('done')).toEqual([]); + }); + }); + + describe('getPaginated', () => { + test('should return tasks for the requested page', () => { + taskService.create({ title: 'Task 1' }); + taskService.create({ title: 'Task 2' }); + taskService.create({ title: 'Task 3' }); + taskService.create({ title: 'Task 4' }); + + const tasks = taskService.getPaginated(1, 2); + + expect(tasks).toHaveLength(2); + expect(tasks[0].title).toBe('Task 1'); + expect(tasks[1].title).toBe('Task 2'); + }); + + test('should return an empty array when page is beyond available tasks', () => { + taskService.create({ title: 'Task 1' }); + + expect(taskService.getPaginated(5, 2)).toEqual([]); + }); + }); + + describe('update', () => { + test('should update an existing task', () => { + const created = taskService.create({ + title: 'Old title', + }); + + const updated = taskService.update(created.id, { + title: 'New title', + }); + + expect(updated.title).toBe('New title'); + expect(updated.id).toBe(created.id); + }); + + test('should return null for a non-existent task', () => { + expect( + taskService.update('does-not-exist', { title: 'New title' }) + ).toBeNull(); + }); + }); + + describe('remove', () => { + test('should remove an existing task', () => { + const created = taskService.create({ + title: 'Delete me', + }); + + expect(taskService.remove(created.id)).toBe(true); + expect(taskService.findById(created.id)).toBeUndefined(); + }); + + test('should return false for a non-existent task', () => { + expect(taskService.remove('does-not-exist')).toBe(false); + }); + }); + + describe('completeTask', () => { + test('should mark an existing task as completed', () => { + const created = taskService.create({ + title: 'Complete me', + priority: 'high', + }); + + const completed = taskService.completeTask(created.id); + + expect(completed.status).toBe('done'); + expect(completed.completedAt).not.toBeNull(); + }); + + test('should return null for a non-existent task', () => { + expect(taskService.completeTask('does-not-exist')).toBeNull(); + }); + }); + + describe('getStats', () => { + test('should return counts by status', () => { + taskService.create({ title: 'Todo 1', status: 'todo' }); + taskService.create({ title: 'Todo 2', status: 'todo' }); + taskService.create({ title: 'Progress', status: 'in_progress' }); + taskService.create({ title: 'Done', status: 'done' }); + + const stats = taskService.getStats(); + + expect(stats.todo).toBe(2); + expect(stats.in_progress).toBe(1); + expect(stats.done).toBe(1); + expect(stats.overdue).toBe(0); + }); + + test('should count unfinished overdue tasks', () => { + taskService.create({ + title: 'Overdue task', + dueDate: '2020-01-01T00:00:00.000Z', + status: 'todo', + }); + + const stats = taskService.getStats(); + + expect(stats.overdue).toBe(1); + }); + + test('should not count completed overdue tasks', () => { + taskService.create({ + title: 'Completed task', + dueDate: '2020-01-01T00:00:00.000Z', + status: 'done', + }); + + const stats = taskService.getStats(); + + expect(stats.overdue).toBe(0); + }); + }); +}); \ No newline at end of file diff --git a/task-api/tests/tasks.integration.test.js b/task-api/tests/tasks.integration.test.js new file mode 100644 index 00000000..4ab75771 --- /dev/null +++ b/task-api/tests/tasks.integration.test.js @@ -0,0 +1,357 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +describe('Task API Integration Tests', () => { + beforeEach(() => { + taskService._reset(); + }); + + describe('POST /tasks', () => { + test('should create a new task', async () => { + const response = await request(app) + .post('/tasks') + .send({ + title: 'Learn Jest', + description: 'Write API tests', + priority: 'high', + }); + + expect(response.status).toBe(201); + expect(response.body).toHaveProperty('id'); + expect(response.body.title).toBe('Learn Jest'); + expect(response.body.description).toBe('Write API tests'); + expect(response.body.priority).toBe('high'); + expect(response.body.status).toBe('todo'); + }); + + test('should reject a task without a title', async () => { + const response = await request(app) + .post('/tasks') + .send({ + description: 'Task without title', + }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + + test('should reject an invalid priority', async () => { + const response = await request(app) + .post('/tasks') + .send({ + title: 'Invalid task', + priority: 'urgent', + }); + + expect(response.status).toBe(400); + expect(response.body.error).toContain('priority'); + }); + }); + + describe('GET /tasks', () => { + test('should return all tasks', async () => { + await request(app) + .post('/tasks') + .send({ title: 'Task 1' }); + + await request(app) + .post('/tasks') + .send({ title: 'Task 2' }); + + const response = await request(app).get('/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + expect(response.body[0].title).toBe('Task 1'); + expect(response.body[1].title).toBe('Task 2'); + }); + + test('should return an empty array when there are no tasks', async () => { + const response = await request(app).get('/tasks'); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + }); + }); + + describe('GET /tasks?status=', () => { + test('should return tasks filtered by status', async () => { + await request(app) + .post('/tasks') + .send({ + title: 'Todo task', + status: 'todo', + }); + + await request(app) + .post('/tasks') + .send({ + title: 'Done task', + status: 'done', + }); + + const response = await request(app) + .get('/tasks') + .query({ status: 'todo' }); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(1); + expect(response.body[0].title).toBe('Todo task'); + expect(response.body[0].status).toBe('todo'); + }); + }); + + describe('GET /tasks?page=&limit=', () => { + test('should return paginated tasks', async () => { + await request(app).post('/tasks').send({ title: 'Task 1' }); + await request(app).post('/tasks').send({ title: 'Task 2' }); + await request(app).post('/tasks').send({ title: 'Task 3' }); + + const response = await request(app) + .get('/tasks') + .query({ page: 1, limit: 2 }); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); + expect(response.body[0].title).toBe('Task 1'); + expect(response.body[1].title).toBe('Task 2'); + }); + }); + + describe('PUT /tasks/:id', () => { + test('should update an existing task', async () => { + const created = await request(app) + .post('/tasks') + .send({ + title: 'Original title', + }); + + const response = await request(app) + .put(`/tasks/${created.body.id}`) + .send({ + title: 'Updated title', + }); + + expect(response.status).toBe(200); + expect(response.body.title).toBe('Updated title'); + expect(response.body.id).toBe(created.body.id); + }); + + test('should return 404 when updating a non-existent task', async () => { + const response = await request(app) + .put('/tasks/non-existent-id') + .send({ + title: 'Updated title', + }); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Task not found'); + }); + }); + + describe('DELETE /tasks/:id', () => { + test('should delete an existing task', async () => { + const created = await request(app) + .post('/tasks') + .send({ + title: 'Delete me', + }); + + const response = await request(app) + .delete(`/tasks/${created.body.id}`); + + expect(response.status).toBe(204); + + const getResponse = await request(app).get('/tasks'); + + expect(getResponse.body).toHaveLength(0); + }); + + test('should return 404 when deleting a non-existent task', async () => { + const response = await request(app) + .delete('/tasks/non-existent-id'); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Task not found'); + }); + }); + + + describe('PATCH /tasks/:id/complete', () => { + test('should mark a task as complete', async () => { + const created = await request(app) + .post('/tasks') + .send({ + title: 'Complete me', + priority: 'high', + }); + + const response = await request(app) + .patch(`/tasks/${created.body.id}/complete`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('done'); + expect(response.body.completedAt).not.toBeNull(); + }); + + test('should return 404 for a non-existent task', async () => { + const response = await request(app) + .patch('/tasks/non-existent-id/complete'); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Task not found'); + }); + }); +describe('PATCH /tasks/:id/assign', () => { + test('should assign a task to a valid assignee', async () => { + const created = await request(app) + .post('/tasks') + .send({ + title: 'Task to assign', + }); + + const response = await request(app) + .patch(`/tasks/${created.body.id}/assign`) + .send({ + assignee: 'Akriti', + }); + + expect(response.status).toBe(200); + expect(response.body.id).toBe(created.body.id); + expect(response.body.assignee).toBe('Akriti'); + }); + + test('should return 400 when assignee is missing', async () => { + const created = await request(app) + .post('/tasks') + .send({ + title: 'Task to assign', + }); + + const response = await request(app) + .patch(`/tasks/${created.body.id}/assign`) + .send({}); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + + test('should return 400 when assignee is an empty string', async () => { + const created = await request(app) + .post('/tasks') + .send({ + title: 'Task to assign', + }); + + const response = await request(app) + .patch(`/tasks/${created.body.id}/assign`) + .send({ + assignee: '', + }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + + test('should return 400 when assignee is only whitespace', async () => { + const created = await request(app) + .post('/tasks') + .send({ + title: 'Task to assign', + }); + + const response = await request(app) + .patch(`/tasks/${created.body.id}/assign`) + .send({ + assignee: ' ', + }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + + test('should return 400 when assignee is not a string', async () => { + const created = await request(app) + .post('/tasks') + .send({ + title: 'Task to assign', + }); + + const response = await request(app) + .patch(`/tasks/${created.body.id}/assign`) + .send({ + assignee: 123, + }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + + test('should return 404 when the task does not exist', async () => { + const response = await request(app) + .patch('/tasks/non-existent-id/assign') + .send({ + assignee: 'Akriti', + }); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Task not found'); + }); + + test('should return 400 when the task is already assigned', async () => { + const created = await request(app) + .post('/tasks') + .send({ + title: 'Already assigned task', + }); + + await request(app) + .patch(`/tasks/${created.body.id}/assign`) + .send({ + assignee: 'Akriti', + }); + + const response = await request(app) + .patch(`/tasks/${created.body.id}/assign`) + .send({ + assignee: 'Rahul', + }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); +}); + describe('GET /tasks/stats', () => { + test('should return task counts by status', async () => { + await request(app) + .post('/tasks') + .send({ + title: 'Todo task', + status: 'todo', + }); + + await request(app) + .post('/tasks') + .send({ + title: 'Progress task', + status: 'in_progress', + }); + + await request(app) + .post('/tasks') + .send({ + title: 'Done task', + status: 'done', + }); + + const response = await request(app).get('/tasks/stats'); + + expect(response.status).toBe(200); + expect(response.body.todo).toBe(1); + expect(response.body.in_progress).toBe(1); + expect(response.body.done).toBe(1); + expect(response.body.overdue).toBe(0); + }); + }); +}); \ No newline at end of file