diff --git a/README.md b/README.md index 5d46160c..50f46fe3 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Read **[ASSIGNMENT.md](./ASSIGNMENT.md)** for the full brief before you start. You're welcome to use AI tools. What we're evaluating is your ability to read and reason about unfamiliar code — so your submission should reflect your own understanding, not just generated output. Concretely: + - For each bug you report: include where in the code it lives and why it happens - For the feature you implement: briefly explain the design decisions you made - If something surprised you or you had to make a tradeoff, say so @@ -42,6 +43,7 @@ npm run coverage # run with coverage report task-api/ src/ app.js # Express app setup + server.js # Server startup (listen) routes/tasks.js # Route handlers services/taskService.js # Business logic + in-memory data store utils/validators.js # Input validation helpers @@ -57,15 +59,15 @@ ASSIGNMENT.md # Full brief — read this first ## API Reference -| Method | Path | Description | -|----------|---------------------------|------------------------------------------| -| `GET` | `/tasks` | List all tasks. Supports `?status=`, `?page=`, `?limit=` | -| `POST` | `/tasks` | Create a new task | -| `PUT` | `/tasks/:id` | Full update of a task | -| `DELETE` | `/tasks/:id` | Delete a task (returns 204) | -| `PATCH` | `/tasks/:id/complete` | Mark a task as complete | -| `GET` | `/tasks/stats` | Counts by status + overdue count | -| `PATCH` | `/tasks/:id/assign` | **Assign a task to a user** _(to implement)_ | +| Method | Path | Description | +| -------- | --------------------- | -------------------------------------------------------- | +| `GET` | `/tasks` | List all tasks. Supports `?status=`, `?page=`, `?limit=` | +| `POST` | `/tasks` | Create a new task | +| `PUT` | `/tasks/:id` | Full update of a task | +| `DELETE` | `/tasks/:id` | Delete a task (returns 204) | +| `PATCH` | `/tasks/:id/complete` | Mark a task as complete | +| `GET` | `/tasks/stats` | Counts by status + overdue count | +| `PATCH` | `/tasks/:id/assign` | Assign a task to a user | ### Task shape @@ -74,9 +76,10 @@ ASSIGNMENT.md # Full brief — read this first "id": "uuid", "title": "string", "description": "string", - "status": "pending | in-progress | completed", + "status": "todo | in_progress | done", "priority": "low | medium | high", "dueDate": "ISO 8601 or null", + "assignee": "string | null", "completedAt": "ISO 8601 or null", "createdAt": "ISO 8601" } @@ -85,6 +88,7 @@ ASSIGNMENT.md # Full brief — read this first ### Sample requests **Create a task** + ```bash curl -X POST http://localhost:3000/tasks \ -H "Content-Type: application/json" \ @@ -92,11 +96,13 @@ curl -X POST http://localhost:3000/tasks \ ``` **List tasks with filter** + ```bash -curl "http://localhost:3000/tasks?status=pending&page=1&limit=10" +curl "http://localhost:3000/tasks?status=todo&page=1&limit=10" ``` **Mark complete** + ```bash curl -X PATCH http://localhost:3000/tasks//complete ``` diff --git a/coverage-report-index.html b/coverage-report-index.html new file mode 100644 index 00000000..44c9625c --- /dev/null +++ b/coverage-report-index.html @@ -0,0 +1,196 @@ + + + + Code coverage report for src + + + + + + + + + +
+
+

All files src

+
+
+ 100% + Statements + 13/13 +
+ +
+ 100% + Branches + 2/2 +
+ +
+ 100% + Functions + 2/2 +
+ +
+ 100% + Lines + 13/13 +
+
+

