diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f4d48..3bfe582 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. (#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. 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');