refactor: Enhance Livewire components and forms with type hints and authorization checks, and add new feature tests.

This commit is contained in:
Yoga Pangestu 2025-12-11 18:39:06 +07:00
parent c3771287f4
commit 99ca332f1c
5 changed files with 397 additions and 27 deletions

View File

@ -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']);
}

View File

@ -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();

View File

@ -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',

View File

@ -0,0 +1,121 @@
<?php
use App\Livewire\Datatable\Studio\Finance\ExpensesTable;
use App\Models\Employee;
use App\Models\Expense;
use App\Models\Outlet;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
// Setup Permissions if needed, though view might not need them strictly for data loading,
// but actions column does check permissions
$this->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');
});

View File

@ -0,0 +1,246 @@
<?php
use App\Livewire\Studio\Finance\Expense;
use App\Models\Employee;
use App\Models\Expense as ExpenseModel;
use App\Models\Outlet;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
uses(RefreshDatabase::class);
beforeEach(function () {
// Setup Permissions
Permission::create(['name' => '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();
});