From 43fee92291bce003ab8ddbdd3b6988fe7a2f4c54 Mon Sep 17 00:00:00 2001 From: Dennis Petersmann Date: Thu, 22 Oct 2026 20:49:54 +0200 Subject: [PATCH 1/2] feat: suggest who to follow from the API The mobile app can show who to follow as the web does: the same three people and three spares, a new page of the day's picks each round, with why each one is suggested and whether they follow you. Not interested keeps someone out of the suggestions for good, as on the web. --- KNOWLEDGE_BASE.md | 1 + .../Api/SuggestedUserController.php | 32 +++++ .../Api/SuggestionDismissalController.php | 24 ++++ .../CreateApiSuggestionDismissalRequest.php | 33 ++++++ .../Requests/ListApiSuggestedUsersRequest.php | 20 ++++ app/Http/Resources/UserResource.php | 2 + routes/api.php | 6 + .../Api/SuggestedUserControllerTest.php | 111 ++++++++++++++++++ .../Api/SuggestionDismissalControllerTest.php | 55 +++++++++ 9 files changed, 284 insertions(+) create mode 100644 app/Http/Controllers/Api/SuggestedUserController.php create mode 100644 app/Http/Controllers/Api/SuggestionDismissalController.php create mode 100644 app/Http/Requests/CreateApiSuggestionDismissalRequest.php create mode 100644 app/Http/Requests/ListApiSuggestedUsersRequest.php create mode 100644 tests/Feature/Controllers/Api/SuggestedUserControllerTest.php create mode 100644 tests/Feature/Controllers/Api/SuggestionDismissalControllerTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index af94f8a08..a6ae99deb 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -463,6 +463,7 @@ A signed request sees what the web shows you, such as your own pending commits. - commit, reply, and quote, with images, and delete your commits; - preview commit drafts without publishing them; - follow or unfollow, mute or unmute, and block or unblock, and list who you muted and blocked; +- read who to follow and mark a suggestion not interested; - star or unstar; - create polls on commits that are neither replies nor quotes, and vote on polls; - change Remotes settings, read and send messages with images and link cards in conversations and orgs, mark them pulled, react to messages and list your reactions, find people, and start orgs; diff --git a/app/Http/Controllers/Api/SuggestedUserController.php b/app/Http/Controllers/Api/SuggestedUserController.php new file mode 100644 index 000000000..b51d9403b --- /dev/null +++ b/app/Http/Controllers/Api/SuggestedUserController.php @@ -0,0 +1,32 @@ +get($user, round: $request->integer('round'), spares: 3); + + $suggestions->each(function (User $suggestion): void { + $suggestion->setAttribute('is_followed', false); + }); + + return UserResource::collection($suggestions); + } +} diff --git a/app/Http/Controllers/Api/SuggestionDismissalController.php b/app/Http/Controllers/Api/SuggestionDismissalController.php new file mode 100644 index 000000000..be62bf0b4 --- /dev/null +++ b/app/Http/Controllers/Api/SuggestionDismissalController.php @@ -0,0 +1,24 @@ +handle($currentUser, $user); + + return response()->noContent(); + } +} diff --git a/app/Http/Requests/CreateApiSuggestionDismissalRequest.php b/app/Http/Requests/CreateApiSuggestionDismissalRequest.php new file mode 100644 index 000000000..6b686c13c --- /dev/null +++ b/app/Http/Requests/CreateApiSuggestionDismissalRequest.php @@ -0,0 +1,33 @@ +user(); + assert($user instanceof User); + + $dismissed = $this->route('user'); + assert($dismissed instanceof User); + + return $user->isNot($dismissed); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ListApiSuggestedUsersRequest.php b/app/Http/Requests/ListApiSuggestedUsersRequest.php new file mode 100644 index 000000000..e9cf7079c --- /dev/null +++ b/app/Http/Requests/ListApiSuggestedUsersRequest.php @@ -0,0 +1,20 @@ +> + */ + public function rules(): array + { + return [ + 'round' => ['nullable', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 312984023..72d7ea937 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -11,6 +11,7 @@ /** * A user's public profile, with their counts when they were loaded. A signed request also gets the lane, the color * the web draws them in, and in a list of people, whether its user follows each one and whether each one follows them. + * Who to follow also says how many people the user follows follow each one. * * @mixin User */ @@ -40,6 +41,7 @@ public function toArray(Request $request): array 'lane' => $this->when($request->user() instanceof User, fn (): int|string => $this->lane()), 'is_followed' => $this->whenHas('is_followed'), 'follows_you' => $this->whenHas('follows_you'), + 'followed_by_following_count' => $this->whenHas('followed_by_following_count'), ]; } } diff --git a/routes/api.php b/routes/api.php index 173a1ae0f..7cda0415a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -36,6 +36,8 @@ use App\Http\Controllers\Api\OrgTypingController; use App\Http\Controllers\Api\RemoteCandidateController; use App\Http\Controllers\Api\StarredCommitController; +use App\Http\Controllers\Api\SuggestedUserController; +use App\Http\Controllers\Api\SuggestionDismissalController; use App\Http\Controllers\Api\UserBlockController; use App\Http\Controllers\Api\UserCommitController; use App\Http\Controllers\Api\UserController; @@ -167,6 +169,10 @@ ->middleware('throttle:follows') ->name('api-user-follow.destroy'); + // Who to Follow... + Route::get('me/suggested-users', [SuggestedUserController::class, 'index'])->name('api-suggested-user.index'); + Route::post('users/{user:username}/dismiss', [SuggestionDismissalController::class, 'store'])->name('api-suggestion-dismissal.store'); + // User Mute... Route::get('me/muted', [UserMuteController::class, 'index'])->name('api-user-mute.index'); Route::post('users/{user:username}/mute', [UserMuteController::class, 'store'])->name('api-user-mute.store'); diff --git a/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php b/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php new file mode 100644 index 000000000..b97d22ba7 --- /dev/null +++ b/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php @@ -0,0 +1,111 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('suggests people to follow, with why and whether they follow the user', function (): void { + $friend = User::factory()->create(); + $vouched = User::factory()->has(Commit::factory())->create(['username' => 'octocat']); + + $this->user->following()->attach($friend); + $friend->following()->attach($vouched); + $vouched->following()->attach($this->user); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index')); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $vouched->id) + ->assertJsonPath('data.0.username', 'octocat') + ->assertJsonPath('data.0.lane', $vouched->lane()) + ->assertJsonPath('data.0.followed_by_following_count', 1) + ->assertJsonPath('data.0.followers_count', 1) + ->assertJsonPath('data.0.follows_you', true) + ->assertJsonPath('data.0.is_followed', false); +}); + +it('suggests popular people nobody the user follows follows, with their followers', function (): void { + $popular = User::factory()->has(Commit::factory())->create(); + + $popular->followers()->attach(User::factory()->count(2)->create()); + + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index')) + ->assertOk() + ->assertJsonPath('data.0.id', $popular->id) + ->assertJsonPath('data.0.followed_by_following_count', 0) + ->assertJsonPath('data.0.followers_count', 2) + ->assertJsonPath('data.0.follows_you', false) + ->assertJsonPath('data.0.is_followed', false); +}); + +it('sends three suggestions and three spares, as the web does', function (): void { + User::factory()->count(8)->has(Commit::factory())->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index')) + ->assertOk() + ->assertJsonCount(6, 'data'); +}); + +it("moves to the next page of the day's picks each round", function (): void { + User::factory()->count(8)->has(Commit::factory())->create(); + + $first = signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index'))->json('data.*.id'); + $again = signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', ['round' => 0]))->json('data.*.id'); + $next = signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', ['round' => 1]))->json('data.*.id'); + + expect($again)->toBe($first) + ->and(array_slice($next, 0, 3))->not->toBe(array_slice($first, 0, 3)); +}); + +it('leaves out people the user dismissed', function (): void { + $dismissed = User::factory()->has(Commit::factory())->create(); + $suggested = User::factory()->has(Commit::factory())->create(); + + $this->user->dismissedSuggestions()->attach($dismissed); + + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index')) + ->assertOk() + ->assertJsonPath('data.*.id', [$suggested->id]); +}); + +it('starts at the first round when none is given', function (): void { + User::factory()->count(8)->has(Commit::factory())->create(); + + $first = signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', ['round' => 0]))->json('data.*.id'); + + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', ['round' => ''])) + ->assertOk() + ->assertJsonPath('data.*.id', $first); +}); + +it('leaves out people the user mutes or has a block with', function (Closure $hide): void { + $hidden = User::factory()->has(Commit::factory())->create(); + $suggested = User::factory()->has(Commit::factory())->create(); + + $hide($this->user, $hidden); + + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index')) + ->assertOk() + ->assertJsonPath('data.*.id', [$suggested->id]); +})->with([ + 'muting' => fn (User $user, User $hidden) => $user->muting()->attach($hidden), + 'blocking' => fn (User $user, User $hidden) => $user->blocking()->attach($hidden), + 'blocked' => fn (User $user, User $hidden) => $hidden->blocking()->attach($user), +]); + +it('refuses a round that is not a whole number from zero', function (mixed $round): void { + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', ['round' => $round])) + ->assertUnprocessable() + ->assertJsonValidationErrors('round'); +})->with([-1, 'next']); + +it('requires a signed request', function (): void { + $this->getJson(route('api-suggested-user.index'))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/Api/SuggestionDismissalControllerTest.php b/tests/Feature/Controllers/Api/SuggestionDismissalControllerTest.php new file mode 100644 index 000000000..8851a07a5 --- /dev/null +++ b/tests/Feature/Controllers/Api/SuggestionDismissalControllerTest.php @@ -0,0 +1,55 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('may dismiss a suggested user', function (): void { + $dismissed = User::factory()->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-suggestion-dismissal.store', $dismissed))->assertNoContent(); + + expect($this->user->dismissedSuggestions()->pluck('users.id')->all())->toBe([$dismissed->id]); +}); + +it('keeps one dismissal when a user is dismissed again', function (): void { + $dismissed = User::factory()->create(); + + $this->user->dismissedSuggestions()->attach($dismissed); + + signedApiRequest($this->keyPair, 'POST', route('api-suggestion-dismissal.store', $dismissed))->assertNoContent(); + + expect($this->user->dismissedSuggestions()->count())->toBe(1); +}); + +it('neither mutes nor unfollows who it dismisses', function (): void { + $dismissed = User::factory()->create(); + + $this->user->following()->attach($dismissed); + + signedApiRequest($this->keyPair, 'POST', route('api-suggestion-dismissal.store', $dismissed))->assertNoContent(); + + expect($this->user->isFollowing($dismissed))->toBeTrue() + ->and($this->user->isMuting($dismissed))->toBeFalse(); +}); + +it('may not dismiss themselves', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-suggestion-dismissal.store', $this->user))->assertForbidden(); + + expect($this->user->dismissedSuggestions()->count())->toBe(0); +}); + +it('returns not found for unknown usernames', function (): void { + signedApiRequest($this->keyPair, 'POST', 'api/v1/users/missing/dismiss')->assertNotFound(); +}); + +it('requires a signed request', function (): void { + $dismissed = User::factory()->create(); + + $this->postJson(route('api-suggestion-dismissal.store', $dismissed))->assertUnauthorized(); +}); From 9fe76242f703e812b0e58bcd8821930658f9d06e Mon Sep 17 00:00:00 2001 From: Dennis Petersmann Date: Sat, 24 Oct 2026 07:57:36 +0200 Subject: [PATCH 2/2] feat: suggest who to follow beside a profile or commit from the API Takes the context the web passes since #1021: `username` for a profile, `commit_id` for a commit the user can see, as its page requires. Without either, the suggestions stay as they were. --- KNOWLEDGE_BASE.md | 2 +- .../Api/SuggestedUserController.php | 7 +- .../Requests/ListApiSuggestedUsersRequest.php | 27 +++- .../Api/SuggestedUserControllerTest.php | 150 ++++++++++++++++++ 4 files changed, 181 insertions(+), 5 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index a6ae99deb..e9f42b7cc 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -463,7 +463,7 @@ A signed request sees what the web shows you, such as your own pending commits. - commit, reply, and quote, with images, and delete your commits; - preview commit drafts without publishing them; - follow or unfollow, mute or unmute, and block or unblock, and list who you muted and blocked; -- read who to follow and mark a suggestion not interested; +- read who to follow, also as it is suggested beside a profile or a commit, and mark a suggestion not interested; - star or unstar; - create polls on commits that are neither replies nor quotes, and vote on polls; - change Remotes settings, read and send messages with images and link cards in conversations and orgs, mark them pulled, react to messages and list your reactions, find people, and start orgs; diff --git a/app/Http/Controllers/Api/SuggestedUserController.php b/app/Http/Controllers/Api/SuggestedUserController.php index b51d9403b..c1483dd72 100644 --- a/app/Http/Controllers/Api/SuggestedUserController.php +++ b/app/Http/Controllers/Api/SuggestedUserController.php @@ -16,12 +16,13 @@ /** * Who to follow, as the web suggests them: three people to show and three spares to fill the place of one * dismissed, each with why they are suggested and whether they follow the user. Each round is the next page of - * the day's picks, as the web's refresh button shows. Nobody suggested is followed yet, which each one says, as - * in any other list of people. + * the day's picks, as the web's refresh button shows. Beside a profile or a commit, the people that developer + * follows or the thread's participants come first, as the web suggests them there. Nobody suggested is followed + * yet, which each one says, as in any other list of people. */ public function index(ListApiSuggestedUsersRequest $request, #[CurrentUser] User $user, SuggestedUsersQuery $suggested): AnonymousResourceCollection { - $suggestions = $suggested->get($user, round: $request->integer('round'), spares: 3); + $suggestions = $suggested->get($user, round: $request->integer('round'), spares: 3, context: $request->context()); $suggestions->each(function (User $suggestion): void { $suggestion->setAttribute('is_followed', false); diff --git a/app/Http/Requests/ListApiSuggestedUsersRequest.php b/app/Http/Requests/ListApiSuggestedUsersRequest.php index e9cf7079c..1003d56fc 100644 --- a/app/Http/Requests/ListApiSuggestedUsersRequest.php +++ b/app/Http/Requests/ListApiSuggestedUsersRequest.php @@ -4,17 +4,42 @@ namespace App\Http\Requests; +use App\Models\Commit; +use App\Models\User; +use App\Rules\VisibleCommit; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +/** + * Who to follow may be asked for the page it shows on, as the web asks for it beside a profile or a commit. The commit + * has to be one the user can see, as its page requires. + */ final class ListApiSuggestedUsersRequest extends FormRequest { /** - * @return array> + * @return array> */ public function rules(): array { + $user = $this->user(); + assert($user instanceof User); + return [ 'round' => ['nullable', 'integer', 'min:0'], + 'username' => ['nullable', 'string', 'exists:users,username'], + 'commit_id' => ['bail', 'nullable', 'uuid', 'prohibits:username', new VisibleCommit($user, __('That commit is no longer available.'))], ]; } + + public function context(): User|Commit|null + { + $user = $this->user(); + assert($user instanceof User); + + return match (true) { + $this->filled('username') => User::query()->where('username', $this->string('username')->value())->firstOrFail(), + $this->filled('commit_id') => Commit::query()->visibleTo($user)->findOrFail($this->string('commit_id')->value()), + default => null, + }; + } } diff --git a/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php b/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php index b97d22ba7..1b0aebcc9 100644 --- a/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php +++ b/tests/Feature/Controllers/Api/SuggestedUserControllerTest.php @@ -4,6 +4,7 @@ use App\Models\Commit; use App\Models\User; +use App\Queries\SuggestedUsersQuery; beforeEach(function (): void { $this->user = User::factory()->create(); @@ -100,6 +101,155 @@ 'blocked' => fn (User $user, User $hidden) => $hidden->blocking()->attach($user), ]); +it('suggests the people a profile follows first, as the web does beside it', function (): void { + $profile = User::factory()->has(Commit::factory())->create(); + $followed = User::factory()->has(Commit::factory())->create(); + $regular = User::factory()->has(Commit::factory())->create(); + + $profile->following()->attach($followed); + + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', ['username' => $profile->username])) + ->assertOk() + ->assertJsonPath('data.*.id', [$followed->id, $regular->id]); +}); + +it("suggests a thread's other participants first, from any commit in it, as the web does beside it", function (): void { + $author = User::factory()->create(); + $root = Commit::factory()->for($author)->create(); + $participant = User::factory()->has(Commit::factory())->create(); + $regular = User::factory()->has(Commit::factory())->create(); + $reply = Commit::factory()->for($participant)->for($root, 'parent')->create(['root_id' => $root->id]); + + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', ['commit_id' => $root->id])) + ->assertOk() + ->assertJsonPath('data.*.id', [$participant->id, $regular->id]) + ->assertJsonPath('data.0.followed_by_following_count', 0) + ->assertJsonPath('data.0.is_followed', false); + + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', ['commit_id' => $reply->id])) + ->assertOk() + ->assertJsonPath('data.*.id', [$author->id, $regular->id]); +}); + +it('suggests what the web suggests on the same profile or commit page', function (string $page): void { + $author = User::factory()->has(Commit::factory())->create(); + $root = Commit::factory()->for($author)->create(); + $people = User::factory()->count(12)->has(Commit::factory())->create(); + + $author->following()->attach($people->take(4)); + foreach ($people->slice(4, 4) as $participant) { + Commit::factory()->for($participant)->for($root, 'parent')->create(['root_id' => $root->id]); + } + + $people->last()->followers()->attach($people->take(3)); + + [$webRoute, $context] = $page === 'profile' + ? [route('user.show', $author), ['username' => $author->username]] + : [route('commit.show', [$author, $root]), ['commit_id' => $root->id]]; + + $web = null; + $this->actingAs($this->user)->get($webRoute) + ->assertOk() + ->assertInertia(function ($inertia) use (&$web) { + return $inertia->loadDeferredProps(function ($reload) use (&$web) { + return $reload->where('suggestedUsers', function ($users) use (&$web): bool { + $web = collect($users)->pluck('id')->all(); + + return true; + }); + }); + }); + + $contextual = ($page === 'profile' ? $people->take(4) : $people->slice(4, 4))->pluck('id')->all(); + + expect($web)->toHaveCount(6) + ->and(array_diff(array_slice($web, 0, 3), $contextual))->toBe([]); + + foreach ([0, 1, 2] as $round) { + $api = signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', [...$context, 'round' => $round])) + ->assertOk() + ->json('data.*.id'); + + if ($round === 0) { + expect($api)->toBe($web); + } + + expect($api)->toBe(resolve(SuggestedUsersQuery::class)->get($this->user, round: $round, spares: 3, context: $page === 'profile' ? $author : $root)->pluck('id')->all()); + } +})->with(['profile', 'commit']); + +it('falls back to the regular suggestions beside a profile across a block, as the web does', function (Closure $block): void { + $profile = User::factory()->has(Commit::factory())->create(); + $followed = User::factory()->has(Commit::factory())->create(); + User::factory()->count(5)->has(Commit::factory())->create(); + + $profile->following()->attach($followed); + $block($this->user, $profile); + + $regular = signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index'))->json('data.*.id'); + + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', ['username' => $profile->username])) + ->assertOk() + ->assertJsonPath('data.*.id', $regular); +})->with([ + 'blocking' => fn (User $user, User $profile) => $user->blocking()->attach($profile), + 'blocked' => fn (User $user, User $profile) => $profile->blocking()->attach($user), +]); + +it("takes the user's own pending commit as the page, as its page shows it to them", function (): void { + $root = Commit::factory()->for($this->user)->pending()->create(); + $participant = User::factory()->has(Commit::factory())->create(); + + Commit::factory()->for($participant)->for($root, 'parent')->create(['root_id' => $root->id]); + + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', ['commit_id' => $root->id])) + ->assertOk() + ->assertJsonPath('data.0.id', $participant->id); +}); + +it('refuses a commit whose page the user cannot see', function (Closure $commit): void { + $participant = User::factory()->has(Commit::factory())->create(); + $root = $commit($this->user); + + Commit::factory()->for($participant)->for($root, 'parent')->create(['root_id' => $root->id]); + + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', ['commit_id' => $root->id])) + ->assertUnprocessable() + ->assertJsonValidationErrors(['commit_id' => 'That commit is no longer available.']); +})->with([ + "someone else's pending commit" => fn (User $user) => Commit::factory()->pending()->create(), + "someone else's rejected commit" => fn (User $user) => Commit::factory()->rejected()->create(), + 'a commit by someone the user blocks' => function (User $user): Commit { + $commit = Commit::factory()->create(); + $user->blocking()->attach($commit->user); + + return $commit; + }, + 'a commit by someone who blocks the user' => function (User $user): Commit { + $commit = Commit::factory()->create(); + $commit->user->blocking()->attach($user); + + return $commit; + }, + 'a deleted commit' => function (User $user): Commit { + $commit = Commit::factory()->create(); + $commit->delete(); + + return $commit; + }, +]); + +it('refuses a page that does not exist or is not one page', function (array $query, string $field): void { + signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', $query)) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); +})->with([ + 'an unknown username' => fn (): array => [['username' => 'nobody-here'], 'username'], + 'an unknown commit' => fn (): array => [['commit_id' => '01a16c57-e371-73fb-a4d7-6ad455c9dd57'], 'commit_id'], + 'a commit id that is not one' => fn (): array => [['commit_id' => 'latest'], 'commit_id'], + 'a profile and a commit' => fn (): array => [['username' => User::factory()->create()->username, 'commit_id' => Commit::factory()->create()->id], 'commit_id'], +]); + it('refuses a round that is not a whole number from zero', function (mixed $round): void { signedApiRequest($this->keyPair, 'GET', route('api-suggested-user.index', ['round' => $round])) ->assertUnprocessable()