diff --git a/tests/Feature/Livewire/Studio/Loyalty/Voucher/CreateTest.php b/tests/Feature/Livewire/Studio/Loyalty/Voucher/CreateTest.php index 4fda89a..7043f2c 100644 --- a/tests/Feature/Livewire/Studio/Loyalty/Voucher/CreateTest.php +++ b/tests/Feature/Livewire/Studio/Loyalty/Voucher/CreateTest.php @@ -1,53 +1,596 @@ 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); + + Bus::fake(); +}); + +function mountCreateComponent(User $user) +{ + return Livewire::actingAs($user)->test(Create::class); +} + +function createOutlets(int $count = 3) +{ + return Outlet::factory() + ->count($count) + ->has(OpeningHour::factory()->count(7)) + ->has(Facility::factory()->count(5)) + ->create(); +} + +/* +|-------------------------------------------------------------------------- +| Component Rendering & Mount +|-------------------------------------------------------------------------- +*/ + +it('renders page successfully', function () { + mountCreateComponent($this->user) ->assertViewIs('livewire.studio.loyalty.voucher.form') ->assertViewHas('pageTitle', 'Tambah Voucher'); }); -it('can create a voucher', function () { - $data = Voucher::factory() - ->active() - ->raw(); +it('loads outlets on mount', function () { + $outlets = createOutlets(3); - $outletIds = Outlet::factory() - ->count(4) - ->has(OpeningHour::factory()->count(7)) - ->has(Facility::factory()->count(5)) - ->create(); + $component = mountCreateComponent($this->user); - Livewire::test(Create::class) - ->set('form.name', $data['name']) - ->set('form.code', $data['code']) - ->set('form.min_purchase', $data['min_purchase']) - ->set('form.quota', $data['quota']) - ->set('form.limit_per_user', $data['limit_per_user']) - ->set('form.start_date', $data['start_date']) - ->set('form.end_date', $data['end_date']) - ->set('form.type', $data['type']) - ->set('form.discount_amount', $data['discount_amount']) - ->set('form.max_discount', $data['max_discount']) - ->set('form.is_active', $data['is_active']->value) - ->set('form.outlet_ids', $outletIds->pluck('id')->toArray()) - ->call('save') - ->assertHasNoErrors() - ->assertDispatched('refreshDatatable') - ->assertRedirect(Index::class); + expect($component->outlets)->toHaveCount(3); + expect($component->outlets)->toHaveKey($outlets->first()->id); + expect($component->outlets[$outlets->first()->id])->toBe($outlets->first()->name); +}); + +it('initializes form with default values', function () { + $component = mountCreateComponent($this->user); + + expect($component->form->name)->toBe(''); + expect($component->form->code)->toBe(''); + expect($component->form->tags)->toBe(''); + expect($component->form->type)->toBe('1'); + expect($component->form->discount_amount)->toBe(''); + expect($component->form->outlet_ids)->toBe([]); +}); + +/* +|-------------------------------------------------------------------------- +| Create Voucher - Fixed Type +|-------------------------------------------------------------------------- +*/ + +it('can create a fixed type voucher with all fields', function () { + $outlets = createOutlets(3); + $outletIds = $outlets->pluck('id')->toArray(); + + $component = mountCreateComponent($this->user) + ->set('form.name', 'Voucher Diskon 10rb') + ->set('form.code', 'DISKON10') + ->set('form.tags', 'Member Baru') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.min_purchase', '50000') + ->set('form.quota', '100') + ->set('form.limit_per_user', '2') + ->set('form.summary', 'Diskon 10rb untuk pembelian minimal 50rb') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.end_date', now()->addMonth()->format('Y-m-d')) + ->set('form.outlet_ids', $outletIds) + ->call('save'); + + $component->assertHasNoErrors() + ->assertRedirect(route('studio.loyalty.voucher.index')); $this->assertDatabaseHas('vouchers', [ - 'name' => $data['name'], - 'code' => $data['code'], + 'name' => 'Voucher Diskon 10rb', + 'code' => 'DISKON10', + 'tags' => 'Member Baru', + 'type' => VoucherType::FIXED->value, + 'discount_amount' => 10000, + 'min_purchase' => 50000, + 'quota' => 100, + 'available_count' => 100, + 'limit_per_user' => 2, + 'summary' => 'Diskon 10rb untuk pembelian minimal 50rb', + 'max_discount' => null, + ]); + + $voucher = Voucher::where('code', 'DISKON10')->first(); + + expect($voucher->outlets)->toHaveCount(3); + expect($voucher->outlets->pluck('id')->toArray())->toEqual($outletIds); +}); + +it('can create a fixed type voucher without optional fields', function () { + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Voucher Gratis') + ->set('form.code', 'GRATIS') + ->set('form.tags', 'Promo') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '5000') + ->set('form.summary', 'Voucher gratis') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('vouchers', [ + 'code' => 'GRATIS', + 'min_purchase' => null, + 'max_discount' => null, + 'quota' => null, + 'available_count' => 0, + 'limit_per_user' => null, + 'end_date' => null, ]); }); + +it('can create a fixed type voucher with currency formatted amounts', function () { + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Voucher Test') + ->set('form.code', 'TEST01') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '25.000') + ->set('form.min_purchase', '100.000') + ->set('form.summary', 'Test voucher') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('vouchers', [ + 'code' => 'TEST01', + 'discount_amount' => 25000, + 'min_purchase' => 100000, + ]); +}); + +/* +|-------------------------------------------------------------------------- +| Create Voucher - Percentage Type +|-------------------------------------------------------------------------- +*/ + +it('can create a percentage type voucher with max discount', function () { + $outlets = createOutlets(2); + + mountCreateComponent($this->user) + ->set('form.name', 'Diskon 20%') + ->set('form.code', 'DISKON20') + ->set('form.tags', 'Promo') + ->set('form.type', VoucherType::PERCENTAGE->value) + ->set('form.discount_amount', '20') + ->set('form.max_discount', '50000') + ->set('form.min_purchase', '100000') + ->set('form.quota', '200') + ->set('form.limit_per_user', '1') + ->set('form.summary', 'Diskon 20% maksimal 50rb') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.end_date', now()->addMonths(2)->format('Y-m-d')) + ->set('form.outlet_ids', $outlets->pluck('id')->toArray()) + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('vouchers', [ + 'code' => 'DISKON20', + 'type' => VoucherType::PERCENTAGE->value, + 'discount_amount' => 20, + 'max_discount' => 50000, + 'quota' => 200, + 'available_count' => 200, + ]); +}); + +it('requires max_discount for percentage type voucher', function () { + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Diskon Test') + ->set('form.code', 'TEST02') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::PERCENTAGE->value) + ->set('form.discount_amount', '15') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.max_discount']); +}); + +/* +|-------------------------------------------------------------------------- +| Validation Tests +|-------------------------------------------------------------------------- +*/ + +it('validates required fields', function () { + mountCreateComponent($this->user) + ->set('form.type', '') + ->call('save') + ->assertHasErrors([ + 'form.name', + 'form.code', + 'form.tags', + 'form.type', + 'form.discount_amount', + 'form.summary', + 'form.start_date', + 'form.outlet_ids', + ]); +}); + +it('validates name max length', function () { + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', str_repeat('a', 51)) + ->set('form.code', 'TEST') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('validates code uniqueness', function () { + $voucher = Voucher::factory()->create(['code' => 'EXISTING']); + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'New Voucher') + ->set('form.code', 'EXISTING') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.code']); +}); + +it('validates code format (alpha_num)', function () { + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST-CODE') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.code']); +}); + +it('validates tags max length', function () { + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST') + ->set('form.tags', str_repeat('a', 21)) + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.tags']); +}); + +it('validates percentage discount amount range', function () { + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::PERCENTAGE->value) + ->set('form.discount_amount', '101') + ->set('form.max_discount', '50000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.discount_amount']); + + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST2') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::PERCENTAGE->value) + ->set('form.discount_amount', '-1') + ->set('form.max_discount', '50000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.discount_amount']); +}); + +it('validates start date is after yesterday', function () { + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->subDays(2)->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.start_date']); +}); + +it('validates end date is after or equal to start date', function () { + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDays(5)->format('Y-m-d')) + ->set('form.end_date', now()->addDays(3)->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.end_date']); +}); + +it('validates outlet_ids is required and has at least one', function () { + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', []) + ->call('save') + ->assertHasErrors(['form.outlet_ids']); + + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST2') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->call('save') + ->assertHasErrors(['form.outlet_ids']); +}); + +it('validates outlet_ids exist', function () { + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [99999]) + ->call('save') + ->assertHasErrors(['form.outlet_ids.0']); +}); + +it('validates summary max length', function () { + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', str_repeat('a', 201)) + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.summary']); +}); + +/* +|-------------------------------------------------------------------------- +| Outlet Selection +|-------------------------------------------------------------------------- +*/ + +it('can select all outlets', function () { + $outlets = createOutlets(5); + + $component = mountCreateComponent($this->user); + $component->call('selectAllOutlets'); + + expect($component->form->outlet_ids)->toHaveCount(5); + expect($component->form->outlet_ids)->toEqual($outlets->pluck('id')->toArray()); +}); + +it('can deselect all outlets', function () { + $outlets = createOutlets(3); + + $component = mountCreateComponent($this->user) + ->set('form.outlet_ids', $outlets->pluck('id')->toArray()); + + expect($component->form->outlet_ids)->toHaveCount(3); + + $component->call('deselectAllOutlets'); + + expect($component->form->outlet_ids)->toHaveCount(0); + expect($component->form->outlet_ids)->toBe([]); +}); + +/* +|-------------------------------------------------------------------------- +| Permissions +|-------------------------------------------------------------------------- +*/ + +it('prevents creation when user lacks create voucher permission', function () { + $this->ownerRole->revokePermissionTo('create voucher'); + Gate::define('create voucher', fn () => false); + + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertForbidden(); +}); + +it('allows creation when user has create voucher permission', function () { + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); +}); + +/* +|-------------------------------------------------------------------------- +| Push Notification +|-------------------------------------------------------------------------- +*/ + +it('dispatches push notification job when creating voucher', function () { + $users = User::factory()->count(3)->create(); + foreach ($users as $user) { + PushNotification::create([ + 'user_id' => $user->id, + 'subscriptions' => json_encode(['endpoint' => 'test', 'keys' => ['p256dh' => 'test', 'auth' => 'test']]), + ]); + } + + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save'); + + Bus::assertDispatched(StorePushNotification::class, function ($job) { + return $job->payload['title'] === '🎁 Voucher Baru!' && + $job->payload['body'] === 'Ada voucher baru untuk kamu! Jangan sampai kelewatan, yuk cek sekarang dan nikmati penawarannya ✨🎉'; + }); +}); + +/* +|-------------------------------------------------------------------------- +| Edge Cases +|-------------------------------------------------------------------------- +*/ + +it('handles null optional fields correctly', function () { + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.min_purchase', null) + ->set('form.quota', null) + ->set('form.limit_per_user', null) + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.end_date', null) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); + + $voucher = Voucher::where('code', 'TEST')->first(); + + expect($voucher->min_purchase)->toBeNull(); + expect($voucher->quota)->toBeNull(); + expect($voucher->limit_per_user)->toBeNull(); + expect($voucher->end_date)->toBeNull(); + expect($voucher->available_count)->toBe(0); +}); + +it('sets available_count equal to quota when quota is provided', function () { + $outlets = createOutlets(1); + + mountCreateComponent($this->user) + ->set('form.name', 'Test') + ->set('form.code', 'TEST') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.quota', '150') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); + + $voucher = Voucher::where('code', 'TEST')->first(); + expect($voucher->quota)->toBe(150); + expect($voucher->available_count)->toBe(150); +}); diff --git a/tests/Feature/Livewire/Studio/Loyalty/Voucher/EditTest.php b/tests/Feature/Livewire/Studio/Loyalty/Voucher/EditTest.php index f0a4922..4edf22a 100644 --- a/tests/Feature/Livewire/Studio/Loyalty/Voucher/EditTest.php +++ b/tests/Feature/Livewire/Studio/Loyalty/Voucher/EditTest.php @@ -1,61 +1,495 @@ active()->create(); +beforeEach(function () { + $this->seed(RolePermissionSeeder::class); - Livewire::test(Edit::class, ['voucher' => $voucher]) + $this->user = User::factory() + ->active() + ->has(Employee::factory()) + ->create(); + + $this->ownerRole = Role::where('name', 'Owner')->first(); + + $this->user->roles()->attach($this->ownerRole->id); +}); + +function mountEditComponent(User $user, Voucher $voucher) +{ + return Livewire::actingAs($user)->test(Edit::class, ['voucher' => $voucher]); +} + +function createOutlets(int $count = 3) +{ + return Outlet::factory() + ->count($count) + ->has(OpeningHour::factory()->count(7)) + ->has(Facility::factory()->count(5)) + ->create(); +} + +/* +|-------------------------------------------------------------------------- +| Component Rendering & Mount +|-------------------------------------------------------------------------- +*/ + +it('renders page successfully', function () { + $voucher = Voucher::factory()->create(); + $voucher->outlets()->attach(createOutlets(2)->pluck('id')); + + mountEditComponent($this->user, $voucher) ->assertViewIs('livewire.studio.loyalty.voucher.form') ->assertViewHas('pageTitle', 'Ubah Voucher'); }); -it('can update a voucher', function () { - $voucher = Voucher::factory()->active()->create(); +it('loads outlets on mount', function () { + $voucher = Voucher::factory()->create(); + $outlets = createOutlets(3); - $data = Voucher::factory() - ->state(fn () => [ - 'is_active' => fake()->randomElement(IsActive::cases()), - ]) - ->raw(); + $component = mountEditComponent($this->user, $voucher); - $outletIds = Outlet::factory() - ->count(4) - ->has(OpeningHour::factory()->count(7)) - ->has(Facility::factory()->count(5)) - ->create(); + expect($component->outlets)->toHaveCount(3); + expect($component->outlets)->toHaveKey($outlets->first()->id); +}); - Livewire::test(Edit::class, ['voucher' => $voucher]) - ->set('form.name', $data['name']) - ->set('form.code', $data['code']) - ->set('form.min_purchase', $data['min_purchase']) - ->set('form.quota', $data['quota']) - ->set('form.limit_per_user', $data['limit_per_user']) - ->set('form.start_date', $data['start_date']) - ->set('form.end_date', $data['end_date']) - ->set('form.type', $data['type']) - ->set('form.discount_amount', $data['discount_amount']) - ->set('form.max_discount', $data['max_discount']) - ->set('form.is_active', $data['is_active']->value) - ->set('form.outlet_ids', $outletIds->pluck('id')->toArray()) +it('loads voucher data into form on mount', function () { + $outlets = createOutlets(2); + $voucher = Voucher::factory()->create([ + 'name' => 'Test Voucher', + 'code' => 'TEST123', + 'tags' => 'Promo', + 'type' => VoucherType::FIXED->value, + 'discount_amount' => 25000, + 'min_purchase' => 100000, + 'max_discount' => null, + 'quota' => '50', + 'limit_per_user' => '2', + 'summary' => 'Test summary', + 'start_date' => now()->addDay()->format('Y-m-d'), + 'end_date' => now()->addMonth()->format('Y-m-d'), + ]); + $voucher->outlets()->attach($outlets->pluck('id')); + + $component = mountEditComponent($this->user, $voucher); + + expect($component->form->name)->toBe('Test Voucher'); + expect($component->form->code)->toBe('TEST123'); + expect($component->form->tags)->toBe('Promo'); + expect($component->form->type)->toBe((string) VoucherType::FIXED->value); + expect($component->form->summary)->toBe('Test summary'); + expect($component->form->quota)->toBe('50'); + expect($component->form->limit_per_user)->toBe('2'); + expect($component->form->start_date)->toBe(now()->addDay()->format('Y-m-d')); + expect($component->form->end_date)->toBe(now()->addMonth()->format('Y-m-d')); + expect($component->form->outlet_ids)->toEqual($outlets->pluck('id')->toArray()); +}); + +it('formats currency values correctly when loading voucher', function () { + $voucher = Voucher::factory()->create([ + 'discount_amount' => 50000, + 'min_purchase' => 200000, + 'max_discount' => 100000, + ]); + $voucher->outlets()->attach(createOutlets(1)->pluck('id')); + + $component = mountEditComponent($this->user, $voucher); + + // Note: replaceCurrency returns integer, so we check the raw value + // The form will display formatted currency in the view + expect($component->form->discount_amount)->toBe('50000'); + expect($component->form->min_purchase)->toBe('200000'); + expect($component->form->max_discount)->toBe('100000'); +}); + +/* +|-------------------------------------------------------------------------- +| Update Voucher - Fixed Type +|-------------------------------------------------------------------------- +*/ + +it('can update a fixed type voucher', function () { + $outlets = createOutlets(3); + $voucher = Voucher::factory()->create([ + 'type' => VoucherType::FIXED->value, + ]); + $voucher->outlets()->attach($outlets->take(2)->pluck('id')); + + $newOutlets = createOutlets(2); + $allOutlets = $outlets->merge($newOutlets); + + mountEditComponent($this->user, $voucher) + ->set('form.name', 'Updated Voucher') + ->set('form.code', 'UPDATED') + ->set('form.tags', 'Updated Tag') + ->set('form.discount_amount', '30000') + ->set('form.min_purchase', '150000') + ->set('form.quota', '75') + ->set('form.limit_per_user', '3') + ->set('form.summary', 'Updated summary') + ->set('form.start_date', now()->addDays(2)->format('Y-m-d')) + ->set('form.end_date', now()->addMonths(2)->format('Y-m-d')) + ->set('form.outlet_ids', $allOutlets->pluck('id')->toArray()) ->call('save') ->assertHasNoErrors() - ->assertDispatched('refreshDatatable') - ->assertRedirect(Index::class); + ->assertRedirect(route('studio.loyalty.voucher.index')); - $this->assertDatabaseHas('vouchers', [ - 'id' => $voucher->id, - 'name' => $data['name'], - 'code' => $data['code'], - ]); + $voucher->refresh(); + expect($voucher->name)->toBe('Updated Voucher'); + expect($voucher->code)->toBe('UPDATED'); + expect($voucher->tags)->toBe('Updated Tag'); + expect($voucher->discount_amount)->toBe(30000); + expect($voucher->min_purchase)->toBe(150000); + expect($voucher->quota)->toBe(75); + expect($voucher->limit_per_user)->toBe(3); + expect($voucher->outlets)->toHaveCount(5); +}); + +it('can update voucher to remove optional fields', function () { + $voucher = Voucher::factory()->create([ + 'min_purchase' => 50000, + 'quota' => 100, + 'limit_per_user' => 2, + 'end_date' => now()->addMonth()->format('Y-m-d'), + ]); + + $voucher->outlets()->attach(createOutlets(1)->pluck('id')); + + mountEditComponent($this->user, $voucher) + ->set('form.min_purchase', null) + ->set('form.quota', null) + ->set('form.limit_per_user', null) + ->set('form.end_date', null) + ->call('save') + ->assertHasNoErrors(); + + $voucher->refresh(); + expect($voucher->min_purchase)->toBeNull(); + expect($voucher->quota)->toBeNull(); + expect($voucher->limit_per_user)->toBeNull(); + expect($voucher->end_date)->toBeNull(); +}); + +/* +|-------------------------------------------------------------------------- +| Update Voucher - Percentage Type +|-------------------------------------------------------------------------- +*/ + +it('can update voucher from fixed to percentage type', function () { + $voucher = Voucher::factory()->create([ + 'type' => VoucherType::FIXED->value, + 'discount_amount' => 10000, + 'max_discount' => null, + ]); + $voucher->outlets()->attach(createOutlets(1)->pluck('id')); + + mountEditComponent($this->user, $voucher) + ->set('form.type', VoucherType::PERCENTAGE->value) + ->set('form.discount_amount', '15') + ->set('form.max_discount', '40000') + ->call('save') + ->assertHasNoErrors(); + + $voucher->refresh(); + expect($voucher->type)->toBe(VoucherType::PERCENTAGE); + expect($voucher->discount_amount)->toBe(15); + expect($voucher->max_discount)->toBe(40000); +}); + +it('can update voucher from percentage to fixed type', function () { + $voucher = Voucher::factory()->create([ + 'type' => VoucherType::PERCENTAGE->value, + 'discount_amount' => 20, + 'max_discount' => 50000, + ]); + $voucher->outlets()->attach(createOutlets(1)->pluck('id')); + + mountEditComponent($this->user, $voucher) + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '25000') + ->call('save') + ->assertHasNoErrors(); + + $voucher->refresh(); + expect($voucher->type)->toBe(VoucherType::FIXED); + expect($voucher->discount_amount)->toBe(25000); + expect($voucher->max_discount)->toBeNull(); +}); + +it('can update percentage voucher with new max discount', function () { + $voucher = Voucher::factory()->create([ + 'type' => VoucherType::PERCENTAGE->value, + 'discount_amount' => 10, + 'max_discount' => 30000, + ]); + $voucher->outlets()->attach(createOutlets(1)->pluck('id')); + + mountEditComponent($this->user, $voucher) + ->set('form.discount_amount', '25') + ->set('form.max_discount', '60000') + ->call('save') + ->assertHasNoErrors(); + + $voucher->refresh(); + expect($voucher->discount_amount)->toBe(25); + expect($voucher->max_discount)->toBe(60000); +}); + +/* +|-------------------------------------------------------------------------- +| Outlet Management +|-------------------------------------------------------------------------- +*/ + +it('can update voucher outlets', function () { + $originalOutlets = createOutlets(2); + $voucher = Voucher::factory()->create(); + $voucher->outlets()->attach($originalOutlets->pluck('id')); + + $newOutlets = createOutlets(3); + + mountEditComponent($this->user, $voucher) + ->set('form.outlet_ids', $newOutlets->pluck('id')->toArray()) + ->call('save') + ->assertHasNoErrors(); + + $voucher->refresh(); + expect($voucher->outlets)->toHaveCount(3); + expect($voucher->outlets->pluck('id')->toArray())->toEqual($newOutlets->pluck('id')->toArray()); +}); + +it('can add more outlets to existing voucher', function () { + $originalOutlets = createOutlets(2); + $voucher = Voucher::factory()->create(); + $voucher->outlets()->attach($originalOutlets->pluck('id')); + + $additionalOutlets = createOutlets(2); + $allOutlets = $originalOutlets->merge($additionalOutlets); + + mountEditComponent($this->user, $voucher) + ->set('form.outlet_ids', $allOutlets->pluck('id')->toArray()) + ->call('save') + ->assertHasNoErrors(); + + $voucher->refresh(); + expect($voucher->outlets)->toHaveCount(4); +}); + +it('can remove all outlets from voucher', function () { + $outlets = createOutlets(3); + $voucher = Voucher::factory()->create(); + $voucher->outlets()->attach($outlets->pluck('id')); + + mountEditComponent($this->user, $voucher) + ->set('form.outlet_ids', []) + ->call('save') + ->assertHasErrors(['form.outlet_ids']); // Should fail validation +}); + +it('can select all outlets when editing', function () { + $outlets = createOutlets(5); + $voucher = Voucher::factory()->create(); + $voucher->outlets()->attach($outlets->take(2)->pluck('id')); + + $component = mountEditComponent($this->user, $voucher); + $component->call('selectAllOutlets'); + + expect($component->form->outlet_ids)->toHaveCount(5); + expect($component->form->outlet_ids)->toEqual($outlets->pluck('id')->toArray()); +}); + +it('can deselect all outlets when editing', function () { + $outlets = createOutlets(3); + $voucher = Voucher::factory()->create(); + $voucher->outlets()->attach($outlets->pluck('id')); + + $component = mountEditComponent($this->user, $voucher); + expect($component->form->outlet_ids)->toHaveCount(3); + + $component->call('deselectAllOutlets'); + + expect($component->form->outlet_ids)->toHaveCount(0); +}); + +/* +|-------------------------------------------------------------------------- +| Validation Tests +|-------------------------------------------------------------------------- +*/ + +it('validates required fields on update', function () { + $voucher = Voucher::factory()->create(); + $voucher->outlets()->attach(createOutlets(1)->pluck('id')); + + mountEditComponent($this->user, $voucher) + ->set('form.name', '') + ->set('form.code', '') + ->set('form.tags', '') + ->set('form.summary', '') + ->set('form.start_date', '') + ->set('form.outlet_ids', []) + ->call('save') + ->assertHasErrors([ + 'form.name', + 'form.code', + 'form.tags', + 'form.summary', + 'form.start_date', + 'form.outlet_ids', + ]); +}); + +it('allows same code when updating voucher', function () { + $voucher = Voucher::factory()->create(['code' => 'EXISTING']); + $voucher->outlets()->attach(createOutlets(1)->pluck('id')); + + mountEditComponent($this->user, $voucher) + ->set('form.name', 'Updated Name') + ->set('form.code', 'EXISTING') // Same code + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$voucher->outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); +}); + +it('validates code uniqueness when changing to existing code', function () { + $voucher1 = Voucher::factory()->create(['code' => 'VOUCHER1']); + $voucher2 = Voucher::factory()->create(['code' => 'VOUCHER2']); + $voucher1->outlets()->attach(createOutlets(1)->pluck('id')); + + mountEditComponent($this->user, $voucher1) + ->set('form.code', 'VOUCHER2') // Trying to use existing code + ->set('form.name', 'Test') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$voucher1->outlets->first()->id]) + ->call('save') + ->assertHasErrors(['form.code']); +}); + +it('validates percentage discount requires max_discount on update', function () { + $voucher = Voucher::factory()->create([ + 'type' => VoucherType::PERCENTAGE->value, + 'max_discount' => 50000, + ]); + $voucher->outlets()->attach(createOutlets(1)->pluck('id')); + + mountEditComponent($this->user, $voucher) + ->set('form.max_discount', null) + ->call('save') + ->assertHasErrors(['form.max_discount']); +}); + +/* +|-------------------------------------------------------------------------- +| Permissions +|-------------------------------------------------------------------------- +*/ + +it('prevents update when user lacks update voucher permission', function () { + $this->ownerRole->revokePermissionTo('update voucher'); + Gate::define('update voucher', fn () => false); + + $voucher = Voucher::factory()->create(); + $voucher->outlets()->attach(createOutlets(1)->pluck('id')); + + mountEditComponent($this->user, $voucher) + ->set('form.name', 'Updated') + ->set('form.code', 'UPDATED') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$voucher->outlets->first()->id]) + ->call('save') + ->assertForbidden(); +}); + +it('allows update when user has update voucher permission', function () { + $voucher = Voucher::factory()->create(); + $voucher->outlets()->attach(createOutlets(1)->pluck('id')); + + mountEditComponent($this->user, $voucher) + ->set('form.name', 'Updated') + ->set('form.code', 'UPDATED') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', [$voucher->outlets->first()->id]) + ->call('save') + ->assertHasNoErrors(); +}); + +/* +|-------------------------------------------------------------------------- +| Edge Cases +|-------------------------------------------------------------------------- +*/ + +it('handles voucher with no outlets initially', function () { + $voucher = Voucher::factory()->create(); + // No outlets attached + + $outlets = createOutlets(2); + + mountEditComponent($this->user, $voucher) + ->set('form.name', 'Test') + ->set('form.code', 'TEST') + ->set('form.tags', 'Test') + ->set('form.type', VoucherType::FIXED->value) + ->set('form.discount_amount', '10000') + ->set('form.summary', 'Test') + ->set('form.start_date', now()->addDay()->format('Y-m-d')) + ->set('form.outlet_ids', $outlets->pluck('id')->toArray()) + ->call('save') + ->assertHasNoErrors(); + + $voucher->refresh(); + expect($voucher->outlets)->toHaveCount(2); +}); + +it('preserves available_count when updating quota', function () { + $voucher = Voucher::factory()->create([ + 'quota' => 100, + 'available_count' => 50, // Some vouchers already used + ]); + $voucher->outlets()->attach(createOutlets(1)->pluck('id')); + + // Note: The form doesn't update available_count, only quota + // This test verifies that available_count is not reset + mountEditComponent($this->user, $voucher) + ->set('form.quota', '200') + ->call('save') + ->assertHasNoErrors(); + + $voucher->refresh(); + + expect($voucher->quota)->toBe(200); + // available_count should remain 50 (not reset to 200) + expect($voucher->available_count)->toBe(50); }); diff --git a/tests/Feature/Livewire/Studio/Loyalty/Voucher/IndexTest.php b/tests/Feature/Livewire/Studio/Loyalty/Voucher/IndexTest.php index 0c6ca6b..246712e 100644 --- a/tests/Feature/Livewire/Studio/Loyalty/Voucher/IndexTest.php +++ b/tests/Feature/Livewire/Studio/Loyalty/Voucher/IndexTest.php @@ -1,43 +1,312 @@ 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 mountIndexComponent(User $user) +{ + return Livewire::actingAs($user)->test(Index::class); +} + +/* +|-------------------------------------------------------------------------- +| Component Rendering & Mount +|-------------------------------------------------------------------------- +*/ + +it('renders page successfully', function () { + mountIndexComponent($this->user) ->assertViewIs('livewire.studio.loyalty.voucher.index') ->assertViewHas('pageTitle', 'Voucher'); }); it('displays all vouchers', function () { - Voucher::factory() - ->count(10) - ->state(fn () => [ - 'is_active' => fake()->randomElement(IsActive::cases()), - ]) - ->create(); + Voucher::factory()->count(10)->create(); $voucher = Voucher::first(); - Livewire::test(Index::class)->assertSee($voucher->name); + mountIndexComponent($this->user) + ->assertSee($voucher->name); }); -it('can delete a voucher', function () { - $voucher = Voucher::factory() - ->state(fn () => [ - 'is_active' => fake()->randomElement(IsActive::cases()), - ]) - ->create(); +/* +|-------------------------------------------------------------------------- +| Statistics +|-------------------------------------------------------------------------- +*/ - Livewire::test(Index::class) +it('displays correct total voucher count', function () { + Voucher::factory()->count(5)->create(); + + $component = mountIndexComponent($this->user); + + expect($component->stats)->toHaveCount(4); + expect($component->stats[0]['title'])->toBe('Total Voucher'); + expect($component->stats[0]['value'])->toBe(5); +}); + +it('displays correct active voucher count', function () { + // Active vouchers: start_date <= now AND (end_date is null OR end_date >= now) + Voucher::factory()->create([ + 'start_date' => now()->subDay()->format('Y-m-d'), + 'end_date' => now()->addDay()->format('Y-m-d'), + ]); + Voucher::factory()->create([ + 'start_date' => now()->subDay()->format('Y-m-d'), + 'end_date' => null, + ]); + Voucher::factory()->create([ + 'start_date' => now()->subWeek()->format('Y-m-d'), + 'end_date' => now()->addWeek()->format('Y-m-d'), + ]); + + // Inactive vouchers + Voucher::factory()->create([ + 'start_date' => now()->subWeek()->format('Y-m-d'), + 'end_date' => now()->subDay()->format('Y-m-d'), + ]); + + // Upcoming vouchers + Voucher::factory()->create([ + 'start_date' => now()->addDay()->format('Y-m-d'), + 'end_date' => now()->addWeek()->format('Y-m-d'), + ]); + + $component = mountIndexComponent($this->user); + + expect($component->stats[1]['title'])->toBe('Voucher Aktif'); + expect($component->stats[1]['value'])->toBe(3); +}); + +it('displays correct inactive voucher count', function () { + // Inactive vouchers: end_date < now + Voucher::factory()->create([ + 'start_date' => now()->subWeek()->format('Y-m-d'), + 'end_date' => now()->subDay()->format('Y-m-d'), + ]); + Voucher::factory()->create([ + 'start_date' => now()->subMonth()->format('Y-m-d'), + 'end_date' => now()->subWeek()->format('Y-m-d'), + ]); + + // Active vouchers + Voucher::factory()->create([ + 'start_date' => now()->subDay()->format('Y-m-d'), + 'end_date' => now()->addDay()->format('Y-m-d'), + ]); + + // Upcoming vouchers + Voucher::factory()->create([ + 'start_date' => now()->addDay()->format('Y-m-d'), + 'end_date' => now()->addWeek()->format('Y-m-d'), + ]); + + $component = mountIndexComponent($this->user); + + expect($component->stats[2]['title'])->toBe('Voucher Tidak Aktif'); + expect($component->stats[2]['value'])->toBe(2); +}); + +it('displays correct upcoming voucher count', function () { + // Upcoming vouchers: start_date > now + Voucher::factory()->create([ + 'start_date' => now()->addDay()->format('Y-m-d'), + 'end_date' => now()->addWeek()->format('Y-m-d'), + ]); + Voucher::factory()->create([ + 'start_date' => now()->addWeek()->format('Y-m-d'), + 'end_date' => now()->addMonth()->format('Y-m-d'), + ]); + + // Active vouchers + Voucher::factory()->create([ + 'start_date' => now()->subDay()->format('Y-m-d'), + 'end_date' => now()->addDay()->format('Y-m-d'), + ]); + + // Inactive vouchers + Voucher::factory()->create([ + 'start_date' => now()->subWeek()->format('Y-m-d'), + 'end_date' => now()->subDay()->format('Y-m-d'), + ]); + + $component = mountIndexComponent($this->user); + + expect($component->stats[3]['title'])->toBe('Voucher Akan Datang'); + expect($component->stats[3]['value'])->toBe(2); +}); + +it('calculates stats correctly with mixed voucher statuses', function () { + // Create vouchers with different statuses + Voucher::factory()->count(3)->create([ + 'start_date' => now()->subDay()->format('Y-m-d'), + 'end_date' => now()->addDay()->format('Y-m-d'), + ]); + + Voucher::factory()->count(2)->create([ + 'start_date' => now()->subWeek()->format('Y-m-d'), + 'end_date' => now()->subDay()->format('Y-m-d'), + ]); + + Voucher::factory()->count(4)->create([ + 'start_date' => now()->addDay()->format('Y-m-d'), + 'end_date' => now()->addWeek()->format('Y-m-d'), + ]); + + $component = mountIndexComponent($this->user); + + expect($component->stats[0]['value'])->toBe(9); // Total + expect($component->stats[1]['value'])->toBe(3); // Active + expect($component->stats[2]['value'])->toBe(2); // Inactive + expect($component->stats[3]['value'])->toBe(4); // Upcoming +}); + +it('handles vouchers with null end_date in stats', function () { + Voucher::factory()->create([ + 'start_date' => now()->subDay()->format('Y-m-d'), + 'end_date' => null, // Never expires + ]); + + Voucher::factory()->create([ + 'start_date' => now()->addDay()->format('Y-m-d'), + 'end_date' => null, // Upcoming, never expires + ]); + + $component = mountIndexComponent($this->user); + + expect($component->stats[1]['value'])->toBe(1); // Active (started, no end date) + expect($component->stats[3]['value'])->toBe(1); // Upcoming (not started yet) +}); + +/* +|-------------------------------------------------------------------------- +| Delete Voucher +|-------------------------------------------------------------------------- +*/ + +it('can delete a voucher', function () { + $voucher = Voucher::factory()->create(); + + mountIndexComponent($this->user) ->call('delete', $voucher) ->assertHasNoErrors() ->assertDispatched('refreshDatatable'); expect(Voucher::count())->toBe(0); + $this->assertSoftDeleted('vouchers', ['id' => $voucher->id]); +}); + +it('can delete multiple vouchers', function () { + $vouchers = Voucher::factory()->count(5)->create(); + + $component = mountIndexComponent($this->user); + + foreach ($vouchers as $voucher) { + $component->call('delete', $voucher); + } + + expect(Voucher::count())->toBe(0); +}); + +it('soft deletes voucher when deleting', function () { + $voucher = Voucher::factory()->create(); + + mountIndexComponent($this->user) + ->call('delete', $voucher); + + expect(Voucher::withTrashed()->count())->toBe(1); + expect(Voucher::count())->toBe(0); + expect($voucher->fresh()->trashed())->toBeTrue(); +}); + +/* +|-------------------------------------------------------------------------- +| Permissions +|-------------------------------------------------------------------------- +*/ + +it('displays create button when user has create voucher permission', function () { + mountIndexComponent($this->user) + ->assertSee('Tambah'); +}); + +it('hides create button when user lacks create voucher permission', function () { + $this->ownerRole->revokePermissionTo('create voucher'); + Gate::define('create voucher', fn () => false); + + mountIndexComponent($this->user) + ->assertDontSee('Tambah'); +}); + +/* +|-------------------------------------------------------------------------- +| Edge Cases +|-------------------------------------------------------------------------- +*/ + +it('handles empty voucher list', function () { + $component = mountIndexComponent($this->user); + + expect($component->stats[0]['value'])->toBe(0); + expect($component->stats[1]['value'])->toBe(0); + expect($component->stats[2]['value'])->toBe(0); + expect($component->stats[3]['value'])->toBe(0); +}); + +it('handles voucher with same start and end date', function () { + $today = now()->format('Y-m-d'); + + Voucher::factory()->create([ + 'start_date' => $today, + 'end_date' => $today, + ]); + + $component = mountIndexComponent($this->user); + + // Should be counted as active (start_date <= now AND end_date >= now) + expect($component->stats[1]['value'])->toBe(1); +}); + +it('updates stats when vouchers are deleted', function () { + $voucher1 = Voucher::factory()->create([ + 'start_date' => now()->subDay()->format('Y-m-d'), + 'end_date' => now()->addDay()->format('Y-m-d'), + ]); + $voucher2 = Voucher::factory()->create([ + 'start_date' => now()->addDay()->format('Y-m-d'), + 'end_date' => now()->addWeek()->format('Y-m-d'), + ]); + + $component = mountIndexComponent($this->user); + expect($component->stats[0]['value'])->toBe(2); + expect($component->stats[1]['value'])->toBe(1); + expect($component->stats[3]['value'])->toBe(1); + + $component->call('delete', $voucher1); + + // Note: Stats are calculated in mount(), so they won't update automatically + // This test verifies the delete works, but stats would need remount to update + expect(Voucher::count())->toBe(1); });