From 4f7724875356c04edc5dbe99cbd1434966b04cf6 Mon Sep 17 00:00:00 2001 From: Kristian Date: Sat, 24 Oct 2026 19:23:41 +0200 Subject: [PATCH 1/2] Let GET /tasks filter by whether a task is done GET /tasks?done=true lists only the tasks that are done and ?done=false only those that are not, in the same order and shape as GET /tasks. Without the parameter every task is listed, as before. Any other value, including an empty or repeated one, answers 400 so a client never gets a list it did not ask for. TaskList.all takes an optional done argument for the filter. The README table and the changelog say so; the changelog line still needs the pull request's number appended. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 2 +- src/server.ts | 15 ++++++++++--- src/tasks.ts | 6 +++-- test/server.test.ts | 55 +++++++++++++++++++++++++++++++++++++++++++++ test/tasks.test.ts | 19 ++++++++++++++++ 6 files changed, 92 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f4d48..07ba658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ what it changes for someone who uses the API. CI checks that it did. ## Unreleased +- `GET /tasks?done=true` or `?done=false` lists only the tasks that are done, or only those that are not. Any other value of `done` answers 400. - `DELETE /tasks/done` removes every task that is done, and says how many it removed. (#6) - `PATCH /tasks/:id` renames a task, body `{"title": "..."}`, and answers with the task. (#5) (#7) - `GET /tasks/count` says how many tasks there are, and how many are done. diff --git a/README.md b/README.md index 5cf67e9..ac21d3e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ and in an adopter's first run. It is small on purpose, so a job is cheap and fas | Method | Path | What it does | |---|---|---| | GET | `/health` | Says the server is up | -| GET | `/tasks` | Lists every task | +| GET | `/tasks` | Lists every task. Takes `?done=true` or `?done=false` to list only the tasks that are done, or only those that are not | | POST | `/tasks` | Adds a task, body `{"title": "..."}` | | GET | `/tasks/count` | Says how many tasks there are, and how many are done | | DELETE | `/tasks/done` | Removes every task that is done, and says how many | diff --git a/src/server.ts b/src/server.ts index 2a46507..5e1b4eb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,7 +5,7 @@ import { TaskList, ValidationError } from './tasks.ts'; * The HTTP API over a task list: * * GET /health is the server up - * GET /tasks every task + * GET /tasks every task, or with ?done=true or ?done=false only those that are or are not done * POST /tasks add a task, body {"title": "..."} * GET /tasks/count how many tasks, and how many are done * DELETE /tasks/done remove every task that is done, and say how many @@ -24,7 +24,7 @@ export function createApp(tasks: TaskList = new TaskList()): Server { } async function handle(tasks: TaskList, request: IncomingMessage, response: ServerResponse): Promise { - const { pathname } = new URL(request.url ?? '/', 'http://localhost'); + const { pathname, searchParams } = new URL(request.url ?? '/', 'http://localhost'); const parts = pathname.split('/').filter(Boolean); const method = request.method ?? 'GET'; @@ -33,7 +33,7 @@ async function handle(tasks: TaskList, request: IncomingMessage, response: Serve if (parts[0] !== 'tasks') return send(response, 404, { error: 'not found' }); if (parts.length === 1) { - if (method === 'GET') return send(response, 200, { tasks: tasks.all() }); + if (method === 'GET') return send(response, 200, { tasks: tasks.all(doneFilter(searchParams)) }); if (method === 'POST') return send(response, 201, tasks.add((await body(request)).title)); return send(response, 405, { error: 'method not allowed' }); } @@ -59,6 +59,15 @@ function found(response: ServerResponse, task: unknown): void { else send(response, 200, task); } +/** The `done` query parameter: undefined when it is absent. Throws when it is not exactly one `true` or `false`. */ +function doneFilter(searchParams: URLSearchParams): boolean | undefined { + const values = searchParams.getAll('done'); + if (values.length === 0) return undefined; + if (values.length === 1 && values[0] === 'true') return true; + if (values.length === 1 && values[0] === 'false') return false; + throw new ValidationError('done must be true or false'); +} + async function body(request: IncomingMessage): Promise> { const chunks: Buffer[] = []; for await (const chunk of request) chunks.push(chunk as Buffer); diff --git a/src/tasks.ts b/src/tasks.ts index 08b1fff..dbddb3f 100644 --- a/src/tasks.ts +++ b/src/tasks.ts @@ -17,8 +17,10 @@ export class TaskList { private readonly tasks = new Map(); private nextId = 1; - all(): Task[] { - return [...this.tasks.values()]; + /** Every task, in the order they were added. Given `done`, only the tasks that are, or are not, done. */ + all(done?: boolean): Task[] { + const tasks = [...this.tasks.values()]; + return done === undefined ? tasks : tasks.filter((task) => task.done === done); } get(id: number): Task | undefined { diff --git a/test/server.test.ts b/test/server.test.ts index 0e2a2fe..1a4eb11 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -83,6 +83,61 @@ describe('the HTTP API', () => { await call('DELETE', `/tasks/${id}`); }); + describe('listing tasks by whether they are done', () => { + let first: number; + let open: number; + let third: number; + + before(async () => { + first = (await call('POST', '/tasks', { title: 'Done one' })).json!.id as number; + open = (await call('POST', '/tasks', { title: 'Still open' })).json!.id as number; + third = (await call('POST', '/tasks', { title: 'Done two' })).json!.id as number; + await call('POST', `/tasks/${first}/complete`); + await call('POST', `/tasks/${third}/complete`); + }); + + after(async () => { + for (const id of [first, open, third]) await call('DELETE', `/tasks/${id}`); + }); + + const mine = (json: Record | undefined) => + (json!.tasks as { id: number }[]).filter((task) => [first, open, third].includes(task.id)); + + it('lists only the tasks that are done with ?done=true', async () => { + const listed = await call('GET', '/tasks?done=true'); + assert.equal(listed.status, 200); + assert.ok((listed.json!.tasks as { done: boolean }[]).every((task) => task.done)); + assert.deepEqual(mine(listed.json), [ + { id: first, title: 'Done one', done: true }, + { id: third, title: 'Done two', done: true }, + ]); + }); + + it('lists only the tasks that are not done with ?done=false', async () => { + const listed = await call('GET', '/tasks?done=false'); + assert.equal(listed.status, 200); + assert.ok((listed.json!.tasks as { done: boolean }[]).every((task) => !task.done)); + assert.deepEqual(mine(listed.json), [{ id: open, title: 'Still open', done: false }]); + }); + + it('lists every task without done', async () => { + const listed = await call('GET', '/tasks'); + assert.equal(listed.status, 200); + assert.deepEqual(mine(listed.json), [ + { id: first, title: 'Done one', done: true }, + { id: open, title: 'Still open', done: false }, + { id: third, title: 'Done two', done: true }, + ]); + }); + + it('answers 400 and lists nothing for any other value of done', async () => { + const refused = { status: 400, json: { error: 'done must be true or false' } }; + for (const query of ['done=yes', 'done=1', 'done=TRUE', 'done=', 'done', 'done=true&done=false']) { + assert.deepEqual(await call('GET', `/tasks?${query}`), refused, query); + } + }); + }); + it('refuses a task without a title, and a body that is not JSON', async () => { assert.deepEqual(await call('POST', '/tasks', { title: '' }), { status: 400, json: { error: 'title must be text' } }); assert.deepEqual(await call('POST', '/tasks', 'not json'), { status: 400, json: { error: 'the body must be JSON' } }); diff --git a/test/tasks.test.ts b/test/tasks.test.ts index 1650aa1..2e0a81a 100644 --- a/test/tasks.test.ts +++ b/test/tasks.test.ts @@ -55,6 +55,25 @@ describe('the task list', () => { assert.equal(tasks.get(id)?.title, 'Write the spec'); }); + it('lists only the tasks that are done, or only those that are not, in order', () => { + const tasks = new TaskList(); + assert.deepEqual(tasks.all(true), []); + assert.deepEqual(tasks.all(false), []); + + const first = tasks.add('Write the spec'); + const second = tasks.add('Review it'); + const third = tasks.add('Ship it'); + tasks.complete(first.id); + tasks.complete(third.id); + + assert.deepEqual(tasks.all(true), [ + { id: first.id, title: 'Write the spec', done: true }, + { id: third.id, title: 'Ship it', done: true }, + ]); + assert.deepEqual(tasks.all(false), [{ id: second.id, title: 'Review it', done: false }]); + assert.deepEqual(tasks.all().map((task) => task.id), [first.id, second.id, third.id]); + }); + it('removes a task once', () => { const tasks = new TaskList(); const { id } = tasks.add('Clean up'); From 2e29b7e1172ec2fdfe060039cc2bde3f966fca2e Mon Sep 17 00:00:00 2001 From: Kristian Date: Sat, 24 Oct 2026 19:25:42 +0200 Subject: [PATCH 2/2] Add the pull request number to the changelog line for done filtering The build check failed because CHANGELOG.md had no new line under Unreleased ending with this pull request's number. The line for GET /tasks?done= now ends with (#8). 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 07ba658..3bfe582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ what it changes for someone who uses the API. CI checks that it did. ## Unreleased -- `GET /tasks?done=true` or `?done=false` lists only the tasks that are done, or only those that are not. Any other value of `done` answers 400. +- `GET /tasks?done=true` or `?done=false` lists only the tasks that are done, or only those that are not. Any other value of `done` answers 400. (#8) - `DELETE /tasks/done` removes every task that is done, and says how many it removed. (#6) - `PATCH /tasks/:id` renames a task, body `{"title": "..."}`, and answers with the task. (#5) (#7) - `GET /tasks/count` says how many tasks there are, and how many are done.