feat(product): 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 09:35:39 +07:00
parent 0c7ab27a78
commit c037accd33
11 changed files with 1348 additions and 44 deletions

View File

@ -20,7 +20,7 @@ class ProductsTable extends DataTableComponent
protected $model = Product::class;
protected int $refreshTime = 3000;
protected int $refreshTime = 30000;
public function columns(): array
{
@ -117,7 +117,6 @@ class='flex items-center space-x-2 bg-gray-50 border border-gray-200 rounded-lg
if (auth()->user()->can('delete product')) {
$actions .= view('components.actions.table.delete', [
'id' => $row->hash,
'deleteRoute' => route('studio.catalog.product.delete', $row->hash),
])->render();
}

View File

@ -61,7 +61,7 @@ public function validationAttributes(): array
];
}
public function setProduct(Product $product)
public function setProduct(Product $product): void
{
$product->load('outlets');
@ -76,7 +76,7 @@ public function setProduct(Product $product)
$this->image = $this->mapMediaCollection($product->getMedia('image'));
}
public function store()
public function store(): void
{
$this->validate();
@ -91,7 +91,7 @@ public function store()
});
}
public function update()
public function update(): void
{
$this->validate();
@ -107,7 +107,7 @@ public function update()
});
}
private function prepareSavedData()
private function prepareSavedData(): array
{
return [
'name' => $this->name,

View File

@ -5,6 +5,8 @@
use App\Enums\AuditStockStatus;
use App\Models\Outlet;
use App\Models\Product;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Title;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
@ -14,9 +16,9 @@ class Audit extends Component
{
public string $label;
public array $activityLogs = [];
public Collection $activityLogs;
public function mount(Outlet $outlet, Product $product)
public function mount(Outlet $outlet, Product $product): void
{
$this->label = $product->name.' di '.$outlet->name;
@ -26,27 +28,18 @@ public function mount(Outlet $outlet, Product $product)
->latest()
->get()
->map(function (Activity $activity) {
return [
'log_name' => $activity->log_name,
'created_at' => $activity->created_at,
'event' => [
'label' => $activity->event,
'color' => AuditStockStatus::from($activity->event)->color(),
],
'new_stock' => currency($activity->properties['new_stock']),
'previous_stock' => currency($activity->properties['previous_stock']),
'quantity_change' => currency($activity->properties['quantity_change']),
'user' => $activity->causer?->profile?->full_name,
'description' => $activity->description,
$activity->event = [
'label' => $activity->event,
'color' => AuditStockStatus::from($activity->event)->color(),
];
})
->toArray();
return $activity;
});
}
public function render()
public function render(): View
{
return view('components.partials.audit', [
return view('components.sections.ui.audit', [
'pageTitle' => 'Audit '.$this->label,
'module' => 'product',
]);

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 product');
@ -38,7 +39,7 @@ public function save()
$this->redirectRoute('studio.catalog.product.index');
}
public function render()
public function render(): View
{
return view('livewire.studio.catalog.product.form', [
'pageTitle' => 'Tambah Produk',

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(Product $product)
public function mount(Product $product): void
{
$this->form->setProduct($product);
$this->outlets = Outlet::pluck('name', 'id')->toArray();
}
public function save()
public function save(): void
{
$this->canOrAbort('update product');
@ -41,7 +42,7 @@ public function save()
$this->redirectRoute('studio.catalog.product.index');
}
public function render()
public function render(): View
{
return view('livewire.studio.catalog.product.form', [
'pageTitle' => 'Ubah Produk',

View File

@ -4,20 +4,24 @@
use App\Models\Product;
use App\Traits\Notification\WithSubscribeNotification;
use App\Traits\WithAuthorization;
use App\Traits\WithConfirmation;
use App\Traits\WithShowPrice;
use App\Traits\WithToast;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Produk')]
class Index extends Component
{
use WithConfirmation, WithShowPrice, WithSubscribeNotification, WithToast;
use WithAuthorization, WithConfirmation, WithShowPrice, WithSubscribeNotification, WithToast;
public function delete(Product $product)
public function delete(Product $product): void
{
$this->canOrAbort('delete product');
$product->delete();
$this->dispatch('refreshDatatable');
@ -27,7 +31,7 @@ public function delete(Product $product)
Flux::modals()->close();
}
public function render()
public function render(): View
{
return view('livewire.studio.catalog.product.index', [
'pageTitle' => 'Produk',

View File

@ -14,14 +14,14 @@
@forelse ($activityLogs as $log)
<flux:card class="p-4 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition text-gray-500 dark:text-gray-300">
<div class="flex justify-between items-center mb-2">
<div class="text-md">{{ $log['log_name'] }}</div>
<div class="text-md">{{ $log['created_at'] }}</div>
<div class="text-md">{{ $log['user'] }}</div>
<div class="text-md">{{ $log->log_name }}</div>
<div class="text-md">{{ $log->created_at }}</div>
<div class="text-md">{{ $log->causer?->employee?->full_name }}</div>
<span
class="px-2 py-1 inline-flex text-xs font-semibold rounded-full
bg-{{ $log['event']['color'] }}-200 text-{{ $log['event']['color'] }}-800
dark:bg-{{ $log['event']['color'] }}-800 dark:text-{{ $log['event']['color'] }}-200">
{{ $log['event']['label'] }}
bg-{{ $log->event['color'] }}-200 text-{{ $log->event['color'] }}-800
dark:bg-{{ $log->event['color'] }}-800 dark:text-{{ $log->event['color'] }}-200">
{{ $log->event['label'] }}
</span>
</div>
@ -29,17 +29,17 @@ class="px-2 py-1 inline-flex text-xs font-semibold rounded-full
dark:border-gray-700" />
<div class="flex flex-wrap gap-4 text-sm ">
<div>Stok Sebelumnya: {{ $log['previous_stock'] }}</div>
<div>Stok Sebelumnya: {{ currency($log->properties['previous_stock']) }}</div>
<flux:separator vertical class="dark:border-gray-700" />
<div>Perubahan: {{ $log['quantity_change'] }}</div>
<div>Perubahan: {{ currency($log->properties['quantity_change']) }}</div>
<flux:separator vertical class="dark:border-gray-700" />
<div>Total Stok: {{ $log['new_stock'] }}</div>
<div>Total Stok: {{ currency($log->properties['new_stock']) }}</div>
</div>
<flux:separator class="my-2 dark:border-gray-700" />
<p class="mt-2text-sm truncate">
{{ $log['description'] }}
{{ $log->description }}
</p>
</flux:card>
@empty

View File

@ -0,0 +1,473 @@
<?php
use App\Enums\AuditStockStatus;
use App\Models\Employee;
use App\Models\Outlet;
use App\Models\Product;
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 mountProductAuditComponent(User $user, Outlet $outlet, Product $product)
{
return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Product\Audit::class, ['outlet' => $outlet, 'product' => $product]);
}
/*
|--------------------------------------------------------------------------
| Audit Component Rendering & Mount
|--------------------------------------------------------------------------
*/
it('renders audit page successfully', function () {
$product = Product::factory()->create();
$outlet = $this->outlets->first();
mountProductAuditComponent($this->user, $outlet, $product)
->assertViewIs('components.sections.ui.audit')
->assertViewHas('pageTitle', "Audit {$product->name} di {$outlet->name}")
->assertViewHas('module', 'product');
});
it('sets label correctly on mount', function () {
$product = Product::factory()->create(['name' => 'Test Product']);
$outlet = $this->outlets->first();
$component = mountProductAuditComponent($this->user, $outlet, $product);
expect($component->label)->toBe('Test Product di '.$outlet->name);
});
/*
|--------------------------------------------------------------------------
| Activity Logs Display Tests
|--------------------------------------------------------------------------
*/
it('displays activity logs correctly', function () {
$product = Product::factory()->create();
$outlet = $this->outlets->first();
// Create some activity logs with expected properties
Activity::create([
'log_name' => 'default',
'description' => 'Stock updated',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet->id,
'previous_stock' => 5,
'quantity_change' => 10,
'new_stock' => 15,
],
'event' => AuditStockStatus::INCREASED->value,
]);
Activity::create([
'log_name' => 'default',
'description' => 'Stock decreased',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet->id,
'previous_stock' => 15,
'quantity_change' => -5,
'new_stock' => 10,
],
'event' => AuditStockStatus::DECREASED->value,
]);
$component = mountProductAuditComponent($this->user, $outlet, $product);
expect($component->activityLogs)->toHaveCount(2);
expect($component->activityLogs->first()->event['label'])->toBe(AuditStockStatus::INCREASED->label());
expect($component->activityLogs->first()->event['color'])->toBe(AuditStockStatus::INCREASED->color());
expect($component->activityLogs->last()->event['label'])->toBe(AuditStockStatus::DECREASED->label());
expect($component->activityLogs->last()->event['color'])->toBe(AuditStockStatus::DECREASED->color());
});
it('filters activity logs by outlet', function () {
$product = Product::factory()->create();
$outlet1 = $this->outlets->first();
$outlet2 = $this->outlets->last();
// Create activity for outlet1
Activity::create([
'log_name' => 'default',
'description' => 'Stock updated outlet1',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet1->id,
'previous_stock' => 5,
'quantity_change' => 10,
'new_stock' => 15,
],
'event' => AuditStockStatus::UPDATED->value,
]);
// Create activity for outlet2
Activity::create([
'log_name' => 'default',
'description' => 'Stock increased outlet2',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet2->id,
'previous_stock' => 0,
'quantity_change' => 20,
'new_stock' => 20,
],
'event' => AuditStockStatus::INCREASED->value,
]);
$component1 = mountProductAuditComponent($this->user, $outlet1, $product);
$component2 = mountProductAuditComponent($this->user, $outlet2, $product);
expect($component1->activityLogs)->toHaveCount(1);
expect($component1->activityLogs->first()->properties['outlet_id'])->toBe($outlet1->id);
expect($component2->activityLogs)->toHaveCount(1);
expect($component2->activityLogs->first()->properties['outlet_id'])->toBe($outlet2->id);
});
it('orders activity logs by latest first', function () {
$product = Product::factory()->create();
$outlet = $this->outlets->first();
// Create activities with different timestamps
$activity1 = Activity::create([
'log_name' => 'default',
'description' => 'first activity',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet->id,
'previous_stock' => 5,
'quantity_change' => 5,
'new_stock' => 10,
],
'event' => AuditStockStatus::UPDATED->value,
'created_at' => now()->subMinutes(5),
]);
$activity2 = Activity::create([
'log_name' => 'default',
'description' => 'second activity',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet->id,
'previous_stock' => 10,
'quantity_change' => 10,
'new_stock' => 20,
],
'event' => AuditStockStatus::INCREASED->value,
'created_at' => now(),
]);
$component = mountProductAuditComponent($this->user, $outlet, $product);
expect($component->activityLogs)->toHaveCount(2);
expect($component->activityLogs->first()->id)->toBe($activity2->id);
expect($component->activityLogs->last()->id)->toBe($activity1->id);
});
/*
|--------------------------------------------------------------------------
| Activity Logs Content Tests
|--------------------------------------------------------------------------
*/
it('maps activity events to correct labels and colors', function () {
$product = Product::factory()->create();
$outlet = $this->outlets->first();
// Test INCREASED event
Activity::create([
'log_name' => 'default',
'description' => 'Stock increased',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet->id,
'previous_stock' => 10,
'quantity_change' => 5,
'new_stock' => 15,
],
'event' => AuditStockStatus::INCREASED->value,
]);
// Test DECREASED event
Activity::create([
'log_name' => 'default',
'description' => 'Stock decreased',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet->id,
'previous_stock' => 15,
'quantity_change' => -3,
'new_stock' => 12,
],
'event' => AuditStockStatus::DECREASED->value,
]);
// Test UPDATED event
Activity::create([
'log_name' => 'default',
'description' => 'Stock updated',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet->id,
'previous_stock' => 12,
'quantity_change' => 0,
'new_stock' => 12,
],
'event' => AuditStockStatus::UPDATED->value,
]);
$component = mountProductAuditComponent($this->user, $outlet, $product);
expect($component->activityLogs)->toHaveCount(3);
// Check all event mappings
foreach ($component->activityLogs as $log) {
match ($log->event['label']) {
AuditStockStatus::INCREASED->label() => expect($log->event['color'])->toBe(AuditStockStatus::INCREASED->color()),
AuditStockStatus::DECREASED->label() => expect($log->event['color'])->toBe(AuditStockStatus::DECREASED->color()),
AuditStockStatus::UPDATED->label() => expect($log->event['color'])->toBe(AuditStockStatus::UPDATED->color()),
default => throw new \Exception("Unexpected event label: {$log->event['label']}")
};
}
});
/*
|--------------------------------------------------------------------------
| Empty State Tests
|--------------------------------------------------------------------------
*/
it('handles empty activity logs in audit component', function () {
$product = Product::factory()->create();
$outlet = $this->outlets->first();
$component = mountProductAuditComponent($this->user, $outlet, $product);
expect($component->activityLogs)->toBeInstanceOf(\Illuminate\Database\Eloquent\Collection::class);
expect($component->activityLogs)->toHaveCount(0);
});
it('handles product with no activities in specific outlet', function () {
$product = Product::factory()->create();
$outlet1 = $this->outlets->first();
$outlet2 = $this->outlets->last();
// Create activity only for outlet2
Activity::create([
'log_name' => 'default',
'description' => 'Stock updated',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet2->id,
'previous_stock' => 5,
'quantity_change' => 5,
'new_stock' => 10,
],
'event' => AuditStockStatus::UPDATED->value,
]);
$component1 = mountProductAuditComponent($this->user, $outlet1, $product);
$component2 = mountProductAuditComponent($this->user, $outlet2, $product);
expect($component1->activityLogs)->toHaveCount(0);
expect($component2->activityLogs)->toHaveCount(1);
});
/*
|--------------------------------------------------------------------------
| Integration Tests
|--------------------------------------------------------------------------
*/
it('displays real activity log data correctly', function () {
$product = Product::factory()->create(['name' => 'Integration Product']);
$outlet = $this->outlets->first();
$activity = Activity::create([
'log_name' => 'default',
'description' => 'Stock adjustment for integration test',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet->id,
'previous_stock' => 100,
'quantity_change' => 25,
'new_stock' => 125,
],
'event' => AuditStockStatus::INCREASED->value,
]);
$component = mountProductAuditComponent($this->user, $outlet, $product);
expect($component->activityLogs)->toHaveCount(1);
$log = $component->activityLogs->first();
expect($log->description)->toBe('Stock adjustment for integration test');
expect($log->properties['previous_stock'])->toBe(100);
expect($log->properties['quantity_change'])->toBe(25);
expect($log->properties['new_stock'])->toBe(125);
expect($log->event['label'])->toBe(AuditStockStatus::INCREASED->label());
expect($log->event['color'])->toBe(AuditStockStatus::INCREASED->color());
});
it('handles multiple activities across different outlets', function () {
$product = Product::factory()->create();
$outlet1 = $this->outlets->first();
$outlet2 = $this->outlets->last();
// Create activities for both outlets
Activity::create([
'log_name' => 'default',
'description' => 'Activity in outlet 1',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet1->id,
'previous_stock' => 10,
'quantity_change' => 5,
'new_stock' => 15,
],
'event' => AuditStockStatus::INCREASED->value,
]);
Activity::create([
'log_name' => 'default',
'description' => 'Activity in outlet 2',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet2->id,
'previous_stock' => 20,
'quantity_change' => -3,
'new_stock' => 17,
],
'event' => AuditStockStatus::DECREASED->value,
]);
$component1 = mountProductAuditComponent($this->user, $outlet1, $product);
$component2 = mountProductAuditComponent($this->user, $outlet2, $product);
expect($component1->activityLogs)->toHaveCount(1);
expect($component1->activityLogs->first()->description)->toBe('Activity in outlet 1');
expect($component2->activityLogs)->toHaveCount(1);
expect($component2->activityLogs->first()->description)->toBe('Activity in outlet 2');
});
/*
|--------------------------------------------------------------------------
| Edge Cases and Error Handling
|--------------------------------------------------------------------------
*/
it('handles activity logs with missing properties gracefully', function () {
$product = Product::factory()->create();
$outlet = $this->outlets->first();
// Create activity with incomplete properties (shouldn't happen in real usage)
Activity::create([
'log_name' => 'default',
'description' => 'Incomplete activity',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => get_class($this->user),
'causer_id' => $this->user->id,
'properties' => [
'outlet_id' => $outlet->id,
'previous_stock' => 0, // Add required properties
'quantity_change' => 0,
'new_stock' => 0,
],
'event' => AuditStockStatus::UPDATED->value,
]);
$component = mountProductAuditComponent($this->user, $outlet, $product);
expect($component->activityLogs)->toHaveCount(1);
// Component should render correctly with all required properties
});
it('handles activities with null causer', function () {
$product = Product::factory()->create();
$outlet = $this->outlets->first();
Activity::create([
'log_name' => 'default',
'description' => 'Activity with no causer',
'subject_type' => get_class($product),
'subject_id' => $product->id,
'causer_type' => null,
'causer_id' => null,
'properties' => [
'outlet_id' => $outlet->id,
'previous_stock' => 5,
'quantity_change' => 5,
'new_stock' => 10,
],
'event' => AuditStockStatus::INCREASED->value,
]);
$component = mountProductAuditComponent($this->user, $outlet, $product);
expect($component->activityLogs)->toHaveCount(1);
expect($component->activityLogs->first()->causer)->toBeNull();
});

