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 ─────────────────────────────────────────────── describe('Cash Update', 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 ────────────────────────────────────────────── describe('Cash Destroy', 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); }); });