From 4b4d8ba2a18fd6daa0face55742d50dc0230a4b4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 19 Sep 2023 15:53:01 +0100 Subject: [PATCH] Avatar Commend: Simplified and updated during review During review of #4560. - Simplified command to share as much log as possible across different run options. - Extracted out user handling to share with MFA command. - Added specific handling for disabled avatar fetching. - Added mention of avatar endpoint, to make it clear where these avatars are coming from (Protect against user expectation of LDAP avatar sync). - Simplified a range of the testing. - Tweaked wording and code formatting. --- app/Console/Commands/HandlesSingleUser.php | 40 +++ app/Console/Commands/RefreshAvatarCommand.php | 121 +++----- app/Console/Commands/ResetMfaCommand.php | 27 +- app/Uploads/UserAvatars.php | 4 +- tests/Commands/RefreshAvatarCommandTest.php | 265 +++++++----------- 5 files changed, 187 insertions(+), 270 deletions(-) create mode 100644 app/Console/Commands/HandlesSingleUser.php diff --git a/app/Console/Commands/HandlesSingleUser.php b/app/Console/Commands/HandlesSingleUser.php new file mode 100644 index 000000000..d3014aab1 --- /dev/null +++ b/app/Console/Commands/HandlesSingleUser.php @@ -0,0 +1,40 @@ +option('id'); + $email = $this->option('email'); + if (!$id && !$email) { + throw new Exception("Either a --id= or --email= option must be provided.\nRun this command with `--help` to show more options."); + } + + $field = $id ? 'id' : 'email'; + $value = $id ?: $email; + + $user = User::query() + ->where($field, '=', $value) + ->first(); + + if (!$user) { + throw new Exception("A user where {$field}={$value} could not be found."); + } + + return $user; + } +} diff --git a/app/Console/Commands/RefreshAvatarCommand.php b/app/Console/Commands/RefreshAvatarCommand.php index ca78d3860..e402285e7 100644 --- a/app/Console/Commands/RefreshAvatarCommand.php +++ b/app/Console/Commands/RefreshAvatarCommand.php @@ -1,16 +1,16 @@ option('force'); + if (!$userAvatar->avatarFetchEnabled()) { + $this->error("Avatar fetching is disabled on this instance."); + return self::FAILURE; + } if ($this->option('users-without-avatars')) { - return $this->handleUpdateWithoutAvatars($userAvatar, $dryRun); + return $this->processUsers(User::query()->whereDoesntHave('avatar')->get()->all(), $userAvatar); } if ($this->option('all')) { - return $this->handleUpdateAllAvatars($userAvatar, $dryRun); + return $this->processUsers(User::query()->get()->all(), $userAvatar); } - return $this->handleSingleUserUpdate($userAvatar); + try { + $user = $this->fetchProvidedUser(); + return $this->processUsers([$user], $userAvatar); + } catch (Exception $exception) { + $this->error($exception->getMessage()); + return self::FAILURE; + } } - private function handleUpdateWithoutAvatars(UserAvatars $userAvatar, bool $dryRun): int + /** + * @param User[] $users + */ + private function processUsers(array $users, UserAvatars $userAvatar): int { - $users = User::query()->where('image_id', '=', 0)->get(); - $this->info(count($users) . ' user(s) found without avatars.'); + $dryRun = !$this->option('force'); + $this->info(count($users) . " user(s) found to update avatars for."); + + if (count($users) === 0) { + return self::SUCCESS; + } if (!$dryRun) { - $proceed = !$this->input->isInteractive() || $this->confirm('Are you sure you want to refresh avatars of users that do not have one?'); + $fetchHost = parse_url($userAvatar->getAvatarUrl(), PHP_URL_HOST); + $this->warn("This will destroy any existing avatar images these users have, and attempt to fetch new avatar images from {$fetchHost}."); + $proceed = !$this->input->isInteractive() || $this->confirm('Are you sure you want to proceed?'); if (!$proceed) { return self::SUCCESS; } } - return $this->processUsers($users, $userAvatar, $dryRun); - } + $this->info(""); - private function handleUpdateAllAvatars(UserAvatars $userAvatar, bool $dryRun): int - { - $users = User::query()->get(); - $this->info(count($users) . ' user(s) found.'); - - if (!$dryRun) { - $proceed = !$this->input->isInteractive() || $this->confirm('Are you sure you want to refresh avatars for ALL USERS?'); - if (!$proceed) { - return self::SUCCESS; - } - } - - return $this->processUsers($users, $userAvatar, $dryRun); - } - - private function processUsers(Collection $users, UserAvatars $userAvatar, bool $dryRun): int - { $exitCode = self::SUCCESS; foreach ($users as $user) { - $this->getOutput()->write("ID {$user->id} - ", false); + $linePrefix = "[ID: {$user->id}] $user->email -"; if ($dryRun) { - $this->warn('Not updated'); + $this->warn("{$linePrefix} Not updated"); continue; } if ($this->fetchAvatar($userAvatar, $user)) { - $this->info('Updated'); + $this->info("{$linePrefix} Updated"); } else { - $this->error('Not updated'); + $this->error("{$linePrefix} Not updated"); $exitCode = self::FAILURE; } } - $this->getOutput()->newLine(); if ($dryRun) { - $this->comment('Dry run, no avatars have been updated'); - $this->comment('Run with -f or --force to perform the update'); + $this->comment(""); + $this->comment("Dry run, no avatars were updated."); + $this->comment('Run with -f or --force to perform the update.'); } return $exitCode; } - - private function handleSingleUserUpdate(UserAvatars $userAvatar): int - { - $id = $this->option('id'); - $email = $this->option('email'); - if (!$id && !$email) { - $this->error('Either a --id= or --email= option must be provided.'); - $this->error('Run with `--help` to more options'); - - return self::FAILURE; - } - - $field = $id ? 'id' : 'email'; - $value = $id ?: $email; - - $user = User::query() - ->where($field, '=', $value) - ->first(); - - if (!$user) { - $this->error("A user where {$field}={$value} could not be found."); - - return self::FAILURE; - } - - $this->info("This will refresh the avatar for user: \n- ID: {$user->id}\n- Name: {$user->name}\n- Email: {$user->email}\n"); - $confirm = $this->confirm('Are you sure you want to proceed?'); - if ($confirm) { - if ($this->fetchAvatar($userAvatar, $user)) { - $this->info('User avatar has been updated.'); - return self::SUCCESS; - } - - $this->info('Could not update avatar please review logs.'); - } - - return self::FAILURE; - } - private function fetchAvatar(UserAvatars $userAvatar, User $user): bool { $oldId = $user->avatar->id ?? 0; diff --git a/app/Console/Commands/ResetMfaCommand.php b/app/Console/Commands/ResetMfaCommand.php index b8076d2d6..2b0801e39 100644 --- a/app/Console/Commands/ResetMfaCommand.php +++ b/app/Console/Commands/ResetMfaCommand.php @@ -2,11 +2,13 @@ namespace BookStack\Console\Commands; -use BookStack\Users\Models\User; +use Exception; use Illuminate\Console\Command; class ResetMfaCommand extends Command { + use HandlesSingleUser; + /** * The name and signature of the console command. * @@ -29,25 +31,10 @@ class ResetMfaCommand extends Command */ public function handle(): int { - $id = $this->option('id'); - $email = $this->option('email'); - if (!$id && !$email) { - $this->error('Either a --id= or --email= option must be provided.'); - - return 1; - } - - $field = $id ? 'id' : 'email'; - $value = $id ?: $email; - - /** @var User $user */ - $user = User::query() - ->where($field, '=', $value) - ->first(); - - if (!$user) { - $this->error("A user where {$field}={$value} could not be found."); - + try { + $user = $this->fetchProvidedUser(); + } catch (Exception $exception) { + $this->error($exception->getMessage()); return 1; } diff --git a/app/Uploads/UserAvatars.php b/app/Uploads/UserAvatars.php index 0cda31a1c..c62324735 100644 --- a/app/Uploads/UserAvatars.php +++ b/app/Uploads/UserAvatars.php @@ -127,7 +127,7 @@ class UserAvatars /** * Check if fetching external avatars is enabled. */ - protected function avatarFetchEnabled(): bool + public function avatarFetchEnabled(): bool { $fetchUrl = $this->getAvatarUrl(); @@ -137,7 +137,7 @@ class UserAvatars /** * Get the URL to fetch avatars from. */ - protected function getAvatarUrl(): string + public function getAvatarUrl(): string { $configOption = config('services.avatar_url'); if ($configOption === false) { diff --git a/tests/Commands/RefreshAvatarCommandTest.php b/tests/Commands/RefreshAvatarCommandTest.php index d625097ef..6126f21a8 100644 --- a/tests/Commands/RefreshAvatarCommandTest.php +++ b/tests/Commands/RefreshAvatarCommandTest.php @@ -1,47 +1,55 @@ set([ + 'services.disable_services' => false, + 'services.avatar_url' => 'https://avatars.example.com?a=b', + ]); + } + + public function test_command_errors_if_avatar_fetch_disabled() + { + config()->set(['services.avatar_url' => false]); + + $this->artisan('bookstack:refresh-avatar') + ->expectsOutputToContain("Avatar fetching is disabled on this instance") + ->assertExitCode(1); + } + public function test_command_requires_email_or_id_option() { - $this->artisan(RefreshAvatarCommand::class) - ->expectsOutput('Either a --id= or --email= option must be provided.') - ->assertExitCode(Command::FAILURE); + $this->artisan('bookstack:refresh-avatar') + ->expectsOutputToContain("Either a --id= or --email= option must be provided") + ->assertExitCode(1); } public function test_command_runs_with_provided_email() { $requests = $this->mockHttpClient([new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData())]); - config()->set(['services.disable_services' => false]); - - /** @var User $user */ - $user = User::query()->first(); - - /** @var UserAvatars $avatar */ - $avatar = app()->make(UserAvatars::class); - $avatar->destroyAllForUser($user); + $user = $this->users->viewer(); $this->assertFalse($user->avatar()->exists()); - $this->artisan(RefreshAvatarCommand::class, ['--email' => $user->email]) - ->expectsOutputToContain("- ID: {$user->id}") - ->expectsQuestion('Are you sure you want to proceed?', true) - ->expectsOutput('User avatar has been updated.') - ->assertExitCode(Command::SUCCESS); - $expectedUri = 'https://www.gravatar.com/avatar/' . md5(strtolower($user->email)) . '?s=500&d=identicon'; - $this->assertEquals($expectedUri, $requests->latestRequest()->getUri()); + $this->artisan("bookstack:refresh-avatar --email={$user->email} -f") + ->expectsQuestion('Are you sure you want to proceed?', true) + ->expectsOutput("[ID: {$user->id}] {$user->email} - Updated") + ->expectsOutputToContain('This will destroy any existing avatar images these users have, and attempt to fetch new avatar images from avatars.example.com') + ->assertExitCode(0); + + $this->assertEquals('https://avatars.example.com?a=b', $requests->latestRequest()->getUri()); $user->refresh(); $this->assertTrue($user->avatar()->exists()); @@ -50,24 +58,16 @@ final class RefreshAvatarCommandTest extends TestCase public function test_command_runs_with_provided_id() { $requests = $this->mockHttpClient([new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData())]); - config()->set(['services.disable_services' => false]); - - /** @var User $user */ - $user = User::query()->first(); - - /** @var UserAvatars $avatar */ - $avatar = app()->make(UserAvatars::class); - $avatar->destroyAllForUser($user); + $user = $this->users->viewer(); $this->assertFalse($user->avatar()->exists()); - $this->artisan(RefreshAvatarCommand::class, ['--id' => $user->id]) - ->expectsOutputToContain("- ID: {$user->id}") - ->expectsQuestion('Are you sure you want to proceed?', true) - ->expectsOutput('User avatar has been updated.') - ->assertExitCode(Command::SUCCESS); - $expectedUri = 'https://www.gravatar.com/avatar/' . md5(strtolower($user->email)) . '?s=500&d=identicon'; - $this->assertEquals($expectedUri, $requests->latestRequest()->getUri()); + $this->artisan("bookstack:refresh-avatar --id={$user->id} -f") + ->expectsQuestion('Are you sure you want to proceed?', true) + ->expectsOutput("[ID: {$user->id}] {$user->email} - Updated") + ->assertExitCode(0); + + $this->assertEquals('https://avatars.example.com?a=b', $requests->latestRequest()->getUri()); $user->refresh(); $this->assertTrue($user->avatar()->exists()); @@ -76,143 +76,93 @@ final class RefreshAvatarCommandTest extends TestCase public function test_command_runs_with_provided_id_error_upstream() { $requests = $this->mockHttpClient([new Response(404)]); - config()->set(['services.disable_services' => false]); - /** @var User $user */ - $user = User::query()->first(); - /** @var UserAvatars $avatar */ - $avatar = app()->make(UserAvatars::class); - $avatar->assignToUserFromExistingData($user, $this->files->pngImageData(), 'png'); + $user = $this->users->viewer(); + $this->assertFalse($user->avatar()->exists()); - $oldId = $user->avatar->id ?? 0; - - $this->artisan(RefreshAvatarCommand::class, ['--id' => $user->id]) - ->expectsOutputToContain("- ID: {$user->id}") + $this->artisan("bookstack:refresh-avatar --id={$user->id} -f") ->expectsQuestion('Are you sure you want to proceed?', true) - ->expectsOutput('Could not update avatar please review logs.') - ->assertExitCode(Command::FAILURE); + ->expectsOutput("[ID: {$user->id}] {$user->email} - Not updated") + ->assertExitCode(1); $this->assertEquals(1, $requests->requestCount()); - - $user->refresh(); - $newId = $user->avatar->id ?? $oldId; - $this->assertEquals($oldId, $newId); + $this->assertFalse($user->avatar()->exists()); } public function test_saying_no_to_confirmation_does_not_refresh_avatar() { - /** @var User $user */ - $user = User::query()->first(); + $user = $this->users->viewer(); $this->assertFalse($user->avatar()->exists()); - $this->artisan(RefreshAvatarCommand::class, ['--id' => $user->id]) + $this->artisan("bookstack:refresh-avatar --id={$user->id} -f") ->expectsQuestion('Are you sure you want to proceed?', false) - ->assertExitCode(Command::FAILURE); + ->assertExitCode(0); $this->assertFalse($user->avatar()->exists()); } public function test_giving_non_existing_user_shows_error_message() { - $this->artisan(RefreshAvatarCommand::class, ['--email' => 'donkeys@example.com']) + $this->artisan('bookstack:refresh-avatar --email=donkeys@example.com') ->expectsOutput('A user where email=donkeys@example.com could not be found.') - ->assertExitCode(Command::FAILURE); + ->assertExitCode(1); } public function test_command_runs_all_users_without_avatars_dry_run() { $users = User::query()->where('image_id', '=', 0)->get(); - $this->artisan(RefreshAvatarCommand::class, ['--users-without-avatars' => true]) - ->expectsOutput(count($users) . ' user(s) found without avatars.') - ->expectsOutput("ID {$users[0]->id} - ") - ->expectsOutput('Not updated') - ->expectsOutput('Dry run, no avatars have been updated') - ->assertExitCode(Command::SUCCESS); + $this->artisan('bookstack:refresh-avatar --users-without-avatars') + ->expectsOutput(count($users) . ' user(s) found to update avatars for.') + ->expectsOutput("[ID: {$users[0]->id}] {$users[0]->email} - Not updated") + ->expectsOutput('Dry run, no avatars were updated.') + ->assertExitCode(0); } - public function test_command_runs_all_users_without_avatars_non_to_update() + public function test_command_runs_all_users_without_avatars_with_none_to_update() { - config()->set(['services.disable_services' => false]); + $requests = $this->mockHttpClient(); + $image = Image::factory()->create(); + User::query()->update(['image_id' => $image->id]); - /** @var UserAvatars $avatar */ - $avatar = app()->make(UserAvatars::class); + $this->artisan('bookstack:refresh-avatar --users-without-avatars -f') + ->expectsOutput('0 user(s) found to update avatars for.') + ->assertExitCode(0); - /** @var Collection|User[] $users */ - $users = User::query()->get(); - $responses = []; - foreach ($users as $user) { - $avatar->fetchAndAssignToUser($user); - $responses[] = new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData()); - } - $requests = $this->mockHttpClient($responses); - - $this->artisan(RefreshAvatarCommand::class, ['--users-without-avatars' => true, '-f' => true]) - ->expectsOutput('0 user(s) found without avatars.') - ->expectsQuestion('Are you sure you want to refresh avatars of users that do not have one?', true) - ->assertExitCode(Command::SUCCESS); - - $userWithAvatars = User::query()->where('image_id', '==', 0)->count(); - $this->assertEquals(0, $userWithAvatars); $this->assertEquals(0, $requests->requestCount()); } public function test_command_runs_all_users_without_avatars() { - config()->set(['services.disable_services' => false]); - - /** @var UserAvatars $avatar */ - $avatar = app()->make(UserAvatars::class); - - /** @var Collection|User[] $users */ - $users = User::query()->get(); - foreach ($users as $user) { - $avatar->destroyAllForUser($user); - } - /** @var Collection|User[] $users */ $users = User::query()->where('image_id', '=', 0)->get(); - $pendingCommand = $this->artisan(RefreshAvatarCommand::class, ['--users-without-avatars' => true, '-f' => true]); + $pendingCommand = $this->artisan('bookstack:refresh-avatar --users-without-avatars -f'); $pendingCommand - ->expectsOutput($users->count() . ' user(s) found without avatars.') - ->expectsQuestion('Are you sure you want to refresh avatars of users that do not have one?', true); + ->expectsOutput($users->count() . ' user(s) found to update avatars for.') + ->expectsQuestion('Are you sure you want to proceed?', true); $responses = []; foreach ($users as $user) { - $pendingCommand->expectsOutput("ID {$user->id} - "); - $pendingCommand->expectsOutput('Updated'); + $pendingCommand->expectsOutput("[ID: {$user->id}] {$user->email} - Updated"); $responses[] = new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData()); } $requests = $this->mockHttpClient($responses); - $pendingCommand->assertExitCode(Command::SUCCESS); + $pendingCommand->assertExitCode(0); $pendingCommand->run(); - $userWithAvatars = User::query()->where('image_id', '!=', 0)->count(); - $this->assertEquals($users->count(), $userWithAvatars); + $this->assertEquals(0, User::query()->where('image_id', '=', 0)->count()); $this->assertEquals($users->count(), $requests->requestCount()); } public function test_saying_no_to_confirmation_all_users_without_avatars() { - $requests = $this->mockHttpClient([new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData())]); - config()->set(['services.disable_services' => false]); + $requests = $this->mockHttpClient(); - /** @var UserAvatars $avatar */ - $avatar = app()->make(UserAvatars::class); + $this->artisan('bookstack:refresh-avatar --users-without-avatars -f') + ->expectsQuestion('Are you sure you want to proceed?', false) + ->assertExitCode(0); - /** @var Collection|User[] $users */ - $users = User::query()->get(); - foreach ($users as $user) { - $avatar->destroyAllForUser($user); - } - - $this->artisan(RefreshAvatarCommand::class, ['--users-without-avatars' => true, '-f' => true]) - ->expectsQuestion('Are you sure you want to refresh avatars of users that do not have one?', false) - ->assertExitCode(Command::SUCCESS); - - $userWithAvatars = User::query()->where('image_id', '=', 0)->count(); - $this->assertEquals($users->count(), $userWithAvatars); $this->assertEquals(0, $requests->requestCount()); } @@ -220,98 +170,77 @@ final class RefreshAvatarCommandTest extends TestCase { $users = User::query()->where('image_id', '=', 0)->get(); - $this->artisan(RefreshAvatarCommand::class, ['--all' => true]) - ->expectsOutput(count($users) . ' user(s) found.') - ->expectsOutput("ID {$users[0]->id} - ") - ->expectsOutput('Not updated') - ->expectsOutput('Dry run, no avatars have been updated') - ->assertExitCode(Command::SUCCESS); + $this->artisan('bookstack:refresh-avatar --all') + ->expectsOutput(count($users) . ' user(s) found to update avatars for.') + ->expectsOutput("[ID: {$users[0]->id}] {$users[0]->email} - Not updated") + ->expectsOutput('Dry run, no avatars were updated.') + ->assertExitCode(0); } public function test_command_runs_update_all_users_avatar() { - config()->set(['services.disable_services' => false]); - /** @var Collection|User[] $users */ $users = User::query()->get(); - $pendingCommand = $this->artisan(RefreshAvatarCommand::class, ['--all' => true, '-f' => true]); + $pendingCommand = $this->artisan('bookstack:refresh-avatar --all -f'); $pendingCommand - ->expectsOutput($users->count() . ' user(s) found.') - ->expectsQuestion('Are you sure you want to refresh avatars for ALL USERS?', true); + ->expectsOutput($users->count() . ' user(s) found to update avatars for.') + ->expectsQuestion('Are you sure you want to proceed?', true); $responses = []; foreach ($users as $user) { - $pendingCommand->expectsOutput("ID {$user->id} - "); - $pendingCommand->expectsOutput('Updated'); + $pendingCommand->expectsOutput("[ID: {$user->id}] {$user->email} - Updated"); $responses[] = new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData()); } $requests = $this->mockHttpClient($responses); - $pendingCommand->assertExitCode(Command::SUCCESS); + $pendingCommand->assertExitCode(0); $pendingCommand->run(); - $userWithAvatars = User::query()->where('image_id', '!=', 0)->count(); - $this->assertEquals($users->count(), $userWithAvatars); + $this->assertEquals(0, User::query()->where('image_id', '=', 0)->count()); $this->assertEquals($users->count(), $requests->requestCount()); } public function test_command_runs_update_all_users_avatar_errors() { - config()->set(['services.disable_services' => false]); - /** @var Collection|User[] $users */ - $users = User::query()->get(); + $users = array_values(User::query()->get()->all()); - $pendingCommand = $this->artisan(RefreshAvatarCommand::class, ['--all' => true, '-f' => true]); + $pendingCommand = $this->artisan('bookstack:refresh-avatar --all -f'); $pendingCommand - ->expectsOutput($users->count() . ' user(s) found.') - ->expectsQuestion('Are you sure you want to refresh avatars for ALL USERS?', true); + ->expectsOutput(count($users) . ' user(s) found to update avatars for.') + ->expectsQuestion('Are you sure you want to proceed?', true); $responses = []; - foreach ($users as $key => $user) { - $pendingCommand->expectsOutput("ID {$user->id} - "); - - if ($key == 1) { - $pendingCommand->expectsOutput('Not updated'); + foreach ($users as $index => $user) { + if ($index === 0) { + $pendingCommand->expectsOutput("[ID: {$user->id}] {$user->email} - Not updated"); $responses[] = new Response(404); continue; } - $pendingCommand->expectsOutput('Updated'); + $pendingCommand->expectsOutput("[ID: {$user->id}] {$user->email} - Updated"); $responses[] = new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData()); } $requests = $this->mockHttpClient($responses); - $pendingCommand->assertExitCode(Command::FAILURE); + $pendingCommand->assertExitCode(1); $pendingCommand->run(); $userWithAvatars = User::query()->where('image_id', '!=', 0)->count(); - $this->assertEquals($users->count() - 1, $userWithAvatars); - $this->assertEquals($users->count(), $requests->requestCount()); + $this->assertEquals(count($users) - 1, $userWithAvatars); + $this->assertEquals(count($users), $requests->requestCount()); } public function test_saying_no_to_confirmation_update_all_users_avatar() { $requests = $this->mockHttpClient([new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData())]); - config()->set(['services.disable_services' => false]); - /** @var UserAvatars $avatar */ - $avatar = app()->make(UserAvatars::class); + $this->artisan('bookstack:refresh-avatar --all -f') + ->expectsQuestion('Are you sure you want to proceed?', false) + ->assertExitCode(0); - /** @var Collection|User[] $users */ - $users = User::query()->get(); - foreach ($users as $user) { - $avatar->destroyAllForUser($user); - } - - $this->artisan(RefreshAvatarCommand::class, ['--all' => true, '-f' => true]) - ->expectsQuestion('Are you sure you want to refresh avatars for ALL USERS?', false) - ->assertExitCode(Command::SUCCESS); - - $userWithAvatars = User::query()->where('image_id', '=', 0)->count(); - $this->assertEquals($users->count(), $userWithAvatars); $this->assertEquals(0, $requests->requestCount()); } }