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); } } diff --git a/app/Application/Users/Actions/ListUsersAction.php b/app/Application/Users/Actions/ListUsersAction.php index aa99464..0023329 100644 --- a/app/Application/Users/Actions/ListUsersAction.php +++ b/app/Application/Users/Actions/ListUsersAction.php @@ -15,20 +15,36 @@ 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. + * 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 { - $users = $this->userService->all(); + $page = max(1, (int)$this->resolveQueryParam('page', 1)); + $perPage = max(1, min(100, (int)$this->resolveQueryParam('perPage', 10))); + + ['users' => $users, 'total' => $total] = $this->userService->paginated($page, $perPage); - $responseData = array_map( + $userData = array_map( fn (User $user) => UserResponseDTO::fromDomain($user), $users ); - return $this->ok($responseData); + return $this->ok( + $userData, + [ + 'total' => $total, + 'page' => $page, + 'perPage' => $perPage, + 'totalPages' => (int)ceil($total / $perPage), + ] + ); } } diff --git a/app/Application/Users/Services/UserService.php b/app/Application/Users/Services/UserService.php index ba78fe8..cc32d71 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 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 + { + return $this->userRepository->findPaginated($page, $perPage); + } + /** * Updates an existing user with the fields provided in the DTO and returns the result. * 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..51c0e79 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 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 + { + $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. * diff --git a/composer.json b/composer.json index 3a4db0f..0de8a90 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ }, "require": { "php": "^8.3", - "andrewdyer/actions": "^0.3", + "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 0463c12..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": "92580881814a4d608d373a654ba5d9db", + "content-hash": "7b402d55d7437779569ac9973b72958d", "packages": [ { "name": "andrewdyer/actions", - "version": "0.3.0", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/andrewdyer/actions.git", - "reference": "91c120b1a866fd02240d309884f780591c617a65" + "reference": "b6899fd2c96dabed4e27a8f57aac32bd1a8cce8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/andrewdyer/actions/zipball/91c120b1a866fd02240d309884f780591c617a65", - "reference": "91c120b1a866fd02240d309884f780591c617a65", + "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.3.0" + "source": "https://github.com/andrewdyer/actions/tree/1.0.0" }, - "time": "2026-04-28T09:38:21+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.2", + "version": "0.2.4", "source": { "type": "git", "url": "https://github.com/andrewdyer/json-error-handler.git", - "reference": "13b537ec623128492bb6bb6d74974b55904c2668" + "reference": "f85ead433f8c7f508c9ced00df5cd45645f647b4" }, "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/f85ead433f8c7f508c9ced00df5cd45645f647b4", + "reference": "f85ead433f8c7f508c9ced00df5cd45645f647b4", "shasum": "" }, "require": { - "andrewdyer/actions": "^0.3", + "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.2" + "source": "https://github.com/andrewdyer/json-error-handler/tree/0.2.4" }, - "time": "2026-04-28T09:57:18+00:00" + "time": "2026-05-20T23:30:08+00:00" }, { "name": "andrewdyer/settings", 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 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()) diff --git a/tests/Integration/Application/Users/Actions/ListUsersActionTest.php b/tests/Integration/Application/Users/Actions/ListUsersActionTest.php index 84a103d..da489f2 100644 --- a/tests/Integration/Application/Users/Actions/ListUsersActionTest.php +++ b/tests/Integration/Application/Users/Actions/ListUsersActionTest.php @@ -12,26 +12,96 @@ final class ListUsersActionTest extends AbstractUsersTestCase { /** - * Asserts that a 200 response containing all seeded users is returned. + * Asserts that the total user count increases after new users are created. */ - public function testReturns200WithAllUsersWhenRequested(): void + public function testTotalIncreasesAfterUsersAreCreated(): void { - $firstUser = $this->userFactory->create(); - $secondUser = $this->userFactory->create(); + $beforeTotal = $this->decodeJson($this->request('GET', '/api/v1/users'))['meta']['total']; - $response = $this->request('GET', '/api/v1/users'); + $this->userFactory->create(); + $this->userFactory->create(); + + $afterTotal = $this->decodeJson($this->request('GET', '/api/v1/users'))['meta']['total']; + + $this->assertSame($beforeTotal + 2, $afterTotal); + } + + /** + * 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'); $this->assertSame(200, $response->getStatusCode()); - $body = $this->decodeJson($response); + $meta = $this->decodeJson($response)['meta']; - $this->assertArrayHasKey('data', $body); + $this->assertArrayHasKey('total', $meta); + $this->assertArrayHasKey('page', $meta); + $this->assertArrayHasKey('perPage', $meta); + $this->assertArrayHasKey('totalPages', $meta); - $data = $body['data']; + $this->assertSame(1, $meta['page']); + $this->assertSame(5, $meta['perPage']); + $this->assertSame( + (int)ceil($meta['total'] / $meta['perPage']), + $meta['totalPages'] + ); + } - $emails = array_column($data, 'email'); + /** + * 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->assertContains($firstUser->getEmail(), $emails); - $this->assertContains($secondUser->getEmail(), $emails); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame($expectedPage, $this->decodeJson($response)['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)['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], + ]; } } 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. */ 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']); + } +}