From 8210f7f6a02899b84192c1d45ae25e516204dc2e Mon Sep 17 00:00:00 2001 From: Matt Young Date: Mon, 7 Jul 2025 00:09:36 -0500 Subject: [PATCH] Dashboard Controller testing --- app/Actions/Fortify/UpdateUserPrivileges.php | 55 +++++ app/Actions/Schools/AssignUserToSchool.php | 10 +- app/Actions/Schools/SetHeadDirector.php | 4 + app/Http/Controllers/Admin/UserController.php | 182 +++++---------- .../Admin/YearEndResetController.php | 2 +- app/Observers/UserObserver.php | 2 +- .../Controllers/Admin/UserControllerTest.php | 220 ++++++++++++++++++ .../Admin/YearEndResetControllerTest.php | 31 +++ tests/Pest.php | 5 + 9 files changed, 379 insertions(+), 132 deletions(-) create mode 100644 app/Actions/Fortify/UpdateUserPrivileges.php create mode 100644 tests/Feature/app/Http/Controllers/Admin/UserControllerTest.php create mode 100644 tests/Feature/app/Http/Controllers/Admin/YearEndResetControllerTest.php diff --git a/app/Actions/Fortify/UpdateUserPrivileges.php b/app/Actions/Fortify/UpdateUserPrivileges.php new file mode 100644 index 0000000..dff3740 --- /dev/null +++ b/app/Actions/Fortify/UpdateUserPrivileges.php @@ -0,0 +1,55 @@ +setPrivilege($user, $action, $privilege); + } + + /** + * @throws AuditionAdminException + */ + public function setPrivilege(User|int $user, string $action, string $privilege): void + { + if (is_int($user)) { + $user = User::findOrFail($user); + } + + if (! User::where('id', $user->id)->exists()) { + throw new AuditionAdminException('User does not exist'); + } + + if (! in_array($action, ['grant', 'revoke'])) { + throw new AuditionAdminException('Invalid Action'); + } + + $field = match ($privilege) { + 'admin' => 'is_admin', + 'tab' => 'is_tab', + default => throw new AuditionAdminException('Invalid Privilege'), + }; + + if ($user->$field == 1 && $action == 'revoke') { + $user->$field = 0; + $user->save(); + } + + if ($user->$field == 0 && $action == 'grant') { + $user->$field = 1; + $user->save(); + } + } +} diff --git a/app/Actions/Schools/AssignUserToSchool.php b/app/Actions/Schools/AssignUserToSchool.php index 7d27d54..d746d92 100644 --- a/app/Actions/Schools/AssignUserToSchool.php +++ b/app/Actions/Schools/AssignUserToSchool.php @@ -8,18 +8,22 @@ use App\Models\User; class AssignUserToSchool { - public function __invoke(User $user, School $school): void + public function __invoke(User $user, School|int $school): void { $this->assign($user, $school); } - public function assign(User $user, School $school, bool $addDomainToSchool = true): void + public function assign(User $user, School|int $school, bool $addDomainToSchool = true): void { + if (is_int($school)) { + $school = School::find($school); + } + if (! User::where('id', $user->id)->exists()) { throw new AuditionAdminException('User does not exist'); } - if (! School::where('id', $school->id)->exists()) { + if (is_null($school) || ! School::where('id', $school->id)->exists()) { throw new AuditionAdminException('School does not exist'); } diff --git a/app/Actions/Schools/SetHeadDirector.php b/app/Actions/Schools/SetHeadDirector.php index e699061..596ec83 100644 --- a/app/Actions/Schools/SetHeadDirector.php +++ b/app/Actions/Schools/SetHeadDirector.php @@ -28,6 +28,10 @@ class SetHeadDirector throw new AuditionAdminException('User does not exist'); } + if ($user->hasFlag('head_director')) { + return; + } + if (is_null($user->school_id)) { throw new AuditionAdminException('User is not associated with a school'); } diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index 77c7a0a..2c79a2d 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -1,7 +1,13 @@ is_admin) { - abort(403); - } $users = User::with('school')->with('flags')->orderBy('last_name')->orderBy('first_name')->get(); return view('admin.users.index', ['users' => $users]); @@ -30,9 +29,7 @@ class UserController extends Controller public function edit(User $user) { - if (! Auth::user()->is_admin) { - abort(403); - } + $schools = School::orderBy('name')->get(); return view('admin.users.edit', ['user' => $user, 'schools' => $schools]); @@ -40,85 +37,53 @@ class UserController extends Controller public function create() { - if (! Auth::user()->is_admin) { - abort(403); - } + $schools = School::orderBy('name')->get(); return view('admin.users.create', ['schools' => $schools]); } - public function update(Request $request, User $user, SetHeadDirector $headSetter) - { - if (! Auth::user()->is_admin) { - abort(403); - } - $oldEmail = $user->email; - $wasAdmin = $user->is_admin; - $wasTab = $user->is_tab; - $validData = $request->validate([ - 'first_name' => ['required'], - 'last_name' => ['required'], - 'email' => ['required', 'email'], - 'cell_phone' => ['required'], - 'judging_preference' => ['required'], - 'school_id' => ['nullable', 'exists:schools,id'], - ]); - $validData['is_admin'] = $request->get('is_admin') == 'on' ? 1 : 0; - $validData['is_tab'] = $request->get('is_tab') == 'on' ? 1 : 0; - $validData['is_head'] = $request->get('is_head') == 'on' ? 1 : 0; - $user->update([ - 'first_name' => $validData['first_name'], - 'last_name' => $validData['last_name'], - 'email' => $validData['email'], - 'cell_phone' => $validData['cell_phone'], - 'judging_preference' => $validData['judging_preference'], - 'school_id' => $validData['school_id'], - 'is_admin' => $validData['is_admin'], - 'is_tab' => $validData['is_tab'], - ]); - $user->refresh(); - $logged_school = $user->school_id ? $user->school->name : 'No School'; - $message = 'Updated user #'.$user->id.' - '.$oldEmail - .'
Name: '.$user->full_name() - .'
Email: '.$user->email - .'
Cell Phone: '.$user->cell_phone - .'
Judging Pref: '.$user->judging_preference - .'
School: '.$logged_school; + public function update( + Request $request, + User $user, + SetHeadDirector $headSetter, + UpdateUserProfileInformation $profileUpdater, + AssignUserToSchool $schoolAssigner, + UpdateUserPrivileges $privilegesUpdater + ) { + // Update basic profile data + $profileData = [ + 'first_name' => $request->get('first_name'), + 'last_name' => $request->get('last_name'), + 'email' => $request->get('email'), + 'cell_phone' => $request->get('cell_phone'), + 'judging_preference' => $request->get('judging_preference'), + ]; + $profileUpdater->update($user, $profileData); - AuditLogEntry::create([ - 'user' => auth()->user()->email, - 'ip_address' => request()->ip(), - 'message' => $message, - 'affected' => ['users' => [$user->id]], - ]); - if ($user->is_admin != $wasAdmin) { - $messageStart = $user->is_admin ? 'Granted admin privileges to ' : 'Revoked admin privileges from '; - AuditLogEntry::create([ - 'user' => auth()->user()->email, - 'ip_address' => request()->ip(), - 'message' => $messageStart.$user->full_name().' - '.$user->email, - 'affected' => ['users' => [$user->id]], - ]); + // Deal with school assignment + if ($user->school_id != $request->get('school_id')) { + $schoolAssigner($user, $request->get('school_id')); } - if ($user->is_tab != $wasTab) { - $messageStart = $user->is_tab ? 'Granted tabulation privileges to ' : 'Revoked tabulation privileges from '; - AuditLogEntry::create([ - 'user' => auth()->user()->email, - 'ip_address' => request()->ip(), - 'message' => $messageStart.$user->full_name().' - '.$user->email, - 'affected' => ['users' => [$user->id]], - ]); + + // Deal with the head director flag + if ($request->has('head_director')) { + $headSetter($user); + } else { + $user->removeFlag('head_director'); } - if ($user->hasFlag('head_director') != $validData['is_head'] && ! is_null($user->school_id)) { - if ($validData['is_head']) { - $headSetter->setHeadDirector($user); - } else { - $user->removeFlag('head_director'); - $logMessage = 'Removed '.$user->full_name().' as head director at '.$user->school->name; - $logAffected = ['users' => [$user->id], 'schools' => [$user->school_id]]; - auditionLog($logMessage, $logAffected); - } + + // Deal with privileges + if ($request->has('is_admin')) { + $privilegesUpdater($user, 'grant', 'admin'); + } else { + $privilegesUpdater($user, 'revoke', 'admin'); + } + + if ($request->has('is_tab')) { + $privilegesUpdater($user, 'grant', 'tab'); + } else { + $privilegesUpdater($user, 'revoke', 'tab'); } return redirect('/admin/users'); @@ -126,60 +91,23 @@ class UserController extends Controller public function store(Request $request) { - $request->validate([ - 'first_name' => ['required'], - 'last_name' => ['required'], - 'email' => ['required', 'email', 'unique:users'], - ]); - - // Generate a random password + $userCreator = app(CreateNewUser::class); $randomPassword = Str::random(12); - - $user = User::make([ - 'first_name' => request('first_name'), - 'last_name' => request('last_name'), - 'email' => request('email'), - 'cell_phone' => request('cell_phone'), - 'judging_preference' => request('judging_preference'), - 'password' => Hash::make($randomPassword), + $data = request()->all(); + $data['password'] = $randomPassword; + $data['password_confirmation'] = $randomPassword; + $newDirector = $userCreator->create($data); + $newDirector->update([ + 'school_id' => $request->get('school_id') ?? null, ]); - if (! is_null(request('school_id'))) { - $request->validate([ - 'school_id' => ['exists:schools,id'], - ]); - } - $user->school_id = request('school_id'); - $user->save(); - $message = 'Created user '.$user->email.' - '.$user->full_name().'
Cell Phone: '.$user->cell_phone.'
Judging Pref: '.$user->judging_preference; - AuditLogEntry::create([ - 'user' => auth()->user()->email, - 'ip_address' => request()->ip(), - 'message' => $message, - 'affected' => ['users' => [$user->id]], - ]); - if ($user->school_id) { - $message = 'Set user '.$user->full_name().' ('.$user->email.') as a director at '.$user->school->name.'(#'.$user->school->id.')'; - AuditLogEntry::create([ - 'user' => auth()->user()->email, - 'ip_address' => request()->ip(), - 'message' => $message, - 'affected' => [ - 'users' => [$user->id], - 'schools' => [$user->id], - ], - ]); - } - Mail::to($user->email)->send(new NewUserPassword($user, $randomPassword)); + Mail::to($newDirector->email)->send(new NewUserPassword($newDirector, $randomPassword)); - return redirect('/admin/users'); + return redirect(route('admin.users.index'))->with('success', 'Director added'); } public function destroy(User $user) { - if (! Auth::user()->is_admin) { - abort(403); - } $message = 'Deleted user '.$user->email; AuditLogEntry::create([ 'user' => auth()->user()->email, diff --git a/app/Http/Controllers/Admin/YearEndResetController.php b/app/Http/Controllers/Admin/YearEndResetController.php index 52d81d2..b3d6d87 100644 --- a/app/Http/Controllers/Admin/YearEndResetController.php +++ b/app/Http/Controllers/Admin/YearEndResetController.php @@ -16,7 +16,7 @@ class YearEndResetController extends Controller public function execute() { - $cleanUpProcedure = new YearEndCleanup; + $cleanUpProcedure = app(YearEndCleanup::class); $options = request()->options; $cleanUpProcedure($options); auditionLog('Executed year end reset.', []); diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php index 9c3ac0a..0477207 100644 --- a/app/Observers/UserObserver.php +++ b/app/Observers/UserObserver.php @@ -50,7 +50,7 @@ class UserObserver //Log if we removed a school if ($oldSchool && ! $newSchool) { $message = 'Removed '.$user->full_name().' from '.$oldSchool; - $affected = ['users' => [$user->id], 'schools' => [$user->getOrigianl('school_id')]]; + $affected = ['users' => [$user->id], 'schools' => [$user->getOriginal('school_id')]]; auditionLog($message, $affected); } diff --git a/tests/Feature/app/Http/Controllers/Admin/UserControllerTest.php b/tests/Feature/app/Http/Controllers/Admin/UserControllerTest.php new file mode 100644 index 0000000..1f92440 --- /dev/null +++ b/tests/Feature/app/Http/Controllers/Admin/UserControllerTest.php @@ -0,0 +1,220 @@ +user = User::factory()->create(); +}); + +afterEach(function () { + Mockery::close(); +}); + +describe('UserController::index', function () { + it('denies access to a non-admin user', function () { + $this->get(route('admin.users.index'))->assertRedirect(route('home')); + actAsNormal(); + $this->get(route('admin.users.index'))->assertRedirect(route('dashboard')); + actAsTab(); + $this->get(route('admin.users.index'))->assertRedirect(route('dashboard')); + }); + + it('allows access for an admin user', function () { + actAsAdmin(); + $users = User::factory()->count(5)->create(); + $response = $this->get(route('admin.users.index')); + $response->assertOk()->assertViewIs('admin.users.index')->assertViewHas('users'); + + // Check if each $users is in the array of users sent to the view + $userIdsSentToView = $response->viewData('users')->pluck('id')->toArray(); + expect(in_array($this->user->id, $userIdsSentToView))->toBeTrue(); + foreach ($users as $user) { + expect(in_array($user->id, $userIdsSentToView))->toBeTrue(); + } + + }); +}); + +describe('UserController::edit', function () { + it('denies access to a non-admin user', function () { + $this->get(route('admin.users.edit', $this->user))->assertRedirect(route('home')); + actAsNormal(); + $this->get(route('admin.users.edit', $this->user))->assertRedirect(route('dashboard')); + actAsTab(); + $this->get(route('admin.users.edit', $this->user))->assertRedirect(route('dashboard')); + }); + + it('allows access for an admin user', function () { + $schools = School::factory()->count(5)->create(); + actAsAdmin(); + $response = $this->get(route('admin.users.edit', $this->user)); + $response->assertOk()->assertViewIs('admin.users.edit')->assertViewHas(['schools', 'user']); + expect($response->viewData('user')->id)->toEqual($this->user->id); + foreach ($schools as $school) { + expect(in_array($school->id, $response->viewData('schools')->pluck('id')->toArray()))->toBeTrue(); + } + }); +}); + +describe('UserController::create', function () { + it('denies access to a non-admin user', function () { + $this->get(route('admin.users.create'))->assertRedirect(route('home')); + actAsNormal(); + $this->get(route('admin.users.create'))->assertRedirect(route('dashboard')); + actAsTab(); + $this->get(route('admin.users.create'))->assertRedirect(route('dashboard')); + }); + + it('allows access for an admin user', function () { + actAsAdmin(); + $schools = School::factory()->count(5)->create(); + $response = $this->get(route('admin.users.create')); + $response->assertOk()->assertViewIs('admin.users.create')->assertViewHas(['schools']); + foreach ($schools as $school) { + expect(in_array($school->id, $response->viewData('schools')->pluck('id')->toArray()))->toBeTrue(); + } + }); +}); + +describe('UserController::update', function () { + beforeEach(function () { + $this->oldSchool = School::factory()->create(); + $this->newSchool = School::factory()->create(); + $this->oldUser = User::create([ + 'first_name' => 'Old', + 'last_name' => 'Name', + 'email' => 'picard@starfleet.com', + 'cell_phone' => '1701', + 'judging_preference' => 'light counting', + 'school_id' => $this->oldSchool->id, + 'password' => \Illuminate\Support\Facades\Hash::make('password'), + ]); + }); + it('denies access to a non-admin user', function () { + $this->patch(route('admin.users.update', $this->user))->assertRedirect(route('home')); + actAsNormal(); + $this->patch(route('admin.users.update', $this->user))->assertRedirect(route('dashboard')); + actAsTab(); + $this->patch(route('admin.users.update', $this->user))->assertRedirect(route('dashboard')); + }); + it('updates user profile information', function () { + actAsAdmin(); + $response = $this->patch(route('admin.users.update', $this->oldUser), [ + 'first_name' => 'New', + 'last_name' => 'Family', + 'email' => 'skywalker@rebellion.org', + 'cell_phone' => '555-555-5555', + 'judging_preference' => 'light sabers', + 'school_id' => $this->newSchool->id, + ]); + //file_put_contents(storage_path('debug.html'), $response->getContent()); + $response->assertRedirect(route('admin.users.index')); + $this->oldUser->refresh(); + expect($this->oldUser->first_name)->toBe('New') + ->and($this->oldUser->last_name)->toBe('Family') + ->and($this->oldUser->email)->toBe('skywalker@rebellion.org') + ->and($this->oldUser->cell_phone)->toBe('555-555-5555') + ->and($this->oldUser->judging_preference)->toBe('light sabers') + ->and($this->oldUser->school_id)->toBe($this->newSchool->id); + }); + it('assigns privileges to a user', function () { + actAsAdmin(); + $this->patch(route('admin.users.update', $this->oldUser), [ + 'first_name' => 'Jean Luc', + 'last_name' => 'Picard', + 'email' => 'skywalker@rebellion.org', + 'cell_phone' => '1701', + 'judging_preference' => 'light sabers', + 'school_id' => $this->newSchool->id, + 'is_admin' => 'on', + 'is_tab' => 'on', + 'head_director' => 'on', + ]); + //file_put_contents(storage_path('debug.html'), $response->getContent()); + $this->oldUser->refresh(); + expect($this->oldUser->is_admin)->toBeTruthy() + ->and($this->oldUser->is_tab)->toBeTruthy(); + + $this->patch(route('admin.users.update', $this->oldUser), [ + 'first_name' => 'Luke', + 'last_name' => 'Skywalker', + 'email' => 'skywalker@rebellion.org', + 'cell_phone' => '555-555-5555', + 'judging_preference' => 'light sabers', + 'school_id' => $this->newSchool->id, + ]); + $this->oldUser->refresh(); + expect($this->oldUser->is_admin)->toBeFalsy() + ->and($this->oldUser->is_tab)->toBeFalsy(); + }); +}); + +describe('UserController::store', function () { + it('denies access to a non-admin user', function () { + $this->post(route('admin.users.store', $this->user))->assertRedirect(route('home')); + actAsNormal(); + $this->post(route('admin.users.store', $this->user))->assertRedirect(route('dashboard')); + actAsTab(); + $this->post(route('admin.users.store', $this->user))->assertRedirect(route('dashboard')); + }); + + it('creates a new user', function () { + actAsAdmin(); + $school = School::factory()->create(); + $response = $this->post(route('admin.users.store', [ + 'first_name' => 'Jean Luc', + 'last_name' => 'Picard', + 'email' => 'picard@starfleet.com', + 'cell_phone' => '1701', + 'judging_preference' => 'light counting', + 'school_id' => $school->id, + ])); + //file_put_contents(storage_path('debug.html'), $response->getContent()); + $response->assertRedirect(route('admin.users.index')); + $user = User::orderBy('id', 'desc')->first(); + expect($user->first_name)->toBe('Jean Luc') + ->and($user->last_name)->toBe('Picard') + ->and($user->email)->toBe('picard@starfleet.com') + ->and($user->cell_phone)->toBe('1701') + ->and($user->judging_preference)->toBe('light counting') + ->and($user->school->id)->toBe($school->id); + }); + it('sends an email upon user creation', function () { + Mail::fake(); + actAsAdmin(); + $school = School::factory()->create(); + $this->post(route('admin.users.store', [ + 'first_name' => 'Jean Luc', + 'last_name' => 'Picard', + 'email' => 'picard@starfleet.com', + 'cell_phone' => '1701', + 'judging_preference' => 'light counting', + 'school_id' => $school->id, + ])); + Mail::assertSent(NewUserPassword::class, function ($mail) { + return $mail->hasTo('picard@starfleet.com'); + }); + }); +}); + +describe('UserController::destroy', function () { + it('denies access to a non-admin user', function () { + $this->delete(route('admin.users.destroy', $this->user))->assertRedirect(route('home')); + actAsNormal(); + $this->delete(route('admin.users.destroy', $this->user))->assertRedirect(route('dashboard')); + actAsTab(); + $this->delete(route('admin.users.destroy', $this->user))->assertRedirect(route('dashboard')); + }); + it('deletes a user', function () { + actAsAdmin(); + $response = $this->delete(route('admin.users.destroy', $this->user)); + $response->assertRedirect(route('admin.users.index')); + $response->assertSessionHas('success', 'User deleted successfully'); + expect(User::where('id', $this->user->id)->exists())->toBeFalsy(); + }); +}); diff --git a/tests/Feature/app/Http/Controllers/Admin/YearEndResetControllerTest.php b/tests/Feature/app/Http/Controllers/Admin/YearEndResetControllerTest.php new file mode 100644 index 0000000..66226c5 --- /dev/null +++ b/tests/Feature/app/Http/Controllers/Admin/YearEndResetControllerTest.php @@ -0,0 +1,31 @@ +get(route('admin.year_end_procedures')); + $response->assertRedirect(route('dashboard')); +}); + +it('shows options for year end reset', function () { + actAsAdmin(); + $response = $this->get(route('admin.year_end_procedures')); + $response->assertOk() + ->assertViewIs('admin.year_end_reset') + ->assertSee('removeAuditionsFromRoom') + ->assertSee('unassignJudges'); +}); + +it('calls the YearEndCleanup action', function () { + $mock = Mockery::mock(YearEndCleanup::class); + $mock->shouldReceive('__invoke')->once(); + app()->instance(YearEndCleanup::class, $mock); + actAsAdmin(); + $response = $this->post(route('admin.year_end_procedures')); + $response->assertRedirect(route('dashboard')) + ->with('success', 'Year end cleanup completed. '); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 54d4edf..43867b7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -65,6 +65,11 @@ function loadSampleAudition() artisan('db:seed', ['--class' => 'AuditionWithScoringGuideAndRoom']); } +function saveContentLocally($content) +{ + file_put_contents(storage_path('app/storage/debug.html'), $content); +} + uses()->beforeEach(function () { Settings::set('auditionName', 'Somewhere Band Directors Association'); Settings::set('auditionAbbreviation', 'SBDA');