From 74f16ef8cacd7e0252261eff2400089abdee09e5 Mon Sep 17 00:00:00 2001 From: Kristian Date: Mon, 12 Oct 2026 21:34:19 +0200 Subject: [PATCH 1/2] Let a client change the title of a task PATCH /tasks/:id with a body {"title": "..."} renames a task and answers 200 with it. An unknown id answers 404, and a missing, empty, or non-text title answers 400 and leaves the task as it was. TaskList.rename does the work. The title rule moves out of add into one function that both add and rename call, so a rename is checked and trimmed exactly as a new task is. The id and done stay untouched. The changelog line has no pull request number yet, because the number is not known before the pull request is opened. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 1 + src/server.ts | 2 ++ src/tasks.ts | 16 ++++++++++++++-- test/server.test.ts | 35 +++++++++++++++++++++++++++++++++++ test/tasks.test.ts | 29 +++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a7e5d4..82215f8 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": "..."}`. - `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'); From c1dca526c0971fceca8ec4cbe4f07e0c29a62dea Mon Sep 17 00:00:00 2001 From: Kristian Date: Mon, 12 Oct 2026 21:37:12 +0200 Subject: [PATCH 2/2] End the changelog line with the pull request's number The build check's "Changelog entry" step failed: CHANGELOG.md had no new line ending with (#5). The line for PATCH /tasks/:id was written before the pull request existed, so it had no number. It now ends with (#5). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82215f8..0eeafa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +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": "..."}`. +- `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)