From c037accd336ef3630cc668238e456d7280507fd4 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 11 Dec 2025 09:35:39 +0700 Subject: [PATCH] feat(product): implementasi testing - kasih return type - ubah array to collection di audit - mengubah waktu refresh tabel menjadi 30 detik --- .../Studio/Catalog/ProductsTable.php | 3 +- .../Forms/Studio/Catalog/ProductForm.php | 8 +- app/Livewire/Studio/Catalog/Product/Audit.php | 31 +- .../Studio/Catalog/Product/Create.php | 7 +- app/Livewire/Studio/Catalog/Product/Edit.php | 7 +- app/Livewire/Studio/Catalog/Product/Index.php | 10 +- .../components/sections/ui/audit.blade.php | 20 +- .../Studio/Catalog/Product/AuditTest.php | 473 ++++++++++++++++++ .../Studio/Catalog/Product/CreateTest.php | 353 +++++++++++++ .../Studio/Catalog/Product/EditTest.php | 360 +++++++++++++ .../Studio/Catalog/Product/IndexTest.php | 120 +++++ 11 files changed, 1348 insertions(+), 44 deletions(-) create mode 100644 tests/Feature/Livewire/Studio/Catalog/Product/AuditTest.php create mode 100644 tests/Feature/Livewire/Studio/Catalog/Product/CreateTest.php create mode 100644 tests/Feature/Livewire/Studio/Catalog/Product/EditTest.php create mode 100644 tests/Feature/Livewire/Studio/Catalog/Product/IndexTest.php diff --git a/app/Livewire/Datatable/Studio/Catalog/ProductsTable.php b/app/Livewire/Datatable/Studio/Catalog/ProductsTable.php index 339444d..53941df 100644 --- a/app/Livewire/Datatable/Studio/Catalog/ProductsTable.php +++ b/app/Livewire/Datatable/Studio/Catalog/ProductsTable.php @@ -20,7 +20,7 @@ class ProductsTable extends DataTableComponent protected $model = Product::class; - protected int $refreshTime = 3000; + protected int $refreshTime = 30000; public function columns(): array { @@ -117,7 +117,6 @@ class='flex items-center space-x-2 bg-gray-50 border border-gray-200 rounded-lg if (auth()->user()->can('delete product')) { $actions .= view('components.actions.table.delete', [ 'id' => $row->hash, - 'deleteRoute' => route('studio.catalog.product.delete', $row->hash), ])->render(); } diff --git a/app/Livewire/Forms/Studio/Catalog/ProductForm.php b/app/Livewire/Forms/Studio/Catalog/ProductForm.php index e15f6e5..cbc14c3 100644 --- a/app/Livewire/Forms/Studio/Catalog/ProductForm.php +++ b/app/Livewire/Forms/Studio/Catalog/ProductForm.php @@ -61,7 +61,7 @@ public function validationAttributes(): array ]; } - public function setProduct(Product $product) + public function setProduct(Product $product): void { $product->load('outlets'); @@ -76,7 +76,7 @@ public function setProduct(Product $product) $this->image = $this->mapMediaCollection($product->getMedia('image')); } - public function store() + public function store(): void { $this->validate(); @@ -91,7 +91,7 @@ public function store() }); } - public function update() + public function update(): void { $this->validate(); @@ -107,7 +107,7 @@ public function update() }); } - private function prepareSavedData() + private function prepareSavedData(): array { return [ 'name' => $this->name, diff --git a/app/Livewire/Studio/Catalog/Product/Audit.php b/app/Livewire/Studio/Catalog/Product/Audit.php index 76ee5fb..8b4eea8 100644 --- a/app/Livewire/Studio/Catalog/Product/Audit.php +++ b/app/Livewire/Studio/Catalog/Product/Audit.php @@ -5,6 +5,8 @@ use App\Enums\AuditStockStatus; use App\Models\Outlet; use App\Models\Product; +use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Collection; use Livewire\Attributes\Title; use Livewire\Component; use Spatie\Activitylog\Models\Activity; @@ -14,9 +16,9 @@ class Audit extends Component { public string $label; - public array $activityLogs = []; + public Collection $activityLogs; - public function mount(Outlet $outlet, Product $product) + public function mount(Outlet $outlet, Product $product): void { $this->label = $product->name.' di '.$outlet->name; @@ -26,27 +28,18 @@ public function mount(Outlet $outlet, Product $product) ->latest() ->get() ->map(function (Activity $activity) { - - return [ - 'log_name' => $activity->log_name, - 'created_at' => $activity->created_at, - 'event' => [ - 'label' => $activity->event, - 'color' => AuditStockStatus::from($activity->event)->color(), - ], - 'new_stock' => currency($activity->properties['new_stock']), - 'previous_stock' => currency($activity->properties['previous_stock']), - 'quantity_change' => currency($activity->properties['quantity_change']), - 'user' => $activity->causer?->profile?->full_name, - 'description' => $activity->description, + $activity->event = [ + 'label' => $activity->event, + 'color' => AuditStockStatus::from($activity->event)->color(), ]; - }) - ->toArray(); + + return $activity; + }); } - public function render() + public function render(): View { - return view('components.partials.audit', [ + return view('components.sections.ui.audit', [ 'pageTitle' => 'Audit '.$this->label, 'module' => 'product', ]); diff --git a/app/Livewire/Studio/Catalog/Product/Create.php b/app/Livewire/Studio/Catalog/Product/Create.php index 38be465..20c67c9 100644 --- a/app/Livewire/Studio/Catalog/Product/Create.php +++ b/app/Livewire/Studio/Catalog/Product/Create.php @@ -8,6 +8,7 @@ use App\Traits\WithOutletSelector; use App\Traits\WithToast; use App\Traits\WithUpdatedData; +use Illuminate\Contracts\View\View; use Livewire\Attributes\Title; use Livewire\Component; @@ -20,12 +21,12 @@ class Create extends Component public array $outlets = []; - public function mount() + public function mount(): void { $this->outlets = Outlet::pluck('name', 'id')->toArray(); } - public function save() + public function save(): void { $this->canOrAbort('create product'); @@ -38,7 +39,7 @@ public function save() $this->redirectRoute('studio.catalog.product.index'); } - public function render() + public function render(): View { return view('livewire.studio.catalog.product.form', [ 'pageTitle' => 'Tambah Produk', diff --git a/app/Livewire/Studio/Catalog/Product/Edit.php b/app/Livewire/Studio/Catalog/Product/Edit.php index 5e033d2..4e6c261 100644 --- a/app/Livewire/Studio/Catalog/Product/Edit.php +++ b/app/Livewire/Studio/Catalog/Product/Edit.php @@ -9,6 +9,7 @@ use App\Traits\WithOutletSelector; use App\Traits\WithToast; use App\Traits\WithUpdatedData; +use Illuminate\Contracts\View\View; use Livewire\Attributes\Title; use Livewire\Component; @@ -21,14 +22,14 @@ class Edit extends Component public array $outlets = []; - public function mount(Product $product) + public function mount(Product $product): void { $this->form->setProduct($product); $this->outlets = Outlet::pluck('name', 'id')->toArray(); } - public function save() + public function save(): void { $this->canOrAbort('update product'); @@ -41,7 +42,7 @@ public function save() $this->redirectRoute('studio.catalog.product.index'); } - public function render() + public function render(): View { return view('livewire.studio.catalog.product.form', [ 'pageTitle' => 'Ubah Produk', diff --git a/app/Livewire/Studio/Catalog/Product/Index.php b/app/Livewire/Studio/Catalog/Product/Index.php index 7cf4785..cedfdcf 100644 --- a/app/Livewire/Studio/Catalog/Product/Index.php +++ b/app/Livewire/Studio/Catalog/Product/Index.php @@ -4,20 +4,24 @@ use App\Models\Product; use App\Traits\Notification\WithSubscribeNotification; +use App\Traits\WithAuthorization; use App\Traits\WithConfirmation; use App\Traits\WithShowPrice; use App\Traits\WithToast; use Flux\Flux; +use Illuminate\Contracts\View\View; use Livewire\Attributes\Title; use Livewire\Component; #[Title('Produk')] class Index extends Component { - use WithConfirmation, WithShowPrice, WithSubscribeNotification, WithToast; + use WithAuthorization, WithConfirmation, WithShowPrice, WithSubscribeNotification, WithToast; - public function delete(Product $product) + public function delete(Product $product): void { + $this->canOrAbort('delete product'); + $product->delete(); $this->dispatch('refreshDatatable'); @@ -27,7 +31,7 @@ public function delete(Product $product) Flux::modals()->close(); } - public function render() + public function render(): View { return view('livewire.studio.catalog.product.index', [ 'pageTitle' => 'Produk', diff --git a/resources/views/components/sections/ui/audit.blade.php b/resources/views/components/sections/ui/audit.blade.php index 75cf3d1..3cb1645 100644 --- a/resources/views/components/sections/ui/audit.blade.php +++ b/resources/views/components/sections/ui/audit.blade.php @@ -14,14 +14,14 @@ @forelse ($activityLogs as $log)
-
{{ $log['log_name'] }}
-
{{ $log['created_at'] }}
-
{{ $log['user'] }}
+
{{ $log->log_name }}
+
{{ $log->created_at }}
+
{{ $log->causer?->employee?->full_name }}
- {{ $log['event']['label'] }} + bg-{{ $log->event['color'] }}-200 text-{{ $log->event['color'] }}-800 + dark:bg-{{ $log->event['color'] }}-800 dark:text-{{ $log->event['color'] }}-200"> + {{ $log->event['label'] }}
@@ -29,17 +29,17 @@ class="px-2 py-1 inline-flex text-xs font-semibold rounded-full dark:border-gray-700" />
-
Stok Sebelumnya: {{ $log['previous_stock'] }}
+
Stok Sebelumnya: {{ currency($log->properties['previous_stock']) }}
-
Perubahan: {{ $log['quantity_change'] }}
+
Perubahan: {{ currency($log->properties['quantity_change']) }}
-
Total Stok: {{ $log['new_stock'] }}
+
Total Stok: {{ currency($log->properties['new_stock']) }}

- {{ $log['description'] }} + {{ $log->description }}

@empty diff --git a/tests/Feature/Livewire/Studio/Catalog/Product/AuditTest.php b/tests/Feature/Livewire/Studio/Catalog/Product/AuditTest.php new file mode 100644 index 0000000..f5ec1b0 --- /dev/null +++ b/tests/Feature/Livewire/Studio/Catalog/Product/AuditTest.php @@ -0,0 +1,473 @@ +seed(RolePermissionSeeder::class); + + $this->user = User::factory() + ->active() + ->has(Employee::factory()) + ->create(); + + $this->ownerRole = Role::where('name', 'Owner')->first(); + $this->user->roles()->attach($this->ownerRole->id); + + // Create test outlets + $this->outlets = Outlet::factory()->count(3)->create(); +}); + +function mountProductAuditComponent(User $user, Outlet $outlet, Product $product) +{ + return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Product\Audit::class, ['outlet' => $outlet, 'product' => $product]); +} + +/* +|-------------------------------------------------------------------------- +| Audit Component Rendering & Mount +|-------------------------------------------------------------------------- +*/ + +it('renders audit page successfully', function () { + $product = Product::factory()->create(); + $outlet = $this->outlets->first(); + + mountProductAuditComponent($this->user, $outlet, $product) + ->assertViewIs('components.sections.ui.audit') + ->assertViewHas('pageTitle', "Audit {$product->name} di {$outlet->name}") + ->assertViewHas('module', 'product'); +}); + +it('sets label correctly on mount', function () { + $product = Product::factory()->create(['name' => 'Test Product']); + $outlet = $this->outlets->first(); + + $component = mountProductAuditComponent($this->user, $outlet, $product); + + expect($component->label)->toBe('Test Product di '.$outlet->name); +}); + +/* +|-------------------------------------------------------------------------- +| Activity Logs Display Tests +|-------------------------------------------------------------------------- +*/ + +it('displays activity logs correctly', function () { + $product = Product::factory()->create(); + $outlet = $this->outlets->first(); + + // Create some activity logs with expected properties + Activity::create([ + 'log_name' => 'default', + 'description' => 'Stock updated', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet->id, + 'previous_stock' => 5, + 'quantity_change' => 10, + 'new_stock' => 15, + ], + 'event' => AuditStockStatus::INCREASED->value, + ]); + + Activity::create([ + 'log_name' => 'default', + 'description' => 'Stock decreased', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet->id, + 'previous_stock' => 15, + 'quantity_change' => -5, + 'new_stock' => 10, + ], + 'event' => AuditStockStatus::DECREASED->value, + ]); + + $component = mountProductAuditComponent($this->user, $outlet, $product); + + expect($component->activityLogs)->toHaveCount(2); + expect($component->activityLogs->first()->event['label'])->toBe(AuditStockStatus::INCREASED->label()); + expect($component->activityLogs->first()->event['color'])->toBe(AuditStockStatus::INCREASED->color()); + expect($component->activityLogs->last()->event['label'])->toBe(AuditStockStatus::DECREASED->label()); + expect($component->activityLogs->last()->event['color'])->toBe(AuditStockStatus::DECREASED->color()); +}); + +it('filters activity logs by outlet', function () { + $product = Product::factory()->create(); + $outlet1 = $this->outlets->first(); + $outlet2 = $this->outlets->last(); + + // Create activity for outlet1 + Activity::create([ + 'log_name' => 'default', + 'description' => 'Stock updated outlet1', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet1->id, + 'previous_stock' => 5, + 'quantity_change' => 10, + 'new_stock' => 15, + ], + 'event' => AuditStockStatus::UPDATED->value, + ]); + + // Create activity for outlet2 + Activity::create([ + 'log_name' => 'default', + 'description' => 'Stock increased outlet2', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet2->id, + 'previous_stock' => 0, + 'quantity_change' => 20, + 'new_stock' => 20, + ], + 'event' => AuditStockStatus::INCREASED->value, + ]); + + $component1 = mountProductAuditComponent($this->user, $outlet1, $product); + $component2 = mountProductAuditComponent($this->user, $outlet2, $product); + + expect($component1->activityLogs)->toHaveCount(1); + expect($component1->activityLogs->first()->properties['outlet_id'])->toBe($outlet1->id); + + expect($component2->activityLogs)->toHaveCount(1); + expect($component2->activityLogs->first()->properties['outlet_id'])->toBe($outlet2->id); +}); + +it('orders activity logs by latest first', function () { + $product = Product::factory()->create(); + $outlet = $this->outlets->first(); + + // Create activities with different timestamps + $activity1 = Activity::create([ + 'log_name' => 'default', + 'description' => 'first activity', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet->id, + 'previous_stock' => 5, + 'quantity_change' => 5, + 'new_stock' => 10, + ], + 'event' => AuditStockStatus::UPDATED->value, + 'created_at' => now()->subMinutes(5), + ]); + + $activity2 = Activity::create([ + 'log_name' => 'default', + 'description' => 'second activity', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet->id, + 'previous_stock' => 10, + 'quantity_change' => 10, + 'new_stock' => 20, + ], + 'event' => AuditStockStatus::INCREASED->value, + 'created_at' => now(), + ]); + + $component = mountProductAuditComponent($this->user, $outlet, $product); + + expect($component->activityLogs)->toHaveCount(2); + expect($component->activityLogs->first()->id)->toBe($activity2->id); + expect($component->activityLogs->last()->id)->toBe($activity1->id); +}); + +/* +|-------------------------------------------------------------------------- +| Activity Logs Content Tests +|-------------------------------------------------------------------------- +*/ + +it('maps activity events to correct labels and colors', function () { + $product = Product::factory()->create(); + $outlet = $this->outlets->first(); + + // Test INCREASED event + Activity::create([ + 'log_name' => 'default', + 'description' => 'Stock increased', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet->id, + 'previous_stock' => 10, + 'quantity_change' => 5, + 'new_stock' => 15, + ], + 'event' => AuditStockStatus::INCREASED->value, + ]); + + // Test DECREASED event + Activity::create([ + 'log_name' => 'default', + 'description' => 'Stock decreased', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet->id, + 'previous_stock' => 15, + 'quantity_change' => -3, + 'new_stock' => 12, + ], + 'event' => AuditStockStatus::DECREASED->value, + ]); + + // Test UPDATED event + Activity::create([ + 'log_name' => 'default', + 'description' => 'Stock updated', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet->id, + 'previous_stock' => 12, + 'quantity_change' => 0, + 'new_stock' => 12, + ], + 'event' => AuditStockStatus::UPDATED->value, + ]); + + $component = mountProductAuditComponent($this->user, $outlet, $product); + + expect($component->activityLogs)->toHaveCount(3); + + // Check all event mappings + foreach ($component->activityLogs as $log) { + match ($log->event['label']) { + AuditStockStatus::INCREASED->label() => expect($log->event['color'])->toBe(AuditStockStatus::INCREASED->color()), + AuditStockStatus::DECREASED->label() => expect($log->event['color'])->toBe(AuditStockStatus::DECREASED->color()), + AuditStockStatus::UPDATED->label() => expect($log->event['color'])->toBe(AuditStockStatus::UPDATED->color()), + default => throw new \Exception("Unexpected event label: {$log->event['label']}") + }; + } +}); + +/* +|-------------------------------------------------------------------------- +| Empty State Tests +|-------------------------------------------------------------------------- +*/ + +it('handles empty activity logs in audit component', function () { + $product = Product::factory()->create(); + $outlet = $this->outlets->first(); + + $component = mountProductAuditComponent($this->user, $outlet, $product); + + expect($component->activityLogs)->toBeInstanceOf(\Illuminate\Database\Eloquent\Collection::class); + expect($component->activityLogs)->toHaveCount(0); +}); + +it('handles product with no activities in specific outlet', function () { + $product = Product::factory()->create(); + $outlet1 = $this->outlets->first(); + $outlet2 = $this->outlets->last(); + + // Create activity only for outlet2 + Activity::create([ + 'log_name' => 'default', + 'description' => 'Stock updated', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet2->id, + 'previous_stock' => 5, + 'quantity_change' => 5, + 'new_stock' => 10, + ], + 'event' => AuditStockStatus::UPDATED->value, + ]); + + $component1 = mountProductAuditComponent($this->user, $outlet1, $product); + $component2 = mountProductAuditComponent($this->user, $outlet2, $product); + + expect($component1->activityLogs)->toHaveCount(0); + expect($component2->activityLogs)->toHaveCount(1); +}); + +/* +|-------------------------------------------------------------------------- +| Integration Tests +|-------------------------------------------------------------------------- +*/ + +it('displays real activity log data correctly', function () { + $product = Product::factory()->create(['name' => 'Integration Product']); + $outlet = $this->outlets->first(); + + $activity = Activity::create([ + 'log_name' => 'default', + 'description' => 'Stock adjustment for integration test', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet->id, + 'previous_stock' => 100, + 'quantity_change' => 25, + 'new_stock' => 125, + ], + 'event' => AuditStockStatus::INCREASED->value, + ]); + + $component = mountProductAuditComponent($this->user, $outlet, $product); + + expect($component->activityLogs)->toHaveCount(1); + + $log = $component->activityLogs->first(); + expect($log->description)->toBe('Stock adjustment for integration test'); + expect($log->properties['previous_stock'])->toBe(100); + expect($log->properties['quantity_change'])->toBe(25); + expect($log->properties['new_stock'])->toBe(125); + expect($log->event['label'])->toBe(AuditStockStatus::INCREASED->label()); + expect($log->event['color'])->toBe(AuditStockStatus::INCREASED->color()); +}); + +it('handles multiple activities across different outlets', function () { + $product = Product::factory()->create(); + $outlet1 = $this->outlets->first(); + $outlet2 = $this->outlets->last(); + + // Create activities for both outlets + Activity::create([ + 'log_name' => 'default', + 'description' => 'Activity in outlet 1', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet1->id, + 'previous_stock' => 10, + 'quantity_change' => 5, + 'new_stock' => 15, + ], + 'event' => AuditStockStatus::INCREASED->value, + ]); + + Activity::create([ + 'log_name' => 'default', + 'description' => 'Activity in outlet 2', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet2->id, + 'previous_stock' => 20, + 'quantity_change' => -3, + 'new_stock' => 17, + ], + 'event' => AuditStockStatus::DECREASED->value, + ]); + + $component1 = mountProductAuditComponent($this->user, $outlet1, $product); + $component2 = mountProductAuditComponent($this->user, $outlet2, $product); + + expect($component1->activityLogs)->toHaveCount(1); + expect($component1->activityLogs->first()->description)->toBe('Activity in outlet 1'); + + expect($component2->activityLogs)->toHaveCount(1); + expect($component2->activityLogs->first()->description)->toBe('Activity in outlet 2'); +}); + +/* +|-------------------------------------------------------------------------- +| Edge Cases and Error Handling +|-------------------------------------------------------------------------- +*/ + +it('handles activity logs with missing properties gracefully', function () { + $product = Product::factory()->create(); + $outlet = $this->outlets->first(); + + // Create activity with incomplete properties (shouldn't happen in real usage) + Activity::create([ + 'log_name' => 'default', + 'description' => 'Incomplete activity', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => get_class($this->user), + 'causer_id' => $this->user->id, + 'properties' => [ + 'outlet_id' => $outlet->id, + 'previous_stock' => 0, // Add required properties + 'quantity_change' => 0, + 'new_stock' => 0, + ], + 'event' => AuditStockStatus::UPDATED->value, + ]); + + $component = mountProductAuditComponent($this->user, $outlet, $product); + + expect($component->activityLogs)->toHaveCount(1); + // Component should render correctly with all required properties +}); + +it('handles activities with null causer', function () { + $product = Product::factory()->create(); + $outlet = $this->outlets->first(); + + Activity::create([ + 'log_name' => 'default', + 'description' => 'Activity with no causer', + 'subject_type' => get_class($product), + 'subject_id' => $product->id, + 'causer_type' => null, + 'causer_id' => null, + 'properties' => [ + 'outlet_id' => $outlet->id, + 'previous_stock' => 5, + 'quantity_change' => 5, + 'new_stock' => 10, + ], + 'event' => AuditStockStatus::INCREASED->value, + ]); + + $component = mountProductAuditComponent($this->user, $outlet, $product); + + expect($component->activityLogs)->toHaveCount(1); + expect($component->activityLogs->first()->causer)->toBeNull(); +}); diff --git a/tests/Feature/Livewire/Studio/Catalog/Product/CreateTest.php b/tests/Feature/Livewire/Studio/Catalog/Product/CreateTest.php new file mode 100644 index 0000000..5fcaa06 --- /dev/null +++ b/tests/Feature/Livewire/Studio/Catalog/Product/CreateTest.php @@ -0,0 +1,353 @@ +seed(RolePermissionSeeder::class); + + $this->user = User::factory() + ->active() + ->has(Employee::factory()) + ->create(); + + $this->ownerRole = Role::where('name', 'Owner')->first(); + $this->user->roles()->attach($this->ownerRole->id); + + // Create test outlets + $this->outlets = Outlet::factory()->count(3)->create(); +}); + +function mountProductCreateComponent(User $user) +{ + return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Product\Create::class); +} + +/* +|-------------------------------------------------------------------------- +| Create Component Rendering & Mount +|-------------------------------------------------------------------------- +*/ + +it('renders create page successfully', function () { + mountProductCreateComponent($this->user) + ->assertViewIs('livewire.studio.catalog.product.form') + ->assertViewHas('pageTitle', 'Tambah Produk'); +}); + +it('initializes form properties on mount', function () { + $component = mountProductCreateComponent($this->user); + + expect($component->form->name)->toBe(''); + expect($component->form->sku)->toBe(''); + expect($component->form->cost_price)->toBe(''); + expect($component->form->sale_price)->toBe(''); + expect($component->form->description)->toBeNull(); + expect($component->form->outlet_ids)->toBe([]); + expect($component->form->image)->toBe([]); + expect($component->outlets)->toHaveCount(3); +}); + +/* +|-------------------------------------------------------------------------- +| Create Product Tests +|-------------------------------------------------------------------------- +*/ + +it('creates product successfully when authorized', function () { + $data = [ + 'name' => 'New Product', + 'sku' => 'NP001', + 'cost_price' => '10000', + 'sale_price' => '15000', + 'description' => 'Product description', + 'outlet_ids' => [$this->outlets->first()->id, $this->outlets->last()->id], + ]; + + mountProductCreateComponent($this->user) + ->set('form.name', $data['name']) + ->set('form.sku', $data['sku']) + ->set('form.cost_price', $data['cost_price']) + ->set('form.sale_price', $data['sale_price']) + ->set('form.description', $data['description']) + ->set('form.outlet_ids', $data['outlet_ids']) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('studio.catalog.product.index')); + + expect(Product::count())->toBe(1); + $product = Product::first(); + expect($product->name)->toBe($data['name']); + expect($product->sku)->toBe($data['sku']); + expect($product->cost_price)->toBe(10000); + expect($product->sale_price)->toBe(15000); + expect($product->point_per_pcs)->toBe(150); // 15000 / 100 + expect($product->description)->toBe($data['description']); + expect($product->outlets)->toHaveCount(2); +}); + +it('prevents product creation when unauthorized', function () { + $this->ownerRole->revokePermissionTo('create product'); + Gate::define('create product', fn () => false); + + mountProductCreateComponent($this->user) + ->set('form.name', 'Test Product') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '10000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertForbidden(); +}); + +it('creates product with empty cost_price for non-owner roles', function () { + // Create user with limited role + $this->user->roles()->detach(); + $limitedRole = Role::create(['name' => 'Cashier']); + $this->user->roles()->attach($limitedRole->id); + + $createPermission = Permission::where('name', 'create product')->first(); + $limitedRole->givePermissionTo($createPermission); + + mountProductCreateComponent($this->user) + ->set('form.name', 'Test Product') + ->set('form.sku', 'TP001') + ->set('form.cost_price', '') // Empty cost price + ->set('form.sale_price', '10000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::first(); + expect($product->cost_price)->toBe(0); +}); + +/* +|-------------------------------------------------------------------------- +| Form Validation Tests +|-------------------------------------------------------------------------- +*/ + +it('validates required name field on create', function () { + mountProductCreateComponent($this->user) + ->set('form.name', '') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '10000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('validates name field max length on create', function () { + mountProductCreateComponent($this->user) + ->set('form.name', str_repeat('a', 51)) + ->set('form.sku', 'TP001') + ->set('form.sale_price', '10000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('validates required sku field on create', function () { + mountProductCreateComponent($this->user) + ->set('form.name', 'Test Product') + ->set('form.sku', '') + ->set('form.sale_price', '10000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.sku']); +}); + +it('validates sku field max length on create', function () { + mountProductCreateComponent($this->user) + ->set('form.name', 'Test Product') + ->set('form.sku', str_repeat('a', 21)) + ->set('form.sale_price', '10000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.sku']); +}); + +it('validates sku uniqueness on create', function () { + Product::factory()->create(['sku' => 'EXISTING001']); + + mountProductCreateComponent($this->user) + ->set('form.name', 'Test Product') + ->set('form.sku', 'EXISTING001') + ->set('form.sale_price', '10000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.sku']); +}); + +it('validates required sale_price field on create', function () { + mountProductCreateComponent($this->user) + ->set('form.name', 'Test Product') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.sale_price']); +}); + +it('validates required outlet_ids field on create', function () { + mountProductCreateComponent($this->user) + ->set('form.name', 'Test Product') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '10000') + ->set('form.outlet_ids', []) + ->call('save') + ->assertHasErrors(['form.outlet_ids']); +}); + +it('validates outlet_ids contains valid outlet ids on create', function () { + mountProductCreateComponent($this->user) + ->set('form.name', 'Test Product') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '10000') + ->set('form.outlet_ids', [999]) + ->call('save') + ->assertHasErrors(['form.outlet_ids.*']); +}); + +/* +|-------------------------------------------------------------------------- +| Real-time Validation Tests +|-------------------------------------------------------------------------- +*/ + +it('validates name field in real-time', function () { + $component = mountProductCreateComponent($this->user); + + // Valid name + $component->set('form.name', 'Valid Product Name') + ->assertHasNoErrors(['form.name']); + + // Empty name + $component->set('form.name', '') + ->assertHasErrors(['form.name']); + + // Name too long + $component->set('form.name', str_repeat('a', 51)) + ->assertHasErrors(['form.name']); +}); + +it('validates sku field in real-time', function () { + $component = mountProductCreateComponent($this->user); + + // Valid SKU + $component->set('form.sku', 'VALID001') + ->assertHasNoErrors(['form.sku']); + + // Empty SKU + $component->set('form.sku', '') + ->assertHasErrors(['form.sku']); + + // SKU too long + $component->set('form.sku', str_repeat('a', 21)) + ->assertHasErrors(['form.sku']); + + // Duplicate SKU + Product::factory()->create(['sku' => 'EXISTING001']); + $component->set('form.sku', 'EXISTING001') + ->assertHasErrors(['form.sku']); +}); + +it('validates price fields in real-time', function () { + $component = mountProductCreateComponent($this->user); + + // Valid sale price + $component->set('form.sale_price', '10000') + ->assertHasNoErrors(['form.sale_price']); + + // Empty sale price + $component->set('form.sale_price', '') + ->assertHasErrors(['form.sale_price']); + + // Negative sale price + $component->set('form.sale_price', '-10000') + ->assertHasErrors(['form.sale_price']); + + // Valid cost price + $component->set('form.cost_price', '5000') + ->assertHasNoErrors(['form.cost_price']); + + // Negative cost price + $component->set('form.cost_price', '-5000') + ->assertHasErrors(['form.cost_price']); +}); + +it('validates outlet_ids in real-time', function () { + $component = mountProductCreateComponent($this->user); + + // Valid outlet IDs + $component->set('form.name', 'Test Product') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '10000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->assertHasNoErrors(['form.outlet_ids']); + + // Empty outlet IDs - should trigger validation on submit + $component->set('form.outlet_ids', []) + ->call('save') + ->assertHasErrors(['form.outlet_ids']); + + // Invalid outlet ID + $component = mountProductCreateComponent($this->user); + $component->set('form.name', 'Test Product') + ->set('form.sku', 'TP002') + ->set('form.sale_price', '10000') + ->set('form.outlet_ids', [999]) + ->call('save') + ->assertHasErrors(['form.outlet_ids.*']); +}); + +/* +|-------------------------------------------------------------------------- +| Integration Tests +|-------------------------------------------------------------------------- +*/ + +it('creates product and attaches to outlets correctly', function () { + $outlet1 = $this->outlets->first(); + $outlet2 = $this->outlets->last(); + + mountProductCreateComponent($this->user) + ->set('form.name', 'Integration Test Product') + ->set('form.sku', 'ITP001') + ->set('form.cost_price', '15000') // Required for Owner role + ->set('form.sale_price', '20000') + ->set('form.outlet_ids', [$outlet1->id, $outlet2->id]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('sku', 'ITP001')->first(); + expect($product)->not->toBeNull(); + expect($product->outlets)->toHaveCount(2); + expect($product->outlets->pluck('id')->toArray())->toContain($outlet1->id, $outlet2->id); +}); + +it('handles large numbers in price fields', function () { + mountProductCreateComponent($this->user) + ->set('form.name', 'Expensive Product') + ->set('form.sku', 'EP001') + ->set('form.cost_price', '10000000') // 10 million + ->set('form.sale_price', '15000000') // 15 million + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); + + $product = Product::where('sku', 'EP001')->first(); + expect($product->cost_price)->toBe(10000000); + expect($product->sale_price)->toBe(15000000); + expect($product->point_per_pcs)->toBe(150000); // 15000000 / 100 +}); diff --git a/tests/Feature/Livewire/Studio/Catalog/Product/EditTest.php b/tests/Feature/Livewire/Studio/Catalog/Product/EditTest.php new file mode 100644 index 0000000..fc0db89 --- /dev/null +++ b/tests/Feature/Livewire/Studio/Catalog/Product/EditTest.php @@ -0,0 +1,360 @@ +seed(RolePermissionSeeder::class); + + $this->user = User::factory() + ->active() + ->has(Employee::factory()) + ->create(); + + $this->ownerRole = Role::where('name', 'Owner')->first(); + $this->user->roles()->attach($this->ownerRole->id); + + // Create test outlets + $this->outlets = Outlet::factory()->count(3)->create(); +}); + +function mountProductEditComponent(User $user, Product $product) +{ + return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Product\Edit::class, ['product' => $product->hash]); +} + +/* +|-------------------------------------------------------------------------- +| Edit Component Rendering & Mount +|-------------------------------------------------------------------------- +*/ + +it('renders edit page successfully', function () { + $product = Product::factory()->create(); + + mountProductEditComponent($this->user, $product) + ->assertViewIs('livewire.studio.catalog.product.form') + ->assertViewHas('pageTitle', 'Ubah Produk'); +}); + +it('populates form with product data on mount', function () { + $product = Product::factory()->create([ + 'name' => 'Test Product', + 'sku' => 'TP001', + 'cost_price' => 10000, + 'sale_price' => 15000, + 'description' => 'Test description', + ]); + + $product->outlets()->attach([$this->outlets->first()->id]); + + $component = mountProductEditComponent($this->user, $product); + + expect($component->form->name)->toBe('Test Product'); + expect($component->form->sku)->toBe('TP001'); + expect($component->form->cost_price)->toBe('10000'); + expect($component->form->sale_price)->toBe('15000'); + expect($component->form->description)->toBe('Test description'); + expect($component->form->outlet_ids)->toBe([$this->outlets->first()->id]); +}); + +/* +|-------------------------------------------------------------------------- +| Update Product Tests +|-------------------------------------------------------------------------- +*/ + +it('updates product successfully when authorized', function () { + $updatePermission = Permission::where('name', 'update product')->first(); + $this->user->givePermissionTo($updatePermission); + + $product = Product::factory()->create([ + 'name' => 'Original Product', + 'sku' => 'OP001', + 'cost_price' => 10000, + 'sale_price' => 15000, + ]); + + $product->outlets()->attach([$this->outlets->first()->id]); + + $updateData = [ + 'name' => 'Updated Product', + 'sku' => 'UP001', + 'cost_price' => '12000', + 'sale_price' => '18000', + 'description' => 'Updated description', + 'outlet_ids' => [$this->outlets->first()->id, $this->outlets->last()->id], + ]; + + mountProductEditComponent($this->user, $product) + ->set('form.name', $updateData['name']) + ->set('form.sku', $updateData['sku']) + ->set('form.cost_price', $updateData['cost_price']) + ->set('form.sale_price', $updateData['sale_price']) + ->set('form.description', $updateData['description']) + ->set('form.outlet_ids', $updateData['outlet_ids']) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('studio.catalog.product.index')); + + $product->refresh(); + expect($product->name)->toBe($updateData['name']); + expect($product->sku)->toBe($updateData['sku']); + expect($product->cost_price)->toBe(12000); + expect($product->sale_price)->toBe(18000); + expect($product->point_per_pcs)->toBe(180); // 18000 / 100 + expect($product->description)->toBe($updateData['description']); + expect($product->outlets)->toHaveCount(2); +}); + +it('prevents product update when unauthorized', function () { + $this->ownerRole->revokePermissionTo('update product'); + Gate::define('update product', fn () => false); + + $product = Product::factory()->create(); + + mountProductEditComponent($this->user, $product) + ->set('form.name', 'Updated Name') + ->call('save') + ->assertForbidden(); +}); + +it('syncs outlet relationships correctly on update', function () { + $updatePermission = Permission::where('name', 'update product')->first(); + $this->user->givePermissionTo($updatePermission); + + $product = Product::factory()->create(); + $product->outlets()->attach([$this->outlets->first()->id]); + + // Change to different outlets + mountProductEditComponent($this->user, $product) + ->set('form.name', 'Updated Product') + ->set('form.sku', 'UP001') + ->set('form.sale_price', '10000') + ->set('form.outlet_ids', [$this->outlets[1]->id, $this->outlets[2]->id]) + ->call('save') + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->outlets)->toHaveCount(2); + expect($product->outlets->pluck('id')->toArray())->toBe([$this->outlets[1]->id, $this->outlets[2]->id]); +}); + +it('allows same sku on update of same product', function () { + $updatePermission = Permission::where('name', 'update product')->first(); + $this->user->givePermissionTo($updatePermission); + + $product = Product::factory()->create(['sku' => 'EXISTING001']); + + mountProductEditComponent($this->user, $product) + ->set('form.name', 'Updated Product') + ->set('form.sku', 'EXISTING001') // Same SKU as existing product + ->set('form.sale_price', '10000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); +}); + +/* +|-------------------------------------------------------------------------- +| Form Validation Tests on Edit +|-------------------------------------------------------------------------- +*/ + +it('validates required name field on update', function () { + $product = Product::factory()->create(); + + mountProductEditComponent($this->user, $product) + ->set('form.name', '') + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('validates name field max length on update', function () { + $product = Product::factory()->create(); + + mountProductEditComponent($this->user, $product) + ->set('form.name', str_repeat('a', 51)) + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('validates required sku field on update', function () { + $product = Product::factory()->create(); + + mountProductEditComponent($this->user, $product) + ->set('form.sku', '') + ->call('save') + ->assertHasErrors(['form.sku']); +}); + +it('validates sku field max length on update', function () { + $product = Product::factory()->create(); + + mountProductEditComponent($this->user, $product) + ->set('form.sku', str_repeat('a', 21)) + ->call('save') + ->assertHasErrors(['form.sku']); +}); + +it('validates sku uniqueness on update with different product', function () { + $product1 = Product::factory()->create(['sku' => 'SKU001']); + $product2 = Product::factory()->create(['sku' => 'SKU002']); + + mountProductEditComponent($this->user, $product1) + ->set('form.sku', 'SKU002') // Try to use SKU from product2 + ->call('save') + ->assertHasErrors(['form.sku']); +}); + +it('validates required sale_price field on update', function () { + $product = Product::factory()->create(); + + mountProductEditComponent($this->user, $product) + ->set('form.sale_price', '') + ->call('save') + ->assertHasErrors(['form.sale_price']); +}); + +it('validates required outlet_ids field on update', function () { + $product = Product::factory()->create(); + + mountProductEditComponent($this->user, $product) + ->set('form.outlet_ids', []) + ->call('save') + ->assertHasErrors(['form.outlet_ids']); +}); + +it('validates outlet_ids contains valid outlet ids on update', function () { + $product = Product::factory()->create(); + + mountProductEditComponent($this->user, $product) + ->set('form.outlet_ids', [999]) + ->call('save') + ->assertHasErrors(['form.outlet_ids.*']); +}); + +/* +|-------------------------------------------------------------------------- +| Real-time Validation Tests on Edit +|-------------------------------------------------------------------------- +*/ + +it('validates name field in real-time on edit', function () { + $product = Product::factory()->create(); + + $component = mountProductEditComponent($this->user, $product); + + // Valid name + $component->set('form.name', 'Valid Updated Product Name') + ->assertHasNoErrors(['form.name']); + + // Empty name + $component->set('form.name', '') + ->assertHasErrors(['form.name']); + + // Name too long + $component->set('form.name', str_repeat('a', 51)) + ->assertHasErrors(['form.name']); +}); + +it('validates sku field in real-time on edit', function () { + $product = Product::factory()->create(); + + $component = mountProductEditComponent($this->user, $product); + + // Valid SKU + $component->set('form.sku', 'VALID001') + ->assertHasNoErrors(['form.sku']); + + // Empty SKU + $component->set('form.sku', '') + ->assertHasErrors(['form.sku']); + + // SKU too long + $component->set('form.sku', str_repeat('a', 21)) + ->assertHasErrors(['form.sku']); +}); + +it('validates price fields in real-time on edit', function () { + $product = Product::factory()->create(); + + $component = mountProductEditComponent($this->user, $product); + + // Valid sale price + $component->set('form.sale_price', '10000') + ->assertHasNoErrors(['form.sale_price']); + + // Empty sale price + $component->set('form.sale_price', '') + ->assertHasErrors(['form.sale_price']); + + // Negative sale price + $component->set('form.sale_price', '-10000') + ->assertHasErrors(['form.sale_price']); +}); + +/* +|-------------------------------------------------------------------------- +| Integration Tests +|-------------------------------------------------------------------------- +*/ + +it('updates product and syncs outlet relationships', function () { + $updatePermission = Permission::where('name', 'update product')->first(); + $this->user->givePermissionTo($updatePermission); + + $product = Product::factory()->create(); + $product->outlets()->attach([$this->outlets->first()->id]); + + $newOutlets = [$this->outlets[1]->id, $this->outlets[2]->id]; + + mountProductEditComponent($this->user, $product) + ->set('form.name', 'Updated Integration Product') + ->set('form.sku', 'UIP001') + ->set('form.sale_price', '25000') + ->set('form.outlet_ids', $newOutlets) + ->call('save') + ->assertHasNoErrors(); + + $product->refresh(); + expect($product->name)->toBe('Updated Integration Product'); + expect($product->outlets)->toHaveCount(2); + expect($product->outlets->pluck('id')->toArray())->toBe($newOutlets); +}); + +/* +|-------------------------------------------------------------------------- +| Error Handling and Edge Cases +|-------------------------------------------------------------------------- +*/ + +it('handles invalid product hash in edit component', function () { + expect(fn () => mountProductEditComponent($this->user, 'invalid-hash')) + ->toThrow(\TypeError::class); +}); + +it('validates form data integrity during operations', function () { + // Test that form data is properly reset between operations + $product1 = Product::factory()->create(['name' => 'First Category']); + $product2 = Product::factory()->create(['name' => 'Second Category']); + + $component = mountProductEditComponent($this->user, $product1); + + // Open modal for first category + expect($component->form->name)->toBe('First Category'); + + // Verify first category wasn't modified + $product1->refresh(); + expect($product1->name)->toBe('First Category'); +}); diff --git a/tests/Feature/Livewire/Studio/Catalog/Product/IndexTest.php b/tests/Feature/Livewire/Studio/Catalog/Product/IndexTest.php new file mode 100644 index 0000000..a82a998 --- /dev/null +++ b/tests/Feature/Livewire/Studio/Catalog/Product/IndexTest.php @@ -0,0 +1,120 @@ +seed(RolePermissionSeeder::class); + + $this->user = User::factory() + ->active() + ->has(Employee::factory()) + ->create(); + + $this->ownerRole = Role::where('name', 'Owner')->first(); + $this->user->roles()->attach($this->ownerRole->id); +}); + +function mountProductIndexComponent(User $user) +{ + return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Product\Index::class); +} + +/* +|-------------------------------------------------------------------------- +| Index Component Rendering & Basic Functionality +|-------------------------------------------------------------------------- +*/ + +it('renders index page successfully', function () { + mountProductIndexComponent($this->user) + ->assertViewIs('livewire.studio.catalog.product.index') + ->assertViewHas('pageTitle', 'Produk'); +}); + +it('displays products correctly', function () { + Product::factory()->count(3)->create(); + + // Just check that the component renders without errors and has products + $component = mountProductIndexComponent($this->user); + $component->assertViewIs('livewire.studio.catalog.product.index'); + expect(Product::count())->toBe(3); +}); + +/* +|-------------------------------------------------------------------------- +| Delete Product Tests +|-------------------------------------------------------------------------- +*/ + +it('deletes product successfully when authorized', function () { + $product = Product::factory()->create([ + 'name' => 'Product to Delete', + ]); + + mountProductIndexComponent($this->user) + ->call('delete', $product) + ->assertHasNoErrors() + ->assertDispatched('refreshDatatable'); + + $this->assertSoftDeleted('products', [ + 'id' => $product->id, + ]); +}); + +it('prevents product deletion when unauthorized', function () { + $this->ownerRole->revokePermissionTo('delete product'); + + Gate::define('delete product', fn () => false); + + $product = Product::factory()->create(); + + mountProductIndexComponent($this->user) + ->call('delete', $product) + ->assertForbidden(); +}); + +/* +|-------------------------------------------------------------------------- +| Authorization Edge Cases +|-------------------------------------------------------------------------- +*/ + +it('handles unauthorized user with revoked permissions', function () { + // Revoke all product permissions + $this->ownerRole->revokePermissionTo(['delete product']); + + // Define gates to return false + Gate::define('delete product', fn () => false); + + $product = Product::factory()->create(); + + // Test delete + mountProductIndexComponent($this->user) + ->call('delete', $product) + ->assertForbidden(); +}); + +it('handles user with partial permissions correctly', function () { + // Remove owner role and create a user with limited permissions + $this->user->roles()->detach(); + $limitedRole = Role::create(['name' => 'Limited User']); + $this->user->roles()->attach($limitedRole->id); + + // Give only some permissions but not delete + $limitedRole->givePermissionTo([]); + + $product = Product::factory()->create(); + + // Should not be able to delete + mountProductIndexComponent($this->user) + ->call('delete', $product) + ->assertForbidden(); +});