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); }); });