›
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. Take care with expiry, races, and notifications. Expiry depends on the clock. Races need a real database. Notifications come from a listener that the package registers for you.
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.
The package decides expiry by comparing expires_at to the current time, so
moving the clock forward with travel() is enough. No command 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. To
assert on the ended row, run assignment:tick first:
$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);
});To silence them entirely in a test that makes many assignments, set every key to null:
config()->set('assignment.notifications', [
'offered' => null,
'offer_expired' => null,
'assigned' => null,
'unassigned' => null,
]);The package dispatches its events after the transaction commits.
RefreshDatabase wraps each test in a transaction. Inside that transaction,
Event::fake() still records the events, 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. If it does not read the database, you can test it without 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 add one integration test that calls autoAssign() and checks the result.
That test also covers the profile's registration in config.
SQLite serializes writes at the database level, so two writes never overlap and the race window never opens. A concurrency test that passes on SQLite proves less than it appears to.
Run those tests against MySQL or PostgreSQL, with parallel processes rather
than sequential calls. A test that calls assign() twice in a row tests the
constraint, not the race. Keep both kinds of test:
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 you can assert on it directly:
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, so a later line
does not fail with an unclear error.$offer still holds the
values it was created with after a cascade moves on. Call refresh().Tradie::factory()->make().queued_at ordering. Two entries queued at the same
frozen instant fall back to insertion order.