Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
2 changes: 2 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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' });
}
Expand Down
16 changes: 14 additions & 2 deletions src/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, Task>();
Expand All @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions test/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading