›
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();Nothing is written to the approval tables while the fake is active. Each faked command returns an unsaved model, or an empty result, of the shape the real operation returns, so code under test that reads the returned request keeps working.
Mutations are faked. Submission, decisions, bulk decisions, withdrawal, cancellation, delegation, 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.
Resolution is not faked either. A faked submit() with no requester resolves
one the way the real manager does, through approvalRequester() and then the
authenticated user, so a test that forgets to authenticate fails the same way
production would.
| 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 |
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 |
assertNothingRecorded() names the interaction types that were recorded when
it fails, which is usually enough to see 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, 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 |
target is null for the operations that act on no single subject, overdue
processing and definition sync.
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 want the real engine need only a database. Everything the package does is local, no HTTP, no queue, 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']);Useful for building an odd request state directly, a conflicted request, a stranded stage, that would take several steps to reach through the engine.
Approval events are ordinary Laravel events:
Event::fake([ApprovalCompleted::class]);
Approval::approve($request, $financeUser);
Event::assertDispatched(ApprovalCompleted::class);Note that faking events stops the notification listener too, since it is registered against those events.
config(['approval.notifications.enabled' => true]);
Notification::fake();
$order->submitForApproval('purchase-order');
Notification::assertSentTo($financeUser, ApprovalRequestedNotification::class);Delivery is deferred with DB::afterCommit(). Outside a transaction the
callback runs immediately, so a test that does not wrap the call in one 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