From 81a9753620441033d990dbbb51656705134c39b0 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 11 Dec 2025 11:15:59 +0700 Subject: [PATCH] feat(perfume. bottle): implementasi testing - kasih return type - ubah array to collection di audit - mengubah waktu refresh tabel menjadi 30 detik --- .../Datatable/Studio/Catalog/BottlesTable.php | 1 - .../Studio/Catalog/PerfumesTable.php | 1 - .../Forms/Studio/Catalog/PerfumeForm.php | 10 +- app/Livewire/Studio/Catalog/Bottle/Audit.php | 31 +- app/Livewire/Studio/Catalog/Bottle/Create.php | 7 +- app/Livewire/Studio/Catalog/Bottle/Edit.php | 7 +- app/Livewire/Studio/Catalog/Bottle/Index.php | 10 +- app/Livewire/Studio/Catalog/Perfume/Audit.php | 31 +- .../Studio/Catalog/Perfume/Create.php | 7 +- app/Livewire/Studio/Catalog/Perfume/Edit.php | 7 +- app/Livewire/Studio/Catalog/Perfume/Index.php | 5 +- .../Studio/Catalog/Bottle/AuditTest.php | 466 +++++++++++++++++ .../Studio/Catalog/Bottle/CreateTest.php | 346 +++++++++++++ .../Studio/Catalog/Bottle/EditTest.php | 345 +++++++++++++ .../Studio/Catalog/Bottle/IndexTest.php | 115 +++++ .../Studio/Catalog/Perfume/AuditTest.php | 475 +++++++++++++++++ .../Studio/Catalog/Perfume/CreateTest.php | 457 +++++++++++++++++ .../Studio/Catalog/Perfume/EditTest.php | 481 ++++++++++++++++++ .../Studio/Catalog/Perfume/IndexTest.php | 118 +++++ 19 files changed, 2859 insertions(+), 61 deletions(-) create mode 100644 tests/Feature/Livewire/Studio/Catalog/Bottle/AuditTest.php create mode 100644 tests/Feature/Livewire/Studio/Catalog/Bottle/CreateTest.php create mode 100644 tests/Feature/Livewire/Studio/Catalog/Bottle/EditTest.php create mode 100644 tests/Feature/Livewire/Studio/Catalog/Bottle/IndexTest.php create mode 100644 tests/Feature/Livewire/Studio/Catalog/Perfume/AuditTest.php create mode 100644 tests/Feature/Livewire/Studio/Catalog/Perfume/CreateTest.php create mode 100644 tests/Feature/Livewire/Studio/Catalog/Perfume/EditTest.php create mode 100644 tests/Feature/Livewire/Studio/Catalog/Perfume/IndexTest.php diff --git a/app/Livewire/Datatable/Studio/Catalog/BottlesTable.php b/app/Livewire/Datatable/Studio/Catalog/BottlesTable.php index 2e8d049..b7fda45 100644 --- a/app/Livewire/Datatable/Studio/Catalog/BottlesTable.php +++ b/app/Livewire/Datatable/Studio/Catalog/BottlesTable.php @@ -125,7 +125,6 @@ class='flex items-center space-x-2 bg-gray-50 border border-gray-200 rounded-lg if (auth()->user()->can('delete bottle')) { $actions .= view('components.actions.table.delete', [ 'id' => $row->hash, - 'deleteRoute' => route('studio.catalog.bottle.delete', $row->hash), ])->render(); } diff --git a/app/Livewire/Datatable/Studio/Catalog/PerfumesTable.php b/app/Livewire/Datatable/Studio/Catalog/PerfumesTable.php index d4994de..fdfe1ff 100644 --- a/app/Livewire/Datatable/Studio/Catalog/PerfumesTable.php +++ b/app/Livewire/Datatable/Studio/Catalog/PerfumesTable.php @@ -128,7 +128,6 @@ class='flex items-center space-x-2 bg-gray-50 border border-gray-200 rounded-lg if (auth()->user()->can('delete perfume')) { $actions .= view('components.actions.table.delete', [ 'id' => $row->hash, - 'deleteRoute' => route('studio.catalog.perfume.delete', $row->hash), ])->render(); } diff --git a/app/Livewire/Forms/Studio/Catalog/PerfumeForm.php b/app/Livewire/Forms/Studio/Catalog/PerfumeForm.php index 98f5844..1ec1cf7 100644 --- a/app/Livewire/Forms/Studio/Catalog/PerfumeForm.php +++ b/app/Livewire/Forms/Studio/Catalog/PerfumeForm.php @@ -84,7 +84,7 @@ public function validationAttributes(): array ]; } - public function setPerfume(Perfume $perfume) + public function setPerfume(Perfume $perfume): void { $perfume->load(['brand', 'outlets', 'categories']); @@ -105,7 +105,7 @@ public function setPerfume(Perfume $perfume) $this->image = $this->mapMediaCollection($perfume->getMedia('image')); } - public function store() + public function store(): void { $this->validate(); @@ -122,7 +122,7 @@ public function store() }); } - public function update() + public function update(): void { $this->validate(); @@ -140,14 +140,14 @@ public function update() }); } - private function prepareSavedData() + private function prepareSavedData(): array { return [ 'name' => $this->name, 'sku' => $this->sku, 'cost_price' => replaceCurrency($this->cost_price == '' ? '0' : $this->cost_price), 'sale_price' => replaceCurrency($this->sale_price), - 'point_per_ml' => round($this->sale_price / 100), + 'point_per_ml' => round(replaceCurrency($this->sale_price) / 1000), 'base_notes' => $this->base_notes, 'middle_notes' => $this->middle_notes, 'top_notes' => $this->top_notes, diff --git a/app/Livewire/Studio/Catalog/Bottle/Audit.php b/app/Livewire/Studio/Catalog/Bottle/Audit.php index 9f9cf44..6d4dc72 100644 --- a/app/Livewire/Studio/Catalog/Bottle/Audit.php +++ b/app/Livewire/Studio/Catalog/Bottle/Audit.php @@ -5,6 +5,8 @@ use App\Enums\AuditStockStatus; use App\Models\Bottle; use App\Models\Outlet; +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, Bottle $bottle) + public function mount(Outlet $outlet, Bottle $bottle): void { $this->label = $bottle->name.' di '.$outlet->name; @@ -26,27 +28,18 @@ public function mount(Outlet $outlet, Bottle $bottle) ->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' => 'bottle', ]); diff --git a/app/Livewire/Studio/Catalog/Bottle/Create.php b/app/Livewire/Studio/Catalog/Bottle/Create.php index fb39084..662d855 100644 --- a/app/Livewire/Studio/Catalog/Bottle/Create.php +++ b/app/Livewire/Studio/Catalog/Bottle/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 bottle'); @@ -38,7 +39,7 @@ public function save() $this->redirectRoute('studio.catalog.bottle.index'); } - public function render() + public function render(): View { return view('livewire.studio.catalog.bottle.form', [ 'pageTitle' => 'Tambah Botol', diff --git a/app/Livewire/Studio/Catalog/Bottle/Edit.php b/app/Livewire/Studio/Catalog/Bottle/Edit.php index 6d4b616..6110605 100644 --- a/app/Livewire/Studio/Catalog/Bottle/Edit.php +++ b/app/Livewire/Studio/Catalog/Bottle/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(Bottle $bottle) + public function mount(Bottle $bottle): void { $this->form->setBottle($bottle); $this->outlets = Outlet::pluck('name', 'id')->toArray(); } - public function save() + public function save(): void { $this->canOrAbort('update bottle'); @@ -41,7 +42,7 @@ public function save() $this->redirectRoute('studio.catalog.bottle.index'); } - public function render() + public function render(): View { return view('livewire.studio.catalog.bottle.form', [ 'pageTitle' => 'Ubah Botol', diff --git a/app/Livewire/Studio/Catalog/Bottle/Index.php b/app/Livewire/Studio/Catalog/Bottle/Index.php index 97773cb..e739f2d 100644 --- a/app/Livewire/Studio/Catalog/Bottle/Index.php +++ b/app/Livewire/Studio/Catalog/Bottle/Index.php @@ -4,20 +4,24 @@ use App\Models\Bottle; 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('Botol')] class Index extends Component { - use WithConfirmation, WithShowPrice, WithSubscribeNotification, WithToast; + use WithAuthorization, WithConfirmation, WithShowPrice, WithSubscribeNotification, WithToast; - public function delete(Bottle $bottle) + public function delete(Bottle $bottle): void { + $this->canOrAbort('delete bottle'); + $bottle->delete(); $this->dispatch('refreshDatatable'); @@ -27,7 +31,7 @@ public function delete(Bottle $bottle) Flux::modals()->close(); } - public function render() + public function render(): View { return view('livewire.studio.catalog.bottle.index', [ 'pageTitle' => 'Botol', diff --git a/app/Livewire/Studio/Catalog/Perfume/Audit.php b/app/Livewire/Studio/Catalog/Perfume/Audit.php index 8dfb116..3ff00fe 100644 --- a/app/Livewire/Studio/Catalog/Perfume/Audit.php +++ b/app/Livewire/Studio/Catalog/Perfume/Audit.php @@ -5,6 +5,8 @@ use App\Enums\AuditStockStatus; use App\Models\Outlet; use App\Models\Perfume; +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, Perfume $perfume) + public function mount(Outlet $outlet, Perfume $perfume): void { $this->label = $perfume->name.' di '.$outlet->name; @@ -26,27 +28,18 @@ public function mount(Outlet $outlet, Perfume $perfume) ->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' => 'perfume', ]); diff --git a/app/Livewire/Studio/Catalog/Perfume/Create.php b/app/Livewire/Studio/Catalog/Perfume/Create.php index 02e5f3e..89e3924 100644 --- a/app/Livewire/Studio/Catalog/Perfume/Create.php +++ b/app/Livewire/Studio/Catalog/Perfume/Create.php @@ -13,6 +13,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; @@ -29,7 +30,7 @@ class Create extends Component public array $categories = []; - public function mount() + public function mount(): void { $this->outlets = Outlet::pluck('name', 'id')->toArray(); @@ -38,7 +39,7 @@ public function mount() $this->categories = Category::pluck('name', 'id')->toArray(); } - public function save() + public function save(): void { $this->canOrAbort('create perfume'); @@ -59,7 +60,7 @@ public function save() $this->redirectRoute('studio.catalog.perfume.index'); } - public function render() + public function render(): View { return view('livewire.studio.catalog.perfume.form', [ 'pageTitle' => 'Tambah Parfum', diff --git a/app/Livewire/Studio/Catalog/Perfume/Edit.php b/app/Livewire/Studio/Catalog/Perfume/Edit.php index b046539..750155c 100644 --- a/app/Livewire/Studio/Catalog/Perfume/Edit.php +++ b/app/Livewire/Studio/Catalog/Perfume/Edit.php @@ -12,6 +12,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; @@ -28,7 +29,7 @@ class Edit extends Component public array $categories = []; - public function mount(Perfume $perfume) + public function mount(Perfume $perfume): void { $this->form->setPerfume($perfume); @@ -39,7 +40,7 @@ public function mount(Perfume $perfume) $this->categories = Category::pluck('name', 'id')->toArray(); } - public function save() + public function save(): void { $this->canOrAbort('update perfume'); @@ -52,7 +53,7 @@ public function save() $this->redirectRoute('studio.catalog.perfume.index'); } - public function render() + public function render(): View { return view('livewire.studio.catalog.perfume.form', [ 'pageTitle' => 'Ubah Parfum', diff --git a/app/Livewire/Studio/Catalog/Perfume/Index.php b/app/Livewire/Studio/Catalog/Perfume/Index.php index abf4e88..1e7ca38 100644 --- a/app/Livewire/Studio/Catalog/Perfume/Index.php +++ b/app/Livewire/Studio/Catalog/Perfume/Index.php @@ -4,6 +4,7 @@ use App\Models\Perfume; use App\Traits\Notification\WithSubscribeNotification; +use App\Traits\WithAuthorization; use App\Traits\WithConfirmation; use App\Traits\WithShowPrice; use App\Traits\WithToast; @@ -14,10 +15,12 @@ #[Title('Parfum')] class Index extends Component { - use WithConfirmation, WithShowPrice, WithSubscribeNotification, WithToast; + use WithAuthorization, WithConfirmation, WithShowPrice, WithSubscribeNotification, WithToast; public function delete(Perfume $perfume) { + $this->canOrAbort('delete perfume'); + $perfume->delete(); $this->dispatch('refreshDatatable'); diff --git a/tests/Feature/Livewire/Studio/Catalog/Bottle/AuditTest.php b/tests/Feature/Livewire/Studio/Catalog/Bottle/AuditTest.php new file mode 100644 index 0000000..77b9916 --- /dev/null +++ b/tests/Feature/Livewire/Studio/Catalog/Bottle/AuditTest.php @@ -0,0 +1,466 @@ +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 mountBottleAuditComponent(User $user, Outlet $outlet, Bottle $bottle) +{ + return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Bottle\Audit::class, ['outlet' => $outlet, 'bottle' => $bottle]); +} + +/* +|-------------------------------------------------------------------------- +| Audit Component Rendering & Mount +|-------------------------------------------------------------------------- +*/ + +it('bottle audit component initializes correctly', function () { + $bottle = Bottle::factory()->create(['name' => 'Test Bottle']); + $outlet = $this->outlets->first(); + + $component = mountBottleAuditComponent($this->user, $outlet, $bottle); + + expect($component->label)->toBe('Test Bottle di '.$outlet->name); + expect($component->activityLogs)->toBeInstanceOf(\Illuminate\Database\Eloquent\Collection::class); +}); + +/* +|-------------------------------------------------------------------------- +| Activity Logs Display Tests +|-------------------------------------------------------------------------- +*/ + +it('displays bottle activity logs correctly', function () { + $bottle = Bottle::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($bottle), + 'subject_id' => $bottle->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($bottle), + 'subject_id' => $bottle->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 = mountBottleAuditComponent($this->user, $outlet, $bottle); + + 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 bottle activity logs by outlet', function () { + $bottle = Bottle::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($bottle), + 'subject_id' => $bottle->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($bottle), + 'subject_id' => $bottle->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 = mountBottleAuditComponent($this->user, $outlet1, $bottle); + $component2 = mountBottleAuditComponent($this->user, $outlet2, $bottle); + + expect($component1->activityLogs)->toHaveCount(1); + expect($component1->activityLogs->first()->description)->toBe('Stock updated outlet1'); + + expect($component2->activityLogs)->toHaveCount(1); + expect($component2->activityLogs->first()->description)->toBe('Stock increased outlet2'); +}); + +it('orders bottle activity logs by latest first', function () { + $bottle = Bottle::factory()->create(); + $outlet = $this->outlets->first(); + + // Create activities with different timestamps + $activity1 = Activity::create([ + 'log_name' => 'default', + 'description' => 'first activity', + 'subject_type' => get_class($bottle), + 'subject_id' => $bottle->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($bottle), + 'subject_id' => $bottle->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 = mountBottleAuditComponent($this->user, $outlet, $bottle); + + expect($component->activityLogs)->toHaveCount(2); + expect($component->activityLogs->first()->description)->toBe('second activity'); + expect($component->activityLogs->last()->description)->toBe('first activity'); +}); + +/* +|-------------------------------------------------------------------------- +| Activity Logs Content Tests +|-------------------------------------------------------------------------- +*/ + +it('maps bottle activity events to correct labels and colors', function () { + $bottle = Bottle::factory()->create(); + $outlet = $this->outlets->first(); + + // Test INCREASED event + Activity::create([ + 'log_name' => 'default', + 'description' => 'Stock increased', + 'subject_type' => get_class($bottle), + 'subject_id' => $bottle->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($bottle), + 'subject_id' => $bottle->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($bottle), + 'subject_id' => $bottle->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 = mountBottleAuditComponent($this->user, $outlet, $bottle); + + expect($component->activityLogs)->toHaveCount(3); + + // Check INCREASED event mapping + $increasedLog = collect($component->activityLogs)->firstWhere('event.label', AuditStockStatus::INCREASED->label()); + expect($increasedLog['event']['color'])->toBe(AuditStockStatus::INCREASED->color()); + + // Check DECREASED event mapping + $decreasedLog = collect($component->activityLogs)->firstWhere('event.label', AuditStockStatus::DECREASED->label()); + expect($decreasedLog['event']['color'])->toBe(AuditStockStatus::DECREASED->color()); + + // Check UPDATED event mapping + $updatedLog = collect($component->activityLogs)->firstWhere('event.label', AuditStockStatus::UPDATED->label()); + expect($updatedLog['event']['color'])->toBe(AuditStockStatus::UPDATED->color()); +}); + +/* +|-------------------------------------------------------------------------- +| Empty State Tests +|-------------------------------------------------------------------------- +*/ + +it('handles empty bottle activity logs in audit component', function () { + $bottle = Bottle::factory()->create(); + $outlet = $this->outlets->first(); + + $component = mountBottleAuditComponent($this->user, $outlet, $bottle); + + expect($component->activityLogs)->toBeInstanceOf(\Illuminate\Database\Eloquent\Collection::class); + expect($component->activityLogs)->toHaveCount(0); +}); + +it('handles bottle with no activities in specific outlet', function () { + $bottle = Bottle::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($bottle), + 'subject_id' => $bottle->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 = mountBottleAuditComponent($this->user, $outlet1, $bottle); + $component2 = mountBottleAuditComponent($this->user, $outlet2, $bottle); + + expect($component1->activityLogs)->toHaveCount(0); + expect($component2->activityLogs)->toHaveCount(1); +}); + +/* +|-------------------------------------------------------------------------- +| Integration Tests +|-------------------------------------------------------------------------- +*/ + +it('displays real bottle activity log data correctly', function () { + $bottle = Bottle::factory()->create(['name' => 'Integration Bottle']); + $outlet = $this->outlets->first(); + + $activity = Activity::create([ + 'log_name' => 'default', + 'description' => 'Stock adjustment for integration test', + 'subject_type' => get_class($bottle), + 'subject_id' => $bottle->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 = mountBottleAuditComponent($this->user, $outlet, $bottle); + + 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 bottle activities across different outlets', function () { + $bottle = Bottle::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($bottle), + 'subject_id' => $bottle->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($bottle), + 'subject_id' => $bottle->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 = mountBottleAuditComponent($this->user, $outlet1, $bottle); + $component2 = mountBottleAuditComponent($this->user, $outlet2, $bottle); + + 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 bottle activity logs with missing properties gracefully', function () { + $bottle = Bottle::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($bottle), + 'subject_id' => $bottle->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 = mountBottleAuditComponent($this->user, $outlet, $bottle); + + expect($component->activityLogs)->toHaveCount(1); + // Component should render correctly with all required properties +}); + +it('handles bottle activities with null causer', function () { + $bottle = Bottle::factory()->create(); + $outlet = $this->outlets->first(); + + Activity::create([ + 'log_name' => 'default', + 'description' => 'Activity with no causer', + 'subject_type' => get_class($bottle), + 'subject_id' => $bottle->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 = mountBottleAuditComponent($this->user, $outlet, $bottle); + + expect($component->activityLogs)->toHaveCount(1); + expect($component->activityLogs->first()->causer)->toBeNull(); +}); diff --git a/tests/Feature/Livewire/Studio/Catalog/Bottle/CreateTest.php b/tests/Feature/Livewire/Studio/Catalog/Bottle/CreateTest.php new file mode 100644 index 0000000..f15497b --- /dev/null +++ b/tests/Feature/Livewire/Studio/Catalog/Bottle/CreateTest.php @@ -0,0 +1,346 @@ +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 mountBottleCreateComponent(User $user) +{ + return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Bottle\Create::class); +} + +/* +|-------------------------------------------------------------------------- +| Create Component Rendering & Mount +|-------------------------------------------------------------------------- +*/ + +it('renders bottle create page successfully', function () { + mountBottleCreateComponent($this->user) + ->assertViewIs('livewire.studio.catalog.bottle.form') + ->assertViewHas('pageTitle', 'Tambah Botol'); +}); + +it('initializes bottle form properties on mount', function () { + $component = mountBottleCreateComponent($this->user); + + expect($component->form->name)->toBe(''); + expect($component->form->size)->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 Bottle Tests +|-------------------------------------------------------------------------- +*/ + +it('creates bottle successfully when authorized', function () { + $data = [ + 'name' => 'New Bottle', + 'size' => '100', + 'cost_price' => '50000', + 'sale_price' => '75000', + 'description' => 'Bottle description', + 'outlet_ids' => [$this->outlets->first()->id, $this->outlets->last()->id], + ]; + + mountBottleCreateComponent($this->user) + ->set('form.name', $data['name']) + ->set('form.size', $data['size']) + ->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.bottle.index')); + + expect(Bottle::count())->toBe(1); + $bottle = Bottle::first(); + expect($bottle->name)->toBe($data['name']); + expect($bottle->size)->toBe(100); + expect($bottle->cost_price)->toBe(50000); + expect($bottle->sale_price)->toBe(75000); + expect($bottle->point_per_pcs)->toBe(750); // 75000 / 100 + expect($bottle->description)->toBe($data['description']); + expect($bottle->outlets)->toHaveCount(2); +}); + +it('prevents bottle creation when unauthorized', function () { + $this->ownerRole->revokePermissionTo('create bottle'); + Gate::define('create bottle', fn () => false); + + mountBottleCreateComponent($this->user) + ->set('form.name', 'Test Bottle') + ->set('form.size', '50') + ->set('form.sale_price', '25000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertForbidden(); +}); + +it('creates bottle 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 bottle')->first(); + $limitedRole->givePermissionTo($createPermission); + + mountBottleCreateComponent($this->user) + ->set('form.name', 'Test Bottle') + ->set('form.size', '30') + ->set('form.cost_price', '') // Empty cost price + ->set('form.sale_price', '20000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); + + $bottle = Bottle::first(); + expect($bottle->cost_price)->toBe(0); +}); + +/* +|-------------------------------------------------------------------------- +| Form Validation Tests +|-------------------------------------------------------------------------- +*/ + +it('validates required name field on bottle create', function () { + mountBottleCreateComponent($this->user) + ->set('form.name', '') + ->set('form.size', '100') + ->set('form.sale_price', '50000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('validates name field max length on bottle create', function () { + mountBottleCreateComponent($this->user) + ->set('form.name', str_repeat('a', 51)) + ->set('form.size', '100') + ->set('form.sale_price', '50000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('validates required size field on bottle create', function () { + mountBottleCreateComponent($this->user) + ->set('form.name', 'Test Bottle') + ->set('form.size', '') + ->set('form.sale_price', '50000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.size']); +}); + +it('validates size is unsigned integer on bottle create', function () { + mountBottleCreateComponent($this->user) + ->set('form.name', 'Test Bottle') + ->set('form.size', '-50') // Negative value + ->set('form.sale_price', '50000') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.size']); +}); + +it('validates required sale_price field on bottle create', function () { + mountBottleCreateComponent($this->user) + ->set('form.name', 'Test Bottle') + ->set('form.size', '100') + ->set('form.sale_price', '') + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.sale_price']); +}); + +it('validates sale_price is unsigned integer on bottle create', function () { + mountBottleCreateComponent($this->user) + ->set('form.name', 'Test Bottle') + ->set('form.size', '100') + ->set('form.sale_price', '-50000') // Negative value + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.sale_price']); +}); + +it('validates required outlet_ids field on bottle create', function () { + mountBottleCreateComponent($this->user) + ->set('form.name', 'Test Bottle') + ->set('form.size', '100') + ->set('form.sale_price', '50000') + ->set('form.outlet_ids', []) + ->call('save') + ->assertHasErrors(['form.outlet_ids']); +}); + +it('validates outlet_ids contains valid outlet ids on bottle create', function () { + mountBottleCreateComponent($this->user) + ->set('form.name', 'Test Bottle') + ->set('form.size', '100') + ->set('form.sale_price', '50000') + ->set('form.outlet_ids', [999]) + ->call('save') + ->assertHasErrors(['form.outlet_ids.*']); +}); + +/* +|-------------------------------------------------------------------------- +| Real-time Validation Tests +|-------------------------------------------------------------------------- +*/ + +it('validates bottle name field in real-time', function () { + $component = mountBottleCreateComponent($this->user); + + // Valid name + $component->set('form.name', 'Valid Bottle 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 bottle size field in real-time', function () { + $component = mountBottleCreateComponent($this->user); + + // Valid size + $component->set('form.size', '100') + ->assertHasNoErrors(['form.size']); + + // Empty size + $component->set('form.size', '') + ->assertHasErrors(['form.size']); + + // Negative size + $component->set('form.size', '-50') + ->assertHasErrors(['form.size']); +}); + +it('validates bottle price fields in real-time', function () { + $component = mountBottleCreateComponent($this->user); + + // Valid sale price + $component->set('form.sale_price', '50000') + ->assertHasNoErrors(['form.sale_price']); + + // Empty sale price + $component->set('form.sale_price', '') + ->assertHasErrors(['form.sale_price']); + + // Negative sale price + $component->set('form.sale_price', '-50000') + ->assertHasErrors(['form.sale_price']); + + // Valid cost price + $component->set('form.cost_price', '30000') + ->assertHasNoErrors(['form.cost_price']); + + // Negative cost price + $component->set('form.cost_price', '-30000') + ->assertHasErrors(['form.cost_price']); +}); + +it('validates bottle outlet_ids in real-time', function () { + $component = mountBottleCreateComponent($this->user); + + // Valid outlet IDs + $component->set('form.name', 'Test Bottle') + ->set('form.size', '100') + ->set('form.sale_price', '50000') + ->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 = mountBottleCreateComponent($this->user); + $component->set('form.name', 'Test Bottle') + ->set('form.size', '100') + ->set('form.sale_price', '50000') + ->set('form.outlet_ids', [999]) + ->call('save') + ->assertHasErrors(['form.outlet_ids.*']); +}); + +/* +|-------------------------------------------------------------------------- +| Integration Tests +|-------------------------------------------------------------------------- +*/ + +it('creates bottle and attaches to outlets correctly', function () { + $outlet1 = $this->outlets->first(); + $outlet2 = $this->outlets->last(); + + mountBottleCreateComponent($this->user) + ->set('form.name', 'Integration Test Bottle') + ->set('form.size', '200') + ->set('form.cost_price', '75000') // Required for Owner role + ->set('form.sale_price', '100000') + ->set('form.outlet_ids', [$outlet1->id, $outlet2->id]) + ->call('save') + ->assertHasNoErrors(); + + $bottle = Bottle::where('size', 200)->first(); + expect($bottle)->not->toBeNull(); + expect($bottle->outlets)->toHaveCount(2); + expect($bottle->outlets->pluck('id')->toArray())->toContain($outlet1->id, $outlet2->id); +}); + +it('handles large numbers in bottle price fields', function () { + mountBottleCreateComponent($this->user) + ->set('form.name', 'Expensive Bottle') + ->set('form.size', '500') + ->set('form.cost_price', '5000000') // 5 million + ->set('form.sale_price', '7500000') // 7.5 million + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); + + $bottle = Bottle::where('size', 500)->first(); + expect($bottle->cost_price)->toBe(5000000); + expect($bottle->sale_price)->toBe(7500000); + expect($bottle->point_per_pcs)->toBe(75000); // 7500000 / 100 +}); diff --git a/tests/Feature/Livewire/Studio/Catalog/Bottle/EditTest.php b/tests/Feature/Livewire/Studio/Catalog/Bottle/EditTest.php new file mode 100644 index 0000000..7f996db --- /dev/null +++ b/tests/Feature/Livewire/Studio/Catalog/Bottle/EditTest.php @@ -0,0 +1,345 @@ +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 mountBottleEditComponent(User $user, Bottle $bottle) +{ + return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Bottle\Edit::class, ['bottle' => $bottle->hash]); +} + +/* +|-------------------------------------------------------------------------- +| Edit Component Rendering & Mount +|-------------------------------------------------------------------------- +*/ + +it('renders bottle edit page successfully', function () { + $bottle = Bottle::factory()->create(); + + mountBottleEditComponent($this->user, $bottle) + ->assertViewIs('livewire.studio.catalog.bottle.form') + ->assertViewHas('pageTitle', 'Ubah Botol'); +}); + +it('populates bottle form with bottle data on mount', function () { + $bottle = Bottle::factory()->create([ + 'name' => 'Test Bottle', + 'size' => 100, + 'cost_price' => 50000, + 'sale_price' => 75000, + 'description' => 'Test description', + ]); + + $bottle->outlets()->attach([$this->outlets->first()->id]); + + $component = mountBottleEditComponent($this->user, $bottle); + + expect($component->form->name)->toBe('Test Bottle'); + expect($component->form->size)->toBe('100'); + expect($component->form->cost_price)->toBe('50000'); + expect($component->form->sale_price)->toBe('75000'); + expect($component->form->description)->toBe('Test description'); + expect($component->form->outlet_ids)->toBe([$this->outlets->first()->id]); +}); + +/* +|-------------------------------------------------------------------------- +| Update Bottle Tests +|-------------------------------------------------------------------------- +*/ + +it('updates bottle successfully when authorized', function () { + $updatePermission = Permission::where('name', 'update bottle')->first(); + $this->user->givePermissionTo($updatePermission); + + $bottle = Bottle::factory()->create([ + 'name' => 'Original Bottle', + 'size' => 50, + 'cost_price' => 25000, + 'sale_price' => 50000, + ]); + + $bottle->outlets()->attach([$this->outlets->first()->id]); + + $updateData = [ + 'name' => 'Updated Bottle', + 'size' => '100', + 'cost_price' => '40000', + 'sale_price' => '60000', + 'description' => 'Updated description', + 'outlet_ids' => [$this->outlets->first()->id, $this->outlets->last()->id], + ]; + + mountBottleEditComponent($this->user, $bottle) + ->set('form.name', $updateData['name']) + ->set('form.size', $updateData['size']) + ->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.bottle.index')); + + $bottle->refresh(); + expect($bottle->name)->toBe($updateData['name']); + expect($bottle->size)->toBe(100); + expect($bottle->cost_price)->toBe(40000); + expect($bottle->sale_price)->toBe(60000); + expect($bottle->point_per_pcs)->toBe(600); // 60000 / 100 + expect($bottle->description)->toBe($updateData['description']); + expect($bottle->outlets)->toHaveCount(2); +}); + +it('prevents bottle update when unauthorized', function () { + $this->ownerRole->revokePermissionTo('update bottle'); + Gate::define('update bottle', fn () => false); + + $bottle = Bottle::factory()->create(); + + mountBottleEditComponent($this->user, $bottle) + ->set('form.name', 'Updated Name') + ->call('save') + ->assertForbidden(); +}); + +it('syncs bottle outlet relationships correctly on update', function () { + $updatePermission = Permission::where('name', 'update bottle')->first(); + $this->user->givePermissionTo($updatePermission); + + $bottle = Bottle::factory()->create(); + $bottle->outlets()->attach([$this->outlets->first()->id]); + + // Change to different outlets + mountBottleEditComponent($this->user, $bottle) + ->set('form.name', 'Updated Bottle') + ->set('form.size', '200') + ->set('form.sale_price', '50000') + ->set('form.outlet_ids', [$this->outlets[1]->id, $this->outlets[2]->id]) + ->call('save') + ->assertHasNoErrors(); + + $bottle->refresh(); + expect($bottle->outlets)->toHaveCount(2); + expect($bottle->outlets->pluck('id')->toArray())->toBe([$this->outlets[1]->id, $this->outlets[2]->id]); +}); + +/* +|-------------------------------------------------------------------------- +| Form Validation Tests on Edit +|-------------------------------------------------------------------------- +*/ + +it('validates required name field on bottle update', function () { + $bottle = Bottle::factory()->create(); + + mountBottleEditComponent($this->user, $bottle) + ->set('form.name', '') + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('validates name field max length on bottle update', function () { + $bottle = Bottle::factory()->create(); + + mountBottleEditComponent($this->user, $bottle) + ->set('form.name', str_repeat('a', 51)) + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('validates required size field on bottle update', function () { + $bottle = Bottle::factory()->create(); + + mountBottleEditComponent($this->user, $bottle) + ->set('form.size', '') + ->call('save') + ->assertHasErrors(['form.size']); +}); + +it('validates size is unsigned integer on bottle update', function () { + $bottle = Bottle::factory()->create(); + + mountBottleEditComponent($this->user, $bottle) + ->set('form.size', '-100') + ->call('save') + ->assertHasErrors(['form.size']); +}); + +it('validates required sale_price field on bottle update', function () { + $bottle = Bottle::factory()->create(); + + mountBottleEditComponent($this->user, $bottle) + ->set('form.sale_price', '') + ->call('save') + ->assertHasErrors(['form.sale_price']); +}); + +it('validates sale_price is unsigned integer on bottle update', function () { + $bottle = Bottle::factory()->create(); + + mountBottleEditComponent($this->user, $bottle) + ->set('form.sale_price', '-50000') + ->call('save') + ->assertHasErrors(['form.sale_price']); +}); + +it('validates required outlet_ids field on bottle update', function () { + $bottle = Bottle::factory()->create(); + + mountBottleEditComponent($this->user, $bottle) + ->set('form.outlet_ids', []) + ->call('save') + ->assertHasErrors(['form.outlet_ids']); +}); + +it('validates outlet_ids contains valid outlet ids on bottle update', function () { + $bottle = Bottle::factory()->create(); + + mountBottleEditComponent($this->user, $bottle) + ->set('form.outlet_ids', [999]) + ->call('save') + ->assertHasErrors(['form.outlet_ids.*']); +}); + +/* +|-------------------------------------------------------------------------- +| Real-time Validation Tests on Edit +|-------------------------------------------------------------------------- +*/ + +it('validates bottle name field in real-time on edit', function () { + $bottle = Bottle::factory()->create(); + + $component = mountBottleEditComponent($this->user, $bottle); + + // Valid name + $component->set('form.name', 'Valid Updated Bottle 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 bottle size field in real-time on edit', function () { + $bottle = Bottle::factory()->create(); + + $component = mountBottleEditComponent($this->user, $bottle); + + // Valid size + $component->set('form.size', '150') + ->assertHasNoErrors(['form.size']); + + // Empty size + $component->set('form.size', '') + ->assertHasErrors(['form.size']); + + // Negative size + $component->set('form.size', '-50') + ->assertHasErrors(['form.size']); +}); + +it('validates bottle price fields in real-time on edit', function () { + $bottle = Bottle::factory()->create(); + + $component = mountBottleEditComponent($this->user, $bottle); + + // Valid sale price + $component->set('form.sale_price', '75000') + ->assertHasNoErrors(['form.sale_price']); + + // Empty sale price + $component->set('form.sale_price', '') + ->assertHasErrors(['form.sale_price']); + + // Negative sale price + $component->set('form.sale_price', '-75000') + ->assertHasErrors(['form.sale_price']); +}); + +/* +|-------------------------------------------------------------------------- +| Integration Tests +|-------------------------------------------------------------------------- +*/ + +it('updates bottle and syncs outlet relationships', function () { + $updatePermission = Permission::where('name', 'update bottle')->first(); + $this->user->givePermissionTo($updatePermission); + + $bottle = Bottle::factory()->create(); + $bottle->outlets()->attach([$this->outlets->first()->id]); + + $newOutlets = [$this->outlets[1]->id, $this->outlets[2]->id]; + + mountBottleEditComponent($this->user, $bottle) + ->set('form.name', 'Updated Integration Bottle') + ->set('form.size', '300') + ->set('form.sale_price', '90000') + ->set('form.outlet_ids', $newOutlets) + ->call('save') + ->assertHasNoErrors(); + + $bottle->refresh(); + expect($bottle->name)->toBe('Updated Integration Bottle'); + expect($bottle->size)->toBe(300); + expect($bottle->outlets)->toHaveCount(2); + expect($bottle->outlets->pluck('id')->toArray())->toBe($newOutlets); +}); + +/* +|-------------------------------------------------------------------------- +| Error Handling and Edge Cases +|-------------------------------------------------------------------------- +*/ + +it('handles invalid bottle hash in edit component', function () { + expect(fn () => mountBottleEditComponent($this->user, 'invalid-hash')) + ->toThrow(\TypeError::class); +}); + +it('validates bottle form data integrity during operations', function () { + // Test that form data is properly reset between operations + $bottle1 = Bottle::factory()->create(['name' => 'First Bottle']); + $bottle2 = Bottle::factory()->create(['name' => 'Second Bottle']); + + $component = mountBottleEditComponent($this->user, $bottle1); + + // Open modal for first bottle + expect($component->form->name)->toBe('First Bottle'); + + // Verify first bottle wasn't modified + $bottle1->refresh(); + expect($bottle1->name)->toBe('First Bottle'); +}); diff --git a/tests/Feature/Livewire/Studio/Catalog/Bottle/IndexTest.php b/tests/Feature/Livewire/Studio/Catalog/Bottle/IndexTest.php new file mode 100644 index 0000000..bbb800e --- /dev/null +++ b/tests/Feature/Livewire/Studio/Catalog/Bottle/IndexTest.php @@ -0,0 +1,115 @@ +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 mountBottleIndexComponent(User $user) +{ + return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Bottle\Index::class); +} + +/* +|-------------------------------------------------------------------------- +| Index Component Rendering & Basic Functionality +|-------------------------------------------------------------------------- +*/ + +it('renders bottle index page successfully', function () { + mountBottleIndexComponent($this->user) + ->assertViewIs('livewire.studio.catalog.bottle.index') + ->assertViewHas('pageTitle', 'Botol'); +}); + +it('displays bottles correctly', function () { + Bottle::factory()->count(3)->create(); + + // Just check that the component renders without errors and has bottles + $component = mountBottleIndexComponent($this->user); + $component->assertViewIs('livewire.studio.catalog.bottle.index'); + expect(Bottle::count())->toBe(3); +}); + +/* +|-------------------------------------------------------------------------- +| Delete Bottle Tests +|-------------------------------------------------------------------------- +*/ + +it('deletes bottle successfully when authorized', function () { + $bottle = Bottle::factory()->create([ + 'name' => 'Bottle to Delete', + ]); + + mountBottleIndexComponent($this->user) + ->call('delete', $bottle) + ->assertHasNoErrors() + ->assertDispatched('refreshDatatable'); + + $this->assertSoftDeleted('bottles', [ + 'id' => $bottle->id, + ]); +}); + +it('prevents bottle deletion when unauthorized', function () { + $this->ownerRole->revokePermissionTo('delete bottle'); + + $bottle = Bottle::factory()->create(); + + mountBottleIndexComponent($this->user) + ->call('delete', $bottle) + ->assertForbidden(); +}); + +/* +|-------------------------------------------------------------------------- +| Authorization Edge Cases +|-------------------------------------------------------------------------- +*/ + +it('handles unauthorized user with revoked permissions', function () { + // Revoke all bottle permissions + $this->ownerRole->revokePermissionTo(['delete bottle']); + + $bottle = Bottle::factory()->create(); + + // Test delete + mountBottleIndexComponent($this->user) + ->call('delete', $bottle) + ->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([]); + + $bottle = Bottle::factory()->create(); + + // Should not be able to delete + mountBottleIndexComponent($this->user) + ->call('delete', $bottle) + ->assertForbidden(); +}); diff --git a/tests/Feature/Livewire/Studio/Catalog/Perfume/AuditTest.php b/tests/Feature/Livewire/Studio/Catalog/Perfume/AuditTest.php new file mode 100644 index 0000000..72c84ab --- /dev/null +++ b/tests/Feature/Livewire/Studio/Catalog/Perfume/AuditTest.php @@ -0,0 +1,475 @@ +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 mountPerfumeAuditComponent(User $user, Outlet $outlet, Perfume $perfume) +{ + return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Perfume\Audit::class, ['outlet' => $outlet, 'perfume' => $perfume]); +} + +/* +|-------------------------------------------------------------------------- +| Audit Component Rendering & Mount +|-------------------------------------------------------------------------- +*/ + +it('renders perfume audit page successfully', function () { + $perfume = Perfume::factory()->create(); + $outlet = $this->outlets->first(); + + mountPerfumeAuditComponent($this->user, $outlet, $perfume) + ->assertViewIs('components.sections.ui.audit') + ->assertViewHas('pageTitle', "Audit {$perfume->name} di {$outlet->name}") + ->assertViewHas('module', 'perfume'); +}); + +it('sets perfume label correctly on mount', function () { + $perfume = Perfume::factory()->create(['name' => 'Test Perfume']); + $outlet = $this->outlets->first(); + + $component = mountPerfumeAuditComponent($this->user, $outlet, $perfume); + + expect($component->label)->toBe('Test Perfume di '.$outlet->name); +}); + +/* +|-------------------------------------------------------------------------- +| Activity Logs Display Tests +|-------------------------------------------------------------------------- +*/ + +it('displays perfume activity logs correctly', function () { + $perfume = Perfume::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($perfume), + 'subject_id' => $perfume->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($perfume), + 'subject_id' => $perfume->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 = mountPerfumeAuditComponent($this->user, $outlet, $perfume); + + 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 perfume activity logs by outlet', function () { + $perfume = Perfume::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($perfume), + 'subject_id' => $perfume->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($perfume), + 'subject_id' => $perfume->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 = mountPerfumeAuditComponent($this->user, $outlet1, $perfume); + $component2 = mountPerfumeAuditComponent($this->user, $outlet2, $perfume); + + expect($component1->activityLogs)->toHaveCount(1); + expect($component1->activityLogs[0]['description'])->toBe('Stock updated outlet1'); + + expect($component2->activityLogs)->toHaveCount(1); + expect($component2->activityLogs[0]['description'])->toBe('Stock increased outlet2'); +}); + +it('orders perfume activity logs by latest first', function () { + $perfume = Perfume::factory()->create(); + $outlet = $this->outlets->first(); + + // Create activities with different timestamps + $activity1 = Activity::create([ + 'log_name' => 'default', + 'description' => 'first activity', + 'subject_type' => get_class($perfume), + 'subject_id' => $perfume->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($perfume), + 'subject_id' => $perfume->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 = mountPerfumeAuditComponent($this->user, $outlet, $perfume); + + expect($component->activityLogs)->toHaveCount(2); + expect($component->activityLogs[0]['description'])->toBe('second activity'); + expect($component->activityLogs[1]['description'])->toBe('first activity'); +}); + +/* +|-------------------------------------------------------------------------- +| Activity Logs Content Tests +|-------------------------------------------------------------------------- +*/ + +it('maps perfume activity events to correct labels and colors', function () { + $perfume = Perfume::factory()->create(); + $outlet = $this->outlets->first(); + + // Test INCREASED event + Activity::create([ + 'log_name' => 'default', + 'description' => 'Stock increased', + 'subject_type' => get_class($perfume), + 'subject_id' => $perfume->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($perfume), + 'subject_id' => $perfume->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($perfume), + 'subject_id' => $perfume->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 = mountPerfumeAuditComponent($this->user, $outlet, $perfume); + + expect($component->activityLogs)->toHaveCount(3); + + // Check INCREASED event mapping + $increasedLog = collect($component->activityLogs)->firstWhere('event.label', AuditStockStatus::INCREASED->label()); + expect($increasedLog['event']['color'])->toBe(AuditStockStatus::INCREASED->color()); + + // Check DECREASED event mapping + $decreasedLog = collect($component->activityLogs)->firstWhere('event.label', AuditStockStatus::DECREASED->label()); + expect($decreasedLog['event']['color'])->toBe(AuditStockStatus::DECREASED->color()); + + // Check UPDATED event mapping + $updatedLog = collect($component->activityLogs)->firstWhere('event.label', AuditStockStatus::UPDATED->label()); + expect($updatedLog['event']['color'])->toBe(AuditStockStatus::UPDATED->color()); +}); + +/* +|-------------------------------------------------------------------------- +| Empty State Tests +|-------------------------------------------------------------------------- +*/ + +it('handles empty perfume activity logs in audit component', function () { + $perfume = Perfume::factory()->create(); + $outlet = $this->outlets->first(); + + $component = mountPerfumeAuditComponent($this->user, $outlet, $perfume); + + expect($component->activityLogs)->toBeInstanceOf(\Illuminate\Database\Eloquent\Collection::class); + expect($component->activityLogs)->toHaveCount(0); +}); + +it('handles perfume with no activities in specific outlet', function () { + $perfume = Perfume::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($perfume), + 'subject_id' => $perfume->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 = mountPerfumeAuditComponent($this->user, $outlet1, $perfume); + $component2 = mountPerfumeAuditComponent($this->user, $outlet2, $perfume); + + expect($component1->activityLogs)->toHaveCount(0); + expect($component2->activityLogs)->toHaveCount(1); +}); + +/* +|-------------------------------------------------------------------------- +| Integration Tests +|-------------------------------------------------------------------------- +*/ + +it('displays real perfume activity log data correctly', function () { + $perfume = Perfume::factory()->create(['name' => 'Integration Perfume']); + $outlet = $this->outlets->first(); + + $activity = Activity::create([ + 'log_name' => 'default', + 'description' => 'Stock adjustment for integration test', + 'subject_type' => get_class($perfume), + 'subject_id' => $perfume->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 = mountPerfumeAuditComponent($this->user, $outlet, $perfume); + + 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 perfume activities across different outlets', function () { + $perfume = Perfume::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($perfume), + 'subject_id' => $perfume->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($perfume), + 'subject_id' => $perfume->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 = mountPerfumeAuditComponent($this->user, $outlet1, $perfume); + $component2 = mountPerfumeAuditComponent($this->user, $outlet2, $perfume); + + expect($component1->activityLogs)->toHaveCount(1); + expect($component1->activityLogs[0]['description'])->toBe('Activity in outlet 1'); + + expect($component2->activityLogs)->toHaveCount(1); + expect($component2->activityLogs[0]['description'])->toBe('Activity in outlet 2'); +}); + +/* +|-------------------------------------------------------------------------- +| Edge Cases and Error Handling +|-------------------------------------------------------------------------- +*/ + +it('handles perfume activity logs with missing properties gracefully', function () { + $perfume = Perfume::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($perfume), + 'subject_id' => $perfume->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 = mountPerfumeAuditComponent($this->user, $outlet, $perfume); + + expect($component->activityLogs)->toHaveCount(1); + // Component should render correctly with all required properties +}); + +it('handles perfume activities with null causer', function () { + $perfume = Perfume::factory()->create(); + $outlet = $this->outlets->first(); + + Activity::create([ + 'log_name' => 'default', + 'description' => 'Activity with no causer', + 'subject_type' => get_class($perfume), + 'subject_id' => $perfume->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 = mountPerfumeAuditComponent($this->user, $outlet, $perfume); + + expect($component->activityLogs)->toHaveCount(1); + expect($component->activityLogs[0]['user'])->toBeNull(); +}); diff --git a/tests/Feature/Livewire/Studio/Catalog/Perfume/CreateTest.php b/tests/Feature/Livewire/Studio/Catalog/Perfume/CreateTest.php new file mode 100644 index 0000000..7307276 --- /dev/null +++ b/tests/Feature/Livewire/Studio/Catalog/Perfume/CreateTest.php @@ -0,0 +1,457 @@ +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 data + $this->outlets = Outlet::factory()->count(3)->create(); + $this->brands = Brand::factory()->count(2)->create(); + $this->categories = Category::factory()->count(3)->create(); +}); + +function mountPerfumeCreateComponent(User $user) +{ + return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Perfume\Create::class); +} + +/* +|-------------------------------------------------------------------------- +| Create Component Rendering & Mount +|-------------------------------------------------------------------------- +*/ + +it('renders perfume create page successfully', function () { + mountPerfumeCreateComponent($this->user) + ->assertViewIs('livewire.studio.catalog.perfume.form') + ->assertViewHas('pageTitle', 'Tambah Parfum'); +}); + +it('initializes perfume form properties on mount', function () { + $component = mountPerfumeCreateComponent($this->user); + + expect($component->form->name)->toBe(''); + expect($component->form->sku)->toBe(''); + expect($component->form->brand_id)->toBeNull(); + expect($component->form->cost_price)->toBe(''); + expect($component->form->sale_price)->toBe(''); + expect($component->form->concentration)->toBe('2'); + expect($component->form->base_notes)->toBeNull(); + expect($component->form->middle_notes)->toBeNull(); + expect($component->form->top_notes)->toBeNull(); + expect($component->form->description)->toBeNull(); + expect($component->form->category_ids)->toBe([]); + expect($component->form->outlet_ids)->toBe([]); + expect($component->form->image)->toBe([]); + expect($component->outlets)->toHaveCount(3); + expect($component->brands)->toHaveCount(2); + expect($component->categories)->toHaveCount(3); +}); + +/* +|-------------------------------------------------------------------------- +| Create Perfume Tests +|-------------------------------------------------------------------------- +*/ + +it('creates perfume successfully when authorized', function () { + $data = [ + 'name' => 'New Perfume', + 'sku' => 'NP001', + 'brand_id' => $this->brands->first()->id, + 'cost_price' => '150000', + 'sale_price' => '250000', + 'concentration' => Concentration::EAU_DE_PERFUME->value, + 'base_notes' => 'Vanilla, Musk', + 'middle_notes' => 'Jasmine, Rose', + 'top_notes' => 'Bergamot, Lemon', + 'description' => 'A beautiful fragrance', + 'category_ids' => [$this->categories->first()->id, $this->categories->last()->id], + 'outlet_ids' => [$this->outlets->first()->id, $this->outlets->last()->id], + ]; + + mountPerfumeCreateComponent($this->user) + ->set('form.name', $data['name']) + ->set('form.sku', $data['sku']) + ->set('form.brand_id', $data['brand_id']) + ->set('form.cost_price', $data['cost_price']) + ->set('form.sale_price', $data['sale_price']) + ->set('form.concentration', $data['concentration']) + ->set('form.base_notes', $data['base_notes']) + ->set('form.middle_notes', $data['middle_notes']) + ->set('form.top_notes', $data['top_notes']) + ->set('form.description', $data['description']) + ->set('form.category_ids', $data['category_ids']) + ->set('form.outlet_ids', $data['outlet_ids']) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('studio.catalog.perfume.index')); + + expect(Perfume::count())->toBe(1); + $perfume = Perfume::first(); + expect($perfume->name)->toBe($data['name']); + expect($perfume->sku)->toBe($data['sku']); + expect($perfume->brand_id)->toBe($data['brand_id']); + expect($perfume->cost_price)->toBe(150000); + expect($perfume->sale_price)->toBe(250000); + expect($perfume->point_per_ml)->toBe(250); // 250000 / 1000 + expect($perfume->concentration->value)->toBe($data['concentration']); + expect($perfume->base_notes)->toBe($data['base_notes']); + expect($perfume->middle_notes)->toBe($data['middle_notes']); + expect($perfume->top_notes)->toBe($data['top_notes']); + expect($perfume->description)->toBe($data['description']); + expect($perfume->categories)->toHaveCount(2); + expect($perfume->outlets)->toHaveCount(2); +}); + +it('prevents perfume creation when unauthorized', function () { + $this->ownerRole->revokePermissionTo('create perfume'); + Gate::define('create perfume', fn () => false); + + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Test Perfume') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '100000') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertForbidden(); +}); + +it('creates perfume 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 perfume')->first(); + $limitedRole->givePermissionTo($createPermission); + + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Test Perfume') + ->set('form.sku', 'TP001') + ->set('form.cost_price', '') // Empty cost price + ->set('form.sale_price', '200000') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); + + $perfume = Perfume::first(); + expect($perfume->cost_price)->toBe(0); +}); + +it('creates perfume with optional brand', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Perfume Without Brand') + ->set('form.sku', 'PWB001') + ->set('form.brand_id', null) // No brand + ->set('form.cost_price', '100000') // Required for Owner role + ->set('form.sale_price', '150000') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); + + $perfume = Perfume::first(); + expect($perfume->brand_id)->toBeNull(); +}); + +/* +|-------------------------------------------------------------------------- +| Form Validation Tests +|-------------------------------------------------------------------------- +*/ + +it('validates required name field on perfume create', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', '') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '100000') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('validates name field max length on perfume create', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', str_repeat('a', 51)) + ->set('form.sku', 'TP001') + ->set('form.sale_price', '100000') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('validates required sku field on perfume create', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Test Perfume') + ->set('form.sku', '') + ->set('form.sale_price', '100000') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.sku']); +}); + +it('validates sku field max length on perfume create', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Test Perfume') + ->set('form.sku', str_repeat('a', 21)) + ->set('form.sale_price', '100000') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.sku']); +}); + +it('validates sku uniqueness on perfume create', function () { + Perfume::factory()->create(['sku' => 'EXISTING001']); + + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Test Perfume') + ->set('form.sku', 'EXISTING001') + ->set('form.sale_price', '100000') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.sku']); +}); + +it('validates brand_id exists if provided', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Test Perfume') + ->set('form.sku', 'TP001') + ->set('form.brand_id', 999) // Non-existent brand + ->set('form.sale_price', '100000') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.brand_id']); +}); + +it('validates required sale_price field on perfume create', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Test Perfume') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.sale_price']); +}); + +it('validates concentration is valid enum value', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Test Perfume') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '100000') + ->set('form.concentration', 'invalid') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.concentration']); +}); + +it('validates required category_ids field on perfume create', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Test Perfume') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '100000') + ->set('form.category_ids', []) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.category_ids']); +}); + +it('validates category_ids contains valid category ids', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Test Perfume') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '100000') + ->set('form.category_ids', [999]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.category_ids.*']); +}); + +it('validates required outlet_ids field on perfume create', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Test Perfume') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '100000') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', []) + ->call('save') + ->assertHasErrors(['form.outlet_ids']); +}); + +it('validates outlet_ids contains valid outlet ids on perfume create', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Test Perfume') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '100000') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [999]) + ->call('save') + ->assertHasErrors(['form.outlet_ids.*']); +}); + +it('validates notes fields max length', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Test Perfume') + ->set('form.sku', 'TP001') + ->set('form.sale_price', '100000') + ->set('form.base_notes', str_repeat('a', 256)) // Exceeds 255 + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.base_notes']); +}); + +/* +|-------------------------------------------------------------------------- +| Real-time Validation Tests +|-------------------------------------------------------------------------- +*/ + +it('validates perfume name field in real-time', function () { + $component = mountPerfumeCreateComponent($this->user); + + // Valid name + $component->set('form.name', 'Valid Perfume 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 perfume sku field in real-time', function () { + $component = mountPerfumeCreateComponent($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 + Perfume::factory()->create(['sku' => 'EXISTING001']); + $component->set('form.sku', 'EXISTING001') + ->assertHasErrors(['form.sku']); +}); + +it('validates perfume price fields in real-time', function () { + $component = mountPerfumeCreateComponent($this->user); + + // Valid sale price + $component->set('form.sale_price', '200000') + ->assertHasNoErrors(['form.sale_price']); + + // Empty sale price + $component->set('form.sale_price', '') + ->assertHasErrors(['form.sale_price']); + + // Negative sale price + $component->set('form.sale_price', '-200000') + ->assertHasErrors(['form.sale_price']); +}); + +it('validates perfume concentration field in real-time', function () { + $component = mountPerfumeCreateComponent($this->user); + + // Valid concentration + $component->set('form.concentration', Concentration::EAU_DE_PERFUME->value) + ->assertHasNoErrors(['form.concentration']); + + // Invalid concentration + $component->set('form.concentration', 'invalid') + ->assertHasErrors(['form.concentration']); +}); + +/* +|-------------------------------------------------------------------------- +| Integration Tests +|-------------------------------------------------------------------------- +*/ + +it('creates perfume and attaches to categories and outlets correctly', function () { + $category1 = $this->categories->first(); + $category2 = $this->categories->last(); + $outlet1 = $this->outlets->first(); + $outlet2 = $this->outlets->last(); + $brand = $this->brands->first(); + + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Integration Test Perfume') + ->set('form.sku', 'ITP001') + ->set('form.brand_id', $brand->id) + ->set('form.cost_price', '200000') // Required for Owner role + ->set('form.sale_price', '350000') + ->set('form.concentration', Concentration::EAU_DE_PERFUME->value) + ->set('form.category_ids', [$category1->id, $category2->id]) + ->set('form.outlet_ids', [$outlet1->id, $outlet2->id]) + ->call('save') + ->assertHasNoErrors(); + + $perfume = Perfume::where('sku', 'ITP001')->first(); + expect($perfume)->not->toBeNull(); + expect($perfume->brand->id)->toBe($brand->id); + expect($perfume->categories)->toHaveCount(2); + expect($perfume->outlets)->toHaveCount(2); + expect($perfume->categories->pluck('id')->toArray())->toContain($category1->id, $category2->id); + expect($perfume->outlets->pluck('id')->toArray())->toContain($outlet1->id, $outlet2->id); +}); + +it('handles large numbers in perfume price fields', function () { + mountPerfumeCreateComponent($this->user) + ->set('form.name', 'Expensive Perfume') + ->set('form.sku', 'EP001') + ->set('form.cost_price', '10000000') // 10 million + ->set('form.sale_price', '15000000') // 15 million + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); + + $perfume = Perfume::where('sku', 'EP001')->first(); + expect($perfume->cost_price)->toBe(10000000); + expect($perfume->sale_price)->toBe(15000000); + expect($perfume->point_per_ml)->toBe(15000); // 15000000 / 1000 +}); diff --git a/tests/Feature/Livewire/Studio/Catalog/Perfume/EditTest.php b/tests/Feature/Livewire/Studio/Catalog/Perfume/EditTest.php new file mode 100644 index 0000000..32150fa --- /dev/null +++ b/tests/Feature/Livewire/Studio/Catalog/Perfume/EditTest.php @@ -0,0 +1,481 @@ +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 data + $this->outlets = Outlet::factory()->count(3)->create(); + $this->brands = Brand::factory()->count(2)->create(); + $this->categories = Category::factory()->count(3)->create(); +}); + +function mountPerfumeEditComponent(User $user, Perfume $perfume) +{ + return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Perfume\Edit::class, ['perfume' => $perfume->hash]); +} + +/* +|-------------------------------------------------------------------------- +| Edit Component Rendering & Mount +|-------------------------------------------------------------------------- +*/ + +it('renders perfume edit page successfully', function () { + $perfume = Perfume::factory()->create(); + + mountPerfumeEditComponent($this->user, $perfume) + ->assertViewIs('livewire.studio.catalog.perfume.form') + ->assertViewHas('pageTitle', 'Ubah Parfum'); +}); + +it('populates perfume form with perfume data on mount', function () { + $brand = $this->brands->first(); + $perfume = Perfume::factory()->create([ + 'name' => 'Test Perfume', + 'sku' => 'TP001', + 'brand_id' => $brand->id, + 'cost_price' => 150000, + 'sale_price' => 250000, + 'concentration' => Concentration::EAU_DE_PERFUME, + 'base_notes' => 'Vanilla, Musk', + 'middle_notes' => 'Jasmine, Rose', + 'top_notes' => 'Bergamot, Lemon', + 'description' => 'Test description', + ]); + + $perfume->categories()->attach([$this->categories->first()->id]); + $perfume->outlets()->attach([$this->outlets->first()->id]); + + $component = mountPerfumeEditComponent($this->user, $perfume); + + expect($component->form->name)->toBe('Test Perfume'); + expect($component->form->sku)->toBe('TP001'); + expect($component->form->brand_id)->toBe((string) $brand->id); + expect($component->form->cost_price)->toBe('150000'); + expect($component->form->sale_price)->toBe('250000'); + expect($component->form->concentration)->toBe((string) Concentration::EAU_DE_PERFUME->value); + expect($component->form->base_notes)->toBe('Vanilla, Musk'); + expect($component->form->middle_notes)->toBe('Jasmine, Rose'); + expect($component->form->top_notes)->toBe('Bergamot, Lemon'); + expect($component->form->description)->toBe('Test description'); + expect($component->form->category_ids)->toBe([$this->categories->first()->id]); + expect($component->form->outlet_ids)->toBe([$this->outlets->first()->id]); +}); + +/* +|-------------------------------------------------------------------------- +| Update Perfume Tests +|-------------------------------------------------------------------------- +*/ + +it('updates perfume successfully when authorized', function () { + $updatePermission = Permission::where('name', 'update perfume')->first(); + $this->user->givePermissionTo($updatePermission); + + $oldBrand = $this->brands->first(); + $perfume = Perfume::factory()->create([ + 'name' => 'Original Perfume', + 'sku' => 'OP001', + 'brand_id' => $oldBrand->id, + 'cost_price' => 100000, + 'sale_price' => 200000, + 'concentration' => Concentration::EAU_DE_TOILETTE, + ]); + + $perfume->categories()->attach([$this->categories->first()->id]); + $perfume->outlets()->attach([$this->outlets->first()->id]); + + $newBrand = $this->brands->last(); + $updateData = [ + 'name' => 'Updated Perfume', + 'sku' => 'UP001', + 'brand_id' => $newBrand->id, + 'cost_price' => '150000', + 'sale_price' => '300000', + 'concentration' => Concentration::EAU_DE_PERFUME->value, + 'base_notes' => 'Updated Base Notes', + 'middle_notes' => 'Updated Middle Notes', + 'top_notes' => 'Updated Top Notes', + 'description' => 'Updated description', + 'category_ids' => [$this->categories->first()->id, $this->categories->last()->id], + 'outlet_ids' => [$this->outlets->first()->id, $this->outlets->last()->id], + ]; + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.name', $updateData['name']) + ->set('form.sku', $updateData['sku']) + ->set('form.brand_id', $updateData['brand_id']) + ->set('form.cost_price', $updateData['cost_price']) + ->set('form.sale_price', $updateData['sale_price']) + ->set('form.concentration', $updateData['concentration']) + ->set('form.base_notes', $updateData['base_notes']) + ->set('form.middle_notes', $updateData['middle_notes']) + ->set('form.top_notes', $updateData['top_notes']) + ->set('form.description', $updateData['description']) + ->set('form.category_ids', $updateData['category_ids']) + ->set('form.outlet_ids', $updateData['outlet_ids']) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('studio.catalog.perfume.index')); + + $perfume->refresh(); + expect($perfume->name)->toBe($updateData['name']); + expect($perfume->sku)->toBe($updateData['sku']); + expect($perfume->brand_id)->toBe($updateData['brand_id']); + expect($perfume->cost_price)->toBe(150000); + expect($perfume->sale_price)->toBe(300000); + expect($perfume->point_per_ml)->toBe(300); // 300000 / 1000 + expect($perfume->concentration->value)->toBe($updateData['concentration']); + expect($perfume->base_notes)->toBe($updateData['base_notes']); + expect($perfume->middle_notes)->toBe($updateData['middle_notes']); + expect($perfume->top_notes)->toBe($updateData['top_notes']); + expect($perfume->description)->toBe($updateData['description']); + expect($perfume->categories)->toHaveCount(2); + expect($perfume->outlets)->toHaveCount(2); +}); + +it('prevents perfume update when unauthorized', function () { + $this->ownerRole->revokePermissionTo('update perfume'); + Gate::define('update perfume', fn () => false); + + $perfume = Perfume::factory()->create(); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.name', 'Updated Name') + ->call('save') + ->assertForbidden(); +}); + +it('syncs perfume category and outlet relationships correctly on update', function () { + $updatePermission = Permission::where('name', 'update perfume')->first(); + $this->user->givePermissionTo($updatePermission); + + $perfume = Perfume::factory()->create(); + $perfume->categories()->attach([$this->categories->first()->id]); + $perfume->outlets()->attach([$this->outlets->first()->id]); + + // Change to different categories and outlets + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.name', 'Updated Perfume') + ->set('form.sku', 'UP001') + ->set('form.sale_price', '200000') + ->set('form.category_ids', [$this->categories[1]->id, $this->categories[2]->id]) + ->set('form.outlet_ids', [$this->outlets[1]->id, $this->outlets[2]->id]) + ->call('save') + ->assertHasNoErrors(); + + $perfume->refresh(); + expect($perfume->categories)->toHaveCount(2); + expect($perfume->outlets)->toHaveCount(2); + expect($perfume->categories->pluck('id')->toArray())->toBe([$this->categories[1]->id, $this->categories[2]->id]); + expect($perfume->outlets->pluck('id')->toArray())->toBe([$this->outlets[1]->id, $this->outlets[2]->id]); +}); + +it('allows same sku on update of same perfume', function () { + $updatePermission = Permission::where('name', 'update perfume')->first(); + $this->user->givePermissionTo($updatePermission); + + $perfume = Perfume::factory()->create(['sku' => 'EXISTING001']); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.name', 'Updated Perfume') + ->set('form.sku', 'EXISTING001') // Same SKU as existing perfume + ->set('form.sale_price', '200000') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); +}); + +it('updates perfume brand to null', function () { + $updatePermission = Permission::where('name', 'update perfume')->first(); + $this->user->givePermissionTo($updatePermission); + + $perfume = Perfume::factory()->create(['brand_id' => $this->brands->first()->id]); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.name', 'Perfume Without Brand') + ->set('form.sku', 'PWB001') + ->set('form.brand_id', null) // Remove brand + ->set('form.sale_price', '150000') + ->set('form.category_ids', [$this->categories->first()->id]) + ->set('form.outlet_ids', [$this->outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); + + $perfume->refresh(); + expect($perfume->brand_id)->toBeNull(); +}); + +/* +|-------------------------------------------------------------------------- +| Form Validation Tests on Edit +|-------------------------------------------------------------------------- +*/ + +it('validates required name field on perfume update', function () { + $perfume = Perfume::factory()->create(); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.name', '') + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('validates name field max length on perfume update', function () { + $perfume = Perfume::factory()->create(); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.name', str_repeat('a', 51)) + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('validates required sku field on perfume update', function () { + $perfume = Perfume::factory()->create(); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.sku', '') + ->call('save') + ->assertHasErrors(['form.sku']); +}); + +it('validates sku field max length on perfume update', function () { + $perfume = Perfume::factory()->create(); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.sku', str_repeat('a', 21)) + ->call('save') + ->assertHasErrors(['form.sku']); +}); + +it('validates sku uniqueness on perfume update with different perfume', function () { + $perfume1 = Perfume::factory()->create(['sku' => 'SKU001']); + $perfume2 = Perfume::factory()->create(['sku' => 'SKU002']); + + mountPerfumeEditComponent($this->user, $perfume1) + ->set('form.sku', 'SKU002') // Try to use SKU from perfume2 + ->call('save') + ->assertHasErrors(['form.sku']); +}); + +it('validates brand_id exists if provided on perfume update', function () { + $perfume = Perfume::factory()->create(); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.brand_id', 999) // Non-existent brand + ->call('save') + ->assertHasErrors(['form.brand_id']); +}); + +it('validates required sale_price field on perfume update', function () { + $perfume = Perfume::factory()->create(); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.sale_price', '') + ->call('save') + ->assertHasErrors(['form.sale_price']); +}); + +it('validates concentration is valid enum value on perfume update', function () { + $perfume = Perfume::factory()->create(); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.concentration', 'invalid') + ->call('save') + ->assertHasErrors(['form.concentration']); +}); + +it('validates required category_ids field on perfume update', function () { + $perfume = Perfume::factory()->create(); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.category_ids', []) + ->call('save') + ->assertHasErrors(['form.category_ids']); +}); + +it('validates category_ids contains valid category ids on perfume update', function () { + $perfume = Perfume::factory()->create(); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.category_ids', [999]) + ->call('save') + ->assertHasErrors(['form.category_ids.*']); +}); + +it('validates required outlet_ids field on perfume update', function () { + $perfume = Perfume::factory()->create(); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.outlet_ids', []) + ->call('save') + ->assertHasErrors(['form.outlet_ids']); +}); + +it('validates outlet_ids contains valid outlet ids on perfume update', function () { + $perfume = Perfume::factory()->create(); + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.outlet_ids', [999]) + ->call('save') + ->assertHasErrors(['form.outlet_ids.*']); +}); + +/* +|-------------------------------------------------------------------------- +| Real-time Validation Tests on Edit +|-------------------------------------------------------------------------- +*/ + +it('validates perfume name field in real-time on edit', function () { + $perfume = Perfume::factory()->create(); + + $component = mountPerfumeEditComponent($this->user, $perfume); + + // Valid name + $component->set('form.name', 'Valid Updated Perfume 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 perfume sku field in real-time on edit', function () { + $perfume = Perfume::factory()->create(); + + $component = mountPerfumeEditComponent($this->user, $perfume); + + // 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 perfume price fields in real-time on edit', function () { + $perfume = Perfume::factory()->create(); + + $component = mountPerfumeEditComponent($this->user, $perfume); + + // Valid sale price + $component->set('form.sale_price', '300000') + ->assertHasNoErrors(['form.sale_price']); + + // Empty sale price + $component->set('form.sale_price', '') + ->assertHasErrors(['form.sale_price']); + + // Negative sale price + $component->set('form.sale_price', '-300000') + ->assertHasErrors(['form.sale_price']); +}); + +it('validates perfume concentration field in real-time on edit', function () { + $perfume = Perfume::factory()->create(); + + $component = mountPerfumeEditComponent($this->user, $perfume); + + // Valid concentration + $component->set('form.concentration', Concentration::EAU_DE_PERFUME->value) + ->assertHasNoErrors(['form.concentration']); + + // Invalid concentration + $component->set('form.concentration', 'invalid') + ->assertHasErrors(['form.concentration']); +}); + +/* +|-------------------------------------------------------------------------- +| Integration Tests +|-------------------------------------------------------------------------- +*/ + +it('updates perfume and syncs category and outlet relationships', function () { + $updatePermission = Permission::where('name', 'update perfume')->first(); + $this->user->givePermissionTo($updatePermission); + + $perfume = Perfume::factory()->create(); + $perfume->categories()->attach([$this->categories->first()->id]); + $perfume->outlets()->attach([$this->outlets->first()->id]); + + $newCategories = [$this->categories[1]->id, $this->categories[2]->id]; + $newOutlets = [$this->outlets[1]->id, $this->outlets[2]->id]; + + mountPerfumeEditComponent($this->user, $perfume) + ->set('form.name', 'Updated Integration Perfume') + ->set('form.sku', 'UIP001') + ->set('form.sale_price', '400000') + ->set('form.category_ids', $newCategories) + ->set('form.outlet_ids', $newOutlets) + ->call('save') + ->assertHasNoErrors(); + + $perfume->refresh(); + expect($perfume->name)->toBe('Updated Integration Perfume'); + expect($perfume->categories)->toHaveCount(2); + expect($perfume->outlets)->toHaveCount(2); + expect($perfume->categories->pluck('id')->toArray())->toBe($newCategories); + expect($perfume->outlets->pluck('id')->toArray())->toBe($newOutlets); +}); + +/* +|-------------------------------------------------------------------------- +| Error Handling and Edge Cases +|-------------------------------------------------------------------------- +*/ + +it('handles invalid perfume hash in edit component', function () { + expect(fn () => mountPerfumeEditComponent($this->user, 'invalid-hash')) + ->toThrow(\TypeError::class); +}); + +it('validates perfume form data integrity during operations', function () { + // Test that form data is properly reset between operations + $perfume1 = Perfume::factory()->create(['name' => 'First Perfume']); + $perfume2 = Perfume::factory()->create(['name' => 'Second Perfume']); + + $component = mountPerfumeEditComponent($this->user, $perfume1); + + // Open modal for first perfume + expect($component->form->name)->toBe('First Perfume'); + + // Verify first perfume wasn't modified + $perfume1->refresh(); + expect($perfume1->name)->toBe('First Perfume'); +}); diff --git a/tests/Feature/Livewire/Studio/Catalog/Perfume/IndexTest.php b/tests/Feature/Livewire/Studio/Catalog/Perfume/IndexTest.php new file mode 100644 index 0000000..8fc97d4 --- /dev/null +++ b/tests/Feature/Livewire/Studio/Catalog/Perfume/IndexTest.php @@ -0,0 +1,118 @@ +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 mountPerfumeIndexComponent(User $user) +{ + return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Perfume\Index::class); +} + +/* +|-------------------------------------------------------------------------- +| Index Component Rendering & Basic Functionality +|-------------------------------------------------------------------------- +*/ + +it('renders perfume index page successfully', function () { + mountPerfumeIndexComponent($this->user) + ->assertViewIs('livewire.studio.catalog.perfume.index') + ->assertViewHas('pageTitle', 'Parfum'); +}); + +it('displays perfumes correctly', function () { + Perfume::factory()->count(3)->create(); + + // Just check that the component renders without errors and has perfumes + $component = mountPerfumeIndexComponent($this->user); + $component->assertViewIs('livewire.studio.catalog.perfume.index'); + expect(Perfume::count())->toBe(3); +}); + +/* +|-------------------------------------------------------------------------- +| Delete Perfume Tests +|-------------------------------------------------------------------------- +*/ + +it('deletes perfume successfully when authorized', function () { + $perfume = Perfume::factory()->create([ + 'name' => 'Perfume to Delete', + ]); + + mountPerfumeIndexComponent($this->user) + ->call('delete', $perfume) + ->assertHasNoErrors() + ->assertDispatched('refreshDatatable'); + + $this->assertSoftDeleted('perfumes', [ + 'id' => $perfume->id, + ]); +}); + +it('prevents perfume deletion when unauthorized', function () { + $this->ownerRole->revokePermissionTo('delete perfume'); + + $perfume = Perfume::factory()->create(); + + mountPerfumeIndexComponent($this->user) + ->call('delete', $perfume) + ->assertForbidden(); +}); + +/* +|-------------------------------------------------------------------------- +| Authorization Edge Cases +|-------------------------------------------------------------------------- +*/ + +it('handles unauthorized user with revoked permissions', function () { + // Revoke all perfume permissions + $this->ownerRole->revokePermissionTo(['delete perfume']); + + // Define gates to return false + Gate::define('delete perfume', fn () => false); + + $perfume = Perfume::factory()->create(); + + // Test delete + mountPerfumeIndexComponent($this->user) + ->call('delete', $perfume) + ->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([]); + + $perfume = Perfume::factory()->create(); + + // Should not be able to delete + mountPerfumeIndexComponent($this->user) + ->call('delete', $perfume) + ->assertForbidden(); +});