From 99ca332f1c0e260051d5f06cb038ff49ec7334f7 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 11 Dec 2025 18:39:06 +0700 Subject: [PATCH] refactor: Enhance Livewire components and forms with type hints and authorization checks, and add new feature tests. --- .../Studio/Finance/ExpensesTable.php | 7 +- .../Forms/Studio/Finance/ExpenseForm.php | 6 +- app/Livewire/Studio/Finance/Expense.php | 44 ++-- .../Studio/Finance/ExpensesTableTest.php | 121 +++++++++ .../Livewire/Studio/Finance/ExpenseTest.php | 246 ++++++++++++++++++ 5 files changed, 397 insertions(+), 27 deletions(-) create mode 100644 tests/Feature/Livewire/Datatable/Studio/Finance/ExpensesTableTest.php create mode 100644 tests/Feature/Livewire/Studio/Finance/ExpenseTest.php diff --git a/app/Livewire/Datatable/Studio/Finance/ExpensesTable.php b/app/Livewire/Datatable/Studio/Finance/ExpensesTable.php index fe184f2..49cdace 100644 --- a/app/Livewire/Datatable/Studio/Finance/ExpensesTable.php +++ b/app/Livewire/Datatable/Studio/Finance/ExpensesTable.php @@ -51,21 +51,20 @@ public function columns(): array if (auth()->user()->can('delete expense')) { $actions .= view('components.actions.table.delete', [ 'id' => $row->hash, - 'deleteRoute' => route('studio.finance.expense.delete', $row->hash), ])->render(); } } return $actions; }) - ->html() - ->hideIf(auth()->user()->cannot('update expense') && auth()->user()->cannot('delete expense') && auth()->id !== $row->user_id), + ->html(), + ]; } public function builder(): Builder { - return Expense::select('expenses.id', 'expenses.user_id', 'description', 'amount', 'expenses.created_at') + return Expense::select('expenses.*') ->whereIn('outlet_id', auth()->user()->outlets->pluck('id')->toArray()) ->with(['outlet', 'user', 'user.employee']); } diff --git a/app/Livewire/Forms/Studio/Finance/ExpenseForm.php b/app/Livewire/Forms/Studio/Finance/ExpenseForm.php index a367d37..1a7ff91 100644 --- a/app/Livewire/Forms/Studio/Finance/ExpenseForm.php +++ b/app/Livewire/Forms/Studio/Finance/ExpenseForm.php @@ -43,7 +43,7 @@ public function validationAttributes(): array ]; } - public function setExpense(Expense $expense) + public function setExpense(Expense $expense): void { $this->expense = $expense; @@ -54,7 +54,7 @@ public function setExpense(Expense $expense) $this->image = $this->mapMediaCollection($expense->getMedia('image')); } - public function store() + public function store(): void { $this->validate(); @@ -71,7 +71,7 @@ public function store() }); } - public function update() + public function update(): void { $this->validate(); diff --git a/app/Livewire/Studio/Finance/Expense.php b/app/Livewire/Studio/Finance/Expense.php index 466ff02..c52198b 100644 --- a/app/Livewire/Studio/Finance/Expense.php +++ b/app/Livewire/Studio/Finance/Expense.php @@ -12,14 +12,16 @@ use App\Traits\WithToast; use App\Traits\WithUpdatedData; use Flux\Flux; +use Illuminate\View\View; use Livewire\Attributes\On; use Livewire\Attributes\Title; use Livewire\Component; +use Livewire\WithFileUploads; #[Title('Pengeluaran')] class Expense extends Component { - use WithAuthorization, WithCloseModal, WithConfirmation, WithSubscribeNotification, WithToast, WithUpdatedData; + use WithAuthorization, WithCloseModal, WithConfirmation, WithFileUploads, WithSubscribeNotification, WithToast, WithUpdatedData; public ExpenseForm $form; @@ -31,28 +33,14 @@ class Expense extends Component public string $modalTitle = ''; - public function mount() + public function mount(): void { $this->outlets = Outlet::pluck('name', 'id')->toArray(); $this->userHasMultipleOutlets = auth()->user()->outlets()->count() > 1; } - #[On('modal:open')] - public function openModal(string $method, string $modalTitle, ?string $id = null) - { - $this->resetValidation(); - $this->resetErrorBag(); - - $this->method = $method; - $this->modalTitle = $modalTitle; - - if ($id) { - $this->form->setExpense(ExpenseModel::byHashOrFail($id)); - } - } - - public function create() + public function create(): void { $this->canOrAbort('create expense'); @@ -65,7 +53,7 @@ public function create() Flux::modals()->close(); } - public function update() + public function update(): void { $this->canOrAbort('update expense'); @@ -78,8 +66,10 @@ public function update() Flux::modals()->close(); } - public function delete(ExpenseModel $expense) + public function delete(ExpenseModel $expense): void { + $this->canOrAbort('delete expense'); + $expense->delete(); $this->dispatch('refreshDatatable'); @@ -89,7 +79,21 @@ public function delete(ExpenseModel $expense) Flux::modals()->close(); } - public function render() + #[On('modal:open')] + public function openModal(string $method, string $modalTitle, ?string $id = null): void + { + $this->resetValidation(); + $this->resetErrorBag(); + + $this->method = $method; + $this->modalTitle = $modalTitle; + + if ($id) { + $this->form->setExpense(ExpenseModel::byHashOrFail($id)); + } + } + + public function render(): View { return view('livewire.studio.finance.expenses', [ 'pageTitle' => 'Pengeluaran', diff --git a/tests/Feature/Livewire/Datatable/Studio/Finance/ExpensesTableTest.php b/tests/Feature/Livewire/Datatable/Studio/Finance/ExpensesTableTest.php new file mode 100644 index 0000000..81c6a82 --- /dev/null +++ b/tests/Feature/Livewire/Datatable/Studio/Finance/ExpensesTableTest.php @@ -0,0 +1,121 @@ +user = User::factory()->has(Employee::factory(), 'employee')->create(); + $this->outlet = Outlet::factory()->create(); + $this->user->outlets()->attach($this->outlet); + + // Give permissions to view actions + // actions column checks: update expense, delete expense, or own data + // So for own data, no perm needed. +}); + +it('renders successfully', function () { + $this->actingAs($this->user); + + Livewire::test(ExpensesTable::class) + ->assertStatus(200); +}); + +it('shows expenses from user outlets', function () { + $this->actingAs($this->user); + + Expense::factory()->create([ + 'user_id' => $this->user->id, + 'outlet_id' => $this->outlet->id, + 'description' => 'My Expense', + 'amount' => 50000, + ]); + + Livewire::test(ExpensesTable::class) + ->assertSee('My Expense') + ->assertSee('50.000'); // Assuming currency formatting adds dot for thousands (ID) +}); + +it('does not show expenses from other outlets', function () { + $this->actingAs($this->user); + + $otherOutlet = Outlet::factory()->create(); + $otherUser = User::factory()->has(Employee::factory(), 'employee')->create(); + $otherUser->outlets()->attach($otherOutlet); + + Expense::factory()->create([ + 'user_id' => $otherUser->id, + 'outlet_id' => $otherOutlet->id, + 'description' => 'Other Expense', + 'amount' => 100000, + ]); + + Livewire::test(ExpensesTable::class) + ->assertDontSee('Other Expense'); +}); + +it('can search by description', function () { + $this->actingAs($this->user); + + Expense::factory()->create([ + 'user_id' => $this->user->id, + 'outlet_id' => $this->outlet->id, + 'description' => 'UniqueTerm', + 'amount' => 12345, + ]); + + Expense::factory()->create([ + 'user_id' => $this->user->id, + 'outlet_id' => $this->outlet->id, + 'description' => 'AnotherItem', + 'amount' => 67890, + ]); + + Livewire::test(ExpensesTable::class) + ->set('search', 'UniqueTerm') // Standard search property + ->assertSee('UniqueTerm') + ->assertDontSee('AnotherItem'); +}); + +it('can search by amount', function () { + $this->actingAs($this->user); + + Expense::factory()->create([ + 'user_id' => $this->user->id, + 'outlet_id' => $this->outlet->id, + 'description' => 'Exp1', + 'amount' => 8888, + ]); + + Expense::factory()->create([ + 'user_id' => $this->user->id, + 'outlet_id' => $this->outlet->id, + 'description' => 'Exp2', + 'amount' => 9999, + ]); + + Livewire::test(ExpensesTable::class) + ->set('search', '8888') + ->assertSee('Exp1') + ->assertDontSee('Exp2'); +}); + +it('renders columns correctly', function () { + $this->actingAs($this->user); + + Livewire::test(ExpensesTable::class) + ->assertSee('Pegawai') + ->assertSee('Keterangan') + ->assertSee('Jumlah') + ->assertSee('Outlet') + ->assertSee('Tanggal') + ->assertSee('Aksi'); +}); diff --git a/tests/Feature/Livewire/Studio/Finance/ExpenseTest.php b/tests/Feature/Livewire/Studio/Finance/ExpenseTest.php new file mode 100644 index 0000000..4bdd0d5 --- /dev/null +++ b/tests/Feature/Livewire/Studio/Finance/ExpenseTest.php @@ -0,0 +1,246 @@ + 'create expense']); + Permission::create(['name' => 'update expense']); + Permission::create(['name' => 'delete expense']); +}); + +function createUserWithOutlet(bool $multipleOutlets = false) +{ + $user = User::factory() + ->has(Employee::factory(), 'employee') + ->create(); + + $outlet1 = Outlet::factory()->create(); + $user->outlets()->attach($outlet1); + + if ($multipleOutlets) { + $outlet2 = Outlet::factory()->create(); + $user->outlets()->attach($outlet2); + } + + // Assign permissions + $user->givePermissionTo(['create expense', 'update expense', 'delete expense']); + + return [$user, $outlet1]; +} + +it('renders successfully', function () { + [$user, $outlet] = createUserWithOutlet(); + $this->actingAs($user); + + Livewire::test(Expense::class) + ->assertStatus(200); +}); + +it('validates required fields', function () { + [$user, $outlet] = createUserWithOutlet(); + $this->actingAs($user); + + Livewire::test(Expense::class) + ->set('form.description', '') + ->set('form.amount', '') + ->call('create') + ->assertHasErrors(['form.description', 'form.amount']); +}); + +it('validates amount must be numeric', function () { + [$user, $outlet] = createUserWithOutlet(); + $this->actingAs($user); + + Livewire::test(Expense::class) + ->set('form.amount', 'abc') + ->call('create') + ->assertHasErrors(['form.amount']); +}); + +it('validates outlet_id is required for multi-outlet users', function () { + [$user, $outlet] = createUserWithOutlet(true); // Multiple outlets + $this->actingAs($user); + + Livewire::test(Expense::class) + ->set('form.description', 'Test Expense') + ->set('form.amount', '50000') + // outlet_id not set + ->call('create') + ->assertHasErrors(['form.outlet_id']); +}); + +it('does not require manually selecting outlet_id for single-outlet users', function () { + [$user, $outlet] = createUserWithOutlet(false); + $this->actingAs($user); + + Livewire::test(Expense::class) + ->set('form.description', 'Test Expense') + ->set('form.amount', '50000') + ->call('create') + ->assertHasNoErrors(['form.outlet_id']); +}); + +it('can create an expense', function () { + [$user, $outlet] = createUserWithOutlet(); + $this->actingAs($user); + + Livewire::test(Expense::class) + ->set('form.description', 'Buy Coffee') + ->set('form.amount', '25000') + ->call('create') + ->assertDispatched('refreshDatatable'); + + $this->assertDatabaseHas('expenses', [ + 'description' => 'Buy Coffee', + 'amount' => 25000, + 'user_id' => $user->id, + 'outlet_id' => $outlet->id, + ]); +}); + +it('can create an expense with image', function () { + [$user, $outlet] = createUserWithOutlet(); + $this->actingAs($user); + + $image = UploadedFile::fake()->image('receipt.jpg'); + // The WithMediaHandler contract seems to expect an array with 'path', not raw file object + $imageData = [['path' => $image->getPathname()]]; + + Livewire::test(Expense::class) + ->set('form.description', 'Buy Coffee with Receipt') + ->set('form.amount', '30000') + ->set('form.image', $imageData) + ->call('create'); + + $expense = ExpenseModel::where('description', 'Buy Coffee with Receipt')->first(); + expect($expense)->not->toBeNull(); + // Assuming media library is set up, verify media count or existence + // Note: In-memory/testing sqlite might behave differently with media library if not fully mocked, + // but basic relation check is good. + expect($expense->getMedia('image'))->toHaveCount(1); +}); + +it('can update an expense', function () { + [$user, $outlet] = createUserWithOutlet(); + $this->actingAs($user); + + $expense = ExpenseModel::factory()->create([ + 'user_id' => $user->id, + 'outlet_id' => $outlet->id, + 'description' => 'Old Description', + 'amount' => 10000, + ]); + + Livewire::test(Expense::class) + ->call('openModal', 'update', 'Edit Expense', $expense->hash) + ->assertSet('form.description', 'Old Description') + ->set('form.description', 'New Description') + ->set('form.amount', '15000') // Updating amount + ->call('update') + ->assertDispatched('refreshDatatable'); + + $this->assertDatabaseHas('expenses', [ + 'id' => $expense->id, + 'description' => 'New Description', + 'amount' => 15000, + ]); +}); + +it('can update an expense image', function () { + [$user, $outlet] = createUserWithOutlet(); + $this->actingAs($user); + + $expense = ExpenseModel::factory()->create([ + 'user_id' => $user->id, + 'outlet_id' => $outlet->id, + 'description' => 'Expense with Image', + 'amount' => 10000, + ]); + + // Initial image upload (simulated via factory or direct DB check if needed, + // but here we just test REPLACING or ADDING via update) + + $newImage = UploadedFile::fake()->image('new_receipt.jpg'); + $imageData = [['path' => $newImage->getPathname()]]; + + Livewire::test(Expense::class) + ->call('openModal', 'update', 'Edit Expense', $expense->hash) + ->set('form.image', $imageData) + ->call('update'); + + $expense->refresh(); + expect($expense->getMedia('image'))->toHaveCount(1); + // In a real scenario we'd check filename, but hash names make it tricky in tests without mocking 'fake' +}); + +it('can delete an expense', function () { + [$user, $outlet] = createUserWithOutlet(); + $this->actingAs($user); + + $expense = ExpenseModel::factory()->create([ + 'user_id' => $user->id, + 'outlet_id' => $outlet->id, + ]); + + Livewire::test(Expense::class) + ->call('delete', $expense->hash) + ->assertDispatched('refreshDatatable'); + + $this->assertSoftDeleted('expenses', [ + 'id' => $expense->id, + ]); +}); + +// AUTHORIZATION TESTS + +it('checks authorization for create', function () { + [$user, $outlet] = createUserWithOutlet(); + $user->revokePermissionTo('create expense'); + $this->actingAs($user); + + Livewire::test(Expense::class) + ->call('create') + ->assertForbidden(); +}); + +it('checks authorization for update', function () { + [$user, $outlet] = createUserWithOutlet(); + $user->revokePermissionTo('update expense'); + $this->actingAs($user); + + $expense = ExpenseModel::factory()->create([ + 'user_id' => $user->id, + 'outlet_id' => $outlet->id, + ]); + + Livewire::test(Expense::class) + ->call('openModal', 'update', 'Edit', $expense->hash) // openModal doesn't check perm, update() does + ->call('update') + ->assertForbidden(); +}); + +it('checks authorization for delete', function () { + [$user, $outlet] = createUserWithOutlet(); + $user->revokePermissionTo('delete expense'); + $this->actingAs($user); + + $expense = ExpenseModel::factory()->create([ + 'user_id' => $user->id, + 'outlet_id' => $outlet->id, + ]); + + Livewire::test(Expense::class) + ->call('delete', $expense->hash) + ->assertForbidden(); +});