Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions bug_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Bug Report

During the testing phase, I identified three bugs in the API logic. I fixed the first one (Pagination Bug) as part of this assignment.

---

## 1. Pagination Offset Calculation Bug (Fixed)

- **Expected Behavior**: When requesting `GET /tasks?page=1&limit=10`, the API should return the first 10 items (items 0 to 9).
- **Actual Behavior**: The API skips the first 10 items and returns items 10 to 19.
- **How it was discovered**: I noticed the formula `const offset = page * limit` in `src/services/taskService.js`. Since `page` safely defaults to `1` in the route handler, `offset` becomes `10` instead of `0`.
- **Fix Applied**: Changed the offset calculation in `getPaginated` to: `const offset = (page - 1) * limit;`

---

## 2. Invalid Status Matching in getByStatus

- **Expected Behavior**: Filtering by status like `GET /tasks?status=todo` should return exactly tasks that have the status `todo`.
- **Actual Behavior**: If a user submits `?status=o`, it matches both `todo` and `in_progress` because the code uses `.includes(status)`.
- **How it was discovered**: Reviewing the `taskService.getByStatus` method, it uses `tasks.filter((t) => t.status.includes(status))`.
- **Proposed Fix**: Use exact equality matching in `getByStatus`: `t.status === status`.

---

## 3. completeTask Overwrites Priority

- **Expected Behavior**: Marking a task as complete (`PATCH /tasks/:id/complete`) should update the status to `done` and set `completedAt` without modifying other unconnected properties like `priority`.
- **Actual Behavior**: The method hardcodes `priority: 'medium'`, which overwrites any `high` or `low` priority that the task originally had.
- **How it was discovered**: Reviewing `taskService.completeTask()`.
- **Proposed Fix**: Remove the `priority: 'medium'` line from the updated object payload in `completeTask`.
18 changes: 9 additions & 9 deletions task-api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion task-api/src/routes/tasks.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}

res.json(task);
});

module.exports = router;
13 changes: 12 additions & 1 deletion task-api/src/services/taskService.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const findById = (id) => tasks.find((t) => t.id === id);
const getByStatus = (status) => tasks.filter((t) => t.status.includes(status));

const getPaginated = (page, limit) => {
const offset = page * limit;
const offset = (page - 1) * limit;
return tasks.slice(offset, offset + limit);
};

