diff --git a/task-api/src/routes/tasks.js b/task-api/src/routes/tasks.js index e8c370fe..1ad3d42b 100644 --- a/task-api/src/routes/tasks.js +++ b/task-api/src/routes/tasks.js @@ -1,7 +1,10 @@ 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'); +const VALID_STATUSES = ['todo', 'in_progress', 'done']; + +const isPositiveIntegerString = (value) => /^\d+$/.test(String(value)) && Number(value) > 0; router.get('/stats', (req, res) => { const stats = taskService.getStats(); @@ -12,13 +15,24 @@ router.get('/', (req, res) => { const { status, page, limit } = req.query; if (status) { + if (!VALID_STATUSES.includes(status)) { + return res.status(400).json({ error: `status must be one of: ${VALID_STATUSES.join(', ')}` }); + } const tasks = taskService.getByStatus(status); return res.json(tasks); } if (page !== undefined || limit !== undefined) { - const pageNum = parseInt(page) || 1; - const limitNum = parseInt(limit) || 10; + if (page !== undefined && !isPositiveIntegerString(page)) { + return res.status(400).json({ error: 'page must be a positive integer' }); + } + + if (limit !== undefined && !isPositiveIntegerString(limit)) { + return res.status(400).json({ error: 'limit must be a positive integer' }); + } + + const pageNum = page === undefined ? 1 : Number(page); + const limitNum = limit === undefined ? 10 : Number(limit); const tasks = taskService.getPaginated(pageNum, limitNum); return res.json(tasks); } @@ -27,6 +41,15 @@ router.get('/', (req, res) => { res.json(tasks); }); +router.get('/:id', (req, res) => { + const task = taskService.findById(req.params.id); + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + res.json(task); +}); + router.post('/', (req, res) => { const error = validateCreateTask(req.body); if (error) { @@ -69,4 +92,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.assignTask(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..5d4c752d 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -1,15 +1,20 @@ const { v4: uuidv4 } = require('uuid'); let tasks = []; +const UPDATABLE_FIELDS = ['title', 'description', 'status', 'priority', 'dueDate']; 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; + if (!Number.isInteger(page) || !Number.isInteger(limit) || page < 1 || limit < 1) { + return []; + } + + const offset = (page-1) * limit; return tasks.slice(offset, offset + limit); }; @@ -36,6 +41,7 @@ const create = ({ title, description = '', status = 'todo', priority = 'medium', status, priority, dueDate, + assignee: null, completedAt: null, createdAt: new Date().toISOString(), }; @@ -47,7 +53,14 @@ const update = (id, fields) => { const index = tasks.findIndex((t) => t.id === id); if (index === -1) return null; - const updated = { ...tasks[index], ...fields }; + const updates = UPDATABLE_FIELDS.reduce((acc, field) => { + if (fields[field] !== undefined) { + acc[field] = fields[field]; + } + return acc; + }, {}); + + const updated = { ...tasks[index], ...updates }; tasks[index] = updated; return updated; }; @@ -66,7 +79,6 @@ const completeTask = (id) => { const updated = { ...task, - priority: 'medium', status: 'done', completedAt: new Date().toISOString(), }; @@ -76,6 +88,19 @@ const completeTask = (id) => { return updated; }; +const assignTask = (id, assignee) => { + const index = tasks.findIndex((t) => t.id === id); + if (index === -1) return null; + + const updated = { + ...tasks[index], + assignee: assignee.trim(), + }; + + tasks[index] = updated; + return updated; +}; + const _reset = () => { tasks = []; }; @@ -90,5 +115,6 @@ module.exports = { update, remove, completeTask, + assignTask, _reset, }; diff --git a/task-api/src/utils/validators.js b/task-api/src/utils/validators.js index 1e908ff5..e6b37ee5 100644 --- a/task-api/src/utils/validators.js +++ b/task-api/src/utils/validators.js @@ -1,36 +1,81 @@ const VALID_STATUSES = ['todo', 'in_progress', 'done']; const VALID_PRIORITIES = ['low', 'medium', 'high']; +const PROTECTED_UPDATE_FIELDS = ['id', 'createdAt', 'completedAt']; +const MUTABLE_UPDATE_FIELDS = ['title', 'description', 'status', 'priority', 'dueDate']; +const VALID_ASSIGN_FIELDS = ['assignee']; + +const isValidFutureDueDate = (dueDate) => { + const parsedDate = Date.parse(dueDate); + if (isNaN(parsedDate)) { + return false; + } + + return parsedDate > Date.now(); +}; const validateCreateTask = (body) => { if (!body.title || typeof body.title !== 'string' || body.title.trim() === '') { return 'title is required and must be a non-empty string'; } - if (body.status && !VALID_STATUSES.includes(body.status)) { + if (body.status !== undefined && !VALID_STATUSES.includes(body.status)) { return `status must be one of: ${VALID_STATUSES.join(', ')}`; } - if (body.priority && !VALID_PRIORITIES.includes(body.priority)) { + if (body.priority !== undefined && !VALID_PRIORITIES.includes(body.priority)) { return `priority must be one of: ${VALID_PRIORITIES.join(', ')}`; } - if (body.dueDate && isNaN(Date.parse(body.dueDate))) { - return 'dueDate must be a valid ISO date string'; + if (body.dueDate !== undefined) { + if (!isValidFutureDueDate(body.dueDate)) { + return 'dueDate must be a valid ISO date string in the future'; + } } return null; }; const validateUpdateTask = (body) => { + const protectedField = PROTECTED_UPDATE_FIELDS.find((field) => body[field] !== undefined); + if (protectedField) { + return `${protectedField} cannot be updated`; + } + + const invalidField = Object.keys(body).find( + (field) => !MUTABLE_UPDATE_FIELDS.includes(field) && !PROTECTED_UPDATE_FIELDS.includes(field) + ); + if (invalidField) { + return `${invalidField} is not a valid task field`; + } + if (body.title !== undefined && (typeof body.title !== 'string' || body.title.trim() === '')) { return 'title must be a non-empty string'; } - if (body.status && !VALID_STATUSES.includes(body.status)) { + if (body.status !== undefined && !VALID_STATUSES.includes(body.status)) { return `status must be one of: ${VALID_STATUSES.join(', ')}`; } - if (body.priority && !VALID_PRIORITIES.includes(body.priority)) { + if (body.priority !== undefined && !VALID_PRIORITIES.includes(body.priority)) { return `priority must be one of: ${VALID_PRIORITIES.join(', ')}`; } - if (body.dueDate && isNaN(Date.parse(body.dueDate))) { - return 'dueDate must be a valid ISO date string'; + if (body.dueDate !== undefined) { + if (!isValidFutureDueDate(body.dueDate)) { + return 'dueDate must be a valid ISO date string in the future'; + } } return null; }; -module.exports = { validateCreateTask, validateUpdateTask }; +const validateAssignTask = (body) => { + const invalidField = Object.keys(body).find((field) => !VALID_ASSIGN_FIELDS.includes(field)); + if (invalidField) { + return `${invalidField} is not a valid task field`; + } + + if (body.assignee === undefined) { + return 'assignee is required'; + } + + if (typeof body.assignee !== 'string' || body.assignee.trim() === '') { + return 'assignee must be a non-empty string'; + } + + return null; +}; + +module.exports = { validateCreateTask, validateUpdateTask, validateAssignTask }; diff --git a/task-api/tests/assignTasks.test.js b/task-api/tests/assignTasks.test.js new file mode 100644 index 00000000..87429dea --- /dev/null +++ b/task-api/tests/assignTasks.test.js @@ -0,0 +1,72 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +beforeEach(() => { + taskService._reset(); +}); + +describe('PATCH /tasks/:id/assign', () => { + let taskId; + + beforeEach(async () => { + const res = await request(app).post('/tasks').send({ + title: 'Unassigned Task', + description: 'Needs a person', + status: 'todo', + priority: 'medium' + }); + + taskId = res.body.id; + }); + + it('should assign a task to a user', async () => { + const res = await request(app) + .patch(`/tasks/${taskId}/assign`) + .send({ assignee: 'Ada Lovelace' }); + + expect(res.statusCode).toBe(200); + expect(res.body.id).toBe(taskId); + expect(res.body.assignee).toBe('Ada Lovelace'); + }); + + it('should trim whitespace from assignee names', async () => { + const res = await request(app) + .patch(`/tasks/${taskId}/assign`) + .send({ assignee: ' Grace Hopper ' }); + + expect(res.statusCode).toBe(200); + expect(res.body.assignee).toBe('Grace Hopper'); + }); + + it('should allow reassignment', async () => { + await request(app) + .patch(`/tasks/${taskId}/assign`) + .send({ assignee: 'First Person' }); + + const res = await request(app) + .patch(`/tasks/${taskId}/assign`) + .send({ assignee: 'Second Person' }); + + expect(res.statusCode).toBe(200); + expect(res.body.assignee).toBe('Second Person'); + }); + + it('should return 400 for empty assignee', async () => { + const res = await request(app) + .patch(`/tasks/${taskId}/assign`) + .send({ assignee: ' ' }); + + expect(res.statusCode).toBe(400); + }); + + it('should return 404 for non-existing task', async () => { + const fakeId = '123e4567-e89b-12d3-a456-426614174000'; + + const res = await request(app) + .patch(`/tasks/${fakeId}/assign`) + .send({ assignee: 'Ada Lovelace' }); + + expect(res.statusCode).toBe(404); + }); +}); diff --git a/task-api/tests/createTasks.test.js b/task-api/tests/createTasks.test.js new file mode 100644 index 00000000..9921c8af --- /dev/null +++ b/task-api/tests/createTasks.test.js @@ -0,0 +1,92 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +beforeEach(() => { + taskService._reset(); +}); + +describe('POST /tasks', () => { + const validTask = { + title: 'Complete assignment', + description: 'Write API tests', + status: 'todo', + priority: 'high', + dueDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + }; + + it('should create a new task successfully', async () => { + const res = await request(app) + .post('/tasks') + .send(validTask); + + expect(res.statusCode).toBe(201); + + expect(res.body).toHaveProperty('id'); + expect(res.body.title).toBe(validTask.title); + expect(res.body.description).toBe(validTask.description); + expect(res.body.status).toBe(validTask.status); + expect(res.body.priority).toBe(validTask.priority); + + expect(res.body.createdAt).toBeDefined(); + + expect(res.body.completedAt).toBeNull(); + }); + + it('should fail if title is missing', async () => { + const res = await request(app) + .post('/tasks') + .send({ + description: 'No title task', + status: 'todo', + priority: 'low' + }); + + expect(res.statusCode).toBe(400); + }); + + it('should fail for invalid status value', async () => { + const res = await request(app) + .post('/tasks') + .send({ + ...validTask, + status: 'invalid_status' + }); + + expect(res.statusCode).toBe(400); + }); + + it('should fail for invalid priority value', async () => { + const res = await request(app) + .post('/tasks') + .send({ + ...validTask, + priority: 'urgent' + }); + + expect(res.statusCode).toBe(400); + }); + + it('should fail for invalid dueDate format', async () => { + const res = await request(app) + .post('/tasks') + .send({ + ...validTask, + dueDate: 'not-a-date' + }); + + expect(res.statusCode).toBe(400); + }); + + it('should ignore or reject extra fields', async () => { + const res = await request(app) + .post('/tasks') + .send({ + ...validTask, + randomField: 'hack' + }); + + expect([200, 201, 400]).toContain(res.statusCode); + }); + +}); diff --git a/task-api/tests/deleteTasks.test.js b/task-api/tests/deleteTasks.test.js new file mode 100644 index 00000000..5d630466 --- /dev/null +++ b/task-api/tests/deleteTasks.test.js @@ -0,0 +1,46 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +beforeEach(() => { + taskService._reset(); +}); + +describe('DELETE /tasks/:id', () => { + + let taskId; + + beforeEach(async () => { + const res = await request(app).post('/tasks').send({ + title: 'Task to delete', + description: 'Delete me', + status: 'todo', + priority: 'low' + }); + + taskId = res.body.id; + }); + + it('should delete a task successfully', async () => { + const res = await request(app).delete(`/tasks/${taskId}`); + + expect([200, 204]).toContain(res.statusCode); + }); + + it('should not find the task after deletion', async () => { + await request(app).delete(`/tasks/${taskId}`); + + const res = await request(app).get(`/tasks/${taskId}`); + + expect(res.statusCode).toBe(404); + }); + + it('should return 404 for non-existing task', async () => { + const fakeId = '123e4567-e89b-12d3-a456-426614174000'; + + const res = await request(app).delete(`/tasks/${fakeId}`); + + expect(res.statusCode).toBe(404); + }); + +}); diff --git a/task-api/tests/getTasks.test.js b/task-api/tests/getTasks.test.js new file mode 100644 index 00000000..99948317 --- /dev/null +++ b/task-api/tests/getTasks.test.js @@ -0,0 +1,186 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +beforeEach(() => { + taskService._reset(); +}); + +describe('GET /tasks?status=todo', () => { + + beforeEach(async () => { + await request(app).post('/tasks').send({ + title: 'Task 1', + description: 'Todo task', + status: 'todo', + priority: 'low' + }); + + await request(app).post('/tasks').send({ + title: 'Task 2', + description: 'In progress task', + status: 'in_progress', + priority: 'medium' + }); + + await request(app).post('/tasks').send({ + title: 'Task 3', + description: 'Another todo', + status: 'todo', + priority: 'high' + }); + }); + + it('should return only tasks with status todo', async () => { + const res = await request(app).get('/tasks?status=todo'); + + expect(res.statusCode).toBe(200); + + expect(Array.isArray(res.body)).toBe(true); + + res.body.forEach(task => { + expect(task.status).toBe('todo'); + }); + + expect(res.body.length).toBeGreaterThan(0); + }); + + it('should return 400 for invalid status query', async () => { + const res = await request(app).get('/tasks?status=invalid'); + + expect(res.statusCode).toBe(400); + }); + +}); + + + + +describe('GET /tasks?page=1&limit=10', () => { + + beforeEach(async () => { + for (let i = 1; i <= 15; i++) { + await request(app).post('/tasks').send({ + title: `Task ${i}`, + description: `Task number ${i}`, + status: 'todo', + priority: 'low' + }); + } + }); + + it('should return paginated tasks with correct limit', async () => { + const res = await request(app).get('/tasks?page=1&limit=10'); + + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + + expect(res.body.length).toBeLessThanOrEqual(10); + }); + + it('should return different tasks for page 2', async () => { + const page1 = await request(app).get('/tasks?page=1&limit=10'); + const page2 = await request(app).get('/tasks?page=2&limit=10'); + + expect(page1.statusCode).toBe(200); + expect(page2.statusCode).toBe(200); + + const idsPage1 = page1.body.map(t => t.id); + const idsPage2 = page2.body.map(t => t.id); + + const overlap = idsPage1.some(id => idsPage2.includes(id)); + expect(overlap).toBe(false); + }); + + it('should return 400 for invalid page value', async () => { + const res = await request(app).get('/tasks?page=-1&limit=10'); + + expect(res.statusCode).toBe(400); + }); + + it('should return 400 for invalid limit value', async () => { + const res = await request(app).get('/tasks?page=1&limit=0'); + + expect(res.statusCode).toBe(400); + }); + +}); + + + + +describe('GET /tasks/stats', () => { + + beforeEach(async () => { + const now = new Date(); + const pastDate = new Date(now.getTime() - 86400000).toISOString(); // yesterday + const futureDate = new Date(now.getTime() + 86400000).toISOString(); // tomorrow + + await request(app).post('/tasks').send({ + title: 'Todo Task', + description: 'Task 1', + status: 'todo', + priority: 'low', + dueDate: futureDate + }); + + await request(app).post('/tasks').send({ + title: 'In Progress Task', + description: 'Task 2', + status: 'in_progress', + priority: 'medium', + dueDate: futureDate + }); + + await request(app).post('/tasks').send({ + title: 'Done Task', + description: 'Task 3', + status: 'done', + priority: 'high', + dueDate: futureDate + }); + + const overdueTaskRes = await request(app).post('/tasks').send({ + title: 'Overdue Task', + description: 'Task 4', + status: 'todo', + priority: 'high', + dueDate: futureDate + }); + + const overdueTask = taskService.findById(overdueTaskRes.body.id); + overdueTask.dueDate = pastDate; + }); + + it('should return correct task statistics', async () => { + const res = await request(app).get('/tasks/stats'); + + expect(res.statusCode).toBe(200); + + expect(res.body).toHaveProperty('todo'); + expect(res.body).toHaveProperty('in_progress'); + expect(res.body).toHaveProperty('done'); + expect(res.body).toHaveProperty('overdue'); + + expect(res.body.todo).toBe(2); + expect(res.body.in_progress).toBe(1); + expect(res.body.done).toBe(1); + expect(res.body.overdue).toBe(1); + }); + + it('should return zero counts when no tasks exist', async () => { + taskService._reset(); + + const res = await request(app).get('/tasks/stats'); + + expect(res.statusCode).toBe(200); + + expect(res.body).toEqual({ + todo: 0, + in_progress: 0, + done: 0, + overdue: 0 + }); + }); + +}); diff --git a/task-api/tests/updateTasks.test.js b/task-api/tests/updateTasks.test.js new file mode 100644 index 00000000..ccf24db4 --- /dev/null +++ b/task-api/tests/updateTasks.test.js @@ -0,0 +1,132 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +beforeEach(() => { + taskService._reset(); +}); + +describe('PUT /tasks/:id', () => { + + let taskId; + + beforeEach(async () => { + const res = await request(app).post('/tasks').send({ + title: 'Original Task', + description: 'Before update', + status: 'todo', + priority: 'low' + }); + + taskId = res.body.id; + }); + + it('should update a task successfully', async () => { + const updatedData = { + title: 'Updated Task', + status: 'in_progress', + priority: 'high' + }; + + const res = await request(app) + .put(`/tasks/${taskId}`) + .send(updatedData); + + expect(res.statusCode).toBe(200); + + expect(res.body.id).toBe(taskId); + expect(res.body.title).toBe(updatedData.title); + expect(res.body.status).toBe(updatedData.status); + expect(res.body.priority).toBe(updatedData.priority); + }); + + it('should return 404 for non-existing task', async () => { + const fakeId = '123e4567-e89b-12d3-a456-426614174000'; + + const res = await request(app) + .put(`/tasks/${fakeId}`) + .send({ title: 'Does not exist' }); + + expect(res.statusCode).toBe(404); + }); + + it('should return 400 for invalid status', async () => { + const res = await request(app) + .put(`/tasks/${taskId}`) + .send({ status: 'wrong_status' }); + + expect(res.statusCode).toBe(400); + }); + + it('should return 400 for invalid priority', async () => { + const res = await request(app) + .put(`/tasks/${taskId}`) + .send({ priority: 'urgent' }); + + expect(res.statusCode).toBe(400); + }); + + it('should return 400 for invalid dueDate format', async () => { + const res = await request(app) + .put(`/tasks/${taskId}`) + .send({ dueDate: 'invalid-date' }); + + expect(res.statusCode).toBe(400); + }); + + it('should update only provided fields (partial update)', async () => { + const res = await request(app) + .put(`/tasks/${taskId}`) + .send({ status: 'done' }); + + expect(res.statusCode).toBe(200); + expect(res.body.status).toBe('done'); + + expect(res.body.title).toBeDefined(); + }); + +}); + +describe('PATCH /tasks/:id/complete', () => { + + let taskId; + + beforeEach(async () => { + const res = await request(app).post('/tasks').send({ + title: 'Incomplete Task', + description: 'Needs completion', + status: 'todo', + priority: 'medium' + }); + + taskId = res.body.id; + }); + + it('should mark task as complete', async () => { + const res = await request(app) + .patch(`/tasks/${taskId}/complete`); + + expect(res.statusCode).toBe(200); + + expect(res.body.status).toBe('done'); + expect(res.body.completedAt).not.toBeNull(); + }); + + it('should not break if task is already completed', async () => { + await request(app).patch(`/tasks/${taskId}/complete`); + const res = await request(app).patch(`/tasks/${taskId}/complete`); + + expect(res.statusCode).toBe(200); + expect(res.body.status).toBe('done'); + }); + + it('should return 404 for non-existing task', async () => { + const fakeId = '123e4567-e89b-12d3-a456-426614174000'; + + const res = await request(app) + .patch(`/tasks/${fakeId}/complete`); + + expect(res.statusCode).toBe(404); + }); + +});