refactor: replace Builder import with Eloquent\Builder for improved clarity in PayrollAdjustment model
This commit is contained in:
parent
8f67a09936
commit
dc72033bd9
@ -11,7 +11,7 @@
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['amount_formatted', 'type_label', 'created_at_formatted', 'created_by_name'])]
|
||||
|
||||
479
tests/Feature/Admin/Finance/CashTest.php
Normal file
479
tests/Feature/Admin/Finance/CashTest.php
Normal file
@ -0,0 +1,479 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\CashTransactionType;
|
||||
use App\Enums\Permission as PermissionEnum;
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\CashAccountSeeder;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
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 createCashUserWithPermission(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 createCashAccountWithBalance(int $balance = 1000000): CashAccount
|
||||
{
|
||||
return CashAccount::factory()->create(['balance' => $balance]);
|
||||
}
|
||||
|
||||
function depositPayload(?int $amount = null, ?string $description = null): array
|
||||
{
|
||||
return [
|
||||
'amount' => $amount ?? 100000,
|
||||
'description' => $description ?? 'Setoran kas',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg', 100, 100)],
|
||||
];
|
||||
}
|
||||
|
||||
function withdrawPayload(?int $amount = null, ?string $description = null): array
|
||||
{
|
||||
return [
|
||||
'amount' => $amount ?? 50000,
|
||||
'description' => $description ?? 'Penarikan kas',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg', 100, 100)],
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Index ────────────────────────────────────────────────
|
||||
|
||||
describe('Cash Index', function () {
|
||||
test('authenticated user with permission can view cash index', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.finance.cash.index'))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('guest is redirected to login', function () {
|
||||
$this->get(route('admin.finance.cash.index'))
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('user without permission is forbidden', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.finance.cash.index'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('index displays cash transactions', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW);
|
||||
|
||||
$account = CashAccount::query()->first();
|
||||
CashTransaction::factory()->count(3)->create([
|
||||
'cash_account_id' => $account->id,
|
||||
'created_by_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.finance.cash.index'))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('index can search transactions by description', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW);
|
||||
|
||||
$account = CashAccount::query()->first();
|
||||
CashTransaction::factory()->create([
|
||||
'cash_account_id' => $account->id,
|
||||
'created_by_id' => $user->id,
|
||||
'description' => 'Setoran modal awal',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.finance.cash.index', ['search' => 'modal']))
|
||||
->assertOk();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Deposit ──────────────────────────────────────────────
|
||||
|
||||
describe('Cash Deposit', function () {
|
||||
test('authenticated user with permission can deposit', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.deposit'), depositPayload(200000))
|
||||
->assertRedirect(route('admin.finance.cash.index'));
|
||||
|
||||
$account = CashAccount::query()->first();
|
||||
expect($account->fresh()->balance)->toBe(200000);
|
||||
});
|
||||
|
||||
test('guest cannot deposit', function () {
|
||||
$this->post(route('admin.finance.cash.deposit'), depositPayload())
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('user without deposit permission is forbidden', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.deposit'), depositPayload())
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('amount is required', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.deposit'), [
|
||||
'amount' => '',
|
||||
'description' => 'Test',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertSessionHasErrors('amount');
|
||||
});
|
||||
|
||||
test('amount must be at least 1', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.deposit'), [
|
||||
'amount' => 0,
|
||||
'description' => 'Test',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertSessionHasErrors('amount');
|
||||
});
|
||||
|
||||
test('description is required', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.deposit'), [
|
||||
'amount' => 100000,
|
||||
'description' => '',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertSessionHasErrors('description');
|
||||
});
|
||||
|
||||
test('description must not exceed 100 characters', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.deposit'), [
|
||||
'amount' => 100000,
|
||||
'description' => str_repeat('a', 101),
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertSessionHasErrors('description');
|
||||
});
|
||||
|
||||
test('deposit creates a cash transaction', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.deposit'), depositPayload(500000, 'Setoran modal'));
|
||||
|
||||
$this->assertDatabaseHas('cash_transactions', [
|
||||
'description' => 'Setoran modal',
|
||||
'amount' => 500000,
|
||||
]);
|
||||
});
|
||||
|
||||
test('deposit updates account balance', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DEPOSIT);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.deposit'), depositPayload(300000));
|
||||
|
||||
$account = CashAccount::query()->first();
|
||||
expect($account->fresh()->balance)->toBe(300000);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.deposit'), depositPayload(200000));
|
||||
|
||||
expect($account->fresh()->balance)->toBe(500000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Withdraw ─────────────────────────────────────────────
|
||||
|
||||
describe('Cash Withdraw', function () {
|
||||
test('authenticated user with permission can withdraw', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_WITHDRAW);
|
||||
|
||||
CashAccount::query()->first()->update(['balance' => 500000]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.withdraw'), withdrawPayload(100000))
|
||||
->assertRedirect(route('admin.finance.cash.index'));
|
||||
|
||||
$account = CashAccount::query()->first();
|
||||
expect($account->fresh()->balance)->toBe(400000);
|
||||
});
|
||||
|
||||
test('guest cannot withdraw', function () {
|
||||
$this->post(route('admin.finance.cash.withdraw'), withdrawPayload())
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('user without withdraw permission is forbidden', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.withdraw'), withdrawPayload())
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('amount is required', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_WITHDRAW);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.withdraw'), [
|
||||
'amount' => '',
|
||||
'description' => 'Test',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertSessionHasErrors('amount');
|
||||
});
|
||||
|
||||
test('amount must be at least 1', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_WITHDRAW);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.withdraw'), [
|
||||
'amount' => 0,
|
||||
'description' => 'Test',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertSessionHasErrors('amount');
|
||||
});
|
||||
|
||||
test('description is required', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_WITHDRAW);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.withdraw'), [
|
||||
'amount' => 100000,
|
||||
'description' => '',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertSessionHasErrors('description');
|
||||
});
|
||||
|
||||
test('withdraw fails if insufficient balance', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_WITHDRAW);
|
||||
|
||||
CashAccount::query()->first()->update(['balance' => 50000]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.withdraw'), withdrawPayload(100000))
|
||||
->assertSessionHasErrors('amount');
|
||||
});
|
||||
|
||||
test('withdraw creates a cash transaction', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_WITHDRAW);
|
||||
|
||||
CashAccount::query()->first()->update(['balance' => 500000]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.cash.withdraw'), withdrawPayload(200000, 'Pembelian bahan'));
|
||||
|
||||
$this->assertDatabaseHas('cash_transactions', [
|
||||
'description' => 'Pembelian bahan',
|
||||
'amount' => 200000,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Update Transaction ───────────────────────────────────
|
||||
|
||||
describe('Cash Update Transaction', function () {
|
||||
test('authenticated user with permission can update a manual transaction', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_UPDATE);
|
||||
|
||||
$account = CashAccount::query()->first();
|
||||
$transaction = CashTransaction::factory()->deposit()->create([
|
||||
'cash_account_id' => $account->id,
|
||||
'created_by_id' => $user->id,
|
||||
'amount' => 100000,
|
||||
'balance_after' => 100000,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.finance.cash.transactions.update', $transaction), [
|
||||
'amount' => 150000,
|
||||
'description' => 'Deskripsi diperbarui',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertRedirect(route('admin.finance.cash.index'));
|
||||
|
||||
expect($transaction->fresh()->amount)->toBe(150000);
|
||||
expect($transaction->fresh()->description)->toBe('Deskripsi diperbarui');
|
||||
});
|
||||
|
||||
test('guest cannot update a transaction', function () {
|
||||
$account = CashAccount::query()->first();
|
||||
$transaction = CashTransaction::factory()->deposit()->create([
|
||||
'cash_account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$this->put(route('admin.finance.cash.transactions.update', $transaction), [
|
||||
'amount' => 100000,
|
||||
'description' => 'Test',
|
||||
])->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('user without update permission is forbidden', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW);
|
||||
|
||||
$account = CashAccount::query()->first();
|
||||
$transaction = CashTransaction::factory()->deposit()->create([
|
||||
'cash_account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.finance.cash.transactions.update', $transaction), [
|
||||
'amount' => 100000,
|
||||
'description' => 'Test',
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('referenced transaction cannot be updated', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_UPDATE);
|
||||
|
||||
$account = CashAccount::query()->first();
|
||||
$transaction = CashTransaction::factory()->create([
|
||||
'cash_account_id' => $account->id,
|
||||
'reference_type' => 'App\\Models\\Expense',
|
||||
'reference_id' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.finance.cash.transactions.update', $transaction), [
|
||||
'amount' => 100000,
|
||||
'description' => 'Test',
|
||||
])
|
||||
->assertForbidden();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Destroy Transaction ──────────────────────────────────
|
||||
|
||||
describe('Cash Destroy Transaction', function () {
|
||||
test('authenticated user with permission can delete a manual transaction', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW, PermissionEnum::CASH_DELETE);
|
||||
|
||||
$account = CashAccount::query()->first();
|
||||
$transaction = CashTransaction::factory()->deposit()->create([
|
||||
'cash_account_id' => $account->id,
|
||||
'created_by_id' => $user->id,
|
||||
'amount' => 100000,
|
||||
'balance_after' => 100000,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('admin.finance.cash.transactions.destroy', $transaction))
|
||||
->assertRedirect(route('admin.finance.cash.index'));
|
||||
|
||||
$this->assertSoftDeleted('cash_transactions', ['id' => $transaction->id]);
|
||||
});
|
||||
|
||||
test('guest cannot delete a transaction', function () {
|
||||
$account = CashAccount::query()->first();
|
||||
$transaction = CashTransaction::factory()->deposit()->create([
|
||||
'cash_account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$this->delete(route('admin.finance.cash.transactions.destroy', $transaction))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertNotSoftDeleted('cash_transactions', ['id' => $transaction->id]);
|
||||
});
|
||||
|
||||
test('user without delete permission is forbidden', function () {
|
||||
$user = createCashUserWithPermission(PermissionEnum::CASH_VIEW);
|
||||
|
||||
$account = CashAccount::query()->first();
|
||||
$transaction = CashTransaction::factory()->deposit()->create([
|
||||
'cash_account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('admin.finance.cash.transactions.destroy', $transaction))
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertNotSoftDeleted('cash_transactions', ['id' => $transaction->id]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Cash Model ───────────────────────────────────────────
|
||||
|
||||
describe('Cash Model', function () {
|
||||
test('cash transaction uses soft deletes', function () {
|
||||
$account = CashAccount::query()->first();
|
||||
$transaction = CashTransaction::factory()->create([
|
||||
'cash_account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$transaction->delete();
|
||||
|
||||
expect($transaction->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('cash account has balance cast to integer', function () {
|
||||
$account = CashAccount::factory()->create(['balance' => 500000]);
|
||||
|
||||
expect($account->balance)->toBeInt();
|
||||
expect($account->balance)->toBe(500000);
|
||||
});
|
||||
|
||||
test('cash account has balance formatted accessor', function () {
|
||||
$account = CashAccount::factory()->create(['balance' => 1500000]);
|
||||
|
||||
expect($account->balance_formatted)->toBe('Rp 1.500.000');
|
||||
});
|
||||
|
||||
test('cash transaction has amount formatted accessor', function () {
|
||||
$account = CashAccount::query()->first();
|
||||
$transaction = CashTransaction::factory()->create([
|
||||
'cash_account_id' => $account->id,
|
||||
'amount' => 2500000,
|
||||
]);
|
||||
|
||||
expect($transaction->amount_formatted)->toBe('Rp 2.500.000');
|
||||
});
|
||||
|
||||
test('cash transaction belongs to cash account', function () {
|
||||
$account = CashAccount::query()->first();
|
||||
$transaction = CashTransaction::factory()->create([
|
||||
'cash_account_id' => $account->id,
|
||||
]);
|
||||
|
||||
expect($transaction->cashAccount)->not->toBeNull();
|
||||
expect($transaction->cashAccount->id)->toBe($account->id);
|
||||
});
|
||||
});
|
||||
558
tests/Feature/Admin/Finance/EmployeeAdvanceTest.php
Normal file
558
tests/Feature/Admin/Finance/EmployeeAdvanceTest.php
Normal file
@ -0,0 +1,558 @@
|
||||
<?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);
|
||||
});
|
||||
});
|
||||
368
tests/Feature/Admin/Finance/ExpenseTest.php
Normal file
368
tests/Feature/Admin/Finance/ExpenseTest.php
Normal file
@ -0,0 +1,368 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\Permission as PermissionEnum;
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Models\Expense;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\CashAccountSeeder;
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
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 createExpenseUserWithPermission(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 createExpenseWithCashTransaction(User $user): Expense
|
||||
{
|
||||
$account = CashAccount::query()->first();
|
||||
$account->update(['balance' => 5000000]);
|
||||
|
||||
$expense = Expense::factory()->create([
|
||||
'created_by_id' => $user->id,
|
||||
'amount' => 100000,
|
||||
]);
|
||||
|
||||
$cashTransaction = CashTransaction::factory()->create([
|
||||
'cash_account_id' => $account->id,
|
||||
'created_by_id' => $user->id,
|
||||
'reference_type' => Expense::class,
|
||||
'reference_id' => $expense->id,
|
||||
'amount' => $expense->amount,
|
||||
'balance_after' => $account->balance - $expense->amount,
|
||||
]);
|
||||
|
||||
$expense->update(['cash_transaction_id' => $cashTransaction->id]);
|
||||
|
||||
return $expense;
|
||||
}
|
||||
|
||||
function expensePayload(?int $amount = null, ?string $description = null): array
|
||||
{
|
||||
return [
|
||||
'amount' => $amount ?? 100000,
|
||||
'description' => $description ?? 'Pembelian ATK',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg', 100, 100)],
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Index ────────────────────────────────────────────────
|
||||
|
||||
describe('Expense Index', function () {
|
||||
test('authenticated user with permission can view expense index', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.finance.expenses.index'))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('guest is redirected to login', function () {
|
||||
$this->get(route('admin.finance.expenses.index'))
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('user without permission is forbidden', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.finance.expenses.index'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('index displays expenses', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW);
|
||||
|
||||
Expense::factory()->count(3)->create(['created_by_id' => $user->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.finance.expenses.index'))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('index can search expenses by description', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW);
|
||||
|
||||
Expense::factory()->create([
|
||||
'created_by_id' => $user->id,
|
||||
'description' => 'Pembelian kain',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('admin.finance.expenses.index', ['search' => 'kain']))
|
||||
->assertOk();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────
|
||||
|
||||
describe('Expense Store', function () {
|
||||
test('authenticated user with permission can create an expense', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_CREATE);
|
||||
|
||||
CashAccount::query()->first()->update(['balance' => 5000000]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.expenses.store'), expensePayload(250000, 'Beli mesin jahit'))
|
||||
->assertRedirect(route('admin.finance.expenses.index'));
|
||||
|
||||
$this->assertDatabaseHas('expenses', [
|
||||
'amount' => 250000,
|
||||
'description' => 'Beli mesin jahit',
|
||||
]);
|
||||
});
|
||||
|
||||
test('guest cannot create an expense', function () {
|
||||
$this->post(route('admin.finance.expenses.store'), expensePayload())
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('user without create permission is forbidden', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.expenses.store'), expensePayload())
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('amount is required', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_CREATE);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.expenses.store'), [
|
||||
'amount' => '',
|
||||
'description' => 'Test',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertSessionHasErrors('amount');
|
||||
});
|
||||
|
||||
test('amount must be at least 1', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_CREATE);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.expenses.store'), [
|
||||
'amount' => 0,
|
||||
'description' => 'Test',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertSessionHasErrors('amount');
|
||||
});
|
||||
|
||||
test('description is required', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_CREATE);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.expenses.store'), [
|
||||
'amount' => 100000,
|
||||
'description' => '',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertSessionHasErrors('description');
|
||||
});
|
||||
|
||||
test('description must not exceed 100 characters', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_CREATE);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.expenses.store'), [
|
||||
'amount' => 100000,
|
||||
'description' => str_repeat('a', 101),
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertSessionHasErrors('description');
|
||||
});
|
||||
|
||||
test('creating expense also creates cash transaction', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_CREATE);
|
||||
|
||||
CashAccount::query()->first()->update(['balance' => 5000000]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('admin.finance.expenses.store'), expensePayload(300000, 'Operasional'));
|
||||
|
||||
$expense = Expense::where('description', 'Operasional')->first();
|
||||
expect($expense)->not->toBeNull();
|
||||
expect($expense->cash_transaction_id)->not->toBeNull();
|
||||
|
||||
$this->assertDatabaseHas('cash_transactions', [
|
||||
'id' => $expense->cash_transaction_id,
|
||||
'amount' => 300000,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Update ───────────────────────────────────────────────
|
||||
|
||||
describe('Expense Update', function () {
|
||||
test('authenticated user with permission can update an expense', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_UPDATE);
|
||||
|
||||
$expense = createExpenseWithCashTransaction($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.finance.expenses.update', $expense), expensePayload(200000, 'Deskripsi baru'))
|
||||
->assertRedirect(route('admin.finance.expenses.index'));
|
||||
|
||||
expect($expense->fresh()->amount)->toBe(200000);
|
||||
expect($expense->fresh()->description)->toBe('Deskripsi baru');
|
||||
});
|
||||
|
||||
test('guest cannot update an expense', function () {
|
||||
$user = User::factory()->create();
|
||||
$expense = createExpenseWithCashTransaction($user);
|
||||
|
||||
$this->put(route('admin.finance.expenses.update', $expense), expensePayload())
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('user without update permission is forbidden', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW);
|
||||
$expense = createExpenseWithCashTransaction($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.finance.expenses.update', $expense), expensePayload())
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('amount is required on update', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_UPDATE);
|
||||
$expense = createExpenseWithCashTransaction($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.finance.expenses.update', $expense), [
|
||||
'amount' => '',
|
||||
'description' => 'Test',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertSessionHasErrors('amount');
|
||||
});
|
||||
|
||||
test('description is required on update', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_UPDATE);
|
||||
$expense = createExpenseWithCashTransaction($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->put(route('admin.finance.expenses.update', $expense), [
|
||||
'amount' => 100000,
|
||||
'description' => '',
|
||||
'photos' => [UploadedFile::fake()->image('bukti.jpg')],
|
||||
])
|
||||
->assertSessionHasErrors('description');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Destroy ──────────────────────────────────────────────
|
||||
|
||||
describe('Expense Destroy', function () {
|
||||
test('authenticated user with permission can delete an expense', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_DELETE);
|
||||
|
||||
$expense = createExpenseWithCashTransaction($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('admin.finance.expenses.destroy', $expense))
|
||||
->assertRedirect(route('admin.finance.expenses.index'));
|
||||
|
||||
$this->assertSoftDeleted('expenses', ['id' => $expense->id]);
|
||||
});
|
||||
|
||||
test('guest cannot delete an expense', function () {
|
||||
$user = User::factory()->create();
|
||||
$expense = createExpenseWithCashTransaction($user);
|
||||
|
||||
$this->delete(route('admin.finance.expenses.destroy', $expense))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertNotSoftDeleted('expenses', ['id' => $expense->id]);
|
||||
});
|
||||
|
||||
test('user without delete permission is forbidden', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW);
|
||||
$expense = createExpenseWithCashTransaction($user);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('admin.finance.expenses.destroy', $expense))
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertNotSoftDeleted('expenses', ['id' => $expense->id]);
|
||||
});
|
||||
|
||||
test('deleting expense also deletes cash transaction', function () {
|
||||
$user = createExpenseUserWithPermission(PermissionEnum::EXPENSES_VIEW, PermissionEnum::EXPENSES_DELETE);
|
||||
|
||||
$expense = createExpenseWithCashTransaction($user);
|
||||
$cashTransactionId = $expense->cash_transaction_id;
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('admin.finance.expenses.destroy', $expense));
|
||||
|
||||
$this->assertSoftDeleted('expenses', ['id' => $expense->id]);
|
||||
$this->assertSoftDeleted('cash_transactions', ['id' => $cashTransactionId]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Expense Model ────────────────────────────────────────
|
||||
|
||||
describe('Expense Model', function () {
|
||||
test('expense uses soft deletes', function () {
|
||||
$expense = Expense::factory()->create();
|
||||
|
||||
$expense->delete();
|
||||
|
||||
expect($expense->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('expense has amount cast to integer', function () {
|
||||
$expense = Expense::factory()->create(['amount' => 500000]);
|
||||
|
||||
expect($expense->amount)->toBeInt();
|
||||
expect($expense->amount)->toBe(500000);
|
||||
});
|
||||
|
||||
test('expense has amount formatted accessor', function () {
|
||||
$expense = Expense::factory()->create(['amount' => 1500000]);
|
||||
|
||||
expect($expense->amount_formatted)->toBe('Rp 1.500.000');
|
||||
});
|
||||
|
||||
test('expense belongs to cash transaction', function () {
|
||||
$user = User::factory()->create();
|
||||
$expense = createExpenseWithCashTransaction($user);
|
||||
|
||||
expect($expense->cashTransaction)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('expense belongs to created by user', function () {
|
||||
$user = User::factory()->create();
|
||||
$expense = Expense::factory()->create(['created_by_id' => $user->id]);
|
||||
|
||||
expect($expense->createdBy)->not->toBeNull();
|
||||
expect($expense->createdBy->id)->toBe($user->id);
|
||||
});
|
||||
});
|
||||
563
tests/Feature/Admin/Finance/PayrollTest.php
Normal file
563
tests/Feature/Admin/Finance/PayrollTest.php
Normal file
@ -0,0 +1,563 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PayrollAdjustmentType;
|
||||
use App\Enums\PayrollPeriodStatus;
|
||||
use App\Enums\PayrollStatus;
|
||||
use App\Enums\Permission as PermissionEnum;
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Payroll;
|
||||
use App\Models\PayrollAdjustment;
|
||||
use App\Models\PayrollPeriod;
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user