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:
parent
0c7ab27a78
commit
c037accd33
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
]);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
473
tests/Feature/Livewire/Studio/Catalog/Product/AuditTest.php
Normal file
473
tests/Feature/Livewire/Studio/Catalog/Product/AuditTest.php
Normal 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();
|
||||
});
|
||||
353
tests/Feature/Livewire/Studio/Catalog/Product/CreateTest.php
Normal file
353
tests/Feature/Livewire/Studio/Catalog/Product/CreateTest.php
Normal 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
|
||||
});
|
||||
360
tests/Feature/Livewire/Studio/Catalog/Product/EditTest.php
Normal file
360
tests/Feature/Livewire/Studio/Catalog/Product/EditTest.php
Normal 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');
|
||||
});
|
||||
120
tests/Feature/Livewire/Studio/Catalog/Product/IndexTest.php
Normal file
120
tests/Feature/Livewire/Studio/Catalog/Product/IndexTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user