Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/Application/Users/Actions/CreateUserAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ protected function handle(): Response

$responseDto = UserResponseDTO::fromDomain($user);

return $this->ok($responseDto, 201);
return $this->ok($responseDto, null, 201);
}
}
2 changes: 1 addition & 1 deletion app/Application/Users/Actions/DeleteUserAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ protected function handle(): Response

$this->userService->delete($userId);

return $this->ok(null, 204);
return $this->ok(null, null, 204);
}
}
26 changes: 21 additions & 5 deletions app/Application/Users/Actions/ListUsersAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
andrewdyer marked this conversation as resolved.
*/
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),
]
);
}
}
12 changes: 12 additions & 0 deletions app/Application/Users/Services/UserService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
9 changes: 9 additions & 0 deletions app/Domain/User/UserRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
21 changes: 21 additions & 0 deletions app/Infrastructure/Persistence/User/InMemoryUserRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +91 to +95

return [
'users' => $users,
'total' => $total,
];
}

/**
* Looks up a user by their ID in the in-memory store.
*
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 14 additions & 14 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 17 additions & 18 deletions resources/http/users.http
Original file line number Diff line number Diff line change
@@ -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
### ==================================================
Expand Down Expand Up @@ -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
9 changes: 7 additions & 2 deletions tests/Integration/AbstractTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed>|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())
Expand Down
92 changes: 81 additions & 11 deletions tests/Integration/Application/Users/Actions/ListUsersActionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{string, int}>
*/
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<string, array{string, int}>
*/
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],
];
}
}
Loading
Loading