refactor: Enhance Livewire components and forms with type hints and authorization checks, and add new feature tests.
This commit is contained in:
parent
c3771287f4
commit
99ca332f1c
@ -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']);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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');
|
||||
});
|
||||
246
tests/Feature/Livewire/Studio/Finance/ExpenseTest.php
Normal file
246
tests/Feature/Livewire/Studio/Finance/ExpenseTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user