diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a7e5d4..0eeafa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,5 +5,6 @@ what it changes for someone who uses the API. CI checks that it did. ## Unreleased +- `PATCH /tasks/:id` changes a task's title, body `{"title": "..."}`. (#5) - `GET /tasks/count` says how many tasks there are, and how many are done. - CI checks that a pull request adds a line here that ends with its number. (#4) diff --git a/README.md b/README.md index 79997e0..8345f85 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ and in an adopter's first run. It is small on purpose, so a job is cheap and fas | POST | `/tasks` | Adds a task, body `{"title": "..."}` | | GET | `/tasks/count` | Says how many tasks there are, and how many are done | | GET | `/tasks/:id` | Returns one task | +| PATCH | `/tasks/:id` | Changes a task's title, body `{"title": "..."}` | | POST | `/tasks/:id/complete` | Marks a task done | | DELETE | `/tasks/:id` | Removes a task | diff --git a/src/server.ts b/src/server.ts index 024a1cb..a4a30f2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,7 @@ import { TaskList, ValidationError } from './tasks.ts'; * POST /tasks add a task, body {"title": "..."} * GET /tasks/count how many tasks, and how many are done * GET /tasks/:id one task + * PATCH /tasks/:id change a task's title, body {"title": "..."} * POST /tasks/:id/complete mark a task done * DELETE /tasks/:id remove a task */ @@ -42,6 +43,7 @@ async function handle(tasks: TaskList, request: IncomingMessage, response: Serve if (!Number.isInteger(id) || id < 1) return send(response, 404, { error: 'not found' }); if (parts.length === 2 && method === 'GET') return found(response, tasks.get(id)); + if (parts.length === 2 && method === 'PATCH') return found(response, tasks.rename(id, (await body(request)).title)); if (parts.length === 2 && method === 'DELETE') { return tasks.remove(id) ? send(response, 204) : send(response, 404, { error: 'not found' }); } diff --git a/src/tasks.ts b/src/tasks.ts index e9e84c5..32d5f4f 100644 --- a/src/tasks.ts +++ b/src/tasks.ts @@ -6,6 +6,12 @@ export interface Task { export class ValidationError extends Error {} +/** The title as it is stored. Throws when it is not text, or is empty. */ +function checked(title: unknown): string { + if (typeof title !== 'string' || title.trim() === '') throw new ValidationError('title must be text'); + return title.trim(); +} + /** The task list, kept in memory. One instance per server. */ export class TaskList { private readonly tasks = new Map(); @@ -20,12 +26,18 @@ export class TaskList { } add(title: unknown): Task { - if (typeof title !== 'string' || title.trim() === '') throw new ValidationError('title must be text'); - const task: Task = { id: this.nextId++, title: title.trim(), done: false }; + const task: Task = { id: this.nextId++, title: checked(title), done: false }; this.tasks.set(task.id, task); return task; } + /** Change a task's title. Returns it, or undefined when there is no such task. */ + rename(id: number, title: unknown): Task | undefined { + const task = this.tasks.get(id); + if (task) task.title = checked(title); + return task; + } + /** Mark a task done. Returns it, or undefined when there is no such task. */ complete(id: number): Task | undefined { const task = this.tasks.get(id); diff --git a/test/server.test.ts b/test/server.test.ts index 8aaa404..0a143c8 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -62,6 +62,41 @@ describe('the HTTP API', () => { assert.deepEqual(await call('POST', '/tasks', 'not json'), { status: 400, json: { error: 'the body must be JSON' } }); }); + it('renames a task, and keeps its id and whether it is done', async () => { + const id = (await call('POST', '/tasks', { title: 'Write the spec' })).json!.id as number; + + assert.deepEqual(await call('PATCH', `/tasks/${id}`, { title: 'Write the plan' }), { + status: 200, + json: { id, title: 'Write the plan', done: false }, + }); + assert.deepEqual((await call('GET', `/tasks/${id}`)).json, { id, title: 'Write the plan', done: false }); + + await call('POST', `/tasks/${id}/complete`); + assert.deepEqual(await call('PATCH', `/tasks/${id}`, { title: 'Ship the plan', id: id + 1, done: false }), { + status: 200, + json: { id, title: 'Ship the plan', done: true }, + }); + + await call('DELETE', `/tasks/${id}`); + }); + + it('refuses to rename a task to no title, and leaves it as it was', async () => { + const id = (await call('POST', '/tasks', { title: 'Keep me' })).json!.id as number; + const refused = { status: 400, json: { error: 'title must be text' } }; + + assert.deepEqual(await call('PATCH', `/tasks/${id}`, {}), refused); + assert.deepEqual(await call('PATCH', `/tasks/${id}`, { title: '' }), refused); + assert.deepEqual(await call('PATCH', `/tasks/${id}`, { title: 42 }), refused); + assert.deepEqual(await call('PATCH', `/tasks/${id}`, 'not json'), { status: 400, json: { error: 'the body must be JSON' } }); + assert.deepEqual((await call('GET', `/tasks/${id}`)).json, { id, title: 'Keep me', done: false }); + + await call('DELETE', `/tasks/${id}`); + }); + + it('answers 404 when there is no task to rename', async () => { + assert.deepEqual(await call('PATCH', '/tasks/99999', { title: 'Nothing' }), { status: 404, json: { error: 'not found' } }); + }); + it('answers 404 for what it does not have, and 405 for a method it does not take', async () => { assert.equal((await call('GET', '/nothing')).status, 404); assert.equal((await call('GET', '/tasks/abc')).status, 404); diff --git a/test/tasks.test.ts b/test/tasks.test.ts index 85c072e..e36c1c7 100644 --- a/test/tasks.test.ts +++ b/test/tasks.test.ts @@ -26,6 +26,35 @@ describe('the task list', () => { assert.equal(tasks.complete(99), undefined); }); + it('renames a task, and keeps its id and whether it is done', () => { + const tasks = new TaskList(); + const { id } = tasks.add('Write the spec'); + const other = tasks.add('Review it'); + assert.deepEqual(tasks.rename(id, 'Write the plan'), { id, title: 'Write the plan', done: false }); + assert.deepEqual(tasks.get(id), { id, title: 'Write the plan', done: false }); + + tasks.complete(id); + assert.deepEqual(tasks.rename(id, ' Ship the plan '), { id, title: 'Ship the plan', done: true }); + assert.deepEqual(tasks.get(other.id), { id: other.id, title: 'Review it', done: false }); + }); + + it('says so when there is no task to rename', () => { + const tasks = new TaskList(); + const { id } = tasks.add('Write the spec'); + assert.equal(tasks.rename(99, 'Nothing'), undefined); + assert.deepEqual(tasks.all(), [{ id, title: 'Write the spec', done: false }]); + }); + + it('refuses to rename a task to no title, and leaves it as it was', () => { + const tasks = new TaskList(); + const { id } = tasks.add('Write the spec'); + assert.throws(() => tasks.rename(id, ''), ValidationError); + assert.throws(() => tasks.rename(id, ' '), ValidationError); + assert.throws(() => tasks.rename(id, 42), ValidationError); + assert.throws(() => tasks.rename(id, undefined), ValidationError); + assert.equal(tasks.get(id)?.title, 'Write the spec'); + }); + it('removes a task once', () => { const tasks = new TaskList(); const { id } = tasks.add('Clean up');