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

373 lines
14 KiB
PHP

<?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'))
->assertSessionHasNoErrors()
->assertRedirect(route('admin.finance.expenses.index'));
$this->assertDatabaseHas('expenses', [
'amount' => 250000,
'description' => 'Beli mesin jahit',
]);
$expense = Expense::where('description', 'Beli mesin jahit')->first();
expect($expense->getMedia('photos'))->not->toBeEmpty();
});
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);
});
});