›
byrcsc/laravel-approval · 1.x
Test approval mutations with a fake and persisted states with factories.
Approval::fake() records every public mutation instead of performing it, and
provides an assertion for each:
$fake = Approval::fake();
$order->submitForApproval('purchase-order', ['amount' => 10_000]);
$fake->assertSubmitted(
$order,
fn ($recorded) => $recorded->metadata['new_attributes'] === ['amount' => 10_000],
);
$fake->restore();While the fake is active, the package writes nothing to the approval tables. Each faked command returns an unsaved model, or an empty result, in the shape the real operation returns. Code under test that reads the returned request keeps working.
Mutations are faked. Submission, decisions, bulk decisions, withdrawal, cancellation, delegation, standing delegations, reassignment, resynchronization, conflict resolution, resubmission, attachment, guard bypass, definition sync, and overdue processing.
Queries are not. awaiting(), pendingFor(), requestsFor(),
overdue(), workflow(), and verifyTrail() run against the database as
usual, so a test can seed real requests and still fake the decisions taken on
them.
Requester resolution is not faked either. A faked submit() with no requester
finds one the same way the real manager does, through approvalRequester() and
then the authenticated user. A test that forgets to authenticate fails the same
way production does.
| Assertion | Covers |
|---|---|
assertSubmitted($approvable, $callback?) | Submission |
assertNotSubmitted($approvable) | none |
assertSubmittedTimes($count, $approvable?) | none |
assertApproved($request, $callback?) | An approval decision |
assertRejected($request, $callback?) | A rejection |
assertReturnedForRevision($request, $callback?) | A return for revision |
assertNotApproved($request) | none |
assertApprovedTimes($count, $request?) | none |
assertRejectedTimes($count, $request?) | none |
assertApprovedMany($callback?) | A bulk approval |
assertRejectedMany($callback?) | A bulk rejection |
assertWithdrawn($request, $callback?) | Withdrawal |
assertCancelled($request, $callback?) | Administrative cancellation |
assertDelegated($request, $callback?) | Delegation |
assertDelegationGranted($delegator, $callback?) | A standing delegation grant |
assertDelegationRevoked($delegation, $callback?) | A standing delegation revoke |
assertDelegationsApplied($callback?) | The delegation sweep |
assertReassigned($request, $callback?) | Reassignment |
assertResynced($request, $callback?) | Resynchronization |
assertConflictResolved($request, $callback?) | Conflict resolution |
assertResubmitted($request, $callback?) | Revise and resubmit |
assertAttached($request, $callback?) | Attachment |
assertGuardBypassed($approvable, $callback?) | A guard-mode bypass |
assertOverdueProcessed($callback?) | The overdue sweep |
assertDefinitionsSynced($callback?) | Definition sync |
assertNothingRecorded() | All of them |
When assertNothingRecorded() fails, it names the interaction types that
were recorded. The names usually show what happened without a debugger.
The optional callback receives an ApprovalInteraction and returns a boolean:
$fake->assertDelegated($request, fn ($recorded) =>
$recorded->recipient->is($deputy)
&& $recorded->comment === 'On leave this week');| Property | Meaning |
|---|---|
type | An InteractionType |
target | The request, record, delegator, delegation, or list of requests it acted on |
actor | Who performed it |
workflow | The workflow argument, for submission and resubmission |
verdict | The Verdict, for decisions |
comment | The comment, trimmed |
metadata | Operation-specific input |
recipient | The approver an assignment moved to, or the delegate a standing delegation names |
target is null for the operations that act on no single subject: overdue
processing, delegation processing, and definition sync.
A faked grantDelegation() records the window in metadata as starts_at and
ends_at, and returns an unsaved ApprovalDelegation.
Read them all with $fake->interactions() when an assertion helper does not
fit.
$fake->restore();Puts the real manager back in the container and on the facade. Calling
fake() again on an active fake clears its recorded interactions instead of
nesting.
Integration tests that use the real engine need only a database. Everything the package does is local. It makes no HTTP calls, uses no queue, and calls no external service:
it('applies the draft after the final approval', function () {
$order = PurchaseOrder::factory()->create(['amount' => 4_000]);
$request = $order->submitForApproval('purchase-order', ['amount' => 10_000]);
Approval::approve($request, $financeUser);
expect($order->fresh()->amount)->toBe(10_000)
->and($request->fresh()->state)->toBe(RequestState::Approved);
});Every model ships a factory under
ByRcsc\LaravelApproval\Database\Factories:
use ByRcsc\LaravelApproval\Models\ApprovalWorkflow;
$workflow = ApprovalWorkflow::factory()
->has(ApprovalWorkflowStage::factory()->count(2), 'stages')
->create(['slug' => 'purchase-order']);Factories build an unusual request state directly, such as a conflicted request or a stranded stage. Reaching those states through the engine takes several steps.
Approval events are ordinary Laravel events:
Event::fake([ApprovalCompleted::class]);
Approval::approve($request, $financeUser);
Event::assertDispatched(ApprovalCompleted::class);Faking events also stops the notification listener, because the package registers the listener against those events.
config(['approval.notifications.enabled' => true]);
Notification::fake();
$order->submitForApproval('purchase-order');
Notification::assertSentTo($financeUser, ApprovalRequestedNotification::class);The listener defers delivery with DB::afterCommit(). Outside a transaction,
the callback runs immediately. A test that does not wrap the call in a
transaction sees the notification without extra work.
The package's own suite needs no external database, .env, or service:
composer install
composer test
composer analyse
vendor/bin/pint --test