›
›
›
  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. 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.

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

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

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

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

Faking events

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.

Testing a policy

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.

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 add one integration test that calls autoAssign() and checks the result. That test also covers the profile's registration in config.

Testing races

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.

Testing the queue

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

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, so a later line does not fail with an unclear error.
  • The package does not refresh your models. $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. The engine rejects a candidate built with Tradie::factory()->make().
  • 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