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:
- Will create the CRUD for activities.
- Will make it work for both
company owner
andadministrator
users. - Will add authorization for activities using Policies.
- Will write tests.
Activities CRUD Actions
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>
Authorization
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); // ... }}
Showing Activities for Administrator User
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>// ...
Tests
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:
- For the activities list: test that the company owner can see only their company's activities and cannot see other companies.
- For create, edit, and delete: it's the same, and we have already written similar tests in the
CompanyGuideTest
. - In the create and edit, the list of the guides should be visible only from that company.
- Image upload shouldn't allow uploading non-image files.
- Administrator user can perform every CRUD action.
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.
Having a bit of an issue with your use statement and the activity::class it seems to have a mismatch,
Yes, correct, well spotted! Fixed now.
Sorry but I just realized that you never created the Activity Model or at least I can’t find it.
I can see where you added
All the database and models have been created in the first lesson.
The existing price values are divided by 100 in front-end as you mention after adding accesor/mutator. Creating new activities, the price value is multiplied by 100 and saved in the DB, but in front-end the value is the same as the one entered. what is the goal for the new values??
Question since I am using the Uuid for the user and the user is being referenced in the guide_id and again in the StoreActivityRequest.php file do I need to replace it with the foreignUuid ?
Yes, most likely.
For some reason the below test is throwing an error and i cant seem to figure out why.
public function test_can_upload_image() { Storage::fake('activities');
I get the follwing error
FAILED Tests\Feature\CompanyActivityTest > can upload image
Unable to find a file or directory at path [o6Kjk8xnngz9MFO2D5E3DwJcneAEp3xpPXy23HPA.jpg]. Failed asserting that false is true.
at vendor/laravel/framework/src/Illuminate/Filesystem/FilesystemAdapter.php:119 115▕ 116▕ $paths = Arr::wrap($path); 117▕ 118▕ foreach ($paths as $path) { ➜ 119▕ PHPUnit::assertTrue( 120▕ $this->exists($path), "Unable to find a file or directory at path [{$path}]." 121▕ ); 122▕ 123▕ if (! is_null($content)) {
2 tests/Feature/CompanyActivityTest.php:86
With
Storage::fake('activities')
we would fake a so-called disk named 'activities'. In ourfilesystems.php
there's no disk declared as 'activities'.In our
CompanyActivityController
we're saving to the subdirectory 'activities' on 'public' disk, so for making our test work, we should fake this disk:Also use this disk then for the assertions:
Hi all,
Awesome tutorial, I'm learning a bunch of new stuff, however I can get pass testing phase in this step. From 14 tests ony 4 are passing, and I see 2 recurring issues:
and
Have I missed some steps (I have been doing this for the last few hours, and it is a posibility, although, I went with copy/past last time to ensure no typing error sneaks in) ?
For the first error you are missing the
activities()
relationship on theCompany
model. For the second, maybe you are missingHasFactory
trait on the model.I understood that, but I'm unable to find the step in this tutorial where it sais I should do that.
The second issue us due to not having an import declared on ActivityFactory.php because, again is not mentioned anywhere in here that is should be.
I'm down to my last issue, and this one I have no clue on how to solve it:
Any suggestion on how I can overcome this obstacle?
Much appreciated!
When you create a model using an artisan command the trait is always added. Also, when using factories you must know about this trait.
I did create the model using artisan. I was not aware that completing this cours has a pre-requisite on knowing stuff (that I don't know) by heart especially as I did not worked with factorie untill now.