From 31a807f0aec45c547652046a4c08cff6c1fd5b43 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:33:22 +0300 Subject: [PATCH 1/6] test SearchUsers use case --- .../Unit/User/UseCases/SearchUsersTest.php | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 backend/tests/Unit/User/UseCases/SearchUsersTest.php diff --git a/backend/tests/Unit/User/UseCases/SearchUsersTest.php b/backend/tests/Unit/User/UseCases/SearchUsersTest.php new file mode 100644 index 0000000..6d0cbed --- /dev/null +++ b/backend/tests/Unit/User/UseCases/SearchUsersTest.php @@ -0,0 +1,95 @@ +userRepo = new FakeUserRepository; + $this->useCase = new SearchUsers($this->userRepo); + + $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('alice@example.com'), + displayName: 'alice', + passwordHash: '', + isAdmin: false, + emailConfirmedAt: null, + )); + $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('alex@example.com'), + displayName: 'alex', + passwordHash: '', + isAdmin: false, + emailConfirmedAt: null, + )); + $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('bob@other.com'), + displayName: 'bob', + passwordHash: '', + isAdmin: false, + emailConfirmedAt: null, + )); + } + + public function test_blank_query_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new SearchUsersRequest(query: ' ')); + } + + public function test_finds_users_by_display_name_prefix(): void + { + $results = $this->useCase->execute(new SearchUsersRequest( + query: 'al', + )); + + $this->assertCount(2, $results); + $names = array_map( + function ($user) { + return $user->getDisplayName(); + }, + $results, + ); + sort($names); + $this->assertSame(['alex', 'alice'], $names); + } + + public function test_search_is_case_insensitive(): void + { + $results = $this->useCase->execute(new SearchUsersRequest( + query: 'ALI', + )); + $this->assertCount(1, $results); + $this->assertSame('alice', $results[0]->getDisplayName()); + } + + public function test_finds_users_by_email_prefix(): void + { + $results = $this->useCase->execute(new SearchUsersRequest( + query: 'bob@', + )); + $this->assertCount(1, $results); + $this->assertSame('bob', $results[0]->getDisplayName()); + } + + public function test_no_results_when_no_match(): void + { + $results = $this->useCase->execute(new SearchUsersRequest( + query: 'zzz', + )); + $this->assertSame([], $results); + } +} From d917e76f1b7e4052d2c41d3f847b7fcbe480775b Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:34:08 +0300 Subject: [PATCH 2/6] implement SearchUsers use case --- backend/app/User/EloquentUserRepository.php | 19 ++++++++++++ .../User/UseCases/SearchUsers/SearchUsers.php | 29 ++++++++++++++++++ .../SearchUsers/SearchUsersRequest.php | 10 +++++++ backend/app/User/UserRepository.php | 5 ++++ backend/tests/Fakes/FakeUserRepository.php | 30 +++++++++++++++++++ 5 files changed, 93 insertions(+) create mode 100644 backend/app/User/UseCases/SearchUsers/SearchUsers.php create mode 100644 backend/app/User/UseCases/SearchUsers/SearchUsersRequest.php diff --git a/backend/app/User/EloquentUserRepository.php b/backend/app/User/EloquentUserRepository.php index a4fdc67..5824a6c 100644 --- a/backend/app/User/EloquentUserRepository.php +++ b/backend/app/User/EloquentUserRepository.php @@ -43,6 +43,25 @@ class EloquentUserRepository implements UserRepository return $model === null ? null : $this->toDomain($model); } + /** + * @return User[] + */ + public function search(string $query): array + { + $like = strtolower($query).'%'; + $models = UserModel::query() + ->whereRaw('LOWER(display_name) LIKE ?', [$like]) + ->orWhereRaw('LOWER(email) LIKE ?', [$like]) + ->orderBy('display_name') + ->get(); + + return $models->map( + function (UserModel $model) { + return $this->toDomain($model); + }, + )->all(); + } + /** * @throws RuntimeException */ diff --git a/backend/app/User/UseCases/SearchUsers/SearchUsers.php b/backend/app/User/UseCases/SearchUsers/SearchUsers.php new file mode 100644 index 0000000..ea64e79 --- /dev/null +++ b/backend/app/User/UseCases/SearchUsers/SearchUsers.php @@ -0,0 +1,29 @@ +query); + if ($query === '') { + throw new BadRequestException('query is required'); + } + + return $this->userRepo->search($query); + } +} diff --git a/backend/app/User/UseCases/SearchUsers/SearchUsersRequest.php b/backend/app/User/UseCases/SearchUsers/SearchUsersRequest.php new file mode 100644 index 0000000..a6be688 --- /dev/null +++ b/backend/app/User/UseCases/SearchUsers/SearchUsersRequest.php @@ -0,0 +1,10 @@ +existingUsers as $user) { + $displayName = strtolower($user->getDisplayName()); + $email = strtolower($user->getEmail()->value()); + if ( + str_starts_with($displayName, $needle) + || str_starts_with($email, $needle) + ) { + $results[] = $this->copy($user); + } + } + usort( + $results, + function (User $left, User $right) { + return strcmp( + $left->getDisplayName(), + $right->getDisplayName(), + ); + }, + ); + + return $results; + } + /** * @throws RuntimeException */ From 8cbc84b051a47de71b009cc955ccb625bb92c222 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:34:29 +0300 Subject: [PATCH 3/6] test PromoteUserToAdmin use case --- .../User/UseCases/PromoteUserToAdminTest.php | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 backend/tests/Unit/User/UseCases/PromoteUserToAdminTest.php diff --git a/backend/tests/Unit/User/UseCases/PromoteUserToAdminTest.php b/backend/tests/Unit/User/UseCases/PromoteUserToAdminTest.php new file mode 100644 index 0000000..0ee5d46 --- /dev/null +++ b/backend/tests/Unit/User/UseCases/PromoteUserToAdminTest.php @@ -0,0 +1,94 @@ +userRepo = new FakeUserRepository; + $this->useCase = new PromoteUserToAdmin($this->userRepo); + } + + private function seedUser(bool $isAdmin): int + { + $user = $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + displayName: 'user', + passwordHash: 'h', + isAdmin: $isAdmin, + emailConfirmedAt: null, + )); + + return $user->getId(); + } + + public function test_non_admin_requester_throws_forbidden(): void + { + $userId = $this->seedUser(false); + + $this->expectException(ForbiddenException::class); + $this->useCase->execute(new PromoteUserToAdminRequest( + targetUserId: $userId, + requesterIsAdmin: false, + )); + } + + public function test_zero_target_id_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new PromoteUserToAdminRequest( + targetUserId: 0, + requesterIsAdmin: true, + )); + } + + public function test_unknown_user_throws_domain_exception(): void + { + $this->expectException(DomainException::class); + $this->useCase->execute(new PromoteUserToAdminRequest( + targetUserId: 999, + requesterIsAdmin: true, + )); + } + + public function test_admin_promotes_target(): void + { + $userId = $this->seedUser(false); + + $this->useCase->execute(new PromoteUserToAdminRequest( + targetUserId: $userId, + requesterIsAdmin: true, + )); + + $reloaded = $this->userRepo->find($userId); + $this->assertTrue($reloaded->isAdmin()); + } + + public function test_already_admin_is_idempotent(): void + { + $userId = $this->seedUser(true); + + $this->useCase->execute(new PromoteUserToAdminRequest( + targetUserId: $userId, + requesterIsAdmin: true, + )); + + $reloaded = $this->userRepo->find($userId); + $this->assertTrue($reloaded->isAdmin()); + } +} From ac7295faf36d91bff103f72118f0a109c6d975bd Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:34:53 +0300 Subject: [PATCH 4/6] implement PromoteUserToAdmin use case --- .../PromoteUserToAdmin/PromoteUserToAdmin.php | 53 +++++++++++++++++++ .../PromoteUserToAdminRequest.php | 11 ++++ 2 files changed, 64 insertions(+) create mode 100644 backend/app/User/UseCases/PromoteUserToAdmin/PromoteUserToAdmin.php create mode 100644 backend/app/User/UseCases/PromoteUserToAdmin/PromoteUserToAdminRequest.php diff --git a/backend/app/User/UseCases/PromoteUserToAdmin/PromoteUserToAdmin.php b/backend/app/User/UseCases/PromoteUserToAdmin/PromoteUserToAdmin.php new file mode 100644 index 0000000..23e6dee --- /dev/null +++ b/backend/app/User/UseCases/PromoteUserToAdmin/PromoteUserToAdmin.php @@ -0,0 +1,53 @@ +requesterIsAdmin) { + throw new ForbiddenException( + 'only admins can promote users' + ); + } + if ($request->targetUserId <= 0) { + throw new BadRequestException( + 'targetUserId must be positive' + ); + } + + $target = $this->userRepo->find($request->targetUserId); + if ($target === null) { + throw new DomainException('user not found'); + } + + if ($target->isAdmin()) { + return $target; + } + + return $this->userRepo->update(new User( + id: $target->getId(), + email: $target->getEmail(), + displayName: $target->getDisplayName(), + passwordHash: $target->getPasswordHash(), + isAdmin: true, + emailConfirmedAt: $target->getEmailConfirmedAt(), + )); + } +} diff --git a/backend/app/User/UseCases/PromoteUserToAdmin/PromoteUserToAdminRequest.php b/backend/app/User/UseCases/PromoteUserToAdmin/PromoteUserToAdminRequest.php new file mode 100644 index 0000000..8728ea3 --- /dev/null +++ b/backend/app/User/UseCases/PromoteUserToAdmin/PromoteUserToAdminRequest.php @@ -0,0 +1,11 @@ + Date: Wed, 6 May 2026 22:36:10 +0300 Subject: [PATCH 5/6] test user search and admin promote endpoints --- .../Feature/User/UserSearchAndPromoteTest.php | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 backend/tests/Feature/User/UserSearchAndPromoteTest.php diff --git a/backend/tests/Feature/User/UserSearchAndPromoteTest.php b/backend/tests/Feature/User/UserSearchAndPromoteTest.php new file mode 100644 index 0000000..b5a67f5 --- /dev/null +++ b/backend/tests/Feature/User/UserSearchAndPromoteTest.php @@ -0,0 +1,92 @@ +signupAndLogin( + email: 'alice@example.com', + displayName: 'alice', + password: 'longenoughpassword', + ); + $this->signupAndLogin( + email: 'alex@example.com', + displayName: 'alex', + password: 'longenoughpassword', + ); + + $this->resetClientState(); + $response = $this->getJson('/api/users?q=al'); + $response->assertStatus(200); + $response->assertJsonCount(2, 'users'); + } + + public function test_search_with_no_query_returns_empty(): void + { + $response = $this->getJson('/api/users'); + $response->assertStatus(200); + $response->assertJsonPath('users', []); + } + + public function test_non_admin_cannot_promote(): void + { + $alice = $this->signupAndLogin( + email: 'alice@example.com', + displayName: 'alice', + password: 'longenoughpassword', + ); + $bob = $this->signupAndLogin( + email: 'bob@example.com', + displayName: 'bob', + password: 'longenoughpassword', + ); + + $this->resetClientState(); + $this->withCredentials() + ->withUnencryptedCookie('auth_token', $alice['cookie']) + ->postJson('/api/admin/users/promote', [ + 'userId' => $bob['user']->getId(), + ]) + ->assertStatus(403); + } + + public function test_admin_promotes_user(): void + { + $alice = $this->signupAndLogin( + email: 'alice@example.com', + displayName: 'alice', + password: 'longenoughpassword', + ); + $bob = $this->signupAndLogin( + email: 'bob@example.com', + displayName: 'bob', + password: 'longenoughpassword', + ); + + $this->promoteToAdmin($alice['user']->getId()); + $loginResponse = $this->postJson('/api/login', [ + 'email' => 'alice@example.com', + 'password' => 'longenoughpassword', + ]); + $aliceCookie = $loginResponse->getCookie('auth_token', false) + ->getValue(); + + $this->resetClientState(); + $this->withCredentials() + ->withUnencryptedCookie('auth_token', $aliceCookie) + ->postJson('/api/admin/users/promote', [ + 'userId' => $bob['user']->getId(), + ]) + ->assertStatus(200) + ->assertJsonPath('user.isAdmin', true); + } +} From a3f90d1e85a1e3e3d183e4d18b15a559659324b8 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:36:15 +0300 Subject: [PATCH 6/6] implement user search and admin promote endpoints GET /users?q=... is public; POST /admin/users/promote is auth required and admin-checked inside the use case. --- backend/app/Controllers/UserController.php | 88 ++++++++++++++++++++++ backend/routes/api.php | 5 ++ 2 files changed, 93 insertions(+) create mode 100644 backend/app/Controllers/UserController.php diff --git a/backend/app/Controllers/UserController.php b/backend/app/Controllers/UserController.php new file mode 100644 index 0000000..7f5370c --- /dev/null +++ b/backend/app/Controllers/UserController.php @@ -0,0 +1,88 @@ +query('q'); + if (! is_string($query) || trim($query) === '') { + return new JsonResponse(['users' => []], 200); + } + try { + $results = $this->searchUsers->execute( + new SearchUsersRequest(query: $query), + ); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 400, + ); + } + + return new JsonResponse([ + 'users' => array_map( + function (User $user) { + return [ + 'id' => $user->getId(), + 'email' => $user->getEmail()->value(), + 'displayName' => $user->getDisplayName(), + 'isAdmin' => $user->isAdmin(), + ]; + }, + $results, + ), + ], 200); + } + + public function promote(Request $request): JsonResponse + { + /** @var User $requester */ + $requester = $request->attributes->get('user'); + try { + $promoted = $this->promoteUserToAdmin->execute( + new PromoteUserToAdminRequest( + targetUserId: (int) $request->input('userId'), + requesterIsAdmin: $requester->isAdmin(), + ), + ); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 400, + ); + } catch (ForbiddenException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 403, + ); + } catch (DomainException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 404, + ); + } + + return new JsonResponse([ + 'user' => [ + 'id' => $promoted->getId(), + 'email' => $promoted->getEmail()->value(), + 'displayName' => $promoted->getDisplayName(), + 'isAdmin' => $promoted->isAdmin(), + ], + ], 200); + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 03408cc..d087223 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -3,6 +3,7 @@ use App\Controllers\AuthController; use App\Controllers\CommentController; use App\Controllers\PostController; +use App\Controllers\UserController; use App\Http\Middleware\AuthMiddleware; use Illuminate\Support\Facades\Route; @@ -32,6 +33,10 @@ Route::post('/admin/posts/feature', [PostController::class, 'feature']) Route::post('/admin/posts/unfeature', [PostController::class, 'unfeature']) ->middleware(AuthMiddleware::class); +Route::get('/users', [UserController::class, 'search']); +Route::post('/admin/users/promote', [UserController::class, 'promote']) + ->middleware(AuthMiddleware::class); + Route::get( '/users/{displayName}/posts', [PostController::class, 'listByUser'],