refactor: Enhance Livewire components and forms with type hints and authorization checks, and add new feature tests.
This commit is contained in:
parent
c3771287f4
commit
99ca332f1c
@ -51,21 +51,20 @@ public function columns(): array
|
|||||||
if (auth()->user()->can('delete expense')) {
|
if (auth()->user()->can('delete expense')) {
|
||||||
$actions .= view('components.actions.table.delete', [
|
$actions .= view('components.actions.table.delete', [
|
||||||
'id' => $row->hash,
|
'id' => $row->hash,
|
||||||
'deleteRoute' => route('studio.finance.expense.delete', $row->hash),
|
|
||||||
])->render();
|
])->render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $actions;
|
return $actions;
|
||||||
})
|
})
|
||||||
->html()
|
->html(),
|
||||||
->hideIf(auth()->user()->cannot('update expense') && auth()->user()->cannot('delete expense') && auth()->id !== $row->user_id),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function builder(): Builder
|
public function builder(): Builder
|
||||||
{
|
{
|
||||||
return Expense::select('expenses.id', 'expenses.user_id', 'description', 'amount', 'expenses.created_at')
|
return Expense::select('expenses.*')
|
||||||
->whereIn('outlet_id', auth()->user()->outlets->pluck('id')->toArray())
|
->whereIn('outlet_id', auth()->user()->outlets->pluck('id')->toArray())
|
||||||
->with(['outlet', 'user', 'user.employee']);
|
->with(['outlet', 'user', 'user.employee']);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ public function validationAttributes(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setExpense(Expense $expense)
|
public function setExpense(Expense $expense): void
|
||||||
{
|
{
|
||||||
$this->expense = $expense;
|
$this->expense = $expense;
|
||||||
|
|
||||||
@ -54,7 +54,7 @@ public function setExpense(Expense $expense)
|
|||||||
$this->image = $this->mapMediaCollection($expense->getMedia('image'));
|
$this->image = $this->mapMediaCollection($expense->getMedia('image'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store()
|
public function store(): void
|
||||||
{
|
{
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
@ -71,7 +71,7 @@ public function store()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update()
|
public function update(): void
|
||||||
{
|
{
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
|
|||||||
@ -12,14 +12,16 @@
|
|||||||
use App\Traits\WithToast;
|
use App\Traits\WithToast;
|
||||||
use App\Traits\WithUpdatedData;
|
use App\Traits\WithUpdatedData;
|
||||||
use Flux\Flux;
|
use Flux\Flux;
|
||||||
|
use Illuminate\View\View;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
#[Title('Pengeluaran')]
|
#[Title('Pengeluaran')]
|
||||||
class Expense extends Component
|
class Expense extends Component
|
||||||
{
|
{
|
||||||
use WithAuthorization, WithCloseModal, WithConfirmation, WithSubscribeNotification, WithToast, WithUpdatedData;
|
use WithAuthorization, WithCloseModal, WithConfirmation, WithFileUploads, WithSubscribeNotification, WithToast, WithUpdatedData;
|
||||||
|
|
||||||
public ExpenseForm $form;
|
public ExpenseForm $form;
|
||||||
|
|
||||||
@ -31,28 +33,14 @@ class Expense extends Component
|
|||||||
|
|
||||||
public string $modalTitle = '';
|
public string $modalTitle = '';
|
||||||
|
|
||||||
public function mount()
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->outlets = Outlet::pluck('name', 'id')->toArray();
|
$this->outlets = Outlet::pluck('name', 'id')->toArray();
|
||||||
|
|
||||||
$this->userHasMultipleOutlets = auth()->user()->outlets()->count() > 1;
|
$this->userHasMultipleOutlets = auth()->user()->outlets()->count() > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[On('modal:open')]
|
public function create(): void
|
||||||
public function openModal(string $method, string $modalTitle, ?string $id = null)
|
|
||||||
{
|
|
||||||
$this->resetValidation();
|
|
||||||
$this->resetErrorBag();
|
|
||||||
|
|
||||||
$this->method = $method;
|
|
||||||
$this->modalTitle = $modalTitle;
|
|
||||||
|
|
||||||
if ($id) {
|
|
||||||
$this->form->setExpense(ExpenseModel::byHashOrFail($id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create()
|
|
||||||
{
|
{
|
||||||
$this->canOrAbort('create expense');
|
$this->canOrAbort('create expense');
|
||||||
|
|
||||||
@ -65,7 +53,7 @@ public function create()
|
|||||||
Flux::modals()->close();
|
Flux::modals()->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update()
|
public function update(): void
|
||||||
{
|
{
|
||||||
$this->canOrAbort('update expense');
|
$this->canOrAbort('update expense');
|
||||||
|
|
||||||
@ -78,8 +66,10 @@ public function update()
|
|||||||
Flux::modals()->close();
|
Flux::modals()->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(ExpenseModel $expense)
|
public function delete(ExpenseModel $expense): void
|
||||||
{
|
{
|
||||||
|
$this->canOrAbort('delete expense');
|
||||||
|
|
||||||
$expense->delete();
|
$expense->delete();
|
||||||
|
|
||||||
$this->dispatch('refreshDatatable');
|
$this->dispatch('refreshDatatable');
|
||||||
@ -89,7 +79,21 @@ public function delete(ExpenseModel $expense)
|
|||||||
Flux::modals()->close();
|
Flux::modals()->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
#[On('modal:open')]
|
||||||
|
public function openModal(string $method, string $modalTitle, ?string $id = null): void
|
||||||
|
{
|
||||||
|
$this->resetValidation();
|
||||||
|
$this->resetErrorBag();
|
||||||
|
|
||||||
|
$this->method = $method;
|
||||||
|
$this->modalTitle = $modalTitle;
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
$this->form->setExpense(ExpenseModel::byHashOrFail($id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
{
|
{
|
||||||
return view('livewire.studio.finance.expenses', [
|
return view('livewire.studio.finance.expenses', [
|
||||||
'pageTitle' => 'Pengeluaran',
|
'pageTitle' => 'Pengeluaran',
|
||||||
|
|||||||
@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Datatable\Studio\Finance\ExpensesTable;
|
||||||
|
use App\Models\Employee;
|
||||||
|
use App\Models\Expense;
|
||||||
|
use App\Models\Outlet;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
// Setup Permissions if needed, though view might not need them strictly for data loading,
|
||||||
|
// but actions column does check permissions
|
||||||
|
$this->user = User::factory()->has(Employee::factory(), 'employee')->create();
|
||||||
|
$this->outlet = Outlet::factory()->create();
|
||||||
|
$this->user->outlets()->attach($this->outlet);
|
||||||
|
|
||||||
|
// Give permissions to view actions
|
||||||
|
// actions column checks: update expense, delete expense, or own data
|
||||||
|
// So for own data, no perm needed.
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders successfully', function () {
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
Livewire::test(ExpensesTable::class)
|
||||||
|
->assertStatus(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows expenses from user outlets', function () {
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
Expense::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'outlet_id' => $this->outlet->id,
|
||||||
|
'description' => 'My Expense',
|
||||||
|
'amount' => 50000,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ExpensesTable::class)
|
||||||
|
->assertSee('My Expense')
|
||||||
|
->assertSee('50.000'); // Assuming currency formatting adds dot for thousands (ID)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show expenses from other outlets', function () {
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
$otherOutlet = Outlet::factory()->create();
|
||||||
|
$otherUser = User::factory()->has(Employee::factory(), 'employee')->create();
|
||||||
|
$otherUser->outlets()->attach($otherOutlet);
|
||||||
|
|
||||||
|
Expense::factory()->create([
|
||||||
|
'user_id' => $otherUser->id,
|
||||||
|
'outlet_id' => $otherOutlet->id,
|
||||||
|
'description' => 'Other Expense',
|
||||||
|
'amount' => 100000,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ExpensesTable::class)
|
||||||
|
->assertDontSee('Other Expense');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can search by description', function () {
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
Expense::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'outlet_id' => $this->outlet->id,
|
||||||
|
'description' => 'UniqueTerm',
|
||||||
|
'amount' => 12345,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Expense::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'outlet_id' => $this->outlet->id,
|
||||||
|
'description' => 'AnotherItem',
|
||||||
|
'amount' => 67890,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ExpensesTable::class)
|
||||||
|
->set('search', 'UniqueTerm') // Standard search property
|
||||||
|
->assertSee('UniqueTerm')
|
||||||
|
->assertDontSee('AnotherItem');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can search by amount', function () {
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
Expense::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'outlet_id' => $this->outlet->id,
|
||||||
|
'description' => 'Exp1',
|
||||||
|
'amount' => 8888,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Expense::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'outlet_id' => $this->outlet->id,
|
||||||
|
'description' => 'Exp2',
|
||||||
|
'amount' => 9999,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ExpensesTable::class)
|
||||||
|
->set('search', '8888')
|
||||||
|
->assertSee('Exp1')
|
||||||
|
->assertDontSee('Exp2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders columns correctly', function () {
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
Livewire::test(ExpensesTable::class)
|
||||||
|
->assertSee('Pegawai')
|
||||||
|
->assertSee('Keterangan')
|
||||||
|
->assertSee('Jumlah')
|
||||||
|
->assertSee('Outlet')
|
||||||
|
->assertSee('Tanggal')
|
||||||
|
->assertSee('Aksi');
|
||||||
|
});
|
||||||
246
tests/Feature/Livewire/Studio/Finance/ExpenseTest.php
Normal file
246
tests/Feature/Livewire/Studio/Finance/ExpenseTest.php
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Studio\Finance\Expense;
|
||||||
|
use App\Models\Employee;
|
||||||
|
use App\Models\Expense as ExpenseModel;
|
||||||
|
use App\Models\Outlet;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
// Setup Permissions
|
||||||
|
Permission::create(['name' => 'create expense']);
|
||||||
|
Permission::create(['name' => 'update expense']);
|
||||||
|
Permission::create(['name' => 'delete expense']);
|
||||||
|
});
|
||||||
|
|
||||||
|
function createUserWithOutlet(bool $multipleOutlets = false)
|
||||||
|
{
|
||||||
|
$user = User::factory()
|
||||||
|
->has(Employee::factory(), 'employee')
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$outlet1 = Outlet::factory()->create();
|
||||||
|
$user->outlets()->attach($outlet1);
|
||||||
|
|
||||||
|
if ($multipleOutlets) {
|
||||||
|
$outlet2 = Outlet::factory()->create();
|
||||||
|
$user->outlets()->attach($outlet2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign permissions
|
||||||
|
$user->givePermissionTo(['create expense', 'update expense', 'delete expense']);
|
||||||
|
|
||||||
|
return [$user, $outlet1];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders successfully', function () {
|
||||||
|
[$user, $outlet] = createUserWithOutlet();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(Expense::class)
|
||||||
|
->assertStatus(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates required fields', function () {
|
||||||
|
[$user, $outlet] = createUserWithOutlet();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(Expense::class)
|
||||||
|
->set('form.description', '')
|
||||||
|
->set('form.amount', '')
|
||||||
|
->call('create')
|
||||||
|
->assertHasErrors(['form.description', 'form.amount']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates amount must be numeric', function () {
|
||||||
|
[$user, $outlet] = createUserWithOutlet();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(Expense::class)
|
||||||
|
->set('form.amount', 'abc')
|
||||||
|
->call('create')
|
||||||
|
->assertHasErrors(['form.amount']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates outlet_id is required for multi-outlet users', function () {
|
||||||
|
[$user, $outlet] = createUserWithOutlet(true); // Multiple outlets
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(Expense::class)
|
||||||
|
->set('form.description', 'Test Expense')
|
||||||
|
->set('form.amount', '50000')
|
||||||
|
// outlet_id not set
|
||||||
|
->call('create')
|
||||||
|
->assertHasErrors(['form.outlet_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not require manually selecting outlet_id for single-outlet users', function () {
|
||||||
|
[$user, $outlet] = createUserWithOutlet(false);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(Expense::class)
|
||||||
|
->set('form.description', 'Test Expense')
|
||||||
|
->set('form.amount', '50000')
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoErrors(['form.outlet_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create an expense', function () {
|
||||||
|
[$user, $outlet] = createUserWithOutlet();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(Expense::class)
|
||||||
|
->set('form.description', 'Buy Coffee')
|
||||||
|
->set('form.amount', '25000')
|
||||||
|
->call('create')
|
||||||
|
->assertDispatched('refreshDatatable');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('expenses', [
|
||||||
|
'description' => 'Buy Coffee',
|
||||||
|
'amount' => 25000,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'outlet_id' => $outlet->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can create an expense with image', function () {
|
||||||
|
[$user, $outlet] = createUserWithOutlet();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$image = UploadedFile::fake()->image('receipt.jpg');
|
||||||
|
// The WithMediaHandler contract seems to expect an array with 'path', not raw file object
|
||||||
|
$imageData = [['path' => $image->getPathname()]];
|
||||||
|
|
||||||
|
Livewire::test(Expense::class)
|
||||||
|
->set('form.description', 'Buy Coffee with Receipt')
|
||||||
|
->set('form.amount', '30000')
|
||||||
|
->set('form.image', $imageData)
|
||||||
|
->call('create');
|
||||||
|
|
||||||
|
$expense = ExpenseModel::where('description', 'Buy Coffee with Receipt')->first();
|
||||||
|
expect($expense)->not->toBeNull();
|
||||||
|
// Assuming media library is set up, verify media count or existence
|
||||||
|
// Note: In-memory/testing sqlite might behave differently with media library if not fully mocked,
|
||||||
|
// but basic relation check is good.
|
||||||
|
expect($expense->getMedia('image'))->toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can update an expense', function () {
|
||||||
|
[$user, $outlet] = createUserWithOutlet();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$expense = ExpenseModel::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'outlet_id' => $outlet->id,
|
||||||
|
'description' => 'Old Description',
|
||||||
|
'amount' => 10000,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Expense::class)
|
||||||
|
->call('openModal', 'update', 'Edit Expense', $expense->hash)
|
||||||
|
->assertSet('form.description', 'Old Description')
|
||||||
|
->set('form.description', 'New Description')
|
||||||
|
->set('form.amount', '15000') // Updating amount
|
||||||
|
->call('update')
|
||||||
|
->assertDispatched('refreshDatatable');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('expenses', [
|
||||||
|
'id' => $expense->id,
|
||||||
|
'description' => 'New Description',
|
||||||
|
'amount' => 15000,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can update an expense image', function () {
|
||||||
|
[$user, $outlet] = createUserWithOutlet();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$expense = ExpenseModel::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'outlet_id' => $outlet->id,
|
||||||
|
'description' => 'Expense with Image',
|
||||||
|
'amount' => 10000,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Initial image upload (simulated via factory or direct DB check if needed,
|
||||||
|
// but here we just test REPLACING or ADDING via update)
|
||||||
|
|
||||||
|
$newImage = UploadedFile::fake()->image('new_receipt.jpg');
|
||||||
|
$imageData = [['path' => $newImage->getPathname()]];
|
||||||
|
|
||||||
|
Livewire::test(Expense::class)
|
||||||
|
->call('openModal', 'update', 'Edit Expense', $expense->hash)
|
||||||
|
->set('form.image', $imageData)
|
||||||
|
->call('update');
|
||||||
|
|
||||||
|
$expense->refresh();
|
||||||
|
expect($expense->getMedia('image'))->toHaveCount(1);
|
||||||
|
// In a real scenario we'd check filename, but hash names make it tricky in tests without mocking 'fake'
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can delete an expense', function () {
|
||||||
|
[$user, $outlet] = createUserWithOutlet();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$expense = ExpenseModel::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'outlet_id' => $outlet->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Expense::class)
|
||||||
|
->call('delete', $expense->hash)
|
||||||
|
->assertDispatched('refreshDatatable');
|
||||||
|
|
||||||
|
$this->assertSoftDeleted('expenses', [
|
||||||
|
'id' => $expense->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AUTHORIZATION TESTS
|
||||||
|
|
||||||
|
it('checks authorization for create', function () {
|
||||||
|
[$user, $outlet] = createUserWithOutlet();
|
||||||
|
$user->revokePermissionTo('create expense');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(Expense::class)
|
||||||
|
->call('create')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks authorization for update', function () {
|
||||||
|
[$user, $outlet] = createUserWithOutlet();
|
||||||
|
$user->revokePermissionTo('update expense');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$expense = ExpenseModel::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'outlet_id' => $outlet->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Expense::class)
|
||||||
|
->call('openModal', 'update', 'Edit', $expense->hash) // openModal doesn't check perm, update() does
|
||||||
|
->call('update')
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks authorization for delete', function () {
|
||||||
|
[$user, $outlet] = createUserWithOutlet();
|
||||||
|
$user->revokePermissionTo('delete expense');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$expense = ExpenseModel::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'outlet_id' => $outlet->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(Expense::class)
|
||||||
|
->call('delete', $expense->hash)
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user