Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions scripts/pest-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ function config(string|array|null $key = null, mixed $default = null): mixed
'fleetops' => [],
'services' => [],
'telematics' => [],
// The core SMS guard is off unless a test turns it on: its counters live in the
// shared cache, so one file's sends would otherwise rate-limit the next's, and
// a second text to the same number inside a test trips its cooldown.
'sms' => ['guard' => ['enabled' => false]],
]));
}

Expand Down
4 changes: 4 additions & 0 deletions server/src/Support/ApiCacheThrottle.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,13 @@ class ApiCacheThrottle
*/
public static function telemetryWrite(Model $model, \Closure $write, int $seconds = self::LOCATION_WINDOW): mixed
{
// @codeCoverageIgnoreStart
// fleetbase/core-api ^1.6.69, which this package requires, always has it; the
// branch only serves an install that pinned an older core by hand.
if (!method_exists(ApiModelCache::class, 'withoutInvalidating')) {
return $write();
}
// @codeCoverageIgnoreEnd

$result = ApiModelCache::withoutInvalidating($write);
static::invalidate($model, $model->company_uuid, $seconds);
Expand Down
26 changes: 26 additions & 0 deletions server/tests/ApiOrderControllerContractsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ protected function findOrder(string $id, array $with = [], array $withCount = []
return $this->order;
}

public ?FleetOpsApiOrderPayloadFake $switchedPayload = null;

protected function findPayloadByUuid(?string $uuid): ?Payload
{
return $this->switchedPayload;
}

protected function beginOrderEdit(Order $order): OrderEdit
{
$this->edit ??= new FleetOpsApiOrderEditFake($order);
Expand Down Expand Up @@ -825,6 +832,25 @@ public function isString(string $key): bool
->and($payload->removedWaypoints)->toBeTrue();
});

test('api order controller moves the cash to collect onto the payload it switches to by id', function () {
$order = new FleetOpsApiOrderCrudFake();
$order->payload = new FleetOpsApiOrderPayloadFake();
$switched = new FleetOpsApiOrderPayloadFake();

$controller = new FleetOpsApiOrderCrudControllerProbe();
$controller->order = $order;
$controller->switchedPayload = $switched;

$controller->update('order-public', new FleetOpsApiOrderUpdateRequestFake([
'payload' => 'payload_public',
'cod_amount' => '1800',
]));

expect($order->updates[0])->toMatchArray(['payload_uuid' => 'payloads-uuid'])
->and($switched->getAttributes()['cod_amount'])->toBe(1800)
->and($switched->savedForTest)->toBeTrue();
});

