Student controller refactoring and testing.

This commit is contained in:
Matt Young 2025-07-04 23:52:25 -05:00
parent 7379500e9a
commit 2962854541
9 changed files with 412 additions and 141 deletions

View File

@ -4,6 +4,7 @@ namespace App\Actions\Students;
use App\Exceptions\AuditionAdminException; use App\Exceptions\AuditionAdminException;
use App\Models\Student; use App\Models\Student;
use Arr;
class CreateStudent class CreateStudent
{ {
@ -11,29 +12,33 @@ class CreateStudent
{ {
} }
public function __invoke( /**
string $firstName, * @throws AuditionAdminException
string $lastName, */
int $grade, public function __invoke(array $newData): Student
array $optionalData = [], {
int|string $school_id = 'user'
): Student { // $newData[] must include keys first_name, last_name, grade - throw an exception if it does not
if ($school_id === 'user') { foreach (['first_name', 'last_name', 'grade'] as $key) {
$school_id = auth()->user()->school_id; if (! Arr::has($newData, $key)) {
throw new AuditionAdminException('Missing required data');
} }
if (Student::where('first_name', $firstName)->where('last_name', $lastName) }
->where('school_id', $school_id)->exists()) {
if (! Arr::has($newData, 'school_id')) {
$newData['school_id'] = auth()->user()->school_id;
}
if (Student::where('first_name', $newData['first_name'])->where('last_name', $newData['last_name'])
->where('school_id', $newData['school_id'])->exists()) {
throw new AuditionAdminException('Student already exists'); throw new AuditionAdminException('Student already exists');
} }
$newStudent = Student::create([ return Student::create([
'first_name' => $firstName, 'first_name' => $newData['first_name'],
'last_name' => $lastName, 'last_name' => $newData['last_name'],
'grade' => $grade, 'grade' => $newData['grade'],
'school_id' => $school_id, 'school_id' => $newData['school_id'],
'optional_data' => $optionalData, 'optional_data' => $newData['optional_data'] ?? null,
]); ]);
return $newStudent;
} }
} }

View File

@ -0,0 +1,48 @@
<?php
namespace App\Actions\Students;
use App\Exceptions\AuditionAdminException;
use App\Models\Student;
use Arr;
class UpdateStudent
{
public function __construct()
{
}
/**
* @throws AuditionAdminException
*/
public function __invoke(Student $student, array $newData): bool
{
// $newData[] must include keys first_name, last_name, grade - throw an exception if it does not
foreach (['first_name', 'last_name', 'grade'] as $key) {
if (! Arr::has($newData, $key)) {
throw new AuditionAdminException('Missing required data');
}
}
if (! Arr::has($newData, 'school_id')) {
$newData['school_id'] = auth()->user()->school_id;
}
if (Student::where('first_name', $newData['first_name'])
->where('last_name', $newData['last_name'])
->where('school_id', $newData['school_id'])
->where('id', '!=', $student->id)
->exists()) {
throw new AuditionAdminException('Student already exists');
}
return $student->update([
'first_name' => $newData['first_name'],
'last_name' => $newData['last_name'],
'grade' => $newData['grade'],
'school_id' => $newData['school_id'],
'optional_data' => $newData['optional_data'] ?? null,
]);
}
}

View File

