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