seed(RolePermissionSeeder::class); User::factory()->create(); // Required by CashAccountSeeder $this->seed(CashAccountSeeder::class); Storage::fake('public'); }); // ─── Helper ─────────────────────────────────────────────── function createAdvanceUserWithPermission(PermissionEnum ...$permissions): User { $user = User::factory()->create(); $user->givePermissionTo( array_merge( [PermissionEnum::DASHBOARD_VIEW->value], array_map(fn (PermissionEnum $p) => $p->value, $permissions) ) ); $user->forgetCachedPermissions(); return $user; } function createEmployeeUser(): User { $user = User::factory()->create(); UserProfile::factory()->create([ 'user_id' => $user->id, 'full_name' => fake()->name(), ]); Employee::factory()->create(['user_id' => $user->id]); return $user; } function createEmployeeUserWithAdvancePermission(): User { $user = createEmployeeUser(); $user->givePermissionTo( PermissionEnum::DASHBOARD_VIEW->value, PermissionEnum::EMPLOYEE_ADVANCES_VIEW->value, PermissionEnum::EMPLOYEE_ADVANCES_CREATE->value, PermissionEnum::EMPLOYEE_ADVANCES_UPDATE->value, PermissionEnum::EMPLOYEE_ADVANCES_DELETE->value, ); $user->forgetCachedPermissions(); return $user; } function advancePayload(?int $amount = null, ?string $description = null, ?string $dueDate = null): array { return [ 'amount' => $amount ?? 500000, 'description' => $description ?? 'Kasbon untuk keperluan pribadi', 'due_date' => $dueDate ?? now()->addMonth()->format('Y-m-d'), ]; } // ─── Index ──────────────────────────────────────────────── describe('Employee Advance Index', function () { test('authenticated user with permission can view index', function () { $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); $this->actingAs($user) ->get(route('admin.finance.employee_advances.index')) ->assertOk(); }); test('guest is redirected to login', function () { $this->get(route('admin.finance.employee_advances.index')) ->assertRedirect(route('login')); }); test('user without permission is forbidden', function () { $user = User::factory()->create(); $this->actingAs($user) ->get(route('admin.finance.employee_advances.index')) ->assertForbidden(); }); test('employee can view their own advances', function () { $user = createEmployeeUserWithAdvancePermission(); $employee = $user->employee; EmployeeAdvance::factory()->count(2)->create(['employee_id' => $employee->id]); $this->actingAs($user) ->get(route('admin.finance.employee_advances.index')) ->assertOk(); }); test('index can filter by status', function () { $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); $this->actingAs($user) ->get(route('admin.finance.employee_advances.index', ['status' => 'pending'])) ->assertOk(); }); }); // ─── Store ──────────────────────────────────────────────── describe('Employee Advance Store', function () { test('employee with permission can submit kasbon', function () { $user = createEmployeeUserWithAdvancePermission(); $this->actingAs($user) ->post(route('admin.finance.employee_advances.store'), advancePayload()) ->assertRedirect(route('admin.finance.employee_advances.index')); $this->assertDatabaseHas('employee_advances', [ 'employee_id' => $user->employee->id, 'amount' => 500000, 'status' => EmployeeAdvanceStatus::PENDING->value, ]); }); test('guest cannot submit kasbon', function () { $this->post(route('admin.finance.employee_advances.store'), advancePayload()) ->assertRedirect(route('login')); }); test('user without create permission cannot submit kasbon', function () { $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); $this->actingAs($user) ->post(route('admin.finance.employee_advances.store'), advancePayload()) ->assertForbidden(); }); test('user without employee record cannot submit kasbon', function () { $user = createAdvanceUserWithPermission( PermissionEnum::EMPLOYEE_ADVANCES_VIEW, PermissionEnum::EMPLOYEE_ADVANCES_CREATE, ); $this->actingAs($user) ->post(route('admin.finance.employee_advances.store'), advancePayload()) ->assertForbidden(); }); test('verifier cannot submit kasbon', function () { $user = createEmployeeUser(); $user->givePermissionTo( PermissionEnum::DASHBOARD_VIEW->value, PermissionEnum::EMPLOYEE_ADVANCES_VIEW->value, PermissionEnum::EMPLOYEE_ADVANCES_VERIFY->value, ); $user->forgetCachedPermissions(); $this->actingAs($user) ->post(route('admin.finance.employee_advances.store'), advancePayload()) ->assertForbidden(); }); test('amount is required', function () { $user = createEmployeeUserWithAdvancePermission(); $this->actingAs($user) ->post(route('admin.finance.employee_advances.store'), [ 'amount' => '', 'description' => 'Test', 'due_date' => now()->addMonth()->format('Y-m-d'), ]) ->assertSessionHasErrors('amount'); }); test('amount must be at least 1', function () { $user = createEmployeeUserWithAdvancePermission(); $this->actingAs($user) ->post(route('admin.finance.employee_advances.store'), advancePayload(amount: 0)) ->assertSessionHasErrors('amount'); }); test('description is required', function () { $user = createEmployeeUserWithAdvancePermission(); $this->actingAs($user) ->post(route('admin.finance.employee_advances.store'), advancePayload(description: '')) ->assertSessionHasErrors('description'); }); test('due_date is required', function () { $user = createEmployeeUserWithAdvancePermission(); $this->actingAs($user) ->post(route('admin.finance.employee_advances.store'), advancePayload(dueDate: '')) ->assertSessionHasErrors('due_date'); }); test('due_date must be today or after', function () { $user = createEmployeeUserWithAdvancePermission(); $this->actingAs($user) ->post(route('admin.finance.employee_advances.store'), advancePayload(dueDate: '2020-01-01')) ->assertSessionHasErrors('due_date'); }); }); // ─── Update ─────────────────────────────────────────────── describe('Employee Advance Update', function () { test('employee can update their own pending kasbon', function () { $user = createEmployeeUserWithAdvancePermission(); $advance = EmployeeAdvance::factory()->create([ 'employee_id' => $user->employee->id, 'status' => EmployeeAdvanceStatus::PENDING->value, ]); $this->actingAs($user) ->put(route('admin.finance.employee_advances.update', $advance), advancePayload(700000, 'Deskripsi baru')) ->assertRedirect(route('admin.finance.employee_advances.index')); expect($advance->fresh()->amount)->toBe(700000); expect($advance->fresh()->description)->toBe('Deskripsi baru'); }); test('guest cannot update kasbon', function () { $advance = EmployeeAdvance::factory()->create(); $this->put(route('admin.finance.employee_advances.update', $advance), advancePayload()) ->assertRedirect(route('login')); }); test('employee cannot update others kasbon', function () { $user = createEmployeeUserWithAdvancePermission(); $otherAdvance = EmployeeAdvance::factory()->create([ 'status' => EmployeeAdvanceStatus::PENDING->value, ]); $this->actingAs($user) ->put(route('admin.finance.employee_advances.update', $otherAdvance), advancePayload()) ->assertForbidden(); }); test('cannot update non-pending kasbon', function () { $user = createEmployeeUserWithAdvancePermission(); $advance = EmployeeAdvance::factory()->approved()->create([ 'employee_id' => $user->employee->id, ]); $this->actingAs($user) ->put(route('admin.finance.employee_advances.update', $advance), advancePayload()) ->assertForbidden(); }); }); // ─── Destroy ────────────────────────────────────────────── describe('Employee Advance Destroy', function () { test('employee can delete their own kasbon', function () { $user = createEmployeeUserWithAdvancePermission(); $advance = EmployeeAdvance::factory()->create([ 'employee_id' => $user->employee->id, ]); $this->actingAs($user) ->delete(route('admin.finance.employee_advances.destroy', $advance)) ->assertRedirect(route('admin.finance.employee_advances.index')); $this->assertDatabaseMissing('employee_advances', ['id' => $advance->id]); }); test('guest cannot delete kasbon', function () { $advance = EmployeeAdvance::factory()->create(); $this->delete(route('admin.finance.employee_advances.destroy', $advance)) ->assertRedirect(route('login')); }); test('user without delete permission cannot delete kasbon', function () { $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); $advance = EmployeeAdvance::factory()->create(); $this->actingAs($user) ->delete(route('admin.finance.employee_advances.destroy', $advance)) ->assertForbidden(); }); }); // ─── Approve ────────────────────────────────────────────── describe('Employee Advance Approve', function () { test('user with verify permission can approve kasbon', function () { $user = createAdvanceUserWithPermission( PermissionEnum::EMPLOYEE_ADVANCES_VIEW, PermissionEnum::EMPLOYEE_ADVANCES_VERIFY, ); $account = CashAccount::query()->first(); $account->update(['balance' => 5000000]); $advance = EmployeeAdvance::factory()->create([ 'amount' => 500000, 'status' => EmployeeAdvanceStatus::PENDING->value, ]); $this->actingAs($user) ->post(route('admin.finance.employee_advances.approve', $advance)) ->assertRedirect(route('admin.finance.employee_advances.index')); expect($advance->fresh()->status)->toBe(EmployeeAdvanceStatus::APPROVED); expect($advance->fresh()->verified_at)->not->toBeNull(); expect($advance->fresh()->verified_by_id)->toBe($user->id); }); test('guest cannot approve kasbon', function () { $advance = EmployeeAdvance::factory()->create(); $this->post(route('admin.finance.employee_advances.approve', $advance)) ->assertRedirect(route('login')); }); test('user without verify permission cannot approve kasbon', function () { $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); $advance = EmployeeAdvance::factory()->create(); $this->actingAs($user) ->post(route('admin.finance.employee_advances.approve', $advance)) ->assertForbidden(); }); test('approving kasbon creates cash transaction', function () { $user = createAdvanceUserWithPermission( PermissionEnum::EMPLOYEE_ADVANCES_VIEW, PermissionEnum::EMPLOYEE_ADVANCES_VERIFY, ); $account = CashAccount::query()->first(); $account->update(['balance' => 5000000]); $advance = EmployeeAdvance::factory()->create([ 'amount' => 500000, 'status' => EmployeeAdvanceStatus::PENDING->value, ]); $this->actingAs($user) ->post(route('admin.finance.employee_advances.approve', $advance)); expect($advance->fresh()->cash_transaction_id)->not->toBeNull(); }); }); // ─── Reject ─────────────────────────────────────────────── describe('Employee Advance Reject', function () { test('user with verify permission can reject kasbon', function () { $user = createAdvanceUserWithPermission( PermissionEnum::EMPLOYEE_ADVANCES_VIEW, PermissionEnum::EMPLOYEE_ADVANCES_VERIFY, ); $advance = EmployeeAdvance::factory()->create([ 'status' => EmployeeAdvanceStatus::PENDING->value, ]); $this->actingAs($user) ->post(route('admin.finance.employee_advances.reject', $advance), [ 'reason' => 'Alasan ditolak', ]) ->assertRedirect(route('admin.finance.employee_advances.index')); expect($advance->fresh()->status)->toBe(EmployeeAdvanceStatus::REJECTED); }); test('guest cannot reject kasbon', function () { $advance = EmployeeAdvance::factory()->create(); $this->post(route('admin.finance.employee_advances.reject', $advance), [ 'reason' => 'Alasan', ])->assertRedirect(route('login')); }); test('user without verify permission cannot reject kasbon', function () { $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); $advance = EmployeeAdvance::factory()->create(); $this->actingAs($user) ->post(route('admin.finance.employee_advances.reject', $advance), [ 'reason' => 'Alasan', ]) ->assertForbidden(); }); test('reason is required for rejection', function () { $user = createAdvanceUserWithPermission( PermissionEnum::EMPLOYEE_ADVANCES_VIEW, PermissionEnum::EMPLOYEE_ADVANCES_VERIFY, ); $advance = EmployeeAdvance::factory()->create(); $this->actingAs($user) ->post(route('admin.finance.employee_advances.reject', $advance), [ 'reason' => '', ]) ->assertSessionHasErrors('reason'); }); test('rejecting kasbon creates rejection record', function () { $user = createAdvanceUserWithPermission( PermissionEnum::EMPLOYEE_ADVANCES_VIEW, PermissionEnum::EMPLOYEE_ADVANCES_VERIFY, ); $advance = EmployeeAdvance::factory()->create([ 'status' => EmployeeAdvanceStatus::PENDING->value, ]); $this->actingAs($user) ->post(route('admin.finance.employee_advances.reject', $advance), [ 'reason' => 'Budget tidak tersedia', ]); $this->assertDatabaseHas('rejections', [ 'rejectable_type' => EmployeeAdvance::class, 'rejectable_id' => $advance->id, 'reason' => 'Budget tidak tersedia', ]); }); }); // ─── Pay ────────────────────────────────────────────────── describe('Employee Advance Pay', function () { test('user with pay permission can mark kasbon as paid', function () { $user = createAdvanceUserWithPermission( PermissionEnum::EMPLOYEE_ADVANCES_VIEW, PermissionEnum::EMPLOYEE_ADVANCES_PAY, ); $account = CashAccount::query()->first(); $account->update(['balance' => 5000000]); $advance = EmployeeAdvance::factory()->approved()->create([ 'amount' => 500000, 'cash_transaction_id' => null, ]); $this->actingAs($user) ->post(route('admin.finance.employee_advances.pay', $advance)) ->assertRedirect(route('admin.finance.employee_advances.index')); expect($advance->fresh()->status)->toBe(EmployeeAdvanceStatus::PAID); expect($advance->fresh()->paid_at)->not->toBeNull(); }); test('guest cannot mark kasbon as paid', function () { $advance = EmployeeAdvance::factory()->approved()->create(); $this->post(route('admin.finance.employee_advances.pay', $advance)) ->assertRedirect(route('login')); }); test('user without pay permission cannot mark kasbon as paid', function () { $user = createAdvanceUserWithPermission(PermissionEnum::EMPLOYEE_ADVANCES_VIEW); $advance = EmployeeAdvance::factory()->approved()->create(); $this->actingAs($user) ->post(route('admin.finance.employee_advances.pay', $advance)) ->assertForbidden(); }); }); // ─── Employee Advance Model ─────────────────────────────── describe('Employee Advance Model', function () { test('employee advance has amount cast to integer', function () { $advance = EmployeeAdvance::factory()->create(['amount' => 1000000]); expect($advance->amount)->toBeInt(); expect($advance->amount)->toBe(1000000); }); test('employee advance has status cast to enum', function () { $advance = EmployeeAdvance::factory()->create([ 'status' => EmployeeAdvanceStatus::PENDING->value, ]); expect($advance->status)->toBe(EmployeeAdvanceStatus::PENDING); }); test('employee advance has amount formatted accessor', function () { $advance = EmployeeAdvance::factory()->create(['amount' => 1500000]); expect($advance->amount_formatted)->toBe('Rp 1.500.000'); }); test('employee advance has status label accessor', function () { $advance = EmployeeAdvance::factory()->create([ 'status' => EmployeeAdvanceStatus::PENDING->value, ]); expect($advance->status_label)->toBe('Menunggu'); }); test('employee advance belongs to employee', function () { $advance = EmployeeAdvance::factory()->create(); expect($advance->employee)->not->toBeNull(); }); test('pending scope returns only pending advances', function () { EmployeeAdvance::factory()->count(2)->create([ 'status' => EmployeeAdvanceStatus::PENDING->value, ]); EmployeeAdvance::factory()->approved()->create(); $pending = EmployeeAdvance::query()->pending()->get(); expect($pending)->toHaveCount(2); }); test('approved scope returns only approved advances', function () { EmployeeAdvance::factory()->count(3)->approved()->create(); EmployeeAdvance::factory()->create([ 'status' => EmployeeAdvanceStatus::PENDING->value, ]); $approved = EmployeeAdvance::query()->approved()->get(); expect($approved)->toHaveCount(3); }); });