@ -2,9 +2,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\Students\CreateStudent;
use App\Http\Requests\StudentStoreRequest; use App\Http\Requests\StudentStoreRequest;
use App\Models\Audition; use App\Models\Audition;
use App\Models\AuditLogEntry;
use App\Models\Student; use App\Models\Student;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -36,31 +36,19 @@ class StudentController extends Controller
*/ */
public function store(StudentStoreRequest $request) public function store(StudentStoreRequest $request)
{ {
if ($request->user()->cannot('create', Student::class)) { $creator = app(CreateStudent::class);
abort(403); /** @noinspection PhpUnhandledExceptionInspection */
} $creator([
$student = Student::create([
'first_name' => $request['first_name'], 'first_name' => $request['first_name'],
'last_name' => $request['last_name'], 'last_name' => $request['last_name'],
'grade' => $request['grade'], 'grade' => $request['grade'],
'school_id' => Auth::user()->school_id, 'optional_data' => $request->optional_data,
]); ]);
if ($request['shirt_size'] !== 'none') {
$student->update(['optional_data->shirt_size' => $request['shirt_size']]);
}
/** @codeCoverageIgnoreEnd */
return redirect('/students')->with('success', 'Student Created'); return redirect('/students')->with('success', 'Student Created');
} }
/**
* Display the specified resource.
*/
public function show(Request $request, Student $student)
{
//
}
/** /**
* Show the form for editing the specified resource. * Show the form for editing the specified resource.
*/ */
@ -78,52 +66,17 @@ class StudentController extends Controller
/** /**
* Update the specified resource in storage. * Update the specified resource in storage.
*/ */
public function update(Request $request, Student $student) public function update(StudentStoreRequest $request, Student $student)
{ {
if ($request->user()->cannot('update', $student)) { if ($request->user()->cannot('update', $student)) {
abort(403); abort(403);
} }
request()->validate([
'first_name' => ['required'],
'last_name' => ['required'],
'grade' => ['required', 'integer'],
'shirt_size' => [
'nullable',
function ($attribute, $value, $fail) {
if (! array_key_exists($value, Student::$shirtSizes)) {
$fail("The selected $attribute is invalid.");
}
},
],
]);
if (Student::where('first_name', request('first_name'))
->where('last_name', request('last_name'))
->where('school_id', Auth::user()->school_id)
->where('id', '!=', $student->id)
->exists()) {
return redirect()->route('students.edit', $student)->with('error',
'A student with that name already exists at your school.');
}
$student->update([ $student->update([
'first_name' => request('first_name'), 'first_name' => $request['first_name'],
'last_name' => request('last_name'), 'last_name' => $request['last_name'],
'grade' => request('grade'), 'grade' => $request['grade'],
]); 'optional_data' => $request->optional_data,
$student->update(['optional_data->shirt_size' => $request['shirt_size']]);
$message = 'Updated student #'.$student->id.'<br>Name: '.$student->full_name().'<br>Grade: '.$student->grade.'<br>School: '.$student->school->name;
AuditLogEntry::create([
'user' => auth()->user()->email,
'ip_address' => request()->ip(),
'message' => $message,
'affected' => [
'students' => [$student->id],
'schools' => [$student->school_id],
],
]); ]);
return redirect('/students')->with('success', 'Student updated successfully.'); return redirect('/students')->with('success', 'Student updated successfully.');
@ -137,16 +90,7 @@ class StudentController extends Controller
if ($request->user()->cannot('delete', $student)) { if ($request->user()->cannot('delete', $student)) {
abort(403); abort(403);
} }
$message = 'Deleted student #'.$student->id.'<br>Name: '.$student->full_name().'<br>Grade: '.$student->grade.'<br>School: '.$student->school->name;
AuditLogEntry::create([
'user' => auth()->user()->email,
'ip_address' => request()->ip(),
'message' => $message,
'affected' => [
'students' => [$student->id],
'schools' => [$student->school_id],
],
]);
$student->delete(); $student->delete();
return redirect(route('students.index')); return redirect(route('students.index'));

View File

@ -32,6 +32,14 @@ class StudentStoreRequest extends FormRequest
]; ];
} }
// Helper method to get optional data (optional)
public function getOptionalData(): array
{
return ($this->shirt_size !== 'none')
? ['shirt_size' => $this->shirt_size]
: [];
}
public function authorize(): bool public function authorize(): bool
{ {
return true; return true;

View File

@ -5,6 +5,9 @@ namespace App\Observers;
use App\Models\AuditLogEntry; use App\Models\AuditLogEntry;
use App\Models\Student; use App\Models\Student;
use function auth;
use function request;
class StudentObserver class StudentObserver
{ {
/** /**
@ -29,7 +32,16 @@ class StudentObserver
*/ */
public function updated(Student $student): void public function updated(Student $student): void
{ {
// $message = 'Updated student #'.$student->id.' - '.$student->full_name().'<br>Grade: '.$student->grade.'<br>School: '.$student->school->name;
AuditLogEntry::create([
'user' => auth()->user()->email ?? 'none',
'ip_address' => request()->ip(),
'message' => $message,
'affected' => [
'students' => [$student->id],
'schools' => [$student->school_id],
],
]);
} }
/** /**
@ -37,22 +49,15 @@ class StudentObserver
*/ */
public function deleted(Student $student): void public function deleted(Student $student): void
{ {
// $message = 'Deleted student #'.$student->id.' - '.$student->full_name().'<br>Grade: '.$student->grade.'<br>School: '.$student->school->name;
} AuditLogEntry::create([
'user' => auth()->user()->email ?? 'none',
/** 'ip_address' => request()->ip(),
* Handle the Student "restored" event. 'message' => $message,
*/ 'affected' => [
public function restored(Student $student): void 'students' => [$student->id],
{ 'schools' => [$student->school_id],
// ],
} ]);
/**
* Handle the Student "force deleted" event.
*/
public function forceDeleted(Student $student): void
{
//
} }
} }

View File

@ -7,7 +7,6 @@ use App\Http\Controllers\EntryController;
use App\Http\Controllers\PdfInvoiceController; use App\Http\Controllers\PdfInvoiceController;
use App\Http\Controllers\SchoolController; use App\Http\Controllers\SchoolController;
use App\Http\Controllers\StudentController; use App\Http\Controllers\StudentController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
@ -40,7 +39,7 @@ Route::middleware([
'auth', 'verified', 'auth', 'verified',
])->controller(StudentController::class)->group(function () { ])->controller(StudentController::class)->group(function () {
Route::get('/students', 'index')->name('students.index'); Route::get('/students', 'index')->name('students.index');
Route::post('students', 'store')->name('students.store'); Route::post('students', 'store')->middleware(['can:create,App\Models\Student'])->name('students.store');
Route::get('/students/{student}/edit', 'edit')->name('students.edit'); Route::get('/students/{student}/edit', 'edit')->name('students.edit');
Route::patch('/students/{student}', 'update')->name('students.update'); Route::patch('/students/{student}', 'update')->name('students.update');
Route::delete('/students/{student}', 'destroy')->name('students.destroy'); Route::delete('/students/{student}', 'destroy')->name('students.destroy');

View File

@ -16,24 +16,23 @@ beforeEach(function () {
}); });
it('can create a student with basic data', function () { it('can create a student with basic data', function () {
($this->creator)( ($this->creator)([
'John', 'first_name' => 'John',
'Doe', 'last_name' => 'Doe',
8, 'grade' => 8,
[], 'school_id' => School::factory()->create()->id,
School::factory()->create()->id ]);
);
expect(Student::first())->toBeInstanceOf(Student::class); expect(Student::first())->toBeInstanceOf(Student::class);
}); });
it('can include optional data', function () { it('can include optional data', function () {
($this->creator)( ($this->creator)([
'John', 'first_name' => 'John',
'Doe', 'last_name' => 'Doe',
8, 'grade' => 8,
['shirt_size' => 'M'], 'optional_data' => ['shirt_size' => 'M'],
School::factory()->create()->id 'school_id' => School::factory()->create()->id,
); ]);
$student = Student::first(); $student = Student::first();
expect($student->optional_data['shirt_size'])->toEqual('M'); expect($student->optional_data['shirt_size'])->toEqual('M');
}); });
@ -44,30 +43,40 @@ it('uses the current users school if none is specified', function () {
$user->school_id = $school->id; $user->school_id = $school->id;
$user->save(); $user->save();
$this->actingAs($user); $this->actingAs($user);
($this->creator)( ($this->creator)([
'John', 'first_name' => 'John',
'Doe', 'last_name' => 'Doe',
8, 'grade' => 8,
['shirt_size' => 'M'], 'optional_data' => ['shirt_size' => 'M'],
); ]);
$student = Student::first(); $student = Student::first();
expect($student->school_id)->toEqual($school->id); expect($student->school_id)->toEqual($school->id);
}); });
it('throws an error if we try to create a duplicate student (same name and school)', function () { it('throws an error if we try to create a duplicate student (same name and school)', function () {
$school = School::factory()->create(); $school = School::factory()->create();
($this->creator)( ($this->creator)([
'John', 'first_name' => 'John',
'Doe', 'last_name' => 'Doe',
8, 'grade' => 8,
['shirt_size' => 'M'], 'optional_data' => ['shirt_size' => 'M'],
$school->id 'school_id' => $school->id,
); ]);
($this->creator)( ($this->creator)([
'John', 'first_name' => 'John',
'Doe', 'last_name' => 'Doe',
11, 'grade' => 11,
['shirt_size' => 'XL'], 'optional_data' => ['shirt_size' => 'XL'],
$school->id 'school_id' => $school->id,
); ]);
})->throws(AuditionAdminException::class, 'Student already exists'); })->throws(AuditionAdminException::class, 'Student already exists');
it('throws an error if data is missing', function () {
$school = School::factory()->create();
($this->creator)([
'last_name' => 'Doe',
'grade' => 8,
'optional_data' => ['shirt_size' => 'M'],
'school_id' => $school->id,
]);
})->throws(AuditionAdminException::class, 'Missing required data');

View File

@ -0,0 +1,98 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
use App\Actions\Students\CreateStudent;
use App\Actions\Students\UpdateStudent;
use App\Exceptions\AuditionAdminException;
use App\Models\School;
use App\Models\Student;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->creator = app(CreateStudent::class);
$this->editor = app(UpdateStudent::class);
$this->editedStudent = ($this->creator)([
'first_name' => 'John',
'last_name' => 'Doe',
'grade' => 8,
'school_id' => School::factory()->create()->id,
]);
});
it('can update a student with basic data', function () {
($this->editor)($this->editedStudent, [
'first_name' => 'John',
'last_name' => 'Doe',
'grade' => 14,
'school_id' => School::factory()->create()->id,
]);
expect(Student::first()->grade)->toBe(14);
});
it('can include optional data', function () {
($this->editor)($this->editedStudent, [
'first_name' => 'John',
'last_name' => 'Doe',
'grade' => 2,
'optional_data' => ['shirt_size' => 'M'],
'school_id' => School::factory()->create()->id,
]);
$student = Student::first();
expect($student->optional_data['shirt_size'])->toEqual('M')
->and($student->grade)->toBe(2);
});
it('uses the current users school if none is specified', function () {
$user = User::factory()->create();
$school = School::factory()->create();
$user->school_id = $school->id;
$user->save();
$this->actingAs($user);
($this->editor)($this->editedStudent, [
'first_name' => 'Jane',
'last_name' => 'Doe',
'grade' => 8,
'optional_data' => ['shirt_size' => 'M'],
]);
$student = Student::first();
expect($student->school_id)->toEqual($school->id)
->and($student->first_name)->toEqual('Jane');
});
it('throws an error if we try to create a duplicate student (same name and school)', function () {
$school = School::factory()->create();
($this->creator)([
'first_name' => 'John',
'last_name' => 'Doe',
'grade' => 8,
'optional_data' => ['shirt_size' => 'M'],
'school_id' => $school->id,
]);
$student2 = ($this->creator)([
'first_name' => 'Jane',
'last_name' => 'Doe',
'grade' => 11,
'optional_data' => ['shirt_size' => 'XL'],
'school_id' => $school->id,
]);
($this->editor)($student2, [
'first_name' => 'John',
'last_name' => 'Doe',
'grade' => 11,
'school_id' => $school->id,
]);
})->throws(AuditionAdminException::class, 'Student already exists');
it('throws an error if data is missing', function () {
$school = School::factory()->create();
($this->editor)($this->editedStudent, [
'last_name' => 'Doe',
'grade' => 8,
'optional_data' => ['shirt_size' => 'M'],
'school_id' => $school->id,
]);
})->throws(AuditionAdminException::class, 'Missing required data');

