diff --git a/task-api/BUG_REPORT.md b/task-api/BUG_REPORT.md new file mode 100644 index 00000000..5bf1b2a7 --- /dev/null +++ b/task-api/BUG_REPORT.md @@ -0,0 +1,8 @@ +Bug Report + +## Pagination off-by-one bug in `getPaginated` +- Expected: `page=1` should return first page items (items 1..limit), `page=2` should return next slice. +- Actual: page was treated as 0-based in service (`offset = page * limit`), so `page=1` returned second page. +- Discovered by: automated unit test `taskService.test.js` with expected first-page items failing. +- Fix implemented: normalize page to 1-based in `getPaginated`, i.e. `offset = (page - 1) * limit` with safe defaults. + \ No newline at end of file diff --git a/task-api/src/app.js b/task-api/src/app.js index 65c03eec..32b1b93f 100644 --- a/task-api/src/app.js +++ b/task-api/src/app.js @@ -11,6 +11,14 @@ app.use((err, req, res, next) => { res.status(500).json({ error: 'Internal server error' }); }); +app.get("/health", (req, res) => { + res.status(200).json({ status: "ok" }); +}); + +app.get("/", (req, res) => { + res.status(200).json({ message: "Welcome to the Task API" }); +}); + const PORT = process.env.PORT || 3000; if (require.main === module) { diff --git a/task-api/src/routes/tasks.js b/task-api/src/routes/tasks.js index e8c370fe..095bc3d3 100644 --- a/task-api/src/routes/tasks.js +++ b/task-api/src/routes/tasks.js @@ -9,10 +9,16 @@ router.get('/stats', (req, res) => { }); router.get('/', (req, res) => { - const { status, page, limit } = req.query; + const { status, priority, page, limit } = req.query; - if (status) { - const tasks = taskService.getByStatus(status); + if (status || priority) { + let tasks = taskService.getAll(); + if (status) { + tasks = tasks.filter((t) => t.status === status); + } + if (priority) { + tasks = tasks.filter((t) => t.priority === priority); + } return res.json(tasks); } @@ -69,4 +75,27 @@ router.patch('/:id/complete', (req, res) => { res.json(task); }); +router.patch('/:id/assign', (req, res) => { + const { assignee } = req.body; + if (!assignee || typeof assignee !== 'string' || assignee.trim() === '') { + return res.status(400).json({ error: 'assignee is required and must be a non-empty string' }); + } + + const existingTask = taskService.findById(req.params.id); + if (!existingTask) { + return res.status(404).json({ error: 'Task not found' }); + } + + if (existingTask.assignee) { + return res.status(409).json({ error: 'Task is already assigned' }); + } + + const task = taskService.assignTask(req.params.id, assignee.trim()); + if (!task) { + return res.status(500).json({ error: 'Unable to assign task' }); + } + + res.json(task); +}); + module.exports = router; diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index f8e89189..f41b3877 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -6,11 +6,15 @@ 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; - return tasks.slice(offset, offset + limit); + const pageNum = Number(page); + const limitNum = Number(limit); + const normalizedPage = Number.isNaN(pageNum) || pageNum < 1 ? 1 : pageNum; + const normalizedLimit = Number.isNaN(limitNum) || limitNum < 1 ? 10 : limitNum; + const offset = (normalizedPage - 1) * normalizedLimit; + return tasks.slice(offset, offset + normalizedLimit); }; const getStats = () => { @@ -28,7 +32,7 @@ const getStats = () => { return { ...counts, overdue }; }; -const create = ({ title, description = '', status = 'todo', priority = 'medium', dueDate = null }) => { +const create = ({ title, description = '', status = 'todo', priority = 'medium', dueDate = null, assignee = null }) => { const task = { id: uuidv4(), title, @@ -38,11 +42,24 @@ const create = ({ title, description = '', status = 'todo', priority = 'medium', dueDate, completedAt: null, createdAt: new Date().toISOString(), + assignee, }; tasks.push(task); return task; }; +const assignTask = (id, assignee) => { + const index = tasks.findIndex((t) => t.id === id); + if (index === -1) return null; + + const task = tasks[index]; + if (task.assignee) return null; // caller can detect already assigned separately + + const updated = { ...task, assignee }; + tasks[index] = updated; + return updated; +}; + const update = (id, fields) => { const index = tasks.findIndex((t) => t.id === id); if (index === -1) return null; @@ -90,5 +107,6 @@ module.exports = { update, remove, completeTask, + assignTask, _reset, }; diff --git a/task-api/tests/taskService.test.js b/task-api/tests/taskService.test.js new file mode 100644 index 00000000..9e8c068a --- /dev/null +++ b/task-api/tests/taskService.test.js @@ -0,0 +1,115 @@ +const taskService = require('../src/services/taskService'); + +describe('taskService', () => { + beforeEach(() => { + taskService._reset(); + }); + + test('create() returns task with defaults and unique id', () => { + const task = taskService.create({ title: 'Test task' }); + + expect(task.id).toBeDefined(); + expect(task.title).toBe('Test task'); + expect(task.status).toBe('todo'); + expect(task.priority).toBe('medium'); + expect(task.description).toBe(''); + expect(task.completedAt).toBeNull(); + expect(task.dueDate).toBeNull(); + expect(task.createdAt).toBeDefined(); + }); + + test('getAll() returns all tasks', () => { + const a = taskService.create({ title: 'A' }); + const b = taskService.create({ title: 'B' }); + + expect(taskService.getAll()).toEqual(expect.arrayContaining([a, b])); + }); + + test('findById() returns correct or undefined', () => { + const task = taskService.create({ title: 'Find me' }); + expect(taskService.findById(task.id)).toEqual(task); + expect(taskService.findById('nope')).toBeUndefined(); + }); + + test('getByStatus() filters by exact status', () => { + const t1 = taskService.create({ title: 'Todo1', status: 'todo' }); + taskService.create({ title: 'Done1', status: 'done' }); + + const todos = taskService.getByStatus('todo'); + expect(todos).toEqual([t1]); + expect(taskService.getByStatus('in_progress')).toEqual([]); + }); + + test('getPaginated() returns correct pages', () => { + for (let i = 1; i <= 12; i++) { + taskService.create({ title: `Task ${i}` }); + } + + expect(taskService.getPaginated(1, 5).map((t) => t.title)).toEqual([ + 'Task 1', + 'Task 2', + 'Task 3', + 'Task 4', + 'Task 5', + ]); + + expect(taskService.getPaginated(2, 5).map((t) => t.title)).toEqual([ + 'Task 6', + 'Task 7', + 'Task 8', + 'Task 9', + 'Task 10', + ]); + + expect(taskService.getPaginated(3, 5).map((t) => t.title)).toEqual([ + 'Task 11', + 'Task 12', + ]); + }); + + test('getStats() counts statuses and overdue tasks', () => { + const now = new Date(); + const past = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const future = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + + taskService.create({ title: 'Todo old', status: 'todo', dueDate: past }); + taskService.create({ title: 'InProgress', status: 'in_progress', dueDate: future }); + taskService.create({ title: 'Done old', status: 'done', dueDate: past }); + + expect(taskService.getStats()).toMatchObject({ + todo: 1, + in_progress: 1, + done: 1, + overdue: 1, + }); + }); + + test('update() works and returns null on missing', () => { + const task = taskService.create({ title: 'Old' }); + const updated = taskService.update(task.id, { title: 'New', status: 'in_progress' }); + + expect(updated.title).toBe('New'); + expect(updated.status).toBe('in_progress'); + expect(taskService.findById(task.id).title).toBe('New'); + + expect(taskService.update('missing', { title: 'X' })).toBeNull(); + }); + + test('remove() removes and returns correct bool', () => { + const task = taskService.create({ title: 'ToDelete' }); + expect(taskService.remove(task.id)).toBe(true); + expect(taskService.findById(task.id)).toBeUndefined(); + expect(taskService.remove('not-there')).toBe(false); + }); + + test('completeTask() marks done and sets completedAt', () => { + const task = taskService.create({ title: 'ToComplete', priority: 'high', status: 'in_progress' }); + const completed = taskService.completeTask(task.id); + + expect(completed.status).toBe('done'); + expect(completed.priority).toBe('medium'); + expect(completed.completedAt).toBeTruthy(); + + expect(taskService.completeTask('missing')).toBeNull(); + }); +}); diff --git a/task-api/tests/tasks.routes.test.js b/task-api/tests/tasks.routes.test.js new file mode 100644 index 00000000..9d1195d7 --- /dev/null +++ b/task-api/tests/tasks.routes.test.js @@ -0,0 +1,98 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +describe('/tasks routes integration', () => { + beforeEach(() => { + taskService._reset(); + }); + + test('POST /tasks creates task and GET /tasks returns it', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Integration task' }); + expect(createRes.status).toBe(201); + expect(createRes.body.title).toBe('Integration task'); + + const listRes = await request(app).get('/tasks'); + expect(listRes.status).toBe(200); + expect(listRes.body).toHaveLength(1); + }); + + test('POST /tasks validation error for missing title', async () => { + const res = await request(app).post('/tasks').send({ description: 'no title' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/title is required/); + }); + + test('PUT /tasks/:id updates task and 404 on invalid id', async () => { + const task = taskService.create({ title: 'Updatable' }); + const res = await request(app).put(`/tasks/${task.id}`).send({ status: 'done' }); + expect(res.status).toBe(200); + expect(res.body.status).toBe('done'); + + const res404 = await request(app).put('/tasks/invalid').send({ status: 'done' }); + expect(res404.status).toBe(404); + }); + + test('DELETE /tasks/:id deletes and returns 204', async () => { + const task = taskService.create({ title: 'Removable' }); + const res = await request(app).delete(`/tasks/${task.id}`); + expect(res.status).toBe(204); + + const res404 = await request(app).delete(`/tasks/${task.id}`); + expect(res404.status).toBe(404); + }); + + test('PATCH /tasks/:id/complete marks done', async () => { + const task = taskService.create({ title: 'Completable', status: 'in_progress' }); + const res = await request(app).patch(`/tasks/${task.id}/complete`); + expect(res.status).toBe(200); + expect(res.body.status).toBe('done'); + + const res404 = await request(app).patch('/tasks/none/complete'); + expect(res404.status).toBe(404); + }); + + test('PATCH /tasks/:id/assign works and validates', async () => { + const task = taskService.create({ title: 'Assignable' }); + + const emptyRes = await request(app).patch(`/tasks/${task.id}/assign`).send({ assignee: ' ' }); + expect(emptyRes.status).toBe(400); + + const notFoundRes = await request(app).patch('/tasks/invalid-id/assign').send({ assignee: 'Alice' }); + expect(notFoundRes.status).toBe(404); + + const assignRes = await request(app).patch(`/tasks/${task.id}/assign`).send({ assignee: 'Alice' }); + expect(assignRes.status).toBe(200); + expect(assignRes.body.assignee).toBe('Alice'); + + const reassignRes = await request(app).patch(`/tasks/${task.id}/assign`).send({ assignee: 'Bob' }); + expect(reassignRes.status).toBe(409); + }); + + test('GET /tasks?status= and ?page=&limit= and /stats', async () => { + taskService.create({ title: 'A', status: 'todo' }); + taskService.create({ title: 'B', status: 'done' }); + taskService.create({ title: 'C', status: 'todo' }); + + const statusRes = await request(app).get('/tasks').query({ status: 'todo' }); + expect(statusRes.body).toHaveLength(2); + + const pageRes = await request(app).get('/tasks').query({ page: '1', limit: '1' }); + expect(pageRes.body).toHaveLength(1); + expect(pageRes.body[0].title).toBe('A'); + + const statsRes = await request(app).get('/tasks/stats'); + expect(statsRes.body.todo).toBe(2); + expect(statsRes.body.done).toBe(1); + }); + + test('GET /tasks?priority= priority filter works', async () => { + taskService.create({ title: 'Low', priority: 'low' }); + taskService.create({ title: 'High', priority: 'high' }); + + const res = await request(app).get('/tasks').query({ priority: 'high' }); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].priority).toBe('high'); + }); +});