From 5a0d29a308970af119278e0d8b7526ddf78ffe78 Mon Sep 17 00:00:00 2001 From: Aneurin Barker Snook Date: Thu, 3 Sep 2026 18:30:52 +0100 Subject: [PATCH] Add stage 3: todo list and item CRUD API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two migrations add todo_lists (owner_id FK to users, title, description) and todo_items (list_id FK, text, complete, position), both with ON DELETE CASCADE. New endpoints under /api/lists, all behind AuthMiddleware: - lists: index / store / show / update (PATCH) / destroy - items: nested under a list, same five verbs Lists are owner-scoped — another user's or a missing list responds 404, never 403. New items append after the highest position unless one is given; the list carries item_count / completed_count. Item PATCH is partial and never renumbers siblings. Adds App\Support\Validator for request-body checks, TodoList/TodoItem repositories, and body()/user() helpers on the Controller base. Feature tests move their shared harness into tests/ApiTestCase; TodoTest covers CRUD, ownership isolation, ordering, completion counts, validation and cascade delete. Full suite: 15 passing. Co-Authored-By: Claude Sonnet 5 --- README.md | 91 +++++++++++- migrations/003_create_todo_lists_table.sql | 10 ++ migrations/004_create_todo_items_table.sql | 11 ++ src/Http/Controllers/Controller.php | 26 ++++ src/Http/Controllers/TodoItemController.php | 156 ++++++++++++++++++++ src/Http/Controllers/TodoListController.php | 129 ++++++++++++++++ src/Repository/TodoItemRepository.php | 133 +++++++++++++++++ src/Repository/TodoListRepository.php | 130 ++++++++++++++++ src/Support/Validator.php | 141 ++++++++++++++++++ src/bootstrap.php | 29 +++- tests/ApiTestCase.php | 88 +++++++++++ tests/TodoTest.php | 153 +++++++++++++++++++ 12 files changed, 1093 insertions(+), 4 deletions(-) create mode 100644 migrations/003_create_todo_lists_table.sql create mode 100644 migrations/004_create_todo_items_table.sql create mode 100644 src/Http/Controllers/TodoItemController.php create mode 100644 src/Http/Controllers/TodoListController.php create mode 100644 src/Repository/TodoItemRepository.php create mode 100644 src/Repository/TodoListRepository.php create mode 100644 src/Support/Validator.php create mode 100644 tests/ApiTestCase.php create mode 100644 tests/TodoTest.php diff --git a/README.md b/README.md index 96c680a..87671f9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ SQLite file, plus a Vue 3 + TypeScript PWA frontend in [web/](web/). |-------|-------|-------| | 1 | Auth API — register, login, `GET /me` | ✅ done | | 2 | Frontend shell — Vite PWA, auth-gated routing, register/login pages | ✅ done | -| 3 | Todo CRUD (API + UI) | planned | +| 3 | Todo list + item CRUD API | ✅ done | +| 4 | Todo UI in the frontend | planned | Registration signs the user in immediately, with the account's email marked unverified (`user.email_verified` is `false` until a future stage adds a @@ -171,6 +172,78 @@ Requires `Authorization: Bearer `. `401` if the header is missing, malformed, or the token is invalid/expired. +### Todo lists + +All routes below require `Authorization: Bearer `. A list belongs to one +owner (the creator); another user's list — or a missing one — always responds +`404`. + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/lists` | the caller's lists, newest first | +| `POST` | `/api/lists` | create a list | +| `GET` | `/api/lists/{id}` | one list | +| `PATCH` | `/api/lists/{id}` | update `title` and/or `description` | +| `DELETE` | `/api/lists/{id}` | delete the list and its items (`204`) | + +Create/update body: `title` (required on create, 1–255 chars), `description` +(optional, ≤ 2000 chars, defaults to `""`). `PATCH` needs at least one field. + +List representation: + +```json +{ + "list": { + "id": 1, + "title": "Shopping", + "description": "For the week", + "owner_id": 1, + "item_count": 3, + "completed_count": 1, + "created_at": "2026-09-03T12:00:00Z", + "updated_at": "2026-09-03T12:00:00Z" + } +} +``` + +`GET /api/lists` returns `{ "lists": [ … ] }`. + +### Todo items + +Scoped to a list; the parent list's ownership is checked first (`404` otherwise). + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/lists/{id}/items` | items, ordered by `position` then `id` | +| `POST` | `/api/lists/{id}/items` | add an item | +| `GET` | `/api/lists/{id}/items/{itemId}` | one item | +| `PATCH` | `/api/lists/{id}/items/{itemId}` | update `text`, `complete`, and/or `position` | +| `DELETE` | `/api/lists/{id}/items/{itemId}` | delete the item (`204`) | + +Create body: `text` (required, 1–1000 chars), `complete` (optional bool, +default `false`), `position` (optional integer ≥ 0; when omitted the item is +appended after the current highest position). `PATCH` needs at least one field. +`position` is a plain sort key the client manages — updating one item never +renumbers its siblings. + +Item representation: + +```json +{ + "item": { + "id": 10, + "list_id": 1, + "text": "Milk", + "complete": false, + "position": 0, + "created_at": "2026-09-03T12:00:00Z", + "updated_at": "2026-09-03T12:00:00Z" + } +} +``` + +`GET …/items` returns `{ "items": [ … ] }`. + ### Error shape Every error response looks like: @@ -195,6 +268,17 @@ TOKEN=$(curl -s -X POST $BASE/api/auth/login \ -d '{"email":"ada@example.com","password":"password123"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4) curl -s $BASE/api/me -H "Authorization: Bearer $TOKEN" + +LIST=$(curl -s -X POST $BASE/api/lists \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"title":"Shopping","description":"For the week"}' \ + | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) + +curl -s -X POST $BASE/api/lists/$LIST/items \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"text":"Milk"}' + +curl -s $BASE/api/lists/$LIST/items -H "Authorization: Bearer $TOKEN" ``` ## Tests @@ -214,8 +298,9 @@ src/Support/Database.php PDO/SQLite connection src/Auth/JwtService.php Issue/verify JWTs src/Auth/AuthMiddleware.php Bearer-token authentication src/Http/JsonErrorHandler.php Uniform JSON error envelope -src/Http/Controllers/ Request handlers -src/Repository/ Database access +src/Http/Controllers/ Request handlers (Auth, TodoList, TodoItem) +src/Repository/ Database access (User, TodoList, TodoItem) +src/Support/Validator.php Request-body validation helper migrations/*.sql Schema, applied by bin/migrate.php Dockerfile PHP 8.3 + Apache image docker-compose.yml One-command local stack (API; web via --profile frontend) diff --git a/migrations/003_create_todo_lists_table.sql b/migrations/003_create_todo_lists_table.sql new file mode 100644 index 0000000..f8162e4 --- /dev/null +++ b/migrations/003_create_todo_lists_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS todo_lists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_todo_lists_owner ON todo_lists (owner_id); diff --git a/migrations/004_create_todo_items_table.sql b/migrations/004_create_todo_items_table.sql new file mode 100644 index 0000000..e41d1c3 --- /dev/null +++ b/migrations/004_create_todo_items_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS todo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + list_id INTEGER NOT NULL REFERENCES todo_lists (id) ON DELETE CASCADE, + text TEXT NOT NULL, + complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)), + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_todo_items_list_position ON todo_items (list_id, position); diff --git a/src/Http/Controllers/Controller.php b/src/Http/Controllers/Controller.php index 5e9127d..651bb34 100644 --- a/src/Http/Controllers/Controller.php +++ b/src/Http/Controllers/Controller.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers; use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; /** * Shared helpers for HTTP controllers. @@ -26,4 +27,29 @@ abstract class Controller ->withHeader('Content-Type', 'application/json') ->withStatus($status); } + + /** + * The parsed JSON request body as an array (empty when absent or not an object). + * + * @return array + */ + protected function body(Request $request): array + { + $parsed = $request->getParsedBody(); + + return is_array($parsed) ? $parsed : []; + } + + /** + * The authenticated user attached by AuthMiddleware. + * + * @return array{id: int, email: string, email_verified_at: string|null, created_at: string} + */ + protected function user(Request $request): array + { + /** @var array{id: int, email: string, email_verified_at: string|null, created_at: string} $user */ + $user = $request->getAttribute('user'); + + return $user; + } } diff --git a/src/Http/Controllers/TodoItemController.php b/src/Http/Controllers/TodoItemController.php new file mode 100644 index 0000000..05566ed --- /dev/null +++ b/src/Http/Controllers/TodoItemController.php @@ -0,0 +1,156 @@ +requireOwnedListId($request, $args); + + return $this->json($response, [ + 'items' => array_map($this->present(...), $this->items->allForList($listId)), + ]); + } + + /** + * POST /api/lists/{listId}/items + */ + public function store(Request $request, Response $response, array $args): Response + { + $listId = $this->requireOwnedListId($request, $args); + + $validator = new Validator($this->body($request)); + $text = $validator->requiredString('text', self::TEXT_MAX); + $complete = $validator->optionalBool('complete') ?? false; + $position = $validator->optionalInt('position', 0); + $validator->assert(); + + $item = $this->items->create($listId, $text, $complete, $position); + $this->lists->touch($listId); + + return $this->json($response, ['item' => $this->present($item)], 201); + } + + /** + * GET /api/lists/{listId}/items/{itemId} + */ + public function show(Request $request, Response $response, array $args): Response + { + $listId = $this->requireOwnedListId($request, $args); + + return $this->json($response, ['item' => $this->present($this->requireItem($listId, $args))]); + } + + /** + * PATCH /api/lists/{listId}/items/{itemId} + */ + public function update(Request $request, Response $response, array $args): Response + { + $listId = $this->requireOwnedListId($request, $args); + $item = $this->requireItem($listId, $args); + + $validator = new Validator($this->body($request)); + $fields = []; + if ($validator->has('text')) { + $fields['text'] = $validator->requiredString('text', self::TEXT_MAX); + } + if ($validator->has('complete')) { + $fields['complete'] = $validator->optionalBool('complete'); + } + if ($validator->has('position')) { + $fields['position'] = $validator->optionalInt('position', 0); + } + if ($fields === [] && !$validator->failed()) { + $validator->add('text', 'Provide at least one of: text, complete, position.'); + } + $validator->assert(); + + $updated = $this->items->update($item['id'], $listId, $fields); + $this->lists->touch($listId); + + return $this->json($response, ['item' => $this->present($updated)]); + } + + /** + * DELETE /api/lists/{listId}/items/{itemId} + */ + public function destroy(Request $request, Response $response, array $args): Response + { + $listId = $this->requireOwnedListId($request, $args); + $this->items->delete($this->requireItem($listId, $args)['id']); + $this->lists->touch($listId); + + return $response->withStatus(204); + } + + /** + * @param array $args + */ + private function requireOwnedListId(Request $request, array $args): int + { + $list = $this->lists->findOwnedBy((int) $args['listId'], $this->user($request)['id']); + + if ($list === null) { + throw new ApiException('List not found.', 404); + } + + return $list['id']; + } + + /** + * @param array $args + * @return array{id: int, list_id: int, text: string, complete: bool, position: int, created_at: string, updated_at: string} + */ + private function requireItem(int $listId, array $args): array + { + $item = $this->items->findInList((int) $args['itemId'], $listId); + + if ($item === null) { + throw new ApiException('Item not found.', 404); + } + + return $item; + } + + /** + * @param array{id: int, list_id: int, text: string, complete: bool, position: int, created_at: string, updated_at: string} $item + * @return array + */ + private function present(array $item): array + { + return [ + 'id' => $item['id'], + 'list_id' => $item['list_id'], + 'text' => $item['text'], + 'complete' => $item['complete'], + 'position' => $item['position'], + 'created_at' => $item['created_at'], + 'updated_at' => $item['updated_at'], + ]; + } +} diff --git a/src/Http/Controllers/TodoListController.php b/src/Http/Controllers/TodoListController.php new file mode 100644 index 0000000..51672f6 --- /dev/null +++ b/src/Http/Controllers/TodoListController.php @@ -0,0 +1,129 @@ +lists->allForOwner($this->user($request)['id']); + + return $this->json($response, ['lists' => array_map($this->present(...), $lists)]); + } + + /** + * POST /api/lists + */ + public function store(Request $request, Response $response): Response + { + $validator = new Validator($this->body($request)); + $title = $validator->requiredString('title', self::TITLE_MAX); + $description = $validator->optionalString('description', self::DESCRIPTION_MAX) ?? ''; + $validator->assert(); + + $list = $this->lists->create($this->user($request)['id'], $title, $description); + + return $this->json($response, ['list' => $this->present($list)], 201); + } + + /** + * GET /api/lists/{listId} + */ + public function show(Request $request, Response $response, array $args): Response + { + return $this->json($response, ['list' => $this->present($this->requireOwnedList($request, $args))]); + } + + /** + * PATCH /api/lists/{listId} + */ + public function update(Request $request, Response $response, array $args): Response + { + $list = $this->requireOwnedList($request, $args); + + $validator = new Validator($this->body($request)); + $fields = []; + if ($validator->has('title')) { + $fields['title'] = $validator->requiredString('title', self::TITLE_MAX); + } + if ($validator->has('description')) { + $fields['description'] = $validator->optionalString('description', self::DESCRIPTION_MAX) ?? ''; + } + if ($fields === [] && !$validator->failed()) { + $validator->add('title', 'Provide at least one of: title, description.'); + } + $validator->assert(); + + $updated = $this->lists->update($list['id'], $list['owner_id'], $fields); + + return $this->json($response, ['list' => $this->present($updated)]); + } + + /** + * DELETE /api/lists/{listId} + */ + public function destroy(Request $request, Response $response, array $args): Response + { + $this->lists->delete($this->requireOwnedList($request, $args)['id']); + + return $response->withStatus(204); + } + + /** + * Load the list named in the route, or 404 if it is missing or not owned by + * the authenticated user. + * + * @param array $args + * @return array{id: int, owner_id: int, title: string, description: string, item_count: int, completed_count: int, created_at: string, updated_at: string} + */ + private function requireOwnedList(Request $request, array $args): array + { + $list = $this->lists->findOwnedBy((int) $args['listId'], $this->user($request)['id']); + + if ($list === null) { + throw new ApiException('List not found.', 404); + } + + return $list; + } + + /** + * @param array{id: int, owner_id: int, title: string, description: string, item_count: int, completed_count: int, created_at: string, updated_at: string} $list + * @return array + */ + private function present(array $list): array + { + return [ + 'id' => $list['id'], + 'title' => $list['title'], + 'description' => $list['description'], + 'owner_id' => $list['owner_id'], + 'item_count' => $list['item_count'], + 'completed_count' => $list['completed_count'], + 'created_at' => $list['created_at'], + 'updated_at' => $list['updated_at'], + ]; + } +} diff --git a/src/Repository/TodoItemRepository.php b/src/Repository/TodoItemRepository.php new file mode 100644 index 0000000..7d771d4 --- /dev/null +++ b/src/Repository/TodoItemRepository.php @@ -0,0 +1,133 @@ +pdo->prepare( + 'SELECT * FROM todo_items WHERE list_id = :list ORDER BY position ASC, id ASC' + ); + $stmt->execute(['list' => $listId]); + + return array_map($this->cast(...), $stmt->fetchAll()); + } + + /** + * @return TodoItemRow|null + */ + public function findInList(int $id, int $listId): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM todo_items WHERE id = :id AND list_id = :list'); + $stmt->execute(['id' => $id, 'list' => $listId]); + + $row = $stmt->fetch(); + + return $row === false ? null : $this->cast($row); + } + + /** + * @return TodoItemRow + */ + public function create(int $listId, string $text, bool $complete, ?int $position): array + { + $position ??= $this->nextPosition($listId); + + $stmt = $this->pdo->prepare( + 'INSERT INTO todo_items (list_id, text, complete, position) + VALUES (:list, :text, :complete, :position)' + ); + $stmt->execute([ + 'list' => $listId, + 'text' => $text, + 'complete' => $complete ? 1 : 0, + 'position' => $position, + ]); + + /** @var TodoItemRow $item */ + $item = $this->findInList((int) $this->pdo->lastInsertId(), $listId); + + return $item; + } + + /** + * @param array{text?: string, complete?: bool, position?: int} $fields + * @return TodoItemRow + */ + public function update(int $id, int $listId, array $fields): array + { + $sets = ['updated_at = ' . "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"]; + $params = ['id' => $id]; + + if (array_key_exists('text', $fields)) { + $sets[] = 'text = :text'; + $params['text'] = $fields['text']; + } + if (array_key_exists('complete', $fields)) { + $sets[] = 'complete = :complete'; + $params['complete'] = $fields['complete'] ? 1 : 0; + } + if (array_key_exists('position', $fields)) { + $sets[] = 'position = :position'; + $params['position'] = $fields['position']; + } + + $stmt = $this->pdo->prepare('UPDATE todo_items SET ' . implode(', ', $sets) . ' WHERE id = :id'); + $stmt->execute($params); + + /** @var TodoItemRow $item */ + $item = $this->findInList($id, $listId); + + return $item; + } + + public function delete(int $id): void + { + $this->pdo->prepare('DELETE FROM todo_items WHERE id = :id')->execute(['id' => $id]); + } + + private function nextPosition(int $listId): int + { + $stmt = $this->pdo->prepare( + 'SELECT COALESCE(MAX(position), -1) + 1 FROM todo_items WHERE list_id = :list' + ); + $stmt->execute(['list' => $listId]); + + return (int) $stmt->fetchColumn(); + } + + /** + * @param array $row + * @return TodoItemRow + */ + private function cast(array $row): array + { + $row['id'] = (int) $row['id']; + $row['list_id'] = (int) $row['list_id']; + $row['complete'] = (bool) $row['complete']; + $row['position'] = (int) $row['position']; + + /** @var TodoItemRow $row */ + return $row; + } +} diff --git a/src/Repository/TodoListRepository.php b/src/Repository/TodoListRepository.php new file mode 100644 index 0000000..c5d77d4 --- /dev/null +++ b/src/Repository/TodoListRepository.php @@ -0,0 +1,130 @@ +pdo->prepare(self::SELECT . ' WHERE l.owner_id = :owner ORDER BY l.created_at DESC, l.id DESC'); + $stmt->execute(['owner' => $ownerId]); + + return array_map($this->cast(...), $stmt->fetchAll()); + } + + /** + * @return TodoListRow|null + */ + public function findOwnedBy(int $id, int $ownerId): ?array + { + $stmt = $this->pdo->prepare(self::SELECT . ' WHERE l.id = :id AND l.owner_id = :owner'); + $stmt->execute(['id' => $id, 'owner' => $ownerId]); + + $row = $stmt->fetch(); + + return $row === false ? null : $this->cast($row); + } + + /** + * @return TodoListRow + */ + public function create(int $ownerId, string $title, string $description): array + { + $stmt = $this->pdo->prepare( + 'INSERT INTO todo_lists (owner_id, title, description) VALUES (:owner, :title, :description)' + ); + $stmt->execute([ + 'owner' => $ownerId, + 'title' => $title, + 'description' => $description, + ]); + + /** @var TodoListRow $list */ + $list = $this->findOwnedBy((int) $this->pdo->lastInsertId(), $ownerId); + + return $list; + } + + /** + * @param array{title?: string, description?: string} $fields + * @return TodoListRow + */ + public function update(int $id, int $ownerId, array $fields): array + { + $sets = ['updated_at = ' . $this->nowExpr()]; + $params = ['id' => $id]; + + foreach (['title', 'description'] as $column) { + if (array_key_exists($column, $fields)) { + $sets[] = "{$column} = :{$column}"; + $params[$column] = $fields[$column]; + } + } + + $stmt = $this->pdo->prepare('UPDATE todo_lists SET ' . implode(', ', $sets) . ' WHERE id = :id'); + $stmt->execute($params); + + /** @var TodoListRow $list */ + $list = $this->findOwnedBy($id, $ownerId); + + return $list; + } + + public function delete(int $id): void + { + $this->pdo->prepare('DELETE FROM todo_lists WHERE id = :id')->execute(['id' => $id]); + } + + /** Bump updated_at, e.g. when the list's items change. */ + public function touch(int $id): void + { + $this->pdo->prepare('UPDATE todo_lists SET updated_at = ' . $this->nowExpr() . ' WHERE id = :id') + ->execute(['id' => $id]); + } + + private function nowExpr(): string + { + return "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"; + } + + /** + * @param array $row + * @return TodoListRow + */ + private function cast(array $row): array + { + $row['id'] = (int) $row['id']; + $row['owner_id'] = (int) $row['owner_id']; + $row['item_count'] = (int) $row['item_count']; + $row['completed_count'] = (int) $row['completed_count']; + + /** @var TodoListRow $row */ + return $row; + } +} diff --git a/src/Support/Validator.php b/src/Support/Validator.php new file mode 100644 index 0000000..0dee993 --- /dev/null +++ b/src/Support/Validator.php @@ -0,0 +1,141 @@ + messages and throws a ValidationException when asked. + */ +final class Validator +{ + /** @var array */ + private array $errors = []; + + /** + * @param array $data + */ + public function __construct(private readonly array $data) + { + } + + public function has(string $field): bool + { + return array_key_exists($field, $this->data); + } + + /** + * A required, non-blank, length-bounded string. + */ + public function requiredString(string $field, int $max, int $min = 1): string + { + if (!$this->has($field) || $this->data[$field] === null) { + $this->errors[$field][] = ucfirst($field) . ' is required.'; + + return ''; + } + + return $this->stringValue($field, $max, $min) ?? ''; + } + + /** + * An optional string; returns null when the field is absent. + */ + public function optionalString(string $field, int $max, int $min = 0): ?string + { + if (!$this->has($field)) { + return null; + } + + return $this->stringValue($field, $max, $min); + } + + public function optionalBool(string $field): ?bool + { + if (!$this->has($field)) { + return null; + } + + $value = $this->data[$field]; + + if (is_bool($value)) { + return $value; + } + if ($value === 0 || $value === 1) { + return $value === 1; + } + + $this->errors[$field][] = ucfirst($field) . ' must be true or false.'; + + return null; + } + + public function optionalInt(string $field, int $min): ?int + { + if (!$this->has($field)) { + return null; + } + + $value = $this->data[$field]; + + if (!is_int($value) || $value < $min) { + $this->errors[$field][] = ucfirst($field) . " must be an integer of at least {$min}."; + + return null; + } + + return $value; + } + + public function add(string $field, string $message): void + { + $this->errors[$field][] = $message; + } + + public function failed(): bool + { + return $this->errors !== []; + } + + /** + * @throws ValidationException when any errors were recorded. + */ + public function assert(): void + { + if ($this->errors !== []) { + throw new ValidationException($this->errors); + } + } + + private function stringValue(string $field, int $max, int $min): ?string + { + $value = $this->data[$field]; + + if (!is_string($value)) { + $this->errors[$field][] = ucfirst($field) . ' must be a string.'; + + return null; + } + + $value = trim($value); + $length = mb_strlen($value); + + if ($length < $min) { + $this->errors[$field][] = $min === 1 + ? ucfirst($field) . ' cannot be empty.' + : ucfirst($field) . " must be at least {$min} characters."; + + return null; + } + if ($length > $max) { + $this->errors[$field][] = ucfirst($field) . " must be at most {$max} characters."; + + return null; + } + + return $value; + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php index 05d13d6..a9e5847 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -5,7 +5,11 @@ declare(strict_types=1); use App\Auth\AuthMiddleware; use App\Auth\JwtService; use App\Http\Controllers\AuthController; +use App\Http\Controllers\TodoItemController; +use App\Http\Controllers\TodoListController; use App\Http\JsonErrorHandler; +use App\Repository\TodoItemRepository; +use App\Repository\TodoListRepository; use App\Repository\UserRepository; use App\Support\Config; use App\Support\Database; @@ -30,14 +34,23 @@ $errorMiddleware->setDefaultErrorHandler( // --- Wiring ----------------------------------------------------------------- $users = new UserRepository($database->pdo()); +$todoLists = new TodoListRepository($database->pdo()); +$todoItems = new TodoItemRepository($database->pdo()); $jwt = new JwtService($config->jwtSecret, $config->jwtTtl); $authController = new AuthController($users, $jwt); +$listController = new TodoListController($todoLists); +$itemController = new TodoItemController($todoLists, $todoItems); $authMiddleware = new AuthMiddleware($jwt, $users); // --- Routes --------------------------------------------------------------- -$app->group('/api', function (RouteCollectorProxy $group) use ($authController, $authMiddleware) { +$app->group('/api', function (RouteCollectorProxy $group) use ( + $authController, + $listController, + $itemController, + $authMiddleware, +) { $group->get('/health', function (Request $request, Response $response): Response { $response->getBody()->write((string) json_encode(['status' => 'ok'])); return $response->withHeader('Content-Type', 'application/json'); @@ -47,6 +60,20 @@ $app->group('/api', function (RouteCollectorProxy $group) use ($authController, $group->post('/auth/login', [$authController, 'login']); $group->get('/me', [$authController, 'me'])->add($authMiddleware); + + $group->group('/lists', function (RouteCollectorProxy $lists) use ($listController, $itemController) { + $lists->get('', [$listController, 'index']); + $lists->post('', [$listController, 'store']); + $lists->get('/{listId:[0-9]+}', [$listController, 'show']); + $lists->patch('/{listId:[0-9]+}', [$listController, 'update']); + $lists->delete('/{listId:[0-9]+}', [$listController, 'destroy']); + + $lists->get('/{listId:[0-9]+}/items', [$itemController, 'index']); + $lists->post('/{listId:[0-9]+}/items', [$itemController, 'store']); + $lists->get('/{listId:[0-9]+}/items/{itemId:[0-9]+}', [$itemController, 'show']); + $lists->patch('/{listId:[0-9]+}/items/{itemId:[0-9]+}', [$itemController, 'update']); + $lists->delete('/{listId:[0-9]+}/items/{itemId:[0-9]+}', [$itemController, 'destroy']); + })->add($authMiddleware); }); return $app; diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php new file mode 100644 index 0000000..e089cc8 --- /dev/null +++ b/tests/ApiTestCase.php @@ -0,0 +1,88 @@ +databasePath = sys_get_temp_dir() . '/todo-test-' . uniqid() . '.sqlite'; + putenv('DATABASE_PATH=' . $this->databasePath); + $_ENV['DATABASE_PATH'] = $this->databasePath; + + $pdo = new PDO('sqlite:' . $this->databasePath); + $pdo->exec('PRAGMA foreign_keys = ON'); + foreach (glob(dirname(__DIR__) . '/migrations/*.sql') ?: [] as $migration) { + $pdo->exec((string) file_get_contents($migration)); + } + + $this->app = require dirname(__DIR__) . '/src/bootstrap.php'; + } + + protected function tearDown(): void + { + @unlink($this->databasePath); + putenv('DATABASE_PATH'); + unset($_ENV['DATABASE_PATH']); + } + + /** + * @param array|null $body + * @param array $headers + */ + protected function request( + string $method, + string $path, + ?array $body = null, + array $headers = [], + ): ResponseInterface { + $request = (new ServerRequestFactory())->createServerRequest($method, $path); + + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + if ($body !== null) { + $request = $request->withParsedBody($body)->withHeader('Content-Type', 'application/json'); + } + + return $this->app->handle($request); + } + + /** + * Register a fresh user and return an `Authorization` header for them. + * + * @return array + */ + protected function authHeader(string $email = 'user@example.com'): array + { + $token = $this->decode( + $this->request('POST', '/api/auth/register', ['email' => $email, 'password' => 'password123']), + )['token']; + + return ['Authorization' => 'Bearer ' . $token]; + } + + /** + * @return array + */ + protected function decode(ResponseInterface $response): array + { + return (array) json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/tests/TodoTest.php b/tests/TodoTest.php new file mode 100644 index 0000000..ca07d6c --- /dev/null +++ b/tests/TodoTest.php @@ -0,0 +1,153 @@ +request('GET', '/api/lists')->getStatusCode()); + } + + public function test_create_and_list_lists(): void + { + $auth = $this->authHeader(); + + $created = $this->request('POST', '/api/lists', [ + 'title' => ' Groceries ', + 'description' => 'Weekly shop', + ], $auth); + + self::assertSame(201, $created->getStatusCode()); + $list = $this->decode($created)['list']; + self::assertSame('Groceries', $list['title']); + self::assertSame('Weekly shop', $list['description']); + self::assertSame(0, $list['item_count']); + + $index = $this->decode($this->request('GET', '/api/lists', null, $auth)); + self::assertCount(1, $index['lists']); + self::assertSame($list['id'], $index['lists'][0]['id']); + } + + public function test_list_creation_validates_title(): void + { + $response = $this->request('POST', '/api/lists', ['description' => 'no title'], $this->authHeader()); + + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('title', $this->decode($response)['error']['details']); + } + + public function test_a_list_is_only_visible_to_its_owner(): void + { + $owner = $this->authHeader('owner@example.com'); + $other = $this->authHeader('other@example.com'); + + $listId = $this->decode( + $this->request('POST', '/api/lists', ['title' => 'Private'], $owner), + )['list']['id']; + + self::assertSame(200, $this->request('GET', "/api/lists/{$listId}", null, $owner)->getStatusCode()); + self::assertSame(404, $this->request('GET', "/api/lists/{$listId}", null, $other)->getStatusCode()); + self::assertSame(404, $this->request('PATCH', "/api/lists/{$listId}", ['title' => 'x'], $other)->getStatusCode()); + self::assertSame(404, $this->request('DELETE', "/api/lists/{$listId}", null, $other)->getStatusCode()); + } + + public function test_update_and_delete_list(): void + { + $auth = $this->authHeader(); + $listId = $this->decode( + $this->request('POST', '/api/lists', ['title' => 'Draft'], $auth), + )['list']['id']; + + $updated = $this->decode( + $this->request('PATCH', "/api/lists/{$listId}", ['title' => 'Final'], $auth), + )['list']; + self::assertSame('Final', $updated['title']); + + self::assertSame(204, $this->request('DELETE', "/api/lists/{$listId}", null, $auth)->getStatusCode()); + self::assertSame(404, $this->request('GET', "/api/lists/{$listId}", null, $auth)->getStatusCode()); + } + + public function test_items_append_in_order_and_track_completion(): void + { + $auth = $this->authHeader(); + $listId = $this->decode( + $this->request('POST', '/api/lists', ['title' => 'Chores'], $auth), + )['list']['id']; + + foreach (['Wash up', 'Hoover', 'Bins'] as $text) { + $this->request('POST', "/api/lists/{$listId}/items", ['text' => $text], $auth); + } + + $items = $this->decode($this->request('GET', "/api/lists/{$listId}/items", null, $auth))['items']; + self::assertSame(['Wash up', 'Hoover', 'Bins'], array_column($items, 'text')); + self::assertSame([0, 1, 2], array_column($items, 'position')); + self::assertFalse($items[0]['complete']); + + $done = $this->decode( + $this->request('PATCH', "/api/lists/{$listId}/items/{$items[0]['id']}", ['complete' => true], $auth), + )['item']; + self::assertTrue($done['complete']); + + $list = $this->decode($this->request('GET', "/api/lists/{$listId}", null, $auth))['list']; + self::assertSame(3, $list['item_count']); + self::assertSame(1, $list['completed_count']); + } + + public function test_item_creation_accepts_explicit_position_and_validates_text(): void + { + $auth = $this->authHeader(); + $listId = $this->decode( + $this->request('POST', '/api/lists', ['title' => 'L'], $auth), + )['list']['id']; + + $item = $this->decode( + $this->request('POST', "/api/lists/{$listId}/items", ['text' => 'Pinned', 'position' => 5], $auth), + )['item']; + self::assertSame(5, $item['position']); + + $bad = $this->request('POST', "/api/lists/{$listId}/items", ['text' => ' '], $auth); + self::assertSame(422, $bad->getStatusCode()); + self::assertArrayHasKey('text', $this->decode($bad)['error']['details']); + } + + public function test_deleting_a_list_cascades_to_its_items(): void + { + $auth = $this->authHeader(); + $listId = $this->decode( + $this->request('POST', '/api/lists', ['title' => 'Temp'], $auth), + )['list']['id']; + $itemId = $this->decode( + $this->request('POST', "/api/lists/{$listId}/items", ['text' => 'x'], $auth), + )['item']['id']; + + $this->request('DELETE', "/api/lists/{$listId}", null, $auth); + + // The parent list is gone, so the item route 404s on the list check. + self::assertSame( + 404, + $this->request('GET', "/api/lists/{$listId}/items/{$itemId}", null, $auth)->getStatusCode(), + ); + } + + public function test_items_under_another_users_list_are_not_reachable(): void + { + $owner = $this->authHeader('owner2@example.com'); + $other = $this->authHeader('other2@example.com'); + + $listId = $this->decode( + $this->request('POST', '/api/lists', ['title' => 'Mine'], $owner), + )['list']['id']; + + self::assertSame( + 404, + $this->request('POST', "/api/lists/{$listId}/items", ['text' => 'sneaky'], $other)->getStatusCode(), + ); + self::assertSame( + 404, + $this->request('GET', "/api/lists/{$listId}/items", null, $other)->getStatusCode(), + ); + } +}