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

The issues below were identified while writing the service and API tests in `task-api/tests/`.

## 1. Pagination skipped the first page of results

- Location: `task-api/src/services/taskService.js:11-13`
- Expected behavior: `GET /tasks?page=1&limit=2` should return the first two tasks.
- Actual behavior: page 1 returned tasks starting at index 2, so the first page skipped the earliest results.
- How I discovered it: the regression tests in `tests/taskService.test.js` and `tests/tasks.routes.test.js` both failed when asserting that page 1 should contain the first created tasks.
- Why it happened: the pagination offset used `page * limit` instead of `(page - 1) * limit`.
- Fix: updated the offset calculation to `(page - 1) * limit`.
- Status: fixed.

## 2. Status filtering matched partial strings

- Location: `task-api/src/services/taskService.js:9`
- Expected behavior: filtering with `status=todo` should only return tasks whose status is exactly `todo`.
- Actual behavior: filtering used `String.prototype.includes`, so partial strings like `progress` matched `in_progress`.
- How I discovered it: a service-level characterization test showed `getByStatus('progress')` incorrectly returning `in_progress` tasks.
- Why it happened: the filter compared by substring rather than strict equality.
- Fix: changed the comparison to `t.status === status`.
- Status: fixed.

## 3. Completing a task reset its priority

- Location: `task-api/src/services/taskService.js:75-79`
- Expected behavior: marking a task complete should update its status and completion timestamp without changing unrelated fields like priority.
- Actual behavior: `completeTask` forced `priority` to `'medium'`, overwriting the task's existing value.
- How I discovered it: both the service and route tests for `PATCH /tasks/:id/complete` failed when a high-priority task came back as medium priority after completion.
- Why it happened: the completion logic rebuilt the task and hard-coded a new priority value.
- Fix: removed the priority overwrite and preserved the task's current priority.
- Status: fixed.
46 changes: 46 additions & 0 deletions SUBMISSION_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Submission Notes

## Coverage

`npm run coverage -- --runInBand`

| Metric | Result |
| --- | --- |
| Statements | 94.15% |
| Branches | 86.2% |
| Functions | 93.33% |
| Lines | 93.57% |

## What I changed

- Added unit tests for `taskService.js` and integration tests for all task routes with Jest and Supertest.
- Fixed three bugs uncovered by the test suite:
- pagination skipped the first page
- status filtering matched partial strings
- completing a task reset priority to `medium`
- Implemented `PATCH /tasks/:id/assign` with validation for a non-empty assignee string.

## Design decisions for `PATCH /tasks/:id/assign`

- I chose to trim the incoming assignee value before storing it so `" Priya "` is saved as `"Priya"`.
- Empty or whitespace-only assignees return `400` because an assignment with no usable name does not add value and usually indicates a client-side validation miss.
- Reassignment is allowed. If a task is already assigned, sending a new valid assignee updates that value and returns the latest task state.
- New tasks now include `assignee: null` so the task shape stays consistent before and after assignment.

## What I would test next

- Validation behavior for unusual pagination inputs such as negative numbers, `page=0`, and very large limits.
- Full task lifecycle consistency, especially how `PUT /tasks/:id` should behave if a completed task is moved back to another status.
- API behavior under concurrent updates, since the current store is in-memory and mutation-based.

## Anything that surprised me

- The README and the implementation describe different status vocabularies in a few places, so the tests had to follow the actual code paths instead of the prose alone.
- The bugs were small but meaningful, which is exactly why the service-level tests paid off quickly.

## Questions I would ask before shipping to production

- Should the documented status values be `todo / in_progress / done`, or should the implementation change to match the README wording?
- Do we want `PUT` to behave as a full replacement or the current partial-merge behavior?
- Should assignment support unassigning a task, and if so, should that be `null`, an empty payload, or a dedicated endpoint?
- What limits should we enforce on `title`, `description`, and `assignee` length?
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 { validateAssignTask, validateCreateTask, validateUpdateTask } = 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;
29 changes: 25 additions & 4 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 All @@ -28,14 +28,22 @@ 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,
dueDate,
assignee,
completedAt: null,
createdAt: new Date().toISOString(),
};
Expand Down Expand Up @@ -66,7 +74,6 @@ const completeTask = (id) => {

const updated = {
...task,
priority: 'medium',
status: 'done',
completedAt: new Date().toISOString(),
};
Expand All @@ -76,6 +83,19 @@ 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: assignee.trim(),
};

