diff --git a/BUG_REPORT.md b/BUG_REPORT.md new file mode 100644 index 00000000..60e4585c --- /dev/null +++ b/BUG_REPORT.md @@ -0,0 +1,97 @@ +# Bug Report + +This report reflects the current codebase after implementation work. Bugs #4, #5, and #9 from the original audit have been fixed. + +## Resolved During Implementation + +- Bug #4: Completing a task no longer resets priority to `medium`. +- Bug #5: Re-completing a task no longer rewrites the original `completedAt` timestamp. +- Bug #9: `PATCH /tasks/:id/assign` now exists and stores `assignee` on the task. + +## Open Bugs + +**Bug #1: Status filter does substring matching** + +- **File:** src/services/taskService.js +- **Line:** ~9 +- **Expected behavior:** `GET /tasks?status=...` should return only tasks whose status exactly matches the requested enum value. +- **Actual behavior:** `getByStatus()` uses `t.status.includes(status)`, so partial values match too. For example, `status=do` returns both `todo` and `done`. +- **How discovered:** Test `getByStatus() -> does not match partial status fragments` and integration check for `GET /tasks?status=`. +- **Proposed fix:** Replace substring matching with strict equality, and validate the query status before filtering. + +--- + +**Bug #2: Pagination skips the first page** + +- **File:** src/services/taskService.js +- **Line:** ~12 +- **Expected behavior:** Page 1 with limit N should return the first N tasks. +- **Actual behavior:** `getPaginated()` computes `offset = page * limit`, so page 1 starts at index `limit` and skips the first page entirely. +- **How discovered:** Test `getPaginated() -> returns the first page of tasks when page=1 and limit=2` and integration check for `GET /tasks` with pagination. +- **Proposed fix:** Use `offset = (page - 1) * limit` for 1-based pagination, and reject invalid page/limit values. + +--- + +**Bug #3: PUT allows immutable fields like createdAt to be overwritten** + +- **File:** src/services/taskService.js +- **Line:** ~50 +- **Expected behavior:** Client updates should only be able to change mutable task fields. +- **Actual behavior:** `update()` merges the entire request body into the existing task object with `{ ...tasks[index], ...fields }`, so a request body containing `createdAt` or `id` overwrites those system fields. +- **How discovered:** Test `update() -> does not allow createdAt to be overwritten by client updates`. +- **Proposed fix:** Whitelist allowed update fields before merging and ignore or reject immutable properties. + +--- + +**Bug #4: Invalid status query values are accepted instead of rejected** + +- **File:** src/routes/tasks.js +- **Line:** ~14-16 +- **Expected behavior:** `GET /tasks?status=...` should validate the requested status and return 400 for unsupported values. +- **Actual behavior:** The route forwards whatever string is in `req.query.status` to `getByStatus()`. Because filtering is substring-based and there is no validation step, unsupported values like `pending` are not rejected consistently. +- **How discovered:** Integration test `GET /tasks -> returns 400 for invalid status filter value`. +- **Proposed fix:** Validate the query parameter against the allowed enum before calling the service and return 400 on invalid input. + +--- + +**Bug #5: Invalid page and limit values are silently coerced** + +- **File:** src/routes/tasks.js +- **Line:** ~18-21 +- **Expected behavior:** Non-numeric, zero, or negative pagination values should be rejected with 400. +- **Actual behavior:** `parseInt(page) || 1` and `parseInt(limit) || 10` coerce invalid values into defaults. That means `page=abc` becomes page 1 and `limit=0` becomes 10 instead of returning a validation error. +- **How discovered:** Integration tests `GET /tasks -> returns 400 for invalid page query value` and `GET /tasks -> returns 400 for invalid limit query value`. +- **Proposed fix:** Parse and validate query parameters explicitly, require positive integers, and return 400 when parsing fails or values are out of range. + +--- + +**Bug #6: PUT accepts an empty payload instead of rejecting it** + +- **File:** src/routes/tasks.js +- **Line:** ~37-46 +- **Expected behavior:** Updating a task should require at least one valid field, or the API should reject empty bodies with 400. +- **Actual behavior:** An empty object passes validation because `validateUpdateTask({})` returns null, and `taskService.update()` simply merges nothing and returns the existing task. +- **How discovered:** Integration test `PUT /tasks/:id -> returns 400 when required update fields are missing`. +- **Proposed fix:** Add a validation rule that rejects empty update payloads and return 400 before calling the service. + +--- + +**Bug #7: The error middleware converts all errors into a 500 response** + +- **File:** src/app.js +- **Line:** ~9-11 +- **Expected behavior:** Client-side validation and JSON parsing errors should return 400-class responses where appropriate. +- **Actual behavior:** The global error handler always returns `500 Internal server error`, regardless of whether the error originated from bad input or a server fault. +- **How discovered:** Manual inspection of the Express error middleware and the null/invalid-body edge cases in the tests. +- **Proposed fix:** Detect known client errors and map them to 400 responses; reserve 500 for unexpected server failures. + +--- + +**Bug #8: Documentation status enum disagrees with the implementation** + +- **File:** README.md +- **Line:** ~77 +- **Expected behavior:** The API documentation should describe the same task status values that the code accepts and returns. +- **Actual behavior:** README uses `pending | in-progress | completed`, while the code and assignment use `todo | in_progress | done`. +- **How discovered:** Manual inspection of README and assignment docs. +- **Proposed fix:** Update README examples and schema to match the actual API contract, or refactor the code to match the documented values. diff --git a/SUBMISSION.md b/SUBMISSION.md new file mode 100644 index 00000000..b383c2c3 --- /dev/null +++ b/SUBMISSION.md @@ -0,0 +1,19 @@ +# Submission Note + +This submission includes the implementation and cleanup work for the Task API take-home assignment. + +## Completed + +- Added the task assignment flow end to end. +- Fixed task completion so it preserves priority and is idempotent. +- Expanded Jest and Supertest coverage to exercise the missing branches and edge cases. +- Updated the bug report to reflect the current open issues. +- Removed the long-form audit notes file from the repository. + +## Verification + +- Verified the API package from `task-api` with the test and coverage workflow used during implementation. + +## Remaining Known Issues + +- The open issues listed in `BUG_REPORT.md` are still reproducible and are intentionally left as follow-up items. \ No newline at end of file diff --git a/task-api/src/app.js b/task-api/src/app.js index 65c03eec..75446737 100644 --- a/task-api/src/app.js +++ b/task-api/src/app.js @@ -1,22 +1,27 @@ -const express = require('express'); -const taskRoutes = require('./routes/tasks'); +const express = require("express"); +const taskRoutes = require("./routes/tasks"); const app = express(); app.use(express.json()); -app.use('/tasks', taskRoutes); +app.use("/tasks", taskRoutes); app.use((err, req, res, next) => { console.error(err.stack); - res.status(500).json({ error: 'Internal server error' }); + res.status(500).json({ error: "Internal server error" }); }); const PORT = process.env.PORT || 3000; -if (require.main === module) { - app.listen(PORT, () => { - console.log(`Task API running on port ${PORT}`); +const startServer = (port = PORT) => + app.listen(port, () => { + console.log(`Task API running on port ${port}`); }); + +if (require.main === module) { + startServer(PORT); } +app.startServer = startServer; + module.exports = app; diff --git a/task-api/src/routes/tasks.js b/task-api/src/routes/tasks.js index e8c370fe..86bba82f 100644 --- a/task-api/src/routes/tasks.js +++ b/task-api/src/routes/tasks.js @@ -1,14 +1,18 @@ -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, + validateAssignTask, +} = require("../utils/validators"); -router.get('/stats', (req, res) => { +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) { @@ -27,7 +31,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 +41,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,28 +49,51 @@ 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); }); +/* + * PATCH /tasks/:id/assign + * Assigns a task to a named person. + * Design decisions: + * - Reassignment is allowed (overwrites existing assignee). + * - Assignee is trimmed server-side to avoid whitespace inconsistencies. + * - No user list validation — assignee is a free-form string by design. + * (In production, this would validate against a users collection.) + */ +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.trim()); + if (!task) { + return res.status(404).json({ error: "Task not found" }); + } + + res.status(200).json(task); +}); + module.exports = router; diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index f8e89189..7149a846 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 = []; @@ -20,7 +20,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 +28,13 @@ 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, +}) => { const task = { id: uuidv4(), title, @@ -36,6 +42,8 @@ const create = ({ title, description = '', status = 'todo', priority = 'medium', status, priority, dueDate, + // assignee: null means unassigned + assignee: null, completedAt: null, createdAt: new Date().toISOString(), }; @@ -64,10 +72,15 @@ const completeTask = (id) => { const task = findById(id); if (!task) return null; + // GUARD: Already-completed tasks are returned unchanged (idempotent). + if (task.status === "done") return task; + + // FIX: Removed hardcoded priority reset — completing a task should + // preserve its existing priority, not silently downgrade it. + const updated = { ...task, - priority: 'medium', - status: 'done', + status: "done", completedAt: new Date().toISOString(), }; @@ -76,6 +89,23 @@ const completeTask = (id) => { return updated; }; +// assignTask: stores an assignee name on a task. +// Overwrites existing assignee — reassignment is intentionally allowed. +// Trimming handles accidental whitespace from API consumers. +const assignTask = (id, assignee) => { + const task = findById(id); + if (!task) return null; + + const updated = { + ...task, + assignee: assignee.trim(), + }; + + const index = tasks.findIndex((t) => t.id === id); + tasks[index] = updated; + return updated; +}; + const _reset = () => { tasks = []; }; @@ -90,5 +120,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..963be1f3 100644 --- a/task-api/src/utils/validators.js +++ b/task-api/src/utils/validators.js @@ -1,36 +1,67 @@ -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.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.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; }; -module.exports = { validateCreateTask, validateUpdateTask }; +/* + * validateAssignTask: validates the body for PATCH /tasks/:id/assign. + * Rejects null, non-string, empty, whitespace-only, and excessively long names. + * The 100-char limit is a reasonable guard against unbounded input. + */ +const validateAssignTask = (body) => { + if (body === null || body === undefined) { + return "Request body is required"; + } + if (body.assignee === undefined) { + return "assignee is required"; + } + if (typeof body.assignee !== "string") { + return "assignee must be a string"; + } + if (body.assignee.trim() === "") { + return "assignee cannot be empty or whitespace"; + } + if (body.assignee.trim().length > 100) { + return "assignee name cannot exceed 100 characters"; + } + return null; +}; + +module.exports = { validateCreateTask, validateUpdateTask, validateAssignTask }; diff --git a/task-api/tests/tasks.test.js b/task-api/tests/tasks.test.js new file mode 100644 index 00000000..2ad89c6a --- /dev/null +++ b/task-api/tests/tasks.test.js @@ -0,0 +1,767 @@ +const request = require("supertest"); +const app = require("../src/app"); +const taskService = require("../src/services/taskService"); +const { + validateAssignTask, + validateCreateTask, + validateUpdateTask, +} = require("../src/utils/validators"); + +const createSeedTask = (overrides = {}) => { + const suffix = Math.random().toString(16).slice(2, 8); + return taskService.create({ + title: `Task ${suffix}`, + description: "Seed task", + status: "todo", + priority: "medium", + dueDate: null, + ...overrides, + }); +}; + +describe("UNIT TESTS (taskService.js directly)", () => { + beforeEach(() => { + taskService._reset(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe("_reset()", () => { + test("clears all tasks from the in-memory store", () => { + createSeedTask({ title: "First" }); + createSeedTask({ title: "Second" }); + + taskService._reset(); + + expect(taskService.getAll()).toEqual([]); + }); + + test("keeps the store empty when called on an already empty store", () => { + taskService._reset(); + + expect(taskService.getAll()).toEqual([]); + }); + + test("is idempotent when called multiple times", () => { + createSeedTask({ title: "Only" }); + + taskService._reset(); + taskService._reset(); + + expect(taskService.getAll()).toEqual([]); + }); + }); + + describe("create()", () => { + test("creates a task with default values when optional fields are omitted", () => { + const task = taskService.create({ title: "Write tests" }); + + expect(task).toMatchObject({ + title: "Write tests", + description: "", + status: "todo", + priority: "medium", + dueDate: null, + completedAt: null, + }); + expect(task.id).toEqual(expect.any(String)); + expect(task.createdAt).toEqual(expect.any(String)); + expect(Number.isNaN(Date.parse(task.createdAt))).toBe(false); + }); + + test("creates a task using explicitly provided fields", () => { + const dueDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + + const task = taskService.create({ + title: "Plan sprint", + description: "Create sprint plan", + status: "in_progress", + priority: "high", + dueDate, + }); + + expect(task).toMatchObject({ + title: "Plan sprint", + description: "Create sprint plan", + status: "in_progress", + priority: "high", + dueDate, + }); + }); + + test("generates unique ids for consecutive tasks", () => { + const first = taskService.create({ title: "A" }); + const second = taskService.create({ title: "B" }); + + expect(first.id).not.toBe(second.id); + }); + }); + + describe("getAll()", () => { + test("returns all tasks in insertion order", () => { + const first = createSeedTask({ title: "First" }); + const second = createSeedTask({ title: "Second" }); + + const all = taskService.getAll(); + + expect(all).toHaveLength(2); + expect(all[0].id).toBe(first.id); + expect(all[1].id).toBe(second.id); + }); + + test("returns an empty array when no tasks exist", () => { + expect(taskService.getAll()).toEqual([]); + }); + + test("does not allow mutation of internal collection via returned array operations", () => { + createSeedTask({ title: "Immutable list check" }); + + const tasks = taskService.getAll(); + tasks.pop(); + + expect(taskService.getAll()).toHaveLength(1); + }); + }); + + describe("findById()", () => { + test("returns the task when id exists", () => { + const task = createSeedTask({ title: "Find me" }); + + const found = taskService.findById(task.id); + + expect(found).toBeDefined(); + expect(found.id).toBe(task.id); + }); + + test("returns undefined for a non-existent id", () => { + expect(taskService.findById("missing-id")).toBeUndefined(); + }); + + test("returns undefined when store is empty", () => { + taskService._reset(); + + expect(taskService.findById("any-id")).toBeUndefined(); + }); + }); + + describe("getByStatus()", () => { + test("returns tasks that exactly match a valid status", () => { + createSeedTask({ title: "Todo task", status: "todo" }); + createSeedTask({ title: "Done task", status: "done" }); + + const filtered = taskService.getByStatus("todo"); + + expect(filtered).toHaveLength(1); + expect(filtered[0].status).toBe("todo"); + }); + + test("returns an empty array when no tasks match the status", () => { + createSeedTask({ status: "done" }); + + const filtered = taskService.getByStatus("in_progress"); + + expect(filtered).toEqual([]); + }); + + // Known bug: status filtering uses substring matching (includes) instead of exact matching. + test("does not match partial status fragments", () => { + createSeedTask({ status: "todo" }); + createSeedTask({ status: "done" }); + + const filtered = taskService.getByStatus("do"); + + expect(filtered).toEqual([]); + }); + }); + + describe("getPaginated()", () => { + test("returns an empty array when requesting beyond available pages", () => { + createSeedTask({ title: "One" }); + createSeedTask({ title: "Two" }); + + const page = taskService.getPaginated(10, 5); + + expect(page).toEqual([]); + }); + + test("returns all tasks for page 0 when limit exceeds total tasks", () => { + createSeedTask({ title: "One" }); + createSeedTask({ title: "Two" }); + + const page = taskService.getPaginated(0, 10); + + expect(page).toHaveLength(2); + }); + + // Known bug: offset is computed as page * limit, so page=1 skips the first page. + test("returns the first page of tasks when page=1 and limit=2", () => { + const created = [ + createSeedTask({ title: "T1" }), + createSeedTask({ title: "T2" }), + createSeedTask({ title: "T3" }), + ]; + + const page = taskService.getPaginated(1, 2); + + expect(page.map((t) => t.id)).toEqual([created[0].id, created[1].id]); + }); + }); + + describe("getStats()", () => { + test("returns correct counts by status on happy path", () => { + createSeedTask({ status: "todo" }); + createSeedTask({ status: "in_progress" }); + createSeedTask({ status: "done" }); + + const stats = taskService.getStats(); + + expect(stats).toEqual({ + todo: 1, + in_progress: 1, + done: 1, + overdue: 0, + }); + }); + + test("returns zero counts when there are no tasks", () => { + const stats = taskService.getStats(); + + expect(stats).toEqual({ + todo: 0, + in_progress: 0, + done: 0, + overdue: 0, + }); + }); + + test("counts overdue tasks only when dueDate is in the past and task is not done", () => { + const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + createSeedTask({ status: "todo", dueDate: pastDate }); + createSeedTask({ status: "done", dueDate: pastDate }); + + const stats = taskService.getStats(); + + expect(stats.overdue).toBe(1); + }); + + test("ignores unknown status values in count buckets", () => { + createSeedTask({ status: "archived" }); + + const stats = taskService.getStats(); + + expect(stats.todo).toBe(0); + expect(stats.in_progress).toBe(0); + expect(stats.done).toBe(0); + }); + }); + + describe("update()", () => { + test("updates provided fields and preserves unspecified fields", () => { + const task = createSeedTask({ + title: "Original", + description: "Original description", + priority: "low", + }); + + const updated = taskService.update(task.id, { title: "Updated title" }); + + expect(updated.title).toBe("Updated title"); + expect(updated.description).toBe("Original description"); + expect(updated.priority).toBe("low"); + }); + + test("returns null for a non-existent id", () => { + const result = taskService.update("missing-id", { title: "Nope" }); + + expect(result).toBeNull(); + }); + + // Known bug: update allows immutable/system fields like createdAt to be overwritten. + test("does not allow createdAt to be overwritten by client updates", () => { + const task = createSeedTask({ title: "Immutable field check" }); + const originalCreatedAt = task.createdAt; + + const updated = taskService.update(task.id, { + createdAt: "2000-01-01T00:00:00.000Z", + }); + + expect(updated.createdAt).toBe(originalCreatedAt); + }); + }); + + describe("remove()", () => { + test("removes an existing task and returns true", () => { + const task = createSeedTask({ title: "Delete me" }); + + const removed = taskService.remove(task.id); + + expect(removed).toBe(true); + expect(taskService.findById(task.id)).toBeUndefined(); + }); + + test("returns false when trying to remove a non-existent task", () => { + expect(taskService.remove("missing-id")).toBe(false); + }); + + test("returns true on first removal and false on second removal for same id", () => { + const task = createSeedTask({ title: "Remove twice" }); + + const firstRemoval = taskService.remove(task.id); + const secondRemoval = taskService.remove(task.id); + + expect(firstRemoval).toBe(true); + expect(secondRemoval).toBe(false); + }); + }); + + describe("completeTask()", () => { + test("marks a task as done and sets completedAt timestamp", () => { + const task = createSeedTask({ status: "todo" }); + + const completed = taskService.completeTask(task.id); + + expect(completed.status).toBe("done"); + expect(completed.completedAt).toEqual(expect.any(String)); + expect(Number.isNaN(Date.parse(completed.completedAt))).toBe(false); + }); + + test("returns null for a non-existent id", () => { + expect(taskService.completeTask("missing-id")).toBeNull(); + }); + + // Known bug: completing a task overwrites existing priority with medium. + test("preserves existing priority when completing a task", () => { + const task = createSeedTask({ priority: "high" }); + + const completed = taskService.completeTask(task.id); + + expect(completed.priority).toBe("high"); + }); + + // Known bug: re-completing a task rewrites completedAt instead of keeping first completion timestamp. + test("does not change completedAt when task is already completed", () => { + jest.useFakeTimers(); + const task = createSeedTask({ status: "todo" }); + + jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const first = taskService.completeTask(task.id); + + jest.setSystemTime(new Date("2026-01-02T00:00:00.000Z")); + const second = taskService.completeTask(task.id); + + expect(second.completedAt).toBe(first.completedAt); + }); + }); +}); + +describe("UNIT TESTS (validators.js directly)", () => { + test("validateCreateTask rejects invalid status values", () => { + const error = validateCreateTask({ + title: "Task", + status: "pending", + }); + + expect(error).toBe("status must be one of: todo, in_progress, done"); + }); + + test("validateUpdateTask rejects invalid dueDate values", () => { + const error = validateUpdateTask({ dueDate: "not-a-date" }); + + expect(error).toBe("dueDate must be a valid ISO date string"); + }); + + test("validateUpdateTask rejects empty title values", () => { + const error = validateUpdateTask({ title: " " }); + + expect(error).toBe("title must be a non-empty string"); + }); + + test("validateUpdateTask rejects invalid status values", () => { + const error = validateUpdateTask({ status: "pending" }); + + expect(error).toBe("status must be one of: todo, in_progress, done"); + }); + + test("validateUpdateTask rejects invalid priority values", () => { + const error = validateUpdateTask({ priority: "urgent" }); + + expect(error).toBe("priority must be one of: low, medium, high"); + }); + + test("validateAssignTask rejects null body", () => { + expect(validateAssignTask(null)).toBe("Request body is required"); + }); + + test("validateAssignTask rejects undefined assignee", () => { + expect(validateAssignTask({})).toBe("assignee is required"); + }); + + test("validateAssignTask rejects non-string assignee", () => { + expect(validateAssignTask({ assignee: 42 })).toBe( + "assignee must be a string", + ); + }); + + test("validateAssignTask rejects empty string assignee", () => { + expect(validateAssignTask({ assignee: "" })).toBe( + "assignee cannot be empty or whitespace", + ); + }); + + test("validateAssignTask rejects whitespace-only assignee", () => { + expect(validateAssignTask({ assignee: " \t\n" })).toBe( + "assignee cannot be empty or whitespace", + ); + }); + + test("validateAssignTask rejects assignee longer than 100 chars", () => { + const tooLong = "a".repeat(101); + + expect(validateAssignTask({ assignee: tooLong })).toBe( + "assignee name cannot exceed 100 characters", + ); + }); + + test("validateAssignTask returns null for a valid assignee", () => { + expect(validateAssignTask({ assignee: " Alice " })).toBeNull(); + }); +}); + +describe("UNIT TESTS (app bootstrap)", () => { + test("startServer listens on provided port and logs startup message", () => { + const listenSpy = jest + .spyOn(app, "listen") + .mockImplementation((port, callback) => { + if (callback) callback(); + return { close: jest.fn() }; + }); + + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + app.startServer(4321); + + expect(listenSpy).toHaveBeenCalledWith(4321, expect.any(Function)); + expect(logSpy).toHaveBeenCalledWith("Task API running on port 4321"); + + logSpy.mockRestore(); + listenSpy.mockRestore(); + }); +}); + +describe("INTEGRATION TESTS (API routes via Supertest)", () => { + beforeEach(() => { + taskService._reset(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe("GET /tasks", () => { + test("returns all tasks on happy path", async () => { + createSeedTask({ title: "A" }); + createSeedTask({ title: "B" }); + + const response = await request(app).get("/tasks"); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + }); + + test("returns tasks filtered by status query", async () => { + createSeedTask({ title: "Todo task", status: "todo" }); + createSeedTask({ 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].status).toBe("todo"); + }); + + // Known bug: pagination branch uses incorrect offset and skips first page records. + test("returns first page when using pagination query params", async () => { + const created = [ + createSeedTask({ title: "T1" }), + createSeedTask({ title: "T2" }), + createSeedTask({ title: "T3" }), + ]; + + const response = await request(app) + .get("/tasks") + .query({ page: 1, limit: 2 }); + + expect(response.status).toBe(200); + expect(response.body.map((t) => t.id)).toEqual([ + created[0].id, + created[1].id, + ]); + }); + + // Known bug: invalid status values are not validated and currently return 200. + test("returns 400 for invalid status filter value", async () => { + const response = await request(app) + .get("/tasks") + .query({ status: "pending" }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("error"); + }); + + // Known bug: invalid page values are silently coerced instead of rejected. + test("returns 400 for invalid page query value", async () => { + const response = await request(app) + .get("/tasks") + .query({ page: "abc", limit: 10 }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("error"); + }); + + // Known bug: invalid limit values are silently coerced instead of rejected. + test("returns 400 for invalid limit query value", async () => { + const response = await request(app) + .get("/tasks") + .query({ page: 1, limit: 0 }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("error"); + }); + }); + + describe("POST /tasks", () => { + test("creates a task on happy path", async () => { + const response = await request(app) + .post("/tasks") + .send({ title: "Write tests", priority: "high" }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ + title: "Write tests", + priority: "high", + status: "todo", + }); + expect(response.body.id).toEqual(expect.any(String)); + }); + + test("returns 400 when title is missing", async () => { + const response = await request(app) + .post("/tasks") + .send({ priority: "high" }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("error"); + }); + + test("returns 400 when priority is invalid", async () => { + const response = await request(app) + .post("/tasks") + .send({ title: "Task", priority: "urgent" }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatch(/priority/i); + }); + + test("returns 400 when dueDate format is invalid", async () => { + const response = await request(app) + .post("/tasks") + .send({ title: "Task", dueDate: "not-an-iso-date" }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatch(/dueDate/i); + }); + + test("returns 500 when body is JSON null and validator throws", async () => { + const response = await request(app) + .post("/tasks") + .set("Content-Type", "application/json") + .send("null"); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: "Internal server error" }); + }); + }); + + describe("PUT /tasks/:id", () => { + test("updates a task on happy path", async () => { + const task = createSeedTask({ title: "Before update", priority: "low" }); + + const response = await request(app) + .put(`/tasks/${task.id}`) + .send({ title: "After update", priority: "high" }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: task.id, + title: "After update", + priority: "high", + }); + }); + + test("returns 404 for non-existent task id", async () => { + const response = await request(app) + .put("/tasks/does-not-exist") + .send({ title: "No task here" }); + + expect(response.status).toBe(404); + expect(response.body.error).toMatch(/not found/i); + }); + + test("returns 400 when update payload has invalid dueDate", async () => { + const task = createSeedTask({ title: "Needs valid dueDate" }); + + const response = await request(app) + .put(`/tasks/${task.id}`) + .send({ dueDate: "not-a-date" }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatch(/dueDate/i); + }); + + // Known bug: empty update payload is currently accepted instead of rejected. + test("returns 400 when required update fields are missing", async () => { + const task = createSeedTask({ title: "Needs update fields" }); + + const response = await request(app).put(`/tasks/${task.id}`).send({}); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("error"); + }); + }); + + describe("DELETE /tasks/:id", () => { + test("deletes a task and returns 204 with no body", async () => { + const task = createSeedTask({ title: "Delete me" }); + + const response = await request(app).delete(`/tasks/${task.id}`); + + expect(response.status).toBe(204); + expect(response.text).toBe(""); + expect(taskService.findById(task.id)).toBeUndefined(); + }); + + test("returns 404 for non-existent task id", async () => { + const response = await request(app).delete("/tasks/not-found-id"); + + expect(response.status).toBe(404); + expect(response.body.error).toMatch(/not found/i); + }); + }); + + describe("PATCH /tasks/:id/complete", () => { + test("marks task complete on happy path", async () => { + const task = createSeedTask({ status: "todo", priority: "high" }); + + const response = await request(app).patch(`/tasks/${task.id}/complete`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe("done"); + expect(response.body.completedAt).toEqual(expect.any(String)); + expect(Number.isNaN(Date.parse(response.body.completedAt))).toBe(false); + }); + + // Known bug: completing an already completed task rewrites completedAt. + test("does not rewrite completedAt when task is already completed", async () => { + const task = createSeedTask({ status: "todo" }); + + const first = await request(app).patch(`/tasks/${task.id}/complete`); + await new Promise((resolve) => setTimeout(resolve, 10)); + const second = await request(app).patch(`/tasks/${task.id}/complete`); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(second.body.completedAt).toBe(first.body.completedAt); + }); + + test("returns 404 for non-existent task id", async () => { + const response = await request(app).patch("/tasks/not-found-id/complete"); + + expect(response.status).toBe(404); + expect(response.body.error).toMatch(/not found/i); + }); + }); + + describe("GET /tasks/stats", () => { + test("returns status counts on happy path", async () => { + createSeedTask({ status: "todo" }); + createSeedTask({ status: "in_progress" }); + createSeedTask({ status: "done" }); + + const response = await request(app).get("/tasks/stats"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + todo: 1, + in_progress: 1, + done: 1, + overdue: 0, + }); + }); + + test("includes overdue count when overdue tasks are present", async () => { + const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + createSeedTask({ status: "todo", dueDate: pastDate }); + createSeedTask({ status: "done", dueDate: pastDate }); + + const response = await request(app).get("/tasks/stats"); + + expect(response.status).toBe(200); + expect(response.body.overdue).toBe(1); + }); + }); + + describe("PATCH /tasks/:id/assign", () => { + // TDD: endpoint does not exist yet; these tests define required behavior first. + test("assigns a task to a user on happy path", async () => { + const task = createSeedTask({ title: "Assignable task" }); + + const response = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: "Alice" }); + + expect(response.status).toBe(200); + expect(response.body.assignee).toBe("Alice"); + expect(response.body.id).toBe(task.id); + }); + + // TDD: validation behavior expected once endpoint is implemented. + test("returns 400 when assignee is an empty string", async () => { + const task = createSeedTask({ title: "Validation task" }); + + const response = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: "" }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty("error"); + }); + + test("returns 404 when assigning a non-existent task", async () => { + const response = await request(app) + .patch("/tasks/missing-id/assign") + .send({ assignee: "Bob" }); + + expect(response.status).toBe(404); + expect(response.body).toHaveProperty("error"); + }); + + test("reassigns task when it is already assigned", async () => { + const task = createSeedTask({ title: "Already assigned task" }); + taskService.update(task.id, { assignee: "Eve" }); + + const response = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: "Mallory" }); + + expect(response.status).toBe(200); + expect(response.body.assignee).toBe("Mallory"); + expect(response.body.id).toBe(task.id); + }); + }); +});