Pengujian Action
Empat helper tersedia untuk empat tempat berbeda tempat schema mendeklarasikan action. Masing-masing mencari action melalui schema yang sama dengan yang digunakan controller saat melakukan resolusi, sehingga action yang dapat ditemukan helper adalah action yang juga dapat ditemukan endpoint — dan action yang tidak dapat ditemukan helper juga tidak dapat dijalankan melalui request. Gunakan helper ini saat pertanyaannya adalah apakah sebuah action tersedia, apakah action tersebut ditawarkan untuk record tertentu, apakah pengguna saat ini diizinkan menjalankannya, dan apa yang terjadi ketika action dijalankan.
Contoh minimal yang berfungsi
<?php
declare(strict_types=1);
use App\Models\User;
use App\Panels\Admin\Resources\Users\UserResource;
use PandaPanel\Core\PanelManager;
beforeEach(function (): void {
app(PanelManager::class)->setCurrentPanel(panel('admin'));
$this->admin = User::factory()->create(['is_admin' => true]);
$this->actingAs($this->admin);
});
it('purges unverified accounts, and only for an administrator', function (): void {
User::factory()->create(['email_verified_at' => null]);
panelTableActions(UserResource::class)->call('purgeUnverified');
expect(User::query()->whereNull('email_verified_at')->count())->toBe(0);
$this->actingAs(User::factory()->create());
panelTableActions(UserResource::class)->assertCanNotRun('purgeUnverified');
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Empat scope
panelRecordActions(UserResource::class) // one row
panelTableActions(UserResource::class) // header, toolbar, empty state
panelBulkActions(UserResource::class) // a selection
panelInfolistActions(UserResource::class) // a record page2
3
4
Keempatnya tidak dapat saling dipertukarkan, dan memang itu tujuannya: row action bernama delete dan bulk action bernama delete adalah dua objek berbeda di balik dua endpoint berbeda, dan menggunakan nama yang sama untuk keduanya adalah hal yang wajar. Berikut scope yang dicari oleh masing-masing helper:
| Helper | Method class | Mencari di |
|---|---|---|
panelRecordActions() | TestsActions::record() | TableSchema::getRecordAction() — recordActions(), lalu action() milik setiap column |
panelTableActions() | TestsActions::table() | TableSchema::getTableAction() — headerActions(), toolbarActions(), dan emptyStateActions(), dalam urutan tersebut |
panelBulkActions() | TestsActions::bulk() | TableSchema::getBulkAction() — bulkActions() |
panelInfolistActions() | TestsActions::infolist() | InfolistSchema::getAction() |
Column action adalah record action dalam semua hal yang penting — action tersebut menunjuk sebuah row, melakukan otorisasi terhadapnya, dan dapat mengubahnya — sehingga panelRecordActions() juga dapat menemukannya:
// `verifyOne` is declared on the BadgeColumn, not in recordActions().
panelRecordActions(UserResource::class)->assertExists('verifyOne');2
Tiga area action pada tabel juga menggunakan satu mekanisme pencarian yang sama, karena endpoint yang menjalankannya tidak peduli action tersebut dirender di area mana:
panelTableActions(UserResource::class)
->assertExists('purgeUnverified') // toolbar
->assertExists('export') // header
->assertExists('import'); // header2
3
4
Semua method
| Method | Signature | Perilaku |
|---|---|---|
find | find(string $name): ?Action | mengembalikan action, atau null jika schema tidak pernah mendeklarasikannya |
call | call(string $name, ?Model $record = null, array $data = []): self | melakukan otorisasi, lalu menjalankan action |
assertExists | assertExists(string $name): self | gagal jika scope tidak memiliki action tersebut |
assertDoesNotExist | assertDoesNotExist(string $name): self | gagal jika action tersebut justru ada |
assertVisible | assertVisible(string $name, ?Model $record = null): self | gagal jika action tidak ditawarkan untuk record tersebut |
assertHidden | assertHidden(string $name, ?Model $record = null): self | lulus jika action tidak ada atau tidak ditawarkan |
assertCanRun | assertCanRun(string $name, ?Model $record = null): self | gagal jika isAuthorizedFor() menghasilkan false |
assertCanNotRun | assertCanNotRun(string $name, ?Model $record = null): self | gagal jika hasilnya true |
Semua assertion mengembalikan $this. Semua assertion kecuali assertDoesNotExist() dan assertHidden() akan gagal dengan pesan "the record/table/bulk/infolist actions of [Resource] do not include [name]" saat action tidak ditemukan, alih-alih baru gagal karena mencoba mengakses nilai null beberapa baris setelahnya.
find()
Gunakan ini sebagai jalan keluar ketika assertion bawaan tidak sesuai dengan kebutuhan:
use PandaPanel\Actions\Action;
$action = panelRecordActions(UserResource::class)->find('edit');
expect($action)->toBeInstanceOf(Action::class)
->and($action->getLabel())->toBe('Edit')
->and($action->isExecutable())->toBeFalse(); // a link action has no handler2
3
4
5
6
7
assertExists() / assertDoesNotExist()
Memastikan keberadaan action di dalam scope tersebut, sebelum melibatkan record apa pun:
panelRecordActions(UserResource::class)
->assertExists('edit')
->assertExists('delete')
->assertDoesNotExist('invented');2
3
4
assertDoesNotExist() memastikan sebuah nama benar-benar tidak dapat dialamatkan. Assertion ini layak ditulis untuk action yang sengaja Anda hapus: endpoint melakukan resolusi berdasarkan nama terhadap schema yang sama, sehingga action yang tidak ada di sini juga tidak dapat dipanggil melalui POST yang dibuat secara manual.
assertVisible() / assertHidden()
Keduanya mengajukan pertanyaan yang sama seperti saat row dirender: Action::toArray($record) mengembalikan null ketika action disembunyikan atau tidak diotorisasi untuk record tersebut. Karena itu, action yang ditolak pada sebuah record tidak muncul di row sebagai tombol yang nantinya baru menjawab 403.
// The example policy refuses self-deletion, so the button is not on the
// administrator's own row.
panelRecordActions(UserResource::class)
->assertHidden('delete', $this->admin)
->assertVisible('edit', $this->admin);2
3
4
5
assertHidden() juga lulus ketika action sama sekali tidak ada, sehingga assertion ini lebih lemah. Pasangkan dengan assertExists() jika yang Anda maksud adalah "action memang dideklarasikan, tetapi tidak ditawarkan di sini":
panelRecordActions(UserResource::class)
->assertExists('delete')
->assertHidden('delete', $this->admin);2
3
assertCanRun() / assertCanNotRun()
Keduanya hanya menguji otorisasi — yaitu Action::isAuthorizedFor($record), yang berasal dari closure authorize() dan tidak mencakup hal lain:
panelTableActions(UserResource::class)->assertCanRun('purgeUnverified');
$this->actingAs(User::factory()->create());
panelTableActions(UserResource::class)->assertCanNotRun('purgeUnverified');2
3
4
5
Perbedaannya dengan assertHidden() penting. visible() dapat menyembunyikan action tanpa menyatakan bahwa action tersebut terlarang, sementara otorisasi tetap diperiksa lagi saat eksekusi. Test yang hanya memastikan action tersembunyi belum membuktikan action tersebut tidak dapat dijalankan.
call()
Menjalankan action seperti endpoint menjalankannya, setelah terlebih dahulu memastikan isAuthorizedFor($record):
// A table action: no record, so `Action::executeWithoutRecord($data)`.
panelTableActions(UserResource::class)->call('purgeUnverified');
// A record action: `Action::execute($record, $data)`, which runs the
// before hook, the handler and the after hook in one transaction.
panelRecordActions(OrderResource::class)->call('approve', $order);
// With the data the action's own form submitted.
panelRecordActions(OrderResource::class)->call('reject', $order, [
'reason' => 'Out of stock',
]);2
3
4
5
6
7
8
9
10
11
Otorisasi diperiksa terlebih dahulu dan membuat test gagal, bukan melewati eksekusi. Ini disengaja: helper yang tetap menjalankan action ketika pengguna sebenarnya tidak berhak akan hanya membuktikan handler bekerja, bukan membuktikan bahwa handler tersebut dapat dijangkau secara sah.
call() menjadi no-op jika action tidak memiliki handler yang sesuai — misalnya link action, atau record action yang dipanggil tanpa record — dan tidak menghasilkan error. Karena itu, uji efek akhirnya, bukan hanya pemanggilan method-nya:
panelRecordActions(UserResource::class)->call('delete', $target);
expect(User::find($target->id))->toBeNull();2
3
Tidak ada callBulk(). Bulk run menerima collection dan mengotorisasi semua record sebelum menulis satu pun. Sifat all-or-nothing ini lebih tepat diuji melalui endpoint — lihat bagian berikutnya.
Menguji melalui endpoint
Setiap panel memiliki satu endpoint action yang melakukan resolusi resource terhadap registry panel tersebut. Ini melengkapi helper: helper membuktikan schema mendeklarasikan action, sedangkan endpoint membuktikan request benar-benar dapat mencapainya dan request yang seharusnya ditolak memang tidak dapat menjalankannya.
| Route | Payload |
|---|---|
POST /{panel}/actions/record | resource, action, record |
POST /{panel}/actions/bulk | resource, action, records[] |
POST /{panel}/actions/table | resource, action |
POST /{panel}/actions/infolist | resource, action, record |
POST /{panel}/actions/cell | resource, column, record, value |
POST /{panel}/actions/reorder | resource, urutan baru |
GET/POST /{panel}/actions/form | form milik action: fetch, lalu submit |
Nama route mengikuti pola panel.{id}.actions.{name} — misalnya panel.admin.actions.record.
it('deletes a record through the action endpoint', function (): void {
$this->from('/admin/users')
->post('/admin/actions/record', [
'resource' => 'users',
'action' => 'delete',
'record' => $this->target->id,
])
->assertRedirect('/admin/users');
expect(User::find($this->target->id))->toBeNull();
});2
3
4
5
6
7
8
9
10
11
Kasus penolakan justru merupakan test yang paling penting, dan masing-masing memiliki status yang berbeda:
// An action the resource never declared.
$this->post('/admin/actions/record', [
'resource' => 'users', 'action' => 'nuke', 'record' => $this->target->id,
])->assertNotFound();
// A resource that is not registered in this panel.
$this->post('/admin/actions/record', [
'resource' => 'invoices', 'action' => 'delete', 'record' => $this->target->id,
])->assertNotFound();
// A key `Resource::query()` cannot reach.
$this->post('/admin/actions/record', [
'resource' => 'users', 'action' => 'delete', 'record' => 999_999,
])->assertNotFound();
// A non-scalar key.
$this->post('/admin/actions/record', [
'resource' => 'users', 'action' => 'delete', 'record' => ['a', 'b'],
])->assertStatus(422);
// A link action, which has no handler.
$this->post('/admin/actions/record', [
'resource' => 'users', 'action' => 'edit', 'record' => $this->target->id,
])->assertStatus(400);
// The policy, enforced on execution rather than on the button being drawn.
$this->actingAs(User::factory()->create())
->post('/admin/actions/record', [
'resource' => 'users', 'action' => 'delete', 'record' => $this->target->id,
])->assertForbidden();2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Bulk
Sifat yang perlu dibuktikan adalah all-or-nothing. Semua record diotorisasi sebelum ada satu pun write, sehingga satu row yang ditolak tidak boleh menghasilkan kondisi "sebagian besar berhasil":
it('deletes nothing when one selected record is forbidden', function (): void {
$this->post('/admin/actions/bulk', [
'resource' => 'users',
'action' => 'delete',
// The administrator's own record cannot be deleted.
'records' => [$this->target->id, $this->admin->id],
])->assertForbidden();
expect(User::count())->toBe(2);
});2
3
4
5
6
7
8
9
10
Selection kosong adalah validation error, bukan keberhasilan diam-diam:
$this->post('/admin/actions/bulk', [
'resource' => 'users', 'action' => 'delete', 'records' => [],
])->assertStatus(302)->assertSessionHasErrors('records');2
3
authorizeEachUsing() adalah lapisan kustom untuk otorisasi per record. Tanpanya, bulk action menggunakan authorize($record) sebagai fallback untuk setiap record terpilih, sehingga closure otorisasi pada level row tetap berlaku. Gunakan authorizeEachUsing() saat "boleh menjalankan action ini" dan "boleh menjalankan action ini pada record-record tersebut" memang sengaja merupakan dua pertanyaan yang berbeda.
Row yang diserialisasi
Action dikirim ke browser sebagai data. Pastikan data tersebut tidak membawa sesuatu yang seharusnya tidak ikut terkirim:
$rows = $this->get('/admin/users')->viewData('page')['props']['rows'];
$action = collect($rows)->firstWhere('key', $this->target->id)['actions'][0];
expect($action)->toHaveKeys(['name', 'label', 'icon', 'variant', 'type', 'url', 'confirmation'])
->and(json_encode($action))->not->toContain('Closure');2
3
4
5
Pastikan juga action yang ditolak benar-benar tidak ada, bukan sekadar ada dalam keadaan disabled:
$ownRow = collect($rows)->firstWhere('key', $this->admin->id);
expect(array_column($ownRow['actions'], 'name'))->not->toContain('delete');2
3
Hal yang perlu diperhatikan
- Scope merupakan bagian dari proses lookup.
panelBulkActions(...)->assertExists('edit')akan gagal jikaeditpada resource tersebut adalah row action. Helper sedang menunjukkan kebenaran sesuai endpoint yang sebenarnya. assertHidden()lulus untuk action yang tidak ada. Pasangkan denganassertExists()jika perbedaannya penting.call()menjalankan handler, bukan endpoint. Tidak ada route, middleware, flash message, atau redirect. Namun hook before/after dan transaction tetap dijalankan karena semuanya berada diAction::execute().- Table action tanpa handler
tableAction()tidak melakukan apa pun.call()kembali secara diam-diam. Schema yang mendeklarasikan action tanpa handler sama sekali akan ditolak saat build —PanelSchemaException: The action [approve] does nothing— jadi kasus diam-diam hanya terjadi ketika scope pemanggilan tidak cocok. - Nama action dikirim sebagai identifier.
Action::make('send invoice')melempar exception saat deklarasi dengan saran (try [send-invoice]), sehingga nama yang tidak mungkin dicocokkan tidak perlu lagi ditangkap oleh test. - Dua action dengan nama yang sama dalam satu set akan melempar exception.
PanelSchemaExceptionmenyebutkan set-nya: "The bulk actions declare more than one action named [delete]".