diff --git a/BUG.FIX.md b/BUG.FIX.md new file mode 100644 index 00000000..823ebe37 --- /dev/null +++ b/BUG.FIX.md @@ -0,0 +1,25 @@ +## 🐞 Bug 01: Status Validation Logic + +**Status:** ✅ Fixed + +### 🔧 Resolution +Updated the validation logic in `src/utils/validators.js` to handle user input inconsistencies. +Instead of validating raw input, status values are now normalized by trimming whitespace and converting to lowercase before validation. + +### 💻 Code Change +```javascript +// Before +const isValid = ['todo', 'in_progress', 'done'].includes(status); + +// After +const normalizedStatus = status?.trim().toLowerCase(); +const isValid = ['todo', 'in_progress', 'done'].includes(normalizedStatus); + +### Verification (Proof of Fix) + +Executed the Jest test suite for this bug. All test cases passed successfully, confirming proper handling of case variations and whitespace. +Bug 01 — Status accepts case variants and whitespace + √ should accept "TODO" (uppercase) + √ should accept " todo " (with spaces) + √ should accept "In_Progress" (mixed case) + √ should still reject completely invalid status diff --git a/BUGS.md b/BUGS.md new file mode 100644 index 00000000..403b2952 --- /dev/null +++ b/BUGS.md @@ -0,0 +1,266 @@ +# Bug Report — Task API + +--- + +## Bug 01: Status Validation Logic + +- **Location:** `src/utils/validators.js:8` + +- **The Issue:** The validator uses strict string matching for task status. + +- **Expected:** Should accept "TODO", "todo", or " todo " (normalized input). + +- **Actual:** Rejects any input that isn't exactly lowercase and trimmed, returning a 400 Bad Request. + +- **Fix:** Apply `.trim().toLowerCase()` to the input before validation. + +--- + +## Bug 02: Title and Description Input Normalisation + +- **Location:** `src/utils/validators.js:5` + +- **The Issue:** The validator does not normalise `title` and `description` fields before storing them. Raw input is saved as-is, including leading/trailing whitespace. + +- **Expected:** `"Write unit tests for auth module "` should be stored as `"Write unit tests for auth module"` and `" hel lo "` should be stored as `"hel lo"`. + +- **Actual:** Both fields are stored with the original whitespace intact, causing inconsistent data in the in-memory database. + +- **Fix:** Apply `.trim()` to `title` and `description` before validation and storage. For collapsing internal spaces use `.trim().replace(/\s+/g, ' ')`. + +--- + +## Bug 03: Priority Validation Logic + +- **Location:** `src/utils/validators.js:11` + +- **The Issue:** The validator uses strict string matching for task priority. + +- **Expected:** Should accept "LOW", "lOw", or " low " (normalized input). + +- **Actual:** Rejects any input that isn't exactly lowercase and trimmed, returning a 400 Bad Request. + +- **Fix:** Apply `.trim().toLowerCase()` to the input before validation. + +--- + +## Bug 04: String "null" Accepted as Valid Title + +- **Location:** `src/utils/validators.js:5` + +- **The Issue:** The validator only checks if `title` is falsy, meaning it catches actual `null` but does not guard against meaningless string values like `"null"`, `"undefined"`, or whitespace-only strings like `" "`. + +- **Expected:** All of the following inputs should be rejected with a 400 Bad Request: + - `{ "title": "null" }` + - `{ "title": "undefined" }` + - `{ "title": " " }` + - `{ "title": "" }` + +- **Actual:** `"null"` and `"undefined"` pass validation and get stored in the database as legitimate task titles since they are technically non-empty strings. + +- **Fix:** Explicitly check for these invalid string values after normalising the input: +```javascript +const invalidTitles = ['null', 'undefined', 'none', '']; +if (!title || invalidTitles.includes(title.trim().toLowerCase())) { + return 'Title must be a valid non-empty string'; +} +``` + +--- + +## Bug 05: No Validation on dueDate Field + +- **Location:** `src/utils/validators.js` + +- **The Issue:** The `dueDate` field accepts any value without validating format, past dates, or unrealistically far future dates. + +- **Expected:** Should reject any `dueDate` that is not in ISO 8601 format, is in the past, or is unrealistically far in the future, returning a 400 Bad Request. + +- **Actual:** All of the following were accepted and stored as-is with no error thrown: + - `"2026-03-02T12:00:00.000Z"` — past date + - `"2026-03-02"` — past date + wrong format + - `"10/11/2022"` — past date + completely wrong format + - `"2091-9-10"` — unrealistically far future + wrong format + +- **Fix:** Apply format checking, past date rejection, and future date capping before storage: +```javascript +const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/; +if (!iso8601Regex.test(dueDate)) { + return 'dueDate must be in ISO 8601 format e.g. 2026-04-10T12:00:00.000Z'; +} +if (new Date(dueDate) < new Date()) { + return 'dueDate cannot be in the past'; +} +if (new Date(dueDate) > new Date('2060-01-01T00:00:00.000Z')) { + return 'dueDate is unrealistically far in the future'; +} +``` + +--- + +## Bug 06: Duplicate Task Entries Not Rejected + +- **Location:** `src/utils/validators.js:31` + +- **The Issue:** The validator does not check for duplicate tasks before creating a new entry. A task with the exact same `title`, `description`, `status`, `priority`, and `dueDate` can be created multiple times without any error. + +- **Expected:** If a task with the same `title` and `dueDate` already exists in the database, the API should reject the request with a 409 Conflict error. + +- **Actual:** The following identical entries were both accepted and stored with different IDs, created at `2026-04-03T07:44:07.611Z` and `2026-04-03T07:46:49.644Z` with different UUIDs but otherwise identical data: +```json +{ + "title": "hey there", + "description": "hello", + "status": "todo", + "priority": "low", + "dueDate": "2091-9-10" +} +``` + +- **Fix:** Before creating a new task, check if a task with the same `title` and `dueDate` already exists: +```javascript +const isDuplicate = tasks.some( + (t) => t.title.trim().toLowerCase() === title.trim().toLowerCase() + && t.dueDate === dueDate +); +if (isDuplicate) { + return 'A task with the same title and due date already exists'; +} +``` + +--- + +## Bug 07: Incorrect Pagination Offset Calculation + +- **Location:** `src/services/taskService.js` + +- **The Issue:** The pagination offset is calculated as `page * limit` instead of `(page - 1) * limit`, causing page 1 to skip the first set of results entirely and return wrong data for every page. + +- **Expected:** Sending `?page=1&limit=5` should return the first 5 tasks (index 0 to 4). + +- **Actual:** Sending `?page=1&limit=5` skips the first 5 tasks and returns index 5 to 9 instead, meaning page 1 behaves like page 2, page 2 behaves like page 3, and so on. The actual first page of results is never accessible. + +- **Example:** +```javascript +// With 10 tasks in DB, page=1, limit=5 +const offset = 1 * 5; // ❌ offset = 5, skips first 5 tasks +const offset = (1 - 1) * 5; // ✅ offset = 0, starts from beginning +``` + +- **Fix:** Subtract 1 from the page number before multiplying: +```javascript +const getPaginated = (page, limit) => { + const offset = (page - 1) * limit; + return tasks.slice(offset, offset + limit); +}; +``` + +--- + +## Bug 08: `getByStatus` Uses Partial Match Instead of Exact Match + +- **Location:** `src/services/taskService.js` + +- **The Issue:** `.includes()` does partial string matching on status instead of exact matching. + +- **Expected:** `?status=do` should return no results since `"do"` is not a valid status. + +- **Actual:** `?status=do` returns both `todo` and `done` tasks since both strings contain the substring `"do"`. + +- **Fix:** +```javascript +tasks.filter((t) => t.status === status.trim().toLowerCase()); +``` + +--- + +## Bug 09: `completeTask` Forcefully Resets Priority to `medium` + +- **Location:** `src/services/taskService.js` + +- **The Issue:** Marking a task complete silently overwrites its existing priority with `"medium"` regardless of what it was before. + +- **Expected:** A task with `priority: "high"` that is marked as complete should remain `priority: "high"`. + +- **Actual:** Priority is hardcoded to `"medium"` on every task completion, losing the original priority value permanently. + +- **Fix:** Remove the hardcoded priority override: +```javascript +const updated = { + ...task, + // priority: 'medium' ← remove this line + status: 'done', + completedAt: new Date().toISOString(), +}; +``` + +--- + +## Bug 10: `PUT /:id` Allows Overwriting Protected Fields + +- **Location:** `src/services/taskService.js` + +- **The Issue:** The `update()` function spreads all incoming fields with no restrictions, allowing clients to overwrite fields that should never change. + +- **Expected:** `id`, `createdAt`, and `completedAt` should never be overwritable by the client. + +- **Actual:** Sending the following body via `PUT` gets accepted and stored without any error: +```json +{ "id": "fake-id", "createdAt": "2000-01-01" } +``` + +- **Fix:** Strip protected fields before merging: +```javascript +const update = (id, fields) => { + const { id: _, createdAt, completedAt, ...safeFields } = fields; + const updated = { ...tasks[index], ...safeFields }; + tasks[index] = updated; + return updated; +}; +``` + +--- + +## Bug 11: No Validation on `page` and `limit` Query Parameters + +- **Location:** `src/routes/tasks.js` + +- **The Issue:** Page and limit query parameters are never validated before being used for pagination. + +- **Expected:** All of the following should be rejected with a 400 Bad Request: + - `?page=-1&limit=5` — negative page number + - `?page=0&limit=5` — zero is not a valid page + - `?page=1&limit=99999` — absurdly large limit + - `?page=abc&limit=5` — non-numeric, silently falls back to 1 + +- **Actual:** All of the above are accepted silently with no error thrown, causing unexpected results. + +- **Fix:** +```javascript +if (pageNum < 1 || limitNum < 1 || limitNum > 100) { + return res.status(400).json({ error: 'Invalid page or limit value' }); +} +``` + +--- + +## Bug 12: `PUT /:id` Can Set `status: done` Without Setting `completedAt` + +- **Location:** `src/routes/tasks.js` + +- **The Issue:** The `PUT` endpoint bypasses the `completeTask()` logic entirely, allowing a task to be marked as `done` without a `completedAt` timestamp being set. + +- **Expected:** Any task with `status: "done"` should always have a `completedAt` timestamp, consistent with the behaviour of `PATCH /:id/complete`. + +- **Actual:** Sending `{ "status": "done" }` via `PUT` leaves `completedAt` as `null`, creating an inconsistency in the data. + +- **Fix:** Intercept the status change inside `update()` and set `completedAt` automatically: +```javascript +if (fields.status === 'done' && !tasks[index].completedAt) { + fields.completedAt = new Date().toISOString(); +} +``` + +--- + +*Total Bugs Found: 12* \ No newline at end of file diff --git a/task-api/SUBMISSION.md b/task-api/SUBMISSION.md new file mode 100644 index 00000000..3111ee4f --- /dev/null +++ b/task-api/SUBMISSION.md @@ -0,0 +1,44 @@ + +## What I'd Test Next + +If I had more time I'd focus on: +- **Load testing** — the in-memory array has no size limit, so I'd test + how the API behaves under a large number of tasks (1000+) and whether + pagination holds up under stress +- **Concurrent requests** — since JavaScript is single-threaded but async, + I'd test whether two simultaneous POST requests with the same title and + dueDate could both slip past the duplicate check +- **Edge cases on the assign endpoint** — specifically what happens when + you try to assign a task that has already been marked as done + +--- + +## What Surprised Me in the Codebase + +A few things stood out: +- The `completeTask()` function silently resets priority to `"medium"` + regardless of the original value — this looked intentional at first + but is almost certainly a bug +- The `getPaginated()` offset used `page * limit` instead of + `(page - 1) * limit`, meaning page 1 always skipped the first set + of results and the true first page was never accessible +- The `getByStatus()` filter used `.includes()` instead of strict + equality, so querying `?status=do` would return both `todo` and + `done` tasks + +--- + +## Questions I'd Ask Before Shipping to Production + +- **Persistence** — the data lives in memory and resets on every server + restart. Is a database like PostgreSQL or MongoDB planned, or is this + intentionally ephemeral? +- **Authentication** — there's no auth layer. Should endpoints be + protected? Who is allowed to assign, complete, or delete tasks? +- **Assignee as a string vs a user reference** — right now assignee is + a free-text name. Should it reference an actual user ID from an auth + system instead? +- **Concurrent write safety** — if two requests hit the server at the + same time, can the duplicate check be bypassed due to a race condition? +- **Rate limiting** — should the API have rate limiting before going live + to prevent abuse? \ No newline at end of file diff --git a/task-api/src/routes/tasks.js b/task-api/src/routes/tasks.js index e8c370fe..82bdb261 100644 --- a/task-api/src/routes/tasks.js +++ b/task-api/src/routes/tasks.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const taskService = require('../services/taskService'); -const { validateCreateTask, validateUpdateTask } = require('../utils/validators'); +const { validateCreateTask, validateUpdateTask, validateAssignTask } = require('../utils/validators'); router.get('/stats', (req, res) => { const stats = taskService.getStats(); @@ -69,4 +69,30 @@ router.patch('/:id/complete', (req, res) => { res.json(task); }); -module.exports = router; +// New — PATCH /tasks/:id/assign +router.patch('/:id/assign', (req, res) => { + // Validate the assignee field + const error = validateAssignTask(req.body); + if (error) { + return res.status(400).json({ error }); + } + + const result = taskService.assignTask(req.params.id, req.body.assignee); + + // Task not found + if (!result) { + return res.status(404).json({ error: 'Task not found' }); + } + + // Task already assigned to someone else — return 409 Conflict + if (result.alreadyAssigned) { + return res.status(409).json({ + error: `Task is already assigned to ${result.task.assignee}`, + assignee: result.task.assignee, + }); + } + + res.json(result.task); +}); + +module.exports = router; \ No newline at end of file diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index f8e89189..f0759d0e 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -36,6 +36,7 @@ const create = ({ title, description = '', status = 'todo', priority = 'medium', status, priority, dueDate, + assignee: null, // new field — null by default completedAt: null, createdAt: new Date().toISOString(), }; @@ -76,6 +77,26 @@ const completeTask = (id) => { return updated; }; +// New — assign a task to a person +const assignTask = (id, assignee) => { + const task = findById(id); + if (!task) return null; + + // Return a specific signal if task is already assigned to someone else + if (task.assignee && task.assignee !== assignee.trim()) { + return { alreadyAssigned: true, task }; + } + + const updated = { + ...task, + assignee: assignee.trim(), + }; + + const index = tasks.findIndex((t) => t.id === id); + tasks[index] = updated; + return { alreadyAssigned: false, task: updated }; +}; + const _reset = () => { tasks = []; }; @@ -90,5 +111,6 @@ module.exports = { update, remove, completeTask, + assignTask, _reset, -}; +}; \ No newline at end of file diff --git a/task-api/src/utils/validators.js b/task-api/src/utils/validators.js index 1e908ff5..15aedb7a 100644 --- a/task-api/src/utils/validators.js +++ b/task-api/src/utils/validators.js @@ -33,4 +33,34 @@ const validateUpdateTask = (body) => { return null; }; -module.exports = { validateCreateTask, validateUpdateTask }; +const validateAssignTask = (body) => { + // assignee field must be present + if (body.assignee === undefined) { + return 'assignee is required'; + } + + // must be a string type + if (typeof body.assignee !== 'string') { + return 'assignee must be a string'; + } + + // must not be empty or whitespace only + if (body.assignee.trim() === '') { + return 'assignee must be a non-empty string'; + } + + // must not be a meaningless placeholder value + const invalidValues = ['null', 'undefined', 'none']; + if (invalidValues.includes(body.assignee.trim().toLowerCase())) { + return 'assignee must be a valid name'; + } + + // reasonable length cap + if (body.assignee.trim().length > 100) { + return 'assignee name must be under 100 characters'; + } + + return null; +}; + +module.exports = { validateCreateTask, validateUpdateTask, validateAssignTask }; \ No newline at end of file diff --git a/task-api/tests/Assign.test.js b/task-api/tests/Assign.test.js new file mode 100644 index 00000000..1b8f9201 --- /dev/null +++ b/task-api/tests/Assign.test.js @@ -0,0 +1,226 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +// Reset in-memory DB before each test +beforeEach(() => { + taskService._reset(); +}); + +// Helper — creates a basic task and returns its id +const createTask = async (overrides = {}) => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + ...overrides, + }); + return res.body.id; +}; + +// ───────────────────────────────────────────── +// HAPPY PATH +// ───────────────────────────────────────────── +describe('PATCH /tasks/:id/assign — happy path', () => { + test('should assign a task to a valid assignee and return 200', async () => { + const id = await createTask(); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'Alice' }); + + expect(res.statusCode).toBe(200); + expect(res.body.assignee).toBe('Alice'); + }); + + test('returned task should contain all original fields plus assignee', async () => { + const id = await createTask({ title: 'Fix bug', priority: 'high' }); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'Bob' }); + + expect(res.statusCode).toBe(200); + expect(res.body.id).toBe(id); + expect(res.body.title).toBe('Fix bug'); + expect(res.body.priority).toBe('high'); + expect(res.body.assignee).toBe('Bob'); + }); + + test('should trim whitespace from assignee name before storing', async () => { + const id = await createTask(); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: ' Alice ' }); + + expect(res.statusCode).toBe(200); + expect(res.body.assignee).toBe('Alice'); + }); + + test('should allow reassigning a task to the same assignee', async () => { + const id = await createTask(); + + await request(app).patch(`/tasks/${id}/assign`).send({ assignee: 'Alice' }); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'Alice' }); + + expect(res.statusCode).toBe(200); + expect(res.body.assignee).toBe('Alice'); + }); + + test('new task should have assignee as null by default', async () => { + const id = await createTask(); + const res = await request(app).get('/tasks'); + + const task = res.body.find((t) => t.id === id); + expect(task.assignee).toBeNull(); + }); +}); + +// ───────────────────────────────────────────── +// 404 — TASK NOT FOUND +// ───────────────────────────────────────────── +describe('PATCH /tasks/:id/assign — 404 cases', () => { + test('should return 404 for non-existent task id', async () => { + const res = await request(app) + .patch('/tasks/non-existent-id/assign') + .send({ assignee: 'Alice' }); + + expect(res.statusCode).toBe(404); + expect(res.body.error).toBe('Task not found'); + }); + + test('should return 404 for valid UUID that does not exist', async () => { + const res = await request(app) + .patch('/tasks/00000000-0000-0000-0000-000000000000/assign') + .send({ assignee: 'Alice' }); + + expect(res.statusCode).toBe(404); + expect(res.body.error).toBe('Task not found'); + }); +}); + +// ───────────────────────────────────────────── +// 409 — ALREADY ASSIGNED +// ───────────────────────────────────────────── +describe('PATCH /tasks/:id/assign — 409 already assigned', () => { + test('should return 409 if task is already assigned to someone else', async () => { + const id = await createTask(); + + await request(app).patch(`/tasks/${id}/assign`).send({ assignee: 'Alice' }); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'Bob' }); + + expect(res.statusCode).toBe(409); + expect(res.body.error).toContain('Alice'); + }); + + test('409 response should include the current assignee name', async () => { + const id = await createTask(); + + await request(app).patch(`/tasks/${id}/assign`).send({ assignee: 'Charlie' }); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'Dave' }); + + expect(res.statusCode).toBe(409); + expect(res.body.assignee).toBe('Charlie'); + }); +}); + +// ───────────────────────────────────────────── +// 400 — VALIDATION ERRORS +// ───────────────────────────────────────────── +describe('PATCH /tasks/:id/assign — 400 validation errors', () => { + test('should return 400 if assignee field is missing', async () => { + const id = await createTask(); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({}); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('assignee is required'); + }); + + test('should return 400 if assignee is an empty string', async () => { + const id = await createTask(); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: '' }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('assignee must be a non-empty string'); + }); + + test('should return 400 if assignee is whitespace only', async () => { + const id = await createTask(); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: ' ' }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('assignee must be a non-empty string'); + }); + + test('should return 400 if assignee is the string "null"', async () => { + const id = await createTask(); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'null' }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('assignee must be a valid name'); + }); + + test('should return 400 if assignee is the string "undefined"', async () => { + const id = await createTask(); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'undefined' }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('assignee must be a valid name'); + }); + + test('should return 400 if assignee is a number', async () => { + const id = await createTask(); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 12345 }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('assignee must be a string'); + }); + + test('should return 400 if assignee exceeds 100 characters', async () => { + const id = await createTask(); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'A'.repeat(101) }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toBe('assignee name must be under 100 characters'); + }); + + test('should accept assignee of exactly 100 characters', async () => { + const id = await createTask(); + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'A'.repeat(100) }); + + expect(res.statusCode).toBe(200); + expect(res.body.assignee).toBe('A'.repeat(100)); + }); +}); \ No newline at end of file diff --git a/task-api/tests/tasks.test.js b/task-api/tests/tasks.test.js new file mode 100644 index 00000000..a4bb755f --- /dev/null +++ b/task-api/tests/tasks.test.js @@ -0,0 +1,699 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +// Reset in-memory DB before each test +beforeEach(() => { + taskService._reset(); +}); + +// ───────────────────────────────────────────── +// BUG 01: Status Validation — case + whitespace +// ───────────────────────────────────────────── +describe('Bug 01 — Status accepts case variants and whitespace', () => { + test('should accept "TODO" (uppercase)', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + status: 'TODO', + }); + expect(res.statusCode).not.toBe(400); + }); + + test('should accept " todo " (with spaces)', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + status: ' todo ', + }); + expect(res.statusCode).not.toBe(400); + }); + + test('should accept "In_Progress" (mixed case)', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + status: 'In_Progress', + }); + expect(res.statusCode).not.toBe(400); + }); + + test('should still reject completely invalid status', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + status: 'invalid_status', + }); + expect(res.statusCode).toBe(400); + }); +}); + +// ───────────────────────────────────────────── +// BUG 02: Title & Description Whitespace Normalisation +// ───────────────────────────────────────────── +describe('Bug 02 — Title and description are trimmed before storing', () => { + test('should trim trailing spaces from title', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Write unit tests for auth module ', + }); + expect(res.statusCode).toBe(201); + expect(res.body.title).toBe('Write unit tests for auth module'); + }); + + test('should trim leading and trailing spaces from description', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + description: ' hel lo ', + }); + expect(res.statusCode).toBe(201); + expect(res.body.description).toBe('hel lo'); + }); +}); + +// ───────────────────────────────────────────── +// BUG 03: Priority Validation — case + whitespace +// ───────────────────────────────────────────── +describe('Bug 03 — Priority accepts case variants and whitespace', () => { + test('should accept "HIGH" (uppercase)', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + priority: 'HIGH', + }); + expect(res.statusCode).not.toBe(400); + }); + + test('should accept " low " (with spaces)', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + priority: ' low ', + }); + expect(res.statusCode).not.toBe(400); + }); + + test('should accept "Medium" (mixed case)', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + priority: 'Medium', + }); + expect(res.statusCode).not.toBe(400); + }); + + test('should still reject completely invalid priority', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + priority: 'urgent', + }); + expect(res.statusCode).toBe(400); + }); +}); + +// ───────────────────────────────────────────── +// BUG 04: String "null" Accepted as Valid Title +// ───────────────────────────────────────────── +describe('Bug 04 — String "null" and "undefined" rejected as title', () => { + test('should reject title "null" (string)', async () => { + const res = await request(app).post('/tasks').send({ + title: 'null', + }); + expect(res.statusCode).toBe(400); + }); + + test('should reject title "undefined" (string)', async () => { + const res = await request(app).post('/tasks').send({ + title: 'undefined', + }); + expect(res.statusCode).toBe(400); + }); + + test('should reject title of only whitespace', async () => { + const res = await request(app).post('/tasks').send({ + title: ' ', + }); + expect(res.statusCode).toBe(400); + }); + + test('should reject empty string title', async () => { + const res = await request(app).post('/tasks').send({ + title: '', + }); + expect(res.statusCode).toBe(400); + }); +}); + +// ───────────────────────────────────────────── +// BUG 05: No Validation on dueDate Field +// ───────────────────────────────────────────── +describe('Bug 05 — dueDate validation: format, past, and far future', () => { + test('should reject past date in ISO 8601 format', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + dueDate: '2020-01-01T00:00:00.000Z', + }); + expect(res.statusCode).toBe(400); + }); + + test('should reject date-only format (not ISO 8601)', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + dueDate: '2026-03-02', + }); + expect(res.statusCode).toBe(400); + }); + + test('should reject MM/DD/YYYY format', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + dueDate: '10/11/2022', + }); + expect(res.statusCode).toBe(400); + }); + + test('should reject unrealistically far future date', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + dueDate: '2091-09-10T00:00:00.000Z', + }); + expect(res.statusCode).toBe(400); + }); + + test('should accept valid future ISO 8601 date', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + dueDate: '2026-12-01T10:00:00.000Z', + }); + expect(res.statusCode).toBe(201); + }); + + test('should accept null dueDate (optional field)', async () => { + const res = await request(app).post('/tasks').send({ + title: 'Test task', + dueDate: null, + }); + expect(res.statusCode).toBe(201); + }); +}); + +// ───────────────────────────────────────────── +// BUG 06: Duplicate Task Entries Not Rejected +// ───────────────────────────────────────────── +describe('Bug 06 — Duplicate tasks with same title and dueDate are rejected', () => { + test('should reject a task that is an exact duplicate', async () => { + const payload = { + title: 'hey there', + description: 'hello', + status: 'todo', + priority: 'low', + dueDate: '2026-12-01T10:00:00.000Z', + }; + + const first = await request(app).post('/tasks').send(payload); + expect(first.statusCode).toBe(201); + + const second = await request(app).post('/tasks').send(payload); + expect(second.statusCode).toBe(409); + }); + + test('should allow same title with different dueDate', async () => { + const first = await request(app).post('/tasks').send({ + title: 'hey there', + dueDate: '2026-12-01T10:00:00.000Z', + }); + expect(first.statusCode).toBe(201); + + const second = await request(app).post('/tasks').send({ + title: 'hey there', + dueDate: '2027-01-01T10:00:00.000Z', + }); + expect(second.statusCode).toBe(201); + }); +}); + +// ───────────────────────────────────────────── +// BUG 07: Incorrect Pagination Offset Calculation +// ───────────────────────────────────────────── +describe('Bug 07 — Pagination page=1 returns first set of results', () => { + beforeEach(async () => { + // Seed 10 tasks + for (let i = 1; i <= 10; i++) { + await request(app).post('/tasks').send({ title: `Task ${i}` }); + } + }); + + test('page=1 should return the first task (Task 1)', async () => { + const res = await request(app).get('/tasks?page=1&limit=5'); + expect(res.statusCode).toBe(200); + expect(res.body.length).toBe(5); + expect(res.body[0].title).toBe('Task 1'); + }); + + test('page=2 should return tasks 6 to 10', async () => { + const res = await request(app).get('/tasks?page=2&limit=5'); + expect(res.statusCode).toBe(200); + expect(res.body[0].title).toBe('Task 6'); + }); +}); + +// ───────────────────────────────────────────── +// BUG 08: getByStatus Uses Partial Match +// ───────────────────────────────────────────── +describe('Bug 08 — Status filter uses exact match not partial match', () => { + beforeEach(async () => { + await request(app).post('/tasks').send({ title: 'Todo task', status: 'todo' }); + await request(app).post('/tasks').send({ title: 'Done task', status: 'done' }); + }); + + test('?status=do should NOT return todo or done tasks', async () => { + const res = await request(app).get('/tasks?status=do'); + expect(res.statusCode).toBe(200); + expect(res.body.length).toBe(0); + }); + + test('?status=todo should return only todo tasks', async () => { + const res = await request(app).get('/tasks?status=todo'); + expect(res.statusCode).toBe(200); + res.body.forEach((task) => expect(task.status).toBe('todo')); + }); + + test('?status=done should return only done tasks', async () => { + const res = await request(app).get('/tasks?status=done'); + expect(res.statusCode).toBe(200); + res.body.forEach((task) => expect(task.status).toBe('done')); + }); +}); + +// ───────────────────────────────────────────── +// BUG 09: completeTask Resets Priority to medium +// ───────────────────────────────────────────── +describe('Bug 09 — completeTask preserves original priority', () => { + test('completing a high priority task should keep priority as high', async () => { + const create = await request(app).post('/tasks').send({ + title: 'High priority task', + priority: 'high', + }); + expect(create.statusCode).toBe(201); + const id = create.body.id; + + const complete = await request(app).patch(`/tasks/${id}/complete`); + expect(complete.statusCode).toBe(200); + expect(complete.body.priority).toBe('high'); + }); + + test('completing a low priority task should keep priority as low', async () => { + const create = await request(app).post('/tasks').send({ + title: 'Low priority task', + priority: 'low', + }); + const id = create.body.id; + + const complete = await request(app).patch(`/tasks/${id}/complete`); + expect(complete.body.priority).toBe('low'); + }); +}); + +// ───────────────────────────────────────────── +// BUG 10: PUT Allows Overwriting Protected Fields +// ───────────────────────────────────────────── +describe('Bug 10 — PUT cannot overwrite id or createdAt', () => { + test('should not allow overwriting id via PUT', async () => { + const create = await request(app).post('/tasks').send({ title: 'Original task' }); + const id = create.body.id; + const originalCreatedAt = create.body.createdAt; + + const update = await request(app).put(`/tasks/${id}`).send({ + title: 'Updated task', + id: 'fake-id-12345', + }); + + expect(update.body.id).toBe(id); + expect(update.body.id).not.toBe('fake-id-12345'); + }); + + test('should not allow overwriting createdAt via PUT', async () => { + const create = await request(app).post('/tasks').send({ title: 'Original task' }); + const id = create.body.id; + const originalCreatedAt = create.body.createdAt; + + const update = await request(app).put(`/tasks/${id}`).send({ + title: 'Updated task', + createdAt: '2000-01-01T00:00:00.000Z', + }); + + expect(update.body.createdAt).toBe(originalCreatedAt); + }); +}); + +// ───────────────────────────────────────────── +// BUG 11: No Validation on page and limit Params +// ───────────────────────────────────────────── +describe('Bug 11 — page and limit query params are validated', () => { + test('should reject negative page number', async () => { + const res = await request(app).get('/tasks?page=-1&limit=5'); + expect(res.statusCode).toBe(400); + }); + + test('should reject page=0', async () => { + const res = await request(app).get('/tasks?page=0&limit=5'); + expect(res.statusCode).toBe(400); + }); + + test('should reject absurdly large limit', async () => { + const res = await request(app).get('/tasks?page=1&limit=99999'); + expect(res.statusCode).toBe(400); + }); + + test('should reject non-numeric page', async () => { + const res = await request(app).get('/tasks?page=abc&limit=5'); + expect(res.statusCode).toBe(400); + }); +}); + +// ───────────────────────────────────────────── +// BUG 12: PUT Sets status=done Without completedAt +// ───────────────────────────────────────────── +describe('Bug 12 — PUT with status=done sets completedAt automatically', () => { + test('should set completedAt when status is set to done via PUT', async () => { + const create = await request(app).post('/tasks').send({ title: 'Test task' }); + const id = create.body.id; + + const update = await request(app).put(`/tasks/${id}`).send({ + status: 'done', + }); + + expect(update.statusCode).toBe(200); + expect(update.body.status).toBe('done'); + expect(update.body.completedAt).not.toBeNull(); + expect(update.body.completedAt).toBeDefined(); + }); + + test('completedAt should be a valid ISO date string', async () => { + const create = await request(app).post('/tasks').send({ title: 'Test task' }); + const id = create.body.id; + + const update = await request(app).put(`/tasks/${id}`).send({ status: 'done' }); + + const parsed = new Date(update.body.completedAt); + expect(isNaN(parsed.getTime())).toBe(false); + }); +}); + +// ───────────────────────────────────────────── +// COVERAGE: GET /stats +// ───────────────────────────────────────────── +describe('Coverage — GET /tasks/stats', () => { + test('should return counts of todo, in_progress, done and overdue', async () => { + await request(app).post('/tasks').send({ title: 'Task 1', status: 'todo' }); + await request(app).post('/tasks').send({ title: 'Task 2', status: 'in_progress' }); + await request(app).post('/tasks').send({ title: 'Task 3', status: 'done' }); + + 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(1); + expect(res.body.in_progress).toBe(1); + expect(res.body.done).toBe(1); + }); + + test('should count overdue tasks correctly', async () => { + // Manually inject an overdue task by bypassing validator + taskService.create({ + title: 'Overdue task', + status: 'todo', + dueDate: '2020-01-01T00:00:00.000Z', + }); + + const res = await request(app).get('/tasks/stats'); + expect(res.body.overdue).toBe(1); + }); +}); + +// ───────────────────────────────────────────── +// COVERAGE: DELETE /tasks/:id +// ───────────────────────────────────────────── +describe('Coverage — DELETE /tasks/:id', () => { + test('should delete an existing task and return 204', async () => { + const create = await request(app).post('/tasks').send({ title: 'Task to delete' }); + const id = create.body.id; + + const res = await request(app).delete(`/tasks/${id}`); + expect(res.statusCode).toBe(204); + }); + + test('should return 404 when deleting a non-existent task', async () => { + const res = await request(app).delete('/tasks/non-existent-id'); + expect(res.statusCode).toBe(404); + expect(res.body.error).toBe('Task not found'); + }); +}); + +// ───────────────────────────────────────────── +// COVERAGE: PATCH /tasks/:id/complete — 404 case +// ───────────────────────────────────────────── +describe('Coverage — PATCH /tasks/:id/complete 404', () => { + test('should return 404 when completing a non-existent task', async () => { + const res = await request(app).patch('/tasks/non-existent-id/complete'); + expect(res.statusCode).toBe(404); + expect(res.body.error).toBe('Task not found'); + }); +}); + +// ───────────────────────────────────────────── +// COVERAGE: Invalid limit values (Bug 11 branches) +// ───────────────────────────────────────────── +describe('Coverage — invalid limit values', () => { + test('should reject limit=0', async () => { + const res = await request(app).get('/tasks?page=1&limit=0'); + expect(res.statusCode).toBe(400); + }); + + test('should reject limit=-5', async () => { + const res = await request(app).get('/tasks?page=1&limit=-5'); + expect(res.statusCode).toBe(400); + }); + + test('should reject non-numeric limit', async () => { + const res = await request(app).get('/tasks?page=1&limit=abc'); + expect(res.statusCode).toBe(400); + }); +}); + +// ───────────────────────────────────────────── +// COVERAGE: Pagination empty page +// ───────────────────────────────────────────── +describe('Coverage — pagination edge cases', () => { + test('should return empty array for page beyond available results', async () => { + await request(app).post('/tasks').send({ title: 'Only task' }); + + const res = await request(app).get('/tasks?page=99&limit=10'); + expect(res.statusCode).toBe(200); + expect(res.body.length).toBe(0); + }); +}); + +// ───────────────────────────────────────────── +// COVERAGE: validateUpdateTask dueDate branches +// ───────────────────────────────────────────── +describe('Coverage — PUT dueDate validation', () => { + test('should reject past dueDate in PUT', async () => { + const create = await request(app).post('/tasks').send({ title: 'Test task' }); + const id = create.body.id; + + const res = await request(app).put(`/tasks/${id}`).send({ + dueDate: '2020-01-01T00:00:00.000Z', + }); + expect(res.statusCode).toBe(400); + }); + + test('should reject far future dueDate in PUT', async () => { + const create = await request(app).post('/tasks').send({ title: 'Test task' }); + const id = create.body.id; + + const res = await request(app).put(`/tasks/${id}`).send({ + dueDate: '2091-01-01T00:00:00.000Z', + }); + expect(res.statusCode).toBe(400); + }); + + test('should reject string "null" as dueDate in PUT', async () => { + const create = await request(app).post('/tasks').send({ title: 'Test task' }); + const id = create.body.id; + + const res = await request(app).put(`/tasks/${id}`).send({ + dueDate: 'null', + }); + expect(res.statusCode).toBe(400); + }); + + test('should return 404 for PUT on non-existent task', async () => { + const res = await request(app).put('/tasks/non-existent-id').send({ + title: 'Updated title', + }); + expect(res.statusCode).toBe(404); + }); +}); + +// ───────────────────────────────────────────── +// COVERAGE: 500 error handler +// ───────────────────────────────────────────── +describe('Coverage — 500 error handler', () => { + test('should return 500 when an unexpected error occurs', async () => { + // Temporarily break getAll to throw + jest.spyOn(taskService, 'getAll').mockImplementationOnce(() => { + throw new Error('Unexpected error'); + }); + + const res = await request(app).get('/tasks'); + expect(res.statusCode).toBe(500); + expect(res.body.error).toBe('Internal server error'); + }); +}); + +// ───────────────────────────────────────────── +// COVERAGE: Invalid limit values +// ───────────────────────────────────────────── +describe('Bug 11 — limit query param validation', () => { + test('should reject limit=0', async () => { + const res = await request(app).get('/tasks?page=1&limit=0'); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + test('should reject limit=-5 (negative limit)', async () => { + const res = await request(app).get('/tasks?page=1&limit=-5'); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + test('should reject limit=abc (non-numeric limit)', async () => { + const res = await request(app).get('/tasks?page=1&limit=abc'); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + test('should reject limit=99999 (absurdly large)', async () => { + const res = await request(app).get('/tasks?page=1&limit=99999'); + expect(res.statusCode).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + test('should accept valid limit=10', async () => { + const res = await request(app).get('/tasks?page=1&limit=10'); + expect(res.statusCode).toBe(200); + }); + + test('should accept limit=1 (minimum valid)', async () => { + const res = await request(app).get('/tasks?page=1&limit=1'); + expect(res.statusCode).toBe(200); + }); + + test('should accept limit=100 (maximum valid)', async () => { + const res = await request(app).get('/tasks?page=1&limit=100'); + expect(res.statusCode).toBe(200); + }); +}); + +// ───────────────────────────────────────────── +// COVERAGE: app.js lines 17-18 +// ───────────────────────────────────────────── +describe('Coverage — app.js startup block', () => { + test('app module exports a valid express app', () => { + expect(app).toBeDefined(); + expect(typeof app).toBe('function'); // express app is a function + expect(typeof app.listen).toBe('function'); + }); +}); + +// ───────────────────────────────────────────── +// COVERAGE: tasks.js line 27 — limit fallback +// ───────────────────────────────────────────── +describe('Coverage — limit provided without page', () => { + test('should use default page=1 when only limit is provided', async () => { + await request(app).post('/tasks').send({ title: 'Task A' }); + await request(app).post('/tasks').send({ title: 'Task B' }); + + const res = await request(app).get('/tasks?limit=1'); + expect(res.statusCode).toBe(200); + expect(res.body.length).toBe(1); + }); + + test('should use default limit=10 when only page is provided', async () => { + const res = await request(app).get('/tasks?page=1'); + expect(res.statusCode).toBe(200); + }); +}); + +// ───────────────────────────────────────────── +// COVERAGE: taskService.js line 22 — findById +// ───────────────────────────────────────────── +describe('Coverage — findById via completeTask', () => { + test('completeTask should return null for non-existent id', () => { + const result = taskService.completeTask('non-existent-id'); + expect(result).toBeNull(); + }); + + test('findById should return undefined for non-existent id', () => { + const result = taskService.findById('non-existent-id'); + expect(result).toBeUndefined(); + }); + + test('findById should return the correct task', async () => { + const create = await request(app).post('/tasks').send({ title: 'Find me' }); + const id = create.body.id; + + const result = taskService.findById(id); + expect(result).toBeDefined(); + expect(result.id).toBe(id); + expect(result.title).toBe('Find me'); + }); +}); + +// ───────────────────────────────────────────── +// COVERAGE: validators.js lines 15, 22, 25, 28 +// — these branches via PUT (validateUpdateTask) +// ───────────────────────────────────────────── +describe('Coverage — validateUpdateTask dueDate all branches', () => { + let taskId; + + beforeEach(async () => { + const create = await request(app).post('/tasks').send({ title: 'Test task' }); + taskId = create.body.id; + }); + + // Line 15 — string "null" check in validateUpdateTask + test('PUT should reject dueDate string "null"', async () => { + const res = await request(app).put(`/tasks/${taskId}`).send({ + dueDate: 'null', + }); + expect(res.statusCode).toBe(400); + }); + + // Line 25 — past date check in validateUpdateTask + test('PUT should reject past dueDate', async () => { + const res = await request(app).put(`/tasks/${taskId}`).send({ + dueDate: '2020-06-15T10:00:00.000Z', + }); + expect(res.statusCode).toBe(400); + }); + + // Line 28 — far future check in validateUpdateTask + test('PUT should reject far future dueDate', async () => { + const res = await request(app).put(`/tasks/${taskId}`).send({ + dueDate: '2091-01-01T00:00:00.000Z', + }); + expect(res.statusCode).toBe(400); + }); + + // Line 22 — wrong format (fails regex, not isNaN) in validateUpdateTask + test('PUT should reject wrongly formatted dueDate', async () => { + const res = await request(app).put(`/tasks/${taskId}`).send({ + dueDate: '01-01-2026', + }); + expect(res.statusCode).toBe(400); + }); + + // Happy path — valid dueDate in PUT + test('PUT should accept valid future dueDate', async () => { + const res = await request(app).put(`/tasks/${taskId}`).send({ + dueDate: '2026-12-01T10:00:00.000Z', + }); + expect(res.statusCode).toBe(200); + }); +}); \ No newline at end of file