diff --git a/task-api/BUG_REPORT.md b/task-api/BUG_REPORT.md new file mode 100644 index 00000000..1de30f0b --- /dev/null +++ b/task-api/BUG_REPORT.md @@ -0,0 +1,19 @@ +# Task API Bug Report + +## 1. Status Filtering Matched Partial Strings +- **Expected Behavior**: `GET /tasks?status=todo` should only return tasks with `status === "todo"`. +- **Actual Behavior**: Filtering used partial matching, so values like `"o"` matched both `"todo"` and `"done"`. +- **How Discovered**: Unit test added for partial string input in `taskService.getByStatus`. +- **Suggested Fix**: Use strict equality (`===`) for status matching. + +## 2. Pagination Started From Wrong Offset +- **Expected Behavior**: Page 1 with limit 10 should return tasks 0-9. +- **Actual Behavior**: Pagination used `offset = page * limit`, which skipped the first page of items. +- **How Discovered**: Unit + integration tests for pagination. +- **Suggested Fix**: Use `offset = (page - 1) * limit`. + +## 3. Assignment Endpoint Needs Strong Input Validation +- **Expected Behavior**: `PATCH /tasks/:id/assign` should reject invalid payloads (missing or non-string assignee). +- **Actual Behavior**: Initial coverage focused on happy path + empty string, but missing/non-string cases needed explicit test coverage. +- **How Discovered**: API test review. +- **Suggested Fix**: Validate `assignee` as a required non-empty string and cover missing/non-string cases in integration tests. diff --git a/task-api/src/app.js b/task-api/src/app.js index 65c03eec..9963e76f 100644 --- a/task-api/src/app.js +++ b/task-api/src/app.js @@ -4,6 +4,9 @@ const taskRoutes = require('./routes/tasks'); const app = express(); app.use(express.json()); +app.get('/', (req, res) => { + res.status(200).json({ message: 'Task API is running' }); +}); app.use('/tasks', taskRoutes); app.use((err, req, res, next) => { diff --git a/task-api/src/routes/tasks.js b/task-api/src/routes/tasks.js index e8c370fe..6b6cc260 100644 --- a/task-api/src/routes/tasks.js +++ b/task-api/src/routes/tasks.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const taskService = require('../services/taskService'); -const { validateCreateTask, validateUpdateTask } = require('../utils/validators'); +const { validateCreateTask, validateUpdateTask, validateAssignTask } = require('../utils/validators'); router.get('/stats', (req, res) => { const stats = taskService.getStats(); @@ -69,4 +69,18 @@ router.patch('/:id/complete', (req, res) => { res.json(task); }); +router.patch('/:id/assign', (req, res) => { + const error = validateAssignTask(req.body); + if (error) { + return res.status(400).json({ error }); + } + + const task = taskService.assign(req.params.id, req.body.assignee); + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + res.json(task); +}); + module.exports = router; diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index f8e89189..86bf82b1 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -6,10 +6,10 @@ const getAll = () => [...tasks]; const findById = (id) => tasks.find((t) => t.id === id); -const getByStatus = (status) => tasks.filter((t) => t.status.includes(status)); +const getByStatus = (status) => tasks.filter((t) => t.status === status); const getPaginated = (page, limit) => { - const offset = page * limit; + const offset = (page - 1) * limit; return tasks.slice(offset, offset + limit); }; @@ -60,13 +60,22 @@ const remove = (id) => { return true; }; +const assign = (id, assignee) => { + const task = findById(id); + if (!task) return null; + + const updated = { ...task, assignee }; + const index = tasks.findIndex((t) => t.id === id); + tasks[index] = updated; + return updated; +}; + const completeTask = (id) => { const task = findById(id); if (!task) return null; const updated = { ...task, - priority: 'medium', status: 'done', completedAt: new Date().toISOString(), }; @@ -89,6 +98,7 @@ module.exports = { create, update, remove, + assign, completeTask, _reset, }; diff --git a/task-api/src/utils/validators.js b/task-api/src/utils/validators.js index 1e908ff5..a87e14fa 100644 --- a/task-api/src/utils/validators.js +++ b/task-api/src/utils/validators.js @@ -33,4 +33,11 @@ const validateUpdateTask = (body) => { return null; }; -module.exports = { validateCreateTask, validateUpdateTask }; +const validateAssignTask = (body) => { + if (!body.assignee || typeof body.assignee !== 'string' || body.assignee.trim() === '') { + return 'assignee is required and must be a non-empty string'; + } + return null; +}; + +module.exports = { validateCreateTask, validateUpdateTask, validateAssignTask }; diff --git a/task-api/tests/taskService.test.js b/task-api/tests/taskService.test.js new file mode 100644 index 00000000..9d3fcc3d --- /dev/null +++ b/task-api/tests/taskService.test.js @@ -0,0 +1,154 @@ +const taskService = require('../src/services/taskService'); + +describe('Task Service', () => { + beforeEach(() => { + taskService._reset(); + }); + + describe('create', () => { + it('creates a task with defaults', () => { + const task = taskService.create({ title: 'Test Task' }); + + expect(task).toHaveProperty('id'); + expect(task.title).toBe('Test Task'); + expect(task.status).toBe('todo'); + expect(task.priority).toBe('medium'); + expect(task.completedAt).toBeNull(); + }); + }); + + describe('getByStatus', () => { + it('returns only exact status matches', () => { + taskService.create({ title: 'Todo Task', status: 'todo' }); + taskService.create({ title: 'Done Task', status: 'done' }); + + const todoTasks = taskService.getByStatus('todo'); + expect(todoTasks).toHaveLength(1); + expect(todoTasks[0].title).toBe('Todo Task'); + }); + + it('does not treat partial strings as matches', () => { + taskService.create({ title: 'Todo Task', status: 'todo' }); + taskService.create({ title: 'Done Task', status: 'done' }); + + const partialMatches = taskService.getByStatus('o'); + expect(partialMatches).toHaveLength(0); + }); + }); + + describe('getPaginated', () => { + it('returns first page from the beginning of the list', () => { + for (let i = 0; i < 15; i++) { + taskService.create({ title: `Task ${i}` }); + } + + const page1 = taskService.getPaginated(1, 10); + expect(page1).toHaveLength(10); + expect(page1[0].title).toBe('Task 0'); + expect(page1[9].title).toBe('Task 9'); + }); + + it('returns remaining records on second page', () => { + for (let i = 0; i < 15; i++) { + taskService.create({ title: `Task ${i}` }); + } + + const page2 = taskService.getPaginated(2, 10); + expect(page2).toHaveLength(5); + expect(page2[0].title).toBe('Task 10'); + }); + }); + + describe('findById', () => { + it('finds a task by id', () => { + const created = taskService.create({ title: 'Lookup Task' }); + const found = taskService.findById(created.id); + + expect(found).toBeDefined(); + expect(found.id).toBe(created.id); + }); + + it('returns undefined for unknown id', () => { + expect(taskService.findById('missing-id')).toBeUndefined(); + }); + }); + + describe('update', () => { + it('updates selected fields on an existing task', () => { + const created = taskService.create({ title: 'Old Title', priority: 'high' }); + const updated = taskService.update(created.id, { title: 'New Title' }); + + expect(updated.title).toBe('New Title'); + expect(updated.priority).toBe('high'); + }); + + it('returns null for unknown id', () => { + const updated = taskService.update('missing-id', { title: 'Nope' }); + expect(updated).toBeNull(); + }); + }); + + describe('remove', () => { + it('removes an existing task', () => { + const created = taskService.create({ title: 'Delete Me' }); + const removed = taskService.remove(created.id); + + expect(removed).toBe(true); + expect(taskService.findById(created.id)).toBeUndefined(); + }); + + it('returns false for unknown id', () => { + expect(taskService.remove('missing-id')).toBe(false); + }); + }); + + describe('completeTask', () => { + it('marks task as done and sets completion time', () => { + const created = taskService.create({ title: 'Complete Me' }); + const completed = taskService.completeTask(created.id); + + expect(completed.status).toBe('done'); + expect(completed.completedAt).not.toBeNull(); + }); + + it('preserves the original priority of the task', () => { + const created = taskService.create({ title: 'High Priority Task', priority: 'high' }); + const completed = taskService.completeTask(created.id); + + expect(completed.priority).toBe('high'); + }); + + it('returns null for unknown id', () => { + expect(taskService.completeTask('missing-id')).toBeNull(); + }); + }); + + describe('assign', () => { + it('sets the assignee on an existing task', () => { + const created = taskService.create({ title: 'Assign Me' }); + const updated = taskService.assign(created.id, 'Alice'); + + expect(updated.assignee).toBe('Alice'); + expect(taskService.findById(created.id).assignee).toBe('Alice'); + }); + + it('returns null for unknown id', () => { + const updated = taskService.assign('missing-id', 'Alice'); + expect(updated).toBeNull(); + }); + }); + + describe('getStats', () => { + it('returns status totals and overdue count', () => { + taskService.create({ title: 'Todo Overdue', status: 'todo', dueDate: '2020-01-01T00:00:00Z' }); + taskService.create({ title: 'Done Old', status: 'done', dueDate: '2020-01-01T00:00:00Z' }); + taskService.create({ title: 'In Progress', status: 'in_progress', dueDate: '2050-01-01T00:00:00Z' }); + + const stats = taskService.getStats(); + expect(stats.todo).toBe(1); + expect(stats.done).toBe(1); + expect(stats.in_progress).toBe(1); + expect(stats.overdue).toBe(1); + }); + }); +}); diff --git a/task-api/tests/tasks.test.js b/task-api/tests/tasks.test.js new file mode 100644 index 00000000..1dc7396a --- /dev/null +++ b/task-api/tests/tasks.test.js @@ -0,0 +1,171 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +describe('Task API routes', () => { + beforeEach(() => { + taskService._reset(); + }); + + describe('GET /', () => { + it('returns API health message', async () => { + const res = await request(app).get('/'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: 'Task API is running' }); + }); + }); + + describe('GET /tasks', () => { + it('returns all tasks', async () => { + taskService.create({ title: 'Task 1' }); + taskService.create({ title: 'Task 2' }); + + const res = await request(app).get('/tasks'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + }); + + it('filters by exact status', async () => { + taskService.create({ title: 'Todo Task', status: 'todo' }); + taskService.create({ title: 'Done Task', status: 'done' }); + + const res = await request(app).get('/tasks?status=todo'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].title).toBe('Todo Task'); + }); + + it('returns paginated tasks when page and limit are provided', async () => { + for (let i = 0; i < 12; i++) { + taskService.create({ title: `Task ${i}` }); + } + + const res = await request(app).get('/tasks?page=1&limit=10'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(10); + expect(res.body[0].title).toBe('Task 0'); + }); + }); + + describe('POST /tasks', () => { + it('creates a task when payload is valid', async () => { + const res = await request(app) + .post('/tasks') + .send({ title: 'New Task', priority: 'high' }); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('id'); + expect(res.body.title).toBe('New Task'); + }); + + it('returns 400 when title is missing', async () => { + const res = await request(app) + .post('/tasks') + .send({ priority: 'high' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + }); + + describe('PUT /tasks/:id', () => { + it('updates an existing task', async () => { + const task = taskService.create({ title: 'Old Title' }); + const res = await request(app) + .put(`/tasks/${task.id}`) + .send({ title: 'New Title' }); + + expect(res.status).toBe(200); + expect(res.body.title).toBe('New Title'); + }); + + it('returns 404 for unknown id', async () => { + const res = await request(app) + .put('/tasks/invalid-id') + .send({ title: 'Updated' }); + + expect(res.status).toBe(404); + }); + }); + + describe('DELETE /tasks/:id', () => { + it('deletes a task and returns 204', async () => { + const task = taskService.create({ title: 'To Delete' }); + const res = await request(app).delete(`/tasks/${task.id}`); + + expect(res.status).toBe(204); + expect(taskService.findById(task.id)).toBeUndefined(); + }); + }); + + describe('PATCH /tasks/:id/complete', () => { + it('marks a task as done', async () => { + const task = taskService.create({ title: 'To Complete' }); + const res = await request(app).patch(`/tasks/${task.id}/complete`); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('done'); + }); + }); + + describe('PATCH /tasks/:id/assign', () => { + it('sets the assignee on a task', async () => { + const task = taskService.create({ title: 'Task to Assign' }); + const res = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: 'Bob' }); + + expect(res.status).toBe(200); + expect(res.body.assignee).toBe('Bob'); + }); + + it('returns 400 when assignee is an empty string', async () => { + const task = taskService.create({ title: 'Task' }); + const res = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: ' ' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + it('returns 400 when assignee is missing', async () => { + const task = taskService.create({ title: 'Task' }); + const res = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + it('returns 400 when assignee is not a string', async () => { + const task = taskService.create({ title: 'Task' }); + const res = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: 123 }); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + it('returns 404 for unknown task id', async () => { + const res = await request(app) + .patch('/tasks/missing-id/assign') + .send({ assignee: 'Bob' }); + + expect(res.status).toBe(404); + }); + }); + + describe('GET /tasks/stats', () => { + it('returns stats summary', async () => { + taskService.create({ title: 'A', status: 'todo' }); + const res = await request(app).get('/tasks/stats'); + + expect(res.status).toBe(200); + expect(res.body.todo).toBe(1); + }); + }); +});