test('api order controller finds deletes and updates distance matrices without database records', function () {
$order = new FleetOpsApiOrderCrudFake();
$controller = new FleetOpsApiOrderCrudControllerProbe();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -452,14 +452,16 @@ public function parameters()
test('verification codes files contacts and customer auth resolve', function () {
$connection = fleetopsCustomerHelperBoot();
$connection->table('verification_codes')->insert(['uuid' => 'vc-1', 'subject_uuid' => 'user-1', 'subject_type' => 'user', 'code' => '123456', 'for' => 'customer_verification']);
// A code is used up by the lookup that accepts it, so each check gets its own row.
$connection->table('verification_codes')->insert(['uuid' => 'vc-2', 'subject_uuid' => 'user-1', 'subject_type' => 'user', 'code' => '654321', 'for' => 'customer_verification']);
$connection->table('files')->insert(['uuid' => 'file-1', 'public_id' => 'file_custhelp1', 'company_uuid' => 'company-1', 'name' => 'photo.png']);
$connection->table('contacts')->insert(['uuid' => 'contact-1', 'public_id' => 'contact_custhelp1', 'company_uuid' => 'company-1', 'name' => 'Casey', 'type' => 'customer']);

$probe = new FleetOpsCustomerControllerProbe();

expect($probe->callHelper('verificationCodeExists', ['code' => '123456', 'for' => 'customer_verification']))->toBeTrue()
->and($probe->callHelper('findVerificationCode', ['code' => '123456'])?->uuid)->toBe('vc-1')
->and($probe->callHelper('findVerificationCode', ['code' => '999999']))->toBeNull()
expect($probe->callHelper('verificationCodeExists', ['code' => '654321', 'for' => 'customer_verification']))->toBeTrue()
->and($probe->callHelper('findVerificationCode', ['code' => '123456', 'for' => 'customer_verification'])?->uuid)->toBe('vc-1')
->and($probe->callHelper('findVerificationCode', ['code' => '999999', 'for' => 'customer_verification']))->toBeNull()
->and($probe->callHelper('findFileByPublicId', 'file_custhelp1')?->uuid)->toBe('file-1');

expect($probe->callHelper('findCustomerContact', ['uuid' => 'contact-1'])?->name)->toBe('Casey')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,18 +157,20 @@ public function __call($method, $arguments)
expect($valid['driver'])->toBe('22222222-2222-4222-8222-222222222222');
});

test('assign driver to orders updates the assignment column', function () {
test('assign driver to orders updates the assignment column and tells the drivers it replaces', function () {
$connection = fleetopsInternalOrderHelpersBoot();
$connection->table('orders')->insert([
['uuid' => 'order-1', 'company_uuid' => 'company-1'],
['uuid' => 'order-2', 'company_uuid' => 'company-1'],
['uuid' => 'order-1', 'company_uuid' => 'company-1', 'driver_assigned_uuid' => null],
['uuid' => 'order-2', 'company_uuid' => 'company-1', 'driver_assigned_uuid' => null],
// already with another driver, who no longer exists: nothing to tell, nothing to fail
['uuid' => 'order-3', 'company_uuid' => 'company-1', 'driver_assigned_uuid' => 'driver-gone'],
]);
$driver = new Driver();
$driver->setRawAttributes(['uuid' => 'driver-1'], true);

(new FleetOpsInternalOrderHelpersProbe())->callProtected('assignDriverToOrders', ['order-1', 'order-2'], $driver);
(new FleetOpsInternalOrderHelpersProbe())->callProtected('assignDriverToOrders', ['order-1', 'order-2', 'order-3'], $driver);

expect($connection->table('orders')->where('driver_assigned_uuid', 'driver-1')->count())->toBe(2);
expect($connection->table('orders')->where('driver_assigned_uuid', 'driver-1')->count())->toBe(3);
});

test('dispatch domain event helper emits and returns the event', function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ function fleetopsOrderChangeNotificationOrder(): Order
->and($notification->toArray())->toMatchArray(['event' => 'order.changed_notification', 'data' => $notification->data])
->and(array_map(fn ($channel) => $channel->name, $notification->broadcastOn()))->toContain('driver.driver-uuid')
->and((new OrderChanged(fleetopsOrderChangeNotificationOrder()))->message)->toBe('This order was changed, tap for details.');

// The push builders reach for the FCM and APN clients, which the harness does not configure
expect(fn () => $notification->toFcm(null))->toThrow(Exception::class)
->and(fn () => $notification->toApn(null))->toThrow(Error::class);
});

test('order unassigned tells the driver the order is no longer theirs', function () {
Expand All @@ -49,4 +53,7 @@ function fleetopsOrderChangeNotificationOrder(): Order
->and($notification->data)->toBe(['type' => 'order_unassigned', 'id' => 'order_change'])
->and($notification->via(null))->toBe([FcmChannel::class, ApnChannel::class])
->and($notification->toArray()['event'])->toBe('order.unassigned_notification');

expect(fn () => $notification->toFcm(null))->toThrow(Exception::class)
->and(fn () => $notification->toApn(null))->toThrow(Error::class);
});
10 changes: 10 additions & 0 deletions server/tests/Unit/Support/ApiCacheDependenciesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,16 @@ public function log(string $description): ?Spatie\Activitylog\Contracts\Activity
app()->forgetInstance(Spatie\Activitylog\ActivityLogStatus::class);
});

test('throttled invalidation does nothing while api caching is off', function () {
config(['api.cache.enabled' => false]);

ApiCacheThrottle::invalidate(new Vehicle(), 'co-1');

config(['api.cache.enabled' => true]);

expect(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(0);
});

test('a cache outage never fails a throttled location invalidation', function () {
Cache::swap(new Repository(new class extends ArrayStore {
public function add($key, $value, $seconds)
Expand Down
28 changes: 28 additions & 0 deletions server/tests/Unit/Support/OrderEditPolicyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,31 @@ function fleetopsEditPolicyOrder(string $status, array $attributes = [], ?OrderC
->and((array) $serialized['restrictions'])->toBe(['items' => 'removal_only', 'schedule' => 'earlier_only'])
->and(fleetopsEditPolicyOrder('ready')->editPolicy())->toBeInstanceOf(OrderEditPolicy::class);
});

class FleetOpsEditPolicyBrokenConfig extends OrderConfig
{
public function isTerminalActivityCode(?string $code): bool
{
throw new RuntimeException('activities unavailable');
}
}

class FleetOpsEditPolicyOrderWithoutDatabase extends Order
{
public function orderConfig(): Illuminate\Database\Eloquent\Relations\BelongsTo
{
throw new RuntimeException('no database');
}
}

test('a config that cannot answer, a config that cannot load and times that do not parse all fail safe', function () {
$brokenConfig = OrderEditPolicy::for(fleetopsEditPolicyOrder('custom_status', [], new FleetOpsEditPolicyBrokenConfig()));

$withoutDatabase = new FleetOpsEditPolicyOrderWithoutDatabase();
$withoutDatabase->setRawAttributes(['uuid' => 'order-uuid', 'public_id' => 'order_policy', 'status' => 'custom_status'], true);

expect($brokenConfig->isTerminal())->toBeFalse()
->and(OrderEditPolicy::for($withoutDatabase)->isTerminal())->toBeFalse()
// "earlier only" cannot be judged from times that do not parse, so the move is refused
->and(OrderEditPolicy::for(fleetopsEditPolicyOrder('ready'))->check('schedule', ['from' => 'soon', 'to' => 'later']))->toBeString();
});
129 changes: 126 additions & 3 deletions server/tests/Unit/Support/OrderOperationsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ function event(...$arguments)
}

use Fleetbase\FleetOps\Events\OrderChanged;
use Fleetbase\FleetOps\Exceptions\OrderEditLockedException;
use Fleetbase\FleetOps\Models\Contact;
use Fleetbase\FleetOps\Models\Driver;
use Fleetbase\FleetOps\Models\Entity;
use Fleetbase\FleetOps\Models\Order;
Expand Down Expand Up @@ -73,6 +75,30 @@ public function listen($events, $listener = null)
}
}

class FleetOpsOrderOpsPayloadControllerProbe extends Fleetbase\FleetOps\Http\Controllers\Api\v1\PayloadController
{
public function edit(Order $order): OrderEdit
{
return $this->beginOrderEdit($order);
}
}

class FleetOpsOrderOpsEntityControllerProbe extends Fleetbase\FleetOps\Http\Controllers\Api\v1\EntityController
{
public function editFor(?string $payloadUuid): ?OrderEdit
{
return $this->orderEditForPayload($payloadUuid);
}
}

class FleetOpsOrderOpsApiOrderControllerProbe extends Fleetbase\FleetOps\Http\Controllers\Api\v1\OrderController
{
public function locked(OrderEditLockedException $exception): mixed
{
return $this->lockedEditResponse($exception);
}
}

class FleetOpsOrderOpsDriver extends Driver
{
public array $notified = [];
Expand Down Expand Up @@ -111,7 +137,7 @@ public function __call($method, $arguments)
}
});
Illuminate\Support\Facades\DB::clearResolvedInstance('db');
class_exists(Fleetbase\FleetOps\Models\Contact::class);
class_exists(Contact::class);

foreach (['DNS2D', 'DNS1D'] as $barcode) {
app()->instance($barcode, new class {
Expand Down Expand Up @@ -222,7 +248,11 @@ function fleetopsOrderOpsOrder(SQLiteConnection $connection, array $order = [],
->and($result['updated'])->toBe([$latte->uuid])
->and($result['deleted'])->toBe([$muffin->uuid])
->and($result['created'])->toHaveCount(1)
->and($payload->syncEntities([['uuid' => $latte->uuid, 'name' => 'Latte', 'price' => '500']])['updated'])->toBe([]);
->and($payload->syncEntities([['uuid' => $latte->uuid, 'name' => 'Latte', 'price' => '500']])['updated'])->toBe([])
// entries that are not arrays are skipped, and a list that is not an array changes nothing
->and($payload->syncEntities([['uuid' => $latte->uuid, 'name' => 'Latte', 'price' => '500'], 'junk'])['deleted'])->toBe([])
->and($payload->syncEntities('junk'))->toBe(['created' => [], 'updated' => [], 'deleted' => []])
->and(Entity::where('payload_uuid', 'payload-1')->count())->toBe(1);
});

test('route signatures report changed parts and a not started order restarts at the first stop', function () {
Expand Down Expand Up @@ -369,7 +399,7 @@ function fleetopsOrderOpsOrder(SQLiteConnection $connection, array $order = [],
$edit->guard($edit->requestedChanges(['entities' => [['id' => $latte->public_id]], 'scheduled_at' => '2026-10-13 11:00:00'], OrderEdit::ENTITIES_REPLACE));

expect(fn () => $edit->guard($edit->requestedChanges(['entities' => [['name' => 'Cake']]])))
->toThrow(Fleetbase\FleetOps\Exceptions\OrderEditLockedException::class, 'Once the order is ready, items can only be removed.')
->toThrow(OrderEditLockedException::class, 'Once the order is ready, items can only be removed.')
->and(OrderEdit::entitiesMode('replace'))->toBe('replace')
->and(OrderEdit::entitiesMode('sync'))->toBe('replace')
->and(OrderEdit::entitiesMode(null))->toBe('append');
Expand All @@ -387,5 +417,98 @@ function fleetopsOrderOpsOrder(SQLiteConnection $connection, array $order = [],
expect($events)->toHaveCount(1)
->and($events[0]->broadcastAs())->toBe('order.driver_unassigned')
->and($events[0]->channelNames)->toBe(['driver.driver_one', 'driver.driver-1', 'company.company-1'])
->and(array_map(fn ($channel) => $channel->name, $events[0]->broadcastOn()))->toBe($events[0]->channelNames)
->and($events[0]->broadcastWith()['data'])->toBe(['id' => 'order_one', 'driver' => 'driver_one']);
});

test('an edit exposes its policy and detects schedule cash contact and item changes, flagging the kitchen while preparing', function () {
$connection = fleetopsOrderOpsBoot();
$connection->table('contacts')->insert(['uuid' => 'contact-1', 'public_id' => 'contact_one', 'company_uuid' => 'company-1', 'name' => 'Ada', 'type' => 'customer']);
$order = fleetopsOrderOpsOrder($connection, [
'status' => 'preparing',
'scheduled_at' => '2026-10-13 12:00:00',
'notes' => 'Same',
'customer_uuid' => 'contact-1',
'customer_type' => Contact::class,
], ['cod_amount' => '0']);
$edit = OrderEdit::begin($order);

expect($edit->policy())->toBeInstanceOf(Fleetbase\FleetOps\Support\OrderEditPolicy::class)
->and($edit->requestedChanges([
// a time that does not parse is kept as given, and still counts as a change
'scheduled_at' => 'not a date',
'notes' => 'Ring twice',
'cod_amount' => '12.50',
'customer' => 'contact_two',
]))->toBe([
'schedule' => ['from' => '2026-10-13T12:00:00Z', 'to' => 'not a date'],
'notes' => true,
'cod_amount' => true,
'contact' => true,
])
// the customer already on the order, by public id, is not a change; anything but a public id is
->and($edit->requestedChanges(['customer' => 'contact_one']))->toBe([])
->and($edit->requestedChanges(['customer' => 'contact-1']))->toBe([])
->and($edit->requestedChanges(['customer' => ['id' => 'contact_one']]))->toBe(['contact' => true]);

$edit = OrderEdit::begin($order);
$edit->markChanged('total', 'total');
$connection->table('payloads')->where('uuid', 'payload-1')->update(['cod_amount' => '900']);
$order->payload->setEntities([['name' => 'Latte']]);
$order->scheduled_at = '2026-10-14 09:30:00';
$order->customer_uuid = 'contact-2';
$order->save();

expect($edit->commit())->toBe(['items', 'cod_amount', 'schedule', 'contact', 'total'])
->and(fleetopsOrderOpsEvents()->of(OrderChanged::class)[0]->data['summary'])->toContain('Rescheduled to 14 Oct 09:30')
->and(OrderEdit::summarize(['pickup', 'schedule'], null, null))->toBe('Pickup changed · Schedule cleared')
->and(OrderEdit::summarize(['service_type']))->toBe('Service type changed');
});

test('an order with no payload, or an unsaved one, commits without touching the database', function () {
$connection = fleetopsOrderOpsBoot();
$connection->table('orders')->insert(['uuid' => 'order-2', 'public_id' => 'order_two', 'company_uuid' => 'company-1', 'status' => 'created']);

$bare = Order::where('uuid', 'order-2')->first();
expect(OrderEdit::begin($bare)->commit())->toBe([]);

$unsaved = Order::where('uuid', 'order-2')->first();
$unsaved->setRelation('payload', new Payload());
expect(OrderEdit::begin($unsaved)->commit())->toBe([])
->and(fleetopsOrderOpsEvents()->of(OrderChanged::class))->toBe([]);
});

test('order changed names the customer channel, reads the editor from the session and copes with a driver that is gone', function () {
$connection = fleetopsOrderOpsBoot();
$connection->table('contacts')->insert(['uuid' => 'contact-1', 'public_id' => 'contact_one', 'company_uuid' => 'company-1', 'name' => 'Ada', 'type' => 'customer']);
$order = fleetopsOrderOpsOrder($connection, ['driver_assigned_uuid' => 'ghost-driver', 'customer_uuid' => 'contact-1', 'customer_type' => Contact::class]);
// No users table here: the editor's name lookup fails quietly
session(['user' => 'user-1']);

$event = OrderChanged::dispatchFor($order, ['dropoff'], 'Drop-off changed');
session(['user' => null]);

$noDriver = new Order();
$noDriver->setRawAttributes(['uuid' => 'order-9', 'public_id' => 'order_nine'], true);

expect($event->channelNames)->toBe(['order.order_one', 'order.order-1', 'driver.ghost-driver', 'customer.contact_one', 'company.company-1'])
->and(array_map(fn ($channel) => $channel->name, $event->broadcastOn()))->toBe($event->channelNames)
->and($event->data['changed_by'])->toBeNull()
->and(OrderChanged::notifyDriver($order, $event))->toBeFalse()
->and(OrderChanged::assignedDriver($noDriver))->toBeNull()
->and(OrderChanged::placeData(null))->toBeNull();
});

test('the api controllers begin an order edit for a payload and answer a locked edit with 422', function () {
$connection = fleetopsOrderOpsBoot();
$order = fleetopsOrderOpsOrder($connection);

$response = (new FleetOpsOrderOpsApiOrderControllerProbe())->locked(new OrderEditLockedException(['items' => 'Items cannot be changed once a driver has the order.']));

expect((new FleetOpsOrderOpsPayloadControllerProbe())->edit($order))->toBeInstanceOf(OrderEdit::class)
->and((new FleetOpsOrderOpsEntityControllerProbe())->editFor('payload-1'))->toBeInstanceOf(OrderEdit::class)
->and((new FleetOpsOrderOpsEntityControllerProbe())->editFor('payload-missing'))->toBeNull()
->and((new FleetOpsOrderOpsEntityControllerProbe())->editFor(null))->toBeNull()
->and($response->getStatusCode())->toBe(422)
->and($response->getData(true)['locked'])->toBe(['items' => 'Items cannot be changed once a driver has the order.']);
});
Loading
Loading