From 66bdd86b4fb0007044d6026809d6da0e4f39abee Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Mon, 22 Jun 2026 20:54:58 +0700 Subject: [PATCH] refactor: remove unused check_out_photo_path from checkedInOnly state in AttendanceFactory --- app/Models/PayrollAdjustment.php | 2 +- database/factories/AttendanceFactory.php | 1 - tests/Feature/Admin/Finance/CashTest.php | 1 - tests/Feature/Admin/Finance/PayrollTest.php | 2 - tests/Feature/Admin/Hr/AttendanceTest.php | 508 ++++++++++++++++ tests/Feature/Admin/Hr/LeaveRequestTest.php | 618 ++++++++++++++++++++ 6 files changed, 1127 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/Admin/Hr/AttendanceTest.php create mode 100644 tests/Feature/Admin/Hr/LeaveRequestTest.php diff --git a/app/Models/PayrollAdjustment.php b/app/Models/PayrollAdjustment.php index 0430399..720e30a 100644 --- a/app/Models/PayrollAdjustment.php +++ b/app/Models/PayrollAdjustment.php @@ -7,11 +7,11 @@ use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Scope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Builder; #[Guarded(['id'])] #[Appends(['amount_formatted', 'type_label', 'created_at_formatted', 'created_by_name'])] diff --git a/database/factories/AttendanceFactory.php b/database/factories/AttendanceFactory.php index 24efa21..dbe32b7 100644 --- a/database/factories/AttendanceFactory.php +++ b/database/factories/AttendanceFactory.php @@ -33,7 +33,6 @@ public function checkedInOnly(): static { return $this->state(fn (array $attributes) => [ 'check_out_at' => null, - 'check_out_photo_path' => null, 'check_out_latitude' => null, 'check_out_longitude' => null, 'work_duration_minutes' => null, diff --git a/tests/Feature/Admin/Finance/CashTest.php b/tests/Feature/Admin/Finance/CashTest.php index e1b3168..e3191fe 100644 --- a/tests/Feature/Admin/Finance/CashTest.php +++ b/tests/Feature/Admin/Finance/CashTest.php @@ -1,6 +1,5 @@ seed(RolePermissionSeeder::class); + Storage::fake('public'); + Queue::fake(); +}); + +// ─── Helper ─────────────────────────────────────────────── + +function createAttendanceUserWithPermission(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 createAttendanceEmployeeUser(PermissionEnum ...$permissions): User +{ + $user = User::factory()->create(); + UserProfile::factory()->create([ + 'user_id' => $user->id, + 'full_name' => fake()->name(), + ]); + Employee::factory()->create(['user_id' => $user->id]); + $user->assignRole('marketing'); + + $user->givePermissionTo( + array_merge( + [PermissionEnum::DASHBOARD_VIEW->value], + array_map(fn (PermissionEnum $p) => $p->value, $permissions) + ) + ); + + $user->forgetCachedPermissions(); + + return $user; +} + +function fakeBase64Photo(): string +{ + // 1x1 pixel transparent PNG + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; +} + +function checkInPayload(): array +{ + return [ + 'photo' => fakeBase64Photo(), + 'latitude' => -6.2088, + 'longitude' => 106.8456, + ]; +} + +function checkOutPayload(): array +{ + return [ + 'photo' => fakeBase64Photo(), + 'latitude' => -6.2090, + 'longitude' => 106.8460, + ]; +} + +function createTodayAttendanceForEmployee(Employee $employee): Attendance +{ + return Attendance::factory()->create([ + 'employee_id' => $employee->id, + 'attendance_date' => today()->format('Y-m-d'), + 'check_in_at' => now()->subHours(8), + 'check_out_at' => null, + 'check_out_latitude' => null, + 'check_out_longitude' => null, + 'work_duration_minutes' => null, + ]); +} + +// ─── Index ──────────────────────────────────────────────── + +describe('Attendance Index', function () { + test('authenticated user with permission can view attendance index', function () { + $user = createAttendanceUserWithPermission(PermissionEnum::ATTENDANCES_VIEW); + + $this->actingAs($user) + ->get(route('admin.hr.attendances.index')) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $this->get(route('admin.hr.attendances.index')) + ->assertRedirect(route('login')); + }); + + test('user without permission is forbidden', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('admin.hr.attendances.index')) + ->assertForbidden(); + }); + + test('index displays attendances for employee user', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW); + + Attendance::factory()->count(3)->create([ + 'employee_id' => $user->employee->id, + ]); + + $this->actingAs($user) + ->get(route('admin.hr.attendances.index')) + ->assertOk(); + }); + + test('manager can see all attendances', function () { + $user = createAttendanceUserWithPermission(PermissionEnum::ATTENDANCES_VIEW); + $user->assignRole('owner'); + $user->forgetCachedPermissions(); + + $this->actingAs($user) + ->get(route('admin.hr.attendances.index')) + ->assertOk(); + }); + + test('index accepts date range parameters', function () { + $user = createAttendanceUserWithPermission(PermissionEnum::ATTENDANCES_VIEW); + + $this->actingAs($user) + ->get(route('admin.hr.attendances.index', [ + 'start' => now()->startOfMonth()->format('Y-m-d'), + 'end' => now()->endOfMonth()->format('Y-m-d'), + ])) + ->assertOk(); + }); +}); + +// ─── Check In ───────────────────────────────────────────── + +describe('Attendance Check In', function () { + test('employee with permission can check in', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW, PermissionEnum::ATTENDANCES_CREATE); + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_in'), checkInPayload()) + ->assertRedirect(route('admin.hr.attendances.index')); + + $this->assertDatabaseHas('attendances', [ + 'employee_id' => $user->employee->id, + 'attendance_date' => today()->format('Y-m-d'), + ]); + }); + + test('guest cannot check in', function () { + $this->post(route('admin.hr.attendances.check_in'), checkInPayload()) + ->assertRedirect(route('login')); + }); + + test('user without create permission is forbidden', function () { + $user = createAttendanceUserWithPermission(PermissionEnum::ATTENDANCES_VIEW); + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_in'), checkInPayload()) + ->assertForbidden(); + }); + + test('user without employee record cannot check in', function () { + $user = createAttendanceUserWithPermission( + PermissionEnum::ATTENDANCES_VIEW, + PermissionEnum::ATTENDANCES_CREATE, + ); + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_in'), checkInPayload()) + ->assertStatus(302); + }); + + test('cannot check in twice on the same day', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW, PermissionEnum::ATTENDANCES_CREATE); + + createTodayAttendanceForEmployee($user->employee); + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_in'), checkInPayload()) + ->assertSessionHasErrors('attendance'); + }); + + test('photo is required for check in', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW, PermissionEnum::ATTENDANCES_CREATE); + + $payload = checkInPayload(); + $payload['photo'] = ''; + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_in'), $payload) + ->assertSessionHasErrors('photo'); + }); + + test('latitude is required for check in', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW, PermissionEnum::ATTENDANCES_CREATE); + + $payload = checkInPayload(); + unset($payload['latitude']); + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_in'), $payload) + ->assertSessionHasErrors('latitude'); + }); + + test('longitude is required for check in', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW, PermissionEnum::ATTENDANCES_CREATE); + + $payload = checkInPayload(); + unset($payload['longitude']); + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_in'), $payload) + ->assertSessionHasErrors('longitude'); + }); + + test('latitude must be between -90 and 90', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW, PermissionEnum::ATTENDANCES_CREATE); + + $payload = checkInPayload(); + $payload['latitude'] = 100; + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_in'), $payload) + ->assertSessionHasErrors('latitude'); + }); + + test('longitude must be between -180 and 180', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW, PermissionEnum::ATTENDANCES_CREATE); + + $payload = checkInPayload(); + $payload['longitude'] = 200; + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_in'), $payload) + ->assertSessionHasErrors('longitude'); + }); + + test('check in stores latitude and longitude', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW, PermissionEnum::ATTENDANCES_CREATE); + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_in'), [ + 'photo' => fakeBase64Photo(), + 'latitude' => -6.2088, + 'longitude' => 106.8456, + ]); + + $attendance = Attendance::where('employee_id', $user->employee->id)->first(); + expect($attendance)->not->toBeNull(); + expect($attendance->check_in_at)->not->toBeNull(); + expect($attendance->check_out_at)->toBeNull(); + }); +}); + +// ─── Check Out ──────────────────────────────────────────── + +describe('Attendance Check Out', function () { + test('employee with permission can check out', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW, PermissionEnum::ATTENDANCES_CREATE); + + createTodayAttendanceForEmployee($user->employee); + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_out'), checkOutPayload()) + ->assertRedirect(route('admin.hr.attendances.index')); + + $attendance = Attendance::where('employee_id', $user->employee->id) + ->whereDate('attendance_date', today()) + ->first(); + + expect($attendance->check_out_at)->not->toBeNull(); + expect($attendance->work_duration_minutes)->not->toBeNull(); + }); + + test('guest cannot check out', function () { + $this->post(route('admin.hr.attendances.check_out'), checkOutPayload()) + ->assertRedirect(route('login')); + }); + + test('user without create permission is forbidden', function () { + $user = createAttendanceUserWithPermission(PermissionEnum::ATTENDANCES_VIEW); + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_out'), checkOutPayload()) + ->assertForbidden(); + }); + + test('cannot check out without checking in first', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW, PermissionEnum::ATTENDANCES_CREATE); + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_out'), checkOutPayload()) + ->assertSessionHasErrors('attendance'); + }); + + test('cannot check out twice on the same day', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW, PermissionEnum::ATTENDANCES_CREATE); + + $attendance = createTodayAttendanceForEmployee($user->employee); + $attendance->update([ + 'check_out_at' => now(), + 'work_duration_minutes' => 480, + ]); + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_out'), checkOutPayload()) + ->assertSessionHasErrors('attendance'); + }); + + test('photo is required for check out', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW, PermissionEnum::ATTENDANCES_CREATE); + + createTodayAttendanceForEmployee($user->employee); + + $payload = checkOutPayload(); + $payload['photo'] = ''; + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_out'), $payload) + ->assertSessionHasErrors('photo'); + }); + + test('check out calculates work duration', function () { + $user = createAttendanceEmployeeUser(PermissionEnum::ATTENDANCES_VIEW, PermissionEnum::ATTENDANCES_CREATE); + + Attendance::factory()->create([ + 'employee_id' => $user->employee->id, + 'attendance_date' => today()->format('Y-m-d'), + 'check_in_at' => now()->subHours(8), + 'check_out_at' => null, + 'check_out_latitude' => null, + 'check_out_longitude' => null, + 'work_duration_minutes' => null, + ]); + + $this->actingAs($user) + ->post(route('admin.hr.attendances.check_out'), checkOutPayload()); + + $attendance = Attendance::where('employee_id', $user->employee->id) + ->whereDate('attendance_date', today()) + ->first(); + + expect($attendance->work_duration_minutes)->toBeGreaterThan(0); + }); +}); + +// ─── Destroy ────────────────────────────────────────────── + +describe('Attendance Destroy', function () { + test('user with permission can delete attendance', function () { + $user = createAttendanceUserWithPermission( + PermissionEnum::ATTENDANCES_VIEW, + PermissionEnum::ATTENDANCES_DELETE, + ); + + $attendance = Attendance::factory()->create(); + + $this->actingAs($user) + ->delete(route('admin.hr.attendances.destroy', $attendance)) + ->assertRedirect(route('admin.hr.attendances.index')); + + $this->assertDatabaseMissing('attendances', ['id' => $attendance->id]); + }); + + test('guest cannot delete attendance', function () { + $attendance = Attendance::factory()->create(); + + $this->delete(route('admin.hr.attendances.destroy', $attendance)) + ->assertRedirect(route('login')); + }); + + test('user without delete permission is forbidden', function () { + $user = createAttendanceUserWithPermission(PermissionEnum::ATTENDANCES_VIEW); + + $attendance = Attendance::factory()->create(); + + $this->actingAs($user) + ->delete(route('admin.hr.attendances.destroy', $attendance)) + ->assertForbidden(); + + $this->assertDatabaseHas('attendances', ['id' => $attendance->id]); + }); +}); + +// ─── Attendance Model ───────────────────────────────────── + +describe('Attendance Model', function () { + test('attendance has correct casts', function () { + $attendance = Attendance::factory()->create([ + 'check_in_latitude' => -6.2088000, + 'check_in_longitude' => 106.8456000, + ]); + + expect($attendance->attendance_date)->toBeInstanceOf(CarbonInterface::class); + expect($attendance->check_in_at)->toBeInstanceOf(CarbonInterface::class); + expect($attendance->check_out_at)->toBeInstanceOf(CarbonInterface::class); + expect($attendance->work_duration_minutes)->toBeInt(); + }); + + test('attendance belongs to employee', function () { + $attendance = Attendance::factory()->create(); + + expect($attendance->employee)->not->toBeNull(); + expect($attendance->employee)->toBeInstanceOf(Employee::class); + }); + + test('attendance has check_in_at_formatted accessor', function () { + $attendance = Attendance::factory()->create([ + 'check_in_at' => '2025-06-15 08:00:00', + ]); + + expect($attendance->check_in_at_formatted)->toBeString(); + }); + + test('attendance has check_out_at_formatted accessor', function () { + $attendance = Attendance::factory()->create([ + 'check_out_at' => '2025-06-15 17:00:00', + ]); + + expect($attendance->check_out_at_formatted)->toBeString(); + }); + + test('attendance has attendance_date_formatted accessor', function () { + $attendance = Attendance::factory()->create([ + 'attendance_date' => '2025-06-15', + ]); + + expect($attendance->attendance_date_formatted)->toBeString(); + }); + + test('attendance has employee_name accessor', function () { + $employee = Employee::factory()->create(); + UserProfile::factory()->create([ + 'user_id' => $employee->user_id, + 'full_name' => 'John Doe', + ]); + + $attendance = Attendance::factory()->create([ + 'employee_id' => $employee->id, + ]); + + expect($attendance->employee_name)->toBe('John Doe'); + }); + + test('attendance has work_duration_formatted accessor with hours', function () { + $attendance = Attendance::factory()->create([ + 'work_duration_minutes' => 480, + ]); + + expect($attendance->work_duration_formatted)->toBe('8 jam 0 menit'); + }); + + test('attendance has work_duration_formatted accessor with minutes only', function () { + $attendance = Attendance::factory()->create([ + 'work_duration_minutes' => 45, + ]); + + expect($attendance->work_duration_formatted)->toBe('45 menit'); + }); + + test('attendance has null work_duration_formatted when no checkout', function () { + $attendance = Attendance::factory()->create([ + 'work_duration_minutes' => null, + ]); + + expect($attendance->work_duration_formatted)->toBeNull(); + }); + + test('checkedInOnly factory state works', function () { + $attendance = Attendance::factory()->checkedInOnly()->create(); + + expect($attendance->check_out_at)->toBeNull(); + expect($attendance->check_out_latitude)->toBeNull(); + expect($attendance->check_out_longitude)->toBeNull(); + expect($attendance->work_duration_minutes)->toBeNull(); + }); + + test('attendance has payroll_adjustments relationship', function () { + $attendance = Attendance::factory()->create(); + + expect($attendance->payrollAdjustments())->toBeInstanceOf(HasMany::class); + }); +}); diff --git a/tests/Feature/Admin/Hr/LeaveRequestTest.php b/tests/Feature/Admin/Hr/LeaveRequestTest.php new file mode 100644 index 0000000..198f08b --- /dev/null +++ b/tests/Feature/Admin/Hr/LeaveRequestTest.php @@ -0,0 +1,618 @@ +seed(RolePermissionSeeder::class); + Queue::fake(); +}); + +// ─── Helper ─────────────────────────────────────────────── + +function createLeaveRequestUserWithPermission(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 createLeaveEmployeeUser(): User +{ + $user = User::factory()->create(); + UserProfile::factory()->create([ + 'user_id' => $user->id, + 'full_name' => fake()->name(), + ]); + Employee::factory()->create(['user_id' => $user->id]); + $user->assignRole('marketing'); + $user->forgetCachedPermissions(); + + return $user; +} + +function createEmployeeUserWithLeavePermission(PermissionEnum ...$permissions): User +{ + $user = createLeaveEmployeeUser(); + + $user->givePermissionTo( + array_merge( + [PermissionEnum::DASHBOARD_VIEW->value], + array_map(fn (PermissionEnum $p) => $p->value, $permissions) + ) + ); + + $user->forgetCachedPermissions(); + + return $user; +} + +function leaveRequestPayload(): array +{ + return [ + 'start_date' => now()->addDays(2)->format('Y-m-d'), + 'end_date' => now()->addDays(4)->format('Y-m-d'), + ]; +} + +// ─── Index ──────────────────────────────────────────────── + +describe('Leave Request Index', function () { + test('authenticated user with permission can view leave request index', function () { + $user = createLeaveRequestUserWithPermission(PermissionEnum::LEAVE_REQUESTS_VIEW); + + $this->actingAs($user) + ->get(route('admin.hr.leave_requests.index')) + ->assertOk(); + }); + + test('guest is redirected to login', function () { + $this->get(route('admin.hr.leave_requests.index')) + ->assertRedirect(route('login')); + }); + + test('user without permission is forbidden', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('admin.hr.leave_requests.index')) + ->assertForbidden(); + }); + + test('index displays leave requests for manager role', function () { + $user = createLeaveRequestUserWithPermission(PermissionEnum::LEAVE_REQUESTS_VIEW); + + LeaveRequest::factory()->count(3)->create(); + + $this->actingAs($user) + ->get(route('admin.hr.leave_requests.index')) + ->assertOk(); + }); + + test('employee only sees own leave requests', function () { + $user = createEmployeeUserWithLeavePermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_CREATE, + ); + + $ownRequest = LeaveRequest::factory()->create([ + 'employee_id' => $user->employee->id, + ]); + + $otherRequest = LeaveRequest::factory()->create(); + + $this->actingAs($user) + ->get(route('admin.hr.leave_requests.index')) + ->assertOk(); + }); +}); + +// ─── Store ──────────────────────────────────────────────── + +describe('Leave Request Store', function () { + test('employee with permission can submit leave request', function () { + $user = createEmployeeUserWithLeavePermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_CREATE, + ); + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.store'), leaveRequestPayload()) + ->assertRedirect(route('admin.hr.leave_requests.index')); + + $this->assertDatabaseHas('leave_requests', [ + 'employee_id' => $user->employee->id, + 'status' => LeaveRequestStatus::PENDING->value, + ]); + }); + + test('guest cannot submit leave request', function () { + $this->post(route('admin.hr.leave_requests.store'), leaveRequestPayload()) + ->assertRedirect(route('login')); + }); + + test('user without create permission is forbidden', function () { + $user = createLeaveRequestUserWithPermission(PermissionEnum::LEAVE_REQUESTS_VIEW); + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.store'), leaveRequestPayload()) + ->assertForbidden(); + }); + + test('user without employee record cannot submit leave request', function () { + $user = createLeaveRequestUserWithPermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_CREATE, + ); + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.store'), leaveRequestPayload()) + ->assertForbidden(); + }); + + test('start_date is required', function () { + $user = createEmployeeUserWithLeavePermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_CREATE, + ); + + $payload = leaveRequestPayload(); + unset($payload['start_date']); + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.store'), $payload) + ->assertSessionHasErrors('start_date'); + }); + + test('end_date is required', function () { + $user = createEmployeeUserWithLeavePermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_CREATE, + ); + + $payload = leaveRequestPayload(); + unset($payload['end_date']); + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.store'), $payload) + ->assertSessionHasErrors('end_date'); + }); + + test('end_date must be after or equal to start_date', function () { + $user = createEmployeeUserWithLeavePermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_CREATE, + ); + + $payload = [ + 'start_date' => now()->addDays(5)->format('Y-m-d'), + 'end_date' => now()->addDays(2)->format('Y-m-d'), + ]; + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.store'), $payload) + ->assertSessionHasErrors('end_date'); + }); + + test('cannot submit when there is a pending leave request', function () { + $user = createEmployeeUserWithLeavePermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_CREATE, + ); + + LeaveRequest::factory()->create([ + 'employee_id' => $user->employee->id, + 'status' => LeaveRequestStatus::PENDING, + ]); + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.store'), leaveRequestPayload()) + ->assertSessionHasErrors('start_date'); + }); + + test('submitting leave request calculates total_days correctly', function () { + $user = createEmployeeUserWithLeavePermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_CREATE, + ); + + $payload = [ + 'start_date' => now()->addDays(2)->format('Y-m-d'), + 'end_date' => now()->addDays(5)->format('Y-m-d'), + ]; + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.store'), $payload); + + $leaveRequest = LeaveRequest::where('employee_id', $user->employee->id)->first(); + expect($leaveRequest->total_days)->toBe(4); + }); + + test('submitting leave request creates with pending status', function () { + $user = createEmployeeUserWithLeavePermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_CREATE, + ); + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.store'), leaveRequestPayload()); + + $leaveRequest = LeaveRequest::where('employee_id', $user->employee->id)->first(); + expect($leaveRequest->status)->toBe(LeaveRequestStatus::PENDING); + expect($leaveRequest->verified_at)->toBeNull(); + expect($leaveRequest->verified_by_id)->toBeNull(); + }); +}); + +// ─── Update ─────────────────────────────────────────────── + +describe('Leave Request Update', function () { + test('employee with permission can update own pending leave request', function () { + $user = createEmployeeUserWithLeavePermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_UPDATE, + ); + + $leaveRequest = LeaveRequest::factory()->create([ + 'employee_id' => $user->employee->id, + 'status' => LeaveRequestStatus::PENDING, + ]); + + $payload = [ + 'start_date' => now()->addDays(10)->format('Y-m-d'), + 'end_date' => now()->addDays(12)->format('Y-m-d'), + ]; + + $this->actingAs($user) + ->put(route('admin.hr.leave_requests.update', $leaveRequest), $payload) + ->assertRedirect(route('admin.hr.leave_requests.index')); + + expect($leaveRequest->fresh()->total_days)->toBe(3); + }); + + test('guest cannot update leave request', function () { + $leaveRequest = LeaveRequest::factory()->create(); + + $this->put(route('admin.hr.leave_requests.update', $leaveRequest), leaveRequestPayload()) + ->assertRedirect(route('login')); + }); + + test('user without update permission is forbidden', function () { + $user = createLeaveRequestUserWithPermission(PermissionEnum::LEAVE_REQUESTS_VIEW); + + $leaveRequest = LeaveRequest::factory()->create([ + 'employee_id' => Employee::factory()->create()->id, + 'status' => LeaveRequestStatus::PENDING, + ]); + + $this->actingAs($user) + ->put(route('admin.hr.leave_requests.update', $leaveRequest), leaveRequestPayload()) + ->assertForbidden(); + }); + + test('employee cannot update other employee leave request', function () { + $user = createEmployeeUserWithLeavePermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_UPDATE, + ); + + $otherLeaveRequest = LeaveRequest::factory()->create([ + 'status' => LeaveRequestStatus::PENDING, + ]); + + $this->actingAs($user) + ->put(route('admin.hr.leave_requests.update', $otherLeaveRequest), leaveRequestPayload()) + ->assertForbidden(); + }); + + test('cannot update approved leave request', function () { + $user = createEmployeeUserWithLeavePermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_UPDATE, + ); + + $leaveRequest = LeaveRequest::factory()->create([ + 'employee_id' => $user->employee->id, + 'status' => LeaveRequestStatus::APPROVED, + ]); + + $this->actingAs($user) + ->put(route('admin.hr.leave_requests.update', $leaveRequest), leaveRequestPayload()) + ->assertForbidden(); + }); +}); + +// ─── Destroy ────────────────────────────────────────────── + +describe('Leave Request Destroy', function () { + test('employee with permission can delete own leave request', function () { + $user = createEmployeeUserWithLeavePermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_DELETE, + ); + + $leaveRequest = LeaveRequest::factory()->create([ + 'employee_id' => $user->employee->id, + ]); + + $this->actingAs($user) + ->delete(route('admin.hr.leave_requests.destroy', $leaveRequest)) + ->assertRedirect(route('admin.hr.leave_requests.index')); + + $this->assertDatabaseMissing('leave_requests', ['id' => $leaveRequest->id]); + }); + + test('guest cannot delete leave request', function () { + $leaveRequest = LeaveRequest::factory()->create(); + + $this->delete(route('admin.hr.leave_requests.destroy', $leaveRequest)) + ->assertRedirect(route('login')); + }); + + test('user without delete permission is forbidden', function () { + $user = createLeaveRequestUserWithPermission(PermissionEnum::LEAVE_REQUESTS_VIEW); + + $leaveRequest = LeaveRequest::factory()->create(); + + $this->actingAs($user) + ->delete(route('admin.hr.leave_requests.destroy', $leaveRequest)) + ->assertForbidden(); + }); +}); + +// ─── Approve ────────────────────────────────────────────── + +describe('Leave Request Approve', function () { + test('user with verify permission can approve leave request', function () { + $user = createLeaveRequestUserWithPermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_VERIFY, + ); + + $leaveRequest = LeaveRequest::factory()->create([ + 'status' => LeaveRequestStatus::PENDING, + ]); + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.approve', $leaveRequest)) + ->assertRedirect(route('admin.hr.leave_requests.index')); + + expect($leaveRequest->fresh()->status)->toBe(LeaveRequestStatus::APPROVED); + expect($leaveRequest->fresh()->verified_by_id)->toBe($user->id); + expect($leaveRequest->fresh()->verified_at)->not->toBeNull(); + }); + + test('guest cannot approve leave request', function () { + $leaveRequest = LeaveRequest::factory()->create(); + + $this->post(route('admin.hr.leave_requests.approve', $leaveRequest)) + ->assertRedirect(route('login')); + }); + + test('user without verify permission is forbidden', function () { + $user = createLeaveRequestUserWithPermission(PermissionEnum::LEAVE_REQUESTS_VIEW); + + $leaveRequest = LeaveRequest::factory()->create([ + 'status' => LeaveRequestStatus::PENDING, + ]); + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.approve', $leaveRequest)) + ->assertForbidden(); + }); +}); + +// ─── Reject ─────────────────────────────────────────────── + +describe('Leave Request Reject', function () { + test('user with verify permission can reject leave request', function () { + $user = createLeaveRequestUserWithPermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_VERIFY, + ); + + $leaveRequest = LeaveRequest::factory()->create([ + 'status' => LeaveRequestStatus::PENDING, + ]); + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.reject', $leaveRequest), [ + 'reason' => 'Tidak ada pengganti', + ]) + ->assertRedirect(route('admin.hr.leave_requests.index')); + + expect($leaveRequest->fresh()->status)->toBe(LeaveRequestStatus::REJECTED); + expect($leaveRequest->fresh()->verified_by_id)->toBe($user->id); + expect($leaveRequest->fresh()->verified_at)->not->toBeNull(); + }); + + test('rejecting creates rejection record', function () { + $user = createLeaveRequestUserWithPermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_VERIFY, + ); + + $leaveRequest = LeaveRequest::factory()->create([ + 'status' => LeaveRequestStatus::PENDING, + ]); + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.reject', $leaveRequest), [ + 'reason' => 'Stok karyawan kurang', + ]); + + $this->assertDatabaseHas('rejections', [ + 'rejectable_type' => LeaveRequest::class, + 'rejectable_id' => $leaveRequest->id, + 'reason' => 'Stok karyawan kurang', + 'rejected_by_id' => $user->id, + ]); + }); + + test('guest cannot reject leave request', function () { + $leaveRequest = LeaveRequest::factory()->create(); + + $this->post(route('admin.hr.leave_requests.reject', $leaveRequest), [ + 'reason' => 'Test', + ])->assertRedirect(route('login')); + }); + + test('user without verify permission is forbidden', function () { + $user = createLeaveRequestUserWithPermission(PermissionEnum::LEAVE_REQUESTS_VIEW); + + $leaveRequest = LeaveRequest::factory()->create([ + 'status' => LeaveRequestStatus::PENDING, + ]); + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.reject', $leaveRequest), [ + 'reason' => 'Test', + ]) + ->assertForbidden(); + }); + + test('reason is required when rejecting', function () { + $user = createLeaveRequestUserWithPermission( + PermissionEnum::LEAVE_REQUESTS_VIEW, + PermissionEnum::LEAVE_REQUESTS_VERIFY, + ); + + $leaveRequest = LeaveRequest::factory()->create([ + 'status' => LeaveRequestStatus::PENDING, + ]); + + $this->actingAs($user) + ->post(route('admin.hr.leave_requests.reject', $leaveRequest), [ + 'reason' => '', + ]) + ->assertSessionHasErrors('reason'); + }); +}); + +// ─── Leave Request Model ────────────────────────────────── + +describe('Leave Request Model', function () { + test('leave request has correct casts', function () { + $leaveRequest = LeaveRequest::factory()->create([ + 'total_days' => 5, + ]); + + expect($leaveRequest->total_days)->toBeInt(); + expect($leaveRequest->start_date)->toBeInstanceOf(CarbonInterface::class); + expect($leaveRequest->end_date)->toBeInstanceOf(CarbonInterface::class); + }); + + test('leave request belongs to employee', function () { + $leaveRequest = LeaveRequest::factory()->create(); + + expect($leaveRequest->employee)->not->toBeNull(); + expect($leaveRequest->employee)->toBeInstanceOf(Employee::class); + }); + + test('leave request has status_label accessor', function () { + $leaveRequest = LeaveRequest::factory()->create([ + 'status' => LeaveRequestStatus::PENDING, + ]); + + expect($leaveRequest->status_label)->toBe('Menunggu'); + + $leaveRequest->update(['status' => LeaveRequestStatus::APPROVED]); + expect($leaveRequest->fresh()->status_label)->toBe('Disetujui'); + + $leaveRequest->update(['status' => LeaveRequestStatus::REJECTED]); + expect($leaveRequest->fresh()->status_label)->toBe('Ditolak'); + }); + + test('leave request has start_date_formatted accessor', function () { + $leaveRequest = LeaveRequest::factory()->create([ + 'start_date' => '2025-06-15', + ]); + + expect($leaveRequest->start_date_formatted)->toBeString(); + }); + + test('leave request has end_date_formatted accessor', function () { + $leaveRequest = LeaveRequest::factory()->create([ + 'end_date' => '2025-06-20', + ]); + + expect($leaveRequest->end_date_formatted)->toBeString(); + }); + + test('leave request has start_date_input accessor', function () { + $leaveRequest = LeaveRequest::factory()->create([ + 'start_date' => '2025-06-15', + ]); + + expect($leaveRequest->start_date_input)->toBe('2025-06-15'); + }); + + test('leave request has end_date_input accessor', function () { + $leaveRequest = LeaveRequest::factory()->create([ + 'end_date' => '2025-06-20', + ]); + + expect($leaveRequest->end_date_input)->toBe('2025-06-20'); + }); + + test('leave request has is_editable accessor', function () { + $leaveRequest = LeaveRequest::factory()->create([ + 'status' => LeaveRequestStatus::PENDING, + ]); + + expect($leaveRequest->is_editable)->toBeTrue(); + + $leaveRequest->update(['status' => LeaveRequestStatus::APPROVED]); + expect($leaveRequest->fresh()->is_editable)->toBeFalse(); + }); + + test('leave request has can_verify accessor', function () { + $leaveRequest = LeaveRequest::factory()->create([ + 'status' => LeaveRequestStatus::PENDING, + ]); + + expect($leaveRequest->can_verify)->toBeTrue(); + + $leaveRequest->update(['status' => LeaveRequestStatus::APPROVED]); + expect($leaveRequest->fresh()->can_verify)->toBeFalse(); + }); + + test('leave request has employee_name accessor', function () { + $leaveRequest = LeaveRequest::factory()->create(); + + expect($leaveRequest->employee_name)->toBeString(); + }); + + test('pending scope filters correctly', function () { + LeaveRequest::factory()->create(['status' => LeaveRequestStatus::PENDING]); + LeaveRequest::factory()->create(['status' => LeaveRequestStatus::APPROVED]); + + expect(LeaveRequest::query()->pending()->count())->toBe(1); + }); + + test('approved scope filters correctly', function () { + LeaveRequest::factory()->create(['status' => LeaveRequestStatus::PENDING]); + LeaveRequest::factory()->create(['status' => LeaveRequestStatus::APPROVED]); + + expect(LeaveRequest::query()->approved()->count())->toBe(1); + }); +});