In this lesson, let's build a new feature for managing activities and assigning guides to them.
Here's the list of topics that we'll cover below:
company owner
and administrator
users.Again, we can only create CRUD with Controller and Routes. The Controller here will also be nested so that we will have the URLs like /companies/1/activities
.
php artisan make:controller CompanyActivityController
routes/web.php:
use App\Http\Controllers\CompanyActivityController; Route::middleware('auth')->group(function () { // ... Route::resource('companies.activities', CompanyActivityController::class); });
Notice: this is not the first CRUD where we use Nested Controllers, but it's not the only way. In this case, it's just my personal preference to implement multi-tenancy this way, to access records only with their company ID in the URL. You may build CRUDs differently, with URLs like /activities
, /guides
, and others, and check company_id
in another way, like using Global Scopes with the auth()->user()->company_id
value.
As for the navigation, we will add the Activities
link after the Guides
.
resources/views/layouts/navigation.blade.php:
// ...<!-- Navigation Links --><div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex"> <x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')"> {{ __('Dashboard') }} </x-nav-link> @if(auth()->user()->role_id === \App\Enums\Role::ADMINISTRATOR->value) <x-nav-link :href="route('companies.index')" :active="request()->routeIs('companies.index')"> {{ __('Companies') }} </x-nav-link> @endif @if(auth()->user()->role_id === \App\Enums\Role::COMPANY_OWNER->value) <x-nav-link :href="route('companies.users.index', auth()->user()->company_id)" :active="request()->routeIs('companies.users.*')"> {{ __('Administrators') }} </x-nav-link> <x-nav-link :href="route('companies.guides.index', auth()->user()->company_id)" :active="request()->routeIs('companies.guides.*')"> {{ __('Guides') }} </x-nav-link> <x-nav-link :href="route('companies.activities.index', auth()->user()->company_id)" :active="request()->routeIs('companies.activities.*')"> {{ __('Activities') }} </x-nav-link> @endif</div>// ...
For the validation, again, the Form Requests.
php artisan make:request StoreActivityRequestphp artisan make:request UpdateActivityRequest
app/Http/Requests/StoreActivityRequest.php:
class StoreActivityRequest extends FormRequest{ public function authorize(): bool { return true; } public function rules(): array { return [ 'name' => ['required'], 'description' => ['required'], 'start_time' => ['required', 'date'], 'price' => ['required', 'numeric'], 'image' => ['image', 'nullable'], 'guide_id' => ['required', 'exists:users,id'], ]; }}
app/Http/Requests/UpdateActivityRequest.php:
class UpdateActivityRequest extends FormRequest{ public function authorize(): bool { return true; } public function rules(): array { return [ 'name' => ['required'], 'description' => ['required'], 'start_time' => ['required', 'date'], 'price' => ['required', 'numeric'], 'image' => ['image', 'nullable'], 'guide_id' => ['required', 'exists:users,id'], ]; }}
Now we can add code to the Controller, show the list of activities, and create and edit forms.
For storing Blade files, we will use the same structure as in the past lessons, the resources/views/companies/activities
directory.
For the photo, for now, it will be just a simple upload stored on a public disk, without any more complex file manipulations or external packages.
app/Http/Controllers/CompanyActivityController.php:
use App\Enums\Role;use App\Models\User;use App\Models\Company;use App\Models\Activity;use Illuminate\Support\Facades\Gate;use Illuminate\Support\Facades\Storage;use App\Http\Requests\StoreActivityRequest;use App\Http\Requests\UpdateActivityRequest; class CompanyActivityController extends Controller{ public function index(Company $company) { $company->load('activities'); return view('companies.activities.index', compact('company')); } public function create(Company $company) { $guides = User::where('company_id', $company->id) ->where('role_id', Role::GUIDE->value) ->pluck('name', 'id'); return view('companies.activities.create', compact('guides', 'company')); } public function store(StoreActivityRequest $request, Company $company) { if ($request->hasFile('image')) { $path = $request->file('image')->store('activities', 'public'); } $activity = Activity::create($request->validated() + [ 'company_id' => $company->id, 'photo' => $path ?? null, ]); return to_route('companies.activities.index', $company); } public function edit(Company $company, Activity $activity) { Gate::authorize('update', $company); $guides = User::where('company_id', $company->id) ->where('role_id', Role::GUIDE->value) ->pluck('name', 'id'); return view('companies.activities.edit', compact('guides', 'activity', 'company')); } public function update(UpdateActivityRequest $request, Company $company, Activity $activity) { if ($request->hasFile('image')) { $path = $request->file('image')->store('activities', 'public'); if ($activity->photo) { Storage::disk('public')->delete($activity->photo); } } $activity->update($request->validated() + [ 'photo' => $path ?? $activity->photo, ]); return to_route('companies.activities.index', $company); } public function destroy(Company $company, Activity $activity) { $activity->delete(); return to_route('companies.activities.index', $company); }}
resources/views/companies/activities/index.blade.php:
<x-app-layout> <x-slot name="header"> <h2 class="text-xl font-semibold leading-tight text-gray-800"> {{ __('Activities') }} </h2> </x-slot> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg"> <div class="overflow-hidden overflow-x-auto border-b border-gray-200 bg-white p-6"> <a href="{{ route('companies.activities.create', $company) }}" class="mb-4 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 shadow-sm transition duration-150 ease-in-out hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25"> Create </a> <div class="min-w-full align-middle"> <table class="min-w-full border divide-y divide-gray-200"> <thead> <tr> <th class="bg-gray-50 px-6 py-3 text-left"> <span class="text-xs font-medium uppercase leading-4 tracking-wider text-gray-500"></span> </th> <th class="bg-gray-50 px-6 py-3 text-left"> <span class="text-xs font-medium uppercase leading-4 tracking-wider text-gray-500">Name</span> </th> <th class="bg-gray-50 px-6 py-3 text-left"> <span class="text-xs font-medium uppercase leading-4 tracking-wider text-gray-500">Start time</span> </th> <th class="bg-gray-50 px-6 py-3 text-left"> <span class="text-xs font-medium uppercase leading-4 tracking-wider text-gray-500">Price</span> </th> <th class="w-96 bg-gray-50 px-6 py-3 text-left"> </th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200 divide-solid"> @foreach($company->activities as $activity) <tr class="bg-white"> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> @if($activity->photo) <img src="{{ asset($activity->photo) }}" alt="{{ $activity->name }}" class="w-16 h-16 rounded-xl"> @endif </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $activity->name }} </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $activity->start_time }} </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $activity->price }} </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> <a href="{{ route('companies.activities.edit', [$company, $activity]) }}" class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 shadow-sm transition duration-150 ease-in-out hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25"> Edit </a> <form action="{{ route('companies.activities.destroy', [$company, $activity]) }}" method="POST" onsubmit="return confirm('Are you sure?')" style="display: inline-block;"> @csrf @method('DELETE') <x-danger-button> Delete </x-danger-button> </form> </td> </tr> @endforeach </tbody> </table> </div> </div> </div> </div> </div></x-app-layout>
Because we are saving the price in cents, before adding value to the DB, we need to multiply it by 100. And the other way around: to show the correct value in the front end, we need to divide by 100. We will use Accessors & Mutators for this.
app/Models/Activity.php:
use Illuminate\Database\Eloquent\Casts\Attribute; class Activity extends Model{ // ... public function price(): Attribute { return Attribute::make( get: fn($value) => $value / 100, set: fn($value) => $value * 100, ); }}
resources/views/companies/activities/create.blade.php:
<x-app-layout> <x-slot name="header"> <h2 class="text-xl font-semibold leading-tight text-gray-800"> {{ __('Create Activity') }} </h2> </x-slot> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg"> <div class="overflow-hidden overflow-x-auto border-b border-gray-200 bg-white p-6"> <form action="{{ route('companies.activities.store', $company) }}" method="POST" enctype="multipart/form-data"> @csrf <div> <x-input-label for="name" value="Name" /> <x-text-input id="name" name="name" value="{{ old('name') }}" type="text" class="mt-1 block w-full" /> <x-input-error :messages="$errors->get('name')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="description" value="Description" /> <textarea id="description" name="description" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">{{ old('description') }}</textarea> <x-input-error :messages="$errors->get('description')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="start_time" value="Start time" /> <x-text-input id="start_time" name="start_time" value="{{ old('start_time') }}" type="datetime-local" class="mt-1 block w-full" /> <x-input-error :messages="$errors->get('start_time')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="price" value="Price" /> <x-text-input id="price" name="price" value="{{ old('price') }}" type="number" step="0.01" class="mt-1 block w-full" /> <x-input-error :messages="$errors->get('price')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="image" value="Photo" /> <x-text-input id="image" name="image" type="file" class="mt-1 block w-full" /> <x-input-error :messages="$errors->get('image')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="guide_id" value="Guides" /> <select name="guide_id" id="guide_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"> <option>-- SELECT GUIDE --</option> @foreach($guides as $id => $name) <option value="{{ $id }}" @selected(old('guide_id') == $id)>{{ $name }}</option> @endforeach </select> <x-input-error :messages="$errors->get('guide_id')" class="mt-2" /> </div> <div class="mt-4"> <x-primary-button> Save </x-primary-button> </div> </form> </div> </div> </div> </div></x-app-layout>
resources/views/companies/activities/edit.blade.php:
<x-app-layout> <x-slot name="header"> <h2 class="text-xl font-semibold leading-tight text-gray-800"> {{ __('Edit Activity') }} </h2> </x-slot> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg"> <div class="overflow-hidden overflow-x-auto border-b border-gray-200 bg-white p-6"> <form action="{{ route('companies.activities.update', [$company, $activity]) }}" method="POST" enctype="multipart/form-data"> @csrf @method('PUT') <div> <x-input-label for="name" value="Name" /> <x-text-input id="name" name="name" value="{{ old('name', $activity->name) }}" type="text" class="mt-1 block w-full" /> <x-input-error :messages="$errors->get('name')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="description" value="Description" /> <textarea id="description" name="description" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">{{ old('description', $activity->description) }}</textarea> <x-input-error :messages="$errors->get('description')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="start_time" value="Start time" /> <x-text-input id="start_time" name="start_time" value="{{ old('start_time', $activity->start_time) }}" type="datetime-local" class="mt-1 block w-full" /> <x-input-error :messages="$errors->get('start_time')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="price" value="Price" /> <x-text-input id="price" name="price" value="{{ old('price', $activity->price) }}" type="number" step="0.01" class="mt-1 block w-full" /> <x-input-error :messages="$errors->get('price')" class="mt-2" /> </div> <div class="mt-4"> @if($activity->photo) <img src="{{ asset($activity->photo) }}" alt="{{ $activity->name }}" class="mb-4 h-48 w-48 rounded-xl"> @endif <x-input-label for="image" value="Photo" /> <x-text-input id="image" name="image" type="file" class="mt-1 block w-full" /> <x-input-error :messages="$errors->get('image')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="guide_id" value="Guides" /> <select name="guide_id" id="guide_id" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"> <option>-- SELECT GUIDE --</option> @foreach($guides as $id => $name) <option value="{{ $id }}" @selected(old('guide_id', $activity->guide_id) === $id)>{{ $name }}</option> @endforeach </select> <x-input-error :messages="$errors->get('guide_id')" class="mt-2" /> </div> <div class="mt-4"> <x-primary-button> Save </x-primary-button> </div> </form> </div> </div> </div> </div></x-app-layout>
Of course, only hiding the navigation isn't secure enough. To keep consistency, we will use Policies. Let's create a Policy and register it.
php artisan make:policy CompanyActivityPolicy --model=Activity
app/Provides/AppServiceProvider.php:
use App\Models\Activity;use Illuminate\Support\Facades\Gate;use App\Policies\CompanyActivityPolicy; class AppServiceProvider extends ServiceProvider{ // ... public function boot(): void { Gate::policy(Company::class, CompanyUserPolicy::class); Gate::policy(Activity::class, CompanyActivityPolicy::class); }}
In the Policy, we will check if the user has the Company Owner
role and is doing the action for his company.
But for the administrator
role, we just need to allow everything as we did in the CompanyUserPolicy
. So, we will use the before
Policy Filter method again. In this method, we will return true
if the user has the role of administrator
.
app/Policies/ActivityPolicity:
use App\Enums\Role;use App\Models\Company;use App\Models\Activity;use App\Models\User; class CompanyActivityPolicy{ public function before(User $user): bool|null { if ($user->role_id === Role::ADMINISTRATOR->value) { return true; } return null; } public function viewAny(User $user, Company $company): bool { return $user->role_id === Role::COMPANY_OWNER->value && $user->company_id === $company->id; } public function create(User $user, Company $company): bool { return $user->role_id === Role::COMPANY_OWNER->value && $user->company_id === $company->id; } public function update(User $user, Activity $activity): bool { return $user->role_id === Role::COMPANY_OWNER->value && $user->company_id === $activity->company_id; } public function delete(User $user, Activity $activity): bool { return $user->role_id === Role::COMPANY_OWNER->value && $user->company_id === $activity->company_id; }}
Next, in the CompanyActivityController
, we must do the authorize
check for each CRUD action. Again, there are a couple of ways to do that, but I will use the authorize
method via Gate
for consistency.
app/Http/Controllers/ActivityController.php:
class CompanyActivityController extends Controller{ public function index(Company $company) { Gate::authorize('viewAny', $company); // ... } public function create(Company $company) { Gate::authorize('create', $company); // ... } public function store(StoreActivityRequest $request, Company $company) { Gate::authorize('create', $company); // ... } public function edit(Company $company, Activity $activity) { Gate::authorize('update', $company); // ... } public function update(UpdateActivityRequest $request, Company $company, Activity $activity) { Gate::authorize('update', $company); // ... } public function destroy(Company $company, Activity $activity) { Gate::authorize('delete', $company); // ... }}
For showing the activities of every company for the administrator
user, we already have made all the logic. We just need to add a link to the Companies list so administrators can access that page.
resources/views/companies/index.blade.php:
// ...<td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> <a href="{{ route('companies.users.index', $company) }}" class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 shadow-sm transition duration-150 ease-in-out hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25"> Users </a> <a href="{{ route('companies.activities.index', $company) }}" class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 shadow-sm transition duration-150 ease-in-out hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25"> Activities </a> {{-- ... Edit/Delete buttons --}}</td>// ...
As with every feature, we add tests, no exception here. First, because we are working with the Activity
Model, we need to create a Factory for it.
php artisan make:factory ActivityFactory
app/database/factories/ActivityFactory.php:
use App\Models\Company;use Illuminate\Support\Carbon; class ActivityFactory extends Factory{ public function definition(): array { return [ 'company_id' => Company::factory(), 'guide_id' => User::factory()->guide(), 'name' => fake()->name(), 'description' => fake()->text(), 'start_time' => Carbon::now(), 'price' => fake()->randomNumber(5), ]; }}
Now the test.
php artisan make:test CompanyActivityTest
So, what do we need to test here? Permissions, probably: the Company Owner
needs to perform every action only for their company.
So, we need to test the following:
CompanyGuideTest
.tests/Feature/ActivityTest.php:
use Tests\TestCase;use App\Models\User;use App\Models\Company;use App\Models\Activity;use Illuminate\Http\UploadedFile;use Illuminate\Support\Collection;use Illuminate\Support\Facades\Storage;use Illuminate\Foundation\Testing\RefreshDatabase; class CompanyActivityTest extends TestCase{ use RefreshDatabase; public function test_company_owner_can_view_activities_page() { $company = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $response = $this->actingAs($user)->get(route('companies.activities.index', $company, $company)); $response->assertOk(); } public function test_company_owner_can_see_only_his_companies_activities() { $company = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $activity = Activity::factory()->create(['company_id' => $company->id]); $activity2 = Activity::factory()->create(); $response = $this->actingAs($user)->get(route('companies.activities.index', $company)); $response->assertSeeText($activity->name) ->assertDontSeeText($activity2->name); } public function test_company_owner_can_create_activity() { $company = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $guide = User::factory()->guide()->create(); $response = $this->actingAs($user)->post(route('companies.activities.store', $company), [ 'name' => 'activity', 'description' => 'description', 'start_time' => '2023-09-01 10:00', 'price' => 9999, 'guide_id' => $guide->id, ]); $response->assertRedirect(route('companies.activities.index', $company)); $this->assertDatabaseHas('activities', [ 'company_id' => $company->id, 'guide_id' => $guide->id, 'name' => 'activity', 'description' => 'description', 'start_time' => '2023-09-01 10:00', 'price' => 999900, ]); } public function test_can_upload_image() { Storage::fake('public'); $company = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $guide = User::factory()->guide()->create(); $file = UploadedFile::fake()->image('avatar.jpg'); $this->actingAs($user)->post(route('companies.activities.store', $company), [ 'name' => 'activity', 'description' => 'description', 'start_time' => '2023-09-01 10:00', 'price' => 9999, 'guide_id' => $guide->id, 'image' => $file, ]); Storage::disk('public')->assertExists('activities/' . $file->hashName()); } public function test_cannon_upload_non_image_file() { Storage::fake('public'); $company = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $guide = User::factory()->guide()->create(); $file = UploadedFile::fake()->create('document.pdf', 2000, 'application/pdf'); $response = $this->actingAs($user)->post(route('companies.activities.store', $company), [ 'name' => 'activity', 'description' => 'description', 'start_time' => '2023-09-01 10:00', 'price' => 9999, 'guides' => $guide->id, 'image' => $file, ]); $response->assertSessionHasErrors(['image']); Storage::disk('public')->assertMissing('activities/' . $file->hashName()); } public function test_guides_are_shown_only_for_specific_company_in_create_form() { $company = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $guide = User::factory()->guide()->create(['company_id' => $company->id]); $company2 = Company::factory()->create(); $guide2 = User::factory()->guide()->create(['company_id' => $company2->id]); $response = $this->actingAs($user)->get(route('companies.activities.create', $company)); $response->assertViewHas('guides', function (Collection $guides) use ($guide) { return $guide->name === $guides[$guide->id]; }); $response->assertViewHas('guides', function (Collection $guides) use ($guide2) { return ! array_key_exists($guide2->id, $guides->toArray()); }); } public function test_guides_are_shown_only_for_specific_company_in_edit_form() { $company = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $guide = User::factory()->guide()->create(['company_id' => $company->id]); $activity = Activity::factory()->create(['company_id' => $company->id]); $company2 = Company::factory()->create(); $guide2 = User::factory()->guide()->create(['company_id' => $company2->id]); $response = $this->actingAs($user)->get(route('companies.activities.edit', [$company, $activity])); $response->assertViewHas('guides', function (Collection $guides) use ($guide) { return $guide->name === $guides[$guide->id]; }); $response->assertViewHas('guides', function (Collection $guides) use ($guide2) { return ! array_key_exists($guide2->id, $guides->toArray()); }); } public function test_company_owner_can_edit_activity() { $company = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $guide = User::factory()->guide()->create(); $activity = Activity::factory()->create(['company_id' => $company->id]); $response = $this->actingAs($user)->put(route('companies.activities.update', [$company, $activity]), [ 'name' => 'activity', 'description' => 'description', 'start_time' => '2023-09-01 10:00', 'price' => 9999, 'guide_id' => $guide->id, ]); $response->assertRedirect(route('companies.activities.index', $company)); $this->assertDatabaseHas('activities', [ 'company_id' => $company->id, 'guide_id' => $guide->id, 'name' => 'activity', 'description' => 'description', 'start_time' => '2023-09-01 10:00', 'price' => 999900, ]); } public function test_company_owner_cannot_edit_activity_for_other_company() { $company = Company::factory()->create(); $company2 = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $guide = User::factory()->guide()->create(); $activity = Activity::factory()->create(['company_id' => $company2->id]); $response = $this->actingAs($user)->put(route('companies.activities.update', [$company2, $activity]), [ 'name' => 'activity', 'description' => 'description', 'start_time' => '2023-09-01 10:00', 'price' => 9999, 'guide_id' => $guide->id, ]); $response->assertForbidden(); } public function test_company_owner_can_delete_activity() { $company = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $activity = Activity::factory()->create(['company_id' => $company->id]); $response = $this->actingAs($user)->delete(route('companies.activities.destroy', [$company, $activity])); $response->assertRedirect(route('companies.activities.index', $company)); $this->assertModelMissing($activity); } public function test_company_owner_cannot_delete_activity_for_other_company() { $company = Company::factory()->create(); $company2 = Company::factory()->create(); $user = User::factory()->companyOwner()->create(['company_id' => $company->id]); $activity = Activity::factory()->create(['company_id' => $company2->id]); $response = $this->actingAs($user)->delete(route('companies.activities.destroy', [$company2, $activity])); $this->assertModelExists($activity); $response->assertForbidden(); } public function test_admin_can_view_companies_activities() { $company = Company::factory()->create(); $user = User::factory()->admin()->create(); $response = $this->actingAs($user)->get(route('companies.activities.index', $company, $company)); $response->assertOk(); } public function test_admin_can_create_activity_for_company() { $company = Company::factory()->create(); $user = User::factory()->admin()->create(); $guide = User::factory()->guide()->create(); $response = $this->actingAs($user)->post(route('companies.activities.store', $company->id), [ 'name' => 'activity', 'description' => 'description', 'start_time' => '2023-09-01 10:00', 'price' => 9999, 'guide_id' => $guide->id, ]); $response->assertRedirect(route('companies.activities.index', $company->id)); $this->assertDatabaseHas('activities', [ 'company_id' => $company->id, 'guide_id' => $guide->id, 'name' => 'activity', 'description' => 'description', 'start_time' => '2023-09-01 10:00', 'price' => 999900, ]); } public function test_admin_can_edit_activity_for_company() { $company = Company::factory()->create(); $user = User::factory()->admin()->create(); $guide = User::factory()->guide()->create(); $activity = Activity::factory()->create(['company_id' => $company->id]); $response = $this->actingAs($user)->put(route('companies.activities.update', [$company, $activity]), [ 'name' => 'activity', 'description' => 'description', 'start_time' => '2023-09-01 10:00', 'price' => 9999, 'guide_id' => $guide->id, ]); $response->assertRedirect(route('companies.activities.index', $company)); $this->assertDatabaseHas('activities', [ 'company_id' => $company->id, 'guide_id' => $guide->id, 'name' => 'activity', 'description' => 'description', 'start_time' => '2023-09-01 10:00', 'price' => 999900, ]); }}
I think we created enough functionality to show it to the client and see what they say so far. Such a feedback loop is very important: the earlier we learn about the changes, the less code refactoring we need.
In the next lesson, we will make changes according to the client's feedback.