559 lines
20 KiB
PHP
559 lines
20 KiB
PHP
<?php
|
|
|
|
use App\Enums\EmployeeAdvanceStatus;
|
|
use App\Enums\Permission as PermissionEnum;
|
|
use App\Models\CashAccount;
|
|
use App\Models\Employee;
|
|
use App\Models\EmployeeAdvance;
|
|
use App\Models\User;
|
|
use App\Models\UserProfile;
|
|
use Database\Seeders\CashAccountSeeder;
|
|
use Database\Seeders\RolePermissionSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
$this->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);
|
|
});
|
|
});
|