View File

@ -8,6 +8,20 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () {
$this->school = School::factory()->create();
$this->submitData = [
'first_name' => 'John',
'last_name' => 'Doe',
'grade' => 8,
];
$this->updateData = [
'first_name' => 'George',
'last_name' => 'Washington',
'grade' => 53,
];
});
describe('Test the index method of the student controller', function () { describe('Test the index method of the student controller', function () {
it('redirects to the dashboard if the user does not have a school', function () { it('redirects to the dashboard if the user does not have a school', function () {
$user = User::factory()->create(); $user = User::factory()->create();
@ -48,5 +62,146 @@ describe('Test the index method of the student controller', function () {
}); });
describe('Test the store method of the student controller', function () { describe('Test the store method of the student controller', function () {
it('redirects to the dashboard if the user does not have a school', function () {
$user = User::factory()->create();
//$user->update(['school_id' => School::factory()->create()->id]);;
$this->actingAs($user);
$response = $this->post(route('students.store'), $this->submitData);
$response->assertStatus(403);
});
it('creates a student with only basic information', function () {
$user = User::factory()->create();
$user->school_id = $this->school->id;
$user->save();
$this->actingAs($user);
$response = $this->post(route('students.store'), $this->submitData);
$response->assertRedirect(route('students.index'));
expect(Student::first())->toBeInstanceOf(Student::class)
->and(Student::first()->first_name)->toEqual('John')
->and(Student::first()->last_name)->toEqual('Doe')
->and(Student::first()->grade)->toEqual(8)
->and(Student::first()->school_id)->toEqual($this->school->id);
});
it('creates a student with optional data', function () {
$user = User::factory()->create();
$user->school_id = $this->school->id;
$user->save();
$this->actingAs($user);
$localData = $this->submitData;
$localData['optional_data'] = ['shirt_size' => 'M'];
$response = $this->post(route('students.store'), $localData);
$response->assertRedirect(route('students.index'));
expect(Student::first()->optional_data['shirt_size'])->toEqual('M');
}); });
it('will not allow a user to create a student for another school', function () {
$otherSchool = School::factory()->create();
$user = User::factory()->create();
$user->school_id = $this->school->id;
$user->save();
$this->actingAs($user);
$localData = $this->submitData;
$localData['school_id'] = $otherSchool->id;
$response = $this->post(route('students.store'), $localData);
expect(Student::first()->school_id)->toBe($this->school->id);
});
});
describe('Test the edit method of the student controller', function () {
it('will not allow a user to edit a student from another school', function () {
$otherStudent = Student::factory()->create();
$user = User::factory()->create();
$user->school_id = $this->school->id;
$user->save();
$this->actingAs($user);
$response = $this->get(route('students.edit', $otherStudent));
$response->assertStatus(403);
});
it('produces an edit for for a student', function () {
$user = User::factory()->create();
$user->school_id = $this->school->id;
$user->save();
$student = Student::factory()->forSchool($this->school)->create();
$this->actingAs($user);
$response = $this->get(route('students.edit', $student));
$response->assertViewIs('students.edit')
->assertViewHas('student', $student)
->assertSee(route('students.update', $student));
});
});
describe('Test the update method of the student controller', function () {
it('will not allow a user to edit a student from another school', function () {
$otherStudent = Student::factory()->create();
$user = User::factory()->create();
$user->school_id = $this->school->id;
$user->save();
$this->actingAs($user);
expect($otherStudent->school_id)->not->toEqual($this->school->id);
$response = $this->patch(route('students.update', $otherStudent), $this->submitData);
$response->assertStatus(403);
});
it('modifies a student with only basic information', function () {
$user = User::factory()->create();
$user->school_id = $this->school->id;
$user->save();
$this->actingAs($user);
$this->post(route('students.store'), $this->submitData);
$response = $this->patch(route('students.update', Student::first()), $this->updateData);
$response->assertRedirect(route('students.index'));
expect(Student::first())->toBeInstanceOf(Student::class)
->and(Student::first()->first_name)->toEqual('George')
->and(Student::first()->last_name)->toEqual('Washington')
->and(Student::first()->grade)->toEqual(53)
->and(Student::first()->school_id)->toEqual($this->school->id);
});
it('modifies a student with optional data', function () {
$user = User::factory()->create();
$user->school_id = $this->school->id;
$user->save();
$this->actingAs($user);
$localData = $this->submitData;
$localData['optional_data'] = ['shirt_size' => 'M'];
$this->post(route('students.store'), $localData);
$localUpdateData = $this->updateData;
$localUpdateData['optional_data'] = ['shirt_size' => 'XL'];
$response = $this->patch(route('students.update', Student::first()), $localUpdateData);
$response->assertRedirect(route('students.index'));
expect(Student::first()->optional_data['shirt_size'])->toEqual('XL');
});
});
describe('Test the destroy method of the student controller', function () {
it('will not allow a user to delete a student from another school', function () {
$otherStudent = Student::factory()->create();
$user = User::factory()->create();
$user->school_id = $this->school->id;
$user->save();
$this->actingAs($user);
expect($otherStudent->school_id)->not->toEqual($this->school->id);
$response = $this->delete(route('students.destroy', $otherStudent));
$response->assertStatus(403);
});
it('allows a user to delete their own students', function () {
$student = Student::factory()->forSchool($this->school)->create();
$user = User::factory()->create();
$user->school_id = $this->school->id;
$user->save();
$this->actingAs($user);
$response = $this->delete(route('students.destroy', $student));
$response->assertRedirect(route('students.index'));
expect(Student::first())->toBeNull();
});
});