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

## Pagination off-by-one bug in `getPaginated`
- Expected: `page=1` should return first page items (items 1..limit), `page=2` should return next slice.
- Actual: page was treated as 0-based in service (`offset = page * limit`), so `page=1` returned second page.
- Discovered by: automated unit test `taskService.test.js` with expected first-page items failing.
- Fix implemented: normalize page to 1-based in `getPaginated`, i.e. `offset = (page - 1) * limit` with safe defaults.

8 changes: 8 additions & 0 deletions task-api/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ app.use((err, req, res, next) => {
res.status(500).json({ error: 'Internal server error' });
});

app.get("/health", (req, res) => {
res.status(200).json({ status: "ok" });
});

app.get("/", (req, res) => {
res.status(200).json({ message: "Welcome to the Task API" });
});

const PORT = process.env.PORT || 3000;

if (require.main === module) {
Expand Down
35 changes: 32 additions & 3 deletions task-api/src/routes/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ router.get('/stats', (req, res) => {
});

router.get('/', (req, res) => {
const { status, page, limit } = req.query;
const { status, priority, page, limit } = req.query;

if (status) {
const tasks = taskService.getByStatus(status);
if (status || priority) {
let tasks = taskService.getAll();
if (status) {
tasks = tasks.filter((t) => t.status === status);
}
if (priority) {
tasks = tasks.filter((t) => t.priority === priority);
}
return res.json(tasks);
}

Expand Down Expand Up @@ -69,4 +75,27 @@ router.patch('/:id/complete', (req, res) => {
res.json(task);
});

router.patch('/:id/assign', (req, res) => {
const { assignee } = req.body;
if (!assignee || typeof assignee !== 'string' || assignee.trim() === '') {
return res.status(400).json({ error: 'assignee is required and must be a non-empty string' });
}

const existingTask = taskService.findById(req.params.id);
if (!existingTask) {
return res.status(404).json({ error: 'Task not found' });
}

if (existingTask.assignee) {
return res.status(409).json({ error: 'Task is already assigned' });
}

const task = taskService.assignTask(req.params.id, assignee.trim());
if (!task) {
return res.status(500).json({ error: 'Unable to assign task' });
}

res.json(task);
});

module.exports = router;
26 changes: 22 additions & 4 deletions task-api/src/services/taskService.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ 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(page);
const limitNum = Number(limit);
const normalizedPage = Number.isNaN(pageNum) || pageNum < 1 ? 1 : pageNum;
const normalizedLimit = Number.isNaN(limitNum) || limitNum < 1 ? 10 : limitNum;
const offset = (normalizedPage - 1) * normalizedLimit;
return tasks.slice(offset, offset + normalizedLimit);
};

const getStats = () => {
Expand All @@ -28,7 +32,7 @@ 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,
Expand All @@ -38,11 +42,24 @@ const create = ({ title, description = '', status = 'todo', priority = 'medium',
dueDate,
completedAt: null,
createdAt: new Date().toISOString(),
assignee,
};
tasks.push(task);
return task;
};

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

const task = tasks[index];
if (task.assignee) return null; // caller can detect already assigned separately

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

const update = (id, fields) => {
const index = tasks.findIndex((t) => t.id === id);
if (index === -1) return null;
Expand Down Expand Up @@ -90,5 +107,6 @@ module.exports = {
update,
remove,
completeTask,
assignTask,
_reset,
};
115 changes: 115 additions & 0 deletions task-api/tests/taskService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
const taskService = require('../src/services/taskService');

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

test('create() returns task with defaults and unique id', () => {
const task = taskService.create({ title: 'Test task' });

expect(task.id).toBeDefined();
expect(task.title).toBe('Test task');
expect(task.status).toBe('todo');
expect(task.priority).toBe('medium');
expect(task.description).toBe('');
expect(task.completedAt).toBeNull();
expect(task.dueDate).toBeNull();
expect(task.createdAt).toBeDefined();
});

test('getAll() returns all tasks', () => {
const a = taskService.create({ title: 'A' });
const b = taskService.create({ title: 'B' });

expect(taskService.getAll()).toEqual(expect.arrayContaining([a, b]));
});

test('findById() returns correct or undefined', () => {
const task = taskService.create({ title: 'Find me' });
expect(taskService.findById(task.id)).toEqual(task);
expect(taskService.findById('nope')).toBeUndefined();
});

test('getByStatus() filters by exact status', () => {
const t1 = taskService.create({ title: 'Todo1', status: 'todo' });
taskService.create({ title: 'Done1', status: 'done' });

const todos = taskService.getByStatus('todo');
expect(todos).toEqual([t1]);
expect(taskService.getByStatus('in_progress')).toEqual([]);
});

test('getPaginated() returns correct pages', () => {
for (let i = 1; i <= 12; i++) {
taskService.create({ title: `Task ${i}` });
}

expect(taskService.getPaginated(1, 5).map((t) => t.title)).toEqual([
'Task 1',
'Task 2',
'Task 3',
'Task 4',
'Task 5',
]);

expect(taskService.getPaginated(2, 5).map((t) => t.title)).toEqual([
'Task 6',
'Task 7',
'Task 8',
'Task 9',
'Task 10',
]);

expect(taskService.getPaginated(3, 5).map((t) => t.title)).toEqual([
'Task 11',
'Task 12',
]);
});

test('getStats() counts statuses and overdue tasks', () => {
const now = new Date();
const past = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString();
const future = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString();

taskService.create({ title: 'Todo old', status: 'todo', dueDate: past });
taskService.create({ title: 'InProgress', status: 'in_progress', dueDate: future });
taskService.create({ title: 'Done old', status: 'done', dueDate: past });

expect(taskService.getStats()).toMatchObject({
todo: 1,
in_progress: 1,
done: 1,
overdue: 1,
});
});

test('update() works and returns null on missing', () => {
const task = taskService.create({ title: 'Old' });
const updated = taskService.update(task.id, { title: 'New', status: 'in_progress' });

expect(updated.title).toBe('New');
expect(updated.status).toBe('in_progress');
expect(taskService.findById(task.id).title).toBe('New');

expect(taskService.update('missing', { title: 'X' })).toBeNull();
});

test('remove() removes and returns correct bool', () => {
const task = taskService.create({ title: 'ToDelete' });
expect(taskService.remove(task.id)).toBe(true);
expect(taskService.findById(task.id)).toBeUndefined();
expect(taskService.remove('not-there')).toBe(false);
});

test('completeTask() marks done and sets completedAt', () => {
const task = taskService.create({ title: 'ToComplete', priority: 'high', status: 'in_progress' });
const completed = taskService.completeTask(task.id);

expect(completed.status).toBe('done');
expect(completed.priority).toBe('medium');
expect(completed.completedAt).toBeTruthy();

expect(taskService.completeTask('missing')).toBeNull();
});
});
98 changes: 98 additions & 0 deletions task-api/tests/tasks.routes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const request = require('supertest');
const app = require('../src/app');
const taskService = require('../src/services/taskService');

describe('/tasks routes integration', () => {
beforeEach(() => {
taskService._reset();
});

test('POST /tasks creates task and GET /tasks returns it', async () => {
const createRes = await request(app).post('/tasks').send({ title: 'Integration task' });
expect(createRes.status).toBe(201);
expect(createRes.body.title).toBe('Integration task');

const listRes = await request(app).get('/tasks');
expect(listRes.status).toBe(200);
expect(listRes.body).toHaveLength(1);
});

test('POST /tasks validation error for missing title', async () => {
const res = await request(app).post('/tasks').send({ description: 'no title' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/title is required/);
});

test('PUT /tasks/:id updates task and 404 on invalid id', async () => {
const task = taskService.create({ title: 'Updatable' });
const res = await request(app).put(`/tasks/${task.id}`).send({ status: 'done' });
expect(res.status).toBe(200);
expect(res.body.status).toBe('done');

const res404 = await request(app).put('/tasks/invalid').send({ status: 'done' });
expect(res404.status).toBe(404);
});

test('DELETE /tasks/:id deletes and returns 204', async () => {
const task = taskService.create({ title: 'Removable' });
const res = await request(app).delete(`/tasks/${task.id}`);
expect(res.status).toBe(204);

const res404 = await request(app).delete(`/tasks/${task.id}`);
expect(res404.status).toBe(404);
});

test('PATCH /tasks/:id/complete marks done', async () => {
const task = taskService.create({ title: 'Completable', status: 'in_progress' });
const res = await request(app).patch(`/tasks/${task.id}/complete`);
expect(res.status).toBe(200);
expect(res.body.status).toBe('done');

const res404 = await request(app).patch('/tasks/none/complete');
expect(res404.status).toBe(404);
});

test('PATCH /tasks/:id/assign works and validates', async () => {
const task = taskService.create({ title: 'Assignable' });

const emptyRes = await request(app).patch(`/tasks/${task.id}/assign`).send({ assignee: ' ' });
expect(emptyRes.status).toBe(400);

const notFoundRes = await request(app).patch('/tasks/invalid-id/assign').send({ assignee: 'Alice' });
expect(notFoundRes.status).toBe(404);

const assignRes = await request(app).patch(`/tasks/${task.id}/assign`).send({ assignee: 'Alice' });
expect(assignRes.status).toBe(200);
expect(assignRes.body.assignee).toBe('Alice');

const reassignRes = await request(app).patch(`/tasks/${task.id}/assign`).send({ assignee: 'Bob' });
expect(reassignRes.status).toBe(409);
});

test('GET /tasks?status= and ?page=&limit= and /stats', async () => {
taskService.create({ title: 'A', status: 'todo' });
taskService.create({ title: 'B', status: 'done' });
taskService.create({ title: 'C', status: 'todo' });

const statusRes = await request(app).get('/tasks').query({ status: 'todo' });
expect(statusRes.body).toHaveLength(2);

const pageRes = await request(app).get('/tasks').query({ page: '1', limit: '1' });
expect(pageRes.body).toHaveLength(1);
expect(pageRes.body[0].title).toBe('A');

const statsRes = await request(app).get('/tasks/stats');
expect(statsRes.body.todo).toBe(2);
expect(statsRes.body.done).toBe(1);
});

test('GET /tasks?priority= priority filter works', async () => {
taskService.create({ title: 'Low', priority: 'low' });
taskService.create({ title: 'High', priority: 'high' });

const res = await request(app).get('/tasks').query({ priority: 'high' });
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].priority).toBe('high');
});
});