View File

@ -0,0 +1,353 @@
<?php
use App\Models\Employee;
use App\Models\Outlet;
use App\Models\Product;
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 mountProductCreateComponent(User $user)
{
return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Product\Create::class);
}
/*
|--------------------------------------------------------------------------
| Create Component Rendering & Mount
|--------------------------------------------------------------------------
*/
it('renders create page successfully', function () {
mountProductCreateComponent($this->user)
->assertViewIs('livewire.studio.catalog.product.form')
->assertViewHas('pageTitle', 'Tambah Produk');
});
it('initializes form properties on mount', function () {
$component = mountProductCreateComponent($this->user);
expect($component->form->name)->toBe('');
expect($component->form->sku)->toBe('');
expect($component->form->cost_price)->toBe('');
expect($component->form->sale_price)->toBe('');
expect($component->form->description)->toBeNull();
expect($component->form->outlet_ids)->toBe([]);
expect($component->form->image)->toBe([]);
expect($component->outlets)->toHaveCount(3);
});
/*
|--------------------------------------------------------------------------
| Create Product Tests
|--------------------------------------------------------------------------
*/
it('creates product successfully when authorized', function () {
$data = [
'name' => 'New Product',
'sku' => 'NP001',
'cost_price' => '10000',
'sale_price' => '15000',
'description' => 'Product description',
'outlet_ids' => [$this->outlets->first()->id, $this->outlets->last()->id],
];
mountProductCreateComponent($this->user)
->set('form.name', $data['name'])
->set('form.sku', $data['sku'])
->set('form.cost_price', $data['cost_price'])
->set('form.sale_price', $data['sale_price'])
->set('form.description', $data['description'])
->set('form.outlet_ids', $data['outlet_ids'])
->call('save')
->assertHasNoErrors()
->assertRedirect(route('studio.catalog.product.index'));
expect(Product::count())->toBe(1);
$product = Product::first();
expect($product->name)->toBe($data['name']);
expect($product->sku)->toBe($data['sku']);
expect($product->cost_price)->toBe(10000);
expect($product->sale_price)->toBe(15000);
expect($product->point_per_pcs)->toBe(150); // 15000 / 100
expect($product->description)->toBe($data['description']);
expect($product->outlets)->toHaveCount(2);
});
it('prevents product creation when unauthorized', function () {
$this->ownerRole->revokePermissionTo('create product');
Gate::define('create product', fn () => false);
mountProductCreateComponent($this->user)
->set('form.name', 'Test Product')
->set('form.sku', 'TP001')
->set('form.sale_price', '10000')
->set('form.outlet_ids', [$this->outlets->first()->id])
->call('save')
->assertForbidden();
});
it('creates product with empty cost_price for non-owner roles', function () {
// Create user with limited role
$this->user->roles()->detach();
$limitedRole = Role::create(['name' => 'Cashier']);
$this->user->roles()->attach($limitedRole->id);
$createPermission = Permission::where('name', 'create product')->first();
$limitedRole->givePermissionTo($createPermission);
mountProductCreateComponent($this->user)
->set('form.name', 'Test Product')
->set('form.sku', 'TP001')
->set('form.cost_price', '') // Empty cost price
->set('form.sale_price', '10000')
->set('form.outlet_ids', [$this->outlets->first()->id])
->call('save')
->assertHasNoErrors();
$product = Product::first();
expect($product->cost_price)->toBe(0);
});
/*
|--------------------------------------------------------------------------
| Form Validation Tests
|--------------------------------------------------------------------------
*/
it('validates required name field on create', function () {
mountProductCreateComponent($this->user)
->set('form.name', '')
->set('form.sku', 'TP001')
->set('form.sale_price', '10000')
->set('form.outlet_ids', [$this->outlets->first()->id])
->call('save')
->assertHasErrors(['form.name']);
});
it('validates name field max length on create', function () {
mountProductCreateComponent($this->user)
->set('form.name', str_repeat('a', 51))
->set('form.sku', 'TP001')
->set('form.sale_price', '10000')
->set('form.outlet_ids', [$this->outlets->first()->id])
->call('save')
->assertHasErrors(['form.name']);
});
it('validates required sku field on create', function () {
mountProductCreateComponent($this->user)
->set('form.name', 'Test Product')
->set('form.sku', '')
->set('form.sale_price', '10000')
->set('form.outlet_ids', [$this->outlets->first()->id])
->call('save')
->assertHasErrors(['form.sku']);
});
it('validates sku field max length on create', function () {
mountProductCreateComponent($this->user)
->set('form.name', 'Test Product')
->set('form.sku', str_repeat('a', 21))
->set('form.sale_price', '10000')
->set('form.outlet_ids', [$this->outlets->first()->id])
->call('save')
->assertHasErrors(['form.sku']);
});
it('validates sku uniqueness on create', function () {
Product::factory()->create(['sku' => 'EXISTING001']);
mountProductCreateComponent($this->user)
->set('form.name', 'Test Product')
->set('form.sku', 'EXISTING001')
->set('form.sale_price', '10000')
->set('form.outlet_ids', [$this->outlets->first()->id])
->call('save')
->assertHasErrors(['form.sku']);
});
it('validates required sale_price field on create', function () {
mountProductCreateComponent($this->user)
->set('form.name', 'Test Product')
->set('form.sku', 'TP001')
->set('form.sale_price', '')
->set('form.outlet_ids', [$this->outlets->first()->id])
->call('save')
->assertHasErrors(['form.sale_price']);
});
it('validates required outlet_ids field on create', function () {
mountProductCreateComponent($this->user)
->set('form.name', 'Test Product')
->set('form.sku', 'TP001')
->set('form.sale_price', '10000')
->set('form.outlet_ids', [])
->call('save')
->assertHasErrors(['form.outlet_ids']);
});
it('validates outlet_ids contains valid outlet ids on create', function () {
mountProductCreateComponent($this->user)
->set('form.name', 'Test Product')
->set('form.sku', 'TP001')
->set('form.sale_price', '10000')
->set('form.outlet_ids', [999])
->call('save')
->assertHasErrors(['form.outlet_ids.*']);
});
/*
|--------------------------------------------------------------------------
| Real-time Validation Tests
|--------------------------------------------------------------------------
*/
it('validates name field in real-time', function () {
$component = mountProductCreateComponent($this->user);
// Valid name
$component->set('form.name', 'Valid Product Name')
->assertHasNoErrors(['form.name']);
// Empty name
$component->set('form.name', '')
->assertHasErrors(['form.name']);
// Name too long
$component->set('form.name', str_repeat('a', 51))
->assertHasErrors(['form.name']);
});
it('validates sku field in real-time', function () {
$component = mountProductCreateComponent($this->user);
// Valid SKU
$component->set('form.sku', 'VALID001')
->assertHasNoErrors(['form.sku']);
// Empty SKU
$component->set('form.sku', '')
->assertHasErrors(['form.sku']);
// SKU too long
$component->set('form.sku', str_repeat('a', 21))
->assertHasErrors(['form.sku']);
// Duplicate SKU
Product::factory()->create(['sku' => 'EXISTING001']);
$component->set('form.sku', 'EXISTING001')
->assertHasErrors(['form.sku']);
});
it('validates price fields in real-time', function () {
$component = mountProductCreateComponent($this->user);
// Valid sale price
$component->set('form.sale_price', '10000')
->assertHasNoErrors(['form.sale_price']);
// Empty sale price
$component->set('form.sale_price', '')
->assertHasErrors(['form.sale_price']);
// Negative sale price
$component->set('form.sale_price', '-10000')
->assertHasErrors(['form.sale_price']);
// Valid cost price
$component->set('form.cost_price', '5000')
->assertHasNoErrors(['form.cost_price']);
// Negative cost price
$component->set('form.cost_price', '-5000')
->assertHasErrors(['form.cost_price']);
});
it('validates outlet_ids in real-time', function () {
$component = mountProductCreateComponent($this->user);
// Valid outlet IDs
$component->set('form.name', 'Test Product')
->set('form.sku', 'TP001')
->set('form.sale_price', '10000')
->set('form.outlet_ids', [$this->outlets->first()->id])
->assertHasNoErrors(['form.outlet_ids']);
// Empty outlet IDs - should trigger validation on submit
$component->set('form.outlet_ids', [])
->call('save')
->assertHasErrors(['form.outlet_ids']);
// Invalid outlet ID
$component = mountProductCreateComponent($this->user);
$component->set('form.name', 'Test Product')
->set('form.sku', 'TP002')
->set('form.sale_price', '10000')
->set('form.outlet_ids', [999])
->call('save')
->assertHasErrors(['form.outlet_ids.*']);
});
/*
|--------------------------------------------------------------------------
| Integration Tests
|--------------------------------------------------------------------------
*/
it('creates product and attaches to outlets correctly', function () {
$outlet1 = $this->outlets->first();
$outlet2 = $this->outlets->last();
mountProductCreateComponent($this->user)
->set('form.name', 'Integration Test Product')
->set('form.sku', 'ITP001')
->set('form.cost_price', '15000') // Required for Owner role
->set('form.sale_price', '20000')
->set('form.outlet_ids', [$outlet1->id, $outlet2->id])
->call('save')
->assertHasNoErrors();
$product = Product::where('sku', 'ITP001')->first();
expect($product)->not->toBeNull();
expect($product->outlets)->toHaveCount(2);
expect($product->outlets->pluck('id')->toArray())->toContain($outlet1->id, $outlet2->id);
});
it('handles large numbers in price fields', function () {
mountProductCreateComponent($this->user)
->set('form.name', 'Expensive Product')
->set('form.sku', 'EP001')
->set('form.cost_price', '10000000') // 10 million
->set('form.sale_price', '15000000') // 15 million
->set('form.outlet_ids', [$this->outlets->first()->id])
->call('save')
->assertHasNoErrors();
$product = Product::where('sku', 'EP001')->first();
expect($product->cost_price)->toBe(10000000);
expect($product->sale_price)->toBe(15000000);
expect($product->point_per_pcs)->toBe(150000); // 15000000 / 100
});

