store/tests/Feature/Admin/Finance/CashTest.php

480 lines
18 KiB
PHP

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