feat(perfume. bottle): implementasi testing

- kasih return type
- ubah array to collection di audit
- mengubah waktu refresh tabel menjadi 30 detik
This commit is contained in:
Yoga Pangestu 2025-12-11 11:15:59 +07:00
parent f205aefbdc
commit 81a9753620
19 changed files with 2859 additions and 61 deletions

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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,

View File

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

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

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

View File

@ -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',

View File

@ -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',

View File

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

View File

@ -0,0 +1,466 @@
<?php
use App\Enums\AuditStockStatus;
use App\Models\Bottle;
use App\Models\Employee;
use App\Models\Outlet;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
use Spatie\Permission\Models\Role;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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();
});

View File

@ -0,0 +1,346 @@
<?php
use App\Models\Bottle;
use App\Models\Employee;
use App\Models\Outlet;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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
});

View File

@ -0,0 +1,345 @@
<?php
use App\Models\Bottle;
use App\Models\Employee;
use App\Models\Outlet;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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');
});

View File

@ -0,0 +1,115 @@
<?php
use App\Models\Bottle;
use App\Models\Employee;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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();
});

View File

@ -0,0 +1,475 @@
<?php
use App\Enums\AuditStockStatus;
use App\Models\Employee;
use App\Models\Outlet;
use App\Models\Perfume;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
use Spatie\Permission\Models\Role;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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();
});

View File

@ -0,0 +1,457 @@
<?php
use App\Enums\Concentration;
use App\Models\Brand;
use App\Models\Category;
use App\Models\Employee;
use App\Models\Outlet;
use App\Models\Perfume;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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
});

View File

@ -0,0 +1,481 @@
<?php
use App\Enums\Concentration;
use App\Models\Brand;
use App\Models\Category;
use App\Models\Employee;
use App\Models\Outlet;
use App\Models\Perfume;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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');
});

View File

@ -0,0 +1,118 @@
<?php
use App\Models\Employee;
use App\Models\Perfume;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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();
});