From e160a9c9da6ff2f142dde367bde32cbed82e6674 Mon Sep 17 00:00:00 2001 From: Vihaan Aswal Date: Thu, 16 Apr 2026 21:23:55 +0530 Subject: [PATCH 1/6] test: add comprehensive task API unit and integration suite --- task-api/tests/tasks.test.js | 626 +++++++++++++++++++++++++++++++++++ 1 file changed, 626 insertions(+) create mode 100644 task-api/tests/tasks.test.js diff --git a/task-api/tests/tasks.test.js b/task-api/tests/tasks.test.js new file mode 100644 index 00000000..f42f6d40 --- /dev/null +++ b/task-api/tests/tasks.test.js @@ -0,0 +1,626 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +const createSeedTask = (overrides = {}) => { + const suffix = Math.random().toString(16).slice(2, 8); + return taskService.create({ + title: `Task ${suffix}`, + description: 'Seed task', + status: 'todo', + priority: 'medium', + dueDate: null, + ...overrides, + }); +}; + +describe('UNIT TESTS (taskService.js directly)', () => { + beforeEach(() => { + taskService._reset(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('_reset()', () => { + test('clears all tasks from the in-memory store', () => { + createSeedTask({ title: 'First' }); + createSeedTask({ title: 'Second' }); + + taskService._reset(); + + expect(taskService.getAll()).toEqual([]); + }); + + test('keeps the store empty when called on an already empty store', () => { + taskService._reset(); + + expect(taskService.getAll()).toEqual([]); + }); + + test('is idempotent when called multiple times', () => { + createSeedTask({ title: 'Only' }); + + taskService._reset(); + taskService._reset(); + + expect(taskService.getAll()).toEqual([]); + }); + }); + + describe('create()', () => { + test('creates a task with default values when optional fields are omitted', () => { + const task = taskService.create({ title: 'Write tests' }); + + expect(task).toMatchObject({ + title: 'Write tests', + description: '', + status: 'todo', + priority: 'medium', + dueDate: null, + completedAt: null, + }); + expect(task.id).toEqual(expect.any(String)); + expect(task.createdAt).toEqual(expect.any(String)); + expect(Number.isNaN(Date.parse(task.createdAt))).toBe(false); + }); + + test('creates a task using explicitly provided fields', () => { + const dueDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + + const task = taskService.create({ + title: 'Plan sprint', + description: 'Create sprint plan', + status: 'in_progress', + priority: 'high', + dueDate, + }); + + expect(task).toMatchObject({ + title: 'Plan sprint', + description: 'Create sprint plan', + status: 'in_progress', + priority: 'high', + dueDate, + }); + }); + + test('generates unique ids for consecutive tasks', () => { + const first = taskService.create({ title: 'A' }); + const second = taskService.create({ title: 'B' }); + + expect(first.id).not.toBe(second.id); + }); + }); + + describe('getAll()', () => { + test('returns all tasks in insertion order', () => { + const first = createSeedTask({ title: 'First' }); + const second = createSeedTask({ title: 'Second' }); + + const all = taskService.getAll(); + + expect(all).toHaveLength(2); + expect(all[0].id).toBe(first.id); + expect(all[1].id).toBe(second.id); + }); + + test('returns an empty array when no tasks exist', () => { + expect(taskService.getAll()).toEqual([]); + }); + + test('does not allow mutation of internal collection via returned array operations', () => { + createSeedTask({ title: 'Immutable list check' }); + + const tasks = taskService.getAll(); + tasks.pop(); + + expect(taskService.getAll()).toHaveLength(1); + }); + }); + + describe('findById()', () => { + test('returns the task when id exists', () => { + const task = createSeedTask({ title: 'Find me' }); + + const found = taskService.findById(task.id); + + expect(found).toBeDefined(); + expect(found.id).toBe(task.id); + }); + + test('returns undefined for a non-existent id', () => { + expect(taskService.findById('missing-id')).toBeUndefined(); + }); + + test('returns undefined when store is empty', () => { + taskService._reset(); + + expect(taskService.findById('any-id')).toBeUndefined(); + }); + }); + + describe('getByStatus()', () => { + test('returns tasks that exactly match a valid status', () => { + createSeedTask({ title: 'Todo task', status: 'todo' }); + createSeedTask({ title: 'Done task', status: 'done' }); + + const filtered = taskService.getByStatus('todo'); + + expect(filtered).toHaveLength(1); + expect(filtered[0].status).toBe('todo'); + }); + + test('returns an empty array when no tasks match the status', () => { + createSeedTask({ status: 'done' }); + + const filtered = taskService.getByStatus('in_progress'); + + expect(filtered).toEqual([]); + }); + + // Known bug: status filtering uses substring matching (includes) instead of exact matching. + test('does not match partial status fragments', () => { + createSeedTask({ status: 'todo' }); + createSeedTask({ status: 'done' }); + + const filtered = taskService.getByStatus('do'); + + expect(filtered).toEqual([]); + }); + }); + + describe('getPaginated()', () => { + test('returns an empty array when requesting beyond available pages', () => { + createSeedTask({ title: 'One' }); + createSeedTask({ title: 'Two' }); + + const page = taskService.getPaginated(10, 5); + + expect(page).toEqual([]); + }); + + test('returns all tasks for page 0 when limit exceeds total tasks', () => { + createSeedTask({ title: 'One' }); + createSeedTask({ title: 'Two' }); + + const page = taskService.getPaginated(0, 10); + + expect(page).toHaveLength(2); + }); + + // Known bug: offset is computed as page * limit, so page=1 skips the first page. + test('returns the first page of tasks when page=1 and limit=2', () => { + const created = [ + createSeedTask({ title: 'T1' }), + createSeedTask({ title: 'T2' }), + createSeedTask({ title: 'T3' }), + ]; + + const page = taskService.getPaginated(1, 2); + + expect(page.map((t) => t.id)).toEqual([created[0].id, created[1].id]); + }); + }); + + describe('getStats()', () => { + test('returns correct counts by status on happy path', () => { + createSeedTask({ status: 'todo' }); + createSeedTask({ status: 'in_progress' }); + createSeedTask({ status: 'done' }); + + const stats = taskService.getStats(); + + expect(stats).toEqual({ + todo: 1, + in_progress: 1, + done: 1, + overdue: 0, + }); + }); + + test('returns zero counts when there are no tasks', () => { + const stats = taskService.getStats(); + + expect(stats).toEqual({ + todo: 0, + in_progress: 0, + done: 0, + overdue: 0, + }); + }); + + test('counts overdue tasks only when dueDate is in the past and task is not done', () => { + const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + createSeedTask({ status: 'todo', dueDate: pastDate }); + createSeedTask({ status: 'done', dueDate: pastDate }); + + const stats = taskService.getStats(); + + expect(stats.overdue).toBe(1); + }); + }); + + describe('update()', () => { + test('updates provided fields and preserves unspecified fields', () => { + const task = createSeedTask({ + title: 'Original', + description: 'Original description', + priority: 'low', + }); + + const updated = taskService.update(task.id, { title: 'Updated title' }); + + expect(updated.title).toBe('Updated title'); + expect(updated.description).toBe('Original description'); + expect(updated.priority).toBe('low'); + }); + + test('returns null for a non-existent id', () => { + const result = taskService.update('missing-id', { title: 'Nope' }); + + expect(result).toBeNull(); + }); + + // Known bug: update allows immutable/system fields like createdAt to be overwritten. + test('does not allow createdAt to be overwritten by client updates', () => { + const task = createSeedTask({ title: 'Immutable field check' }); + const originalCreatedAt = task.createdAt; + + const updated = taskService.update(task.id, { + createdAt: '2000-01-01T00:00:00.000Z', + }); + + expect(updated.createdAt).toBe(originalCreatedAt); + }); + }); + + describe('remove()', () => { + test('removes an existing task and returns true', () => { + const task = createSeedTask({ title: 'Delete me' }); + + const removed = taskService.remove(task.id); + + expect(removed).toBe(true); + expect(taskService.findById(task.id)).toBeUndefined(); + }); + + test('returns false when trying to remove a non-existent task', () => { + expect(taskService.remove('missing-id')).toBe(false); + }); + + test('returns true on first removal and false on second removal for same id', () => { + const task = createSeedTask({ title: 'Remove twice' }); + + const firstRemoval = taskService.remove(task.id); + const secondRemoval = taskService.remove(task.id); + + expect(firstRemoval).toBe(true); + expect(secondRemoval).toBe(false); + }); + }); + + describe('completeTask()', () => { + test('marks a task as done and sets completedAt timestamp', () => { + const task = createSeedTask({ status: 'todo' }); + + const completed = taskService.completeTask(task.id); + + expect(completed.status).toBe('done'); + expect(completed.completedAt).toEqual(expect.any(String)); + expect(Number.isNaN(Date.parse(completed.completedAt))).toBe(false); + }); + + test('returns null for a non-existent id', () => { + expect(taskService.completeTask('missing-id')).toBeNull(); + }); + + // Known bug: completing a task overwrites existing priority with medium. + test('preserves existing priority when completing a task', () => { + const task = createSeedTask({ priority: 'high' }); + + const completed = taskService.completeTask(task.id); + + expect(completed.priority).toBe('high'); + }); + + // Known bug: re-completing a task rewrites completedAt instead of keeping first completion timestamp. + test('does not change completedAt when task is already completed', () => { + jest.useFakeTimers(); + const task = createSeedTask({ status: 'todo' }); + + jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + const first = taskService.completeTask(task.id); + + jest.setSystemTime(new Date('2026-01-02T00:00:00.000Z')); + const second = taskService.completeTask(task.id); + + expect(second.completedAt).toBe(first.completedAt); + }); + }); +}); + +describe('INTEGRATION TESTS (API routes via Supertest)', () => { + beforeEach(() => { + taskService._reset(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('GET /tasks', () => { + test('returns all tasks on happy path', async () => { + createSeedTask({ title: 'A' }); + createSeedTask({ title: 'B' }); + + const response = await request(app).get('/tasks'); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + }); + + test('returns tasks filtered by status query', async () => { + createSeedTask({ title: 'Todo task', status: 'todo' }); + createSeedTask({ title: 'Done task', status: 'done' }); + + const response = await request(app).get('/tasks').query({ status: 'todo' }); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(1); + expect(response.body[0].status).toBe('todo'); + }); + + // Known bug: pagination branch uses incorrect offset and skips first page records. + test('returns first page when using pagination query params', async () => { + const created = [ + createSeedTask({ title: 'T1' }), + createSeedTask({ title: 'T2' }), + createSeedTask({ title: 'T3' }), + ]; + + const response = await request(app).get('/tasks').query({ page: 1, limit: 2 }); + + expect(response.status).toBe(200); + expect(response.body.map((t) => t.id)).toEqual([created[0].id, created[1].id]); + }); + + // Known bug: invalid status values are not validated and currently return 200. + test('returns 400 for invalid status filter value', async () => { + const response = await request(app).get('/tasks').query({ status: 'pending' }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + + // Known bug: invalid page values are silently coerced instead of rejected. + test('returns 400 for invalid page query value', async () => { + const response = await request(app).get('/tasks').query({ page: 'abc', limit: 10 }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + + // Known bug: invalid limit values are silently coerced instead of rejected. + test('returns 400 for invalid limit query value', async () => { + const response = await request(app).get('/tasks').query({ page: 1, limit: 0 }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + }); + + describe('POST /tasks', () => { + test('creates a task on happy path', async () => { + const response = await request(app) + .post('/tasks') + .send({ title: 'Write tests', priority: 'high' }); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ + title: 'Write tests', + priority: 'high', + status: 'todo', + }); + expect(response.body.id).toEqual(expect.any(String)); + }); + + test('returns 400 when title is missing', async () => { + const response = await request(app) + .post('/tasks') + .send({ priority: 'high' }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + + test('returns 400 when priority is invalid', async () => { + const response = await request(app) + .post('/tasks') + .send({ title: 'Task', priority: 'urgent' }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatch(/priority/i); + }); + + test('returns 400 when dueDate format is invalid', async () => { + const response = await request(app) + .post('/tasks') + .send({ title: 'Task', dueDate: 'not-an-iso-date' }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatch(/dueDate/i); + }); + }); + + describe('PUT /tasks/:id', () => { + test('updates a task on happy path', async () => { + const task = createSeedTask({ title: 'Before update', priority: 'low' }); + + const response = await request(app) + .put(`/tasks/${task.id}`) + .send({ title: 'After update', priority: 'high' }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + id: task.id, + title: 'After update', + priority: 'high', + }); + }); + + test('returns 404 for non-existent task id', async () => { + const response = await request(app) + .put('/tasks/does-not-exist') + .send({ title: 'No task here' }); + + expect(response.status).toBe(404); + expect(response.body.error).toMatch(/not found/i); + }); + + // Known bug: empty update payload is currently accepted instead of rejected. + test('returns 400 when required update fields are missing', async () => { + const task = createSeedTask({ title: 'Needs update fields' }); + + const response = await request(app) + .put(`/tasks/${task.id}`) + .send({}); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + }); + + describe('DELETE /tasks/:id', () => { + test('deletes a task and returns 204 with no body', async () => { + const task = createSeedTask({ title: 'Delete me' }); + + const response = await request(app).delete(`/tasks/${task.id}`); + + expect(response.status).toBe(204); + expect(response.text).toBe(''); + expect(taskService.findById(task.id)).toBeUndefined(); + }); + + test('returns 404 for non-existent task id', async () => { + const response = await request(app).delete('/tasks/not-found-id'); + + expect(response.status).toBe(404); + expect(response.body.error).toMatch(/not found/i); + }); + }); + + describe('PATCH /tasks/:id/complete', () => { + test('marks task complete on happy path', async () => { + const task = createSeedTask({ status: 'todo', priority: 'high' }); + + const response = await request(app).patch(`/tasks/${task.id}/complete`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('done'); + expect(response.body.completedAt).toEqual(expect.any(String)); + expect(Number.isNaN(Date.parse(response.body.completedAt))).toBe(false); + }); + + // Known bug: completing an already completed task rewrites completedAt. + test('does not rewrite completedAt when task is already completed', async () => { + const task = createSeedTask({ status: 'todo' }); + + const first = await request(app).patch(`/tasks/${task.id}/complete`); + await new Promise((resolve) => setTimeout(resolve, 10)); + const second = await request(app).patch(`/tasks/${task.id}/complete`); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(second.body.completedAt).toBe(first.body.completedAt); + }); + + test('returns 404 for non-existent task id', async () => { + const response = await request(app).patch('/tasks/not-found-id/complete'); + + expect(response.status).toBe(404); + expect(response.body.error).toMatch(/not found/i); + }); + }); + + describe('GET /tasks/stats', () => { + test('returns status counts on happy path', async () => { + createSeedTask({ status: 'todo' }); + createSeedTask({ status: 'in_progress' }); + createSeedTask({ status: 'done' }); + + const response = await request(app).get('/tasks/stats'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + todo: 1, + in_progress: 1, + done: 1, + overdue: 0, + }); + }); + + test('includes overdue count when overdue tasks are present', async () => { + const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + createSeedTask({ status: 'todo', dueDate: pastDate }); + createSeedTask({ status: 'done', dueDate: pastDate }); + + const response = await request(app).get('/tasks/stats'); + + expect(response.status).toBe(200); + expect(response.body.overdue).toBe(1); + }); + }); + + describe('PATCH /tasks/:id/assign', () => { + // TDD: endpoint does not exist yet; these tests define required behavior first. + test('assigns a task to a user on happy path', async () => { + const task = createSeedTask({ title: 'Assignable task' }); + + const response = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: 'Alice' }); + + expect(response.status).toBe(200); + expect(response.body.assignee).toBe('Alice'); + expect(response.body.id).toBe(task.id); + }); + + // TDD: validation behavior expected once endpoint is implemented. + test('returns 400 when assignee is an empty string', async () => { + const task = createSeedTask({ title: 'Validation task' }); + + const response = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: '' }); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('error'); + }); + + test('returns 404 when assigning a non-existent task', async () => { + const response = await request(app) + .patch('/tasks/missing-id/assign') + .send({ assignee: 'Bob' }); + + expect(response.status).toBe(404); + expect(response.body).toHaveProperty('error'); + }); + + // TDD: conflict behavior expected once endpoint is implemented. + test('returns 409 when task is already assigned', async () => { + const task = createSeedTask({ title: 'Already assigned task' }); + taskService.update(task.id, { assignee: 'Eve' }); + + const response = await request(app) + .patch(`/tasks/${task.id}/assign`) + .send({ assignee: 'Mallory' }); + + expect(response.status).toBe(409); + expect(response.body).toHaveProperty('error'); + }); + }); +}); From b6f5f467415587a1e4f4f8ee643b9c73c5690d3c Mon Sep 17 00:00:00 2001 From: Vihaan Aswal Date: Thu, 16 Apr 2026 21:46:55 +0530 Subject: [PATCH 2/6] fix: preserve priority and make completeTask idempotent --- task-api/src/services/taskService.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index f8e89189..6acae6c2 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -64,9 +64,14 @@ const completeTask = (id) => { const task = findById(id); if (!task) return null; + // GUARD: Already-completed tasks are returned unchanged (idempotent). + if (task.status === 'done') return task; + + // FIX: Removed hardcoded priority reset — completing a task should + // preserve its existing priority, not silently downgrade it. + const updated = { ...task, - priority: 'medium', status: 'done', completedAt: new Date().toISOString(), }; From 3633b709d36b4dcb8d235715ee3cf2e782236192 Mon Sep 17 00:00:00 2001 From: Vihaan Aswal Date: Thu, 16 Apr 2026 21:55:09 +0530 Subject: [PATCH 3/6] feat: add assignee model, validation, and assign endpoint --- task-api/src/routes/tasks.js | 25 ++++++++++++++++++++++++- task-api/src/services/taskService.js | 20 ++++++++++++++++++++ task-api/src/utils/validators.js | 26 +++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/task-api/src/routes/tasks.js b/task-api/src/routes/tasks.js index e8c370fe..c56ad313 100644 --- a/task-api/src/routes/tasks.js +++ b/task-api/src/routes/tasks.js @@ -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(); @@ -69,4 +69,27 @@ router.patch('/:id/complete', (req, res) => { res.json(task); }); +/* + * PATCH /tasks/:id/assign + * Assigns a task to a named person. + * Design decisions: + * - Reassignment is allowed (overwrites existing assignee). + * - Assignee is trimmed server-side to avoid whitespace inconsistencies. + * - No user list validation — assignee is a free-form string by design. + * (In production, this would validate against a users collection.) + */ +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.trim()); + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + res.status(200).json(task); +}); + module.exports = router; diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index 6acae6c2..0d9d8dbf 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -36,6 +36,8 @@ const create = ({ title, description = '', status = 'todo', priority = 'medium', status, priority, dueDate, + // assignee: null means unassigned + assignee: null, completedAt: null, createdAt: new Date().toISOString(), }; @@ -81,6 +83,23 @@ const completeTask = (id) => { return updated; }; +// assignTask: stores an assignee name on a task. +// Overwrites existing assignee — reassignment is intentionally allowed. +// Trimming handles accidental whitespace from API consumers. +const assignTask = (id, assignee) => { + const task = findById(id); + if (!task) return null; + + const updated = { + ...task, + assignee: assignee.trim(), + }; + + const index = tasks.findIndex((t) => t.id === id); + tasks[index] = updated; + return updated; +}; + const _reset = () => { tasks = []; }; @@ -95,5 +114,6 @@ module.exports = { update, remove, completeTask, + assignTask, _reset, }; diff --git a/task-api/src/utils/validators.js b/task-api/src/utils/validators.js index 1e908ff5..7d2c6b1b 100644 --- a/task-api/src/utils/validators.js +++ b/task-api/src/utils/validators.js @@ -33,4 +33,28 @@ const validateUpdateTask = (body) => { return null; }; -module.exports = { validateCreateTask, validateUpdateTask }; +/* + * validateAssignTask: validates the body for PATCH /tasks/:id/assign. + * Rejects null, non-string, empty, whitespace-only, and excessively long names. + * The 100-char limit is a reasonable guard against unbounded input. + */ +const validateAssignTask = (body) => { + if (body === null || body === undefined) { + return 'Request body is required'; + } + if (body.assignee === undefined) { + return 'assignee is required'; + } + if (typeof body.assignee !== 'string') { + return 'assignee must be a string'; + } + if (body.assignee.trim() === '') { + return 'assignee cannot be empty or whitespace'; + } + if (body.assignee.trim().length > 100) { + return 'assignee name cannot exceed 100 characters'; + } + return null; +}; + +module.exports = { validateCreateTask, validateUpdateTask, validateAssignTask }; From 2dc15168854fe356c98834aa7e23b53bebe7ce33 Mon Sep 17 00:00:00 2001 From: Vihaan Aswal Date: Thu, 16 Apr 2026 21:56:36 +0530 Subject: [PATCH 4/6] test: allow reassignment in assign endpoint --- task-api/tests/tasks.test.js | 443 ++++++++++++++++++----------------- 1 file changed, 227 insertions(+), 216 deletions(-) diff --git a/task-api/tests/tasks.test.js b/task-api/tests/tasks.test.js index f42f6d40..2601a393 100644 --- a/task-api/tests/tasks.test.js +++ b/task-api/tests/tasks.test.js @@ -1,20 +1,20 @@ -const request = require('supertest'); -const app = require('../src/app'); -const taskService = require('../src/services/taskService'); +const request = require("supertest"); +const app = require("../src/app"); +const taskService = require("../src/services/taskService"); const createSeedTask = (overrides = {}) => { const suffix = Math.random().toString(16).slice(2, 8); return taskService.create({ title: `Task ${suffix}`, - description: 'Seed task', - status: 'todo', - priority: 'medium', + description: "Seed task", + status: "todo", + priority: "medium", dueDate: null, ...overrides, }); }; -describe('UNIT TESTS (taskService.js directly)', () => { +describe("UNIT TESTS (taskService.js directly)", () => { beforeEach(() => { taskService._reset(); }); @@ -23,24 +23,24 @@ describe('UNIT TESTS (taskService.js directly)', () => { jest.useRealTimers(); }); - describe('_reset()', () => { - test('clears all tasks from the in-memory store', () => { - createSeedTask({ title: 'First' }); - createSeedTask({ title: 'Second' }); + describe("_reset()", () => { + test("clears all tasks from the in-memory store", () => { + createSeedTask({ title: "First" }); + createSeedTask({ title: "Second" }); taskService._reset(); expect(taskService.getAll()).toEqual([]); }); - test('keeps the store empty when called on an already empty store', () => { + test("keeps the store empty when called on an already empty store", () => { taskService._reset(); expect(taskService.getAll()).toEqual([]); }); - test('is idempotent when called multiple times', () => { - createSeedTask({ title: 'Only' }); + test("is idempotent when called multiple times", () => { + createSeedTask({ title: "Only" }); taskService._reset(); taskService._reset(); @@ -49,15 +49,15 @@ describe('UNIT TESTS (taskService.js directly)', () => { }); }); - describe('create()', () => { - test('creates a task with default values when optional fields are omitted', () => { - const task = taskService.create({ title: 'Write tests' }); + describe("create()", () => { + test("creates a task with default values when optional fields are omitted", () => { + const task = taskService.create({ title: "Write tests" }); expect(task).toMatchObject({ - title: 'Write tests', - description: '', - status: 'todo', - priority: 'medium', + title: "Write tests", + description: "", + status: "todo", + priority: "medium", dueDate: null, completedAt: null, }); @@ -66,38 +66,38 @@ describe('UNIT TESTS (taskService.js directly)', () => { expect(Number.isNaN(Date.parse(task.createdAt))).toBe(false); }); - test('creates a task using explicitly provided fields', () => { + test("creates a task using explicitly provided fields", () => { const dueDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); const task = taskService.create({ - title: 'Plan sprint', - description: 'Create sprint plan', - status: 'in_progress', - priority: 'high', + title: "Plan sprint", + description: "Create sprint plan", + status: "in_progress", + priority: "high", dueDate, }); expect(task).toMatchObject({ - title: 'Plan sprint', - description: 'Create sprint plan', - status: 'in_progress', - priority: 'high', + title: "Plan sprint", + description: "Create sprint plan", + status: "in_progress", + priority: "high", dueDate, }); }); - test('generates unique ids for consecutive tasks', () => { - const first = taskService.create({ title: 'A' }); - const second = taskService.create({ title: 'B' }); + test("generates unique ids for consecutive tasks", () => { + const first = taskService.create({ title: "A" }); + const second = taskService.create({ title: "B" }); expect(first.id).not.toBe(second.id); }); }); - describe('getAll()', () => { - test('returns all tasks in insertion order', () => { - const first = createSeedTask({ title: 'First' }); - const second = createSeedTask({ title: 'Second' }); + describe("getAll()", () => { + test("returns all tasks in insertion order", () => { + const first = createSeedTask({ title: "First" }); + const second = createSeedTask({ title: "Second" }); const all = taskService.getAll(); @@ -106,12 +106,12 @@ describe('UNIT TESTS (taskService.js directly)', () => { expect(all[1].id).toBe(second.id); }); - test('returns an empty array when no tasks exist', () => { + test("returns an empty array when no tasks exist", () => { expect(taskService.getAll()).toEqual([]); }); - test('does not allow mutation of internal collection via returned array operations', () => { - createSeedTask({ title: 'Immutable list check' }); + test("does not allow mutation of internal collection via returned array operations", () => { + createSeedTask({ title: "Immutable list check" }); const tasks = taskService.getAll(); tasks.pop(); @@ -120,9 +120,9 @@ describe('UNIT TESTS (taskService.js directly)', () => { }); }); - describe('findById()', () => { - test('returns the task when id exists', () => { - const task = createSeedTask({ title: 'Find me' }); + describe("findById()", () => { + test("returns the task when id exists", () => { + const task = createSeedTask({ title: "Find me" }); const found = taskService.findById(task.id); @@ -130,60 +130,60 @@ describe('UNIT TESTS (taskService.js directly)', () => { expect(found.id).toBe(task.id); }); - test('returns undefined for a non-existent id', () => { - expect(taskService.findById('missing-id')).toBeUndefined(); + test("returns undefined for a non-existent id", () => { + expect(taskService.findById("missing-id")).toBeUndefined(); }); - test('returns undefined when store is empty', () => { + test("returns undefined when store is empty", () => { taskService._reset(); - expect(taskService.findById('any-id')).toBeUndefined(); + expect(taskService.findById("any-id")).toBeUndefined(); }); }); - describe('getByStatus()', () => { - test('returns tasks that exactly match a valid status', () => { - createSeedTask({ title: 'Todo task', status: 'todo' }); - createSeedTask({ title: 'Done task', status: 'done' }); + describe("getByStatus()", () => { + test("returns tasks that exactly match a valid status", () => { + createSeedTask({ title: "Todo task", status: "todo" }); + createSeedTask({ title: "Done task", status: "done" }); - const filtered = taskService.getByStatus('todo'); + const filtered = taskService.getByStatus("todo"); expect(filtered).toHaveLength(1); - expect(filtered[0].status).toBe('todo'); + expect(filtered[0].status).toBe("todo"); }); - test('returns an empty array when no tasks match the status', () => { - createSeedTask({ status: 'done' }); + test("returns an empty array when no tasks match the status", () => { + createSeedTask({ status: "done" }); - const filtered = taskService.getByStatus('in_progress'); + const filtered = taskService.getByStatus("in_progress"); expect(filtered).toEqual([]); }); // Known bug: status filtering uses substring matching (includes) instead of exact matching. - test('does not match partial status fragments', () => { - createSeedTask({ status: 'todo' }); - createSeedTask({ status: 'done' }); + test("does not match partial status fragments", () => { + createSeedTask({ status: "todo" }); + createSeedTask({ status: "done" }); - const filtered = taskService.getByStatus('do'); + const filtered = taskService.getByStatus("do"); expect(filtered).toEqual([]); }); }); - describe('getPaginated()', () => { - test('returns an empty array when requesting beyond available pages', () => { - createSeedTask({ title: 'One' }); - createSeedTask({ title: 'Two' }); + describe("getPaginated()", () => { + test("returns an empty array when requesting beyond available pages", () => { + createSeedTask({ title: "One" }); + createSeedTask({ title: "Two" }); const page = taskService.getPaginated(10, 5); expect(page).toEqual([]); }); - test('returns all tasks for page 0 when limit exceeds total tasks', () => { - createSeedTask({ title: 'One' }); - createSeedTask({ title: 'Two' }); + test("returns all tasks for page 0 when limit exceeds total tasks", () => { + createSeedTask({ title: "One" }); + createSeedTask({ title: "Two" }); const page = taskService.getPaginated(0, 10); @@ -191,11 +191,11 @@ describe('UNIT TESTS (taskService.js directly)', () => { }); // Known bug: offset is computed as page * limit, so page=1 skips the first page. - test('returns the first page of tasks when page=1 and limit=2', () => { + test("returns the first page of tasks when page=1 and limit=2", () => { const created = [ - createSeedTask({ title: 'T1' }), - createSeedTask({ title: 'T2' }), - createSeedTask({ title: 'T3' }), + createSeedTask({ title: "T1" }), + createSeedTask({ title: "T2" }), + createSeedTask({ title: "T3" }), ]; const page = taskService.getPaginated(1, 2); @@ -204,11 +204,11 @@ describe('UNIT TESTS (taskService.js directly)', () => { }); }); - describe('getStats()', () => { - test('returns correct counts by status on happy path', () => { - createSeedTask({ status: 'todo' }); - createSeedTask({ status: 'in_progress' }); - createSeedTask({ status: 'done' }); + describe("getStats()", () => { + test("returns correct counts by status on happy path", () => { + createSeedTask({ status: "todo" }); + createSeedTask({ status: "in_progress" }); + createSeedTask({ status: "done" }); const stats = taskService.getStats(); @@ -220,7 +220,7 @@ describe('UNIT TESTS (taskService.js directly)', () => { }); }); - test('returns zero counts when there are no tasks', () => { + test("returns zero counts when there are no tasks", () => { const stats = taskService.getStats(); expect(stats).toEqual({ @@ -231,10 +231,10 @@ describe('UNIT TESTS (taskService.js directly)', () => { }); }); - test('counts overdue tasks only when dueDate is in the past and task is not done', () => { + test("counts overdue tasks only when dueDate is in the past and task is not done", () => { const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - createSeedTask({ status: 'todo', dueDate: pastDate }); - createSeedTask({ status: 'done', dueDate: pastDate }); + createSeedTask({ status: "todo", dueDate: pastDate }); + createSeedTask({ status: "done", dueDate: pastDate }); const stats = taskService.getStats(); @@ -242,43 +242,43 @@ describe('UNIT TESTS (taskService.js directly)', () => { }); }); - describe('update()', () => { - test('updates provided fields and preserves unspecified fields', () => { + describe("update()", () => { + test("updates provided fields and preserves unspecified fields", () => { const task = createSeedTask({ - title: 'Original', - description: 'Original description', - priority: 'low', + title: "Original", + description: "Original description", + priority: "low", }); - const updated = taskService.update(task.id, { title: 'Updated title' }); + const updated = taskService.update(task.id, { title: "Updated title" }); - expect(updated.title).toBe('Updated title'); - expect(updated.description).toBe('Original description'); - expect(updated.priority).toBe('low'); + expect(updated.title).toBe("Updated title"); + expect(updated.description).toBe("Original description"); + expect(updated.priority).toBe("low"); }); - test('returns null for a non-existent id', () => { - const result = taskService.update('missing-id', { title: 'Nope' }); + test("returns null for a non-existent id", () => { + const result = taskService.update("missing-id", { title: "Nope" }); expect(result).toBeNull(); }); // Known bug: update allows immutable/system fields like createdAt to be overwritten. - test('does not allow createdAt to be overwritten by client updates', () => { - const task = createSeedTask({ title: 'Immutable field check' }); + test("does not allow createdAt to be overwritten by client updates", () => { + const task = createSeedTask({ title: "Immutable field check" }); const originalCreatedAt = task.createdAt; const updated = taskService.update(task.id, { - createdAt: '2000-01-01T00:00:00.000Z', + createdAt: "2000-01-01T00:00:00.000Z", }); expect(updated.createdAt).toBe(originalCreatedAt); }); }); - describe('remove()', () => { - test('removes an existing task and returns true', () => { - const task = createSeedTask({ title: 'Delete me' }); + describe("remove()", () => { + test("removes an existing task and returns true", () => { + const task = createSeedTask({ title: "Delete me" }); const removed = taskService.remove(task.id); @@ -286,12 +286,12 @@ describe('UNIT TESTS (taskService.js directly)', () => { expect(taskService.findById(task.id)).toBeUndefined(); }); - test('returns false when trying to remove a non-existent task', () => { - expect(taskService.remove('missing-id')).toBe(false); + test("returns false when trying to remove a non-existent task", () => { + expect(taskService.remove("missing-id")).toBe(false); }); - test('returns true on first removal and false on second removal for same id', () => { - const task = createSeedTask({ title: 'Remove twice' }); + test("returns true on first removal and false on second removal for same id", () => { + const task = createSeedTask({ title: "Remove twice" }); const firstRemoval = taskService.remove(task.id); const secondRemoval = taskService.remove(task.id); @@ -301,39 +301,39 @@ describe('UNIT TESTS (taskService.js directly)', () => { }); }); - describe('completeTask()', () => { - test('marks a task as done and sets completedAt timestamp', () => { - const task = createSeedTask({ status: 'todo' }); + describe("completeTask()", () => { + test("marks a task as done and sets completedAt timestamp", () => { + const task = createSeedTask({ status: "todo" }); const completed = taskService.completeTask(task.id); - expect(completed.status).toBe('done'); + expect(completed.status).toBe("done"); expect(completed.completedAt).toEqual(expect.any(String)); expect(Number.isNaN(Date.parse(completed.completedAt))).toBe(false); }); - test('returns null for a non-existent id', () => { - expect(taskService.completeTask('missing-id')).toBeNull(); + test("returns null for a non-existent id", () => { + expect(taskService.completeTask("missing-id")).toBeNull(); }); // Known bug: completing a task overwrites existing priority with medium. - test('preserves existing priority when completing a task', () => { - const task = createSeedTask({ priority: 'high' }); + test("preserves existing priority when completing a task", () => { + const task = createSeedTask({ priority: "high" }); const completed = taskService.completeTask(task.id); - expect(completed.priority).toBe('high'); + expect(completed.priority).toBe("high"); }); // Known bug: re-completing a task rewrites completedAt instead of keeping first completion timestamp. - test('does not change completedAt when task is already completed', () => { + test("does not change completedAt when task is already completed", () => { jest.useFakeTimers(); - const task = createSeedTask({ status: 'todo' }); + const task = createSeedTask({ status: "todo" }); - jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + jest.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); const first = taskService.completeTask(task.id); - jest.setSystemTime(new Date('2026-01-02T00:00:00.000Z')); + jest.setSystemTime(new Date("2026-01-02T00:00:00.000Z")); const second = taskService.completeTask(task.id); expect(second.completedAt).toBe(first.completedAt); @@ -341,7 +341,7 @@ describe('UNIT TESTS (taskService.js directly)', () => { }); }); -describe('INTEGRATION TESTS (API routes via Supertest)', () => { +describe("INTEGRATION TESTS (API routes via Supertest)", () => { beforeEach(() => { taskService._reset(); }); @@ -350,183 +350,194 @@ describe('INTEGRATION TESTS (API routes via Supertest)', () => { jest.useRealTimers(); }); - describe('GET /tasks', () => { - test('returns all tasks on happy path', async () => { - createSeedTask({ title: 'A' }); - createSeedTask({ title: 'B' }); + describe("GET /tasks", () => { + test("returns all tasks on happy path", async () => { + createSeedTask({ title: "A" }); + createSeedTask({ title: "B" }); - const response = await request(app).get('/tasks'); + const response = await request(app).get("/tasks"); expect(response.status).toBe(200); expect(Array.isArray(response.body)).toBe(true); expect(response.body).toHaveLength(2); }); - test('returns tasks filtered by status query', async () => { - createSeedTask({ title: 'Todo task', status: 'todo' }); - createSeedTask({ title: 'Done task', status: 'done' }); + test("returns tasks filtered by status query", async () => { + createSeedTask({ title: "Todo task", status: "todo" }); + createSeedTask({ title: "Done task", status: "done" }); - const response = await request(app).get('/tasks').query({ status: 'todo' }); + const response = await request(app) + .get("/tasks") + .query({ status: "todo" }); expect(response.status).toBe(200); expect(response.body).toHaveLength(1); - expect(response.body[0].status).toBe('todo'); + expect(response.body[0].status).toBe("todo"); }); // Known bug: pagination branch uses incorrect offset and skips first page records. - test('returns first page when using pagination query params', async () => { + test("returns first page when using pagination query params", async () => { const created = [ - createSeedTask({ title: 'T1' }), - createSeedTask({ title: 'T2' }), - createSeedTask({ title: 'T3' }), + createSeedTask({ title: "T1" }), + createSeedTask({ title: "T2" }), + createSeedTask({ title: "T3" }), ]; - const response = await request(app).get('/tasks').query({ page: 1, limit: 2 }); + const response = await request(app) + .get("/tasks") + .query({ page: 1, limit: 2 }); expect(response.status).toBe(200); - expect(response.body.map((t) => t.id)).toEqual([created[0].id, created[1].id]); + expect(response.body.map((t) => t.id)).toEqual([ + created[0].id, + created[1].id, + ]); }); // Known bug: invalid status values are not validated and currently return 200. - test('returns 400 for invalid status filter value', async () => { - const response = await request(app).get('/tasks').query({ status: 'pending' }); + test("returns 400 for invalid status filter value", async () => { + const response = await request(app) + .get("/tasks") + .query({ status: "pending" }); expect(response.status).toBe(400); - expect(response.body).toHaveProperty('error'); + expect(response.body).toHaveProperty("error"); }); // Known bug: invalid page values are silently coerced instead of rejected. - test('returns 400 for invalid page query value', async () => { - const response = await request(app).get('/tasks').query({ page: 'abc', limit: 10 }); + test("returns 400 for invalid page query value", async () => { + const response = await request(app) + .get("/tasks") + .query({ page: "abc", limit: 10 }); expect(response.status).toBe(400); - expect(response.body).toHaveProperty('error'); + expect(response.body).toHaveProperty("error"); }); // Known bug: invalid limit values are silently coerced instead of rejected. - test('returns 400 for invalid limit query value', async () => { - const response = await request(app).get('/tasks').query({ page: 1, limit: 0 }); + test("returns 400 for invalid limit query value", async () => { + const response = await request(app) + .get("/tasks") + .query({ page: 1, limit: 0 }); expect(response.status).toBe(400); - expect(response.body).toHaveProperty('error'); + expect(response.body).toHaveProperty("error"); }); }); - describe('POST /tasks', () => { - test('creates a task on happy path', async () => { + describe("POST /tasks", () => { + test("creates a task on happy path", async () => { const response = await request(app) - .post('/tasks') - .send({ title: 'Write tests', priority: 'high' }); + .post("/tasks") + .send({ title: "Write tests", priority: "high" }); expect(response.status).toBe(201); expect(response.body).toMatchObject({ - title: 'Write tests', - priority: 'high', - status: 'todo', + title: "Write tests", + priority: "high", + status: "todo", }); expect(response.body.id).toEqual(expect.any(String)); }); - test('returns 400 when title is missing', async () => { + test("returns 400 when title is missing", async () => { const response = await request(app) - .post('/tasks') - .send({ priority: 'high' }); + .post("/tasks") + .send({ priority: "high" }); expect(response.status).toBe(400); - expect(response.body).toHaveProperty('error'); + expect(response.body).toHaveProperty("error"); }); - test('returns 400 when priority is invalid', async () => { + test("returns 400 when priority is invalid", async () => { const response = await request(app) - .post('/tasks') - .send({ title: 'Task', priority: 'urgent' }); + .post("/tasks") + .send({ title: "Task", priority: "urgent" }); expect(response.status).toBe(400); expect(response.body.error).toMatch(/priority/i); }); - test('returns 400 when dueDate format is invalid', async () => { + test("returns 400 when dueDate format is invalid", async () => { const response = await request(app) - .post('/tasks') - .send({ title: 'Task', dueDate: 'not-an-iso-date' }); + .post("/tasks") + .send({ title: "Task", dueDate: "not-an-iso-date" }); expect(response.status).toBe(400); expect(response.body.error).toMatch(/dueDate/i); }); }); - describe('PUT /tasks/:id', () => { - test('updates a task on happy path', async () => { - const task = createSeedTask({ title: 'Before update', priority: 'low' }); + describe("PUT /tasks/:id", () => { + test("updates a task on happy path", async () => { + const task = createSeedTask({ title: "Before update", priority: "low" }); const response = await request(app) .put(`/tasks/${task.id}`) - .send({ title: 'After update', priority: 'high' }); + .send({ title: "After update", priority: "high" }); expect(response.status).toBe(200); expect(response.body).toMatchObject({ id: task.id, - title: 'After update', - priority: 'high', + title: "After update", + priority: "high", }); }); - test('returns 404 for non-existent task id', async () => { + test("returns 404 for non-existent task id", async () => { const response = await request(app) - .put('/tasks/does-not-exist') - .send({ title: 'No task here' }); + .put("/tasks/does-not-exist") + .send({ title: "No task here" }); expect(response.status).toBe(404); expect(response.body.error).toMatch(/not found/i); }); // Known bug: empty update payload is currently accepted instead of rejected. - test('returns 400 when required update fields are missing', async () => { - const task = createSeedTask({ title: 'Needs update fields' }); + test("returns 400 when required update fields are missing", async () => { + const task = createSeedTask({ title: "Needs update fields" }); - const response = await request(app) - .put(`/tasks/${task.id}`) - .send({}); + const response = await request(app).put(`/tasks/${task.id}`).send({}); expect(response.status).toBe(400); - expect(response.body).toHaveProperty('error'); + expect(response.body).toHaveProperty("error"); }); }); - describe('DELETE /tasks/:id', () => { - test('deletes a task and returns 204 with no body', async () => { - const task = createSeedTask({ title: 'Delete me' }); + describe("DELETE /tasks/:id", () => { + test("deletes a task and returns 204 with no body", async () => { + const task = createSeedTask({ title: "Delete me" }); const response = await request(app).delete(`/tasks/${task.id}`); expect(response.status).toBe(204); - expect(response.text).toBe(''); + expect(response.text).toBe(""); expect(taskService.findById(task.id)).toBeUndefined(); }); - test('returns 404 for non-existent task id', async () => { - const response = await request(app).delete('/tasks/not-found-id'); + test("returns 404 for non-existent task id", async () => { + const response = await request(app).delete("/tasks/not-found-id"); expect(response.status).toBe(404); expect(response.body.error).toMatch(/not found/i); }); }); - describe('PATCH /tasks/:id/complete', () => { - test('marks task complete on happy path', async () => { - const task = createSeedTask({ status: 'todo', priority: 'high' }); + describe("PATCH /tasks/:id/complete", () => { + test("marks task complete on happy path", async () => { + const task = createSeedTask({ status: "todo", priority: "high" }); const response = await request(app).patch(`/tasks/${task.id}/complete`); expect(response.status).toBe(200); - expect(response.body.status).toBe('done'); + expect(response.body.status).toBe("done"); expect(response.body.completedAt).toEqual(expect.any(String)); expect(Number.isNaN(Date.parse(response.body.completedAt))).toBe(false); }); // Known bug: completing an already completed task rewrites completedAt. - test('does not rewrite completedAt when task is already completed', async () => { - const task = createSeedTask({ status: 'todo' }); + test("does not rewrite completedAt when task is already completed", async () => { + const task = createSeedTask({ status: "todo" }); const first = await request(app).patch(`/tasks/${task.id}/complete`); await new Promise((resolve) => setTimeout(resolve, 10)); @@ -537,21 +548,21 @@ describe('INTEGRATION TESTS (API routes via Supertest)', () => { expect(second.body.completedAt).toBe(first.body.completedAt); }); - test('returns 404 for non-existent task id', async () => { - const response = await request(app).patch('/tasks/not-found-id/complete'); + test("returns 404 for non-existent task id", async () => { + const response = await request(app).patch("/tasks/not-found-id/complete"); expect(response.status).toBe(404); expect(response.body.error).toMatch(/not found/i); }); }); - describe('GET /tasks/stats', () => { - test('returns status counts on happy path', async () => { - createSeedTask({ status: 'todo' }); - createSeedTask({ status: 'in_progress' }); - createSeedTask({ status: 'done' }); + describe("GET /tasks/stats", () => { + test("returns status counts on happy path", async () => { + createSeedTask({ status: "todo" }); + createSeedTask({ status: "in_progress" }); + createSeedTask({ status: "done" }); - const response = await request(app).get('/tasks/stats'); + const response = await request(app).get("/tasks/stats"); expect(response.status).toBe(200); expect(response.body).toEqual({ @@ -562,65 +573,65 @@ describe('INTEGRATION TESTS (API routes via Supertest)', () => { }); }); - test('includes overdue count when overdue tasks are present', async () => { + test("includes overdue count when overdue tasks are present", async () => { const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - createSeedTask({ status: 'todo', dueDate: pastDate }); - createSeedTask({ status: 'done', dueDate: pastDate }); + createSeedTask({ status: "todo", dueDate: pastDate }); + createSeedTask({ status: "done", dueDate: pastDate }); - const response = await request(app).get('/tasks/stats'); + const response = await request(app).get("/tasks/stats"); expect(response.status).toBe(200); expect(response.body.overdue).toBe(1); }); }); - describe('PATCH /tasks/:id/assign', () => { + describe("PATCH /tasks/:id/assign", () => { // TDD: endpoint does not exist yet; these tests define required behavior first. - test('assigns a task to a user on happy path', async () => { - const task = createSeedTask({ title: 'Assignable task' }); + test("assigns a task to a user on happy path", async () => { + const task = createSeedTask({ title: "Assignable task" }); const response = await request(app) .patch(`/tasks/${task.id}/assign`) - .send({ assignee: 'Alice' }); + .send({ assignee: "Alice" }); expect(response.status).toBe(200); - expect(response.body.assignee).toBe('Alice'); + expect(response.body.assignee).toBe("Alice"); expect(response.body.id).toBe(task.id); }); // TDD: validation behavior expected once endpoint is implemented. - test('returns 400 when assignee is an empty string', async () => { - const task = createSeedTask({ title: 'Validation task' }); + test("returns 400 when assignee is an empty string", async () => { + const task = createSeedTask({ title: "Validation task" }); const response = await request(app) .patch(`/tasks/${task.id}/assign`) - .send({ assignee: '' }); + .send({ assignee: "" }); expect(response.status).toBe(400); - expect(response.body).toHaveProperty('error'); + expect(response.body).toHaveProperty("error"); }); - test('returns 404 when assigning a non-existent task', async () => { + test("returns 404 when assigning a non-existent task", async () => { const response = await request(app) - .patch('/tasks/missing-id/assign') - .send({ assignee: 'Bob' }); + .patch("/tasks/missing-id/assign") + .send({ assignee: "Bob" }); expect(response.status).toBe(404); - expect(response.body).toHaveProperty('error'); + expect(response.body).toHaveProperty("error"); }); - // TDD: conflict behavior expected once endpoint is implemented. - test('returns 409 when task is already assigned', async () => { - const task = createSeedTask({ title: 'Already assigned task' }); - taskService.update(task.id, { assignee: 'Eve' }); + test("reassigns task when it is already assigned", async () => { + const task = createSeedTask({ title: "Already assigned task" }); + taskService.update(task.id, { assignee: "Eve" }); const response = await request(app) .patch(`/tasks/${task.id}/assign`) - .send({ assignee: 'Mallory' }); + .send({ assignee: "Mallory" }); - expect(response.status).toBe(409); - expect(response.body).toHaveProperty('error'); + expect(response.status).toBe(200); + expect(response.body.assignee).toBe("Mallory"); + expect(response.body.id).toBe(task.id); }); }); }); From ce09113f328f27443083e87b660e51992f8875c0 Mon Sep 17 00:00:00 2001 From: Vihaan Aswal Date: Thu, 16 Apr 2026 22:36:07 +0530 Subject: [PATCH 5/6] feat: expand task api coverage and assign support --- task-api/src/app.js | 19 ++-- task-api/src/routes/tasks.js | 34 +++---- task-api/src/services/taskService.js | 18 ++-- task-api/src/utils/validators.js | 45 ++++++---- task-api/tests/tasks.test.js | 130 +++++++++++++++++++++++++++ 5 files changed, 199 insertions(+), 47 deletions(-) diff --git a/task-api/src/app.js b/task-api/src/app.js index 65c03eec..75446737 100644 --- a/task-api/src/app.js +++ b/task-api/src/app.js @@ -1,22 +1,27 @@ -const express = require('express'); -const taskRoutes = require('./routes/tasks'); +const express = require("express"); +const taskRoutes = require("./routes/tasks"); const app = express(); app.use(express.json()); -app.use('/tasks', taskRoutes); +app.use("/tasks", taskRoutes); app.use((err, req, res, next) => { console.error(err.stack); - res.status(500).json({ error: 'Internal server error' }); + res.status(500).json({ error: "Internal server error" }); }); const PORT = process.env.PORT || 3000; -if (require.main === module) { - app.listen(PORT, () => { - console.log(`Task API running on port ${PORT}`); +const startServer = (port = PORT) => + app.listen(port, () => { + console.log(`Task API running on port ${port}`); }); + +if (require.main === module) { + startServer(PORT); } +app.startServer = startServer; + module.exports = app; diff --git a/task-api/src/routes/tasks.js b/task-api/src/routes/tasks.js index c56ad313..86bba82f 100644 --- a/task-api/src/routes/tasks.js +++ b/task-api/src/routes/tasks.js @@ -1,14 +1,18 @@ -const express = require('express'); +const express = require("express"); const router = express.Router(); -const taskService = require('../services/taskService'); -const { validateCreateTask, validateUpdateTask, validateAssignTask } = require('../utils/validators'); - -router.get('/stats', (req, res) => { +const taskService = require("../services/taskService"); +const { + validateCreateTask, + validateUpdateTask, + validateAssignTask, +} = require("../utils/validators"); + +router.get("/stats", (req, res) => { const stats = taskService.getStats(); res.json(stats); }); -router.get('/', (req, res) => { +router.get("/", (req, res) => { const { status, page, limit } = req.query; if (status) { @@ -27,7 +31,7 @@ router.get('/', (req, res) => { res.json(tasks); }); -router.post('/', (req, res) => { +router.post("/", (req, res) => { const error = validateCreateTask(req.body); if (error) { return res.status(400).json({ error }); @@ -37,7 +41,7 @@ router.post('/', (req, res) => { res.status(201).json(task); }); -router.put('/:id', (req, res) => { +router.put("/:id", (req, res) => { const error = validateUpdateTask(req.body); if (error) { return res.status(400).json({ error }); @@ -45,25 +49,25 @@ router.put('/:id', (req, res) => { const task = taskService.update(req.params.id, req.body); if (!task) { - return res.status(404).json({ error: 'Task not found' }); + return res.status(404).json({ error: "Task not found" }); } res.json(task); }); -router.delete('/:id', (req, res) => { +router.delete("/:id", (req, res) => { const deleted = taskService.remove(req.params.id); if (!deleted) { - return res.status(404).json({ error: 'Task not found' }); + return res.status(404).json({ error: "Task not found" }); } res.status(204).send(); }); -router.patch('/:id/complete', (req, res) => { +router.patch("/:id/complete", (req, res) => { const task = taskService.completeTask(req.params.id); if (!task) { - return res.status(404).json({ error: 'Task not found' }); + return res.status(404).json({ error: "Task not found" }); } res.json(task); @@ -78,7 +82,7 @@ router.patch('/:id/complete', (req, res) => { * - No user list validation — assignee is a free-form string by design. * (In production, this would validate against a users collection.) */ -router.patch('/:id/assign', (req, res) => { +router.patch("/:id/assign", (req, res) => { const error = validateAssignTask(req.body); if (error) { return res.status(400).json({ error }); @@ -86,7 +90,7 @@ router.patch('/:id/assign', (req, res) => { const task = taskService.assignTask(req.params.id, req.body.assignee.trim()); if (!task) { - return res.status(404).json({ error: 'Task not found' }); + return res.status(404).json({ error: "Task not found" }); } res.status(200).json(task); diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index 0d9d8dbf..7149a846 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -1,4 +1,4 @@ -const { v4: uuidv4 } = require('uuid'); +const { v4: uuidv4 } = require("uuid"); let tasks = []; @@ -20,7 +20,7 @@ const getStats = () => { tasks.forEach((t) => { if (counts[t.status] !== undefined) counts[t.status]++; - if (t.dueDate && t.status !== 'done' && new Date(t.dueDate) < now) { + if (t.dueDate && t.status !== "done" && new Date(t.dueDate) < now) { overdue++; } }); @@ -28,7 +28,13 @@ 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, +}) => { const task = { id: uuidv4(), title, @@ -67,14 +73,14 @@ const completeTask = (id) => { if (!task) return null; // GUARD: Already-completed tasks are returned unchanged (idempotent). - if (task.status === 'done') return task; + if (task.status === "done") return task; - // FIX: Removed hardcoded priority reset — completing a task should + // FIX: Removed hardcoded priority reset — completing a task should // preserve its existing priority, not silently downgrade it. const updated = { ...task, - status: 'done', + status: "done", completedAt: new Date().toISOString(), }; diff --git a/task-api/src/utils/validators.js b/task-api/src/utils/validators.js index 7d2c6b1b..963be1f3 100644 --- a/task-api/src/utils/validators.js +++ b/task-api/src/utils/validators.js @@ -1,34 +1,41 @@ -const VALID_STATUSES = ['todo', 'in_progress', 'done']; -const VALID_PRIORITIES = ['low', 'medium', 'high']; +const VALID_STATUSES = ["todo", "in_progress", "done"]; +const VALID_PRIORITIES = ["low", "medium", "high"]; const validateCreateTask = (body) => { - if (!body.title || typeof body.title !== 'string' || body.title.trim() === '') { - return 'title is required and must be a non-empty string'; + if ( + !body.title || + typeof body.title !== "string" || + body.title.trim() === "" + ) { + return "title is required and must be a non-empty string"; } if (body.status && !VALID_STATUSES.includes(body.status)) { - return `status must be one of: ${VALID_STATUSES.join(', ')}`; + return `status must be one of: ${VALID_STATUSES.join(", ")}`; } if (body.priority && !VALID_PRIORITIES.includes(body.priority)) { - return `priority must be one of: ${VALID_PRIORITIES.join(', ')}`; + return `priority must be one of: ${VALID_PRIORITIES.join(", ")}`; } if (body.dueDate && isNaN(Date.parse(body.dueDate))) { - return 'dueDate must be a valid ISO date string'; + return "dueDate must be a valid ISO date string"; } return null; }; const validateUpdateTask = (body) => { - if (body.title !== undefined && (typeof body.title !== 'string' || body.title.trim() === '')) { - return 'title must be a non-empty string'; + if ( + body.title !== undefined && + (typeof body.title !== "string" || body.title.trim() === "") + ) { + return "title must be a non-empty string"; } if (body.status && !VALID_STATUSES.includes(body.status)) { - return `status must be one of: ${VALID_STATUSES.join(', ')}`; + return `status must be one of: ${VALID_STATUSES.join(", ")}`; } if (body.priority && !VALID_PRIORITIES.includes(body.priority)) { - return `priority must be one of: ${VALID_PRIORITIES.join(', ')}`; + return `priority must be one of: ${VALID_PRIORITIES.join(", ")}`; } if (body.dueDate && isNaN(Date.parse(body.dueDate))) { - return 'dueDate must be a valid ISO date string'; + return "dueDate must be a valid ISO date string"; } return null; }; @@ -40,19 +47,19 @@ const validateUpdateTask = (body) => { */ const validateAssignTask = (body) => { if (body === null || body === undefined) { - return 'Request body is required'; + return "Request body is required"; } if (body.assignee === undefined) { - return 'assignee is required'; + return "assignee is required"; } - if (typeof body.assignee !== 'string') { - return 'assignee must be a string'; + if (typeof body.assignee !== "string") { + return "assignee must be a string"; } - if (body.assignee.trim() === '') { - return 'assignee cannot be empty or whitespace'; + if (body.assignee.trim() === "") { + return "assignee cannot be empty or whitespace"; } if (body.assignee.trim().length > 100) { - return 'assignee name cannot exceed 100 characters'; + return "assignee name cannot exceed 100 characters"; } return null; }; diff --git a/task-api/tests/tasks.test.js b/task-api/tests/tasks.test.js index 2601a393..2ad89c6a 100644 --- a/task-api/tests/tasks.test.js +++ b/task-api/tests/tasks.test.js @@ -1,6 +1,11 @@ const request = require("supertest"); const app = require("../src/app"); const taskService = require("../src/services/taskService"); +const { + validateAssignTask, + validateCreateTask, + validateUpdateTask, +} = require("../src/utils/validators"); const createSeedTask = (overrides = {}) => { const suffix = Math.random().toString(16).slice(2, 8); @@ -240,6 +245,16 @@ describe("UNIT TESTS (taskService.js directly)", () => { expect(stats.overdue).toBe(1); }); + + test("ignores unknown status values in count buckets", () => { + createSeedTask({ status: "archived" }); + + const stats = taskService.getStats(); + + expect(stats.todo).toBe(0); + expect(stats.in_progress).toBe(0); + expect(stats.done).toBe(0); + }); }); describe("update()", () => { @@ -341,6 +356,100 @@ describe("UNIT TESTS (taskService.js directly)", () => { }); }); +describe("UNIT TESTS (validators.js directly)", () => { + test("validateCreateTask rejects invalid status values", () => { + const error = validateCreateTask({ + title: "Task", + status: "pending", + }); + + expect(error).toBe("status must be one of: todo, in_progress, done"); + }); + + test("validateUpdateTask rejects invalid dueDate values", () => { + const error = validateUpdateTask({ dueDate: "not-a-date" }); + + expect(error).toBe("dueDate must be a valid ISO date string"); + }); + + test("validateUpdateTask rejects empty title values", () => { + const error = validateUpdateTask({ title: " " }); + + expect(error).toBe("title must be a non-empty string"); + }); + + test("validateUpdateTask rejects invalid status values", () => { + const error = validateUpdateTask({ status: "pending" }); + + expect(error).toBe("status must be one of: todo, in_progress, done"); + }); + + test("validateUpdateTask rejects invalid priority values", () => { + const error = validateUpdateTask({ priority: "urgent" }); + + expect(error).toBe("priority must be one of: low, medium, high"); + }); + + test("validateAssignTask rejects null body", () => { + expect(validateAssignTask(null)).toBe("Request body is required"); + }); + + test("validateAssignTask rejects undefined assignee", () => { + expect(validateAssignTask({})).toBe("assignee is required"); + }); + + test("validateAssignTask rejects non-string assignee", () => { + expect(validateAssignTask({ assignee: 42 })).toBe( + "assignee must be a string", + ); + }); + + test("validateAssignTask rejects empty string assignee", () => { + expect(validateAssignTask({ assignee: "" })).toBe( + "assignee cannot be empty or whitespace", + ); + }); + + test("validateAssignTask rejects whitespace-only assignee", () => { + expect(validateAssignTask({ assignee: " \t\n" })).toBe( + "assignee cannot be empty or whitespace", + ); + }); + + test("validateAssignTask rejects assignee longer than 100 chars", () => { + const tooLong = "a".repeat(101); + + expect(validateAssignTask({ assignee: tooLong })).toBe( + "assignee name cannot exceed 100 characters", + ); + }); + + test("validateAssignTask returns null for a valid assignee", () => { + expect(validateAssignTask({ assignee: " Alice " })).toBeNull(); + }); +}); + +describe("UNIT TESTS (app bootstrap)", () => { + test("startServer listens on provided port and logs startup message", () => { + const listenSpy = jest + .spyOn(app, "listen") + .mockImplementation((port, callback) => { + if (callback) callback(); + return { close: jest.fn() }; + }); + + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + app.startServer(4321); + + expect(listenSpy).toHaveBeenCalledWith(4321, expect.any(Function)); + expect(logSpy).toHaveBeenCalledWith("Task API running on port 4321"); + + logSpy.mockRestore(); + listenSpy.mockRestore(); + }); +}); + describe("INTEGRATION TESTS (API routes via Supertest)", () => { beforeEach(() => { taskService._reset(); @@ -466,6 +575,16 @@ describe("INTEGRATION TESTS (API routes via Supertest)", () => { expect(response.status).toBe(400); expect(response.body.error).toMatch(/dueDate/i); }); + + test("returns 500 when body is JSON null and validator throws", async () => { + const response = await request(app) + .post("/tasks") + .set("Content-Type", "application/json") + .send("null"); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: "Internal server error" }); + }); }); describe("PUT /tasks/:id", () => { @@ -493,6 +612,17 @@ describe("INTEGRATION TESTS (API routes via Supertest)", () => { expect(response.body.error).toMatch(/not found/i); }); + test("returns 400 when update payload has invalid dueDate", async () => { + const task = createSeedTask({ title: "Needs valid dueDate" }); + + const response = await request(app) + .put(`/tasks/${task.id}`) + .send({ dueDate: "not-a-date" }); + + expect(response.status).toBe(400); + expect(response.body.error).toMatch(/dueDate/i); + }); + // Known bug: empty update payload is currently accepted instead of rejected. test("returns 400 when required update fields are missing", async () => { const task = createSeedTask({ title: "Needs update fields" }); From 0c8c7323b216c209d80f9165417ddd8f03590469 Mon Sep 17 00:00:00 2001 From: Vihaan Aswal Date: Thu, 16 Apr 2026 22:57:50 +0530 Subject: [PATCH 6/6] docs: refresh bug report and submission note --- BUG_REPORT.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++ SUBMISSION.md | 19 ++++++++++ 2 files changed, 116 insertions(+) create mode 100644 BUG_REPORT.md create mode 100644 SUBMISSION.md diff --git a/BUG_REPORT.md b/BUG_REPORT.md new file mode 100644 index 00000000..60e4585c --- /dev/null +++ b/BUG_REPORT.md @@ -0,0 +1,97 @@ +# Bug Report + +This report reflects the current codebase after implementation work. Bugs #4, #5, and #9 from the original audit have been fixed. + +## Resolved During Implementation + +- Bug #4: Completing a task no longer resets priority to `medium`. +- Bug #5: Re-completing a task no longer rewrites the original `completedAt` timestamp. +- Bug #9: `PATCH /tasks/:id/assign` now exists and stores `assignee` on the task. + +## Open Bugs + +**Bug #1: Status filter does substring matching** + +- **File:** src/services/taskService.js +- **Line:** ~9 +- **Expected behavior:** `GET /tasks?status=...` should return only tasks whose status exactly matches the requested enum value. +- **Actual behavior:** `getByStatus()` uses `t.status.includes(status)`, so partial values match too. For example, `status=do` returns both `todo` and `done`. +- **How discovered:** Test `getByStatus() -> does not match partial status fragments` and integration check for `GET /tasks?status=`. +- **Proposed fix:** Replace substring matching with strict equality, and validate the query status before filtering. + +--- + +**Bug #2: Pagination skips the first page** + +- **File:** src/services/taskService.js +- **Line:** ~12 +- **Expected behavior:** Page 1 with limit N should return the first N tasks. +- **Actual behavior:** `getPaginated()` computes `offset = page * limit`, so page 1 starts at index `limit` and skips the first page entirely. +- **How discovered:** Test `getPaginated() -> returns the first page of tasks when page=1 and limit=2` and integration check for `GET /tasks` with pagination. +- **Proposed fix:** Use `offset = (page - 1) * limit` for 1-based pagination, and reject invalid page/limit values. + +--- + +**Bug #3: PUT allows immutable fields like createdAt to be overwritten** + +- **File:** src/services/taskService.js +- **Line:** ~50 +- **Expected behavior:** Client updates should only be able to change mutable task fields. +- **Actual behavior:** `update()` merges the entire request body into the existing task object with `{ ...tasks[index], ...fields }`, so a request body containing `createdAt` or `id` overwrites those system fields. +- **How discovered:** Test `update() -> does not allow createdAt to be overwritten by client updates`. +- **Proposed fix:** Whitelist allowed update fields before merging and ignore or reject immutable properties. + +--- + +**Bug #4: Invalid status query values are accepted instead of rejected** + +- **File:** src/routes/tasks.js +- **Line:** ~14-16 +- **Expected behavior:** `GET /tasks?status=...` should validate the requested status and return 400 for unsupported values. +- **Actual behavior:** The route forwards whatever string is in `req.query.status` to `getByStatus()`. Because filtering is substring-based and there is no validation step, unsupported values like `pending` are not rejected consistently. +- **How discovered:** Integration test `GET /tasks -> returns 400 for invalid status filter value`. +- **Proposed fix:** Validate the query parameter against the allowed enum before calling the service and return 400 on invalid input. + +--- + +**Bug #5: Invalid page and limit values are silently coerced** + +- **File:** src/routes/tasks.js +- **Line:** ~18-21 +- **Expected behavior:** Non-numeric, zero, or negative pagination values should be rejected with 400. +- **Actual behavior:** `parseInt(page) || 1` and `parseInt(limit) || 10` coerce invalid values into defaults. That means `page=abc` becomes page 1 and `limit=0` becomes 10 instead of returning a validation error. +- **How discovered:** Integration tests `GET /tasks -> returns 400 for invalid page query value` and `GET /tasks -> returns 400 for invalid limit query value`. +- **Proposed fix:** Parse and validate query parameters explicitly, require positive integers, and return 400 when parsing fails or values are out of range. + +--- + +**Bug #6: PUT accepts an empty payload instead of rejecting it** + +- **File:** src/routes/tasks.js +- **Line:** ~37-46 +- **Expected behavior:** Updating a task should require at least one valid field, or the API should reject empty bodies with 400. +- **Actual behavior:** An empty object passes validation because `validateUpdateTask({})` returns null, and `taskService.update()` simply merges nothing and returns the existing task. +- **How discovered:** Integration test `PUT /tasks/:id -> returns 400 when required update fields are missing`. +- **Proposed fix:** Add a validation rule that rejects empty update payloads and return 400 before calling the service. + +--- + +**Bug #7: The error middleware converts all errors into a 500 response** + +- **File:** src/app.js +- **Line:** ~9-11 +- **Expected behavior:** Client-side validation and JSON parsing errors should return 400-class responses where appropriate. +- **Actual behavior:** The global error handler always returns `500 Internal server error`, regardless of whether the error originated from bad input or a server fault. +- **How discovered:** Manual inspection of the Express error middleware and the null/invalid-body edge cases in the tests. +- **Proposed fix:** Detect known client errors and map them to 400 responses; reserve 500 for unexpected server failures. + +--- + +**Bug #8: Documentation status enum disagrees with the implementation** + +- **File:** README.md +- **Line:** ~77 +- **Expected behavior:** The API documentation should describe the same task status values that the code accepts and returns. +- **Actual behavior:** README uses `pending | in-progress | completed`, while the code and assignment use `todo | in_progress | done`. +- **How discovered:** Manual inspection of README and assignment docs. +- **Proposed fix:** Update README examples and schema to match the actual API contract, or refactor the code to match the documented values. diff --git a/SUBMISSION.md b/SUBMISSION.md new file mode 100644 index 00000000..b383c2c3 --- /dev/null +++ b/SUBMISSION.md @@ -0,0 +1,19 @@ +# Submission Note + +This submission includes the implementation and cleanup work for the Task API take-home assignment. + +## Completed + +- Added the task assignment flow end to end. +- Fixed task completion so it preserves priority and is idempotent. +- Expanded Jest and Supertest coverage to exercise the missing branches and edge cases. +- Updated the bug report to reflect the current open issues. +- Removed the long-form audit notes file from the repository. + +## Verification + +- Verified the API package from `task-api` with the test and coverage workflow used during implementation. + +## Remaining Known Issues + +- The open issues listed in `BUG_REPORT.md` are still reproducible and are intentionally left as follow-up items. \ No newline at end of file