tasks[index] = updated;
return updated;
};

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

module.exports = { validateCreateTask, validateUpdateTask };
const validateAssignTask = (body) => {
if (!body || typeof body.assignee !== 'string' || body.assignee.trim() === '') {
return 'assignee must be a non-empty string';
}

return null;
};

module.exports = { validateCreateTask, validateUpdateTask, validateAssignTask };
159 changes: 159 additions & 0 deletions task-api/tests/taskService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
const taskService = require('../src/services/taskService');

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

describe('create', () => {
test('creates a task with defaults', () => {
const task = taskService.create({
title: 'Write tests',
});

expect(task).toEqual(
expect.objectContaining({
title: 'Write tests',
description: '',
status: 'todo',
priority: 'medium',
dueDate: null,
completedAt: null,
assignee: null,
})
);
expect(task.id).toEqual(expect.any(String));
expect(task.createdAt).toEqual(expect.any(String));
});
});

describe('getByStatus', () => {
test('returns only exact status matches', () => {
const todoTask = taskService.create({ title: 'Todo item', status: 'todo' });
const inProgressTask = taskService.create({ title: 'In progress item', status: 'in_progress' });

const results = taskService.getByStatus('progress');

expect(results).toEqual([]);
expect(results).not.toContain(inProgressTask);
expect(results).not.toContain(todoTask);
});
});

describe('getPaginated', () => {
test('returns the first page when page is 1', () => {
const first = taskService.create({ title: 'First task' });
const second = taskService.create({ title: 'Second task' });
taskService.create({ title: 'Third task' });

const results = taskService.getPaginated(1, 2);

expect(results).toHaveLength(2);
expect(results.map((task) => task.id)).toEqual([first.id, second.id]);
});

test('returns an empty array when the requested page is beyond the data set', () => {
taskService.create({ title: 'Only task' });

const results = taskService.getPaginated(3, 10);

expect(results).toEqual([]);
});
});

describe('getStats', () => {
test('counts tasks by status and overdue tasks', () => {
const overdueDate = new Date(Date.now() - 60_000).toISOString();
const futureDate = new Date(Date.now() + 60_000).toISOString();

taskService.create({ title: 'Todo overdue', status: 'todo', dueDate: overdueDate });
taskService.create({ title: 'In progress', status: 'in_progress', dueDate: futureDate });
taskService.create({ title: 'Done overdue but finished', status: 'done', dueDate: overdueDate });

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

describe('update', () => {
test('updates an existing task', () => {
const task = taskService.create({ title: 'Original title', priority: 'low' });

const updated = taskService.update(task.id, {
title: 'Updated title',
priority: 'high',
});

expect(updated).toEqual(
expect.objectContaining({
id: task.id,
title: 'Updated title',
priority: 'high',
})
);
});

test('returns null when the task does not exist', () => {
expect(taskService.update('missing-id', { title: 'Nope' })).toBeNull();
});
});

describe('remove', () => {
test('removes an existing task', () => {
const task = taskService.create({ title: 'Delete me' });

const removed = taskService.remove(task.id);

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

test('returns false when the task does not exist', () => {
expect(taskService.remove('missing-id')).toBe(false);
});
});

describe('completeTask', () => {
test('marks a task as done without changing its priority', () => {
const task = taskService.create({ title: 'Ship feature', priority: 'high' });

const completed = taskService.completeTask(task.id);

expect(completed).toEqual(
expect.objectContaining({
id: task.id,
status: 'done',
priority: 'high',
})
);
expect(completed.completedAt).toEqual(expect.any(String));
});

test('returns null when the task does not exist', () => {
expect(taskService.completeTask('missing-id')).toBeNull();
});
});

describe('assignTask', () => {
test('stores the trimmed assignee on the task', () => {
const task = taskService.create({ title: 'Assign me' });

const updated = taskService.assignTask(task.id, ' Priya ');

expect(updated).toEqual(
expect.objectContaining({
id: task.id,
assignee: 'Priya',
})
);
});

test('returns null when the task does not exist', () => {
expect(taskService.assignTask('missing-id', 'Priya')).toBeNull();
});
});
});
Loading