dstpabuaran.com/tests/Feature/Admin/HR/CheckAttendancePenaltiesJobTest.php
Yoga Pangestu 67e5a6271f Add tests for CheckAttendancePenaltiesJob to validate attendance penalties logic
- Implement tests to ensure job skips execution on weekends, when penalties are zero, or when no payroll period exists.
- Validate late penalty creation for late check-ins and ensure no penalties for on-time or early check-ins.
- Test absent penalties for employees without attendance records.
- Ensure no duplicate penalties are created and that payroll recalculations are accurate after penalties are applied.
- Handle multiple employees and edge cases, including employees without associated users.
- Verify that the job can be dispatched to the queue and has the correct retry configuration.
2026-07-31 11:13:46 +07:00

635 lines
20 KiB
PHP

<?php
use App\Enums\PayrollAdjustmentType;
use App\Enums\PayrollStatus;
use App\Jobs\CheckAttendancePenaltiesJob;
use App\Models\Attendance;
use App\Models\Employee;
use App\Models\Payroll;
use App\Models\PayrollAdjustment;
use App\Models\PayrollPeriod;
use App\Models\User;
use App\Settings\HRSettings;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| HELPER
|--------------------------------------------------------------------------
*/
function createActiveEmployeeWithPayroll(int $year, int $month, PayrollStatus|string $status = PayrollStatus::UNPAID): array
{
$period = PayrollPeriod::firstOrCreate(
['year' => $year, 'month' => $month],
['status' => 'open']
);
$user = User::factory()->create(['is_active' => true]);
$employee = Employee::factory()->create([
'user_id' => $user->id,
'join_date' => Carbon::createFromDate($year, $month, 1)->subMonth()->toDateString(),
'resign_date' => null,
'employment_status' => 'full_time',
'base_salary' => 5000000,
]);
$payroll = Payroll::factory()->create([
'payroll_period_id' => $period->id,
'employee_id' => $employee->id,
'base_salary' => $employee->base_salary,
'bonus_amount' => 0,
'deduction_amount' => 0,
'total_amount' => $employee->base_salary,
'status' => $status,
]);
return compact('period', 'user', 'employee', 'payroll');
}
function setHrSettings(int $latePenalty, int $absentPenalty, string $checkInTime = '08:00'): void
{
$settings = app(HRSettings::class);
$settings->scheduled_check_in_time = $checkInTime;
$settings->scheduled_check_out_time = '17:00';
$settings->late_penalty_amount = $latePenalty;
$settings->absent_penalty_amount = $absentPenalty;
$settings->save();
}
/*
|--------------------------------------------------------------------------
| SKIP CONDITIONS
|--------------------------------------------------------------------------
*/
test('job skips when yesterday is sunday', function () {
$sunday = Carbon::parse('2024-07-07'); // Sunday
Carbon::setTestNow($sunday->copy()->addDay());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($sunday->year, $sunday->month);
Attendance::factory()->create([
'employee_id' => $data['employee']->id,
'attendance_date' => $sunday->toDateString(),
'check_in_at' => $sunday->copy()->setHour(9),
]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 0);
});
test('job skips when yesterday is saturday', function () {
$saturday = Carbon::parse('2024-07-06'); // Saturday
Carbon::setTestNow($saturday->copy()->addDay());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($saturday->year, $saturday->month);
Attendance::factory()->create([
'employee_id' => $data['employee']->id,
'attendance_date' => $saturday->toDateString(),
'check_in_at' => $saturday->copy()->setHour(9),
]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 0);
});
test('job skips when both penalties are zero', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(0, 0);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
Attendance::factory()->create([
'employee_id' => $data['employee']->id,
'attendance_date' => $yesterday->toDateString(),
'check_in_at' => $yesterday->copy()->setHour(9),
]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 0);
});
test('job skips when no payroll period exists', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000);
$user = User::factory()->create(['is_active' => true]);
$employee = Employee::factory()->create([
'user_id' => $user->id,
'join_date' => $yesterday->copy()->subMonth()->toDateString(),
'resign_date' => null,
'employment_status' => 'full_time',
'base_salary' => 5000000,
]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 0);
});
test('job skips when no system user exists', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
User::query()->delete();
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 0);
});
test('job skips inactive employees', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
$data['user']->update(['is_active' => false]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 0);
});
test('job skips resigned employees', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
$data['employee']->update(['resign_date' => $yesterday->copy()->subDays(5)->toDateString()]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 0);
});
test('job skips when payroll is already paid', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month, PayrollStatus::PAID);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 0);
});
test('job skips when payroll is cancelled', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month, PayrollStatus::CANCELLED);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 0);
});
/*
|--------------------------------------------------------------------------
| LATE PENALTY
|--------------------------------------------------------------------------
*/
test('job creates late penalty when employee checks in late', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000, '08:00');
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
Attendance::factory()->create([
'employee_id' => $data['employee']->id,
'attendance_date' => $yesterday->toDateString(),
'check_in_at' => $yesterday->copy()->setHour(8)->setMinute(30),
]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseHas('payroll_adjustments', [
'payroll_id' => $data['payroll']->id,
'type' => PayrollAdjustmentType::DEDUCTION,
'amount' => 50000,
]);
});
test('job does not create late penalty when employee checks in on time', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000, '08:00');
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
Attendance::factory()->create([
'employee_id' => $data['employee']->id,
'attendance_date' => $yesterday->toDateString(),
'check_in_at' => $yesterday->copy()->setTime(8, 0, 0),
]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 0);
});
test('job does not create late penalty when employee checks in early', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000, '08:00');
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
Attendance::factory()->create([
'employee_id' => $data['employee']->id,
'attendance_date' => $yesterday->toDateString(),
'check_in_at' => $yesterday->copy()->setTime(7, 30, 0),
]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 0);
});
test('job does not create late penalty when late_penalty_amount is zero', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(0, 100000, '08:00');
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
Attendance::factory()->create([
'employee_id' => $data['employee']->id,
'attendance_date' => $yesterday->toDateString(),
'check_in_at' => $yesterday->copy()->setHour(9),
]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 0);
});
test('late penalty description includes date and minutes', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000, '08:00');
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
Attendance::factory()->create([
'employee_id' => $data['employee']->id,
'attendance_date' => $yesterday->toDateString(),
'check_in_at' => $yesterday->copy()->setHour(8)->setMinute(30),
]);
(new CheckAttendancePenaltiesJob)->handle();
$adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first();
expect($adjustment->description)->toContain('Denda keterlambatan');
expect($adjustment->description)->toContain($yesterday->format('d/m/Y'));
expect($adjustment->description)->toContain('30 menit');
});
test('late penalty links to attendance record', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000, '08:00');
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
$attendance = Attendance::factory()->create([
'employee_id' => $data['employee']->id,
'attendance_date' => $yesterday->toDateString(),
'check_in_at' => $yesterday->copy()->setHour(9),
]);
(new CheckAttendancePenaltiesJob)->handle();
$adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first();
expect($adjustment->attendance_id)->toBe($attendance->id);
});
/*
|--------------------------------------------------------------------------
| ABSENT PENALTY
|--------------------------------------------------------------------------
*/
test('job creates absent penalty when employee has no attendance', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseHas('payroll_adjustments', [
'payroll_id' => $data['payroll']->id,
'type' => PayrollAdjustmentType::DEDUCTION,
'amount' => 100000,
'attendance_id' => null,
]);
});
test('job does not create absent penalty when absent_penalty_amount is zero', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 0);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 0);
});
test('absent penalty description includes date', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
(new CheckAttendancePenaltiesJob)->handle();
$adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first();
expect($adjustment->description)->toContain('Denda ketidakhadiran');
expect($adjustment->description)->toContain($yesterday->format('d/m/Y'));
});
test('absent penalty has null attendance_id', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
(new CheckAttendancePenaltiesJob)->handle();
$adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first();
expect($adjustment->attendance_id)->toBeNull();
});
/*
|--------------------------------------------------------------------------
| DEDUPLICATION
|--------------------------------------------------------------------------
*/
test('job does not create duplicate late penalty', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000, '08:00');
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
$attendance = Attendance::factory()->create([
'employee_id' => $data['employee']->id,
'attendance_date' => $yesterday->toDateString(),
'check_in_at' => $yesterday->copy()->setHour(9),
]);
PayrollAdjustment::create([
'payroll_id' => $data['payroll']->id,
'created_by_id' => User::first()->id,
'attendance_id' => $attendance->id,
'type' => PayrollAdjustmentType::DEDUCTION,
'amount' => 50000,
'description' => 'Denda keterlambatan manual',
]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 1);
});
test('job does not create duplicate absent penalty', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
PayrollAdjustment::create([
'payroll_id' => $data['payroll']->id,
'created_by_id' => User::first()->id,
'attendance_id' => null,
'type' => PayrollAdjustmentType::DEDUCTION,
'amount' => 100000,
'description' => 'Denda ketidakhadiran '.$yesterday->format('d/m/Y'),
]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseCount('payroll_adjustments', 1);
});
/*
|--------------------------------------------------------------------------
| PAYROLL RECALCULATION
|--------------------------------------------------------------------------
*/
test('job recalculates payroll total_amount after late penalty', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000, '08:00');
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
Attendance::factory()->create([
'employee_id' => $data['employee']->id,
'attendance_date' => $yesterday->toDateString(),
'check_in_at' => $yesterday->copy()->setHour(9),
]);
(new CheckAttendancePenaltiesJob)->handle();
$data['payroll']->refresh();
expect($data['payroll']->deduction_amount)->toBe(50000);
expect($data['payroll']->total_amount)->toBe($data['payroll']->base_salary - 50000);
});
test('job recalculates payroll total_amount after absent penalty', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
(new CheckAttendancePenaltiesJob)->handle();
$data['payroll']->refresh();
expect($data['payroll']->deduction_amount)->toBe(100000);
expect($data['payroll']->total_amount)->toBe($data['payroll']->base_salary - 100000);
});
test('job recalculates payroll with existing bonus adjustments', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000, '08:00');
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
PayrollAdjustment::create([
'payroll_id' => $data['payroll']->id,
'created_by_id' => User::first()->id,
'attendance_id' => null,
'type' => PayrollAdjustmentType::BONUS,
'amount' => 200000,
'description' => 'Bonus test',
]);
Attendance::factory()->create([
'employee_id' => $data['employee']->id,
'attendance_date' => $yesterday->toDateString(),
'check_in_at' => $yesterday->copy()->setHour(9),
]);
(new CheckAttendancePenaltiesJob)->handle();
$data['payroll']->refresh();
expect($data['payroll']->bonus_amount)->toBe(200000);
expect($data['payroll']->deduction_amount)->toBe(50000);
expect($data['payroll']->total_amount)->toBe($data['payroll']->base_salary + 200000 - 50000);
});
/*
|--------------------------------------------------------------------------
| MULTIPLE EMPLOYEES
|--------------------------------------------------------------------------
*/
test('job processes multiple employees', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000, '08:00');
$data1 = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
$data2 = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
Attendance::factory()->create([
'employee_id' => $data1['employee']->id,
'attendance_date' => $yesterday->toDateString(),
'check_in_at' => $yesterday->copy()->setHour(9),
]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseHas('payroll_adjustments', [
'payroll_id' => $data1['payroll']->id,
'type' => PayrollAdjustmentType::DEDUCTION,
'amount' => 50000,
]);
$this->assertDatabaseHas('payroll_adjustments', [
'payroll_id' => $data2['payroll']->id,
'type' => PayrollAdjustmentType::DEDUCTION,
'amount' => 100000,
]);
});
test('job handles mix of late and absent employees', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000, '08:00');
$lateEmp = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
$absentEmp = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
Attendance::factory()->create([
'employee_id' => $lateEmp['employee']->id,
'attendance_date' => $yesterday->toDateString(),
'check_in_at' => $yesterday->copy()->setHour(9),
]);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseHas('payroll_adjustments', [
'payroll_id' => $lateEmp['payroll']->id,
'amount' => 50000,
]);
$this->assertDatabaseHas('payroll_adjustments', [
'payroll_id' => $absentEmp['payroll']->id,
'amount' => 100000,
]);
});
/*
|--------------------------------------------------------------------------
| EDGE CASES
|--------------------------------------------------------------------------
*/
test('job handles employee with no user gracefully', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
(new CheckAttendancePenaltiesJob)->handle();
$this->assertDatabaseHas('payroll_adjustments', [
'payroll_id' => $data['payroll']->id,
]);
});
test('job uses system user as created_by for adjustments', function () {
$yesterday = now()->subDay();
Carbon::setTestNow(now());
setHrSettings(50000, 100000);
$data = createActiveEmployeeWithPayroll($yesterday->year, $yesterday->month);
$systemUser = User::first();
Attendance::factory()->create([
'employee_id' => $data['employee']->id,
'attendance_date' => $yesterday->toDateString(),
'check_in_at' => $yesterday->copy()->setHour(9),
]);
(new CheckAttendancePenaltiesJob)->handle();
$adjustment = PayrollAdjustment::where('payroll_id', $data['payroll']->id)->first();
expect($adjustment->created_by_id)->toBe($systemUser->id);
});
test('job can be dispatched to queue', function () {
Queue::fake();
CheckAttendancePenaltiesJob::dispatch();
Queue::assertPushed(CheckAttendancePenaltiesJob::class);
});
test('job has correct tries property', function () {
$job = new CheckAttendancePenaltiesJob;
expect($job->tries)->toBe(3);
});