View File

@ -0,0 +1,360 @@
<?php
use App\Models\Employee;
use App\Models\Outlet;
use App\Models\Product;
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 mountProductEditComponent(User $user, Product $product)
{
return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Product\Edit::class, ['product' => $product->hash]);
}
/*
|--------------------------------------------------------------------------
| Edit Component Rendering & Mount
|--------------------------------------------------------------------------
*/
it('renders edit page successfully', function () {
$product = Product::factory()->create();
mountProductEditComponent($this->user, $product)
->assertViewIs('livewire.studio.catalog.product.form')
->assertViewHas('pageTitle', 'Ubah Produk');
});
it('populates form with product data on mount', function () {
$product = Product::factory()->create([
'name' => 'Test Product',
'sku' => 'TP001',
'cost_price' => 10000,
'sale_price' => 15000,
'description' => 'Test description',
]);
$product->outlets()->attach([$this->outlets->first()->id]);
$component = mountProductEditComponent($this->user, $product);
expect($component->form->name)->toBe('Test Product');
expect($component->form->sku)->toBe('TP001');
expect($component->form->cost_price)->toBe('10000');
expect($component->form->sale_price)->toBe('15000');
expect($component->form->description)->toBe('Test description');
expect($component->form->outlet_ids)->toBe([$this->outlets->first()->id]);
});
/*
|--------------------------------------------------------------------------
| Update Product Tests
|--------------------------------------------------------------------------
*/
it('updates product successfully when authorized', function () {
$updatePermission = Permission::where('name', 'update product')->first();
$this->user->givePermissionTo($updatePermission);
$product = Product::factory()->create([
'name' => 'Original Product',
'sku' => 'OP001',
'cost_price' => 10000,
'sale_price' => 15000,
]);
$product->outlets()->attach([$this->outlets->first()->id]);
$updateData = [
'name' => 'Updated Product',
'sku' => 'UP001',
'cost_price' => '12000',
'sale_price' => '18000',
'description' => 'Updated description',
'outlet_ids' => [$this->outlets->first()->id, $this->outlets->last()->id],
];
mountProductEditComponent($this->user, $product)
->set('form.name', $updateData['name'])
->set('form.sku', $updateData['sku'])
->set('form.cost_price', $updateData['cost_price'])
->set('form.sale_price', $updateData['sale_price'])
->set('form.description', $updateData['description'])
->set('form.outlet_ids', $updateData['outlet_ids'])
->call('save')
->assertHasNoErrors()
->assertRedirect(route('studio.catalog.product.index'));
$product->refresh();
expect($product->name)->toBe($updateData['name']);
expect($product->sku)->toBe($updateData['sku']);
expect($product->cost_price)->toBe(12000);
expect($product->sale_price)->toBe(18000);
expect($product->point_per_pcs)->toBe(180); // 18000 / 100
expect($product->description)->toBe($updateData['description']);
expect($product->outlets)->toHaveCount(2);
});
it('prevents product update when unauthorized', function () {
$this->ownerRole->revokePermissionTo('update product');
Gate::define('update product', fn () => false);
$product = Product::factory()->create();
mountProductEditComponent($this->user, $product)
->set('form.name', 'Updated Name')
->call('save')
->assertForbidden();
});
it('syncs outlet relationships correctly on update', function () {
$updatePermission = Permission::where('name', 'update product')->first();
$this->user->givePermissionTo($updatePermission);
$product = Product::factory()->create();
$product->outlets()->attach([$this->outlets->first()->id]);
// Change to different outlets
mountProductEditComponent($this->user, $product)
->set('form.name', 'Updated Product')
->set('form.sku', 'UP001')
->set('form.sale_price', '10000')
->set('form.outlet_ids', [$this->outlets[1]->id, $this->outlets[2]->id])
->call('save')
->assertHasNoErrors();
$product->refresh();
expect($product->outlets)->toHaveCount(2);
expect($product->outlets->pluck('id')->toArray())->toBe([$this->outlets[1]->id, $this->outlets[2]->id]);
});
it('allows same sku on update of same product', function () {
$updatePermission = Permission::where('name', 'update product')->first();
$this->user->givePermissionTo($updatePermission);
$product = Product::factory()->create(['sku' => 'EXISTING001']);
mountProductEditComponent($this->user, $product)
->set('form.name', 'Updated Product')
->set('form.sku', 'EXISTING001') // Same SKU as existing product
->set('form.sale_price', '10000')
->set('form.outlet_ids', [$this->outlets->first()->id])
->call('save')
->assertHasNoErrors();
});
/*
|--------------------------------------------------------------------------
| Form Validation Tests on Edit
|--------------------------------------------------------------------------
*/
it('validates required name field on update', function () {
$product = Product::factory()->create();
mountProductEditComponent($this->user, $product)
->set('form.name', '')
->call('save')
->assertHasErrors(['form.name']);
});
it('validates name field max length on update', function () {
$product = Product::factory()->create();
mountProductEditComponent($this->user, $product)
->set('form.name', str_repeat('a', 51))
->call('save')
->assertHasErrors(['form.name']);
});
it('validates required sku field on update', function () {
$product = Product::factory()->create();
mountProductEditComponent($this->user, $product)
->set('form.sku', '')
->call('save')
->assertHasErrors(['form.sku']);
});
it('validates sku field max length on update', function () {
$product = Product::factory()->create();
mountProductEditComponent($this->user, $product)
->set('form.sku', str_repeat('a', 21))
->call('save')
->assertHasErrors(['form.sku']);
});
it('validates sku uniqueness on update with different product', function () {
$product1 = Product::factory()->create(['sku' => 'SKU001']);
$product2 = Product::factory()->create(['sku' => 'SKU002']);
mountProductEditComponent($this->user, $product1)
->set('form.sku', 'SKU002') // Try to use SKU from product2
->call('save')
->assertHasErrors(['form.sku']);
});
it('validates required sale_price field on update', function () {
$product = Product::factory()->create();
mountProductEditComponent($this->user, $product)
->set('form.sale_price', '')
->call('save')
->assertHasErrors(['form.sale_price']);
});
it('validates required outlet_ids field on update', function () {
$product = Product::factory()->create();
mountProductEditComponent($this->user, $product)
->set('form.outlet_ids', [])
->call('save')
->assertHasErrors(['form.outlet_ids']);
});
it('validates outlet_ids contains valid outlet ids on update', function () {
$product = Product::factory()->create();
mountProductEditComponent($this->user, $product)
->set('form.outlet_ids', [999])
->call('save')
->assertHasErrors(['form.outlet_ids.*']);
});
/*
|--------------------------------------------------------------------------
| Real-time Validation Tests on Edit
|--------------------------------------------------------------------------
*/
it('validates name field in real-time on edit', function () {
$product = Product::factory()->create();
$component = mountProductEditComponent($this->user, $product);
// Valid name
$component->set('form.name', 'Valid Updated Product Name')
->assertHasNoErrors(['form.name']);
// Empty name
$component->set('form.name', '')
->assertHasErrors(['form.name']);
// Name too long
$component->set('form.name', str_repeat('a', 51))
->assertHasErrors(['form.name']);
});
it('validates sku field in real-time on edit', function () {
$product = Product::factory()->create();
$component = mountProductEditComponent($this->user, $product);
// Valid SKU
$component->set('form.sku', 'VALID001')
->assertHasNoErrors(['form.sku']);
// Empty SKU
$component->set('form.sku', '')
->assertHasErrors(['form.sku']);
// SKU too long
$component->set('form.sku', str_repeat('a', 21))
->assertHasErrors(['form.sku']);
});
it('validates price fields in real-time on edit', function () {
$product = Product::factory()->create();
$component = mountProductEditComponent($this->user, $product);
// Valid sale price
$component->set('form.sale_price', '10000')
->assertHasNoErrors(['form.sale_price']);
// Empty sale price
$component->set('form.sale_price', '')
->assertHasErrors(['form.sale_price']);
// Negative sale price
$component->set('form.sale_price', '-10000')
->assertHasErrors(['form.sale_price']);
});
/*
|--------------------------------------------------------------------------
| Integration Tests
|--------------------------------------------------------------------------
*/
it('updates product and syncs outlet relationships', function () {
$updatePermission = Permission::where('name', 'update product')->first();
$this->user->givePermissionTo($updatePermission);
$product = Product::factory()->create();
$product->outlets()->attach([$this->outlets->first()->id]);
$newOutlets = [$this->outlets[1]->id, $this->outlets[2]->id];
mountProductEditComponent($this->user, $product)
->set('form.name', 'Updated Integration Product')
->set('form.sku', 'UIP001')
->set('form.sale_price', '25000')
->set('form.outlet_ids', $newOutlets)
->call('save')
->assertHasNoErrors();
$product->refresh();
expect($product->name)->toBe('Updated Integration Product');
expect($product->outlets)->toHaveCount(2);
expect($product->outlets->pluck('id')->toArray())->toBe($newOutlets);
});
/*
|--------------------------------------------------------------------------
| Error Handling and Edge Cases
|--------------------------------------------------------------------------
*/
it('handles invalid product hash in edit component', function () {
expect(fn () => mountProductEditComponent($this->user, 'invalid-hash'))
->toThrow(\TypeError::class);
});
it('validates form data integrity during operations', function () {
// Test that form data is properly reset between operations
$product1 = Product::factory()->create(['name' => 'First Category']);
$product2 = Product::factory()->create(['name' => 'Second Category']);
$component = mountProductEditComponent($this->user, $product1);
// Open modal for first category
expect($component->form->name)->toBe('First Category');
// Verify first category wasn't modified
$product1->refresh();
expect($product1->name)->toBe('First Category');
});

