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
19 changes: 19 additions & 0 deletions task-api/BUG_REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Task API Bug Report

## 1. Status Filtering Matched Partial Strings
- **Expected Behavior**: `GET /tasks?status=todo` should only return tasks with `status === "todo"`.
- **Actual Behavior**: Filtering used partial matching, so values like `"o"` matched both `"todo"` and `"done"`.
- **How Discovered**: Unit test added for partial string input in `taskService.getByStatus`.
- **Suggested Fix**: Use strict equality (`===`) for status matching.

## 2. Pagination Started From Wrong Offset
- **Expected Behavior**: Page 1 with limit 10 should return tasks 0-9.
- **Actual Behavior**: Pagination used `offset = page * limit`, which skipped the first page of items.
- **How Discovered**: Unit + integration tests for pagination.
- **Suggested Fix**: Use `offset = (page - 1) * limit`.

## 3. Assignment Endpoint Needs Strong Input Validation
- **Expected Behavior**: `PATCH /tasks/:id/assign` should reject invalid payloads (missing or non-string assignee).
- **Actual Behavior**: Initial coverage focused on happy path + empty string, but missing/non-string cases needed explicit test coverage.
- **How Discovered**: API test review.
- **Suggested Fix**: Validate `assignee` as a required non-empty string and cover missing/non-string cases in integration tests.
3 changes: 3 additions & 0 deletions task-api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ const taskRoutes = require('./routes/tasks');
const app = express();

app.use(express.json());
app.get('/', (req, res) => {
res.status(200).json({ message: 'Task API is running' });
});
app.use('/tasks', taskRoutes);

app.use((err, req, res, next) => {
Expand Down
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.assign(req.params.id, req.body.assignee);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}

res.json(task);
});

module.exports = router;
16 changes: 13 additions & 3 deletions task-api/src/services/taskService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};

Expand Down Expand Up @@ -60,13 +60,22 @@ const remove = (id) => {
return true;
};

const assign = (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 completeTask = (id) => {
const task = findById(id);
if (!task) return null;

const updated = {
...task,
priority: 'medium',
status: 'done',
completedAt: new Date().toISOString(),
};
Expand All @@ -89,6 +98,7 @@ module.exports = {
create,
update,
remove,
assign,
completeTask,
_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 (!body.assignee || 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 };
154 changes: 154 additions & 0 deletions task-api/tests/taskService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
const taskService = require('../src/services/taskService');

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

describe('create', () => {
it('creates a task with defaults', () => {
const task = taskService.create({ title: 'Test Task' });

expect(task).toHaveProperty('id');
expect(task.title).toBe('Test Task');
expect(task.status).toBe('todo');
expect(task.priority).toBe('medium');
expect(task.completedAt).toBeNull();
});
});

describe('getByStatus', () => {
it('returns only exact status matches', () => {
taskService.create({ title: 'Todo Task', status: 'todo' });
taskService.create({ title: 'Done Task', status: 'done' });

const todoTasks = taskService.getByStatus('todo');
expect(todoTasks).toHaveLength(1);
expect(todoTasks[0].title).toBe('Todo Task');
});

it('does not treat partial strings as matches', () => {
taskService.create({ title: 'Todo Task', status: 'todo' });
taskService.create({ title: 'Done Task', status: 'done' });

const partialMatches = taskService.getByStatus('o');
expect(partialMatches).toHaveLength(0);
});
});

describe('getPaginated', () => {
it('returns first page from the beginning of the list', () => {
for (let i = 0; i < 15; i++) {
taskService.create({ title: `Task ${i}` });
}

const page1 = taskService.getPaginated(1, 10);
expect(page1).toHaveLength(10);
expect(page1[0].title).toBe('Task 0');
expect(page1[9].title).toBe('Task 9');
});

it('returns remaining records on second page', () => {
for (let i = 0; i < 15; i++) {
taskService.create({ title: `Task ${i}` });
}

const page2 = taskService.getPaginated(2, 10);
expect(page2).toHaveLength(5);
expect(page2[0].title).toBe('Task 10');
});
});

describe('findById', () => {
it('finds a task by id', () => {
const created = taskService.create({ title: 'Lookup Task' });
const found = taskService.findById(created.id);

expect(found).toBeDefined();
expect(found.id).toBe(created.id);
});

it('returns undefined for unknown id', () => {
expect(taskService.findById('missing-id')).toBeUndefined();
});
});

describe('update', () => {
it('updates selected fields on an existing task', () => {
const created = taskService.create({ title: 'Old Title', priority: 'high' });
const updated = taskService.update(created.id, { title: 'New Title' });

expect(updated.title).toBe('New Title');
expect(updated.priority).toBe('high');
});

it('returns null for unknown id', () => {
const updated = taskService.update('missing-id', { title: 'Nope' });
expect(updated).toBeNull();
});
});

describe('remove', () => {
it('removes an existing task', () => {
const created = taskService.create({ title: 'Delete Me' });
const removed = taskService.remove(created.id);

expect(removed).toBe(true);
expect(taskService.findById(created.id)).toBeUndefined();
});

it('returns false for unknown id', () => {
expect(taskService.remove('missing-id')).toBe(false);
});
});

describe('completeTask', () => {
it('marks task as done and sets completion time', () => {
const created = taskService.create({ title: 'Complete Me' });
const completed = taskService.completeTask(created.id);

expect(completed.status).toBe('done');
expect(completed.completedAt).not.toBeNull();
});

it('preserves the original priority of the task', () => {
const created = taskService.create({ title: 'High Priority Task', priority: 'high' });
const completed = taskService.completeTask(created.id);

expect(completed.priority).toBe('high');
});

it('returns null for unknown id', () => {
expect(taskService.completeTask('missing-id')).toBeNull();
});
});

describe('assign', () => {
it('sets the assignee on an existing task', () => {
const created = taskService.create({ title: 'Assign Me' });
const updated = taskService.assign(created.id, 'Alice');

expect(updated.assignee).toBe('Alice');
expect(taskService.findById(created.id).assignee).toBe('Alice');
});

it('returns null for unknown id', () => {
const updated = taskService.assign('missing-id', 'Alice');
expect(updated).toBeNull();
});
});

describe('getStats', () => {
it('returns status totals and overdue count', () => {
taskService.create({ title: 'Todo Overdue', status: 'todo', dueDate: '2020-01-01T00:00:00Z' });
taskService.create({ title: 'Done Old', status: 'done', dueDate: '2020-01-01T00:00:00Z' });
taskService.create({ title: 'In Progress', status: 'in_progress', dueDate: '2050-01-01T00:00:00Z' });

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