diff --git a/README.md b/README.md index 5d46160c..3dbbe9d6 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,4 @@ See [ASSIGNMENT.md](./ASSIGNMENT.md) for full submission requirements. At minimu - **Bug report** — what you found, where in the code, and why it's a bug (not just symptoms) - **At least one fix** — with a note on your approach - **`PATCH /tasks/:id/assign` implementation** — plus a short explanation of any design decisions (validation, edge cases, etc.) + diff --git a/screenshots/tests-passed.png b/screenshots/tests-passed.png new file mode 100644 index 00000000..e69de29b diff --git a/task-api/BUG_REPORT.md b/task-api/BUG_REPORT.md new file mode 100644 index 00000000..6d7aa3e7 --- /dev/null +++ b/task-api/BUG_REPORT.md @@ -0,0 +1,206 @@ +# Bug Report – Task Manager API + +**Author:** Himesh +**Date:** 2026-07-15 + +## Overview + +While writing unit and integration tests for the Task Manager API, I identified several issues related to pagination, task filtering, and task completion. The bugs below were discovered through a combination of code review and automated testing. + +--- + +## Summary + +| Bug | Severity | Status | +| ---------------------------------------- | -------- | ------- | +| Pagination offset calculation | Critical | Fixed | +| Status filtering uses substring matching | Medium | Fixed | +| Task completion resets priority | Medium | Fixed | + +--- + +# Bug #1 – Pagination Returns Incorrect Results + +**Severity:** Critical + +## Expected Behavior + +Pagination should treat the `page` parameter as **1-indexed**. + +Example: + +``` +GET /tasks?page=1&limit=5 +``` + +should return the first five tasks. + +--- + +## Actual Behavior + +The pagination offset was calculated using: + +```javascript +const offset = page * limit; +``` + +As a result: + +| Request | Expected Items | Actual Items | +| --------------- | -------------- | ------------ | +| page=1, limit=5 | 1–5 | 6–10 | +| page=2, limit=5 | 6–10 | 11–15 | + +Every paginated request skipped the first page of results. + +--- + +## How It Was Discovered + +While writing pagination tests, the first task returned for page 1 did not match the expected data. Reviewing the pagination logic revealed an incorrect offset calculation. + +--- + +## Fix + +Changed: + +```javascript +const offset = page * limit; +``` + +to: + +```javascript +const offset = (page - 1) * limit; +``` + +--- + +## Test Coverage + +Verified by: + +* Unit tests for `taskService.getPaginated()` +* Integration tests for `GET /tasks?page=&limit=` + +--- + +# Bug #2 – Status Filtering Used Substring Matching + +**Severity:** Medium + +## Expected Behavior + +Filtering tasks by status should return only tasks whose status exactly matches the requested value. + +Example: + +``` +GET /tasks?status=todo +``` + +should only return tasks with status `"todo"`. + +--- + +## Actual Behavior + +The filtering logic used substring matching. + +Example: + +``` +GET /tasks?status=do +``` + +incorrectly returned tasks whose status was `"done"`. + +--- + +## How It Was Discovered + +A unit test querying `"do"` unexpectedly matched `"done"` tasks. Code review showed that the implementation used `String.prototype.includes()` instead of strict equality. + +--- + +## Fix + +Changed: + +```javascript +task.status.includes(status) +``` + +to: + +```javascript +task.status === status +``` + +--- + +## Test Coverage + +Verified by: + +* Unit tests for `taskService.getByStatus()` +* Integration tests for `GET /tasks?status=` + +--- + +# Bug #3 – Completing a Task Reset Priority + +**Severity:** Medium + +## Expected Behavior + +Completing a task should only update: + +* `status` +* `completedAt` + +All other task properties should remain unchanged. + +--- + +## Actual Behavior + +Completing a task overwrote the existing priority, causing a task marked as `"high"` priority to become `"medium"` after completion. + +This resulted in unintended modification of task data unrelated to completion. + +--- + +## How It Was Discovered + +A unit test created a high-priority task, completed it, and verified that the priority should remain unchanged. The test exposed the issue during implementation. + +--- + +## Fix + +Updated the completion logic to preserve all existing task properties and modify only: + +* `status` +* `completedAt` + +The original priority is now retained after task completion. + +--- + +## Test Coverage + +Verified by: + +* Unit tests for `taskService.completeTask()` +* Integration tests for `PATCH /tasks/:id/complete` + +--- + +# Conclusion + +Automated testing helped uncover issues that were not immediately visible during manual inspection. These tests now serve as regression tests to ensure that the corrected behavior remains intact in future changes. + +The identified issues have been documented, reproduced through automated tests, and fixed as part of this submission. diff --git a/task-api/Readme.md b/task-api/Readme.md new file mode 100644 index 00000000..d7e553a9 --- /dev/null +++ b/task-api/Readme.md @@ -0,0 +1,186 @@ +# Task Manager API – Take-Home Assignment + +## Overview + +This repository contains my solution for the Task Manager API take-home assignment. + +The objective of the assignment was to understand an unfamiliar codebase, write automated tests, identify bugs, fix one bug, and implement a new feature while maintaining good code quality. + +--- + +# Completed Tasks + +## Unit Tests + +Added comprehensive unit tests for `taskService.js`, covering: + +* Task creation +* Task retrieval +* Find by ID +* Status filtering +* Pagination +* Statistics +* Task updates +* Task deletion +* Task completion +* Task assignment +* Reset functionality + +--- + +## Integration Tests + +Added API integration tests using **Jest** and **Supertest** covering: + +* POST `/tasks` +* GET `/tasks` +* GET `/tasks?status=` +* GET `/tasks?page=&limit=` +* PUT `/tasks/:id` +* DELETE `/tasks/:id` +* PATCH `/tasks/:id/complete` +* PATCH `/tasks/:id/assign` +* GET `/tasks/stats` + +Each endpoint includes both happy-path and edge-case scenarios. + +--- + +## Bug Investigation + +During testing, multiple issues were identified and documented in **BUG_REPORT.md**. + +The report includes: + +* Expected behavior +* Actual behavior +* How each issue was discovered +* Suggested fix +* Fix status + +--- + +## Bug Fixed + +Fixed identified issues in the application and updated the corresponding tests to verify the corrected behavior. + +--- + +## New Feature Added + +Implemented the required endpoint: + +```http +PATCH /tasks/:id/assign +``` + +### Request Body + +```json +{ + "assignee": "Alice" +} +``` + +### Behavior + +* Assigns a user to a task +* Allows reassignment +* Returns the updated task +* Returns **404** if the task does not exist +* Validates missing, empty, whitespace-only, and invalid assignee values + +--- + +# Test Coverage + +The project includes: + +* Unit tests +* Integration tests +* Validation tests +* Edge-case tests +* Regression tests for discovered bugs + +Coverage summary: + +```text +Paste your coverage summary here. + +Example: + +Test Suites: 2 passed +Tests: 79 passed + +Statements : 96.81% +Branches : 94.5% +Functions : 93.33% +Lines : 95.6% +``` + +--- + +## Test Results + +### All Tests Passing + +![All Tests Passing](screenshots/tests-passed.png) + +### Test Coverage + +![Coverage Report](screenshots/coverage.png) + + +# Project Structure + +```text +task-api/ +│ +├── src/ +├── tests/ +├── screenshots/ +│ ├── coverage.png +│ └── tests-passed.png +├── BUG_REPORT.md +├── README.md +├── package.json +└── .gitignore +``` + +--- + +# Assumptions + +* Pagination is 1-indexed. +* Tasks can be reassigned. +* Statistics are computed dynamically. +* The application uses an in-memory data store for simplicity. + +--- + +# Additional Documentation + +* **BUG_REPORT.md** – Details of the bugs identified during testing and their resolutions. + +--- + +# Future Improvements + +Given additional time, I would focus on: + +* Authentication and authorization +* Persistent database storage +* Concurrency testing +* Performance and load testing +* Security-focused validation +* API documentation (OpenAPI/Swagger) +* CI/CD integration with automated test execution + +--- + +# Author + +**Himesh** + +Take-Home Assignment Submission – 2026 + diff --git a/task-api/screenshots/coverage.png b/task-api/screenshots/coverage.png new file mode 100644 index 00000000..6ecae195 Binary files /dev/null and b/task-api/screenshots/coverage.png differ diff --git a/task-api/screenshots/tests-passed.png b/task-api/screenshots/tests-passed.png new file mode 100644 index 00000000..73c39781 Binary files /dev/null and b/task-api/screenshots/tests-passed.png differ diff --git a/task-api/src/routes/tasks.js b/task-api/src/routes/tasks.js index e8c370fe..0587ecc0 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,18 @@ router.patch('/:id/complete', (req, res) => { res.json(task); }); +router.patch('/:id/assign', (req, res) => { + const error = validateAssignTask(req.body); + if (error) { + return res.status(400).json({ error }); + } + + const task = taskService.assignTask(req.params.id, req.body.assignee.trim()); + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + res.json(task); +}); + module.exports = router; diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index f8e89189..5c6ad5ff 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -6,10 +6,10 @@ 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; + const offset = (page - 1) * limit; return tasks.slice(offset, offset + limit); }; @@ -36,6 +36,7 @@ const create = ({ title, description = '', status = 'todo', priority = 'medium', status, priority, dueDate, + assignee: null, completedAt: null, createdAt: new Date().toISOString(), }; @@ -66,9 +67,8 @@ const completeTask = (id) => { const updated = { ...task, - priority: 'medium', status: 'done', - completedAt: new Date().toISOString(), + completedAt: task.completedAt || new Date().toISOString(), }; const index = tasks.findIndex((t) => t.id === id); @@ -76,6 +76,16 @@ const completeTask = (id) => { 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 _reset = () => { tasks = []; }; @@ -90,5 +100,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..893ff73d 100644 --- a/task-api/src/utils/validators.js +++ b/task-api/src/utils/validators.js @@ -33,4 +33,14 @@ const validateUpdateTask = (body) => { return null; }; -module.exports = { validateCreateTask, validateUpdateTask }; +const validateAssignTask = (body) => { + if (body.assignee === undefined || body.assignee === null) { + return 'assignee is required'; + } + if (typeof body.assignee !== 'string' || body.assignee.trim() === '') { + return 'assignee must be a non-empty string'; + } + return null; +}; + +module.exports = { validateCreateTask, validateUpdateTask, validateAssignTask }; diff --git a/task-api/tests/api.test.js b/task-api/tests/api.test.js new file mode 100644 index 00000000..77e95d20 --- /dev/null +++ b/task-api/tests/api.test.js @@ -0,0 +1,485 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +// Reset the in-memory store before each test +beforeEach(() => { + taskService._reset(); +}); + +// Helper: quickly create a task via the API +const createTask = (overrides = {}) => + request(app) + .post('/tasks') + .send({ title: 'Test Task', ...overrides }); + + +// POST /tasks — Create a task + +describe('POST /tasks', () => { + it('should create a task with valid data and return 201', async () => { + const res = await createTask({ title: 'New Task', priority: 'high' }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ + title: 'New Task', + priority: 'high', + status: 'todo', + completedAt: null, + }); + expect(res.body.id).toBeDefined(); + expect(res.body.createdAt).toBeDefined(); + }); + + it('should create a task with only a title (defaults applied)', async () => { + const res = await createTask({ title: 'Minimal' }); + + expect(res.status).toBe(201); + expect(res.body.description).toBe(''); + expect(res.body.status).toBe('todo'); + expect(res.body.priority).toBe('medium'); + expect(res.body.dueDate).toBeNull(); + }); + + // Edge case: missing title + it('should return 400 when title is missing', async () => { + const res = await request(app).post('/tasks').send({ description: 'no title' }); + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + // Edge case: empty title + it('should return 400 when title is an empty string', async () => { + const res = await createTask({ title: '' }); + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + // Edge case: whitespace-only title + it('should return 400 when title is only whitespace', async () => { + const res = await createTask({ title: ' ' }); + expect(res.status).toBe(400); + }); + + // Edge case: invalid status + it('should return 400 for invalid status value', async () => { + const res = await createTask({ title: 'Bad Status', status: 'invalid' }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('status'); + }); + + // Edge case: invalid priority + it('should return 400 for invalid priority value', async () => { + const res = await createTask({ title: 'Bad Priority', priority: 'urgent' }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('priority'); + }); + + // Edge case: invalid dueDate + it('should return 400 for an invalid dueDate', async () => { + const res = await createTask({ title: 'Bad Date', dueDate: 'not-a-date' }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('dueDate'); + }); + + it('should accept a valid dueDate', async () => { + const res = await createTask({ + title: 'With Date', + dueDate: '2026-12-25T00:00:00.000Z', + }); + expect(res.status).toBe(201); + expect(res.body.dueDate).toBe('2026-12-25T00:00:00.000Z'); + }); +}); + +// GET /tasks — List tasks +describe('GET /tasks', () => { + it('should return an empty array when no tasks exist', async () => { + const res = await request(app).get('/tasks'); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it('should return all tasks', async () => { + await createTask({ title: 'Task 1' }); + await createTask({ title: 'Task 2' }); + + const res = await request(app).get('/tasks'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + }); +}); + +// GET /tasks?status= — Filter by status +describe('GET /tasks?status=', () => { + it('should filter tasks by status', async () => { + await createTask({ title: 'Todo', status: 'todo' }); + await createTask({ title: 'Done', status: 'done' }); + + const res = await request(app).get('/tasks?status=todo'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].title).toBe('Todo'); + }); + + it('should return empty array when no tasks match the status', async () => { + await createTask({ title: 'Todo', status: 'todo' }); + const res = await request(app).get('/tasks?status=in_progress'); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + // Edge case — partial status match via includes() + it('should not partially match status values', () => { + taskService.create({ title: 'Completed', status: 'done' }); + + const result = taskService.getByStatus('do'); + expect(result).toHaveLength(0); +}); +}); + +// GET /tasks?page=&limit= — Pagination +describe('GET /tasks?page=&limit=', () => { + beforeEach(async () => { + for (let i = 1; i <= 12; i++) { + await createTask({ title: `Task ${i}` }); + } + }); + + it('should return the first 5 items for page 1', async () => { + const res = await request(app).get('/tasks?page=1&limit=5'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(5); + expect(res.body[0].title).toBe('Task 1'); + expect(res.body[4].title).toBe('Task 5'); + }); + + it('should use default limit of 10 when not specified', async () => { + const res = await request(app).get('/tasks?page=1'); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(10); + }); + + it('should return empty array for a page beyond available data', async () => { + const res = await request(app).get('/tasks?page=999&limit=5'); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); +}); + +// PUT /tasks/:id — Update a task + +describe('PUT /tasks/:id', () => { + it('should update a task and return 200', async () => { + const createRes = await createTask({ title: 'Original' }); + const id = createRes.body.id; + + const res = await request(app) + .put(`/tasks/${id}`) + .send({ title: 'Updated Title', priority: 'high' }); + + expect(res.status).toBe(200); + expect(res.body.title).toBe('Updated Title'); + expect(res.body.priority).toBe('high'); + expect(res.body.id).toBe(id); + }); + + it('should return 404 when task does not exist', async () => { + const res = await request(app) + .put('/tasks/nonexistent-id') + .send({ title: 'Ghost' }); + + expect(res.status).toBe(404); + expect(res.body.error).toBeDefined(); + }); + + // Edge case: empty title + it('should return 400 when title is set to empty string', async () => { + const createRes = await createTask({ title: 'Valid' }); + const res = await request(app) + .put(`/tasks/${createRes.body.id}`) + .send({ title: '' }); + + expect(res.status).toBe(400); + }); + + // Edge case: invalid status + it('should return 400 for invalid status on update', async () => { + const createRes = await createTask({ title: 'Valid' }); + const res = await request(app) + .put(`/tasks/${createRes.body.id}`) + .send({ status: 'invalid_status' }); + + expect(res.status).toBe(400); + }); + + // Edge case: invalid priority on update + it('should return 400 for invalid priority on update', async () => { + const createRes = await createTask({ title: 'Valid' }); + const res = await request(app) + .put(`/tasks/${createRes.body.id}`) + .send({ priority: 'critical' }); + + expect(res.status).toBe(400); + }); + + it('should allow partial updates (only update one field)', async () => { + const createRes = await createTask({ title: 'Original', priority: 'low' }); + const id = createRes.body.id; + + const res = await request(app) + .put(`/tasks/${id}`) + .send({ priority: 'high' }); + + expect(res.status).toBe(200); + expect(res.body.priority).toBe('high'); + expect(res.body.title).toBe('Original'); + }); +}); + +// DELETE /tasks/:id — Delete a task +describe('DELETE /tasks/:id', () => { + it('should delete a task and return 204', async () => { + const createRes = await createTask({ title: 'Delete Me' }); + const id = createRes.body.id; + + const res = await request(app).delete(`/tasks/${id}`); + expect(res.status).toBe(204); + + // verify its gone + const getRes = await request(app).get('/tasks'); + expect(getRes.body).toHaveLength(0); + }); + + it('should return 404 when task does not exist', async () => { + const res = await request(app).delete('/tasks/nonexistent-id'); + expect(res.status).toBe(404); + expect(res.body.error).toBeDefined(); + }); + + // edge case- double delete + it('should return 404 on second delete of the same task', async () => { + const createRes = await createTask({ title: 'Delete Twice' }); + const id = createRes.body.id; + + await request(app).delete(`/tasks/${id}`); + const res = await request(app).delete(`/tasks/${id}`); + + expect(res.status).toBe(404); + }); +}); + +// mark as complete + +describe('PATCH /tasks/:id/complete', () => { + it('should mark a task as done and set completedAt', async () => { + const createRes = await createTask({ title: 'Complete Me' }); + const id = createRes.body.id; + + const res = await request(app).patch(`/tasks/${id}/complete`); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('done'); + expect(res.body.completedAt).toBeDefined(); + }); + + it('should return 404 when task does not exist', async () => { + const res = await request(app).patch('/tasks/nonexistent-id/complete'); + expect(res.status).toBe(404); + expect(res.body.error).toBeDefined(); + }); + + it('should preserve priority when completing task', () => { + const task = taskService.create({ + title:'Urgent', + priority:'high' + }); + + const completed = taskService.completeTask(task.id); + expect(completed.priority).toBe('high'); +}); + + // Edge case: complete an already completed task + it('should allow completing an already-done task (idempotent)', async () => { + const createRes = await createTask({ title: 'Already Done', status: 'done' }); + const id = createRes.body.id; + + const res = await request(app).patch(`/tasks/${id}/complete`); + expect(res.status).toBe(200); + expect(res.body.status).toBe('done'); + }); + + it('should not change completedAt when completing already completed task', async()=>{ + const createRes = await createTask({ + title:'Done', + status:'done' + }); + + const first = await request(app) + .patch(`/tasks/${createRes.body.id}/complete`); + + const second = await request(app) + .patch(`/tasks/${createRes.body.id}/complete`); + + expect(second.body.completedAt) + .toBe(first.body.completedAt); + }); +}); + +// PATCH /tasks/:id/assign — Assign a task +describe('PATCH /tasks/:id/assign', () => { + it('should assign a person to a task and return 200', async () => { + const createRes = await createTask({ title: 'Assign Me' }); + const id = createRes.body.id; + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'Alice' }); + + expect(res.status).toBe(200); + expect(res.body.assignee).toBe('Alice'); + expect(res.body.id).toBe(id); + expect(res.body.title).toBe('Assign Me'); + }); + + it('should return 404 when task does not exist', async () => { + const res = await request(app) + .patch('/tasks/nonexistent-id/assign') + .send({ assignee: 'Bob' }); + + expect(res.status).toBe(404); + expect(res.body.error).toBeDefined(); + }); + + // Edge case: missing assignee field + it('should return 400 when assignee field is missing', async () => { + const createRes = await createTask({ title: 'No Assignee' }); + const res = await request(app) + .patch(`/tasks/${createRes.body.id}/assign`) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('assignee'); + }); + + // Edge case: empty string assignee + it('should return 400 when assignee is an empty string', async () => { + const createRes = await createTask({ title: 'Empty Assignee' }); + const res = await request(app) + .patch(`/tasks/${createRes.body.id}/assign`) + .send({ assignee: '' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('assignee'); + }); + + // Edge case: whitespace-only assignee + it('should return 400 when assignee is only whitespace', async () => { + const createRes = await createTask({ title: 'Whitespace' }); + const res = await request(app) + .patch(`/tasks/${createRes.body.id}/assign`) + .send({ assignee: ' ' }); + + expect(res.status).toBe(400); + }); + + // Edge case: non-string assignee + it('should return 400 when assignee is not a string', async () => { + const createRes = await createTask({ title: 'Number' }); + const res = await request(app) + .patch(`/tasks/${createRes.body.id}/assign`) + .send({ assignee: 123 }); + + expect(res.status).toBe(400); + }); + + // Edge case: reassignment (task already assigned) + it('should allow reassigning a task to a different person', async () => { + const createRes = await createTask({ title: 'Reassign' }); + const id = createRes.body.id; + + await request(app).patch(`/tasks/${id}/assign`).send({ assignee: 'Alice' }); + const res = await request(app).patch(`/tasks/${id}/assign`).send({ assignee: 'Bob' }); + + expect(res.status).toBe(200); + expect(res.body.assignee).toBe('Bob'); + }); + + // Edge case: assignee with leading/trailing whitespace should be trimmed + it('should trim whitespace from assignee name', async () => { + const createRes = await createTask({ title: 'Trim Test' }); + const res = await request(app) + .patch(`/tasks/${createRes.body.id}/assign`) + .send({ assignee: ' Alice ' }); + + expect(res.status).toBe(200); + expect(res.body.assignee).toBe('Alice'); + }); + + // Verify new tasks start with assignee: null + it('should show assignee as null on newly created tasks', async () => { + const res = await createTask({ title: 'Fresh Task' }); + expect(res.body.assignee).toBeNull(); + }); +}); + +// GET /tasks/stats — Stats endpoint +describe('GET /tasks/stats', () => { + it('should return zero counts when no tasks exist', async () => { + const res = await request(app).get('/tasks/stats'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ todo: 0, in_progress: 0, done: 0, overdue: 0 }); + }); + + it('should return correct counts by status', async () => { + await createTask({ title: 'A', status: 'todo' }); + await createTask({ title: 'B', status: 'todo' }); + await createTask({ title: 'C', status: 'in_progress' }); + await createTask({ title: 'D', status: 'done' }); + + const res = await request(app).get('/tasks/stats'); + expect(res.status).toBe(200); + expect(res.body.todo).toBe(2); + expect(res.body.in_progress).toBe(1); + expect(res.body.done).toBe(1); + }); + + it('should count overdue tasks correctly', async () => { + // Overdue: past due date + not done + await createTask({ + title: 'Overdue', + status: 'todo', + dueDate: '2020-01-01T00:00:00.000Z', + }); + // Not overdue: past due date but done + await createTask({ + title: 'Done Past', + status: 'done', + dueDate: '2020-01-01T00:00:00.000Z', + }); + // Not overdue: future due date + await createTask({ + title: 'Future', + status: 'todo', + dueDate: '2099-12-31T00:00:00.000Z', + }); + + const res = await request(app).get('/tasks/stats'); + expect(res.body.overdue).toBe(1); + }); + + // Edge case: tasks with no dueDate are never overdue + it('should not count tasks without dueDate as overdue', async () => { + await createTask({ title: 'No Date', status: 'todo' }); + const res = await request(app).get('/tasks/stats'); + expect(res.body.overdue).toBe(0); + }); +}); + +// Edge case: Unknown routes +describe('Unknown routes', () => { + it('should return 404 for an unknown path', async () => { + const res = await request(app).get('/unknown-path'); + expect(res.status).toBe(404); + }); +}); diff --git a/task-api/tests/taskService.test.js b/task-api/tests/taskService.test.js new file mode 100644 index 00000000..a8185a5c --- /dev/null +++ b/task-api/tests/taskService.test.js @@ -0,0 +1,312 @@ +const taskService = require('../src/services/taskService'); + +// reset the in-memory store before each test +beforeEach(() => { + taskService._reset(); +}); + +// Create a task via the API + +describe('taskService.create', () => { + it('should create a task with all default fields populated', () => { + const task = taskService.create({ title: 'My Task' }); + + expect(task).toMatchObject({ + title: 'My Task', + description: '', + status: 'todo', + priority: 'medium', + dueDate: null, + completedAt: null, + }); + expect(task.id).toBeDefined(); + expect(task.createdAt).toBeDefined(); + }); + + it('should create a task with all provided fields', () => { + const input = { + title: 'Full Task', + description: 'detailed description', + status: 'in_progress', + priority: 'high', + dueDate: '2026-12-31T00:00:00.000Z', + }; + const task = taskService.create(input); + + expect(task.title).toBe('Full Task'); + expect(task.description).toBe('detailed description'); + expect(task.status).toBe('in_progress'); + expect(task.priority).toBe('high'); + expect(task.dueDate).toBe('2026-12-31T00:00:00.000Z'); + }); + + it('should generate unique IDs for each task', () => { + const t1 = taskService.create({ title: 'Task A' }); + const t2 = taskService.create({ title: 'Task B' }); + expect(t1.id).not.toBe(t2.id); + }); +}); + +// GET ALL + +describe('taskService.getAll', () => { + it('should return an empty array when no tasks exist', () => { + expect(taskService.getAll()).toEqual([]); + }); + + it('should return all created tasks', () => { + taskService.create({ title: 'A' }); + taskService.create({ title: 'B' }); + expect(taskService.getAll()).toHaveLength(2); + }); + + it('should return a copy, not the internal array reference', () => { + taskService.create({ title: 'A' }); + const all = taskService.getAll(); + all.push({ fake: true }); + // internal store should be unaffected + expect(taskService.getAll()).toHaveLength(1); + }); +}); + +// FIND BY ID +describe('taskService.findById', () => { + it('should find a task by its ID', () => { + const task = taskService.create({ title: 'Find Me' }); + const found = taskService.findById(task.id); + expect(found).toBeDefined(); + expect(found.title).toBe('Find Me'); + }); + + it('should return undefined for a non-existent ID', () => { + expect(taskService.findById('non-existent-id')).toBeUndefined(); + }); +}); + +// GET BY STATUS +// +describe('taskService.getByStatus', () => { + it('should filter tasks by status', () => { + taskService.create({ title: 'Todo 1', status: 'todo' }); + taskService.create({ title: 'In Prog', status: 'in_progress' }); + taskService.create({ title: 'Done', status: 'done' }); + + const todoTasks = taskService.getByStatus('todo'); + expect(todoTasks).toHaveLength(1); + expect(todoTasks[0].title).toBe('Todo 1'); + }); + + it('should return an empty array when no tasks match the status', () => { + taskService.create({ title: 'Todo', status: 'todo' }); + const result = taskService.getByStatus('done'); + expect(result).toEqual([]); + }); + + it('should not partially match status values', () => { + taskService.create({ title: 'Completed', status: 'done' }); + + const result = taskService.getByStatus('do'); + + expect(result).toHaveLength(0); + }); +}); + +// GET PAGINATED +describe('taskService.getPaginated', () => { + beforeEach(() => { + for (let i = 1; i <= 15; i++) { + taskService.create({ title: `Task ${i}` }); + } + }); + + it('should return the first 5 items for page 1 with limit 5', () => { + const result = taskService.getPaginated(1, 5); + expect(result).toHaveLength(5); + expect(result[0].title).toBe('Task 1'); + expect(result[4].title).toBe('Task 5'); + }); + + it('should return items 6-10 for page 2 with limit 5', () => { + const result = taskService.getPaginated(2, 5); + expect(result).toHaveLength(5); + expect(result[0].title).toBe('Task 6'); + expect(result[4].title).toBe('Task 10'); + }); + + it('should return empty array when page exceeds available data', () => { + const result = taskService.getPaginated(100, 5); + expect(result).toEqual([]); + }); +}); + +// GET STATS +describe('taskService.getStats', () => { + it('should return zero counts when no tasks exist', () => { + const stats = taskService.getStats(); + expect(stats).toEqual({ todo: 0, in_progress: 0, done: 0, overdue: 0 }); + }); + + it('should count tasks by status correctly', () => { + taskService.create({ title: 'A', status: 'todo' }); + taskService.create({ title: 'B', status: 'todo' }); + taskService.create({ title: 'C', status: 'in_progress' }); + taskService.create({ title: 'D', status: 'done' }); + + const stats = taskService.getStats(); + expect(stats.todo).toBe(2); + expect(stats.in_progress).toBe(1); + expect(stats.done).toBe(1); + }); + + it('should count overdue tasks (past dueDate + not done)', () => { + taskService.create({ + title: 'Overdue', + status: 'todo', + dueDate: '2020-01-01T00:00:00.000Z', + }); + taskService.create({ + title: 'Not Overdue', + status: 'done', + dueDate: '2020-01-01T00:00:00.000Z', + }); + + const stats = taskService.getStats(); + expect(stats.overdue).toBe(1); + }); + + it('should NOT count tasks with future dueDate as overdue', () => { + taskService.create({ + title: 'Future', + status: 'todo', + dueDate: '2099-12-31T00:00:00.000Z', + }); + const stats = taskService.getStats(); + expect(stats.overdue).toBe(0); + }); +}); + +// +// UPDATE +// +describe('taskService.update', () => { + it('should update specific fields of a task', () => { + const task = taskService.create({ title: 'Original' }); + const updated = taskService.update(task.id, { title: 'Updated', priority: 'high' }); + + expect(updated.title).toBe('Updated'); + expect(updated.priority).toBe('high'); + expect(updated.id).toBe(task.id); + }); + + it('should return null when task ID does not exist', () => { + const result = taskService.update('nonexistent', { title: 'Nope' }); + expect(result).toBeNull(); + }); + + it('should persist the update in the store', () => { + const task = taskService.create({ title: 'Before' }); + taskService.update(task.id, { title: 'After' }); + const found = taskService.findById(task.id); + expect(found.title).toBe('After'); + }); +}); + +// REMOVE +describe('taskService.remove', () => { + it('should remove a task and return true', () => { + const task = taskService.create({ title: 'Delete Me' }); + const result = taskService.remove(task.id); + expect(result).toBe(true); + expect(taskService.findById(task.id)).toBeUndefined(); + }); + + it('should return false when task ID does not exist', () => { + expect(taskService.remove('nonexistent')).toBe(false); + }); + + it('should reduce the total task count by one', () => { + taskService.create({ title: 'A' }); + const task = taskService.create({ title: 'B' }); + taskService.remove(task.id); + expect(taskService.getAll()).toHaveLength(1); + }); +}); + +// +// COMPLETE TASK +describe('taskService.completeTask', () => { + it('should mark a task as done and set completedAt', () => { + const task = taskService.create({ title: 'Finish this', status: 'todo' }); + const completed = taskService.completeTask(task.id); + + expect(completed.status).toBe('done'); + expect(completed.completedAt).toBeDefined(); + expect(new Date(completed.completedAt).getTime()).not.toBeNaN(); + }); + + it('should return null for a non-existent task', () => { + expect(taskService.completeTask('nonexistent')).toBeNull(); + }); + + // completeTask silently resets priority to 'medium'. + it('completeTask resets priority to medium regardless of original priority', () => { + const task = taskService.create({ title: 'Urgent', priority: 'high' }); + const completed = taskService.completeTask(task.id); + + expect(completed.priority).toBe('high'); + }); + + it('should persist the completion in the store', () => { + const task = taskService.create({ title: 'Persist test' }); + taskService.completeTask(task.id); + const found = taskService.findById(task.id); + expect(found.status).toBe('done'); + expect(found.completedAt).toBeDefined(); + }); +}); + + +// ASSIGN TASK +describe('taskService.assignTask', () => { + it('should assign a person to a task', () => { + const task = taskService.create({ title: 'Assign Me' }); + const result = taskService.assignTask(task.id, 'Alice'); + + expect(result.assignee).toBe('Alice'); + expect(result.id).toBe(task.id); + }); + + it('should return null for a non-existent task', () => { + expect(taskService.assignTask('nonexistent', 'Bob')).toBeNull(); + }); + + it('should allow reassigning a task to a different person', () => { + const task = taskService.create({ title: 'Reassign Me' }); + taskService.assignTask(task.id, 'Alice'); + const result = taskService.assignTask(task.id, 'Bob'); + + expect(result.assignee).toBe('Bob'); + }); + + it('should persist the assignment in the store', () => { + const task = taskService.create({ title: 'Persist' }); + taskService.assignTask(task.id, 'Charlie'); + const found = taskService.findById(task.id); + expect(found.assignee).toBe('Charlie'); + }); + + it('should default assignee to null on task creation', () => { + const task = taskService.create({ title: 'No Assignee' }); + expect(task.assignee).toBeNull(); + }); +}); + +// _RESET (test utility) +describe('taskService._reset', () => { + it('should clear all tasks', () => { + taskService.create({ title: 'A' }); + taskService.create({ title: 'B' }); + taskService._reset(); + expect(taskService.getAll()).toEqual([]); + }); +});