diff --git a/task-api/src/routes/tasks.js b/task-api/src/routes/tasks.js index e8c370fe..10586202 100644 --- a/task-api/src/routes/tasks.js +++ b/task-api/src/routes/tasks.js @@ -1,24 +1,41 @@ -const express = require('express'); +const express = require("express"); const router = express.Router(); -const taskService = require('../services/taskService'); -const { validateCreateTask, validateUpdateTask } = require('../utils/validators'); +const taskService = require("../services/taskService"); +const { + validateCreateTask, + validateUpdateTask, + validateAssignee, +} = require("../utils/validators"); -router.get('/stats', (req, res) => { +const VALID_STATUS = ["todo", "in_progress", "done"]; + +router.get("/stats", (req, res) => { const stats = taskService.getStats(); res.json(stats); }); -router.get('/', (req, res) => { +router.get("/", (req, res) => { const { status, page, limit } = req.query; if (status) { + if (!VALID_STATUS.includes(status)) { + return res.status(400).json({ error: "invalid status query parameter" }); + } const tasks = taskService.getByStatus(status); return res.json(tasks); } if (page !== undefined || limit !== undefined) { - const pageNum = parseInt(page) || 1; - const limitNum = parseInt(limit) || 10; + const pageNum = page !== undefined ? parseInt(page, 10) : 1; + const limitNum = limit !== undefined ? parseInt(limit, 10) : 10; + if ( + (page !== undefined && (Number.isNaN(pageNum) || pageNum < 1)) || + (limit !== undefined && (Number.isNaN(limitNum) || limitNum < 1)) + ) { + return res + .status(400) + .json({ error: "page and limit must be positive integers" }); + } const tasks = taskService.getPaginated(pageNum, limitNum); return res.json(tasks); } @@ -27,7 +44,7 @@ router.get('/', (req, res) => { res.json(tasks); }); -router.post('/', (req, res) => { +router.post("/", (req, res) => { const error = validateCreateTask(req.body); if (error) { return res.status(400).json({ error }); @@ -37,7 +54,7 @@ router.post('/', (req, res) => { res.status(201).json(task); }); -router.put('/:id', (req, res) => { +router.put("/:id", (req, res) => { const error = validateUpdateTask(req.body); if (error) { return res.status(400).json({ error }); @@ -45,27 +62,43 @@ router.put('/:id', (req, res) => { const task = taskService.update(req.params.id, req.body); if (!task) { - return res.status(404).json({ error: 'Task not found' }); + return res.status(404).json({ error: "Task not found" }); } res.json(task); }); -router.delete('/:id', (req, res) => { +router.delete("/:id", (req, res) => { const deleted = taskService.remove(req.params.id); if (!deleted) { - return res.status(404).json({ error: 'Task not found' }); + return res.status(404).json({ error: "Task not found" }); } res.status(204).send(); }); -router.patch('/:id/complete', (req, res) => { +router.patch("/:id/complete", (req, res) => { const task = taskService.completeTask(req.params.id); if (!task) { - return res.status(404).json({ error: 'Task not found' }); + return res.status(404).json({ error: "Task not found" }); + } + + res.json(task); +}); + +router.patch("/:id/assign", (req, res) => { + const error = validateAssignee(req.body); + if (error) { + return res.status(400).json({ error }); + } + + const assignee = req.body.assignee.trim(); + const task = taskService.assignTask(req.params.id, assignee); + if (!task) { + return res.status(404).json({ error: "Task not found" }); } + // Overwrite existing assignee when present because reassignment should update the task assignee. res.json(task); }); diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index f8e89189..2ac5ed71 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -1,4 +1,4 @@ -const { v4: uuidv4 } = require('uuid'); +const { v4: uuidv4 } = require("uuid"); let tasks = []; @@ -6,10 +6,12 @@ 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; + // Expected: page 1 begins at offset 0, page 2 begins at offset limit. + // Actual bug: offset was computed as page * limit, causing the first page to skip items. + const offset = (page - 1) * limit; return tasks.slice(offset, offset + limit); }; @@ -20,7 +22,7 @@ const getStats = () => { tasks.forEach((t) => { if (counts[t.status] !== undefined) counts[t.status]++; - if (t.dueDate && t.status !== 'done' && new Date(t.dueDate) < now) { + if (t.dueDate && t.status !== "done" && new Date(t.dueDate) < now) { overdue++; } }); @@ -28,7 +30,14 @@ 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, @@ -36,6 +45,7 @@ const create = ({ title, description = '', status = 'todo', priority = 'medium', status, priority, dueDate, + assignee, completedAt: null, createdAt: new Date().toISOString(), }; @@ -52,6 +62,19 @@ const update = (id, fields) => { return updated; }; +const assignTask = (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 remove = (id) => { const index = tasks.findIndex((t) => t.id === id); if (index === -1) return false; @@ -66,8 +89,8 @@ const completeTask = (id) => { const updated = { ...task, - priority: 'medium', - status: 'done', + priority: "medium", + status: "done", completedAt: new Date().toISOString(), }; @@ -90,5 +113,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..09c6e8b6 100644 --- a/task-api/src/utils/validators.js +++ b/task-api/src/utils/validators.js @@ -1,36 +1,66 @@ -const VALID_STATUSES = ['todo', 'in_progress', 'done']; -const VALID_PRIORITIES = ['low', 'medium', 'high']; +const VALID_STATUSES = ["todo", "in_progress", "done"]; +const VALID_PRIORITIES = ["low", "medium", "high"]; 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.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)) { - return `status must be one of: ${VALID_STATUSES.join(', ')}`; + return `status must be one of: ${VALID_STATUSES.join(", ")}`; } if (body.priority && !VALID_PRIORITIES.includes(body.priority)) { - return `priority must be one of: ${VALID_PRIORITIES.join(', ')}`; + return `priority must be one of: ${VALID_PRIORITIES.join(", ")}`; + } + if ( + body.assignee !== undefined && + (typeof body.assignee !== "string" || body.assignee.trim() === "") + ) { + return "assignee must be a non-empty string when provided"; } if (body.dueDate && isNaN(Date.parse(body.dueDate))) { - return 'dueDate must be a valid ISO date string'; + return "dueDate must be a valid ISO date string"; } return null; }; const validateUpdateTask = (body) => { - if (body.title !== undefined && (typeof body.title !== 'string' || body.title.trim() === '')) { - return 'title must be a non-empty string'; + 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)) { - return `status must be one of: ${VALID_STATUSES.join(', ')}`; + return `status must be one of: ${VALID_STATUSES.join(", ")}`; } if (body.priority && !VALID_PRIORITIES.includes(body.priority)) { - return `priority must be one of: ${VALID_PRIORITIES.join(', ')}`; + return `priority must be one of: ${VALID_PRIORITIES.join(", ")}`; + } + if ( + body.assignee !== undefined && + (typeof body.assignee !== "string" || body.assignee.trim() === "") + ) { + return "assignee must be a non-empty string when provided"; } if (body.dueDate && isNaN(Date.parse(body.dueDate))) { - return 'dueDate must be a valid ISO date string'; + return "dueDate must be a valid ISO date string"; + } + return null; +}; + +const validateAssignee = (body) => { + if ( + !body || + typeof body.assignee !== "string" || + body.assignee.trim() === "" + ) { + return "assignee is required and must be a non-empty string"; } return null; }; -module.exports = { validateCreateTask, validateUpdateTask }; +module.exports = { validateCreateTask, validateUpdateTask, validateAssignee }; diff --git a/task-api/tests/integration/tasksRoutes.test.js b/task-api/tests/integration/tasksRoutes.test.js new file mode 100644 index 00000000..3e740cae --- /dev/null +++ b/task-api/tests/integration/tasksRoutes.test.js @@ -0,0 +1,205 @@ +const request = require("supertest"); +const app = require("../../src/app"); +const taskService = require("../../src/services/taskService"); + +beforeEach(() => { + taskService._reset(); +}); + +describe("Tasks API", () => { + describe("GET /tasks", () => { + it("returns all tasks", async () => { + const task = taskService.create({ title: "List me" }); + + const res = await request(app).get("/tasks"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([task]); + }); + + it("filters by status", async () => { + taskService.create({ title: "Todo", status: "todo" }); + const doneTask = taskService.create({ title: "Done", status: "done" }); + + const res = await request(app).get("/tasks").query({ status: "done" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual([doneTask]); + }); + + it("returns 400 for invalid status query", async () => { + const res = await request(app).get("/tasks").query({ status: "invalid" }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("invalid status query parameter"); + }); + + it("paginates tasks correctly", async () => { + const first = taskService.create({ title: "First" }); + taskService.create({ title: "Second" }); + + const res = await request(app).get("/tasks").query({ page: 1, limit: 1 }); + + expect(res.status).toBe(200); + expect(res.body).toEqual([first]); + }); + + it("returns 400 for invalid pagination values", async () => { + const res = await request(app) + .get("/tasks") + .query({ page: "zero", limit: "one" }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("page and limit must be positive integers"); + }); + }); + + describe("GET /tasks/stats", () => { + it("returns statistics for tasks", async () => { + taskService.create({ title: "Task 1", status: "todo" }); + taskService.create({ title: "Task 2", status: "done" }); + + const res = await request(app).get("/tasks/stats"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + todo: 1, + in_progress: 0, + done: 1, + overdue: 0, + }); + }); + }); + + describe("POST /tasks", () => { + it("creates a task", async () => { + const res = await request(app) + .post("/tasks") + .send({ title: "New task", description: "A task", priority: "low" }); + + expect(res.status).toBe(201); + expect(res.body.title).toBe("New task"); + expect(res.body.priority).toBe("low"); + expect(res.body.assignee).toBeNull(); + }); + + it("returns 400 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/); + }); + }); + + describe("PUT /tasks/:id", () => { + it("updates a task", async () => { + const task = taskService.create({ title: "Edit me" }); + const res = await request(app) + .put(`/tasks/${task.id}`) + .send({ title: "Edited", assignee: "Jason" }); + + expect(res.status).toBe(200); + expect(res.body.title).toBe("Edited"); + expect(res.body.assignee).toBe("Jason"); + }); + + it("returns 404 when task does not exist", async () => { + const res = await request(app) + .put("/tasks/nonexistent") + .send({ title: "Edited" }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Task not found"); + }); + + it("returns 400 for invalid update data", async () => { + const task = taskService.create({ title: "Edit me" }); + const res = await request(app) + .put(`/tasks/${task.id}`) + .send({ title: "" }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/title must be a non-empty string/); + }); + }); + + describe("DELETE /tasks/:id", () => { + it("deletes a task", async () => { + const task = taskService.create({ title: "Remove me" }); + const res = await request(app).delete(`/tasks/${task.id}`); + + expect(res.status).toBe(204); + }); + + it("returns 404 when task not found", async () => { + const res = await request(app).delete("/tasks/missing"); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Task not found"); + }); + }); + + describe("PATCH /tasks/:id/complete", () => { + it("marks a task complete", async () => { + const task = taskService.create({ title: "Complete me" }); + const res = await request(app).patch(`/tasks/${task.id}/complete`); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("done"); + expect(res.body.completedAt).toBeDefined(); + }); + + it("returns 404 when task not found", async () => { + const res = await request(app).patch("/tasks/missing/complete"); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Task not found"); + }); + }); + + describe("PATCH /tasks/:id/assign", () => { + it("assigns a task successfully", async () => { + const task = taskService.create({ title: "Assign me" }); + const res = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: "Sam" }); + + expect(res.status).toBe(200); + expect(res.body.assignee).toBe("Sam"); + }); + + it("returns 400 for empty assignee", async () => { + const task = taskService.create({ title: "Assign me" }); + const res = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: "" }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/assignee is required/); + }); + + it("returns 404 when task not found", async () => { + const res = await request(app) + .patch("/tasks/does-not-exist/assign") + .send({ assignee: "Sam" }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Task not found"); + }); + + it("allows reassignment when assignee already exists", async () => { + const task = taskService.create({ + title: "Reassign me", + assignee: "Alex", + }); + const res = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: "Jordan" }); + + expect(res.status).toBe(200); + expect(res.body.assignee).toBe("Jordan"); + }); + }); +}); diff --git a/task-api/tests/unit/taskService.test.js b/task-api/tests/unit/taskService.test.js new file mode 100644 index 00000000..3eb20884 --- /dev/null +++ b/task-api/tests/unit/taskService.test.js @@ -0,0 +1,177 @@ +const taskService = require("../../src/services/taskService"); + +describe("taskService", () => { + beforeEach(() => { + taskService._reset(); + }); + + describe("create", () => { + it("creates a task with defaults", () => { + const task = taskService.create({ title: "Test task" }); + + expect(task).toMatchObject({ + title: "Test task", + description: "", + status: "todo", + priority: "medium", + dueDate: null, + assignee: null, + }); + expect(task.id).toBeDefined(); + expect(task.createdAt).toBeDefined(); + expect(task.completedAt).toBeNull(); + }); + + it("accepts valid optional fields", () => { + const dueDate = new Date(Date.now() + 86400000).toISOString(); + const task = taskService.create({ + title: "Assigned task", + description: "details", + status: "in_progress", + priority: "high", + dueDate, + assignee: "Alice", + }); + + expect(task).toMatchObject({ + title: "Assigned task", + description: "details", + status: "in_progress", + priority: "high", + dueDate, + assignee: "Alice", + }); + }); + }); + + describe("update", () => { + it("updates an existing task", () => { + const task = taskService.create({ title: "Original" }); + const updated = taskService.update(task.id, { + title: "Updated", + priority: "high", + }); + + expect(updated).toMatchObject({ title: "Updated", priority: "high" }); + expect(updated.createdAt).toBe(task.createdAt); + }); + + it("returns null for non-existent task", () => { + expect(taskService.update("missing-id", { title: "Nope" })).toBeNull(); + }); + }); + + describe("remove", () => { + it("removes a task by id", () => { + const task = taskService.create({ title: "Delete me" }); + expect(taskService.remove(task.id)).toBe(true); + expect(taskService.findById(task.id)).toBeUndefined(); + }); + + it("returns false when task does not exist", () => { + expect(taskService.remove("missing-id")).toBe(false); + }); + }); + + describe("getByStatus", () => { + it("filters tasks by exact status", () => { + const todo = taskService.create({ title: "Todo task" }); + const done = taskService.create({ title: "Done task", status: "done" }); + + const result = taskService.getByStatus("todo"); + + expect(result).toEqual([todo]); + expect(result).not.toContain(done); + }); + }); + + describe("getPaginated", () => { + it("returns the correct page of tasks", () => { + const first = taskService.create({ title: "First" }); + const second = taskService.create({ title: "Second" }); + const third = taskService.create({ title: "Third" }); + + // Expected: page 1 starts at offset 0, page 2 starts at offset limit. + const page1 = taskService.getPaginated(1, 2); + const page2 = taskService.getPaginated(2, 2); + + expect(page1).toEqual([first, second]); + expect(page2).toEqual([third]); + }); + + it("returns an empty array for out-of-range pages", () => { + taskService.create({ title: "Only task" }); + const page = taskService.getPaginated(2, 2); + + expect(page).toEqual([]); + }); + }); + + describe("getStats", () => { + it("counts tasks by status and overdue tasks", () => { + const pastDate = new Date(Date.now() - 86400000).toISOString(); + const futureDate = new Date(Date.now() + 86400000).toISOString(); + + taskService.create({ title: "Todo overdue", dueDate: pastDate }); + taskService.create({ + title: "In progress", + status: "in_progress", + dueDate: futureDate, + }); + taskService.create({ + title: "Done task", + status: "done", + dueDate: pastDate, + }); + + expect(taskService.getStats()).toEqual({ + todo: 1, + in_progress: 1, + done: 1, + overdue: 1, + }); + }); + }); + + describe("completeTask", () => { + it("marks a task as done and sets completedAt", () => { + const task = taskService.create({ + title: "Complete me", + priority: "high", + }); + const completed = taskService.completeTask(task.id); + + expect(completed).toMatchObject({ status: "done" }); + expect(completed.completedAt).toBeDefined(); + expect(completed.priority).toBe("medium"); + }); + + it("returns null when completing a missing task", () => { + expect(taskService.completeTask("bad-id")).toBeNull(); + }); + }); + + describe("assignTask", () => { + it("assigns an assignee to a task", () => { + const task = taskService.create({ title: "Assign me" }); + const assigned = taskService.assignTask(task.id, "Dana"); + + expect(assigned).toMatchObject({ assignee: "Dana" }); + expect(taskService.findById(task.id).assignee).toBe("Dana"); + }); + + it("allows reassignment of an existing task", () => { + const task = taskService.create({ + title: "Reassign me", + assignee: "Bob", + }); + const reassigned = taskService.assignTask(task.id, "Carol"); + + expect(reassigned.assignee).toBe("Carol"); + }); + + it("returns null for missing tasks", () => { + expect(taskService.assignTask("missing-id", "Dana")).toBeNull(); + }); + }); +});