From 7efe029ff9eae70b40cf68acb674f7f83a56bfc1 Mon Sep 17 00:00:00 2001 From: Matt Young Date: Wed, 9 Jul 2025 15:51:42 -0500 Subject: [PATCH] add test for admin DrawController. Work on deprecating DrawService --- app/Actions/Draw/ClearDraw.php | 38 +++++ app/Actions/Draw/RunDraw.php | 54 ++++++ app/Http/Controllers/Admin/DrawController.php | 21 ++- app/Observers/AuditionObserver.php | 48 ++++++ app/Observers/EntryObserver.php | 45 +++++ app/Providers/AppServiceProvider.php | 3 + .../Feature/app/Actions/Draw/RunDrawTest.php | 99 +++++++++++ .../Controllers/Admin/DrawControllerTest.php | 157 ++++++++++++++++++ 8 files changed, 460 insertions(+), 5 deletions(-) create mode 100644 app/Actions/Draw/ClearDraw.php create mode 100644 app/Actions/Draw/RunDraw.php create mode 100644 app/Observers/AuditionObserver.php create mode 100644 tests/Feature/app/Actions/Draw/RunDrawTest.php create mode 100644 tests/Feature/app/Http/Controllers/Admin/DrawControllerTest.php diff --git a/app/Actions/Draw/ClearDraw.php b/app/Actions/Draw/ClearDraw.php new file mode 100644 index 0000000..66ecdd8 --- /dev/null +++ b/app/Actions/Draw/ClearDraw.php @@ -0,0 +1,38 @@ +clearDraw($auditions); + } + if ($auditions instanceof Collection) { + $this->clearDraws($auditions); + } + } + + public function clearDraw(Audition $audition): void + { + $audition->removeFlag('drawn'); + DB::table('entries')->where('audition_id', $audition->id)->update(['draw_number' => null]); + $message = 'Cleared draw for audition #'.$audition->id.' '.$audition->name; + $affected['auditions'] = [$audition->id]; + auditionLog($message, $affected); + } + + public function clearDraws(Collection $auditions): void + { + foreach ($auditions as $audition) { + $this->clearDraw($audition); + } + } +} diff --git a/app/Actions/Draw/RunDraw.php b/app/Actions/Draw/RunDraw.php new file mode 100644 index 0000000..45dc878 --- /dev/null +++ b/app/Actions/Draw/RunDraw.php @@ -0,0 +1,54 @@ +runDraw($auditions); + + return; + } elseif ($auditions instanceof Collection) { + $this->runDrawMultiple($auditions); + + return; + } + } + + public function runDraw(Audition $audition): void + { + // start off by clearing any existing draw numbers in the audition + DB::table('entries')->where('audition_id', $audition->id)->update(['draw_number' => null]); + + $randomizedEntries = $audition->entries->shuffle(); + + // Move entries flagged as no show to the end + [$noShowEntries, $otherEntries] = $randomizedEntries->partition(function ($entry) { + return $entry->hasFlag('no_show'); + }); + $randomizedEntries = $otherEntries->merge($noShowEntries); + + // Save draw numbers back to the entries\ + $nextNumber = 1; + foreach ($randomizedEntries as $index => $entry) { + $entry->update(['draw_number' => $nextNumber]); + $nextNumber++; + } + $audition->addFlag('drawn'); + } + + public function runDrawMultiple(Collection $auditions): void + { + // Eager load the 'entries' relationship on all auditions if not already loaded + $auditions->loadMissing('entries'); + + $auditions->each(fn ($audition) => $this->runDraw($audition)); + } +} diff --git a/app/Http/Controllers/Admin/DrawController.php b/app/Http/Controllers/Admin/DrawController.php index 64d15e8..b97a6eb 100644 --- a/app/Http/Controllers/Admin/DrawController.php +++ b/app/Http/Controllers/Admin/DrawController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Admin; +use App\Actions\Draw\RunDraw; use App\Http\Controllers\Controller; use App\Http\Requests\ClearDrawRequest; use App\Http\Requests\RunDrawRequest; @@ -10,7 +11,6 @@ use App\Models\Event; use App\Services\DrawService; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Log; use function array_keys; use function to_route; @@ -26,6 +26,7 @@ class DrawController extends Controller public function index(Request $request) { $events = Event::with('auditions.flags')->get(); + // $drawnAuditionsExist is true if any audition->hasFlag('drawn') is true $drawnAuditionsExist = Audition::whereHas('flags', function ($query) { $query->where('flag_name', 'drawn'); @@ -36,18 +37,23 @@ class DrawController extends Controller public function store(RunDrawRequest $request) { + // Request will contain audition which is an array of audition IDs all with a value of 1 + // Code below results in a collection of auditions that were checked on the form $auditions = Audition::with('flags')->findMany(array_keys($request->input('audition', []))); if ($this->drawService->checkCollectionForDrawnAuditions($auditions)) { return to_route('admin.draw.index')->with('error', - 'Invalid attempt to draw an audition that has already been drawn'); + 'Cannot run draw. Some auditions have already been drawn.'); } - $this->drawService->runDrawsOnCollection($auditions); + app(RunDraw::class)($auditions); - return to_route('admin.draw.index')->with('status', 'Draw completed successfully'); + return to_route('admin.draw.index')->with('success', 'Draw completed successfully'); } + /** + * generates the page with checkboxes for each drawn audition with an intent to clear them + */ public function edit(Request $request) { $drawnAuditions = Audition::whereHas('flags', function ($query) { @@ -57,12 +63,17 @@ class DrawController extends Controller return view('admin.draw.edit', compact('drawnAuditions')); } + /** + * Clears the draw for auditions + */ public function destroy(ClearDrawRequest $request) { + // Request will contain audition which is an array of audition IDs all with a value of 1 + // Code below results in a collection of auditions that were checked on the form $auditions = Audition::with('flags')->findMany(array_keys($request->input('audition', []))); $this->drawService->clearDrawsOnCollection($auditions); - return to_route('admin.draw.index')->with('status', 'Draw completed successfully'); + return to_route('admin.draw.index')->with('success', 'Draws cleared successfully'); } } diff --git a/app/Observers/AuditionObserver.php b/app/Observers/AuditionObserver.php new file mode 100644 index 0000000..237d554 --- /dev/null +++ b/app/Observers/AuditionObserver.php @@ -0,0 +1,48 @@ +id.' '.$audition->name.' to event '.$audition->event->name; + $message .= '
Deadline: '.$audition->entry_deadline->format('m/d/Y'); + $message .= '
Entry Fee: '.$audition->display_fee(); + $message .= '
Grade Range: '.$audition->minimum_grade.' - '.$audition->maximum_grade; + $affected = ['auditions' => [$audition->id], 'events' => [$audition->event_id]]; + auditionLog($message, $affected); + } + + public function updated(Audition $audition): void + { + $message = 'Updated audition #'.$audition->getOriginal('name').' '.$audition->name; + if ($audition->event_id !== $audition->getOriginal('event_id')) { + $message .= '
Event: '.Event::find($audition->getOriginal('event_id'))->name.' -> '.Event::find($audition->event_id)->name; + $affected['events'] = [$audition->event_id, $audition->getOriginal('event_id')]; + } else { + $affected['auditions'] = [$audition->id]; + } + if ($audition->entry_deadline !== $audition->getOriginal('entry_deadline')) { + $message .= '
Deadline: '.$audition->entry_deadline->format('m/d/Y'); + } + if ($audition->entryFee !== $audition->getOriginal('entryFee')) { + $message .= '
Entry Fee: '.$audition->display_fee(); + } + if ($audition->minimum_grade !== $audition->getOriginal('minimum_grade') || $audition->maximum_grade !== $audition->getOriginal('maximum_grade')) { + $message .= '
Grade Range: '.$audition->minimum_grade.' - '.$audition->maximum_grade; + } + $affected['auditions'] = [$audition->id]; + auditionLog($message, $affected); + } + + public function deleted(Audition $audition): void + { + $message = 'Deleted audition #'.$audition->id.' '.$audition->name; + $affected = ['auditions' => [$audition->id]]; + auditionLog($message, $affected); + } +} diff --git a/app/Observers/EntryObserver.php b/app/Observers/EntryObserver.php index d36ec45..9133ec6 100644 --- a/app/Observers/EntryObserver.php +++ b/app/Observers/EntryObserver.php @@ -7,6 +7,8 @@ use App\Models\Audition; use App\Models\Doubler; use App\Models\Entry; +use function auditionSetting; + class EntryObserver { /** @@ -30,6 +32,9 @@ class EntryObserver $message .= '
Student: '.$entry->student->full_name(); $message .= '
Grade: '.$entry->student->grade; $message .= '
School: '.$entry->student->school->name; + if ($entry->draw_number) { + $message .= '
Draw Number: '.$entry->draw_number; + } $affected = [ 'students' => [$entry->student_id], @@ -48,6 +53,46 @@ class EntryObserver $syncer = app(DoublerSync::class); // Update doubler table when an entry is updated $syncer(); + + // Log entry changes + $message = 'Updated Entry #'.$entry->id; + $affected['entries'] = [$entry->id]; + $affected['auditions'] = [$entry->audition_id]; + $affected['students'] = [$entry->student_id]; + $shouldLog = false; + if ($entry->wasChanged('audition_id')) { + $originalAuditionName = Audition::find($entry->getOriginal('audition_id'))->name; + $message .= '
Audition: '.$originalAuditionName.' -> '.$entry->audition->name; + $affected['auditions'][] = $entry->getOriginal('audition_id'); + $shouldLog = true; + } + if ($entry->wasChanged('student_id')) { + $originalStudentName = $entry->getOriginal('student')->full_name(); + $message .= '
Student: '.$originalStudentName.' -> '.$entry->student->full_name(); + $affected['students'][] = $entry->getOriginal('student_id'); + $shouldLog = true; + } + if ($entry->wasChanged('for_advancement' && auditionSetting('advanceTo'))) { + $message .= '
Entered for '.auditionSetting('advanceTo'); + $shouldLog = true; + } + if ($entry->wasChanged('for_seating')) { + $message .= '
Entered for seating'; + $shouldLog = true; + } + if ($shouldLog) { + auditionLog($message, $affected); + } + + if ($entry->wasChanged('draw_number')) { + $message = 'Assigned Entry #'.$entry->id.' draw number '.$entry->draw_number; + $message .= '
Audition: '.$entry->audition->name; + $message .= '
Student: '.$entry->student->full_name(); + $affected['students'] = [$entry->student_id]; + $affected['auditions'] = [$entry->audition_id]; + $affected['entries'] = [$entry->id]; + auditionLog($message, $affected); + } } /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9b20c3f..398ee75 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,6 +7,7 @@ use App\Actions\Entries\UpdateEntry; use App\Actions\Schools\SetHeadDirector; use App\Actions\Tabulation\CalculateAuditionScores; use App\Actions\Tabulation\TotalEntryScores; +use App\Models\Audition; use App\Models\BonusScore; use App\Models\Entry; use App\Models\EntryFlag; @@ -17,6 +18,7 @@ use App\Models\ScoreSheet; use App\Models\ScoringGuide; use App\Models\Student; use App\Models\User; +use App\Observers\AuditionObserver; use App\Observers\BonusScoreObserver; use App\Observers\EntryFlagObserver; use App\Observers\EntryObserver; @@ -61,6 +63,7 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + Audition::observe(AuditionObserver::class); BonusScore::observe(BonusScoreObserver::class); Entry::observe(EntryObserver::class); EntryFlag::observe(EntryFlagObserver::class); diff --git a/tests/Feature/app/Actions/Draw/RunDrawTest.php b/tests/Feature/app/Actions/Draw/RunDrawTest.php new file mode 100644 index 0000000..2a7c05a --- /dev/null +++ b/tests/Feature/app/Actions/Draw/RunDrawTest.php @@ -0,0 +1,99 @@ +create(); + + // Create entries: some flagged as no_show, some not + $entries = Entry::factory() + ->count(5) + ->for($audition) + ->create(); + + // Flag two entries as no_show + $noShowEntries = $entries->take(2); + foreach ($noShowEntries as $entry) { + $entry->addFlag('no_show'); + $entry->save(); + } + + // Optionally, assign some existing draw numbers to test clearing + // foreach ($entries as $entry) { + // $entry->draw_number = 99; + // $entry->save(); + // } + + // Run the draw + app(RunDraw::class)($audition); + + // Reload entries from DB to get fresh data + $entries = $audition->entries()->orderBy('draw_number')->get(); + + // Assert all draw_numbers are sequential starting at 1 + $drawNumbers = $entries->pluck('draw_number')->all(); + expect($drawNumbers)->toEqual(range(1, $entries->count())); + + // Assert entries without no_show flag come first + $entriesWithoutNoShow = $entries->filter(fn ($e) => ! $e->hasFlag('no_show')); + $entriesWithNoShow = $entries->filter(fn ($e) => $e->hasFlag('no_show')); + + // The max draw_number of entries without no_show should be less than min draw_number of no_show entries + if ($entriesWithNoShow->isNotEmpty()) { + expect($entriesWithoutNoShow->max('draw_number'))->toBeLessThan($entriesWithNoShow->min('draw_number')); + } + + // Assert the audition has the 'drawn' flag + expect($audition->hasFlag('drawn'))->toBeTrue(); +}); + +it('runs draw on multiple auditions correctly', function () { + // Create multiple auditions + $auditions = Audition::factory()->count(3)->create(); + + // For each audition, create entries with some flagged as no_show + foreach ($auditions as $audition) { + $entries = Entry::factory()->count(4)->for($audition)->create(); + + // Flag one entry as no_show per audition + $entries->first()->addFlag('no_show'); + $entries->first()->save(); + + // // Assign dummy draw numbers to test clearing + // foreach ($entries as $entry) { + // $entry->draw_number = 99; + // $entry->save(); + // } + } + + // Run the draw on all auditions + app(RunDraw::class)($auditions); + + // Reload auditions with entries + $auditions = $auditions->load('entries'); + + foreach ($auditions as $audition) { + $entries = $audition->entries->sortBy('draw_number')->values(); + + // Assert draw numbers are sequential starting at 1 + $drawNumbers = $entries->pluck('draw_number')->all(); + expect($drawNumbers)->toEqual(range(1, $entries->count())); + + // Separate no_show and other entries + $entriesWithoutNoShow = $entries->filter(fn ($e) => ! $e->hasFlag('no_show')); + $entriesWithNoShow = $entries->filter(fn ($e) => $e->hasFlag('no_show')); + + if ($entriesWithNoShow->isNotEmpty()) { + expect($entriesWithoutNoShow->max('draw_number'))->toBeLessThan($entriesWithNoShow->min('draw_number')); + } + + // Assert the audition has the 'drawn' flag + expect($audition->hasFlag('drawn'))->toBeTrue(); + } +}); diff --git a/tests/Feature/app/Http/Controllers/Admin/DrawControllerTest.php b/tests/Feature/app/Http/Controllers/Admin/DrawControllerTest.php new file mode 100644 index 0000000..13d0827 --- /dev/null +++ b/tests/Feature/app/Http/Controllers/Admin/DrawControllerTest.php @@ -0,0 +1,157 @@ +get(route('admin.draw.index'))->assertRedirect(route('home')); + actAsNormal(); + $this->get(route('admin.draw.index'))->assertRedirect(route('dashboard')); + actAsTab(); + $this->get(route('admin.draw.index'))->assertRedirect(route('dashboard')); + }); + it('returns a page to select auditions to draw, including a section for each event that has entries', function () { + $events = Event::factory()->count(3)->create(); + foreach ($events as $event) { + Audition::factory()->forEvent($event)->count(3)->create(); + } + actAsAdmin(); + $response = $this->get(route('admin.draw.index')); + $response->assertOk() + ->assertViewIs('admin.draw.index') + ->assertSee('events'); + foreach ($events as $event) { + $response->assertSee($event->name, false); + } + }); + it('tells the view if any auditions are drawn', function () { + $events = Event::factory()->count(3)->create(); + foreach ($events as $event) { + Audition::factory()->forEvent($event)->count(3)->create(); + } + actAsAdmin(); + $response = $this->get(route('admin.draw.index')); + $response->assertOk(); + expect($response->viewData('drawnAuditionsExist'))->toBeFalse(); + foreach (Audition::all() as $audition) { + $response->assertSee($audition->name, false); + } + $testCase = Audition::first(); + $testCase->addFlag('drawn'); + $response = $this->get(route('admin.draw.index')); + $response->assertOk(); + expect($response->viewData('drawnAuditionsExist'))->toBeTrue(); + $response->assertDontSee($testCase->name, false); + + }); +}); + +describe('DrawController::store', function () { + it('denies access to a non-admin user', function () { + $this->post(route('admin.draw.store'))->assertRedirect(route('home')); + actAsNormal(); + $this->post(route('admin.draw.store'))->assertRedirect(route('dashboard')); + actAsTab(); + $this->post(route('admin.draw.store'))->assertRedirect(route('dashboard')); + }); + it('draws selected auditions', function () { + $auditions = Audition::factory()->count(5)->create(); + $testCase1 = $auditions[0]; + $testCase2 = $auditions[1]; + $input = ['audition' => [$testCase1->id => 1, $testCase2->id => 1]]; + actAsAdmin(); + $this->post(route('admin.draw.store'), $input) + ->assertRedirect(route('admin.draw.index')) + ->assertSessionHas('success'); + $testCase1->refresh(); + $testCase2->refresh(); + expect($testCase1->hasFlag('drawn'))->toBeTrue(); + expect($testCase2->hasFlag('drawn'))->toBeTrue(); + }); + it('will not draw an audition if it is already drawn', function () { + $auditions = Audition::factory()->count(5)->create(); + $testCase = $auditions[0]; + $testCase->addFlag('drawn'); + $input = ['audition' => [$testCase->id => 1]]; + actAsAdmin(); + $this->post(route('admin.draw.store'), $input) + ->assertRedirect(route('admin.draw.index')) + ->assertSessionHas('error', 'Cannot run draw. Some auditions have already been drawn.'); + $testCase->refresh(); + }); + +}); + +describe('DrawController::edit', function () { + it('denies access to a non-admin user', function () { + $this->get(route('admin.draw.edit'))->assertRedirect(route('home')); + actAsNormal(); + $this->get(route('admin.draw.edit'))->assertRedirect(route('dashboard')); + actAsTab(); + $this->get(route('admin.draw.edit'))->assertRedirect(route('dashboard')); + }); + it('returns a page to select drawn auditions to be cleared. Includes all drawn auditions', function () { + $auditions = Audition::factory()->count(5)->create(); + $testCase1 = $auditions[0]; + $testCase2 = $auditions[1]; + $testCase1->addFlag('drawn'); + $testCase2->addFlag('drawn'); + actAsAdmin(); + $response = $this->get(route('admin.draw.edit'), ['audition' => [$testCase1->id => 1, $testCase2->id => 1]]); + $response->assertOk() + ->assertViewIs('admin.draw.edit'); + expect($response->viewData('drawnAuditions')->contains('id', $testCase1->id))->toBeTrue(); + expect($response->viewData('drawnAuditions')->contains('id', $testCase2->id))->toBeTrue(); + expect($response->viewData('drawnAuditions')->contains('id', $auditions[2]->id))->toBeFalse(); + }); +}); + +describe('DrawController::destroy', function () { + it('denies access to a non-admin user', function () { + $this->delete(route('admin.draw.destroy'))->assertRedirect(route('home')); + actAsNormal(); + $this->delete(route('admin.draw.destroy'))->assertRedirect(route('dashboard')); + actAsTab(); + $this->delete(route('admin.draw.destroy'))->assertRedirect(route('dashboard')); + }); + it('clears selected drawn auditions', function () { + $auditions = Audition::factory()->count(5)->create(); + $testCase1 = $auditions[0]; + $testCase2 = $auditions[1]; + $testCase3 = $auditions[2]; + foreach (Audition::all() as $audition) { + Entry::factory()->forAudition($audition)->count(5)->create(); + } + actAsAdmin(); + expect(Entry::where('audition_id', $testCase1->id)->first()->draw_number)->toBeNull(); + // First off, get the draws run + $this->post(route('admin.draw.store'), [ + 'audition' => [ + $testCase1->id => 1, + $testCase2->id => 1, + $testCase3->id => 1, + ], + ]); + $testCase1->refresh(); + $testCase2->refresh(); + $testCase3->refresh(); + expect($testCase1->hasFlag('drawn'))->toBeTrue() + ->and(Entry::where('audition_id', $testCase1->id)->first()->draw_number)->not()->toBeNull(); + + $this->delete(route('admin.draw.destroy'), ['audition' => [$testCase1->id => 1, $testCase2->id => 1]]) + ->assertRedirect(route('admin.draw.index')) + ->assertSessionHas('success'); + $testCase1->refresh(); + $testCase2->refresh(); + $testCase3->refresh(); + expect($testCase1->hasFlag('drawn'))->toBeFalse() + ->and($testCase2->hasFlag('drawn'))->toBeFalse() + ->and($testCase3->hasFlag('drawn'))->toBeTrue(); + expect(Entry::where('audition_id', $testCase1->id)->first()->draw_number)->toBeNull(); + }); +});