›
›
›
  1. docs
  2. ›
  3. byrcsc/laravel-assignment
1.x
Browse documentationOpenClose

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Assignments and slots
  • Assigning and offering
  • Selection policies
  • Scopes and rotation
  • Profiles
  • The queue
  • Offer cascades

Operations

  • Expiry and scheduling
  • Reading assignments
  • Notifications
  • Events and listeners
  • Concurrency guarantees

Reference

  • Configuration
  • API reference
  • Console commands
  • Exceptions
  • Testing
  • Troubleshooting

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Assignments and slots
  • Assigning and offering
  • Selection policies
  • Scopes and rotation
  • Profiles
  • The queue
  • Offer cascades

Operations

  • Expiry and scheduling
  • Reading assignments
  • Notifications
  • Events and listeners
  • Concurrency guarantees

Reference

  • Configuration
  • API reference
  • Console commands
  • Exceptions
  • Testing
  • Troubleshooting

byrcsc/laravel-assignment · 1.x

Testing.

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.

Assert the outcome, not the row

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.

Freezing time for expiry

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);

Faking notifications

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,
]);

Faking events

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.

Testing a policy

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.

Testing a profile

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.

Testing races

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.

Testing the queue

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();
});

What to watch out for

  • A 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.
  • Models are not refreshed for you. $offer still holds the values it was created with after a cascade moves on. Call refresh().
  • Factories need persisted models. Candidates must exist in the database; Tradie::factory()->make() is rejected.
  • Frozen time affects queued_at ordering. Two entries queued at the same frozen instant fall back to insertion order.

What to read next

  • Concurrency guarantees for what races are guaranteed to do.
  • Expiry and scheduling for what the tick processes.
  • Troubleshooting for behaviour that looks wrong but is not.
PreviousExceptionsNextTroubleshooting
View source

On this page

  1. Assert the outcome, not the row
  2. Freezing time for expiry
  3. Faking notifications
  4. Faking events
  5. Testing a policy
  6. Testing a profile
  7. Testing races
  8. Testing the queue
  9. What to watch out for
  10. What to read next