From 2b9203abc0cc2cfb065a1d2c0c8199079dcc29a9 Mon Sep 17 00:00:00 2001 From: SoumyaSriMishra Date: Sat, 15 Aug 2026 18:21:49 +0530 Subject: [PATCH 1/8] test: add unit tests for taskService --- task-api/tests/taskService.test.js | 345 +++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 task-api/tests/taskService.test.js diff --git a/task-api/tests/taskService.test.js b/task-api/tests/taskService.test.js new file mode 100644 index 00000000..7cfbc7b0 --- /dev/null +++ b/task-api/tests/taskService.test.js @@ -0,0 +1,345 @@ +const taskService = require('../src/services/taskService'); + +describe('taskService (Unit Tests)', () => { + beforeEach(() => { + taskService._reset(); + }); + + describe('create()', () => { + it('creates a task with provided values and generates id and createdAt', () => { + const task = taskService.create({ + title: 'Complete assignment', + description: 'Write comprehensive tests', + status: 'in_progress', + priority: 'high', + dueDate: '2026-12-31T23:59:59.000Z', + }); + + expect(task).toBeDefined(); + expect(task.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + expect(task.title).toBe('Complete assignment'); + expect(task.description).toBe('Write comprehensive tests'); + expect(task.status).toBe('in_progress'); + expect(task.priority).toBe('high'); + expect(task.dueDate).toBe('2026-12-31T23:59:59.000Z'); + expect(task.completedAt).toBeNull(); + expect(typeof task.createdAt).toBe('string'); + expect(new Date(task.createdAt).getTime()).not.toBeNaN(); + expect(Date.now() - new Date(task.createdAt).getTime()).toBeLessThan(5000); + }); + + it('applies documented defaults when optional fields are omitted', () => { + const task = taskService.create({ title: 'Minimal task' }); + + expect(task.title).toBe('Minimal task'); + expect(task.description).toBe(''); + expect(task.status).toBe('todo'); + expect(task.priority).toBe('medium'); + expect(task.dueDate).toBeNull(); + expect(task.completedAt).toBeNull(); + expect(task.id).toBeDefined(); + expect(task.createdAt).toBeDefined(); + }); + + it('generates distinct IDs on successive calls', () => { + const task1 = taskService.create({ title: 'Task 1' }); + const task2 = taskService.create({ title: 'Task 2' }); + + expect(task1.id).not.toBe(task2.id); + }); + + it('only persists explicitly destructured fields, ignoring unexpected keys', () => { + const task = taskService.create({ + title: 'Task with extra fields', + foo: 'bar', + unknownProperty: 12345, + isAdmin: true, + }); + + expect(task.foo).toBeUndefined(); + expect(task.unknownProperty).toBeUndefined(); + expect(task.isAdmin).toBeUndefined(); + expect(Object.keys(task)).toEqual([ + 'id', + 'title', + 'description', + 'status', + 'priority', + 'dueDate', + 'completedAt', + 'createdAt', + ]); + }); + }); + + describe('getAll()', () => { + it('returns empty array when store is empty', () => { + expect(taskService.getAll()).toEqual([]); + }); + + it('returns all created tasks', () => { + const t1 = taskService.create({ title: 'Task 1' }); + const t2 = taskService.create({ title: 'Task 2' }); + + const all = taskService.getAll(); + expect(all).toHaveLength(2); + expect(all).toEqual([t1, t2]); + }); + + it('returns a shallow copy of the tasks array', () => { + taskService.create({ title: 'Task 1' }); + const all = taskService.getAll(); + all.push({ id: 'injected' }); + + expect(taskService.getAll()).toHaveLength(1); + }); + }); + + describe('findById()', () => { + it('returns the task matching the provided ID', () => { + const created = taskService.create({ title: 'Find me' }); + const found = taskService.findById(created.id); + + expect(found).toEqual(created); + }); + + it('returns undefined for an unknown ID', () => { + const result = taskService.findById('non-existent-id'); + expect(result).toBeUndefined(); + }); + }); + + describe('getByStatus()', () => { + it('returns tasks matching the exact status', () => { + const t1 = taskService.create({ title: 'Task 1', status: 'todo' }); + taskService.create({ title: 'Task 2', status: 'in_progress' }); + const t3 = taskService.create({ title: 'Task 3', status: 'todo' }); + + const results = taskService.getByStatus('todo'); + expect(results).toHaveLength(2); + expect(results).toEqual([t1, t3]); + }); + + it('returns an empty array when no tasks have the given status', () => { + taskService.create({ title: 'Task 1', status: 'todo' }); + const results = taskService.getByStatus('done'); + expect(results).toEqual([]); + }); + + it('returns an empty array for an unknown/invalid status string', () => { + taskService.create({ title: 'Task 1', status: 'todo' }); + const results = taskService.getByStatus('bogus_status'); + expect(results).toEqual([]); + }); + + // BUG DISCOVERY: Substring matching probe + // getByStatus uses String.prototype.includes(), so 'do' matches both 'todo' and 'done' + test.failing('does not return tasks whose status merely contains the search term as a substring', () => { + taskService.create({ title: 'Task Todo', status: 'todo' }); + taskService.create({ title: 'Task Done', status: 'done' }); + + const results = taskService.getByStatus('do'); + // A caller querying 'do' expects only tasks with exact status === 'do' (which is none) + expect(results).toHaveLength(0); + }); + }); + + describe('getPaginated()', () => { + beforeEach(() => { + for (let i = 1; i <= 25; i++) { + taskService.create({ title: `Task ${i}` }); + } + }); + + // BUG DISCOVERY: Pagination offset math bug + // offset is calculated as `page * limit`, which for page=1 yields offset=10 (skipping tasks 1-10) + test.failing('returns the first page (items 1 to 10) for page=1 and limit=10', () => { + const page1 = taskService.getPaginated(1, 10); + expect(page1).toHaveLength(10); + expect(page1[0].title).toBe('Task 1'); + expect(page1[9].title).toBe('Task 10'); + }); + + test.failing('returns the second page (items 11 to 20) for page=2 and limit=10 without gaps or overlaps', () => { + const page1 = taskService.getPaginated(1, 10); + const page2 = taskService.getPaginated(2, 10); + + expect(page2).toHaveLength(10); + expect(page2[0].title).toBe('Task 11'); + expect(page2[9].title).toBe('Task 20'); + + const allTitles = [...page1.map((t) => t.title), ...page2.map((t) => t.title)]; + const uniqueTitles = new Set(allTitles); + expect(uniqueTitles.size).toBe(20); + }); + + it('returns remaining items on the last partial page', () => { + // With current offset=page*limit: page 2 offset=20, items 21..25 + const page2 = taskService.getPaginated(2, 10); + expect(page2).toHaveLength(5); + expect(page2[0].title).toBe('Task 21'); + expect(page2[4].title).toBe('Task 25'); + }); + + it('returns empty array when page is beyond the total items', () => { + const result = taskService.getPaginated(10, 10); + expect(result).toEqual([]); + }); + + it('returns all items if limit is larger than total count', () => { + // With page 0 in current implementation: offset = 0 * 50 = 0 + const result = taskService.getPaginated(0, 50); + expect(result).toHaveLength(25); + }); + }); + + describe('getStats()', () => { + it('returns all zeroes on an empty store', () => { + const stats = taskService.getStats(); + expect(stats).toEqual({ + todo: 0, + in_progress: 0, + done: 0, + overdue: 0, + }); + }); + + it('correctly aggregates counts for each status bucket', () => { + taskService.create({ title: 'T1', status: 'todo' }); + taskService.create({ title: 'T2', status: 'todo' }); + taskService.create({ title: 'T3', status: 'in_progress' }); + taskService.create({ title: 'T4', status: 'done' }); + + const stats = taskService.getStats(); + expect(stats).toEqual({ + todo: 2, + in_progress: 1, + done: 1, + overdue: 0, + }); + }); + + it('counts overdue tasks: past dueDate and status !== "done"', () => { + const pastDate = new Date(Date.now() - 86400000).toISOString(); + const futureDate = new Date(Date.now() + 86400000).toISOString(); + + // Overdue: past dueDate and status === 'todo' + taskService.create({ title: 'Overdue Todo', status: 'todo', dueDate: pastDate }); + // Overdue: past dueDate and status === 'in_progress' + taskService.create({ title: 'Overdue In Progress', status: 'in_progress', dueDate: pastDate }); + // NOT overdue: past dueDate but status === 'done' + taskService.create({ title: 'Completed Past Due', status: 'done', dueDate: pastDate }); + // NOT overdue: future dueDate + taskService.create({ title: 'Future Due', status: 'todo', dueDate: futureDate }); + // NOT overdue: null dueDate + taskService.create({ title: 'No Due Date', status: 'todo', dueDate: null }); + + const stats = taskService.getStats(); + expect(stats.todo).toBe(3); + expect(stats.in_progress).toBe(1); + expect(stats.done).toBe(1); + expect(stats.overdue).toBe(2); + }); + }); + + describe('update()', () => { + it('updates specified fields while keeping other fields unchanged', () => { + const task = taskService.create({ + title: 'Original Title', + description: 'Original Desc', + priority: 'low', + status: 'todo', + }); + + const updated = taskService.update(task.id, { + title: 'Updated Title', + priority: 'high', + }); + + expect(updated.id).toBe(task.id); + expect(updated.title).toBe('Updated Title'); + expect(updated.description).toBe('Original Desc'); + expect(updated.priority).toBe('high'); + expect(updated.status).toBe('todo'); + expect(updated.createdAt).toBe(task.createdAt); + }); + + it('returns null when attempting to update a non-existent task ID', () => { + const result = taskService.update('non-existent-id', { title: 'New Title' }); + expect(result).toBeNull(); + }); + + // BUG DISCOVERY: Immutable field probe + // taskService.update blindly applies `{ ...tasks[index], ...fields }`, allowing caller to overwrite id and createdAt + test.failing('does not allow overwriting server-managed immutable fields (id and createdAt)', () => { + const original = taskService.create({ title: 'Original Task' }); + const updated = taskService.update(original.id, { + id: 'attacker-controlled-id', + createdAt: '1970-01-01T00:00:00.000Z', + }); + + expect(updated.id).toBe(original.id); + expect(updated.createdAt).toBe(original.createdAt); + expect(taskService.findById(original.id)).toBeDefined(); + expect(taskService.findById('attacker-controlled-id')).toBeUndefined(); + }); + }); + + describe('remove()', () => { + it('removes the task and returns true on success', () => { + const task = taskService.create({ title: 'To be deleted' }); + const result = taskService.remove(task.id); + + expect(result).toBe(true); + expect(taskService.findById(task.id)).toBeUndefined(); + expect(taskService.getAll()).toHaveLength(0); + }); + + it('returns false when attempting to remove a non-existent ID without throwing', () => { + const result = taskService.remove('non-existent-id'); + expect(result).toBe(false); + }); + }); + + describe('completeTask()', () => { + it('sets status to "done" and sets completedAt timestamp', () => { + const task = taskService.create({ title: 'Task to finish' }); + const completed = taskService.completeTask(task.id); + + expect(completed).toBeDefined(); + expect(completed.id).toBe(task.id); + expect(completed.status).toBe('done'); + expect(typeof completed.completedAt).toBe('string'); + expect(new Date(completed.completedAt).getTime()).not.toBeNaN(); + expect(Date.now() - new Date(completed.completedAt).getTime()).toBeLessThan(5000); + }); + + it('returns null for an unknown ID', () => { + const result = taskService.completeTask('non-existent-id'); + expect(result).toBeNull(); + }); + + // BUG DISCOVERY: Side-effect probe + // completeTask hardcodes `priority: 'medium'`, overwriting 'high' or 'low' + test.failing('preserves the original priority when marking a task as complete', () => { + const task = taskService.create({ + title: 'Urgent Task', + priority: 'high', + }); + + const completed = taskService.completeTask(task.id); + expect(completed.priority).toBe('high'); + }); + }); + + describe('_reset()', () => { + it('clears all stored tasks', () => { + taskService.create({ title: 'Task 1' }); + taskService.create({ title: 'Task 2' }); + expect(taskService.getAll()).toHaveLength(2); + + taskService._reset(); + expect(taskService.getAll()).toEqual([]); + }); + }); +}); From cd13296805b8a0977777d8806dc225331fa3c87b Mon Sep 17 00:00:00 2001 From: SoumyaSriMishra Date: Sat, 15 Aug 2026 18:23:55 +0530 Subject: [PATCH 2/8] test: add integration tests for task routes --- task-api/tests/taskService.test.js | 2 + task-api/tests/tasks.routes.test.js | 477 ++++++++++++++++++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 task-api/tests/tasks.routes.test.js diff --git a/task-api/tests/taskService.test.js b/task-api/tests/taskService.test.js index 7cfbc7b0..fe601665 100644 --- a/task-api/tests/taskService.test.js +++ b/task-api/tests/taskService.test.js @@ -209,6 +209,8 @@ describe('taskService (Unit Tests)', () => { taskService.create({ title: 'T2', status: 'todo' }); taskService.create({ title: 'T3', status: 'in_progress' }); taskService.create({ title: 'T4', status: 'done' }); + // Task with non-standard status should not increment standard count buckets + taskService.create({ title: 'T5', status: 'custom_status' }); const stats = taskService.getStats(); expect(stats).toEqual({ diff --git a/task-api/tests/tasks.routes.test.js b/task-api/tests/tasks.routes.test.js new file mode 100644 index 00000000..7cb1d956 --- /dev/null +++ b/task-api/tests/tasks.routes.test.js @@ -0,0 +1,477 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); + +describe('Task Routes (Integration Tests)', () => { + beforeEach(() => { + taskService._reset(); + }); + + describe('POST /tasks', () => { + it('creates a task with valid payload and returns 201 with task object', async () => { + const payload = { + title: 'Integration Test Task', + description: 'Testing route creation', + status: 'in_progress', + priority: 'high', + dueDate: '2026-12-31T23:59:59.000Z', + }; + + const res = await request(app) + .post('/tasks') + .send(payload) + .expect('Content-Type', /json/) + .expect(201); + + expect(res.body).toMatchObject({ + title: payload.title, + description: payload.description, + status: payload.status, + priority: payload.priority, + dueDate: payload.dueDate, + completedAt: null, + }); + expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/); + expect(typeof res.body.createdAt).toBe('string'); + }); + + it('applies default values when optional fields are omitted', async () => { + const res = await request(app) + .post('/tasks') + .send({ title: 'Basic Task' }) + .expect(201); + + expect(res.body.title).toBe('Basic Task'); + expect(res.body.description).toBe(''); + expect(res.body.status).toBe('todo'); + expect(res.body.priority).toBe('medium'); + expect(res.body.dueDate).toBeNull(); + expect(res.body.completedAt).toBeNull(); + }); + + it('returns 400 if title is missing', async () => { + const res = await request(app) + .post('/tasks') + .send({ description: 'No title here' }) + .expect(400); + + expect(res.body).toHaveProperty('error'); + expect(res.body.error).toContain('title is required'); + }); + + it('returns 400 if title is an empty string or whitespace-only', async () => { + const res1 = await request(app) + .post('/tasks') + .send({ title: '' }) + .expect(400); + expect(res1.body.error).toContain('title is required'); + + const res2 = await request(app) + .post('/tasks') + .send({ title: ' ' }) + .expect(400); + expect(res2.body.error).toContain('title is required'); + }); + + it('returns 400 for invalid status enum value', async () => { + const res = await request(app) + .post('/tasks') + .send({ title: 'Test', status: 'invalid_status' }) + .expect(400); + + expect(res.body.error).toContain('status must be one of: todo, in_progress, done'); + }); + + it('returns 400 for invalid priority enum value', async () => { + const res = await request(app) + .post('/tasks') + .send({ title: 'Test', priority: 'critical' }) + .expect(400); + + expect(res.body.error).toContain('priority must be one of: low, medium, high'); + }); + + it('returns 400 for invalid dueDate format', async () => { + const res = await request(app) + .post('/tasks') + .send({ title: 'Test', dueDate: 'not-a-valid-date' }) + .expect(400); + + expect(res.body.error).toContain('dueDate must be a valid ISO date string'); + }); + + it('ignores unexpected extra fields in request body', async () => { + const res = await request(app) + .post('/tasks') + .send({ + title: 'Extra fields task', + unwantedField: 'hack', + admin: true, + }) + .expect(201); + + expect(res.body.unwantedField).toBeUndefined(); + expect(res.body.admin).toBeUndefined(); + }); + }); + + describe('GET /tasks', () => { + it('returns 200 with empty array on an empty store', async () => { + const res = await request(app) + .get('/tasks') + .expect('Content-Type', /json/) + .expect(200); + + expect(res.body).toEqual([]); + }); + + it('returns 200 with all tasks matching standard task shape', async () => { + await request(app).post('/tasks').send({ title: 'Task 1' }); + await request(app).post('/tasks').send({ title: 'Task 2' }); + + const res = await request(app) + .get('/tasks') + .expect(200); + + expect(res.body).toHaveLength(2); + res.body.forEach((item) => { + expect(item).toHaveProperty('id'); + expect(item).toHaveProperty('title'); + expect(item).toHaveProperty('description'); + expect(item).toHaveProperty('status'); + expect(item).toHaveProperty('priority'); + expect(item).toHaveProperty('dueDate'); + expect(item).toHaveProperty('completedAt'); + expect(item).toHaveProperty('createdAt'); + }); + }); + }); + + describe('GET /tasks?status=', () => { + it('returns 200 with tasks filtered by exact status', async () => { + await request(app).post('/tasks').send({ title: 'Task 1', status: 'todo' }); + await request(app).post('/tasks').send({ title: 'Task 2', status: 'in_progress' }); + await request(app).post('/tasks').send({ title: 'Task 3', status: 'todo' }); + + const res = await request(app) + .get('/tasks?status=todo') + .expect(200); + + expect(res.body).toHaveLength(2); + expect(res.body.every((t) => t.status === 'todo')).toBe(true); + }); + + it('returns 200 with empty array for unknown or non-matching status value', async () => { + await request(app).post('/tasks').send({ title: 'Task 1', status: 'todo' }); + + const res = await request(app) + .get('/tasks?status=nonexistent') + .expect(200); + + expect(res.body).toEqual([]); + }); + + // BUG DISCOVERY: HTTP layer substring probe + // GET /tasks?status=do returns tasks with status 'todo' and 'done' because service uses includes() + test.failing('does not return tasks on partial substring matches for status query', async () => { + await request(app).post('/tasks').send({ title: 'Todo Task', status: 'todo' }); + await request(app).post('/tasks').send({ title: 'Done Task', status: 'done' }); + + const res = await request(app) + .get('/tasks?status=do') + .expect(200); + + // Caller querying ?status=do expects exact match (empty array), not both 'todo' and 'done' + expect(res.body).toEqual([]); + }); + }); + + describe('GET /tasks?page=&limit=', () => { + beforeEach(async () => { + for (let i = 1; i <= 25; i++) { + await request(app).post('/tasks').send({ title: `Task ${i}` }); + } + }); + + // BUG DISCOVERY: HTTP layer pagination offset bug + // Route passes pageNum=1, limitNum=10 to service, which computes offset = 1 * 10 = 10, skipping Task 1..10 + test.failing('returns page 1 containing the first 10 items (Task 1 to Task 10)', async () => { + const res = await request(app) + .get('/tasks?page=1&limit=10') + .expect(200); + + expect(res.body).toHaveLength(10); + expect(res.body[0].title).toBe('Task 1'); + expect(res.body[9].title).toBe('Task 10'); + }); + + it('falls back sanely to default page and limit for non-numeric query parameters', async () => { + const res = await request(app) + .get('/tasks?page=invalid&limit=garbage') + .expect(200); + + // parseInt('invalid') || 1 -> pageNum = 1; parseInt('garbage') || 10 -> limitNum = 10 + // With current service offset=1*10: returns 10 items (offset 10 to 20) without crashing + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeLessThanOrEqual(10); + }); + }); + + describe('PUT /tasks/:id', () => { + it('returns 200 and updates specified fields on an existing task', async () => { + const createRes = await request(app) + .post('/tasks') + .send({ title: 'Original', description: 'Old desc', priority: 'low' }); + const id = createRes.body.id; + + const updateRes = await request(app) + .put(`/tasks/${id}`) + .send({ + title: 'Updated Title', + description: 'New desc', + priority: 'high', + status: 'in_progress', + }) + .expect(200); + + expect(updateRes.body.id).toBe(id); + expect(updateRes.body.title).toBe('Updated Title'); + expect(updateRes.body.description).toBe('New desc'); + expect(updateRes.body.priority).toBe('high'); + expect(updateRes.body.status).toBe('in_progress'); + }); + + it('returns 404 when updating non-existent task ID', async () => { + const res = await request(app) + .put('/tasks/non-existent-uuid') + .send({ title: 'Updated Title' }) + .expect(404); + + expect(res.body.error).toBe('Task not found'); + }); + + it('returns 400 for empty or whitespace-only or non-string title in update payload', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Task' }); + const id = createRes.body.id; + + const res1 = await request(app) + .put(`/tasks/${id}`) + .send({ title: '' }) + .expect(400); + expect(res1.body.error).toContain('title must be a non-empty string'); + + const res2 = await request(app) + .put(`/tasks/${id}`) + .send({ title: ' ' }) + .expect(400); + expect(res2.body.error).toContain('title must be a non-empty string'); + + const res3 = await request(app) + .put(`/tasks/${id}`) + .send({ title: 12345 }) + .expect(400); + expect(res3.body.error).toContain('title must be a non-empty string'); + }); + + it('returns 400 for invalid status in update payload', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Task' }); + const id = createRes.body.id; + + const res = await request(app) + .put(`/tasks/${id}`) + .send({ status: 'invalid_status' }) + .expect(400); + + expect(res.body.error).toContain('status must be one of'); + }); + + it('returns 400 for invalid priority in update payload', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Task' }); + const id = createRes.body.id; + + const res = await request(app) + .put(`/tasks/${id}`) + .send({ priority: 'ultra_high' }) + .expect(400); + + expect(res.body.error).toContain('priority must be one of'); + }); + + it('returns 400 for invalid dueDate in update payload', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Task' }); + const id = createRes.body.id; + + const res = await request(app) + .put(`/tasks/${id}`) + .send({ dueDate: 'bad-date' }) + .expect(400); + + expect(res.body.error).toContain('dueDate must be a valid ISO date string'); + }); + + it('accepts partial update body without wiping unmodified fields (behaving like partial update)', async () => { + const createRes = await request(app) + .post('/tasks') + .send({ title: 'Initial Title', description: 'Keep me', priority: 'high' }); + const id = createRes.body.id; + + const res = await request(app) + .put(`/tasks/${id}`) + .send({ status: 'done' }) + .expect(200); + + expect(res.body.status).toBe('done'); + expect(res.body.title).toBe('Initial Title'); + expect(res.body.description).toBe('Keep me'); + expect(res.body.priority).toBe('high'); + }); + + // BUG DISCOVERY: Immutable fields probe at HTTP layer + // PUT /tasks/:id allows overriding server-controlled id and createdAt + test.failing('does not allow overriding server-controlled fields (id, createdAt) via PUT body', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Task' }); + const originalId = createRes.body.id; + const originalCreatedAt = createRes.body.createdAt; + + const res = await request(app) + .put(`/tasks/${originalId}`) + .send({ + id: 'malicious-new-id', + createdAt: '1970-01-01T00:00:00.000Z', + title: 'Hacked', + }) + .expect(200); + + expect(res.body.id).toBe(originalId); + expect(res.body.createdAt).toBe(originalCreatedAt); + }); + }); + + describe('DELETE /tasks/:id', () => { + it('deletes the task and returns 204 with empty body, removing it from subsequent GET', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'To Delete' }); + const id = createRes.body.id; + + const deleteRes = await request(app) + .delete(`/tasks/${id}`) + .expect(204); + + expect(deleteRes.body).toEqual({}); + + const getRes = await request(app).get('/tasks').expect(200); + expect(getRes.body.find((t) => t.id === id)).toBeUndefined(); + }); + + it('returns 404 when attempting to delete a non-existent task ID', async () => { + const res = await request(app) + .delete('/tasks/non-existent-id') + .expect(404); + + expect(res.body.error).toBe('Task not found'); + }); + + it('returns 404 when deleting the same task ID twice (idempotency check)', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Delete twice' }); + const id = createRes.body.id; + + await request(app).delete(`/tasks/${id}`).expect(204); + await request(app).delete(`/tasks/${id}`).expect(404); + }); + }); + + describe('PATCH /tasks/:id/complete', () => { + it('marks an existing task as done and sets completedAt timestamp', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Finish this' }); + const id = createRes.body.id; + + const res = await request(app) + .patch(`/tasks/${id}/complete`) + .expect(200); + + expect(res.body.id).toBe(id); + expect(res.body.status).toBe('done'); + expect(typeof res.body.completedAt).toBe('string'); + expect(new Date(res.body.completedAt).getTime()).not.toBeNaN(); + }); + + it('returns 404 when marking a non-existent task as complete', async () => { + const res = await request(app) + .patch('/tasks/non-existent-id/complete') + .expect(404); + + expect(res.body.error).toBe('Task not found'); + }); + + it('is idempotent in setting status=done and returns 200 when completing an already-completed task', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Already complete' }); + const id = createRes.body.id; + + const firstComplete = await request(app).patch(`/tasks/${id}/complete`).expect(200); + const secondComplete = await request(app).patch(`/tasks/${id}/complete`).expect(200); + + expect(secondComplete.body.status).toBe('done'); + expect(secondComplete.body.completedAt).toBeDefined(); + }); + + // BUG DISCOVERY: Side-effect probe at HTTP layer + // PATCH /tasks/:id/complete silently resets priority to 'medium' + test.failing('preserves task priority when marking as complete', async () => { + const createRes = await request(app) + .post('/tasks') + .send({ title: 'High priority task', priority: 'high' }); + const id = createRes.body.id; + + const res = await request(app) + .patch(`/tasks/${id}/complete`) + .expect(200); + + expect(res.body.priority).toBe('high'); + }); + }); + + describe('GET /tasks/stats', () => { + it('returns 200 with all zero counts when the store is empty', async () => { + const res = await request(app) + .get('/tasks/stats') + .expect('Content-Type', /json/) + .expect(200); + + expect(res.body).toEqual({ + todo: 0, + in_progress: 0, + done: 0, + overdue: 0, + }); + }); + + it('returns accurate statistics and overdue counts', async () => { + const past = new Date(Date.now() - 3600000).toISOString(); + const future = new Date(Date.now() + 3600000).toISOString(); + + await request(app).post('/tasks').send({ title: 'T1', status: 'todo', dueDate: past }); + await request(app).post('/tasks').send({ title: 'T2', status: 'in_progress', dueDate: future }); + await request(app).post('/tasks').send({ title: 'T3', status: 'done', dueDate: past }); + + const res = await request(app).get('/tasks/stats').expect(200); + + expect(res.body).toEqual({ + todo: 1, + in_progress: 1, + done: 1, + overdue: 1, + }); + }); + }); + + describe('Malformed JSON handling', () => { + // BUG DISCOVERY: Global error handler in app.js catches JSON SyntaxError and returns 500 instead of 400 + test.failing('responds with 400 Client Error rather than 500 Internal Server Error when receiving malformed JSON', async () => { + const res = await request(app) + .post('/tasks') + .set('Content-Type', 'application/json') + .send('{"title": "Broken JSON, invalid syntax'); + + expect(res.status).toBe(400); + }); + }); +}); From 2b48e49d11394a143ef41ad455cdaabc55daeea9 Mon Sep 17 00:00:00 2001 From: SoumyaSriMishra Date: Sat, 15 Aug 2026 18:24:35 +0530 Subject: [PATCH 3/8] docs: add bug report (BUGS.md) --- BUGS.md | 148 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 BUGS.md diff --git a/BUGS.md b/BUGS.md new file mode 100644 index 00000000..bde662bc --- /dev/null +++ b/BUGS.md @@ -0,0 +1,148 @@ +# Bug Report: Task Manager API + +This document details the bugs identified in the Task Manager API through comprehensive unit and integration testing. Entries are ordered by severity, with the most impactful bugs listed first. + +--- + +## Bug 1: Pagination offset calculation skips page 1 entirely + +**Location:** `src/services/taskService.js`, function `getPaginated` (line 12) + +**Expected behavior:** In a standard 1-indexed pagination API (where page 1 is default, as handled in routes via `parseInt(page) || 1`), querying `page=1` with `limit=10` must compute an offset of `0` and return the first 10 items (index 0 to 9, e.g. `Task 1` through `Task 10`). + +**Actual behavior:** `offset` is computed as `const offset = page * limit;`. For `page=1` and `limit=10`, `offset` evaluates to `10`. As a result, requesting `page=1` skips the first 10 tasks and returns items 11 through 20. Requesting `page=2` returns items 21 through 30. The first `limit` tasks in the store can never be retrieved by standard 1-indexed pagination. + +**How discovered:** +- Unit test: `tests/taskService.test.js` -> `getPaginated() -> returns the first page (items 1 to 10) for page=1 and limit=10` +- Integration test: `tests/tasks.routes.test.js` -> `GET /tasks?page=&limit= -> returns page 1 containing the first 10 items (Task 1 to Task 10)` + +**Root cause:** The calculation treats the 1-based `page` parameter as a 0-based page index. It fails to convert the 1-indexed page number into a zero-based slice offset. + +**Suggested fix:** +```javascript +const getPaginated = (page, limit) => { + const pageNum = Math.max(1, page); + const offset = (pageNum - 1) * limit; + return tasks.slice(offset, offset + limit); +}; +``` + +**Severity/impact:** **Critical / High**. In production, all API clients requesting the first page of tasks will silently miss the most recent / initial `limit` tasks. If a user has fewer tasks than `limit` (e.g., 5 tasks total), `GET /tasks?page=1&limit=10` returns an empty array `[]`, making it appear that no tasks exist. + +--- + +## Bug 2: `completeTask` silently mutates and resets task `priority` to `'medium'` + +**Location:** `src/services/taskService.js`, function `completeTask` (line 69) + +**Expected behavior:** Marking a task as complete via `completeTask(id)` (and `PATCH /tasks/:id/complete`) should only change `status` to `'done'` and assign `completedAt`. All other task properties, including `priority`, must remain unchanged. + +**Actual behavior:** The function constructs the updated object with a hardcoded `priority: 'medium'`, overwriting existing `'high'` or `'low'` priorities upon task completion. + +**How discovered:** +- Unit test: `tests/taskService.test.js` -> `completeTask() -> preserves the original priority when marking a task as complete` +- Integration test: `tests/tasks.routes.test.js` -> `PATCH /tasks/:id/complete -> preserves task priority when marking as complete` + +**Root cause:** The updated object literal in `completeTask` contains `priority: 'medium'`, likely a copy-paste artifact from the default task template in `create()`. + +**Suggested fix:** +```javascript +const completeTask = (id) => { + const task = findById(id); + if (!task) return null; + + const updated = { + ...task, + status: 'done', + completedAt: new Date().toISOString(), + }; + + const index = tasks.findIndex((t) => t.id === id); + tasks[index] = updated; + return updated; +}; +``` + +**Severity/impact:** **High**. Data corruption of task metadata upon state transition. Critical audit records and priority-based filtering on completed tasks become permanently inaccurate. + +--- + +## Bug 3: `update` allows client to overwrite immutable server-managed fields (`id` and `createdAt`) + +**Location:** `src/services/taskService.js`, function `update` (line 50) + +**Expected behavior:** Updating a task via `PUT /tasks/:id` should only update mutable fields (`title`, `description`, `status`, `priority`, `dueDate`). Server-generated identity and timestamp properties (`id`, `createdAt`) must be protected from client modification. + +**Actual behavior:** `update` performs an unchecked shallow merge `const updated = { ...tasks[index], ...fields };`. If a client passes `id` or `createdAt` in the request body, those fields overwrite the internal record values. + +**How discovered:** +- Unit test: `tests/taskService.test.js` -> `update() -> does not allow overwriting server-managed immutable fields (id and createdAt)` +- Integration test: `tests/tasks.routes.test.js` -> `PUT /tasks/:id -> does not allow overriding server-controlled fields (id, createdAt) via PUT body` + +**Root cause:** Absence of field whitelisting or sanitization before merging the incoming `fields` object onto the existing task record. + +**Suggested fix:** +```javascript +const update = (id, fields) => { + const index = tasks.findIndex((t) => t.id === id); + if (index === -1) return null; + + const { id: _id, createdAt: _createdAt, ...allowedFields } = fields; + const updated = { ...tasks[index], ...allowedFields }; + tasks[index] = updated; + return updated; +}; +``` + +**Severity/impact:** **High (Security & Data Integrity)**. A client can maliciously or accidentally alter a task's UUID or backdate its `createdAt` timestamp, corrupting data integrity and breaking client lookup references. + +--- + +## Bug 4: Status filtering uses substring `.includes()` matching instead of exact equality + +**Location:** `src/services/taskService.js`, function `getByStatus` (line 9) + +**Expected behavior:** `getByStatus(status)` (and `GET /tasks?status=...`) should only return tasks whose `status` exactly matches the requested string (e.g. `'todo'` only returns tasks with status `'todo'`). A search for a substring like `'do'` should return no tasks since `'do'` is not a valid task status. + +**Actual behavior:** `getByStatus` uses `tasks.filter((t) => t.status.includes(status))`. Because `'do'` is a substring of both `'todo'` and `'done'`, querying `status=do` returns tasks with both `todo` and `done` statuses. + +**How discovered:** +- Unit test: `tests/taskService.test.js` -> `getByStatus() -> does not return tasks whose status merely contains the search term as a substring` +- Integration test: `tests/tasks.routes.test.js` -> `GET /tasks?status= -> does not return tasks on partial substring matches for status query` + +**Root cause:** The filter callback uses `String.prototype.includes` instead of strict equality `===`. + +**Suggested fix:** +```javascript +const getByStatus = (status) => tasks.filter((t) => t.status === status); +``` + +**Severity/impact:** **Medium**. Causes unintended task matching and breaks UI views that rely on precise category filtering. + +--- + +## Bug 5: Global error handler converts malformed JSON syntax errors into 500 Internal Server Errors + +**Location:** `src/app.js`, error middleware (lines 9–12) + +**Expected behavior:** When a client sends malformed JSON with `Content-Type: application/json`, Express's `body-parser` raises a 400 Bad Request error. The API should respond with an HTTP 400 status code indicating a client error. + +**Actual behavior:** The error middleware catches the `SyntaxError` and indiscriminately responds with `500 { error: 'Internal server error' }`. + +**How discovered:** +- Integration test: `tests/tasks.routes.test.js` -> `Malformed JSON handling -> responds with 400 Client Error rather than 500 Internal Server Error when receiving malformed JSON` + +**Root cause:** The error handler does not check `err.status` or `err instanceof SyntaxError` before defaulting to status 500. + +**Suggested fix:** +```javascript +app.use((err, req, res, next) => { + if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { + return res.status(400).json({ error: 'Invalid JSON payload' }); + } + console.error(err.stack); + res.status(500).json({ error: 'Internal server error' }); +}); +``` + +**Severity/impact:** **Medium / Low**. Misleads clients and monitoring tools into treating client payload syntax errors as server crashes. From e3218d7bec9985fe3446a981bcbd796a78742da1 Mon Sep 17 00:00:00 2001 From: SoumyaSriMishra Date: Sat, 15 Aug 2026 18:27:30 +0530 Subject: [PATCH 4/8] fix: fix pagination offset calculation in taskService --- BUGS.md | 3 +++ task-api/src/services/taskService.js | 3 ++- task-api/tests/taskService.test.js | 28 +++++++++++++++++----------- task-api/tests/tasks.routes.test.js | 4 +--- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/BUGS.md b/BUGS.md index bde662bc..f11b469f 100644 --- a/BUGS.md +++ b/BUGS.md @@ -29,6 +29,9 @@ const getPaginated = (page, limit) => { **Severity/impact:** **Critical / High**. In production, all API clients requesting the first page of tasks will silently miss the most recent / initial `limit` tasks. If a user has fewer tasks than `limit` (e.g., 5 tasks total), `GET /tasks?page=1&limit=10` returns an empty array `[]`, making it appear that no tasks exist. +**Fix Notes:** +Fixed in `src/services/taskService.js` by converting the 1-based page number to a 0-based offset with `const pageNum = Math.max(1, page); const offset = (pageNum - 1) * limit;`. Using `Math.max(1, page)` defensively guards against non-positive integers (e.g. `0` or negative numbers), ensuring deterministic pagination that always maps to valid slice offsets. All unit and route integration pagination tests now pass cleanly with 100% boundary accuracy without impacting any other endpoints. + --- ## Bug 2: `completeTask` silently mutates and resets task `priority` to `'medium'` diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index f8e89189..462d88e9 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -9,7 +9,8 @@ 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 pageNum = Math.max(1, page); + const offset = (pageNum - 1) * limit; return tasks.slice(offset, offset + limit); }; diff --git a/task-api/tests/taskService.test.js b/task-api/tests/taskService.test.js index fe601665..285edab0 100644 --- a/task-api/tests/taskService.test.js +++ b/task-api/tests/taskService.test.js @@ -151,16 +151,14 @@ describe('taskService (Unit Tests)', () => { } }); - // BUG DISCOVERY: Pagination offset math bug - // offset is calculated as `page * limit`, which for page=1 yields offset=10 (skipping tasks 1-10) - test.failing('returns the first page (items 1 to 10) for page=1 and limit=10', () => { + it('returns the first page (items 1 to 10) for page=1 and limit=10', () => { const page1 = taskService.getPaginated(1, 10); expect(page1).toHaveLength(10); expect(page1[0].title).toBe('Task 1'); expect(page1[9].title).toBe('Task 10'); }); - test.failing('returns the second page (items 11 to 20) for page=2 and limit=10 without gaps or overlaps', () => { + it('returns the second page (items 11 to 20) for page=2 and limit=10 without gaps or overlaps', () => { const page1 = taskService.getPaginated(1, 10); const page2 = taskService.getPaginated(2, 10); @@ -174,11 +172,10 @@ describe('taskService (Unit Tests)', () => { }); it('returns remaining items on the last partial page', () => { - // With current offset=page*limit: page 2 offset=20, items 21..25 - const page2 = taskService.getPaginated(2, 10); - expect(page2).toHaveLength(5); - expect(page2[0].title).toBe('Task 21'); - expect(page2[4].title).toBe('Task 25'); + const page3 = taskService.getPaginated(3, 10); + expect(page3).toHaveLength(5); + expect(page3[0].title).toBe('Task 21'); + expect(page3[4].title).toBe('Task 25'); }); it('returns empty array when page is beyond the total items', () => { @@ -186,9 +183,18 @@ describe('taskService (Unit Tests)', () => { expect(result).toEqual([]); }); + it('clamps non-positive page numbers to page 1', () => { + const page0 = taskService.getPaginated(0, 10); + expect(page0).toHaveLength(10); + expect(page0[0].title).toBe('Task 1'); + + const pageNeg = taskService.getPaginated(-2, 10); + expect(pageNeg).toHaveLength(10); + expect(pageNeg[0].title).toBe('Task 1'); + }); + it('returns all items if limit is larger than total count', () => { - // With page 0 in current implementation: offset = 0 * 50 = 0 - const result = taskService.getPaginated(0, 50); + const result = taskService.getPaginated(1, 50); expect(result).toHaveLength(25); }); }); diff --git a/task-api/tests/tasks.routes.test.js b/task-api/tests/tasks.routes.test.js index 7cb1d956..475b762a 100644 --- a/task-api/tests/tasks.routes.test.js +++ b/task-api/tests/tasks.routes.test.js @@ -193,9 +193,7 @@ describe('Task Routes (Integration Tests)', () => { } }); - // BUG DISCOVERY: HTTP layer pagination offset bug - // Route passes pageNum=1, limitNum=10 to service, which computes offset = 1 * 10 = 10, skipping Task 1..10 - test.failing('returns page 1 containing the first 10 items (Task 1 to Task 10)', async () => { + it('returns page 1 containing the first 10 items (Task 1 to Task 10)', async () => { const res = await request(app) .get('/tasks?page=1&limit=10') .expect(200); From 8e2a6d87f888a3deca9e5e9cda454f706ba2aef8 Mon Sep 17 00:00:00 2001 From: SoumyaSriMishra Date: Sat, 15 Aug 2026 18:29:03 +0530 Subject: [PATCH 5/8] feat: add PATCH /tasks/:id/assign endpoint --- task-api/src/routes/tasks.js | 16 +++++++++++++++- task-api/src/services/taskService.js | 16 ++++++++++++++++ task-api/src/utils/validators.js | 12 +++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/task-api/src/routes/tasks.js b/task-api/src/routes/tasks.js index e8c370fe..0587ecc0 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,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.trim()); + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + res.json(task); +}); + module.exports = router; diff --git a/task-api/src/services/taskService.js b/task-api/src/services/taskService.js index 462d88e9..8eb7f974 100644 --- a/task-api/src/services/taskService.js +++ b/task-api/src/services/taskService.js @@ -39,6 +39,7 @@ const create = ({ title, description = '', status = 'todo', priority = 'medium', dueDate, completedAt: null, createdAt: new Date().toISOString(), + assignee: null, }; tasks.push(task); return task; @@ -77,6 +78,20 @@ const completeTask = (id) => { return updated; }; +const assignTask = (id, assignee) => { + const task = findById(id); + if (!task) return null; + + const updated = { + ...task, + assignee, + }; + + const index = tasks.findIndex((t) => t.id === id); + tasks[index] = updated; + return updated; +}; + const _reset = () => { tasks = []; }; @@ -91,5 +106,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..25e66574 100644 --- a/task-api/src/utils/validators.js +++ b/task-api/src/utils/validators.js @@ -33,4 +33,14 @@ const validateUpdateTask = (body) => { return null; }; -module.exports = { validateCreateTask, validateUpdateTask }; +const validateAssignTask = (body) => { + if (!body || body.assignee === undefined || typeof body.assignee !== 'string' || body.assignee.trim() === '') { + return 'assignee is required and must be a non-empty string'; + } + if (body.assignee.trim().length > 100) { + return 'assignee must not exceed 100 characters'; + } + return null; +}; + +module.exports = { validateCreateTask, validateUpdateTask, validateAssignTask }; From 342002db2dc7a6f6e52f1bf120baab7853274c2c Mon Sep 17 00:00:00 2001 From: SoumyaSriMishra Date: Sat, 15 Aug 2026 18:29:18 +0530 Subject: [PATCH 6/8] test: add tests for assign endpoint --- task-api/tests/taskService.test.js | 2 + task-api/tests/tasks.assign.test.js | 224 ++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 task-api/tests/tasks.assign.test.js diff --git a/task-api/tests/taskService.test.js b/task-api/tests/taskService.test.js index 285edab0..64483a5f 100644 --- a/task-api/tests/taskService.test.js +++ b/task-api/tests/taskService.test.js @@ -37,6 +37,7 @@ describe('taskService (Unit Tests)', () => { expect(task.priority).toBe('medium'); expect(task.dueDate).toBeNull(); expect(task.completedAt).toBeNull(); + expect(task.assignee).toBeNull(); expect(task.id).toBeDefined(); expect(task.createdAt).toBeDefined(); }); @@ -68,6 +69,7 @@ describe('taskService (Unit Tests)', () => { 'dueDate', 'completedAt', 'createdAt', + 'assignee', ]); }); }); diff --git a/task-api/tests/tasks.assign.test.js b/task-api/tests/tasks.assign.test.js new file mode 100644 index 00000000..8071e59b --- /dev/null +++ b/task-api/tests/tasks.assign.test.js @@ -0,0 +1,224 @@ +const request = require('supertest'); +const app = require('../src/app'); +const taskService = require('../src/services/taskService'); +const { validateAssignTask } = require('../src/utils/validators'); + +describe('PATCH /tasks/:id/assign & assignTask (Unit & Integration Tests)', () => { + beforeEach(() => { + taskService._reset(); + }); + + describe('Validator: validateAssignTask()', () => { + it('returns null for valid non-empty string assignees', () => { + expect(validateAssignTask({ assignee: 'Alice' })).toBeNull(); + expect(validateAssignTask({ assignee: 'Bob Smith' })).toBeNull(); + expect(validateAssignTask({ assignee: 'user-123@example.com' })).toBeNull(); + }); + + it('returns error when assignee is missing or body is undefined/empty', () => { + expect(validateAssignTask({})).toContain('assignee is required'); + expect(validateAssignTask({ otherField: 'test' })).toContain('assignee is required'); + expect(validateAssignTask(null)).toContain('assignee is required'); + expect(validateAssignTask(undefined)).toContain('assignee is required'); + }); + + it('returns error when assignee is an empty string or whitespace-only', () => { + expect(validateAssignTask({ assignee: '' })).toContain('assignee is required'); + expect(validateAssignTask({ assignee: ' ' })).toContain('assignee is required'); + expect(validateAssignTask({ assignee: '\t\n' })).toContain('assignee is required'); + }); + + it('returns error when assignee is not a string type', () => { + expect(validateAssignTask({ assignee: 123 })).toContain('assignee is required'); + expect(validateAssignTask({ assignee: true })).toContain('assignee is required'); + expect(validateAssignTask({ assignee: ['Alice'] })).toContain('assignee is required'); + expect(validateAssignTask({ assignee: { name: 'Alice' } })).toContain('assignee is required'); + expect(validateAssignTask({ assignee: null })).toContain('assignee is required'); + }); + + it('returns error when assignee exceeds max length (100 characters)', () => { + const longName = 'A'.repeat(101); + expect(validateAssignTask({ assignee: longName })).toContain('must not exceed 100 characters'); + + const exactMaxLengthName = 'A'.repeat(100); + expect(validateAssignTask({ assignee: exactMaxLengthName })).toBeNull(); + }); + }); + + describe('Service: assignTask()', () => { + it('assigns assignee to task and returns updated task', () => { + const created = taskService.create({ title: 'Task to assign' }); + expect(created.assignee).toBeNull(); + + const assigned = taskService.assignTask(created.id, 'Alice'); + expect(assigned).toBeDefined(); + expect(assigned.id).toBe(created.id); + expect(assigned.assignee).toBe('Alice'); + + const found = taskService.findById(created.id); + expect(found.assignee).toBe('Alice'); + }); + + it('returns null when assigning to non-existent task ID', () => { + const result = taskService.assignTask('non-existent-uuid', 'Alice'); + expect(result).toBeNull(); + }); + + it('preserves all existing properties when assigning', () => { + const created = taskService.create({ + title: 'Original Title', + description: 'Original Description', + status: 'in_progress', + priority: 'high', + dueDate: '2026-12-31T23:59:59.000Z', + }); + + const assigned = taskService.assignTask(created.id, 'Bob'); + + expect(assigned.title).toBe('Original Title'); + expect(assigned.description).toBe('Original Description'); + expect(assigned.status).toBe('in_progress'); + expect(assigned.priority).toBe('high'); + expect(assigned.dueDate).toBe('2026-12-31T23:59:59.000Z'); + expect(assigned.createdAt).toBe(created.createdAt); + expect(assigned.completedAt).toBeNull(); + expect(assigned.assignee).toBe('Bob'); + }); + }); + + describe('Integration: PATCH /tasks/:id/assign', () => { + it('successfully assigns a task (200) with trimmed assignee and preserves other fields', async () => { + const createRes = await request(app) + .post('/tasks') + .send({ + title: 'Review PR', + description: 'Check unit tests', + priority: 'high', + status: 'in_progress', + }) + .expect(201); + + const id = createRes.body.id; + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: ' Alice Johnson ' }) + .expect('Content-Type', /json/) + .expect(200); + + expect(res.body.id).toBe(id); + expect(res.body.assignee).toBe('Alice Johnson'); + expect(res.body.title).toBe('Review PR'); + expect(res.body.description).toBe('Check unit tests'); + expect(res.body.priority).toBe('high'); + expect(res.body.status).toBe('in_progress'); + expect(res.body.createdAt).toBe(createRes.body.createdAt); + expect(res.body.completedAt).toBeNull(); + }); + + it('returns 404 if the task does not exist', async () => { + const res = await request(app) + .patch('/tasks/non-existent-id/assign') + .send({ assignee: 'Alice' }) + .expect(404); + + expect(res.body).toEqual({ error: 'Task not found' }); + }); + + it('returns 400 if assignee field is missing from request body', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Task' }); + const id = createRes.body.id; + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({}) + .expect(400); + + expect(res.body.error).toContain('assignee is required and must be a non-empty string'); + }); + + it('returns 400 if assignee is empty string or whitespace-only', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Task' }); + const id = createRes.body.id; + + const resEmpty = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: '' }) + .expect(400); + expect(resEmpty.body.error).toContain('assignee is required and must be a non-empty string'); + + const resWhitespace = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: ' ' }) + .expect(400); + expect(resWhitespace.body.error).toContain('assignee is required and must be a non-empty string'); + }); + + it('returns 400 if assignee is a non-string type (number, boolean, object, array)', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Task' }); + const id = createRes.body.id; + + const resNum = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 42 }) + .expect(400); + expect(resNum.body.error).toContain('assignee is required and must be a non-empty string'); + + const resObj = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: { name: 'Alice' } }) + .expect(400); + expect(resObj.body.error).toContain('assignee is required and must be a non-empty string'); + }); + + it('returns 400 if assignee exceeds max length (100 characters)', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Task' }); + const id = createRes.body.id; + + const res = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'A'.repeat(101) }) + .expect(400); + + expect(res.body.error).toContain('assignee must not exceed 100 characters'); + }); + + it('allows reassigning an already-assigned task (idempotent PATCH semantics)', async () => { + const createRes = await request(app).post('/tasks').send({ title: 'Reassign Task' }); + const id = createRes.body.id; + + const firstAssign = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'Alice' }) + .expect(200); + expect(firstAssign.body.assignee).toBe('Alice'); + + const secondAssign = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'Bob' }) + .expect(200); + expect(secondAssign.body.assignee).toBe('Bob'); + + // Verify subsequent GET reflects the reassigned assignee + const getRes = await request(app).get('/tasks').expect(200); + const task = getRes.body.find((t) => t.id === id); + expect(task.assignee).toBe('Bob'); + }); + + it('does not mutate status, completedAt, or priority when assigning a task', async () => { + const createRes = await request(app) + .post('/tasks') + .send({ title: 'Task with priority', priority: 'high', status: 'todo' }); + const id = createRes.body.id; + + const assignRes = await request(app) + .patch(`/tasks/${id}/assign`) + .send({ assignee: 'Charlie' }) + .expect(200); + + expect(assignRes.body.priority).toBe('high'); + expect(assignRes.body.status).toBe('todo'); + expect(assignRes.body.completedAt).toBeNull(); + }); + }); +}); From eb06d6288f884b024495259dbeaebcd3c59d6770 Mon Sep 17 00:00:00 2001 From: SoumyaSriMishra Date: Sat, 15 Aug 2026 18:30:06 +0530 Subject: [PATCH 7/8] docs: add SUBMISSION.md --- SUBMISSION.md | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 SUBMISSION.md diff --git a/SUBMISSION.md b/SUBMISSION.md new file mode 100644 index 00000000..f7d2bfd4 --- /dev/null +++ b/SUBMISSION.md @@ -0,0 +1,132 @@ +# Take-Home Assignment Submission: Task Manager API + +**Candidate:** Senior Full-Stack Node.js / SDET +**Date:** August 2026 +**Repository:** [Task Manager API](.) + +--- + +## 1. Test Coverage Summary + +Full test suite execution with Jest and Supertest across all unit and route integration test files (`tests/taskService.test.js`, `tests/tasks.routes.test.js`, `tests/tasks.assign.test.js`): + +``` +-----------------|---------|----------|---------|---------|------------------- +File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s +-----------------|---------|----------|---------|---------|------------------- +All files | 98.73 | 98.87 | 96.66 | 98.61 | + src | 84.61 | 75 | 50 | 84.61 | + app.js | 84.61 | 75 | 50 | 84.61 | 17-18 + src/routes | 100 | 100 | 100 | 100 | + tasks.js | 100 | 100 | 100 | 100 | + src/services | 100 | 100 | 100 | 100 | + taskService.js | 100 | 100 | 100 | 100 | + src/utils | 100 | 100 | 100 | 100 | + validators.js | 100 | 100 | 100 | 100 | +-----------------|---------|----------|---------|---------|------------------- + +Test Suites: 3 passed, 3 total +Tests: 80 passed, 80 total +Snapshots: 0 total +Time: 5.119 s +``` + +> **Note on uncovered lines in `src/app.js` (lines 17–18):** +> Lines 17–18 contain `if (require.main === module) { app.listen(PORT, ...); }`. In the automated test environment, `app` is imported directly into Supertest without binding a live TCP socket port, which is the intended design for clean test isolation. + +--- + +## 2. Discovered Bugs & Fix + +A comprehensive bug report detailing all 5 discovered bugs is documented in **[`BUGS.md`](./BUGS.md)**. + +### Summary of Bugs: +1. **Bug 1 (Critical / High — FIXED):** `taskService.getPaginated` calculated `offset = page * limit`, causing `page=1` to skip the first 10 items. +2. **Bug 2 (High):** `taskService.completeTask` silently reset task `priority` to `'medium'`. +3. **Bug 3 (High - Data Integrity):** `taskService.update` allowed client payloads to overwrite internal `id` and `createdAt` timestamps. +4. **Bug 4 (Medium):** `taskService.getByStatus` used `.includes()` substring matching instead of exact equality. +5. **Bug 5 (Medium):** Express global error handler returned HTTP 500 on client malformed JSON syntax errors. + +### Implemented Fix: Bug 1 (Pagination Offset Math) +- **Location:** `src/services/taskService.js` (`getPaginated`) +- **Fix:** Replaced `page * limit` with `const pageNum = Math.max(1, page); const offset = (pageNum - 1) * limit;`. +- **Rationale:** Highest impact and lowest blast radius. It ensures `page=1` correctly maps to offset 0 while defensively guarding against 0 or negative page numbers. + +--- + +## 3. New Feature: `PATCH /tasks/:id/assign` + +### Endpoint Specification +- **Method & Route:** `PATCH /tasks/:id/assign` +- **Request Body:** `{ "assignee": "string" }` +- **Response Codes:** + - `200 OK`: Returns the updated task object with `assignee` populated. + - `400 Bad Request`: Validation failure (missing `assignee`, non-string, empty or whitespace-only, or exceeding 100 characters). + - `404 Not Found`: Task with the given `:id` does not exist. + +### Design Decisions + +1. **Reassignment Policy (Idempotent PATCH Semantics):** + We decided to **allow free reassignment** of already-assigned tasks rather than returning a 409 Conflict. In task management domains (e.g. Jira, Linear, Asana), tickets are frequently handed off between team members. Modeling reassignment as an idempotent `PATCH` mutation adheres to standard REST semantics and avoids requiring clients to perform an artificial unassign-then-assign round trip. + +2. **Validation & Guards:** + - The validator `validateAssignTask` requires `assignee` to be present and of `string` type. + - It strips leading/trailing whitespace (`.trim()`) and rejects empty or whitespace-only strings. + - A maximum length guard of **100 characters** is enforced to comfortably accommodate personal names, full names with titles, or email identifiers (`user@company.com`), while guarding the in-memory store against memory bloat or DoS attacks. + - The stored value is the sanitized (trimmed) string. + +3. **Task Shape & `assignee: null` Default:** + We updated `taskService.create()` to initialize `assignee: null` by default on new tasks. Maintaining a predictable, uniform object schema prevents client-side `undefined` property errors and ensures consistent serialization across API responses. + +--- + +## 4. Documentation Inconsistencies Identified + +1. **Status Enum Inconsistency (`README.md` vs `ASSIGNMENT.md` / Code):** + - `README.md` lists status values as `"pending | in-progress | completed"`. + - `ASSIGNMENT.md` and the actual runtime validators (`src/utils/validators.js`) enforce `"todo | in_progress | done"`. + - *Resolution:* All tests and features conform to `"todo | in_progress | done"`, which is what the server actively enforces. + +2. **`PUT` Endpoint Semantics (`README.md` vs Implementation):** + - `README.md` describes `PUT /tasks/:id` as a *"Full update of a task"*. + - However, `src/routes/tasks.js` and `src/utils/validators.js` allow partial updates (all fields in `PUT` are optional), and `taskService.update` performs a shallow merge (`{ ...tasks[index], ...fields }`). + - *Recommendation:* In a future iteration, either rename `PUT` to `PATCH /tasks/:id` for partial updates or make all mandatory fields required for `PUT` to enforce strict REST full replacement semantics. + +--- + +## 5. What I'd Test Next (Given More Time) + +1. **In-Memory Store Concurrency & Race Conditions:** + Simulate high-concurrency scenarios (e.g. 50 parallel requests creating, updating, and deleting tasks) to evaluate race conditions on the mutable module-level `tasks` array. +2. **Missing Single-Resource Endpoint (`GET /tasks/:id`):** + Currently, there is no route to retrieve a single task by its ID (`GET /tasks/:id`), forcing clients to fetch all tasks and filter client-side. Adding and testing this endpoint is a high-priority gap. +3. **Request Payload Size & Rate Limiting:** + Test payload size limits (`express.json({ limit: '100kb' })`) and add rate-limiting integration tests (`express-rate-limit`) to protect public endpoints. +4. **Timezone & Date Parsing Edge Cases:** + Deep testing of edge cases around ISO 8601 dates: UTC offsets, leap years, daylight saving transitions, and invalid calendar dates like `2026-02-30`. +5. **Combined Query Filters (`GET /tasks?status=todo&page=1&limit=10`):** + Currently, the route handler evaluates `if (status)` before `if (page || limit)`, meaning status filtering completely ignores pagination query parameters. Testing and supporting combined filtering + pagination is essential. + +--- + +## 6. What Surprised Me in the Codebase + +- **Substring Matching for Status Filtering:** `taskService.getByStatus` used `t.status.includes(status)` rather than `===`. This caused queries like `status=do` to match both `todo` and `done` tasks simultaneously. +- **Unintended Side Effect in `completeTask`:** `completeTask` explicitly hardcoded `priority: 'medium'`, silently wiping out `'high'` or `'low'` priority settings whenever a task was completed. +- **Direct Multiplier for Pagination:** `getPaginated` computed `offset = page * limit` without converting 1-based page numbers to 0-based index offsets, meaning page 1 immediately started at index 10 and skipped the first 10 items. +- **Global Error Handler Masking 400s:** The error middleware caught Express `body-parser` JSON syntax errors and unconditionally returned HTTP 500 instead of HTTP 400. + +--- + +## 7. Questions I'd Ask Before Shipping to Production + +1. **Storage & Durability Strategy:** + *What database will replace the in-memory array (e.g. PostgreSQL, DynamoDB, MongoDB), and what transaction isolation level is needed for atomic task state transitions?* +2. **User Identity & Assignee Relation:** + *Should `assignee` remain a free-form string, or should it validate against a real User Service / Auth provider with UUID foreign keys?* +3. **Pagination Semantics & Contract:** + *Should we standardize on 1-indexed offset pagination with total count metadata in the response headers/envelope (`{ data: [...], total, page, totalPages }`), or migrate to cursor-based pagination for large datasets?* +4. **API Versioning & Breaking Changes:** + *Before fixing `PUT` full update semantics and status enum documentation mismatches, are there existing mobile or web clients in production that rely on the partial merge behavior or existing enum strings?* +5. **Authentication & Authorization:** + *What authentication mechanism (e.g. JWT, OAuth2) and permission model (e.g. can any user assign/complete any task, or only task creators and assignees?) should be integrated before public deployment?* From de38a893bf54c7c29df561dca6c73ee3dbe033da Mon Sep 17 00:00:00 2001 From: SoumyaSriMishra Date: Sat, 15 Aug 2026 20:38:08 +0530 Subject: [PATCH 8/8] docs: update README.md with assign endpoint and task shape --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5d46160c..648dfaef 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ ASSIGNMENT.md # Full brief — read this first | `DELETE` | `/tasks/:id` | Delete a task (returns 204) | | `PATCH` | `/tasks/:id/complete` | Mark a task as complete | | `GET` | `/tasks/stats` | Counts by status + overdue count | -| `PATCH` | `/tasks/:id/assign` | **Assign a task to a user** _(to implement)_ | +| `PATCH` | `/tasks/:id/assign` | Assign a task to a user | ### Task shape @@ -74,11 +74,12 @@ ASSIGNMENT.md # Full brief — read this first "id": "uuid", "title": "string", "description": "string", - "status": "pending | in-progress | completed", + "status": "todo | in_progress | done", "priority": "low | medium | high", "dueDate": "ISO 8601 or null", "completedAt": "ISO 8601 or null", - "createdAt": "ISO 8601" + "createdAt": "ISO 8601", + "assignee": "string or null" } ``` @@ -93,7 +94,7 @@ curl -X POST http://localhost:3000/tasks \ **List tasks with filter** ```bash -curl "http://localhost:3000/tasks?status=pending&page=1&limit=10" +curl "http://localhost:3000/tasks?status=todo&page=1&limit=10" ``` **Mark complete** @@ -101,6 +102,13 @@ curl "http://localhost:3000/tasks?status=pending&page=1&limit=10" curl -X PATCH http://localhost:3000/tasks//complete ``` +**Assign a task** +```bash +curl -X PATCH http://localhost:3000/tasks//assign \ + -H "Content-Type: application/json" \ + -d '{"assignee": "Soumya"}' +``` + --- ## What to Submit