diff --git a/package.json b/package.json index 4ba7a49..6401967 100644 --- a/package.json +++ b/package.json @@ -9,5 +9,8 @@ "test": "node --test src/**/*.test.js" }, "keywords": ["api", "rest", "tasks"], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20" + } } diff --git a/src/index.js b/src/index.js index 60221e6..64be478 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,15 @@ const store = new TaskStore(); const PORT = process.env.PORT || 3000; const server = http.createServer((req, res) => { - const url = new URL(req.url, `http://${req.headers.host}`); + let url; + try { + url = new URL(req.url, `http://${req.headers.host}`); + } catch (e) { + res.setHeader('Content-Type', 'application/json'); + res.writeHead(400); + res.end(JSON.stringify({ error: 'bad request' })); + return; + } const method = req.method; res.setHeader('Content-Type', 'application/json'); @@ -23,12 +31,12 @@ const server = http.createServer((req, res) => { req.on('end', () => { try { const { title, description } = JSON.parse(body); - if (!title) { + if (typeof title !== 'string' || !title.trim()) { res.writeHead(400); res.end(JSON.stringify({ error: 'title is required' })); return; } - const task = store.create(title, description); + const task = store.create(title.trim(), description); res.writeHead(201); res.end(JSON.stringify(task)); } catch (e) { @@ -61,6 +69,14 @@ const server = http.createServer((req, res) => { req.on('end', () => { try { const updates = JSON.parse(body); + if (updates.title !== undefined && (typeof updates.title !== 'string' || !updates.title.trim())) { + res.writeHead(400); + res.end(JSON.stringify({ error: 'title is required' })); + return; + } + if (updates.title !== undefined) { + updates.title = updates.title.trim(); + } const task = store.update(id, updates); if (!task) { res.writeHead(404); diff --git a/src/index.test.js b/src/index.test.js new file mode 100644 index 0000000..349800b --- /dev/null +++ b/src/index.test.js @@ -0,0 +1,215 @@ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert'); +const http = require('node:http'); + +let server; +let baseUrl; + +function request(method, path, body) { + return new Promise((resolve, reject) => { + const url = new URL(path, baseUrl); + const options = { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method, + headers: { 'Content-Type': 'application/json' }, + }; + const req = http.request(options, (res) => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + body: data ? JSON.parse(data) : null, + }); + }); + }); + req.on('error', reject); + if (body !== undefined) { + req.write(JSON.stringify(body)); + } + req.end(); + }); +} + +// Use port 0 to get a random available port +before(() => { + process.env.PORT = '0'; + // Clear require cache so index.js binds to port 0 + delete require.cache[require.resolve('./index')]; + const mod = require('./index'); + server = mod.server; + return new Promise((resolve) => { + if (server.listening) { + baseUrl = `http://localhost:${server.address().port}`; + resolve(); + } else { + server.on('listening', () => { + baseUrl = `http://localhost:${server.address().port}`; + resolve(); + }); + } + }); +}); + +after(() => { + return new Promise((resolve) => { + server.close(resolve); + }); +}); + +function rawRequest(method, rawPath) { + return new Promise((resolve, reject) => { + const parsedBase = new URL(baseUrl); + const options = { + hostname: parsedBase.hostname, + port: parsedBase.port, + path: rawPath, + method, + headers: { 'Content-Type': 'application/json' }, + }; + const req = http.request(options, (res) => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + body: data ? JSON.parse(data) : null, + }); + }); + }); + req.on('error', reject); + req.end(); + }); +} + +describe('Malformed URL handling', () => { + it('returns 400 for URL that triggers parsing failure instead of crashing', async () => { + const res = await rawRequest('GET', '///'); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'bad request'); + }); +}); + +describe('POST /tasks title validation', () => { + it('rejects empty string title with 400', async () => { + const res = await request('POST', '/tasks', { title: '' }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('rejects whitespace-only title with 400', async () => { + const res = await request('POST', '/tasks', { title: ' ' }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('rejects tab-only title with 400', async () => { + const res = await request('POST', '/tasks', { title: '\t' }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('rejects null title with 400', async () => { + const res = await request('POST', '/tasks', { title: null }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('rejects numeric title with 400', async () => { + const res = await request('POST', '/tasks', { title: 123 }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('rejects boolean title with 400', async () => { + const res = await request('POST', '/tasks', { title: true }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('accepts valid title and trims whitespace', async () => { + const res = await request('POST', '/tasks', { title: ' Buy groceries ' }); + assert.strictEqual(res.statusCode, 201); + assert.strictEqual(res.body.title, 'Buy groceries'); + }); + + it('accepts valid title without extra whitespace', async () => { + const res = await request('POST', '/tasks', { title: 'Clean house' }); + assert.strictEqual(res.statusCode, 201); + assert.strictEqual(res.body.title, 'Clean house'); + }); + + it('preserves description with leading/trailing whitespace', async () => { + const res = await request('POST', '/tasks', { title: 'Task', description: ' spaced out ' }); + assert.strictEqual(res.statusCode, 201); + assert.strictEqual(res.body.description, ' spaced out '); + }); +}); + +describe('PATCH /tasks/:id title validation', () => { + it('rejects whitespace-only title update with 400', async () => { + const createRes = await request('POST', '/tasks', { title: 'Original' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { title: ' ' }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('rejects empty string title update with 400', async () => { + const createRes = await request('POST', '/tasks', { title: 'Original' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { title: '' }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('rejects numeric title update with 400', async () => { + const createRes = await request('POST', '/tasks', { title: 'Original' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { title: 42 }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('rejects null title update with 400', async () => { + const createRes = await request('POST', '/tasks', { title: 'Original' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { title: null }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('accepts valid title update and trims whitespace', async () => { + const createRes = await request('POST', '/tasks', { title: 'Original' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { title: ' Updated ' }); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.body.title, 'Updated'); + }); + + it('allows updates without title field', async () => { + const createRes = await request('POST', '/tasks', { title: 'Keep this' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { completed: true }); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.body.title, 'Keep this'); + assert.strictEqual(res.body.completed, true); + }); + + it('preserves description with leading/trailing whitespace on update', async () => { + const createRes = await request('POST', '/tasks', { title: 'Original' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { description: ' spaced out ' }); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.body.description, ' spaced out '); + }); +});