View File

@ -0,0 +1,120 @@
<?php
use App\Models\Employee;
use App\Models\Product;
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 mountProductIndexComponent(User $user)
{
return Livewire::actingAs($user)->test(\App\Livewire\Studio\Catalog\Product\Index::class);
}
/*
|--------------------------------------------------------------------------
| Index Component Rendering & Basic Functionality
|--------------------------------------------------------------------------
*/
it('renders index page successfully', function () {
mountProductIndexComponent($this->user)
->assertViewIs('livewire.studio.catalog.product.index')
->assertViewHas('pageTitle', 'Produk');
});
it('displays products correctly', function () {
Product::factory()->count(3)->create();
// Just check that the component renders without errors and has products
$component = mountProductIndexComponent($this->user);
$component->assertViewIs('livewire.studio.catalog.product.index');
expect(Product::count())->toBe(3);
});
/*
|--------------------------------------------------------------------------
| Delete Product Tests
|--------------------------------------------------------------------------
*/
it('deletes product successfully when authorized', function () {
$product = Product::factory()->create([
'name' => 'Product to Delete',
]);
mountProductIndexComponent($this->user)
->call('delete', $product)
->assertHasNoErrors()
->assertDispatched('refreshDatatable');
$this->assertSoftDeleted('products', [
'id' => $product->id,
]);
});
it('prevents product deletion when unauthorized', function () {
$this->ownerRole->revokePermissionTo('delete product');
Gate::define('delete product', fn () => false);
$product = Product::factory()->create();
mountProductIndexComponent($this->user)
->call('delete', $product)
->assertForbidden();
});
/*
|--------------------------------------------------------------------------
| Authorization Edge Cases
|--------------------------------------------------------------------------
*/
it('handles unauthorized user with revoked permissions', function () {
// Revoke all product permissions
$this->ownerRole->revokePermissionTo(['delete product']);
// Define gates to return false
Gate::define('delete product', fn () => false);
$product = Product::factory()->create();
// Test delete
mountProductIndexComponent($this->user)
->call('delete', $product)
->assertForbidden();
});
it('handles user with partial permissions correctly', function () {
// Remove owner role and create a user with limited permissions
$this->user->roles()->detach();
$limitedRole = Role::create(['name' => 'Limited User']);
$this->user->roles()->attach($limitedRole->id);
// Give only some permissions but not delete
$limitedRole->givePermissionTo([]);
$product = Product::factory()->create();
// Should not be able to delete
mountProductIndexComponent($this->user)
->call('delete', $product)
->assertForbidden();
});