]> BookStack Code Mirror - bookstack/commitdiff
Avatar Commend: Simplified and updated during review
authorDan Brown <redacted>
Tue, 19 Sep 2023 14:53:01 +0000 (15:53 +0100)
committerDan Brown <redacted>
Tue, 19 Sep 2023 14:53:01 +0000 (15:53 +0100)
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 [new file with mode: 0644]
app/Console/Commands/RefreshAvatarCommand.php
app/Console/Commands/ResetMfaCommand.php
app/Uploads/UserAvatars.php
tests/Commands/RefreshAvatarCommandTest.php

diff --git a/app/Console/Commands/HandlesSingleUser.php b/app/Console/Commands/HandlesSingleUser.php
new file mode 100644 (file)
index 0000000..d3014aa
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+
+namespace BookStack\Console\Commands;
+
+use BookStack\Users\Models\User;
+use Exception;
+use Illuminate\Console\Command;
+
+/**
+ * @mixin Command
+ */
+trait HandlesSingleUser
+{
+    /**
+     * Fetch a user provided to this command.
+     * Expects the command to accept 'id' and 'email' options.
+     * @throws Exception
+     */
+    private function fetchProvidedUser(): User
+    {
+        $id = $this->option('id');
+        $email = $this->option('email');
+        if (!$id && !$email) {
+            throw new Exception("Either a --id=<number> or --email=<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;
+    }
+}
index ca78d3860520fbb7fd449b5dcdc34aa911292be2..e402285e734856012be3a1b83066bf9f8919c7e4 100644 (file)
@@ -1,16 +1,16 @@
 <?php
 
-declare(strict_types=1);
-
 namespace BookStack\Console\Commands;
 
 use BookStack\Users\Models\User;
+use Exception;
 use Illuminate\Console\Command;
 use BookStack\Uploads\UserAvatars;
-use Illuminate\Database\Eloquent\Collection;
 
-final class RefreshAvatarCommand extends Command
+class RefreshAvatarCommand extends Command
 {
+    use HandlesSingleUser;
+
     /**
      * The name and signature of the console command.
      *
@@ -20,128 +20,89 @@ final class RefreshAvatarCommand extends Command
                             {--id= : Numeric ID of the user to refresh avatar for}
                             {--email= : Email address of the user to refresh avatar for}
                             {--users-without-avatars : Refresh avatars for users that currently have no avatar}
-                            {--a|all : Refresh all user avatars}
-                            {--f|force : Actually run the update for --users-without-avatars, Defaults to a dry-run}';
+                            {--a|all : Refresh avatars for all users}
+                            {--f|force : Actually run the update, Defaults to a dry-run}';
 
     /**
      * The console command description.
      *
      * @var string
      */
-    protected $description = 'Refresh avatar for given user or users';
+    protected $description = 'Refresh avatar for the given user(s)';
 
     public function handle(UserAvatars $userAvatar): int
     {
-        $dryRun = !$this->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 (!$dryRun) {
-            $proceed = !$this->input->isInteractive() || $this->confirm('Are you sure you want to refresh avatars of users that do not have one?');
-            if (!$proceed) {
-                return self::SUCCESS;
-            }
+        if (count($users) === 0) {
+            return self::SUCCESS;
         }
 
-        return $this->processUsers($users, $userAvatar, $dryRun);
-    }
-
-    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?');
+            $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 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=<number> or --email=<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;
index b8076d2d61f19d37854e497b70370e98d9ed5039..2b0801e39da637e63d0d748b463366d611fd57e2 100644 (file)
@@ -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=<number> or --email=<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;
         }
 
index 0cda31a1c3675a6e38b3e0d141b1c3530718001e..c623247352b17234cd60168fb7686f19375478bc 100644 (file)
@@ -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) {
index d625097efedb8094b9f76d9c50d135eec9465b52..6126f21a8a7912e55b3a1922cde8e43a76a37ca1 100644 (file)
@@ -1,47 +1,55 @@
 <?php
 
-declare(strict_types=1);
-
 namespace Tests\Commands;
 
-use BookStack\Console\Commands\RefreshAvatarCommand;
-use BookStack\Uploads\UserAvatars;
+use BookStack\Uploads\Image;
 use BookStack\Users\Models\User;
 use GuzzleHttp\Psr7\Response;
 use Illuminate\Database\Eloquent\Collection;
-use Symfony\Component\Console\Command\Command;
 use Tests\TestCase;
 
-final class RefreshAvatarCommandTest extends TestCase
+class RefreshAvatarCommandTest extends TestCase
 {
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        config()->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=<number> or --email=<email> option must be provided.')
-            ->assertExitCode(Command::FAILURE);
+        $this->artisan('bookstack:refresh-avatar')
+            ->expectsOutputToContain("Either a --id=<number> or --email=<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}")
+
+        $this->artisan("bookstack:refresh-avatar --email={$user->email} -f")
             ->expectsQuestion('Are you sure you want to proceed?', true)
-            ->expectsOutput('User avatar has been updated.')
-            ->assertExitCode(Command::SUCCESS);
+            ->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);
 
-        $expectedUri = 'https://www.gravatar.com/avatar/' . md5(strtolower($user->email)) . '?s=500&d=identicon';
-        $this->assertEquals($expectedUri, $requests->latestRequest()->getUri());
+        $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}")
+
+        $this->artisan("bookstack:refresh-avatar --id={$user->id} -f")
             ->expectsQuestion('Are you sure you want to proceed?', true)
-            ->expectsOutput('User avatar has been updated.')
-            ->assertExitCode(Command::SUCCESS);
+            ->expectsOutput("[ID: {$user->id}] {$user->email} - Updated")
+            ->assertExitCode(0);
 
-        $expectedUri = 'https://www.gravatar.com/avatar/' . md5(strtolower($user->email)) . '?s=500&d=identicon';
-        $this->assertEquals($expectedUri, $requests->latestRequest()->getUri());
+        $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');
-
-        $oldId = $user->avatar->id ?? 0;
+        $user = $this->users->viewer();
+        $this->assertFalse($user->avatar()->exists());
 
-        $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' => '[email protected]'])
+        $this->artisan('bookstack:refresh-avatar [email protected]')
             ->expectsOutput('A user where [email protected] 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]);
-
-        /** @var UserAvatars $avatar */
-        $avatar = app()->make(UserAvatars::class);
-
-        /** @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);
+        $requests = $this->mockHttpClient();
+        $image = Image::factory()->create();
+        User::query()->update(['image_id' => $image->id]);
 
-        $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);
+        $this->artisan('bookstack:refresh-avatar --users-without-avatars -f')
+            ->expectsOutput('0 user(s) found to update avatars for.')
+            ->assertExitCode(0);
 
-        $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]);
-
-        /** @var UserAvatars $avatar */
-        $avatar = app()->make(UserAvatars::class);
-
-        /** @var Collection|User[] $users */
-        $users = User::query()->get();
-        foreach ($users as $user) {
-            $avatar->destroyAllForUser($user);
-        }
+        $requests = $this->mockHttpClient();
 
-        $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);
+        $this->artisan('bookstack:refresh-avatar --users-without-avatars -f')
+            ->expectsQuestion('Are you sure you want to proceed?', false)
+            ->assertExitCode(0);
 
-        $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);
-
-        /** @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);
+        $this->artisan('bookstack:refresh-avatar --all -f')
+            ->expectsQuestion('Are you sure you want to proceed?', false)
+            ->assertExitCode(0);
 
-        $userWithAvatars = User::query()->where('image_id', '=', 0)->count();
-        $this->assertEquals($users->count(), $userWithAvatars);
         $this->assertEquals(0, $requests->requestCount());
     }
 }