diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7db041b --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Dependencies +node_modules/ + +# Database +tasks.db +*.db + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +.env.local +.env.*.local + +# OS files +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Test coverage +coverage/ +.nyc_output/ + +# Build artifacts +dist/ +build/ +*.tgz diff --git a/database.js b/database.js index feb0890..13f2fb3 100644 --- a/database.js +++ b/database.js @@ -16,6 +16,32 @@ const initialize = () => { updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); + + // Create labels table + db.run(` + CREATE TABLE IF NOT EXISTS labels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT DEFAULT '#808080', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Create task_labels junction table for many-to-many relationship + db.run(` + CREATE TABLE IF NOT EXISTS task_labels ( + task_id INTEGER NOT NULL, + label_id INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (task_id, label_id), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE + ) + `); + + // Create indexes for optimal query performance + db.run(`CREATE INDEX IF NOT EXISTS idx_task_labels_task_id ON task_labels(task_id)`); + db.run(`CREATE INDEX IF NOT EXISTS idx_task_labels_label_id ON task_labels(label_id)`); }); }; @@ -54,6 +80,149 @@ const closeDatabase = () => { db.close(); }; +// Label operations +const createLabel = (name, color, callback) => { + const sql = `INSERT INTO labels (name, color) VALUES (?, ?)`; + db.run(sql, [name, color || '#808080'], function(err) { + callback(err, this.lastID); + }); +}; + +const getAllLabels = (callback) => { + const sql = `SELECT * FROM labels ORDER BY name ASC`; + db.all(sql, [], callback); +}; + +const getLabelById = (id, callback) => { + const sql = `SELECT * FROM labels WHERE id = ?`; + db.get(sql, [id], callback); +}; + +// Optimized function to get labels for multiple tasks in a single query +// This avoids N+1 query problem +const getTaskLabelsOptimized = (taskIds, callback) => { + if (!taskIds || taskIds.length === 0) { + return callback(null, {}); + } + + const placeholders = taskIds.map(() => '?').join(','); + const sql = ` + SELECT + tl.task_id, + l.id, + l.name, + l.color, + l.created_at + FROM task_labels tl + INNER JOIN labels l ON tl.label_id = l.id + WHERE tl.task_id IN (${placeholders}) + ORDER BY l.name ASC + `; + + db.all(sql, taskIds, (err, rows) => { + if (err) { + return callback(err); + } + + // Group labels by task_id for easy lookup + const labelsByTask = {}; + rows.forEach(row => { + if (!labelsByTask[row.task_id]) { + labelsByTask[row.task_id] = []; + } + labelsByTask[row.task_id].push({ + id: row.id, + name: row.name, + color: row.color, + created_at: row.created_at + }); + }); + + callback(null, labelsByTask); + }); +}; + +// Get labels for a single task (convenience function) +const getTaskLabels = (taskId, callback) => { + getTaskLabelsOptimized([taskId], (err, labelsByTask) => { + if (err) { + return callback(err); + } + callback(null, labelsByTask[taskId] || []); + }); +}; + +const assignLabelToTask = (taskId, labelId, callback) => { + const sql = `INSERT OR IGNORE INTO task_labels (task_id, label_id) VALUES (?, ?)`; + db.run(sql, [taskId, labelId], callback); +}; + +const removeLabelFromTask = (taskId, labelId, callback) => { + const sql = `DELETE FROM task_labels WHERE task_id = ? AND label_id = ?`; + db.run(sql, [taskId, labelId], callback); +}; + +// Bulk operations for better performance +const bulkAssignLabels = (assignments, callback) => { + if (!assignments || assignments.length === 0) { + return callback(null); + } + + let completed = 0; + let callbackCalled = false; + + assignments.forEach(({ taskId, labelId }) => { + db.run( + `INSERT OR IGNORE INTO task_labels (task_id, label_id) VALUES (?, ?)`, + [taskId, labelId], + (err) => { + if (callbackCalled) return; + + if (err) { + callbackCalled = true; + return callback(err); + } + + completed++; + if (completed === assignments.length) { + callbackCalled = true; + callback(null); + } + } + ); + }); +}; + +const bulkRemoveLabels = (removals, callback) => { + if (!removals || removals.length === 0) { + return callback(null); + } + + let completed = 0; + let callbackCalled = false; + + removals.forEach(({ taskId, labelId }) => { + db.run( + `DELETE FROM task_labels WHERE task_id = ? AND label_id = ?`, + [taskId, labelId], + (err) => { + if (callbackCalled) return; + + if (err) { + callbackCalled = true; + return callback(err); + } + + completed++; + if (completed === removals.length) { + callbackCalled = true; + callback(null); + } + } + ); + }); +}; + module.exports = { initialize, createTask, @@ -61,5 +230,15 @@ module.exports = { getTaskById, updateTask, deleteTask, - closeDatabase + closeDatabase, + // Label operations + createLabel, + getAllLabels, + getLabelById, + getTaskLabels, + getTaskLabelsOptimized, + assignLabelToTask, + removeLabelFromTask, + bulkAssignLabels, + bulkRemoveLabels }; diff --git a/index.js b/index.js index b2c6a6a..c2a4d25 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,29 @@ const db = require('./database'); const app = express(); +// Helper function to enrich tasks with labels in a single optimized query +// Avoids N+1 query problem by batching label fetches +const enrichTasksWithLabels = (tasks, callback) => { + if (!tasks || tasks.length === 0) { + return callback(null, []); + } + + const taskIds = tasks.map(task => task.id); + db.getTaskLabelsOptimized(taskIds, (err, labelsByTask) => { + if (err) { + return callback(err); + } + + // Attach labels to each task + const enrichedTasks = tasks.map(task => ({ + ...task, + labels: labelsByTask[task.id] || [] + })); + + callback(null, enrichedTasks); + }); +}; + // Middleware app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); @@ -58,12 +81,51 @@ const validateCompleted = (completed) => { }; const validateTaskId = (id) => { - if (!id || isNaN(parseInt(id))) { + if (id === undefined || id === null || id === '') { + return { valid: false, error: 'Invalid task ID' }; + } + const numId = typeof id === 'number' ? id : parseInt(id); + if (isNaN(numId) || numId < 1) { return { valid: false, error: 'Invalid task ID' }; } return { valid: true }; }; +const validateLabelName = (name) => { + if (!name || typeof name !== 'string') { + return { valid: false, error: 'Label name is required and must be a string' }; + } + const trimmedName = name.trim(); + if (trimmedName.length === 0) { + return { valid: false, error: 'Label name cannot be empty' }; + } + if (trimmedName.length > 50) { + return { valid: false, error: 'Label name cannot exceed 50 characters' }; + } + return { valid: true }; +}; + +const validateLabelColor = (color) => { + if (color && typeof color !== 'string') { + return { valid: false, error: 'Label color must be a string' }; + } + if (color && !/^#[0-9A-Fa-f]{6}$/.test(color)) { + return { valid: false, error: 'Label color must be a valid hex color (e.g., #FF5733)' }; + } + return { valid: true }; +}; + +const validateLabelId = (id) => { + if (id === undefined || id === null || id === '') { + return { valid: false, error: 'Invalid label ID' }; + } + const numId = typeof id === 'number' ? id : parseInt(id); + if (isNaN(numId) || numId < 1) { + return { valid: false, error: 'Invalid label ID' }; + } + return { valid: true }; +}; + // Routes app.get('/', (req, res) => { db.getAllTasks((err, tasks) => { @@ -72,7 +134,15 @@ app.get('/', (req, res) => { res.status(500).render('error', { message: 'Error retrieving tasks' }); return; } - res.render('index', { tasks }); + // Enrich tasks with labels using optimized batch query + enrichTasksWithLabels(tasks, (err, enrichedTasks) => { + if (err) { + console.error('Error enriching tasks with labels:', err); + res.status(500).render('error', { message: 'Error retrieving tasks' }); + return; + } + res.render('index', { tasks: enrichedTasks }); + }); }); }); @@ -158,13 +228,56 @@ app.post('/api/tasks', (req, res) => { }); }); +// POST bulk assign labels to multiple tasks (MUST come before parameterized routes) +app.post('/api/tasks/bulk/labels', (req, res) => { + const { assignments } = req.body; + + if (!assignments || !Array.isArray(assignments) || assignments.length === 0) { + return res.status(400).json({ error: 'assignments array is required and must not be empty' }); + } + + // Validate all assignments + for (const assignment of assignments) { + const { taskId, labelId } = assignment; + + const taskIdValidation = validateTaskId(taskId); + if (!taskIdValidation.valid) { + return res.status(400).json({ error: `Invalid task ID in assignment: ${taskId}` }); + } + + const labelIdValidation = validateLabelId(labelId); + if (!labelIdValidation.valid) { + return res.status(400).json({ error: `Invalid label ID in assignment: ${labelId}` }); + } + } + + db.bulkAssignLabels(assignments, (err) => { + if (err) { + console.error('Error bulk assigning labels:', err); + // Handle foreign key constraint violation + if (err.message && err.message.includes('FOREIGN KEY constraint failed')) { + return res.status(400).json({ error: 'One or more task or label IDs are invalid' }); + } + return res.status(500).json({ error: 'Error bulk assigning labels', details: err.message }); + } + res.json({ message: 'Labels assigned successfully to all tasks' }); + }); +}); + app.get('/api/tasks', (req, res) => { db.getAllTasks((err, tasks) => { if (err) { console.error('Error retrieving tasks:', err); return res.status(500).json({ error: 'Error retrieving tasks', details: err.message }); } - res.json(tasks); + // Enrich tasks with labels using optimized batch query + enrichTasksWithLabels(tasks, (err, enrichedTasks) => { + if (err) { + console.error('Error enriching tasks with labels:', err); + return res.status(500).json({ error: 'Error retrieving tasks', details: err.message }); + } + res.json(enrichedTasks); + }); }); }); @@ -258,6 +371,134 @@ app.delete('/api/tasks/:id', (req, res) => { }); }); +// Label Routes + +// GET all labels +app.get('/api/labels', (req, res) => { + db.getAllLabels((err, labels) => { + if (err) { + console.error('Error retrieving labels:', err); + return res.status(500).json({ error: 'Error retrieving labels', details: err.message }); + } + res.json(labels); + }); +}); + +// POST create a new label +app.post('/api/labels', (req, res) => { + const { name, color } = req.body; + + // Validate label name + const nameValidation = validateLabelName(name); + if (!nameValidation.valid) { + return res.status(400).json({ error: nameValidation.error }); + } + + // Validate color if provided + const colorValidation = validateLabelColor(color); + if (!colorValidation.valid) { + return res.status(400).json({ error: colorValidation.error }); + } + + db.createLabel(name.trim(), color, (err, id) => { + if (err) { + console.error('Error creating label:', err); + // Handle unique constraint violation + if (err.message && err.message.includes('UNIQUE constraint failed')) { + return res.status(400).json({ error: 'A label with this name already exists' }); + } + return res.status(500).json({ error: 'Error creating label', details: err.message }); + } + res.status(201).json({ + id, + name: name.trim(), + color: color || '#808080', + created_at: new Date().toISOString() + }); + }); +}); + +// POST assign label(s) to a task +app.post('/api/tasks/:id/labels', (req, res) => { + const { labelId, labelIds } = req.body; + + // Validate task ID + const idValidation = validateTaskId(req.params.id); + if (!idValidation.valid) { + return res.status(400).json({ error: idValidation.error }); + } + + // Support both single label and multiple labels + let labels = []; + if (labelId) { + labels = [labelId]; + } else if (labelIds && Array.isArray(labelIds)) { + labels = labelIds; + } else { + return res.status(400).json({ error: 'labelId or labelIds array is required' }); + } + + // Validate all label IDs + for (const lid of labels) { + const labelIdValidation = validateLabelId(lid); + if (!labelIdValidation.valid) { + return res.status(400).json({ error: `Invalid label ID: ${lid}` }); + } + } + + // First verify the task exists + db.getTaskById(req.params.id, (err, task) => { + if (err) { + console.error('Error retrieving task:', err); + return res.status(500).json({ error: 'Error retrieving task', details: err.message }); + } + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + // Prepare assignments for bulk operation + const assignments = labels.map(labelId => ({ + taskId: req.params.id, + labelId: labelId + })); + + db.bulkAssignLabels(assignments, (err) => { + if (err) { + console.error('Error assigning labels to task:', err); + // Handle foreign key constraint violation + if (err.message && err.message.includes('FOREIGN KEY constraint failed')) { + return res.status(400).json({ error: 'One or more label IDs are invalid' }); + } + return res.status(500).json({ error: 'Error assigning labels to task', details: err.message }); + } + res.json({ message: 'Labels assigned successfully' }); + }); + }); +}); + +// DELETE remove a label from a task +app.delete('/api/tasks/:id/labels/:labelId', (req, res) => { + // Validate task ID + const taskIdValidation = validateTaskId(req.params.id); + if (!taskIdValidation.valid) { + return res.status(400).json({ error: taskIdValidation.error }); + } + + // Validate label ID + const labelIdValidation = validateLabelId(req.params.labelId); + if (!labelIdValidation.valid) { + return res.status(400).json({ error: labelIdValidation.error }); + } + + db.removeLabelFromTask(req.params.id, req.params.labelId, (err) => { + if (err) { + console.error('Error removing label from task:', err); + return res.status(500).json({ error: 'Error removing label from task', details: err.message }); + } + res.json({ message: 'Label removed successfully' }); + }); +}); + // Error handling middleware app.use((err, req, res, next) => { console.error('Unhandled error:', err); diff --git a/node_modules/sqlite3/build/Release/node_sqlite3.node b/node_modules/sqlite3/build/Release/node_sqlite3.node deleted file mode 100644 index dfdab7e..0000000 Binary files a/node_modules/sqlite3/build/Release/node_sqlite3.node and /dev/null differ diff --git a/tasks.db b/tasks.db index b1ad045..a2de77f 100644 Binary files a/tasks.db and b/tasks.db differ diff --git a/tests/routes.test.js b/tests/routes.test.js index b415135..cf85477 100644 --- a/tests/routes.test.js +++ b/tests/routes.test.js @@ -13,6 +13,87 @@ app.use(bodyParser.urlencoded({ extended: true })); app.set('view engine', 'ejs'); app.set('views', './views'); +// Validation helpers (must be defined before routes) +const validateTaskId = (id) => { + if (id === undefined || id === null || id === '') { + return { valid: false, error: 'Invalid task ID' }; + } + const numId = typeof id === 'number' ? id : parseInt(id); + if (isNaN(numId) || numId < 1) { + return { valid: false, error: 'Invalid task ID' }; + } + return { valid: true }; +}; + +const validateLabelId = (id) => { + if (id === undefined || id === null || id === '') { + return { valid: false, error: 'Invalid label ID' }; + } + const numId = typeof id === 'number' ? id : parseInt(id); + if (isNaN(numId) || numId < 1) { + return { valid: false, error: 'Invalid label ID' }; + } + return { valid: true }; +}; + +const validateLabelName = (name) => { + if (!name || typeof name !== 'string') { + return { valid: false, error: 'Label name is required and must be a string' }; + } + name = name.trim(); + if (name.length === 0) { + return { valid: false, error: 'Label name cannot be empty' }; + } + if (name.length > 50) { + return { valid: false, error: 'Label name cannot exceed 50 characters' }; + } + return { valid: true }; +}; + +const validateLabelColor = (color) => { + if (color && typeof color !== 'string') { + return { valid: false, error: 'Label color must be a string' }; + } + if (color && !/^#[0-9A-Fa-f]{6}$/.test(color)) { + return { valid: false, error: 'Label color must be a valid hex color (e.g., #FF5733)' }; + } + return { valid: true }; +}; + +// More specific routes must come BEFORE less specific ones +// Define bulk operations first +app.post('/api/tasks/bulk/labels', (req, res) => { + const { assignments } = req.body; + + if (!assignments || !Array.isArray(assignments) || assignments.length === 0) { + return res.status(400).json({ error: 'assignments array is required and must not be empty' }); + } + + for (const assignment of assignments) { + const { taskId, labelId } = assignment; + + const taskIdValidation = validateTaskId(taskId); + if (!taskIdValidation.valid) { + return res.status(400).json({ error: `Invalid task ID in assignment: ${taskId}` }); + } + + const labelIdValidation = validateLabelId(labelId); + if (!labelIdValidation.valid) { + return res.status(400).json({ error: `Invalid label ID in assignment: ${labelId}` }); + } + } + + db.bulkAssignLabels(assignments, (err) => { + if (err) { + if (err.message && err.message.includes('FOREIGN KEY constraint failed')) { + return res.status(400).json({ error: 'One or more task or label IDs are invalid' }); + } + return res.status(500).json({ error: 'Error bulk assigning labels', details: err.message }); + } + res.json({ message: 'Labels assigned successfully to all tasks' }); + }); +}); + // Routes from index.js app.post('/api/tasks', (req, res) => { const { title, description, priority } = req.body; @@ -29,15 +110,6 @@ app.post('/api/tasks', (req, res) => { }); }); -app.get('/api/tasks', (req, res) => { - db.getAllTasks((err, tasks) => { - if (err) { - return res.status(500).json({ error: 'Error retrieving tasks' }); - } - res.json(tasks); - }); -}); - app.get('/api/tasks/:id', (req, res) => { db.getTaskById(req.params.id, (err, task) => { if (err) { @@ -70,6 +142,149 @@ app.delete('/api/tasks/:id', (req, res) => { }); }); +// Helper function to enrich tasks with labels +const enrichTasksWithLabels = (tasks, callback) => { + if (!tasks || tasks.length === 0) { + return callback(null, []); + } + + const taskIds = tasks.map(task => task.id); + db.getTaskLabelsOptimized(taskIds, (err, labelsByTask) => { + if (err) { + return callback(err); + } + + const enrichedTasks = tasks.map(task => ({ + ...task, + labels: labelsByTask[task.id] || [] + })); + + callback(null, enrichedTasks); + }); +}; + +// Label routes for tests +app.get('/api/labels', (req, res) => { + db.getAllLabels((err, labels) => { + if (err) { + return res.status(500).json({ error: 'Error retrieving labels', details: err.message }); + } + res.json(labels); + }); +}); + +app.post('/api/labels', (req, res) => { + const { name, color } = req.body; + + const nameValidation = validateLabelName(name); + if (!nameValidation.valid) { + return res.status(400).json({ error: nameValidation.error }); + } + + const colorValidation = validateLabelColor(color); + if (!colorValidation.valid) { + return res.status(400).json({ error: colorValidation.error }); + } + + db.createLabel(name.trim(), color, (err, id) => { + if (err) { + if (err.message && err.message.includes('UNIQUE constraint failed')) { + return res.status(400).json({ error: 'A label with this name already exists' }); + } + return res.status(500).json({ error: 'Error creating label', details: err.message }); + } + res.status(201).json({ + id, + name: name.trim(), + color: color || '#808080', + created_at: new Date().toISOString() + }); + }); +}); + +app.post('/api/tasks/:id/labels', (req, res) => { + const { labelId, labelIds } = req.body; + + const idValidation = validateTaskId(req.params.id); + if (!idValidation.valid) { + return res.status(400).json({ error: idValidation.error }); + } + + let labels = []; + if (labelId) { + labels = [labelId]; + } else if (labelIds && Array.isArray(labelIds)) { + labels = labelIds; + } else { + return res.status(400).json({ error: 'labelId or labelIds array is required' }); + } + + for (const lid of labels) { + const labelIdValidation = validateLabelId(lid); + if (!labelIdValidation.valid) { + return res.status(400).json({ error: `Invalid label ID: ${lid}` }); + } + } + + db.getTaskById(req.params.id, (err, task) => { + if (err) { + return res.status(500).json({ error: 'Error retrieving task', details: err.message }); + } + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + const assignments = labels.map(labelId => ({ + taskId: req.params.id, + labelId: labelId + })); + + db.bulkAssignLabels(assignments, (err) => { + if (err) { + if (err.message && err.message.includes('FOREIGN KEY constraint failed')) { + return res.status(400).json({ error: 'One or more label IDs are invalid' }); + } + return res.status(500).json({ error: 'Error assigning labels to task', details: err.message }); + } + res.json({ message: 'Labels assigned successfully' }); + }); + }); +}); + +app.delete('/api/tasks/:id/labels/:labelId', (req, res) => { + const taskIdValidation = validateTaskId(req.params.id); + if (!taskIdValidation.valid) { + return res.status(400).json({ error: taskIdValidation.error }); + } + + const labelIdValidation = validateLabelId(req.params.labelId); + if (!labelIdValidation.valid) { + return res.status(400).json({ error: labelIdValidation.error }); + } + + db.removeLabelFromTask(req.params.id, req.params.labelId, (err) => { + if (err) { + return res.status(500).json({ error: 'Error removing label from task', details: err.message }); + } + res.json({ message: 'Label removed successfully' }); + }); +}); + +// Update GET /api/tasks to include labels +app.get('/api/tasks', (req, res) => { + db.getAllTasks((err, tasks) => { + if (err) { + return res.status(500).json({ error: 'Error retrieving tasks' }); + } + enrichTasksWithLabels(tasks, (err, enrichedTasks) => { + if (err) { + return res.status(500).json({ error: 'Error retrieving tasks' }); + } + res.json(enrichedTasks); + }); + }); +}); + describe('Task Manager API Routes', () => { beforeEach(() => { @@ -570,4 +785,392 @@ describe('Task Manager API Routes', () => { }); }); }); + + // Label API Tests + describe('Label API Routes', () => { + + describe('POST /api/labels - Create Label', () => { + test('should create a label with valid name and color', (done) => { + db.createLabel.mockImplementation((name, color, callback) => { + callback(null, 1); + }); + + request(app) + .post('/api/labels') + .send({ name: 'Bug', color: '#FF0000' }) + .expect(201) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('id', 1); + expect(res.body).toHaveProperty('name', 'Bug'); + expect(res.body).toHaveProperty('color', '#FF0000'); + expect(db.createLabel).toHaveBeenCalledWith('Bug', '#FF0000', expect.any(Function)); + done(); + }); + }); + + test('should create a label with default color when not provided', (done) => { + db.createLabel.mockImplementation((name, color, callback) => { + callback(null, 2); + }); + + request(app) + .post('/api/labels') + .send({ name: 'Feature' }) + .expect(201) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('color', '#808080'); + done(); + }); + }); + + test('should return 400 when name is missing', (done) => { + request(app) + .post('/api/labels') + .send({ color: '#FF0000' }) + .expect(400) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('error'); + expect(res.body.error).toContain('Label name is required'); + done(); + }); + }); + + test('should return 400 when color is invalid', (done) => { + request(app) + .post('/api/labels') + .send({ name: 'Bug', color: 'red' }) + .expect(400) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('error'); + expect(res.body.error).toContain('valid hex color'); + done(); + }); + }); + + test('should return 400 when label name already exists', (done) => { + db.createLabel.mockImplementation((name, color, callback) => { + const error = new Error('UNIQUE constraint failed: labels.name'); + callback(error); + }); + + request(app) + .post('/api/labels') + .send({ name: 'Bug', color: '#FF0000' }) + .expect(400) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('error'); + expect(res.body.error).toContain('already exists'); + done(); + }); + }); + }); + + describe('GET /api/labels - Get All Labels', () => { + test('should return all labels', (done) => { + const mockLabels = [ + { id: 1, name: 'Bug', color: '#FF0000', created_at: '2024-01-01' }, + { id: 2, name: 'Feature', color: '#00FF00', created_at: '2024-01-02' } + ]; + + db.getAllLabels.mockImplementation((callback) => { + callback(null, mockLabels); + }); + + request(app) + .get('/api/labels') + .expect(200) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toEqual(mockLabels); + expect(db.getAllLabels).toHaveBeenCalled(); + done(); + }); + }); + + test('should return 500 on database error', (done) => { + db.getAllLabels.mockImplementation((callback) => { + callback(new Error('Database error')); + }); + + request(app) + .get('/api/labels') + .expect(500) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('error'); + done(); + }); + }); + }); + + describe('POST /api/tasks/:id/labels - Assign Labels to Task', () => { + test('should assign a single label to a task', (done) => { + db.getTaskById.mockImplementation((id, callback) => { + callback(null, { id: 1, title: 'Test Task' }); + }); + db.bulkAssignLabels.mockImplementation((assignments, callback) => { + callback(null); + }); + + request(app) + .post('/api/tasks/1/labels') + .send({ labelId: 1 }) + .expect(200) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('message'); + expect(db.bulkAssignLabels).toHaveBeenCalledWith( + [{ taskId: '1', labelId: 1 }], + expect.any(Function) + ); + done(); + }); + }); + + test('should assign multiple labels to a task', (done) => { + db.getTaskById.mockImplementation((id, callback) => { + callback(null, { id: 1, title: 'Test Task' }); + }); + db.bulkAssignLabels.mockImplementation((assignments, callback) => { + callback(null); + }); + + request(app) + .post('/api/tasks/1/labels') + .send({ labelIds: [1, 2, 3] }) + .expect(200) + .end((err, res) => { + if (err) return done(err); + expect(db.bulkAssignLabels).toHaveBeenCalledWith( + [ + { taskId: '1', labelId: 1 }, + { taskId: '1', labelId: 2 }, + { taskId: '1', labelId: 3 } + ], + expect.any(Function) + ); + done(); + }); + }); + + test('should return 404 when task does not exist', (done) => { + db.getTaskById.mockImplementation((id, callback) => { + callback(null, null); + }); + + request(app) + .post('/api/tasks/999/labels') + .send({ labelId: 1 }) + .expect(404) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('error', 'Task not found'); + done(); + }); + }); + + test('should return 400 when labelId or labelIds is missing', (done) => { + request(app) + .post('/api/tasks/1/labels') + .send({}) + .expect(400) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('error'); + expect(res.body.error).toContain('labelId or labelIds'); + done(); + }); + }); + + test('should return 400 when label ID is invalid', (done) => { + db.getTaskById.mockImplementation((id, callback) => { + callback(null, { id: 1, title: 'Test Task' }); + }); + db.bulkAssignLabels.mockImplementation((assignments, callback) => { + const error = new Error('FOREIGN KEY constraint failed'); + callback(error); + }); + + request(app) + .post('/api/tasks/1/labels') + .send({ labelId: 999 }) + .expect(400) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('error'); + expect(res.body.error).toContain('invalid'); + done(); + }); + }); + }); + + describe('DELETE /api/tasks/:id/labels/:labelId - Remove Label from Task', () => { + test('should remove a label from a task', (done) => { + db.removeLabelFromTask.mockImplementation((taskId, labelId, callback) => { + callback(null); + }); + + request(app) + .delete('/api/tasks/1/labels/2') + .expect(200) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('message', 'Label removed successfully'); + expect(db.removeLabelFromTask).toHaveBeenCalledWith('1', '2', expect.any(Function)); + done(); + }); + }); + + test('should return 400 with invalid task ID', (done) => { + request(app) + .delete('/api/tasks/invalid/labels/1') + .expect(400) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('error'); + done(); + }); + }); + + test('should return 400 with invalid label ID', (done) => { + request(app) + .delete('/api/tasks/1/labels/invalid') + .expect(400) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('error'); + done(); + }); + }); + }); + + describe('POST /api/tasks/bulk/labels - Bulk Assign Labels', () => { + test('should bulk assign labels to multiple tasks', (done) => { + db.bulkAssignLabels.mockImplementation((assignments, callback) => { + callback(null); + }); + + const assignments = [ + { taskId: 1, labelId: 1 }, + { taskId: 2, labelId: 1 }, + { taskId: 3, labelId: 2 } + ]; + + request(app) + .post('/api/tasks/bulk/labels') + .send({ assignments }) + .expect(200) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('message'); + expect(db.bulkAssignLabels).toHaveBeenCalledWith(assignments, expect.any(Function)); + done(); + }); + }); + + test('should return 400 when assignments is missing', (done) => { + request(app) + .post('/api/tasks/bulk/labels') + .send({}) + .expect(400) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('error'); + expect(res.body.error).toContain('assignments array is required'); + done(); + }); + }); + + test('should return 400 when assignments is empty', (done) => { + request(app) + .post('/api/tasks/bulk/labels') + .send({ assignments: [] }) + .expect(400) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('error'); + done(); + }); + }); + + test('should return 400 with invalid task ID in assignments', (done) => { + request(app) + .post('/api/tasks/bulk/labels') + .send({ + assignments: [{ taskId: 'invalid', labelId: 1 }] + }) + .expect(400) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveProperty('error'); + expect(res.body.error).toContain('Invalid task ID'); + done(); + }); + }); + }); + + describe('GET /api/tasks - Tasks with Labels (Optimized)', () => { + test('should return tasks with labels using optimized query', (done) => { + const mockTasks = [ + { id: 1, title: 'Task 1', description: 'Desc 1' }, + { id: 2, title: 'Task 2', description: 'Desc 2' } + ]; + + const mockLabelsByTask = { + 1: [{ id: 1, name: 'Bug', color: '#FF0000' }], + 2: [{ id: 2, name: 'Feature', color: '#00FF00' }] + }; + + db.getAllTasks.mockImplementation((callback) => { + callback(null, mockTasks); + }); + + db.getTaskLabelsOptimized.mockImplementation((taskIds, callback) => { + callback(null, mockLabelsByTask); + }); + + request(app) + .get('/api/tasks') + .expect(200) + .end((err, res) => { + if (err) return done(err); + expect(res.body).toHaveLength(2); + expect(res.body[0]).toHaveProperty('labels'); + expect(res.body[0].labels).toEqual(mockLabelsByTask[1]); + expect(res.body[1].labels).toEqual(mockLabelsByTask[2]); + // Verify optimized query is used (single call with all task IDs) + expect(db.getTaskLabelsOptimized).toHaveBeenCalledWith([1, 2], expect.any(Function)); + done(); + }); + }); + + test('should return tasks with empty labels array when no labels assigned', (done) => { + const mockTasks = [ + { id: 1, title: 'Task 1', description: 'Desc 1' } + ]; + + db.getAllTasks.mockImplementation((callback) => { + callback(null, mockTasks); + }); + + db.getTaskLabelsOptimized.mockImplementation((taskIds, callback) => { + callback(null, {}); // No labels for any task + }); + + request(app) + .get('/api/tasks') + .expect(200) + .end((err, res) => { + if (err) return done(err); + expect(res.body[0]).toHaveProperty('labels', []); + done(); + }); + }); + }); + }); });