diff --git a/task-api/DAY1_SUMMARY.md b/task-api/DAY1_SUMMARY.md new file mode 100644 index 00000000..4f3e49d8 --- /dev/null +++ b/task-api/DAY1_SUMMARY.md @@ -0,0 +1,20 @@ +# Day 1 Test and Coverage Summary + +- Command run: npm run coverage +- Test suites: 2 failed, 2 total +- Tests: 4 failed, 28 passed, 32 total + +## Coverage + +- Statements: 94.77% +- Branches: 89.33% +- Functions: 92.3% +- Lines: 94.26% + +## Bugs Found by Tests + +1. Status filter bug: partial value like do matches tasks due to substring logic. +2. Pagination bug: page 1 starts at wrong offset and skips first items. + +![alt text]() +![alt text]() \ No newline at end of file diff --git a/task-api/DAY2_SUMMARY.md b/task-api/DAY2_SUMMARY.md new file mode 100644 index 00000000..d85fb79e --- /dev/null +++ b/task-api/DAY2_SUMMARY.md @@ -0,0 +1,34 @@ +# Day 2 + +## Bug Fix (Part B) + +Primary bug fixed: pagination offset. + +- Expected: `page=1&limit=2` should return the first two tasks. +- Fix: changed offset to `(page - 1) * limit` and guarded invalid page/limit values. + +Additional fix applied: status filtering now uses exact match instead of substring match. + +## Assign Endpoint (Part C) + +Added endpoint: `PATCH /tasks/:id/assign` + +### Behavior + +- Stores assignee on task as `assignee`. +- Returns updated task with `200` when assignment succeeds. +- Returns `404` when task is not found. + +### Validation Decisions + +1. Empty or missing assignee +- Returns `400` with `assignee is required and must be a non-empty string`. + +2. Already assigned task +- Returns `409` with `Task is already assigned`. +- Rationale: prevents accidental overwrite of ownership. + +3. Normalization +- Assignee is stored as trimmed text (`assignee.trim()`). + +![alt text]() \ No newline at end of file diff --git a/task-api/Screenshot 2026-04-15 164021.png b/task-api/Screenshot 2026-04-15 164021.png new file mode 100644 index 00000000..0eabb4ba Binary files /dev/null and b/task-api/Screenshot 2026-04-15 164021.png differ diff --git a/task-api/Screenshot 2026-04-15 164037.png b/task-api/Screenshot 2026-04-15 164037.png new file mode 100644 index 00000000..2ffac252 Binary files /dev/null and b/task-api/Screenshot 2026-04-15 164037.png differ diff --git a/task-api/Screenshot 2026-04-15 165034.png b/task-api/Screenshot 2026-04-15 165034.png new file mode 100644 index 00000000..c257b3c8 Binary files /dev/null and b/task-api/Screenshot 2026-04-15 165034.png differ diff --git a/task-api/api/index.js b/task-api/api/index.js new file mode 100644 index 00000000..d9f21a72 --- /dev/null +++ b/task-api/api/index.js @@ -0,0 +1,3 @@ +const app = require('../src/app'); + +module.exports = app; \ No newline at end of file diff --git a/task-api/src/routes/tasks.js b/task-api/src/routes/tasks.js index e8c370fe..dc4548b4 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,23 @@ 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 existing = taskService.findById(req.params.id); + if (!existing) { + return res.status(404).json({ error: 'Task not found' }); + } + + if (existing.assignee) { + return res.status(409).json({ error: 'Task is already assigned' }); + } + + const task = taskService.update(req.params.id, { assignee: req.body.assignee.trim() }); + res.json(task); +}); + module.exports = router; diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index f8e89189..d9c291a1 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -6,11 +6,13 @@ 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; - return tasks.slice(offset, offset + limit); + const pageNum = Number.isInteger(page) && page > 0 ? page : 1; + const limitNum = Number.isInteger(limit) && limit > 0 ? limit : 10; + const offset = (pageNum - 1) * limitNum; + return tasks.slice(offset, offset + limitNum); }; const getStats = () => { @@ -28,13 +30,21 @@ const getStats = () => { return { ...counts, overdue }; }; -const create = ({ title, description = '', status = 'todo', priority = 'medium', dueDate = null }) => { +const create = ({ + title, + description = '', + status = 'todo', + priority = 'medium', + dueDate = null, + assignee = null, +}) => { const task = { id: uuidv4(), title, description, status, priority, + assignee, dueDate, completedAt: null, createdAt: new Date().toISOString(), 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/taskService.test.js b/task-api/tests/taskService.test.js new file mode 100644 index 00000000..4f866780 --- /dev/null +++ b/task-api/tests/taskService.test.js @@ -0,0 +1,137 @@ +const taskService = require('../src/services/taskService'); + +describe('taskService unit tests', () => { + beforeEach(() => { + taskService._reset(); + }); + + test('create should persist a task with defaults', () => { + const task = taskService.create({ title: 'Write tests' }); + + expect(task.id).toEqual(expect.any(String)); + 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.completedAt).toBeNull(); + expect(task.createdAt).toEqual(expect.any(String)); + expect(taskService.getAll()).toHaveLength(1); + }); + + test('create should persist provided fields', () => { + const dueDate = new Date(Date.now() + 3600000).toISOString(); + const task = taskService.create({ + title: 'High priority work', + description: 'Do this now', + status: 'in_progress', + priority: 'high', + dueDate, + }); + + expect(task.description).toBe('Do this now'); + expect(task.status).toBe('in_progress'); + expect(task.priority).toBe('high'); + expect(task.dueDate).toBe(dueDate); + }); + + test('getAll should return a copy of task list', () => { + taskService.create({ title: 'Original' }); + + const snapshot = taskService.getAll(); + snapshot.push({ id: 'fake' }); + + expect(taskService.getAll()).toHaveLength(1); + }); + + test('findById should return matching task and undefined for unknown id', () => { + const created = taskService.create({ title: 'Lookup' }); + + expect(taskService.findById(created.id)).toMatchObject({ title: 'Lookup' }); + expect(taskService.findById('missing-id')).toBeUndefined(); + }); + + test('getByStatus should return only exact status matches', () => { + taskService.create({ title: 'Todo item', status: 'todo' }); + taskService.create({ title: 'Done item', status: 'done' }); + + const result = taskService.getByStatus('todo'); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Todo item'); + }); + + test('getByStatus should not treat partial status as a valid match', () => { + taskService.create({ title: 'Todo item', status: 'todo' }); + taskService.create({ title: 'Done item', status: 'done' }); + + expect(taskService.getByStatus('do')).toEqual([]); + }); + + test('getPaginated should use 1-based page numbers', () => { + for (let i = 1; i <= 5; i++) { + taskService.create({ title: `Task ${i}` }); + } + + const pageOne = taskService.getPaginated(1, 2).map((task) => task.title); + const pageThree = taskService.getPaginated(3, 2).map((task) => task.title); + + expect(pageOne).toEqual(['Task 1', 'Task 2']); + expect(pageThree).toEqual(['Task 5']); + }); + + test('getStats should include status counts and overdue count', () => { + const past = new Date(Date.now() - 86400000).toISOString(); + const future = new Date(Date.now() + 86400000).toISOString(); + + taskService.create({ title: 'A', status: 'todo', dueDate: past }); + taskService.create({ title: 'B', status: 'in_progress', dueDate: future }); + taskService.create({ title: 'C', status: 'done', dueDate: past }); + + expect(taskService.getStats()).toEqual({ + todo: 1, + in_progress: 1, + done: 1, + overdue: 1, + }); + }); + + test('update should patch an existing task and return null for unknown id', () => { + const created = taskService.create({ title: 'Before', priority: 'low' }); + + const updated = taskService.update(created.id, { + title: 'After', + priority: 'high', + status: 'in_progress', + }); + + expect(updated).toMatchObject({ + id: created.id, + title: 'After', + priority: 'high', + status: 'in_progress', + }); + expect(taskService.update('missing-id', { title: 'Nope' })).toBeNull(); + }); + + test('remove should delete existing task and return false for unknown id', () => { + const created = taskService.create({ title: 'Delete me' }); + + expect(taskService.remove(created.id)).toBe(true); + expect(taskService.findById(created.id)).toBeUndefined(); + expect(taskService.remove('missing-id')).toBe(false); + }); + + test('completeTask should mark task done and set completedAt timestamp', () => { + const created = taskService.create({ title: 'Finish me', status: 'todo' }); + + const completed = taskService.completeTask(created.id); + + expect(completed).toMatchObject({ + id: created.id, + status: 'done', + }); + expect(completed.completedAt).toEqual(expect.any(String)); + expect(taskService.completeTask('missing-id')).toBeNull(); + }); +}); \ No newline at end of file diff --git a/task-api/tests/tasks.routes.test.js b/task-api/tests/tasks.routes.test.js new file mode 100644 index 00000000..6cf2d048 --- /dev/null +++ b/task-api/tests/tasks.routes.test.js @@ -0,0 +1,290 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +describe('tasks routes integration tests', () => { + beforeEach(() => { + taskService._reset(); + }); + + describe('GET /tasks', () => { + test('returns all tasks', async () => { + taskService.create({ title: 'Task A', status: 'todo' }); + taskService.create({ title: 'Task B', status: 'done' }); + + const res = await request(app).get('/tasks'); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + expect(res.body.map((t) => t.title)).toEqual(['Task A', 'Task B']); + }); + + test('filters by exact status', async () => { + taskService.create({ title: 'Todo task', status: 'todo' }); + taskService.create({ title: 'Done task', status: 'done' }); + + const res = await request(app).get('/tasks').query({ status: 'todo' }); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].status).toBe('todo'); + }); + + test('returns empty array for invalid status query', async () => { + taskService.create({ title: 'Todo task', status: 'todo' }); + taskService.create({ title: 'Done task', status: 'done' }); + + const res = await request(app).get('/tasks').query({ status: 'do' }); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + test('returns first page when page=1 and limit=2', async () => { + for (let i = 1; i <= 5; i++) { + taskService.create({ title: `Task ${i}` }); + } + + const res = await request(app).get('/tasks').query({ page: 1, limit: 2 }); + + expect(res.status).toBe(200); + expect(res.body.map((t) => t.title)).toEqual(['Task 1', 'Task 2']); + }); + }); + + describe('POST /tasks', () => { + test('creates a task with valid payload', async () => { + const res = await request(app) + .post('/tasks') + .send({ title: 'New task', status: 'todo', priority: 'high' }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ + title: 'New task', + status: 'todo', + priority: 'high', + }); + expect(res.body.id).toEqual(expect.any(String)); + }); + + test('returns 400 when title is missing', async () => { + const res = await request(app) + .post('/tasks') + .send({ status: 'todo', priority: 'medium' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('title is required and must be a non-empty string'); + }); + + test('returns 400 for invalid status', async () => { + const res = await request(app) + .post('/tasks') + .send({ title: 'Bad status', status: 'invalid-status' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('status must be one of'); + }); + + test('returns 400 for invalid dueDate', async () => { + const res = await request(app) + .post('/tasks') + .send({ title: 'Bad date', dueDate: 'not-an-iso-date' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('dueDate must be a valid ISO date string'); + }); + }); + + describe('PUT /tasks/:id', () => { + test('updates an existing task', async () => { + const existing = taskService.create({ title: 'Before update', status: 'todo' }); + + const res = await request(app) + .put(`/tasks/${existing.id}`) + .send({ title: 'After update', status: 'in_progress', priority: 'low' }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + id: existing.id, + title: 'After update', + status: 'in_progress', + priority: 'low', + }); + }); + + test('returns 404 when task id does not exist', async () => { + const res = await request(app).put('/tasks/missing-id').send({ title: 'Updated title' }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('Task not found'); + }); + + test('returns 400 for invalid status value', async () => { + const existing = taskService.create({ title: 'Needs valid update' }); + + const res = await request(app) + .put(`/tasks/${existing.id}`) + .send({ status: 'bad-status' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('status must be one of'); + }); + + test('returns 400 for empty title', async () => { + const existing = taskService.create({ title: 'Title exists' }); + + const res = await request(app) + .put(`/tasks/${existing.id}`) + .send({ title: ' ' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('title must be a non-empty string'); + }); + }); + + describe('DELETE /tasks/:id', () => { + test('deletes an existing task', async () => { + const existing = taskService.create({ title: 'Delete target' }); + + const res = await request(app).delete(`/tasks/${existing.id}`); + + expect(res.status).toBe(204); + expect(taskService.findById(existing.id)).toBeUndefined(); + }); + + test('returns 404 when task id does not exist', async () => { + const res = await request(app).delete('/tasks/missing-id'); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('Task not found'); + }); + + test('returns 404 when deleting the same task twice', async () => { + const existing = taskService.create({ title: 'Delete twice' }); + + const first = await request(app).delete(`/tasks/${existing.id}`); + const second = await request(app).delete(`/tasks/${existing.id}`); + + expect(first.status).toBe(204); + expect(second.status).toBe(404); + expect(second.body.error).toBe('Task not found'); + }); + }); + + describe('PATCH /tasks/:id/complete', () => { + test('marks an existing task as complete', async () => { + const existing = taskService.create({ title: 'Complete me', status: 'in_progress' }); + + const res = await request(app).patch(`/tasks/${existing.id}/complete`); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('done'); + expect(res.body.completedAt).toEqual(expect.any(String)); + }); + + test('returns 404 when task id does not exist', async () => { + const res = await request(app).patch('/tasks/missing-id/complete'); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('Task not found'); + }); + + test('is idempotent enough to keep done status on repeated completion calls', async () => { + const existing = taskService.create({ title: 'Complete twice' }); + + const first = await request(app).patch(`/tasks/${existing.id}/complete`); + const second = await request(app).patch(`/tasks/${existing.id}/complete`); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(second.body.status).toBe('done'); + expect(second.body.completedAt).toEqual(expect.any(String)); + }); + }); + + describe('PATCH /tasks/:id/assign', () => { + test('assigns an assignee to an existing task', async () => { + const existing = taskService.create({ title: 'Assign me' }); + + const res = await request(app) + .patch(`/tasks/${existing.id}/assign`) + .send({ assignee: 'Ravi Shyam' }); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(existing.id); + expect(res.body.assignee).toBe('Ravi Shyam'); + }); + + test('returns 404 when task does not exist', async () => { + const res = await request(app) + .patch('/tasks/missing-id/assign') + .send({ assignee: 'Ravi Shyam' }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('Task not found'); + }); + + test('returns 400 when assignee is missing or empty', async () => { + const existing = taskService.create({ title: 'Needs assignee' }); + + const missing = await request(app) + .patch(`/tasks/${existing.id}/assign`) + .send({}); + const empty = await request(app) + .patch(`/tasks/${existing.id}/assign`) + .send({ assignee: ' ' }); + + expect(missing.status).toBe(400); + expect(missing.body.error).toBe('assignee is required and must be a non-empty string'); + expect(empty.status).toBe(400); + expect(empty.body.error).toBe('assignee is required and must be a non-empty string'); + }); + + test('returns 409 when task is already assigned', async () => { + const existing = taskService.create({ title: 'Assigned task', assignee: 'Initial Owner' }); + + const res = await request(app) + .patch(`/tasks/${existing.id}/assign`) + .send({ assignee: 'New Owner' }); + + expect(res.status).toBe(409); + expect(res.body.error).toBe('Task is already assigned'); + expect(taskService.findById(existing.id).assignee).toBe('Initial Owner'); + }); + }); + + describe('GET /tasks/stats', () => { + test('returns counts by status and overdue count', async () => { + const past = new Date(Date.now() - 86400000).toISOString(); + const future = new Date(Date.now() + 86400000).toISOString(); + + taskService.create({ title: 'Todo overdue', status: 'todo', dueDate: past }); + taskService.create({ title: 'In progress', status: 'in_progress', dueDate: future }); + taskService.create({ title: 'Done old', status: 'done', dueDate: past }); + + 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('returns all zero values when there are no tasks', 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 }); + }); + + test('does not count done tasks as overdue', async () => { + const past = new Date(Date.now() - 86400000).toISOString(); + + taskService.create({ title: 'Finished overdue date', status: 'done', dueDate: past }); + + const res = await request(app).get('/tasks/stats'); + + expect(res.status).toBe(200); + expect(res.body.overdue).toBe(0); + expect(res.body.done).toBe(1); + }); + }); +}); \ No newline at end of file diff --git a/task-api/vercel.json b/task-api/vercel.json new file mode 100644 index 00000000..2f332a04 --- /dev/null +++ b/task-api/vercel.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "rewrites": [ + { + "source": "/(.*)", + "destination": "/api/index" + } + ] +} \ No newline at end of file