From 872d81f9a2d284a96b71d9af79a93958783e23ab Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Mon, 18 May 2026 18:09:08 +0100 Subject: [PATCH 01/16] chore(deps): :link: update andrewdyer/actions dependency to version 0.6 --- composer.json | 2 +- composer.lock | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/composer.json b/composer.json index 3a4db0f..2eea046 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ }, "require": { "php": "^8.3", - "andrewdyer/actions": "^0.3", + "andrewdyer/actions": "^0.6", "andrewdyer/cors-response-emitter": "^0.1", "andrewdyer/json-error-handler": "^0.2", "andrewdyer/settings": "^0.1", diff --git a/composer.lock b/composer.lock index 0463c12..d00f51c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "92580881814a4d608d373a654ba5d9db", + "content-hash": "0f354e71ace1ddd2e7a4b2ceb12d7587", "packages": [ { "name": "andrewdyer/actions", - "version": "0.3.0", + "version": "0.6.0", "source": { "type": "git", "url": "https://github.com/andrewdyer/actions.git", - "reference": "91c120b1a866fd02240d309884f780591c617a65" + "reference": "a2863691aded8d25d87232658259c07f7545bf3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/andrewdyer/actions/zipball/91c120b1a866fd02240d309884f780591c617a65", - "reference": "91c120b1a866fd02240d309884f780591c617a65", + "url": "https://api.github.com/repos/andrewdyer/actions/zipball/a2863691aded8d25d87232658259c07f7545bf3b", + "reference": "a2863691aded8d25d87232658259c07f7545bf3b", "shasum": "" }, "require": { @@ -55,9 +55,9 @@ ], "support": { "issues": "https://github.com/andrewdyer/actions/issues", - "source": "https://github.com/andrewdyer/actions/tree/0.3.0" + "source": "https://github.com/andrewdyer/actions/tree/0.6.0" }, - "time": "2026-04-28T09:38:21+00:00" + "time": "2026-05-18T14:38:49+00:00" }, { "name": "andrewdyer/cors-response-emitter", @@ -109,20 +109,20 @@ }, { "name": "andrewdyer/json-error-handler", - "version": "0.2.2", + "version": "0.2.3", "source": { "type": "git", "url": "https://github.com/andrewdyer/json-error-handler.git", - "reference": "13b537ec623128492bb6bb6d74974b55904c2668" + "reference": "d1437d1b0deca4f9aae6becf005638b5445f6c7f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/andrewdyer/json-error-handler/zipball/13b537ec623128492bb6bb6d74974b55904c2668", - "reference": "13b537ec623128492bb6bb6d74974b55904c2668", + "url": "https://api.github.com/repos/andrewdyer/json-error-handler/zipball/d1437d1b0deca4f9aae6becf005638b5445f6c7f", + "reference": "d1437d1b0deca4f9aae6becf005638b5445f6c7f", "shasum": "" }, "require": { - "andrewdyer/actions": "^0.3", + "andrewdyer/actions": "^0.6", "php": "^8.3", "psr/http-factory": "^1.1", "psr/http-message": "^1.1 || ^2.0", @@ -147,9 +147,9 @@ "description": "A structured JSON error handler for Slim Framework applications that maps exceptions to typed, consistent error payloads", "support": { "issues": "https://github.com/andrewdyer/json-error-handler/issues", - "source": "https://github.com/andrewdyer/json-error-handler/tree/0.2.2" + "source": "https://github.com/andrewdyer/json-error-handler/tree/0.2.3" }, - "time": "2026-04-28T09:57:18+00:00" + "time": "2026-05-18T15:09:02+00:00" }, { "name": "andrewdyer/settings", From 1caa31cbc76651822f5f4ae63e936b229b5279f9 Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Mon, 18 May 2026 22:07:25 +0100 Subject: [PATCH 02/16] feat: :sparkles: add paginated user repository retrieval Adds paginated user retrieval support to the user repository contract via a new findPaginated() method. The in-memory repository implementation now returns a paginated subset of users along with the total user count, enabling pagination metadata to be calculated by consumers without requiring a separate query. This implementation is intended primarily for demo and development use. --- app/Domain/User/UserRepository.php | 9 ++++++++ .../User/InMemoryUserRepository.php | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/app/Domain/User/UserRepository.php b/app/Domain/User/UserRepository.php index 3ff16d0..0ac8d69 100644 --- a/app/Domain/User/UserRepository.php +++ b/app/Domain/User/UserRepository.php @@ -36,6 +36,15 @@ public function delete(int $id): bool; */ public function findAll(): array; + /** + * Returns a paginated subset of users. + * + * @param int $page The page number to retrieve (1-indexed). + * @param int $perPage The number of users per page. + * @return array{users: User[], total: int} An array containing the users for the requested page and the total count. + */ + public function findPaginated(int $page, int $perPage): array; + /** * Finds a user by their unique identifier. * diff --git a/app/Infrastructure/Persistence/User/InMemoryUserRepository.php b/app/Infrastructure/Persistence/User/InMemoryUserRepository.php index a00cbd7..b94ee4c 100644 --- a/app/Infrastructure/Persistence/User/InMemoryUserRepository.php +++ b/app/Infrastructure/Persistence/User/InMemoryUserRepository.php @@ -79,6 +79,27 @@ public function findAll(): array return array_values($this->store); } + /** + * Returns a paginated subset of users from the in-memory store. + * + * @param int $page The page number to retrieve (1-indexed). + * @param int $perPage The number of users per page. + * @return array{users: User[], total: int} An array containing the users for the requested page and the total count. + */ + public function findPaginated(int $page, int $perPage): array + { + $allUsers = $this->findAll(); + $total = count($allUsers); + $offset = ($page - 1) * $perPage; + + $users = array_slice($allUsers, $offset, $perPage); + + return [ + 'users' => $users, + 'total' => $total, + ]; + } + /** * Looks up a user by their ID in the in-memory store. * From 80616480fa22ccf7249f85f6eb0c9db9df6fe206 Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Tue, 19 May 2026 13:21:12 +0100 Subject: [PATCH 03/16] feat: :sparkles: add paginated user service support Adds paginated user retrieval to the user service by delegating to the repository findPaginated() method. Also introduces service-level tests covering: - correct user counts - page-based result separation - per-page limits - empty results beyond available pages This establishes a basic pagination flow across the service layer. --- .../Users/Services/UserService.php | 12 ++++ .../Users/Services/UserServiceTest.php | 64 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/app/Application/Users/Services/UserService.php b/app/Application/Users/Services/UserService.php index ba78fe8..ca150f2 100644 --- a/app/Application/Users/Services/UserService.php +++ b/app/Application/Users/Services/UserService.php @@ -81,6 +81,18 @@ public function find(int $id): User return $user; } + /** + * Retrieves a paginated list of users from the repository. + * + * @param int $page The page number to retrieve (1-indexed). + * @param int $perPage The number of users per page. + * @return array{users: User[], total: int} An array containing the users for the requested page and the total count. + */ + public function paginated(int $page, int $perPage): array + { + return $this->userRepository->findPaginated($page, $perPage); + } + /** * Updates an existing user with the fields provided in the DTO and returns the result. * diff --git a/tests/Unit/Application/Users/Services/UserServiceTest.php b/tests/Unit/Application/Users/Services/UserServiceTest.php index 38a6d61..eee4b19 100644 --- a/tests/Unit/Application/Users/Services/UserServiceTest.php +++ b/tests/Unit/Application/Users/Services/UserServiceTest.php @@ -44,6 +44,70 @@ public function testReturnsAllUsersWhenUsersExist(): void $this->assertSame('French', $users[0]->getLastName()); } + /** + * Asserts that paginated results contain the correct number of users. + */ + public function testReturnsPaginatedUsersWithCorrectCount(): void + { + $result = $this->userService->paginated(1, 2); + + $this->assertIsArray($result); + $this->assertArrayHasKey('users', $result); + $this->assertArrayHasKey('total', $result); + $this->assertCount(2, $result['users']); + $this->assertSame(5, $result['total']); + } + + /** + * Asserts that the second page of paginated results contains different users. + */ + public function testReturnsCorrectUsersForSecondPage(): void + { + $firstPageResult = $this->userService->paginated(1, 2); + $secondPageResult = $this->userService->paginated(2, 2); + + $this->assertCount(2, $secondPageResult['users']); + $this->assertNotSame( + $firstPageResult['users'][0]->getId(), + $secondPageResult['users'][0]->getId() + ); + } + + /** + * Asserts that paginated results respect the perPage parameter. + */ + public function testRespectsPerPageParameter(): void + { + $result = $this->userService->paginated(1, 3); + + $this->assertCount(3, $result['users']); + $this->assertSame(5, $result['total']); + } + + /** + * Asserts that an empty array is returned when requesting a page beyond available data. + */ + public function testReturnsEmptyArrayWhenPageExceedsTotalPages(): void + { + $result = $this->userService->paginated(10, 10); + + $this->assertEmpty($result['users']); + $this->assertSame(5, $result['total']); + } + + /** + * Asserts that the final page returns the remaining partial set of users. + */ + public function testReturnsPartialFinalPage(): void + { + $result = $this->userService->paginated(2, 3); + + $this->assertCount(2, $result['users']); + $this->assertSame(5, $result['total']); + $this->assertSame(4, $result['users'][0]->getId()); + $this->assertSame(5, $result['users'][1]->getId()); + } + /** * Asserts that a User entity with the correct data is returned after a successful creation. */ From e7b7bc39d672ebe351a6c973a2e8b39d5a28cfef Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Tue, 19 May 2026 23:12:20 +0100 Subject: [PATCH 04/16] feat: :sparkles: add paginated users response DTO Added a dedicated DTO for paginated user collections, including pagination metadata such as total items, current page, items per page, and total pages. --- .../Users/DTOs/PaginatedUsersDTO.php | 47 +++++++ .../Users/DTOs/PaginatedUsersDTOTest.php | 117 ++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 app/Application/Users/DTOs/PaginatedUsersDTO.php create mode 100644 tests/Unit/Application/Users/DTOs/PaginatedUsersDTOTest.php diff --git a/app/Application/Users/DTOs/PaginatedUsersDTO.php b/app/Application/Users/DTOs/PaginatedUsersDTO.php new file mode 100644 index 0000000..c924810 --- /dev/null +++ b/app/Application/Users/DTOs/PaginatedUsersDTO.php @@ -0,0 +1,47 @@ + + */ + public function jsonSerialize(): array + { + return [ + 'data' => $this->data, + 'meta' => [ + 'total' => $this->total, + 'page' => $this->page, + 'perPage' => $this->perPage, + 'totalPages' => $this->totalPages, + ], + ]; + } +} diff --git a/tests/Unit/Application/Users/DTOs/PaginatedUsersDTOTest.php b/tests/Unit/Application/Users/DTOs/PaginatedUsersDTOTest.php new file mode 100644 index 0000000..207568f --- /dev/null +++ b/tests/Unit/Application/Users/DTOs/PaginatedUsersDTOTest.php @@ -0,0 +1,117 @@ +assertSame($users, $dto->data); + $this->assertSame(50, $dto->total); + $this->assertSame(2, $dto->page); + $this->assertSame(10, $dto->perPage); + $this->assertSame(5, $dto->totalPages); + } + + /** + * Asserts that jsonSerialize returns the correct structure with data and meta keys. + */ + public function testReturnsCorrectStructureWhenSerialized(): void + { + $users = [ + new UserResponseDTO(1, 'Jane', 'Doe', 'jane@example.com'), + new UserResponseDTO(2, 'John', 'Smith', 'john@example.com'), + ]; + + $dto = new PaginatedUsersDTO( + data: $users, + total: 25, + page: 1, + perPage: 10, + totalPages: 3 + ); + + $serialized = $dto->jsonSerialize(); + + $this->assertArrayHasKey('data', $serialized); + $this->assertArrayHasKey('meta', $serialized); + $this->assertSame($users, $serialized['data']); + $this->assertSame([ + 'total' => 25, + 'page' => 1, + 'perPage' => 10, + 'totalPages' => 3, + ], $serialized['meta']); + } + + /** + * Asserts that the DTO handles an empty data array correctly. + */ + public function testHandlesEmptyDataArray(): void + { + $dto = new PaginatedUsersDTO( + data: [], + total: 0, + page: 1, + perPage: 10, + totalPages: 0 + ); + + $this->assertSame([], $dto->data); + $this->assertSame(0, $dto->total); + $this->assertSame(0, $dto->totalPages); + } + + /** + * Asserts that jsonSerialize produces valid JSON when encoded. + */ + public function testProducesValidJsonWhenEncoded(): void + { + $users = [ + new UserResponseDTO(1, 'Jane', 'Doe', 'jane@example.com'), + ]; + + $dto = new PaginatedUsersDTO( + data: $users, + total: 1, + page: 1, + perPage: 10, + totalPages: 1 + ); + + $json = json_encode($dto); + + $this->assertIsString($json); + $this->assertJson($json); + + $decoded = json_decode($json, true); + $this->assertArrayHasKey('data', $decoded); + $this->assertArrayHasKey('meta', $decoded); + $this->assertCount(1, $decoded['data']); + } +} From 82c42e9e84496b6af1b74eeef0d6dcb547495008 Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Wed, 20 May 2026 23:22:19 +0100 Subject: [PATCH 05/16] feat: :sparkles: update request method to handle query strings Modify the request method to parse and include query strings in the URI. --- tests/Integration/AbstractTestCase.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/Integration/AbstractTestCase.php b/tests/Integration/AbstractTestCase.php index 046960e..25a8ad1 100644 --- a/tests/Integration/AbstractTestCase.php +++ b/tests/Integration/AbstractTestCase.php @@ -42,18 +42,23 @@ protected function setUp(): void * Processes an HTTP request through the application. * * @param string $method The HTTP method. - * @param string $path The request URI path. + * @param string $path The request URI path (may include query string). * @param array|null $data Optional JSON request body. * @return ResponseInterface The application response. * @throws JsonException If JSON encoding fails. */ protected function request(string $method, string $path, ?array $data = null): ResponseInterface { + $urlParts = is_array($parsed = parse_url($path)) ? $parsed : []; + $uriPath = $urlParts['path'] ?? $path; + $queryString = $urlParts['query'] ?? ''; + $uri = new Uri( scheme: '', host: '', port: 80, - path: $path + path: $uriPath, + query: $queryString ); $serverRequest = (new ServerRequestFactory()) From 01a4674f58a8cdb4dfd158a928e6254f4b78bb2e Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Wed, 20 May 2026 23:39:09 +0100 Subject: [PATCH 06/16] feat: :sparkles: update ListUsersAction for pagination Modify ListUsersAction to retrieve paginated users and return metadata in the response. --- .../Users/Actions/ListUsersAction.php | 26 ++++++++--- .../Users/Actions/ListUsersActionTest.php | 45 ++++++++++++++----- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/app/Application/Users/Actions/ListUsersAction.php b/app/Application/Users/Actions/ListUsersAction.php index aa99464..b6adc01 100644 --- a/app/Application/Users/Actions/ListUsersAction.php +++ b/app/Application/Users/Actions/ListUsersAction.php @@ -4,6 +4,7 @@ namespace App\Application\Users\Actions; +use App\Application\Users\DTOs\PaginatedUsersDTO; use App\Application\Users\DTOs\UserResponseDTO; use App\Domain\User\User; use JsonException; @@ -15,20 +16,33 @@ final class ListUsersAction extends AbstractUserAction { /** - * Retrieves all users from the service and returns them as a JSON collection. + * Retrieves paginated users from the service and returns them with pagination metadata. * - * @return Response A 200 JSON response containing an array of all users. + * @return Response A 200 JSON response containing paginated users and metadata. * @throws JsonException If the request body contains invalid JSON. */ protected function handle(): Response { - $users = $this->userService->all(); + $page = (int)$this->resolveQueryParam('page', 1); + $perPage = (int)$this->resolveQueryParam('perPage', 10); - $responseData = array_map( + $result = $this->userService->paginated($page, $perPage); + + $userData = array_map( fn (User $user) => UserResponseDTO::fromDomain($user), - $users + $result['users'] + ); + + $totalPages = (int)ceil($result['total'] / $perPage); + + $paginatedDto = new PaginatedUsersDTO( + data: $userData, + total: $result['total'], + page: $page, + perPage: $perPage, + totalPages: $totalPages, ); - return $this->ok($responseData); + return $this->ok($paginatedDto); } } diff --git a/tests/Integration/Application/Users/Actions/ListUsersActionTest.php b/tests/Integration/Application/Users/Actions/ListUsersActionTest.php index 84a103d..8f96e22 100644 --- a/tests/Integration/Application/Users/Actions/ListUsersActionTest.php +++ b/tests/Integration/Application/Users/Actions/ListUsersActionTest.php @@ -12,26 +12,51 @@ final class ListUsersActionTest extends AbstractUsersTestCase { /** - * Asserts that a 200 response containing all seeded users is returned. + * Asserts that created users appear in the paginated response regardless + * of how many records already exist in the backing store. */ - public function testReturns200WithAllUsersWhenRequested(): void + public function testCreatedUsersAppearInPaginatedResponse(): void { $firstUser = $this->userFactory->create(); $secondUser = $this->userFactory->create(); - $response = $this->request('GET', '/api/v1/users'); - - $this->assertSame(200, $response->getStatusCode()); + // Fetch the total count first, then request all records in one page. + // This ensures the assertion holds whether the backing store is empty + // (in-memory) or already contains data (e.g. Eloquent against a seeded DB). + $countResponse = $this->request('GET', '/api/v1/users'); + $total = $this->decodeJson($countResponse)['data']['meta']['total']; + $response = $this->request('GET', "/api/v1/users?page=1&perPage={$total}"); $body = $this->decodeJson($response); + $users = $body['data']['data']; - $this->assertArrayHasKey('data', $body); + $emails = array_column($users, 'email'); + $this->assertContains($firstUser->getEmail(), $emails); + $this->assertContains($secondUser->getEmail(), $emails); + } - $data = $body['data']; + /** + * Asserts that the pagination metadata is structurally correct and + * that the totals and page count arithmetic is consistent. + */ + public function testPaginationMetadataIsCorrect(): void + { + $response = $this->request('GET', '/api/v1/users?page=1&perPage=5'); - $emails = array_column($data, 'email'); + $this->assertSame(200, $response->getStatusCode()); - $this->assertContains($firstUser->getEmail(), $emails); - $this->assertContains($secondUser->getEmail(), $emails); + $meta = $this->decodeJson($response)['data']['meta']; + + $this->assertArrayHasKey('total', $meta); + $this->assertArrayHasKey('page', $meta); + $this->assertArrayHasKey('perPage', $meta); + $this->assertArrayHasKey('totalPages', $meta); + + $this->assertSame(1, $meta['page']); + $this->assertSame(5, $meta['perPage']); + $this->assertSame( + (int)ceil($meta['total'] / $meta['perPage']), + $meta['totalPages'] + ); } } From ad3855ad1e078225da6287eb712368debfce9665 Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Thu, 21 May 2026 00:11:00 +0100 Subject: [PATCH 07/16] test: :rotating_light: test status codes Add assertions to verify the response status for user count and paginated user requests. --- .../Application/Users/Actions/ListUsersActionTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Integration/Application/Users/Actions/ListUsersActionTest.php b/tests/Integration/Application/Users/Actions/ListUsersActionTest.php index 8f96e22..3d2a785 100644 --- a/tests/Integration/Application/Users/Actions/ListUsersActionTest.php +++ b/tests/Integration/Application/Users/Actions/ListUsersActionTest.php @@ -24,9 +24,11 @@ public function testCreatedUsersAppearInPaginatedResponse(): void // This ensures the assertion holds whether the backing store is empty // (in-memory) or already contains data (e.g. Eloquent against a seeded DB). $countResponse = $this->request('GET', '/api/v1/users'); + $this->assertSame(200, $countResponse->getStatusCode()); $total = $this->decodeJson($countResponse)['data']['meta']['total']; $response = $this->request('GET', "/api/v1/users?page=1&perPage={$total}"); + $this->assertSame(200, $response->getStatusCode()); $body = $this->decodeJson($response); $users = $body['data']['data']; From 02eccb807dc39417c924110a70136e196d40f4eb Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Thu, 21 May 2026 00:32:29 +0100 Subject: [PATCH 08/16] chore(deps): :arrow_up: update andrewdyer/actions dependency to version ^1.0 --- composer.json | 2 +- composer.lock | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/composer.json b/composer.json index 2eea046..0de8a90 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ }, "require": { "php": "^8.3", - "andrewdyer/actions": "^0.6", + "andrewdyer/actions": "^1.0", "andrewdyer/cors-response-emitter": "^0.1", "andrewdyer/json-error-handler": "^0.2", "andrewdyer/settings": "^0.1", diff --git a/composer.lock b/composer.lock index d00f51c..40e19da 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0f354e71ace1ddd2e7a4b2ceb12d7587", + "content-hash": "7b402d55d7437779569ac9973b72958d", "packages": [ { "name": "andrewdyer/actions", - "version": "0.6.0", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/andrewdyer/actions.git", - "reference": "a2863691aded8d25d87232658259c07f7545bf3b" + "reference": "b6899fd2c96dabed4e27a8f57aac32bd1a8cce8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/andrewdyer/actions/zipball/a2863691aded8d25d87232658259c07f7545bf3b", - "reference": "a2863691aded8d25d87232658259c07f7545bf3b", + "url": "https://api.github.com/repos/andrewdyer/actions/zipball/b6899fd2c96dabed4e27a8f57aac32bd1a8cce8a", + "reference": "b6899fd2c96dabed4e27a8f57aac32bd1a8cce8a", "shasum": "" }, "require": { @@ -55,9 +55,9 @@ ], "support": { "issues": "https://github.com/andrewdyer/actions/issues", - "source": "https://github.com/andrewdyer/actions/tree/0.6.0" + "source": "https://github.com/andrewdyer/actions/tree/1.0.0" }, - "time": "2026-05-18T14:38:49+00:00" + "time": "2026-05-20T23:27:26+00:00" }, { "name": "andrewdyer/cors-response-emitter", @@ -109,20 +109,20 @@ }, { "name": "andrewdyer/json-error-handler", - "version": "0.2.3", + "version": "0.2.4", "source": { "type": "git", "url": "https://github.com/andrewdyer/json-error-handler.git", - "reference": "d1437d1b0deca4f9aae6becf005638b5445f6c7f" + "reference": "f85ead433f8c7f508c9ced00df5cd45645f647b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/andrewdyer/json-error-handler/zipball/d1437d1b0deca4f9aae6becf005638b5445f6c7f", - "reference": "d1437d1b0deca4f9aae6becf005638b5445f6c7f", + "url": "https://api.github.com/repos/andrewdyer/json-error-handler/zipball/f85ead433f8c7f508c9ced00df5cd45645f647b4", + "reference": "f85ead433f8c7f508c9ced00df5cd45645f647b4", "shasum": "" }, "require": { - "andrewdyer/actions": "^0.6", + "andrewdyer/actions": "^1.0", "php": "^8.3", "psr/http-factory": "^1.1", "psr/http-message": "^1.1 || ^2.0", @@ -147,9 +147,9 @@ "description": "A structured JSON error handler for Slim Framework applications that maps exceptions to typed, consistent error payloads", "support": { "issues": "https://github.com/andrewdyer/json-error-handler/issues", - "source": "https://github.com/andrewdyer/json-error-handler/tree/0.2.3" + "source": "https://github.com/andrewdyer/json-error-handler/tree/0.2.4" }, - "time": "2026-05-18T15:09:02+00:00" + "time": "2026-05-20T23:30:08+00:00" }, { "name": "andrewdyer/settings", From e7dd8672ae26ca466716246ad7b8c7d248c1e9fa Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Thu, 21 May 2026 00:34:39 +0100 Subject: [PATCH 09/16] refactor: :hammer: update response handling in user actions Change response method calls to include a null meta parameter. --- app/Application/Users/Actions/CreateUserAction.php | 2 +- app/Application/Users/Actions/DeleteUserAction.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Application/Users/Actions/CreateUserAction.php b/app/Application/Users/Actions/CreateUserAction.php index aaad256..4e120c3 100644 --- a/app/Application/Users/Actions/CreateUserAction.php +++ b/app/Application/Users/Actions/CreateUserAction.php @@ -30,6 +30,6 @@ protected function handle(): Response $responseDto = UserResponseDTO::fromDomain($user); - return $this->ok($responseDto, 201); + return $this->ok($responseDto, null, 201); } } diff --git a/app/Application/Users/Actions/DeleteUserAction.php b/app/Application/Users/Actions/DeleteUserAction.php index 38de5ab..138da56 100644 --- a/app/Application/Users/Actions/DeleteUserAction.php +++ b/app/Application/Users/Actions/DeleteUserAction.php @@ -24,6 +24,6 @@ protected function handle(): Response $this->userService->delete($userId); - return $this->ok(null, 204); + return $this->ok(null, null, 204); } } From 39db8ef17310e5d67375dbc85cc3108cdad70759 Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Thu, 21 May 2026 00:41:50 +0100 Subject: [PATCH 10/16] feat: :sparkles: add pagination validation for user listing Implement clamping for invalid page and perPage parameters in ListUsersAction and add corresponding tests. --- .../Users/Actions/ListUsersAction.php | 11 ++-- .../Users/Actions/ListUsersActionTest.php | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/app/Application/Users/Actions/ListUsersAction.php b/app/Application/Users/Actions/ListUsersAction.php index b6adc01..bf37f1a 100644 --- a/app/Application/Users/Actions/ListUsersAction.php +++ b/app/Application/Users/Actions/ListUsersAction.php @@ -18,13 +18,18 @@ final class ListUsersAction extends AbstractUserAction /** * Retrieves paginated users from the service and returns them with pagination metadata. * + * Invalid page or perPage values are silently clamped to their nearest valid boundary + * rather than rejected with a 400. This keeps the API forgiving for UI consumers where + * stale or out-of-range params are common. To enforce strict validation instead, replace + * the clamping with a 400 error response. + * * @return Response A 200 JSON response containing paginated users and metadata. * @throws JsonException If the request body contains invalid JSON. */ protected function handle(): Response { - $page = (int)$this->resolveQueryParam('page', 1); - $perPage = (int)$this->resolveQueryParam('perPage', 10); + $page = max(1, (int)$this->resolveQueryParam('page', 1)); + $perPage = max(1, min(100, (int)$this->resolveQueryParam('perPage', 10))); $result = $this->userService->paginated($page, $perPage); @@ -33,7 +38,7 @@ protected function handle(): Response $result['users'] ); - $totalPages = (int)ceil($result['total'] / $perPage); + $totalPages = max(1, (int)ceil($result['total'] / $perPage)); $paginatedDto = new PaginatedUsersDTO( data: $userData, diff --git a/tests/Integration/Application/Users/Actions/ListUsersActionTest.php b/tests/Integration/Application/Users/Actions/ListUsersActionTest.php index 3d2a785..c349afa 100644 --- a/tests/Integration/Application/Users/Actions/ListUsersActionTest.php +++ b/tests/Integration/Application/Users/Actions/ListUsersActionTest.php @@ -61,4 +61,58 @@ public function testPaginationMetadataIsCorrect(): void $meta['totalPages'] ); } + + /** + * Asserts that an invalid page parameter is clamped to a valid value. + * + * @dataProvider invalidPageProvider + */ + public function testInvalidPageIsClamped(string $query, int $expectedPage): void + { + $response = $this->request('GET', "/api/v1/users?{$query}"); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame($expectedPage, $this->decodeJson($response)['data']['meta']['page']); + } + + /** + * Provides invalid page query strings and their expected clamped values. + * + * @return array + */ + public static function invalidPageProvider(): array + { + return [ + 'zero page' => ['page=0&perPage=10', 1], + 'negative page' => ['page=-5&perPage=10', 1], + 'non-numeric' => ['page=abc&perPage=10', 1], + ]; + } + + /** + * Asserts that an invalid perPage parameter is clamped to a valid value. + * + * @dataProvider invalidPerPageProvider + */ + public function testInvalidPerPageIsClamped(string $query, int $expectedPerPage): void + { + $response = $this->request('GET', "/api/v1/users?{$query}"); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame($expectedPerPage, $this->decodeJson($response)['data']['meta']['perPage']); + } + + /** + * Provides invalid perPage query strings and their expected clamped values. + * + * @return array + */ + public static function invalidPerPageProvider(): array + { + return [ + 'zero perPage' => ['page=1&perPage=0', 1], + 'negative perPage' => ['page=1&perPage=-10', 1], + 'exceeds maximum' => ['page=1&perPage=999', 100], + ]; + } } From c30f09c037f95a1d6454a727c798f65d00ef263a Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Thu, 21 May 2026 19:07:41 +0100 Subject: [PATCH 11/16] docs: :bulb: update parameter descriptions for pagination methods Clarify that page and perPage parameters must be >= 1 in UserService and InMemoryUserRepository. --- app/Application/Users/Services/UserService.php | 4 ++-- .../Persistence/User/InMemoryUserRepository.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Application/Users/Services/UserService.php b/app/Application/Users/Services/UserService.php index ca150f2..cc32d71 100644 --- a/app/Application/Users/Services/UserService.php +++ b/app/Application/Users/Services/UserService.php @@ -84,8 +84,8 @@ public function find(int $id): User /** * Retrieves a paginated list of users from the repository. * - * @param int $page The page number to retrieve (1-indexed). - * @param int $perPage The number of users per page. + * @param int $page The 1-indexed page number. Must be >= 1. + * @param int $perPage The number of users per page. Must be >= 1. * @return array{users: User[], total: int} An array containing the users for the requested page and the total count. */ public function paginated(int $page, int $perPage): array diff --git a/app/Infrastructure/Persistence/User/InMemoryUserRepository.php b/app/Infrastructure/Persistence/User/InMemoryUserRepository.php index b94ee4c..51c0e79 100644 --- a/app/Infrastructure/Persistence/User/InMemoryUserRepository.php +++ b/app/Infrastructure/Persistence/User/InMemoryUserRepository.php @@ -82,8 +82,8 @@ public function findAll(): array /** * Returns a paginated subset of users from the in-memory store. * - * @param int $page The page number to retrieve (1-indexed). - * @param int $perPage The number of users per page. + * @param int $page The 1-indexed page number. Must be >= 1. + * @param int $perPage The number of users per page. Must be >= 1. * @return array{users: User[], total: int} An array containing the users for the requested page and the total count. */ public function findPaginated(int $page, int $perPage): array From 96f8a2946ac9348d7a67a7dbed1d39ce5749e86e Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Thu, 21 May 2026 19:32:47 +0100 Subject: [PATCH 12/16] refactor: :hammer: simplify pagination response handling Remove PaginatedUsersDTO and streamline response structure in ListUsersAction. --- .../Users/Actions/ListUsersAction.php | 23 ++-- .../Users/DTOs/PaginatedUsersDTO.php | 47 ------- .../Users/Actions/ListUsersActionTest.php | 10 +- .../Users/DTOs/PaginatedUsersDTOTest.php | 117 ------------------ 4 files changed, 15 insertions(+), 182 deletions(-) delete mode 100644 app/Application/Users/DTOs/PaginatedUsersDTO.php delete mode 100644 tests/Unit/Application/Users/DTOs/PaginatedUsersDTOTest.php diff --git a/app/Application/Users/Actions/ListUsersAction.php b/app/Application/Users/Actions/ListUsersAction.php index bf37f1a..d408a9e 100644 --- a/app/Application/Users/Actions/ListUsersAction.php +++ b/app/Application/Users/Actions/ListUsersAction.php @@ -4,7 +4,6 @@ namespace App\Application\Users\Actions; -use App\Application\Users\DTOs\PaginatedUsersDTO; use App\Application\Users\DTOs\UserResponseDTO; use App\Domain\User\User; use JsonException; @@ -31,23 +30,21 @@ protected function handle(): Response $page = max(1, (int)$this->resolveQueryParam('page', 1)); $perPage = max(1, min(100, (int)$this->resolveQueryParam('perPage', 10))); - $result = $this->userService->paginated($page, $perPage); + ['users' => $users, 'total' => $total] = $this->userService->paginated($page, $perPage); $userData = array_map( fn (User $user) => UserResponseDTO::fromDomain($user), - $result['users'] + $users ); - $totalPages = max(1, (int)ceil($result['total'] / $perPage)); - - $paginatedDto = new PaginatedUsersDTO( - data: $userData, - total: $result['total'], - page: $page, - perPage: $perPage, - totalPages: $totalPages, + return $this->ok( + $userData, + [ + 'total' => $total, + 'page' => $page, + 'perPage' => $perPage, + 'totalPages' => max(1, (int)ceil($total / $perPage)), + ] ); - - return $this->ok($paginatedDto); } } diff --git a/app/Application/Users/DTOs/PaginatedUsersDTO.php b/app/Application/Users/DTOs/PaginatedUsersDTO.php deleted file mode 100644 index c924810..0000000 --- a/app/Application/Users/DTOs/PaginatedUsersDTO.php +++ /dev/null @@ -1,47 +0,0 @@ - - */ - public function jsonSerialize(): array - { - return [ - 'data' => $this->data, - 'meta' => [ - 'total' => $this->total, - 'page' => $this->page, - 'perPage' => $this->perPage, - 'totalPages' => $this->totalPages, - ], - ]; - } -} diff --git a/tests/Integration/Application/Users/Actions/ListUsersActionTest.php b/tests/Integration/Application/Users/Actions/ListUsersActionTest.php index c349afa..c590f6f 100644 --- a/tests/Integration/Application/Users/Actions/ListUsersActionTest.php +++ b/tests/Integration/Application/Users/Actions/ListUsersActionTest.php @@ -25,12 +25,12 @@ public function testCreatedUsersAppearInPaginatedResponse(): void // (in-memory) or already contains data (e.g. Eloquent against a seeded DB). $countResponse = $this->request('GET', '/api/v1/users'); $this->assertSame(200, $countResponse->getStatusCode()); - $total = $this->decodeJson($countResponse)['data']['meta']['total']; + $total = $this->decodeJson($countResponse)['meta']['total']; $response = $this->request('GET', "/api/v1/users?page=1&perPage={$total}"); $this->assertSame(200, $response->getStatusCode()); $body = $this->decodeJson($response); - $users = $body['data']['data']; + $users = $body['data']; $emails = array_column($users, 'email'); $this->assertContains($firstUser->getEmail(), $emails); @@ -47,7 +47,7 @@ public function testPaginationMetadataIsCorrect(): void $this->assertSame(200, $response->getStatusCode()); - $meta = $this->decodeJson($response)['data']['meta']; + $meta = $this->decodeJson($response)['meta']; $this->assertArrayHasKey('total', $meta); $this->assertArrayHasKey('page', $meta); @@ -72,7 +72,7 @@ public function testInvalidPageIsClamped(string $query, int $expectedPage): void $response = $this->request('GET', "/api/v1/users?{$query}"); $this->assertSame(200, $response->getStatusCode()); - $this->assertSame($expectedPage, $this->decodeJson($response)['data']['meta']['page']); + $this->assertSame($expectedPage, $this->decodeJson($response)['meta']['page']); } /** @@ -99,7 +99,7 @@ public function testInvalidPerPageIsClamped(string $query, int $expectedPerPage) $response = $this->request('GET', "/api/v1/users?{$query}"); $this->assertSame(200, $response->getStatusCode()); - $this->assertSame($expectedPerPage, $this->decodeJson($response)['data']['meta']['perPage']); + $this->assertSame($expectedPerPage, $this->decodeJson($response)['meta']['perPage']); } /** diff --git a/tests/Unit/Application/Users/DTOs/PaginatedUsersDTOTest.php b/tests/Unit/Application/Users/DTOs/PaginatedUsersDTOTest.php deleted file mode 100644 index 207568f..0000000 --- a/tests/Unit/Application/Users/DTOs/PaginatedUsersDTOTest.php +++ /dev/null @@ -1,117 +0,0 @@ -assertSame($users, $dto->data); - $this->assertSame(50, $dto->total); - $this->assertSame(2, $dto->page); - $this->assertSame(10, $dto->perPage); - $this->assertSame(5, $dto->totalPages); - } - - /** - * Asserts that jsonSerialize returns the correct structure with data and meta keys. - */ - public function testReturnsCorrectStructureWhenSerialized(): void - { - $users = [ - new UserResponseDTO(1, 'Jane', 'Doe', 'jane@example.com'), - new UserResponseDTO(2, 'John', 'Smith', 'john@example.com'), - ]; - - $dto = new PaginatedUsersDTO( - data: $users, - total: 25, - page: 1, - perPage: 10, - totalPages: 3 - ); - - $serialized = $dto->jsonSerialize(); - - $this->assertArrayHasKey('data', $serialized); - $this->assertArrayHasKey('meta', $serialized); - $this->assertSame($users, $serialized['data']); - $this->assertSame([ - 'total' => 25, - 'page' => 1, - 'perPage' => 10, - 'totalPages' => 3, - ], $serialized['meta']); - } - - /** - * Asserts that the DTO handles an empty data array correctly. - */ - public function testHandlesEmptyDataArray(): void - { - $dto = new PaginatedUsersDTO( - data: [], - total: 0, - page: 1, - perPage: 10, - totalPages: 0 - ); - - $this->assertSame([], $dto->data); - $this->assertSame(0, $dto->total); - $this->assertSame(0, $dto->totalPages); - } - - /** - * Asserts that jsonSerialize produces valid JSON when encoded. - */ - public function testProducesValidJsonWhenEncoded(): void - { - $users = [ - new UserResponseDTO(1, 'Jane', 'Doe', 'jane@example.com'), - ]; - - $dto = new PaginatedUsersDTO( - data: $users, - total: 1, - page: 1, - perPage: 10, - totalPages: 1 - ); - - $json = json_encode($dto); - - $this->assertIsString($json); - $this->assertJson($json); - - $decoded = json_decode($json, true); - $this->assertArrayHasKey('data', $decoded); - $this->assertArrayHasKey('meta', $decoded); - $this->assertCount(1, $decoded['data']); - } -} From e2098079a743c861869196ca8dc3ea89e5044059 Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Thu, 21 May 2026 19:41:01 +0100 Subject: [PATCH 13/16] refactor: :hammer: simplify user listing documentation Remove outdated comments and clarify pagination sections for user listing. --- resources/http/users.http | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/resources/http/users.http b/resources/http/users.http index 34c4b69..39ae514 100644 --- a/resources/http/users.http +++ b/resources/http/users.http @@ -1,15 +1,30 @@ @baseUrl = http://localhost:8888 @userId = 1 -### ### ================================================== -### USERS: LIST (INITIAL STATE) +### USERS: LIST ### ================================================== GET {{baseUrl}}/api/v1/users Accept: application/json ### +### ================================================== +### USERS: LIST (PAGINATED - FIRST PAGE) +### ================================================== +GET {{baseUrl}}/api/v1/users?page=1&perPage=3 +Accept: application/json + +### + +### ================================================== +### USERS: LIST (PAGINATED - SECOND PAGE) +### ================================================== +GET {{baseUrl}}/api/v1/users?page=2&perPage=3 +Accept: application/json + +### + ### ================================================== ### USERS: CREATE ### ================================================== @@ -48,24 +63,8 @@ Accept: application/json ### -### ================================================== -### USERS: LIST (AFTER UPDATE) -### ================================================== -GET {{baseUrl}}/api/v1/users -Accept: application/json - -### - ### ================================================== ### USERS: DELETE ### ================================================== DELETE {{baseUrl}}/api/v1/users/{{userId}} -Accept: application/json - -### - -### ================================================== -### USERS: LIST (FINAL STATE) -### ================================================== -GET {{baseUrl}}/api/v1/users Accept: application/json \ No newline at end of file From 01f16ae3ff4d43d4da7b21ac490e086b405250e6 Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Thu, 21 May 2026 20:00:20 +0100 Subject: [PATCH 14/16] test: :rotating_light: add unit tests for InMemoryUserRepository Implement tests for pagination functionality and user retrieval in InMemoryUserRepository. --- .../User/InMemoryUserRepositoryTest.php | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/Unit/Infrastructure/Persistence/User/InMemoryUserRepositoryTest.php diff --git a/tests/Unit/Infrastructure/Persistence/User/InMemoryUserRepositoryTest.php b/tests/Unit/Infrastructure/Persistence/User/InMemoryUserRepositoryTest.php new file mode 100644 index 0000000..965bbcd --- /dev/null +++ b/tests/Unit/Infrastructure/Persistence/User/InMemoryUserRepositoryTest.php @@ -0,0 +1,80 @@ +repository = new InMemoryUserRepository(); + } + + /** + * Asserts that the repository is seeded with the expected number of users on construction. + */ + public function testRepositoryIsSeededOnConstruction(): void + { + $result = $this->repository->findPaginated(1, 100); + + $this->assertSame(5, $result['total']); + } + + /** + * Asserts that findPaginated returns the correct slice of users for a given page. + */ + public function testFindPaginatedReturnsCorrectSliceForPage(): void + { + $result = $this->repository->findPaginated(1, 2); + + $this->assertCount(2, $result['users']); + $this->assertSame(5, $result['total']); + } + + /** + * Asserts that findPaginated returns the correct users on the second page. + */ + public function testFindPaginatedReturnsCorrectUsersOnSecondPage(): void + { + $firstPage = $this->repository->findPaginated(1, 2); + $secondPage = $this->repository->findPaginated(2, 2); + + $firstPageIds = array_column(array_map(fn ($u) => ['id' => $u->getId()], $firstPage['users']), 'id'); + $secondPageIds = array_column(array_map(fn ($u) => ['id' => $u->getId()], $secondPage['users']), 'id'); + + // Pages should not overlap + $this->assertEmpty(array_intersect($firstPageIds, $secondPageIds)); + } + + /** + * Asserts that findPaginated returns an empty users array when the page is out of range. + */ + public function testFindPaginatedReturnsEmptyUsersForOutOfRangePage(): void + { + $result = $this->repository->findPaginated(10, 10); + + $this->assertEmpty($result['users']); + $this->assertSame(5, $result['total']); + } + + /** + * Asserts that the total reflects all users regardless of the requested page. + */ + public function testFindPaginatedTotalIsAlwaysTheFullCount(): void + { + $firstPage = $this->repository->findPaginated(1, 2); + $secondPage = $this->repository->findPaginated(2, 2); + + $this->assertSame($firstPage['total'], $secondPage['total']); + } +} From a89d5e70dc32dcba5f511fe1d75e9ea9e43c9f9d Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Thu, 21 May 2026 20:13:33 +0100 Subject: [PATCH 15/16] fix: :bug: correct totalPages calculation in pagination Ensure totalPages is calculated correctly without clamping to a minimum of 1. --- app/Application/Users/Actions/ListUsersAction.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Application/Users/Actions/ListUsersAction.php b/app/Application/Users/Actions/ListUsersAction.php index d408a9e..0023329 100644 --- a/app/Application/Users/Actions/ListUsersAction.php +++ b/app/Application/Users/Actions/ListUsersAction.php @@ -43,7 +43,7 @@ protected function handle(): Response 'total' => $total, 'page' => $page, 'perPage' => $perPage, - 'totalPages' => max(1, (int)ceil($total / $perPage)), + 'totalPages' => (int)ceil($total / $perPage), ] ); } From 947a271a7db338cd9f0b24d3a66799aab9ef8c49 Mon Sep 17 00:00:00 2001 From: Andrew Dyer Date: Thu, 21 May 2026 20:21:21 +0100 Subject: [PATCH 16/16] refactor: :hammer: simplify user creation test logic Update testTotalIncreasesAfterUsersAreCreated to directly assert user count increase without unnecessary pagination checks. --- .../Users/Actions/ListUsersActionTest.php | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/tests/Integration/Application/Users/Actions/ListUsersActionTest.php b/tests/Integration/Application/Users/Actions/ListUsersActionTest.php index c590f6f..da489f2 100644 --- a/tests/Integration/Application/Users/Actions/ListUsersActionTest.php +++ b/tests/Integration/Application/Users/Actions/ListUsersActionTest.php @@ -12,29 +12,18 @@ final class ListUsersActionTest extends AbstractUsersTestCase { /** - * Asserts that created users appear in the paginated response regardless - * of how many records already exist in the backing store. + * Asserts that the total user count increases after new users are created. */ - public function testCreatedUsersAppearInPaginatedResponse(): void + public function testTotalIncreasesAfterUsersAreCreated(): void { - $firstUser = $this->userFactory->create(); - $secondUser = $this->userFactory->create(); + $beforeTotal = $this->decodeJson($this->request('GET', '/api/v1/users'))['meta']['total']; - // Fetch the total count first, then request all records in one page. - // This ensures the assertion holds whether the backing store is empty - // (in-memory) or already contains data (e.g. Eloquent against a seeded DB). - $countResponse = $this->request('GET', '/api/v1/users'); - $this->assertSame(200, $countResponse->getStatusCode()); - $total = $this->decodeJson($countResponse)['meta']['total']; + $this->userFactory->create(); + $this->userFactory->create(); - $response = $this->request('GET', "/api/v1/users?page=1&perPage={$total}"); - $this->assertSame(200, $response->getStatusCode()); - $body = $this->decodeJson($response); - $users = $body['data']; + $afterTotal = $this->decodeJson($this->request('GET', '/api/v1/users'))['meta']['total']; - $emails = array_column($users, 'email'); - $this->assertContains($firstUser->getEmail(), $emails); - $this->assertContains($secondUser->getEmail(), $emails); + $this->assertSame($beforeTotal + 2, $afterTotal); } /**