+ Press n or j to go to the next uncovered block, + b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ File + + Statements + + Branches + + Functions + + Lines +
+ app.js + +
+
+
+
+
100%9/9100%0/0100%1/1100%9/9
+ server.js + +
+
+
+
+
100%4/4100%2/2100%1/1100%4/4
+
+
+ +
+ + + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..6dc50269 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "PARTH-Take-Home-Assignment-The-Untested-API", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/task-api/BUG_REPORT.md b/task-api/BUG_REPORT.md new file mode 100644 index 00000000..5b36bd2a --- /dev/null +++ b/task-api/BUG_REPORT.md @@ -0,0 +1,36 @@ +# Bug Report + +## 1) Pagination returns the wrong page (fixed) + +- **What should happen:** `/tasks?page=1&limit=2` should return the first two tasks. Page 2 should return the next two. +- **What happens now:** Page 1 starts too late, so it returns tasks 3-4. +- **How found:** Tests for pagination failed. +- **Where:** `task-api/src/services/taskService.js` in `getPaginated`. +- **Why:** It used `page * limit` instead of `(page - 1) * limit`. +- **Fix applied:** Use `(page - 1) * limit`. + +## 2) Status filter matches part of a word (fixed) + +- **What should happen:** `/tasks?status=todo` should only return tasks with `todo`. +- **What happens now:** `/tasks?status=do` would match both `todo` and `done`. +- **How found:** While writing tests for the status filter. +- **Where:** `task-api/src/services/taskService.js` in `getByStatus`. +- **Why:** It used `includes` instead of exact match. +- **Fix applied:** Use exact match only. + +## 3) Completing a task resets priority (fixed) + +- **What should happen:** A task should keep its priority when completed. +- **What happens now:** The priority is forced to `medium`. +- **How found:** While adding tests for completing a task. +- **Where:** `task-api/src/services/taskService.js` in `completeTask`. +- **Why:** It set `priority: 'medium'` every time. +- **Fix applied:** Do not change priority when completing. + +## 4) README status values were wrong (fixed) + +- **What should happen:** The README should match the real status values. +- **What happens now:** README listed different status names. +- **How found:** Comparing README with the code and tests. +- **Where:** `README.md` vs `task-api/src/utils/validators.js`. +- **Fix applied:** README now matches the real status values. diff --git a/task-api/COVERAGE_SUMMARY.md b/task-api/COVERAGE_SUMMARY.md new file mode 100644 index 00000000..30faeea5 --- /dev/null +++ b/task-api/COVERAGE_SUMMARY.md @@ -0,0 +1,35 @@ +# Coverage Summary + +Command used (works on this Windows setup): + +```bash +node ./node_modules/jest/bin/jest.js --coverage --runInBand +``` + +Output: + +``` +-----------------|---------|----------|---------|---------|------------------- +File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s +-----------------|---------|----------|---------|---------|------------------- +All files | 100 | 96.42 | 100 | 100 | + src | 100 | 100 | 100 | 100 | + app.js | 100 | 100 | 100 | 100 | + server.js | 100 | 100 | 100 | 100 | + src/routes | 100 | 91.66 | 100 | 100 | + tasks.js | 100 | 91.66 | 100 | 100 | 20-21 + src/services | 100 | 94.73 | 100 | 100 | + taskService.js | 100 | 94.73 | 100 | 100 | 22 + src/utils | 100 | 100 | 100 | 100 | + validators.js | 100 | 100 | 100 | 100 | +-----------------|---------|----------|---------|---------|------------------- +PASS tests/server.test.js +PASS tests/tasks.api.test.js +PASS tests/taskService.test.js + +Test Suites: 3 passed, 3 total +Tests: 27 passed, 27 total +Snapshots: 0 total +Time: 1.866 s, estimated 2 s +Ran all test suites. +``` diff --git a/task-api/SUBMISSION_NOTES.md b/task-api/SUBMISSION_NOTES.md new file mode 100644 index 00000000..249af21b --- /dev/null +++ b/task-api/SUBMISSION_NOTES.md @@ -0,0 +1,24 @@ +# Submission Notes + +## New assign route decisions + +- `assignee` must be a real name (not empty). Extra spaces are trimmed. +- You can change the assignee later. The route always returns the updated task. +- The task stores `assignee` and starts as `null` when created. + +## What I would test next + +- Two requests at the same time (for example: update and complete). +- Very long titles/descriptions and very large `page`/`limit` values. +- Make sure `completedAt` is set when a task is set to `done` using `PUT`. + +## Anything that surprised me + +- The README used different status names than the code. +- Pagination was off by one because of the math. + +## Questions before shipping + +- Should `PUT /tasks/:id` replace the whole task, or only the fields sent? +- Should changing assignee be allowed, or should it fail if already assigned? +- Should we block moving from `done` back to `in_progress`? diff --git a/task-api/package-lock.json b/task-api/package-lock.json index 901be207..7e2c272a 100644 --- a/task-api/package-lock.json +++ b/task-api/package-lock.json @@ -1357,9 +1357,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3773,9 +3773,9 @@ "license": "MIT" }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/picocolors": { @@ -3786,9 +3786,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { diff --git a/task-api/package.json b/task-api/package.json index 6a36a476..247fc49d 100644 --- a/task-api/package.json +++ b/task-api/package.json @@ -4,7 +4,7 @@ "description": "Task Manager API", "main": "src/app.js", "scripts": { - "start": "node src/app.js", + "start": "node src/server.js", "test": "jest", "coverage": "jest --coverage" }, diff --git a/task-api/src/app.js b/task-api/src/app.js index 65c03eec..d5db8ea5 100644 --- a/task-api/src/app.js +++ b/task-api/src/app.js @@ -11,12 +11,4 @@ app.use((err, req, res, next) => { 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}`); - }); -} - module.exports = app; 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/server.js b/task-api/src/server.js new file mode 100644 index 00000000..ba04fc95 --- /dev/null +++ b/task-api/src/server.js @@ -0,0 +1,7 @@ +const app = require('./app'); + +const PORT = process.env.PORT || 3000; + +app.listen(PORT, () => { + console.log(`Task API running on port ${PORT}`); +}); diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index f8e89189..02ee7316 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 = Math.max(page - 1, 0) * 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,7 +67,6 @@ const completeTask = (id) => { const updated = { ...task, - priority: 'medium', status: 'done', completedAt: new Date().toISOString(), }; @@ -76,6 +76,20 @@ 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 +104,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..98f03c09 100644 --- a/task-api/src/utils/validators.js +++ b/task-api/src/utils/validators.js @@ -33,4 +33,11 @@ const validateUpdateTask = (body) => { return null; }; -module.exports = { validateCreateTask, validateUpdateTask }; +const validateAssignTask = (body) => { + if (!body || typeof body.assignee !== 'string' || body.assignee.trim() === '') { + return 'assignee is required and must be a non-empty string'; + } + return null; +}; + +module.exports = { validateCreateTask, validateUpdateTask, validateAssignTask }; diff --git a/task-api/tests/.gitkeep b/task-api/tests/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/task-api/tests/server.test.js b/task-api/tests/server.test.js new file mode 100644 index 00000000..b3768e0d --- /dev/null +++ b/task-api/tests/server.test.js @@ -0,0 +1,49 @@ +describe('server startup', () => { + const originalPort = process.env.PORT; + + afterEach(() => { + process.env.PORT = originalPort; + jest.resetModules(); + jest.clearAllMocks(); + }); + + test('uses PORT from environment and logs startup', () => { + jest.resetModules(); + + const listenMock = jest.fn((port, cb) => { + if (cb) cb(); + return { close: jest.fn() }; + }); + jest.doMock('../src/app', () => ({ listen: listenMock })); + + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + process.env.PORT = '5001'; + + require('../src/server'); + + expect(listenMock).toHaveBeenCalledWith('5001', expect.any(Function)); + expect(logSpy).toHaveBeenCalledWith('Task API running on port 5001'); + + logSpy.mockRestore(); + }); + + test('defaults to port 3000 when PORT is not set', () => { + jest.resetModules(); + + const listenMock = jest.fn((port, cb) => { + if (cb) cb(); + return { close: jest.fn() }; + }); + jest.doMock('../src/app', () => ({ listen: listenMock })); + + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + delete process.env.PORT; + + require('../src/server'); + + expect(listenMock).toHaveBeenCalledWith(3000, expect.any(Function)); + expect(logSpy).toHaveBeenCalledWith('Task API running on port 3000'); + + logSpy.mockRestore(); + }); +}); diff --git a/task-api/tests/taskService.test.js b/task-api/tests/taskService.test.js new file mode 100644 index 00000000..26322d55 --- /dev/null +++ b/task-api/tests/taskService.test.js @@ -0,0 +1,96 @@ +const taskService = require('../src/services/taskService'); + +beforeEach(() => { + taskService._reset(); +}); + +describe('taskService', () => { + test('create sets defaults and persists the task', () => { + const task = taskService.create({ title: 'Write tests' }); + + expect(task.id).toBeDefined(); + expect(task.title).toBe('Write tests'); + expect(task.description).toBe(''); + expect(task.status).toBe('todo'); + expect(task.priority).toBe('medium'); + expect(task.dueDate).toBeNull(); + expect(task.assignee).toBeNull(); + expect(task.completedAt).toBeNull(); + expect(task.createdAt).toBeDefined(); + + const all = taskService.getAll(); + expect(all).toHaveLength(1); + expect(all[0].id).toBe(task.id); + }); + + test('getPaginated returns the expected page', () => { + const titles = ['t1', 't2', 't3', 't4', 't5']; + titles.forEach((title) => taskService.create({ title })); + + const page1 = taskService.getPaginated(1, 2); + const page2 = taskService.getPaginated(2, 2); + + expect(page1.map((t) => t.title)).toEqual(['t1', 't2']); + expect(page2.map((t) => t.title)).toEqual(['t3', 't4']); + }); + + test('update returns null for unknown id', () => { + const result = taskService.update('missing-id', { title: 'x' }); + expect(result).toBeNull(); + }); + + test('update merges fields for an existing task', () => { + const task = taskService.create({ title: 'Original', priority: 'low' }); + 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); + }); + + test('remove returns false for unknown id', () => { + const result = taskService.remove('missing-id'); + expect(result).toBe(false); + }); + + test('remove deletes an existing task', () => { + const task = taskService.create({ title: 'To delete' }); + const result = taskService.remove(task.id); + + expect(result).toBe(true); + expect(taskService.getAll()).toHaveLength(0); + }); + + test('completeTask marks done and sets completedAt', () => { + const task = taskService.create({ title: 'Complete me', priority: 'high' }); + const completed = taskService.completeTask(task.id); + + expect(completed.status).toBe('done'); + expect(completed.completedAt).toBeDefined(); + expect(completed.priority).toBe('high'); + }); + + test('getStats counts by status and overdue', () => { + taskService.create({ title: 'Overdue', dueDate: '2000-01-01T00:00:00Z', status: 'todo' }); + taskService.create({ title: 'In progress', status: 'in_progress' }); + taskService.create({ title: 'Done', status: 'done', dueDate: '2000-01-01T00:00:00Z' }); + + const stats = taskService.getStats(); + expect(stats.todo).toBe(1); + expect(stats.in_progress).toBe(1); + expect(stats.done).toBe(1); + expect(stats.overdue).toBe(1); + }); + + test('getByStatus requires an exact match (not substring)', () => { + taskService.create({ title: 'Todo', status: 'todo' }); + taskService.create({ title: 'Done', status: 'done' }); + + const exact = taskService.getByStatus('todo'); + const substring = taskService.getByStatus('do'); + + expect(exact).toHaveLength(1); + expect(exact[0].status).toBe('todo'); + expect(substring).toHaveLength(0); + }); +}); diff --git a/task-api/tests/tasks.api.test.js b/task-api/tests/tasks.api.test.js new file mode 100644 index 00000000..5b0ba0f7 --- /dev/null +++ b/task-api/tests/tasks.api.test.js @@ -0,0 +1,203 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +beforeEach(() => { + taskService._reset(); +}); + +describe('Task API', () => { + test('GET /tasks returns an empty list initially', async () => { + const res = await request(app).get('/tasks'); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + test('POST /tasks creates a task', async () => { + const res = await request(app) + .post('/tasks') + .send({ title: 'Write tests', priority: 'high' }); + + expect(res.status).toBe(201); + expect(res.body.id).toBeDefined(); + expect(res.body.title).toBe('Write tests'); + expect(res.body.priority).toBe('high'); + expect(res.body.status).toBe('todo'); + }); + + test('POST /tasks rejects invalid payloads', async () => { + const missingTitle = await request(app).post('/tasks').send({ priority: 'low' }); + expect(missingTitle.status).toBe(400); + + const badStatus = await request(app) + .post('/tasks') + .send({ title: 'Bad status', status: 'invalid' }); + expect(badStatus.status).toBe(400); + + const badPriority = await request(app) + .post('/tasks') + .send({ title: 'Bad priority', priority: 'urgent' }); + expect(badPriority.status).toBe(400); + + const badDueDate = await request(app) + .post('/tasks') + .send({ title: 'Bad date', dueDate: 'not-a-date' }); + expect(badDueDate.status).toBe(400); + }); + + test('GET /tasks?status filters tasks', async () => { + taskService.create({ title: 'Todo task', status: 'todo' }); + taskService.create({ title: 'Done task', 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].status).toBe('todo'); + + const partial = await request(app).get('/tasks?status=do'); + expect(partial.status).toBe(200); + expect(partial.body).toHaveLength(0); + }); + + test('GET /tasks paginates results', async () => { + ['t1', 't2', 't3', 't4', 't5'].forEach((title) => + taskService.create({ title, status: 'todo' }) + ); + + const page1 = await request(app).get('/tasks?page=1&limit=2'); + const page2 = await request(app).get('/tasks?page=2&limit=2'); + + expect(page1.status).toBe(200); + expect(page1.body.map((t) => t.title)).toEqual(['t1', 't2']); + expect(page2.body.map((t) => t.title)).toEqual(['t3', 't4']); + }); + + test('PUT /tasks/:id updates a task', async () => { + const task = taskService.create({ title: 'Original', priority: 'low' }); + + const res = await request(app) + .put(`/tasks/${task.id}`) + .send({ title: 'Updated', priority: 'high' }); + + expect(res.status).toBe(200); + expect(res.body.title).toBe('Updated'); + expect(res.body.priority).toBe('high'); + }); + + test('PUT /tasks/:id validates and handles missing tasks', async () => { + const invalid = await request(app).put('/tasks/unknown').send({ title: '' }); + expect(invalid.status).toBe(400); + + const missing = await request(app).put('/tasks/unknown').send({ title: 'Valid' }); + expect(missing.status).toBe(404); + }); + + test('PUT /tasks/:id rejects invalid status/priority/dueDate', async () => { + const task = taskService.create({ title: 'Validate me' }); + + const badStatus = await request(app) + .put(`/tasks/${task.id}`) + .send({ status: 'invalid' }); + expect(badStatus.status).toBe(400); + + const badPriority = await request(app) + .put(`/tasks/${task.id}`) + .send({ priority: 'urgent' }); + expect(badPriority.status).toBe(400); + + const badDueDate = await request(app) + .put(`/tasks/${task.id}`) + .send({ dueDate: 'not-a-date' }); + expect(badDueDate.status).toBe(400); + }); + + test('DELETE /tasks/:id removes a task', async () => { + const task = taskService.create({ title: 'Delete me' }); + + const res = await request(app).delete(`/tasks/${task.id}`); + expect(res.status).toBe(204); + + const list = await request(app).get('/tasks'); + expect(list.body).toHaveLength(0); + }); + + test('DELETE /tasks/:id returns 404 when missing', async () => { + const res = await request(app).delete('/tasks/unknown'); + expect(res.status).toBe(404); + }); + + test('PATCH /tasks/:id/complete marks a task complete', async () => { + const task = taskService.create({ title: 'Complete me', priority: 'high' }); + + const res = await request(app).patch(`/tasks/${task.id}/complete`); + expect(res.status).toBe(200); + expect(res.body.status).toBe('done'); + expect(res.body.completedAt).toBeDefined(); + }); + + test('PATCH /tasks/:id/complete returns 404 when missing', async () => { + const res = await request(app).patch('/tasks/unknown/complete'); + expect(res.status).toBe(404); + }); + + test('GET /tasks/stats returns counts and overdue', async () => { + taskService.create({ title: 'Overdue', dueDate: '2000-01-01T00:00:00Z', status: 'todo' }); + taskService.create({ title: 'In progress', status: 'in_progress' }); + taskService.create({ title: 'Done', status: 'done', dueDate: '2000-01-01T00:00:00Z' }); + + const res = await request(app).get('/tasks/stats'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ todo: 1, in_progress: 1, done: 1, overdue: 1 }); + }); + + test('PATCH /tasks/:id/assign assigns a task', async () => { + const task = taskService.create({ title: 'Assign me' }); + + const res = await request(app).patch(`/tasks/${task.id}/assign`).send({ assignee: 'Alice' }); + expect(res.status).toBe(200); + expect(res.body.assignee).toBe('Alice'); + + const trimmed = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: ' Bob ' }); + expect(trimmed.status).toBe(200); + expect(trimmed.body.assignee).toBe('Bob'); + + const reassign = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: 'Bob' }); + expect(reassign.status).toBe(200); + expect(reassign.body.assignee).toBe('Bob'); + }); + + test('PATCH /tasks/:id/assign validates and handles missing tasks', async () => { + const task = taskService.create({ title: 'Assign me' }); + + const invalid = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: '' }); + expect(invalid.status).toBe(400); + + const missingBody = await request(app).patch(`/tasks/${task.id}/assign`).send({}); + expect(missingBody.status).toBe(400); + + const missing = await request(app) + .patch('/tasks/unknown/assign') + .send({ assignee: 'Alice' }); + expect(missing.status).toBe(404); + }); + + test('GET /tasks returns 500 on unexpected service errors', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const spy = jest.spyOn(taskService, 'getAll').mockImplementation(() => { + throw new Error('boom'); + }); + + const res = await request(app).get('/tasks'); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Internal server error' }); + + spy.mockRestore(); + errorSpy.mockRestore(); + }); +});