From ea7592509f61605b84959d39d74757e962501609 Mon Sep 17 00:00:00 2001 From: Marc Hagen Date: Mon, 18 Sep 2023 19:07:30 +0200 Subject: [PATCH] feat: Artisan command for updating avatars for existing users --- app/Console/Commands/RefreshAvatarCommand.php | 155 +++++++++ tests/Commands/RefreshAvatarCommandTest.php | 317 ++++++++++++++++++ 2 files changed, 472 insertions(+) create mode 100644 app/Console/Commands/RefreshAvatarCommand.php create mode 100644 tests/Commands/RefreshAvatarCommandTest.php diff --git a/app/Console/Commands/RefreshAvatarCommand.php b/app/Console/Commands/RefreshAvatarCommand.php new file mode 100644 index 000000000..ca78d3860 --- /dev/null +++ b/app/Console/Commands/RefreshAvatarCommand.php @@ -0,0 +1,155 @@ +option('force'); + + if ($this->option('users-without-avatars')) { + return $this->handleUpdateWithoutAvatars($userAvatar, $dryRun); + } + + if ($this->option('all')) { + return $this->handleUpdateAllAvatars($userAvatar, $dryRun); + } + + return $this->handleSingleUserUpdate($userAvatar); + } + + private function handleUpdateWithoutAvatars(UserAvatars $userAvatar, bool $dryRun): int + { + $users = User::query()->where('image_id', '=', 0)->get(); + $this->info(count($users) . ' user(s) found without avatars.'); + + 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; + } + } + + 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?'); + 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); + + if ($dryRun) { + $this->warn('Not updated'); + continue; + } + + if ($this->fetchAvatar($userAvatar, $user)) { + $this->info('Updated'); + } else { + $this->error('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'); + } + + 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; + + $userAvatar->fetchAndAssignToUser($user); + + $user->refresh(); + $newId = $user->avatar->id ?? $oldId; + return $oldId !== $newId; + } +} diff --git a/tests/Commands/RefreshAvatarCommandTest.php b/tests/Commands/RefreshAvatarCommandTest.php new file mode 100644 index 000000000..d625097ef --- /dev/null +++ b/tests/Commands/RefreshAvatarCommandTest.php @@ -0,0 +1,317 @@ +artisan(RefreshAvatarCommand::class) + ->expectsOutput('Either a --id= or --email= option must be provided.') + ->assertExitCode(Command::FAILURE); + } + + 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); + + $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()); + + $user->refresh(); + $this->assertTrue($user->avatar()->exists()); + } + + 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); + + $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()); + + $user->refresh(); + $this->assertTrue($user->avatar()->exists()); + } + + 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; + + $this->artisan(RefreshAvatarCommand::class, ['--id' => $user->id]) + ->expectsOutputToContain("- ID: {$user->id}") + ->expectsQuestion('Are you sure you want to proceed?', true) + ->expectsOutput('Could not update avatar please review logs.') + ->assertExitCode(Command::FAILURE); + + $this->assertEquals(1, $requests->requestCount()); + + $user->refresh(); + $newId = $user->avatar->id ?? $oldId; + $this->assertEquals($oldId, $newId); + } + + public function test_saying_no_to_confirmation_does_not_refresh_avatar() + { + /** @var User $user */ + $user = User::query()->first(); + + $this->assertFalse($user->avatar()->exists()); + $this->artisan(RefreshAvatarCommand::class, ['--id' => $user->id]) + ->expectsQuestion('Are you sure you want to proceed?', false) + ->assertExitCode(Command::FAILURE); + $this->assertFalse($user->avatar()->exists()); + } + + public function test_giving_non_existing_user_shows_error_message() + { + $this->artisan(RefreshAvatarCommand::class, ['--email' => 'donkeys@example.com']) + ->expectsOutput('A user where email=donkeys@example.com could not be found.') + ->assertExitCode(Command::FAILURE); + } + + 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); + } + + public function test_command_runs_all_users_without_avatars_non_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); + + $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 + ->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); + + $responses = []; + foreach ($users as $user) { + $pendingCommand->expectsOutput("ID {$user->id} - "); + $pendingCommand->expectsOutput('Updated'); + $responses[] = new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData()); + } + $requests = $this->mockHttpClient($responses); + + $pendingCommand->assertExitCode(Command::SUCCESS); + $pendingCommand->run(); + + $userWithAvatars = User::query()->where('image_id', '!=', 0)->count(); + $this->assertEquals($users->count(), $userWithAvatars); + $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); + } + + $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()); + } + + public function test_command_runs_all_users_dry_run() + { + $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); + } + + 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 + ->expectsOutput($users->count() . ' user(s) found.') + ->expectsQuestion('Are you sure you want to refresh avatars for ALL USERS?', true); + + $responses = []; + foreach ($users as $user) { + $pendingCommand->expectsOutput("ID {$user->id} - "); + $pendingCommand->expectsOutput('Updated'); + $responses[] = new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData()); + } + $requests = $this->mockHttpClient($responses); + + $pendingCommand->assertExitCode(Command::SUCCESS); + $pendingCommand->run(); + + $userWithAvatars = User::query()->where('image_id', '!=', 0)->count(); + $this->assertEquals($users->count(), $userWithAvatars); + $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(); + + $pendingCommand = $this->artisan(RefreshAvatarCommand::class, ['--all' => true, '-f' => true]); + $pendingCommand + ->expectsOutput($users->count() . ' user(s) found.') + ->expectsQuestion('Are you sure you want to refresh avatars for ALL USERS?', true); + + $responses = []; + foreach ($users as $key => $user) { + $pendingCommand->expectsOutput("ID {$user->id} - "); + + if ($key == 1) { + $pendingCommand->expectsOutput('Not updated'); + $responses[] = new Response(404); + continue; + } + + $pendingCommand->expectsOutput('Updated'); + $responses[] = new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData()); + } + + $requests = $this->mockHttpClient($responses); + + $pendingCommand->assertExitCode(Command::FAILURE); + $pendingCommand->run(); + + $userWithAvatars = User::query()->where('image_id', '!=', 0)->count(); + $this->assertEquals($users->count() - 1, $userWithAvatars); + $this->assertEquals($users->count(), $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); + + $userWithAvatars = User::query()->where('image_id', '=', 0)->count(); + $this->assertEquals($users->count(), $userWithAvatars); + $this->assertEquals(0, $requests->requestCount()); + } +}