Courses

Laravel 11: Small Reservation Project Step-By-Step

Manage Activities and Assign Guides

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 and administrator 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>
// ...

activities navigation link

For the validation, again, the Form Requests.

php artisan make:request StoreActivityRequest
php 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>

activities list

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>

create activity

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>

edit activity


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,
]);
}
}

activities tests

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.

Previous: Managing Guides
avatar

Having a bit of an issue with your use statement and the activity::class it seems to have a mismatch,


With the Activity::class being 
Activity::class => CompanyActivityPolicy::class,
and the use I thank you want to be the following:
use App\Policies\CompanyActivityPolicy;
not 
use App\Policies\ActivityPolicy;
Right?

avatar

Yes, correct, well spotted! Fixed now.

avatar

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


 public function price(): Attribute
    {
        return Attribute::make(
            get: fn($value) => $value / 100,
            set: fn($value) => $value * 100,
        );
    }

	to it but not where you carated it in the first place.
avatar

All the database and models have been created in the first lesson.

avatar

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??

avatar
You can use Markdown
avatar

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 ?

avatar

Yes, most likely.

avatar
You can use Markdown
avatar

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

    $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('activities')->assertExists($file->hashName());
    Storage::disk('activities')->assertExists('thumbs/'.$file->hashName());
}

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)) {

  +1 vendor frames 

2 tests/Feature/CompanyActivityTest.php:86

👍 3
avatar

With Storage::fake('activities') we would fake a so-called disk named 'activities'. In our filesystems.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:

Storage::fake('public');

Also use this disk then for the assertions:

Storage::disk('public')->assertExists('/activities/'.$file->hashName());
avatar
You can use Markdown
avatar

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:

Call to undefined relationship [activities] on model [App\Models\Company].

and

FAILED Tests\Feature\CompanyActivityTest > admin can edit activity for company ────────────────────────────────────────────────
Error Error
Class "Database\Factories\Factory" not found

at database\factories\ActivityFactory.php:9 5▕ use App\Models\Company; 6▕ use Illuminate\Support\Carbon; 7▕ 8▕ ➜ 9▕ class ActivityFactory extends Factory 10▕ { 11▕ public function definition(): array 12▕ { 13▕ return [

1 database\factories\ActivityFactory.php:9 2 vendor\laravel\framework\src\Illuminate\Database\Eloquent\Factories\Factory.php:829

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) ?

avatar

For the first error you are missing the activities() relationship on the Company model. For the second, maybe you are missing HasFactory trait on the model.

avatar

I understood that, but I'm unable to find the step in this tutorial where it sais I should do that.

avatar

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.

use Illuminate\Database\Eloquent\Factories\Factory;

I'm down to my last issue, and this one I have no clue on how to solve it:

FAILED Tests\Feature\CompanyActivityTest > guides are shown only for specific company in edit form AssertionFailedError
The response is not a view.

at tests\Feature\CompanyActivityTest.php:147 143▕ $guide2 = User::factory()->guide()->create(['company_id' => $company2->id]); 144▕ 145▕ $response = $this->actingAs($user)->get(route('companies.activities.edit', [$company, $activity])); 146▕ ➜ 147▕ $response->assertViewHas('guides', function (Collection $guides) use ($guide) { 148▕ return $guide->name === $guides[$guide->id]; 149▕ }); 150▕ 151▕ $response->assertViewHas('guides', function (Collection $guides) use ($guide2) {

Tests: 1 failed, 13 passed (29 assertions) Duration: 3.92s

Any suggestion on how I can overcome this obstacle?

Much appreciated!

avatar

When you create a model using an artisan command the trait is always added. Also, when using factories you must know about this trait.

avatar

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.

avatar
You can use Markdown
avatar
You can use Markdown