›
byrcsc/laravel-assignment · 1.x
Test assignment logic with frozen time, faked notifications, and a real database for races.
Most assignment behaviour is testable with the tools Laravel already gives you. Three areas need a little care: expiry, which depends on the clock; races, which need a real database; and notifications, which are dispatched by a listener you did not register.
it('routes an enquiry to an available tradie', function () {
$enquiry = Enquiry::factory()->create();
$tradie = Tradie::factory()->create(['available' => true]);
$enquiry->autoAssign();
expect($enquiry->assignee()->is($tradie))->toBeTrue()
->and($enquiry->isAssigned())->toBeTrue();
});Reading through assignee() and isAssigned() keeps the test tied to the
package's public behaviour rather than to its column layout.
Expiry is decided by comparing expires_at to now, so travelling forward is
enough. Nothing has to run.
it('releases the slot when an offer lapses', function () {
$this->freezeTime();
$callout = Callout::factory()->create();
$crew = Crew::factory()->create();
$offer = $callout->offer($crew, role: 'crew', ttlSeconds: 120);
$this->travel(121)->seconds();
expect($offer->isExpired())->toBeTrue()
->and($callout->openAssignment('crew'))->toBeNull();
});The row still reads offered at that point, and its ended_at is still null.
Assert on isOpen(), isExpired(), or a reader rather than on status, or
run assignment:tick first when you want the row tidied:
$this->artisan('assignment:tick')->assertSuccessful();
expect($offer->refresh()->ended_reason)->toBe(EndReason::Expired);The package registers its own listener, so notifications go out on a bare
assign(). Fake them when they are not what you are testing:
use ByRcsc\LaravelAssignment\Notifications\AssignmentReceived;
use Illuminate\Support\Facades\Notification;
it('notifies the new holder', function () {
Notification::fake();
$enquiry = Enquiry::factory()->create();
$tradie = Tradie::factory()->create();
$enquiry->assign($tradie);
Notification::assertSentTo($tradie, AssignmentReceived::class);
});Silencing them entirely is a config change, which suits a test that assigns a lot:
config()->set('assignment.notifications', [
'offered' => null,
'offer_expired' => null,
'assigned' => null,
'unassigned' => null,
]);Package events are dispatched after the transaction commits. Inside a test
wrapped in a transaction, which is what RefreshDatabase does, Event::fake()
still records them, but a real listener does not run until commit.
use ByRcsc\LaravelAssignment\Events\OffersExhausted;
use Illuminate\Support\Facades\Event;
Event::fake([OffersExhausted::class]);
// ... run the cascade to exhaustion
Event::assertDispatched(OffersExhausted::class);When you are testing your own listener end to end, assert on its effect rather than on the event.
A policy is a plain class, so it can be tested without touching the database when it does not read one:
use ByRcsc\LaravelAssignment\Policies\FirstAvailable;
it('takes the first candidate in query order', function () {
$candidates = collect([$ada, $bo, $cleo]);
$selected = (new FirstAvailable)->select($enquiry, $candidates, $state);
expect($selected->is($ada))->toBeTrue();
});RoundRobin and LeastWorkload need a real ScopeState and real rows, so
test them through an assignment instead of in isolation.
Assert what it returns rather than what happens afterwards:
it('rotates each trade separately', function () {
$profile = new EnquiryProfile;
$enquiry = Enquiry::factory()->create(['trade' => 'plumbing']);
expect($profile->scope($enquiry, null))->toBe('trade:plumbing')
->and($profile->mode($enquiry, null))->toBe(Mode::Assign);
});Then one integration test that calls autoAssign() and checks the result, so
the registration in config is covered too.
SQLite serializes writes at the database level, which hides the window a race needs. A concurrency test that passes on SQLite proves less than it looks like it does.
Run those tests against MySQL or PostgreSQL, with genuinely parallel processes
rather than sequential calls. A test that calls assign() twice in a row is
testing the constraint, not the race, and both are worth having:
it('refuses a second assignment on the same slot', function () {
$callout->assign($crew, role: 'crew');
expect(fn () => $callout->assign($other, role: 'crew'))
->toThrow(SlotOccupied::class);
});The package's own suite forks processes and runs against all three databases in CI.
The queue is a table, so it is directly assertable:
it('parks an enquiry when nobody is active', function () {
Tradie::query()->update(['available' => false]);
$enquiry = Enquiry::factory()->create();
expect($enquiry->autoAssign())->toBeNull()
->and($enquiry->queuedAssignment())->not->toBeNull();
Tradie::query()->update(['available' => true]);
expect(Assignment::flushQueue())->toBe(1)
->and($enquiry->isAssigned())->toBeTrue();
});null return is not a failure. assign() and autoAssign() return
null when nobody was selected. Assert on null explicitly rather than letting
a later line fail confusingly.$offer still holds the values it was
created with after a cascade moves on. Call refresh().Tradie::factory()->make() is rejected.queued_at ordering. Two entries queued at the same
frozen instant fall back to insertion order.