Expand All @@ -36,6 +36,7 @@ const create = ({ title, description = '', status = 'todo', priority = 'medium',
status,
priority,
dueDate,
assignee: null,
completedAt: null,
createdAt: new Date().toISOString(),
};
Expand Down Expand Up @@ -76,6 +77,15 @@ const completeTask = (id) => {
return updated;
};

const assignTask = (id, assignee) => {
const index = tasks.findIndex((t) => t.id === id);
if (index === -1) return null;

const updated = { ...tasks[index], assignee };
tasks[index] = updated;
return updated;
};

const _reset = () => {
tasks = [];
};
Expand All @@ -90,5 +100,6 @@ module.exports = {
update,
remove,
completeTask,
assignTask,
_reset,
};
9 changes: 8 additions & 1 deletion task-api/src/utils/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,11 @@ const validateUpdateTask = (body) => {
return null;
};

module.exports = { validateCreateTask, validateUpdateTask };
const validateAssignTask = (body) => {
if (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 };
160 changes: 160 additions & 0 deletions task-api/tests/taskService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
const taskService = require('../src/services/taskService');

describe('Task Service', () => {
beforeEach(() => {
taskService._reset();
});

describe('create', () => {
it('should create a new task with default values', () => {
const task = taskService.create({ title: 'Test task' });
expect(task).toHaveProperty('id');
expect(task.title).toBe('Test task');
expect(task.description).toBe('');
expect(task.status).toBe('todo');
expect(task.priority).toBe('medium');
expect(task.dueDate).toBeNull();
expect(task.completedAt).toBeNull();
expect(task).toHaveProperty('createdAt');
});

it('should create a new task with provided values', () => {
const task = taskService.create({
title: 'Another task',
description: 'Details',
status: 'in_progress',
priority: 'high',
dueDate: '2026-12-31T23:59:59.000Z'
});
expect(task.status).toBe('in_progress');
expect(task.priority).toBe('high');
expect(task.dueDate).toBe('2026-12-31T23:59:59.000Z');
});
});

describe('getAll', () => {
it('should return all tasks', () => {
taskService.create({ title: 'Task 1' });
taskService.create({ title: 'Task 2' });
const tasks = taskService.getAll();
expect(tasks).toHaveLength(2);
});
});

describe('findById', () => {
it('should return the correct task by id', () => {
const created = taskService.create({ title: 'Find me' });
const found = taskService.findById(created.id);
expect(found).toEqual(created);
});

it('should return undefined if id not found', () => {
const found = taskService.findById('invalid-id');
expect(found).toBeUndefined();
});
});

describe('getByStatus', () => {
it('should return tasks matching the status', () => {
taskService.create({ title: 'Task 1', status: 'todo' });
taskService.create({ title: 'Task 2', status: 'done' });
const tasks = taskService.getByStatus('todo');
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toBe('Task 1');
});

// Note: there is a bug with getByStatus matching using .includes
// Let's document its actual existing behavior so the test passes,
// or test the strict behavior since we'll fix it anyway?
// The instructions say "Write tests... At minimum: happy path...". We'll just test the happy path here.
});

describe('getPaginated', () => {
it('should return a paginated slice of tasks based on page and limit', () => {
for (let i = 0; i < 5; i++) {
taskService.create({ title: `Task ${i}` });
}
// After our bug fix, page 1 limit 2 should return first 2
const tasks1 = taskService.getPaginated(1, 2);
expect(tasks1).toHaveLength(2);
expect(tasks1[0].title).toBe('Task 0');
expect(tasks1[1].title).toBe('Task 1');

const tasks2 = taskService.getPaginated(2, 2);
expect(tasks2).toHaveLength(2);
expect(tasks2[0].title).toBe('Task 2');
expect(tasks2[1].title).toBe('Task 3');
});
});

describe('getStats', () => {
it('should calculate task stats correctly', () => {
taskService.create({ title: 'T1', status: 'todo' });
taskService.create({ title: 'T2', status: 'in_progress' });
taskService.create({ title: 'T3', status: 'done' });
taskService.create({ title: 'T4', status: 'todo', dueDate: '2000-01-01T00:00:00Z' }); // Overdue

const stats = taskService.getStats();
expect(stats.todo).toBe(2);
expect(stats.in_progress).toBe(1);
expect(stats.done).toBe(1);
expect(stats.overdue).toBe(1);
});
});

describe('update', () => {
it('should update an existing task', () => {
const created = taskService.create({ title: 'Old title' });
const updated = taskService.update(created.id, { title: 'New title', status: 'in_progress' });
expect(updated.title).toBe('New title');
expect(updated.status).toBe('in_progress');
expect(updated.id).toBe(created.id);
});

it('should return null when updating a non-existent task', () => {
const result = taskService.update('invalid', { title: 'Test' });
expect(result).toBeNull();
});
});

describe('remove', () => {
it('should delete an existing task', () => {
const created = taskService.create({ title: 'Delete me' });
const success = taskService.remove(created.id);
expect(success).toBe(true);
expect(taskService.getAll()).toHaveLength(0);
});

it('should return false for non-existent task', () => {
const result = taskService.remove('invalid');
expect(result).toBe(false);
});
});

describe('completeTask', () => {
it('should mark task as complete and set completedAt', () => {
const created = taskService.create({ title: 'Complete me' });
const completed = taskService.completeTask(created.id);
expect(completed.status).toBe('done');
expect(completed.completedAt).not.toBeNull();
});

it('should return null for non-existent task', () => {
const result = taskService.completeTask('invalid');
expect(result).toBeNull();
});
});

describe('assignTask', () => {
it('should assign an assignee to the task', () => {
const created = taskService.create({ title: 'To assign' });
const assigned = taskService.assignTask(created.id, 'Alice');
expect(assigned.assignee).toBe('Alice');
});

it('should return null for non-existent task', () => {
const result = taskService.assignTask('invalid', 'Bob');
expect(result).toBeNull();
});
});
});
Loading