add test for admin DrawController. Work on deprecating DrawService

This commit is contained in:
Matt Young 2025-07-09 15:51:42 -05:00
parent e1d72ee040
commit 7efe029ff9
8 changed files with 460 additions and 5 deletions

View File

@ -0,0 +1,38 @@
<?php
namespace App\Actions\Draw;
use App\Models\Audition;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use function auditionLog;
class ClearDraw
{
public function __invoke(Audition|collection $auditions): void
{
if ($auditions instanceof Audition) {
$this->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);
}
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Actions\Draw;
use App\Models\Audition;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class RunDraw
{
public function __invoke(Audition|Collection $auditions): void
{
if ($auditions instanceof Audition) {
// Single audition, run draw directly
$this->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));
}
}

View File

@ -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');
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Observers;
use App\Models\Audition;
use App\Models\Event;
class AuditionObserver
{
public function created(Audition $audition): void
{
$message = 'Added audition #'.$audition->id.' '.$audition->name.' to event '.$audition->event->name;
$message .= '<br>Deadline: '.$audition->entry_deadline->format('m/d/Y');
$message .= '<br>Entry Fee: '.$audition->display_fee();
$message .= '<br>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 .= '<br>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 .= '<br>Deadline: '.$audition->entry_deadline->format('m/d/Y');
}
if ($audition->entryFee !== $audition->getOriginal('entryFee')) {
$message .= '<br>Entry Fee: '.$audition->display_fee();
}
if ($audition->minimum_grade !== $audition->getOriginal('minimum_grade') || $audition->maximum_grade !== $audition->getOriginal('maximum_grade')) {
$message .= '<br>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);
}
}

View File

@ -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 .= '<br>Student: '.$entry->student->full_name();
$message .= '<br>Grade: '.$entry->student->grade;
$message .= '<br>School: '.$entry->student->school->name;
if ($entry->draw_number) {
$message .= '<br>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 .= '<br>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 .= '<br>Student: '.$originalStudentName.' -> '.$entry->student->full_name();
$affected['students'][] = $entry->getOriginal('student_id');
$shouldLog = true;
}
if ($entry->wasChanged('for_advancement' && auditionSetting('advanceTo'))) {
$message .= '<br>Entered for '.auditionSetting('advanceTo');
$shouldLog = true;
}
if ($entry->wasChanged('for_seating')) {
$message .= '<br>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 .= '<br>Audition: '.$entry->audition->name;
$message .= '<br>Student: '.$entry->student->full_name();
$affected['students'] = [$entry->student_id];
$affected['auditions'] = [$entry->audition_id];
$affected['entries'] = [$entry->id];
auditionLog($message, $affected);
}
}
/**

View File

@ -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);

View File

@ -0,0 +1,99 @@
<?php
use App\Actions\Draw\RunDraw;
use App\Models\Audition;
use App\Models\Entry;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('assigns sequential draw numbers with no_show entries at the end', function () {
// Create an audition with entries
$audition = Audition::factory()->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();
}
});

View File

@ -0,0 +1,157 @@
<?php
use App\Models\Audition;
use App\Models\Entry;
use App\Models\Event;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
describe('DrawController::index', function () {
it('denies access to a non-admin user', function () {
$this->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();
});
});