Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions task-api/src/routes/tasks.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
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');
const VALID_STATUSES = ['todo', 'in_progress', 'done'];

const isPositiveIntegerString = (value) => /^\d+$/.test(String(value)) && Number(value) > 0;

router.get('/stats', (req, res) => {
const stats = taskService.getStats();
Expand All @@ -12,13 +15,24 @@ router.get('/', (req, res) => {
const { status, page, limit } = req.query;

if (status) {
if (!VALID_STATUSES.includes(status)) {
return res.status(400).json({ error: `status must be one of: ${VALID_STATUSES.join(', ')}` });
}
const tasks = taskService.getByStatus(status);
return res.json(tasks);
}

if (page !== undefined || limit !== undefined) {
const pageNum = parseInt(page) || 1;
const limitNum = parseInt(limit) || 10;
if (page !== undefined && !isPositiveIntegerString(page)) {
return res.status(400).json({ error: 'page must be a positive integer' });
}

if (limit !== undefined && !isPositiveIntegerString(limit)) {
return res.status(400).json({ error: 'limit must be a positive integer' });
}

const pageNum = page === undefined ? 1 : Number(page);
const limitNum = limit === undefined ? 10 : Number(limit);
const tasks = taskService.getPaginated(pageNum, limitNum);
return res.json(tasks);
}
Expand All @@ -27,6 +41,15 @@ router.get('/', (req, res) => {
res.json(tasks);
});

router.get('/:id', (req, res) => {
const task = taskService.findById(req.params.id);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}

res.json(task);
});

router.post('/', (req, res) => {
const error = validateCreateTask(req.body);
if (error) {
Expand Down Expand Up @@ -69,4 +92,18 @@ router.patch('/:id/complete', (req, res) => {
res.json(task);
});

router.patch('/:id/assign', (req, res) => {
const error = validateAssignTask(req.body);
if (error) {
return res.status(400).json({ error });
}

const task = taskService.assignTask(req.params.id, req.body.assignee);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}

res.json(task);
});

module.exports = router;
34 changes: 30 additions & 4 deletions task-api/src/services/taskService.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
const { v4: uuidv4 } = require('uuid');

let tasks = [];
const UPDATABLE_FIELDS = ['title', 'description', 'status', 'priority', 'dueDate'];

const getAll = () => [...tasks];

const findById = (id) => tasks.find((t) => t.id === id);

const getByStatus = (status) => tasks.filter((t) => t.status.includes(status));
const getByStatus = (status) => tasks.filter((t) => t.status===status);

const getPaginated = (page, limit) => {
const offset = page * limit;
if (!Number.isInteger(page) || !Number.isInteger(limit) || page < 1 || limit < 1) {
return [];
}

const offset = (page-1) * limit;
return tasks.slice(offset, offset + limit);
};

Expand All @@ -36,6 +41,7 @@ const create = ({ title, description = '', status = 'todo', priority = 'medium',
status,
priority,
dueDate,
assignee: null,
completedAt: null,
createdAt: new Date().toISOString(),
};
Expand All @@ -47,7 +53,14 @@ const update = (id, fields) => {
const index = tasks.findIndex((t) => t.id === id);
if (index === -1) return null;

const updated = { ...tasks[index], ...fields };
const updates = UPDATABLE_FIELDS.reduce((acc, field) => {
if (fields[field] !== undefined) {
acc[field] = fields[field];
}
return acc;
}, {});

const updated = { ...tasks[index], ...updates };
tasks[index] = updated;
return updated;
};
Expand All @@ -66,7 +79,6 @@ const completeTask = (id) => {

const updated = {
...task,
priority: 'medium',
status: 'done',
completedAt: new Date().toISOString(),
};
Expand All @@ -76,6 +88,19 @@ const completeTask = (id) => {
return updated;
};

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

const updated = {
...tasks[index],
assignee: assignee.trim(),
};

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

const _reset = () => {
tasks = [];
};
Expand All @@ -90,5 +115,6 @@ module.exports = {
update,
remove,
completeTask,
assignTask,
_reset,
};
63 changes: 54 additions & 9 deletions task-api/src/utils/validators.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,81 @@
const VALID_STATUSES = ['todo', 'in_progress', 'done'];
const VALID_PRIORITIES = ['low', 'medium', 'high'];
const PROTECTED_UPDATE_FIELDS = ['id', 'createdAt', 'completedAt'];
const MUTABLE_UPDATE_FIELDS = ['title', 'description', 'status', 'priority', 'dueDate'];
const VALID_ASSIGN_FIELDS = ['assignee'];

const isValidFutureDueDate = (dueDate) => {
const parsedDate = Date.parse(dueDate);
if (isNaN(parsedDate)) {
return false;
}

return parsedDate > Date.now();
};

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.status && !VALID_STATUSES.includes(body.status)) {
if (body.status !== undefined && !VALID_STATUSES.includes(body.status)) {
return `status must be one of: ${VALID_STATUSES.join(', ')}`;
}
if (body.priority && !VALID_PRIORITIES.includes(body.priority)) {
if (body.priority !== undefined && !VALID_PRIORITIES.includes(body.priority)) {
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';
if (body.dueDate !== undefined) {
if (!isValidFutureDueDate(body.dueDate)) {
return 'dueDate must be a valid ISO date string in the future';
}
}
return null;
};

const validateUpdateTask = (body) => {
const protectedField = PROTECTED_UPDATE_FIELDS.find((field) => body[field] !== undefined);
if (protectedField) {
return `${protectedField} cannot be updated`;
}

const invalidField = Object.keys(body).find(
(field) => !MUTABLE_UPDATE_FIELDS.includes(field) && !PROTECTED_UPDATE_FIELDS.includes(field)
);
if (invalidField) {
return `${invalidField} is not a valid task field`;
}

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)) {
if (body.status !== undefined && !VALID_STATUSES.includes(body.status)) {
return `status must be one of: ${VALID_STATUSES.join(', ')}`;
}
if (body.priority && !VALID_PRIORITIES.includes(body.priority)) {
if (body.priority !== undefined && !VALID_PRIORITIES.includes(body.priority)) {
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';
if (body.dueDate !== undefined) {
if (!isValidFutureDueDate(body.dueDate)) {
return 'dueDate must be a valid ISO date string in the future';
}
}
return null;
};

module.exports = { validateCreateTask, validateUpdateTask };
const validateAssignTask = (body) => {
const invalidField = Object.keys(body).find((field) => !VALID_ASSIGN_FIELDS.includes(field));
if (invalidField) {
return `${invalidField} is not a valid task field`;
}

if (body.assignee === undefined) {
return 'assignee is required';
}

if (typeof body.assignee !== 'string' || body.assignee.trim() === '') {
return 'assignee must be a non-empty string';
}

return null;
};

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

beforeEach(() => {
taskService._reset();
});

describe('PATCH /tasks/:id/assign', () => {
let taskId;

beforeEach(async () => {
const res = await request(app).post('/tasks').send({
title: 'Unassigned Task',
description: 'Needs a person',
status: 'todo',
priority: 'medium'
});

taskId = res.body.id;
});

it('should assign a task to a user', async () => {
const res = await request(app)
.patch(`/tasks/${taskId}/assign`)
.send({ assignee: 'Ada Lovelace' });

expect(res.statusCode).toBe(200);
expect(res.body.id).toBe(taskId);
expect(res.body.assignee).toBe('Ada Lovelace');
});

it('should trim whitespace from assignee names', async () => {
const res = await request(app)
.patch(`/tasks/${taskId}/assign`)
.send({ assignee: ' Grace Hopper ' });

expect(res.statusCode).toBe(200);
expect(res.body.assignee).toBe('Grace Hopper');
});

it('should allow reassignment', async () => {
await request(app)
.patch(`/tasks/${taskId}/assign`)
.send({ assignee: 'First Person' });

const res = await request(app)
.patch(`/tasks/${taskId}/assign`)
.send({ assignee: 'Second Person' });

expect(res.statusCode).toBe(200);
expect(res.body.assignee).toBe('Second Person');
});

it('should return 400 for empty assignee', async () => {
const res = await request(app)
.patch(`/tasks/${taskId}/assign`)
.send({ assignee: ' ' });

expect(res.statusCode).toBe(400);
});

it('should return 404 for non-existing task', async () => {
const fakeId = '123e4567-e89b-12d3-a456-426614174000';

const res = await request(app)
.patch(`/tasks/${fakeId}/assign`)
.send({ assignee: 'Ada Lovelace' });

expect(res.statusCode).toBe(404);
});
});
Loading