seed(RolePermissionSeeder::class); User::factory()->create(); // Required by CashAccountSeeder $this->seed(CashAccountSeeder::class); Storage::fake('public'); }); // ─── Helper ─────────────────────────────────────────────── function createPayrollUserWithPermission(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 createPayrollWithPeriod(?Employee $employee = null): Payroll { $period = PayrollPeriod::factory()->create([ 'status' => PayrollPeriodStatus::OPEN->value, ]); $employee ??= Employee::factory()->create(); return Payroll::factory()->create([ 'payroll_period_id' => $period->id, 'employee_id' => $employee->id, 'status' => PayrollStatus::UNPAID->value, ]); } function adjustmentPayload(?string $type = null, ?int $amount = null, ?string $description = null): array { return [ 'type' => $type ?? PayrollAdjustmentType::BONUS->value, 'amount' => $amount ?? 500000, 'description' => $description ?? 'Tunjangan transport', ]; } // ─── Index ──────────────────────────────────────────────── describe('Payroll Index', function () { test('authenticated user with permission can view payroll index', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW); $this->actingAs($user) ->get(route('admin.finance.payroll.index')) ->assertOk(); }); test('guest is redirected to login', function () { $this->get(route('admin.finance.payroll.index')) ->assertRedirect(route('login')); }); test('user without permission is forbidden', function () { $user = User::factory()->create(); $this->actingAs($user) ->get(route('admin.finance.payroll.index')) ->assertForbidden(); }); test('index displays payrolls for a period', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW); $period = PayrollPeriod::factory()->create(); Payroll::factory()->count(3)->create(['payroll_period_id' => $period->id]); $this->actingAs($user) ->get(route('admin.finance.payroll.index', ['period_id' => $period->id])) ->assertOk(); }); test('index can search by employee name', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW); $period = PayrollPeriod::factory()->create(); Payroll::factory()->create(['payroll_period_id' => $period->id]); $this->actingAs($user) ->get(route('admin.finance.payroll.index', [ 'period_id' => $period->id, 'search' => 'test', ])) ->assertOk(); }); }); // ─── Store Adjustment ───────────────────────────────────── describe('Payroll Store Adjustment', function () { test('authenticated user with permission can add adjustment', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST); $payroll = createPayrollWithPeriod(); $this->actingAs($user) ->post(route('admin.finance.payroll.adjustments.store', $payroll), adjustmentPayload()) ->assertRedirect(); $this->assertDatabaseHas('payroll_adjustments', [ 'payroll_id' => $payroll->id, 'type' => PayrollAdjustmentType::BONUS->value, 'amount' => 500000, 'description' => 'Tunjangan transport', ]); }); test('guest cannot add adjustment', function () { $payroll = createPayrollWithPeriod(); $this->post(route('admin.finance.payroll.adjustments.store', $payroll), adjustmentPayload()) ->assertRedirect(route('login')); }); test('user without adjust permission is forbidden', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW); $payroll = createPayrollWithPeriod(); $this->actingAs($user) ->post(route('admin.finance.payroll.adjustments.store', $payroll), adjustmentPayload()) ->assertForbidden(); }); test('type is required', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST); $payroll = createPayrollWithPeriod(); $this->actingAs($user) ->post(route('admin.finance.payroll.adjustments.store', $payroll), [ 'type' => '', 'amount' => 500000, 'description' => 'Test', ]) ->assertSessionHasErrors('type'); }); test('type must be valid enum value', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST); $payroll = createPayrollWithPeriod(); $this->actingAs($user) ->post(route('admin.finance.payroll.adjustments.store', $payroll), [ 'type' => 'invalid', 'amount' => 500000, 'description' => 'Test', ]) ->assertSessionHasErrors('type'); }); test('amount is required', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST); $payroll = createPayrollWithPeriod(); $this->actingAs($user) ->post(route('admin.finance.payroll.adjustments.store', $payroll), [ 'type' => PayrollAdjustmentType::BONUS->value, 'amount' => '', 'description' => 'Test', ]) ->assertSessionHasErrors('amount'); }); test('amount must be at least 1', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST); $payroll = createPayrollWithPeriod(); $this->actingAs($user) ->post(route('admin.finance.payroll.adjustments.store', $payroll), [ 'type' => PayrollAdjustmentType::BONUS->value, 'amount' => 0, 'description' => 'Test', ]) ->assertSessionHasErrors('amount'); }); test('description is required', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST); $payroll = createPayrollWithPeriod(); $this->actingAs($user) ->post(route('admin.finance.payroll.adjustments.store', $payroll), [ 'type' => PayrollAdjustmentType::BONUS->value, 'amount' => 500000, 'description' => '', ]) ->assertSessionHasErrors('description'); }); test('adding bonus adjustment recalculates payroll amounts', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST); $payroll = Payroll::factory()->create([ 'base_salary' => 3000000, 'bonus_amount' => 0, 'deduction_amount' => 0, 'total_amount' => 3000000, 'status' => PayrollStatus::UNPAID->value, ]); $this->actingAs($user) ->post(route('admin.finance.payroll.adjustments.store', $payroll), adjustmentPayload( type: PayrollAdjustmentType::BONUS->value, amount: 500000, )); expect($payroll->fresh()->bonus_amount)->toBe(500000); expect($payroll->fresh()->total_amount)->toBe(3500000); }); test('adding deduction adjustment recalculates payroll amounts', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST); $payroll = Payroll::factory()->create([ 'base_salary' => 3000000, 'bonus_amount' => 0, 'deduction_amount' => 0, 'total_amount' => 3000000, 'status' => PayrollStatus::UNPAID->value, ]); $this->actingAs($user) ->post(route('admin.finance.payroll.adjustments.store', $payroll), adjustmentPayload( type: PayrollAdjustmentType::DEDUCTION->value, amount: 200000, )); expect($payroll->fresh()->deduction_amount)->toBe(200000); expect($payroll->fresh()->total_amount)->toBe(2800000); }); }); // ─── Update Adjustment ──────────────────────────────────── describe('Payroll Update Adjustment', function () { test('authenticated user with permission can update adjustment', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST); $payroll = createPayrollWithPeriod(); $adjustment = PayrollAdjustment::factory()->bonus()->create([ 'payroll_id' => $payroll->id, 'amount' => 500000, ]); $this->actingAs($user) ->put(route('admin.finance.payroll.adjustments.update', $adjustment), adjustmentPayload( type: PayrollAdjustmentType::BONUS->value, amount: 700000, description: 'Deskripsi baru', )) ->assertRedirect(); expect($adjustment->fresh()->amount)->toBe(700000); expect($adjustment->fresh()->description)->toBe('Deskripsi baru'); }); test('guest cannot update adjustment', function () { $adjustment = PayrollAdjustment::factory()->create(); $this->put(route('admin.finance.payroll.adjustments.update', $adjustment), adjustmentPayload()) ->assertRedirect(route('login')); }); test('user without adjust permission is forbidden', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW); $adjustment = PayrollAdjustment::factory()->create(); $this->actingAs($user) ->put(route('admin.finance.payroll.adjustments.update', $adjustment), adjustmentPayload()) ->assertForbidden(); }); test('updating adjustment recalculates payroll amounts', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST); $payroll = Payroll::factory()->create([ 'base_salary' => 3000000, 'bonus_amount' => 500000, 'deduction_amount' => 0, 'total_amount' => 3500000, 'status' => PayrollStatus::UNPAID->value, ]); $adjustment = PayrollAdjustment::factory()->bonus()->create([ 'payroll_id' => $payroll->id, 'amount' => 500000, ]); $this->actingAs($user) ->put(route('admin.finance.payroll.adjustments.update', $adjustment), adjustmentPayload( type: PayrollAdjustmentType::BONUS->value, amount: 800000, )); expect($payroll->fresh()->bonus_amount)->toBe(800000); expect($payroll->fresh()->total_amount)->toBe(3800000); }); }); // ─── Destroy Adjustment ─────────────────────────────────── describe('Payroll Destroy Adjustment', function () { test('authenticated user with permission can delete adjustment', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST); $payroll = createPayrollWithPeriod(); $adjustment = PayrollAdjustment::factory()->create(['payroll_id' => $payroll->id]); $this->actingAs($user) ->delete(route('admin.finance.payroll.adjustments.destroy', $adjustment)) ->assertRedirect(); $this->assertDatabaseMissing('payroll_adjustments', ['id' => $adjustment->id]); }); test('guest cannot delete adjustment', function () { $adjustment = PayrollAdjustment::factory()->create(); $this->delete(route('admin.finance.payroll.adjustments.destroy', $adjustment)) ->assertRedirect(route('login')); }); test('user without adjust permission is forbidden', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW); $adjustment = PayrollAdjustment::factory()->create(); $this->actingAs($user) ->delete(route('admin.finance.payroll.adjustments.destroy', $adjustment)) ->assertForbidden(); }); test('deleting adjustment recalculates payroll amounts', function () { $user = createPayrollUserWithPermission(PermissionEnum::PAYROLL_VIEW, PermissionEnum::PAYROLL_ADJUST); $payroll = Payroll::factory()->create([ 'base_salary' => 3000000, 'bonus_amount' => 500000, 'deduction_amount' => 0, 'total_amount' => 3500000, 'status' => PayrollStatus::UNPAID->value, ]); $adjustment = PayrollAdjustment::factory()->bonus()->create([ 'payroll_id' => $payroll->id, 'amount' => 500000, ]); $this->actingAs($user) ->delete(route('admin.finance.payroll.adjustments.destroy', $adjustment)); expect($payroll->fresh()->bonus_amount)->toBe(0); expect($payroll->fresh()->total_amount)->toBe(3000000); }); }); // ─── Payroll Model ──────────────────────────────────────── describe('Payroll Model', function () { test('payroll has amounts cast to integer', function () { $payroll = Payroll::factory()->create([ 'base_salary' => 3000000, 'bonus_amount' => 500000, 'deduction_amount' => 200000, 'total_amount' => 3300000, ]); expect($payroll->base_salary)->toBeInt(); expect($payroll->bonus_amount)->toBeInt(); expect($payroll->deduction_amount)->toBeInt(); expect($payroll->total_amount)->toBeInt(); }); test('payroll has status cast to enum', function () { $payroll = Payroll::factory()->create([ 'status' => PayrollStatus::UNPAID->value, ]); expect($payroll->status)->toBe(PayrollStatus::UNPAID); }); test('payroll has formatted amount accessors', function () { $payroll = Payroll::factory()->create([ 'base_salary' => 3000000, 'bonus_amount' => 500000, 'deduction_amount' => 200000, 'total_amount' => 3300000, ]); expect($payroll->base_salary_formatted)->toBe('Rp 3.000.000'); expect($payroll->bonus_amount_formatted)->toBe('Rp 500.000'); expect($payroll->deduction_amount_formatted)->toBe('Rp 200.000'); expect($payroll->total_amount_formatted)->toBe('Rp 3.300.000'); }); test('payroll has status label accessor', function () { $payroll = Payroll::factory()->create([ 'status' => PayrollStatus::UNPAID->value, ]); expect($payroll->status_label)->toBe('Belum Dibayar'); }); test('payroll belongs to employee', function () { $payroll = Payroll::factory()->create(); expect($payroll->employee)->not->toBeNull(); }); test('payroll belongs to payroll period', function () { $payroll = Payroll::factory()->create(); expect($payroll->payrollPeriod)->not->toBeNull(); }); test('payroll can have adjustments', function () { $payroll = Payroll::factory()->create(); PayrollAdjustment::factory()->count(3)->create(['payroll_id' => $payroll->id]); expect($payroll->fresh()->adjustments)->toHaveCount(3); }); }); // ─── Payroll Period Model ───────────────────────────────── describe('Payroll Period Model', function () { test('payroll period has status cast to enum', function () { $period = PayrollPeriod::factory()->create([ 'status' => PayrollPeriodStatus::OPEN->value, ]); expect($period->status)->toBe(PayrollPeriodStatus::OPEN); }); test('payroll period has period label accessor', function () { $period = PayrollPeriod::factory()->create([ 'year' => 2026, 'month' => 6, ]); expect($period->period_label)->not->toBeEmpty(); }); test('payroll period has status label accessor', function () { $period = PayrollPeriod::factory()->create([ 'status' => PayrollPeriodStatus::OPEN->value, ]); expect($period->status_label)->toBe('Dibuka'); }); test('payroll period isOpen method', function () { $openPeriod = PayrollPeriod::factory()->create([ 'status' => PayrollPeriodStatus::OPEN->value, ]); $closedPeriod = PayrollPeriod::factory()->closed()->create(); expect($openPeriod->isOpen())->toBeTrue(); expect($closedPeriod->isOpen())->toBeFalse(); }); test('payroll period uses soft deletes', function () { $period = PayrollPeriod::factory()->create(); $period->delete(); expect($period->trashed())->toBeTrue(); }); }); // ─── Payroll Adjustment Model ───────────────────────────── describe('Payroll Adjustment Model', function () { test('payroll adjustment has type cast to enum', function () { $adjustment = PayrollAdjustment::factory()->create([ 'type' => PayrollAdjustmentType::BONUS->value, ]); expect($adjustment->type)->toBe(PayrollAdjustmentType::BONUS); }); test('payroll adjustment has amount cast to integer', function () { $adjustment = PayrollAdjustment::factory()->create(['amount' => 500000]); expect($adjustment->amount)->toBeInt(); expect($adjustment->amount)->toBe(500000); }); test('payroll adjustment has formatted accessors', function () { $adjustment = PayrollAdjustment::factory()->create(['amount' => 750000]); expect($adjustment->amount_formatted)->toBe('Rp 750.000'); }); test('payroll adjustment has type label accessor', function () { $adjustment = PayrollAdjustment::factory()->bonus()->create(); expect($adjustment->type_label)->toBe('Tunjangan'); }); test('payroll adjustment belongs to payroll', function () { $adjustment = PayrollAdjustment::factory()->create(); expect($adjustment->payroll)->not->toBeNull(); }); test('bonus scope returns only bonus adjustments', function () { PayrollAdjustment::factory()->count(2)->bonus()->create(); PayrollAdjustment::factory()->deduction()->create(); $bonuses = PayrollAdjustment::query()->bonus()->get(); expect($bonuses)->toHaveCount(2); }); test('deduction scope returns only deduction adjustments', function () { PayrollAdjustment::factory()->count(3)->deduction()->create(); PayrollAdjustment::factory()->bonus()->create(); $deductions = PayrollAdjustment::query()->deduction()->get(); expect($deductions)->toHaveCount(3); }); });