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

## 1. Pagination skips the first page (fixed)

- Location: `src/services/taskService.js` in `getPaginated`
- Expected behavior: `page=1&limit=2` should return the first two tasks.
- Actual behavior: it returned tasks starting from index 2 (effectively page 2 behavior).
- How discovered: failing unit test in `tests/taskService.test.js` and failing integration test in `tests/tasks.routes.test.js`.
- Root cause: offset was calculated as `page * limit` instead of `(page - 1) * limit`.
- Fix applied: changed offset formula to `(page - 1) * limit`.

## 2. Status filtering is too permissive (not fixed)

- Location: `src/services/taskService.js` in `getByStatus`
- Expected behavior: filtering by status should match exact status values only.
- Actual behavior: it uses substring matching (`includes`), so invalid partial values can match unexpectedly.
- How discovered: code review while writing service tests.
- Suggested fix: replace `t.status.includes(status)` with `t.status === status` and consider validating `status` query values in the route.

## 3. Completing a task overwrites priority (not fixed)

- Location: `src/services/taskService.js` in `completeTask`
- Expected behavior: marking completion should update completion fields, not silently change unrelated task properties.
- Actual behavior: completion always sets `priority` to `medium`.
- How discovered: code review while writing tests for completion behavior.
- Suggested fix: remove forced `priority: 'medium'` from the completion update payload.
37 changes: 37 additions & 0 deletions task-api/SUBMISSION_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Submission Notes

## Coverage Summary

- Command run: `npm run coverage -- --runInBand`
- Result:
- Statements: 93.67%
- Branches: 86.66%
- Functions: 93.33%
- Lines: 93.10%

## Feature Design Decisions: PATCH /tasks/:id/assign

- Validation: `assignee` must be a non-empty string after trim.
- Missing task: returns `404` with `Task not found`.
- Already assigned task: returns `409` with `Task is already assigned`.
- Data model: task now stores `assignee` and defaults to `null` on creation.

## What I’d Test Next

- Validation and behavior for pagination edge cases such as negative page/limit or very large values.
- Behavior when `status` query is invalid (currently no explicit query validation in route).
- Additional API contract tests for date parsing edge cases and timezone-related overdue calculations.
- More branch-focused tests around global error handler behavior.

## What Surprised Me

- A core pagination bug was present in the service offset calculation and was caught quickly by tests.
- Completion logic changes `priority` to `medium`, which is unexpected for a completion action.
- Status filtering uses substring matching, which allows accidental matches rather than strict status checks.

## Questions Before Production

- Should task assignment be reassignable (replace existing assignee), or should unassign/reassign endpoints be added explicitly?
- Should status query values be strictly validated and return `400` for unsupported values?
- Should completion preserve all existing fields except completion metadata (status and completedAt)?
- Should idempotency rules be defined for assignment and completion endpoints?
20 changes: 19 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,22 @@ 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 result = taskService.assignTask(req.params.id, req.body.assignee.trim());
if (result.error === 'not_found') {
return res.status(404).json({ error: 'Task not found' });
}

if (result.error === 'already_assigned') {
return res.status(409).json({ error: 'Task is already assigned' });
}

res.json(result.task);
});

module.exports = router;
23 changes: 22 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,25 @@ const completeTask = (id) => {
return updated;
};

const assignTask = (id, assignee) => {
const index = tasks.findIndex((t) => t.id === id);
if (index === -1) {
return { error: 'not_found' };
}

if (tasks[index].assignee) {
return { error: 'already_assigned' };
}

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

tasks[index] = updated;
return { task: 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 };
129 changes: 129 additions & 0 deletions task-api/tests/taskService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
const taskService = require('../src/services/taskService');

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

describe('create and read operations', () => {
test('create should apply defaults and return new task', () => {
const task = taskService.create({ title: 'Write tests' });

expect(task.id).toBeDefined();
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).toBeDefined();
});

test('getAll should return a copy, not the internal array', () => {
taskService.create({ title: 'Task 1' });
const all = taskService.getAll();

all.push({ id: 'x', title: 'Injected' });

expect(taskService.getAll()).toHaveLength(1);
});

test('findById returns task when it exists', () => {
const created = taskService.create({ title: 'Find me' });

const found = taskService.findById(created.id);

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

describe('filtering and pagination', () => {
test('getByStatus should return only exact status matches', () => {
taskService.create({ title: 'Todo task', status: 'todo' });
taskService.create({ title: 'Done task', status: 'done' });

const tasks = taskService.getByStatus('done');

expect(tasks).toHaveLength(1);
expect(tasks[0].status).toBe('done');
});

test('getPaginated should return first page when page=1', () => {
taskService.create({ title: 'Task 1' });
taskService.create({ title: 'Task 2' });
taskService.create({ title: 'Task 3' });

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

expect(page1).toHaveLength(2);
expect(page1[0].title).toBe('Task 1');
expect(page1[1].title).toBe('Task 2');
});
});

describe('update and delete operations', () => {
test('update should merge fields and return updated task', () => {
const created = taskService.create({ title: 'Old', priority: 'low' });

const updated = taskService.update(created.id, { title: 'New', status: 'in_progress' });

expect(updated).toBeDefined();
expect(updated.title).toBe('New');
expect(updated.status).toBe('in_progress');
expect(updated.priority).toBe('low');
});

test('update should return null for missing task', () => {
const updated = taskService.update('missing-id', { title: 'Nope' });
expect(updated).toBeNull();
});

test('remove should delete existing task and return true', () => {
const created = taskService.create({ title: 'Delete me' });

const deleted = taskService.remove(created.id);

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

test('remove should return false for missing task', () => {
const deleted = taskService.remove('missing-id');
expect(deleted).toBe(false);
});
});

describe('completion and stats', () => {
test('completeTask should mark task as done and set completedAt', () => {
const created = taskService.create({ title: 'Complete me', priority: 'high' });

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

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

test('completeTask should return null for missing task', () => {
const completed = taskService.completeTask('missing-id');
expect(completed).toBeNull();
});

test('getStats should return status counts and overdue count', () => {
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();

taskService.create({ title: 'Todo overdue', status: 'todo', dueDate: yesterday });
taskService.create({ title: 'In progress due later', status: 'in_progress', dueDate: tomorrow });
taskService.create({ title: 'Done overdue but completed', status: 'done', dueDate: yesterday });

const stats = taskService.getStats();

expect(stats.todo).toBe(1);
expect(stats.in_progress).toBe(1);
expect(stats.done).toBe(1);
expect(stats.overdue).toBe(1);
});
});
});
Loading