diff --git a/scripts/pest-bootstrap.php b/scripts/pest-bootstrap.php index 6ea5bf5ed..95b00ad3a 100644 --- a/scripts/pest-bootstrap.php +++ b/scripts/pest-bootstrap.php @@ -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]], ])); } diff --git a/server/src/Support/ApiCacheThrottle.php b/server/src/Support/ApiCacheThrottle.php index 65b571bf9..d98f542c1 100644 --- a/server/src/Support/ApiCacheThrottle.php +++ b/server/src/Support/ApiCacheThrottle.php @@ -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); diff --git a/server/tests/ApiOrderControllerContractsTest.php b/server/tests/ApiOrderControllerContractsTest.php index 5b87e94d8..228827ff1 100644 --- a/server/tests/ApiOrderControllerContractsTest.php +++ b/server/tests/ApiOrderControllerContractsTest.php @@ -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); @@ -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(); diff --git a/server/tests/Feature/Http/Api/CustomerControllerHelperSeamsTest.php b/server/tests/Feature/Http/Api/CustomerControllerHelperSeamsTest.php index bd034d946..d886630d2 100644 --- a/server/tests/Feature/Http/Api/CustomerControllerHelperSeamsTest.php +++ b/server/tests/Feature/Http/Api/CustomerControllerHelperSeamsTest.php @@ -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') diff --git a/server/tests/Feature/Http/Internal/OrderControllerHelpersTest.php b/server/tests/Feature/Http/Internal/OrderControllerHelpersTest.php index 1760fbc3f..f0b15b9a6 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerHelpersTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerHelpersTest.php @@ -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 () { diff --git a/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php b/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php index ae9005236..7fbd15f48 100644 --- a/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php +++ b/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php @@ -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 () { @@ -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); }); diff --git a/server/tests/Unit/Support/ApiCacheDependenciesTest.php b/server/tests/Unit/Support/ApiCacheDependenciesTest.php index d721343e0..94135bfca 100644 --- a/server/tests/Unit/Support/ApiCacheDependenciesTest.php +++ b/server/tests/Unit/Support/ApiCacheDependenciesTest.php @@ -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) diff --git a/server/tests/Unit/Support/OrderEditPolicyTest.php b/server/tests/Unit/Support/OrderEditPolicyTest.php index 8153865e0..802883f23 100644 --- a/server/tests/Unit/Support/OrderEditPolicyTest.php +++ b/server/tests/Unit/Support/OrderEditPolicyTest.php @@ -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(); +}); diff --git a/server/tests/Unit/Support/OrderOperationsTest.php b/server/tests/Unit/Support/OrderOperationsTest.php index 5ac197271..b84999e24 100644 --- a/server/tests/Unit/Support/OrderOperationsTest.php +++ b/server/tests/Unit/Support/OrderOperationsTest.php @@ -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; @@ -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 = []; @@ -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 { @@ -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 () { @@ -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'); @@ -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.']); +}); diff --git a/server/tests/Unit/Support/TrackingPublisherTest.php b/server/tests/Unit/Support/TrackingPublisherTest.php index b588b462a..1ef383f22 100644 --- a/server/tests/Unit/Support/TrackingPublisherTest.php +++ b/server/tests/Unit/Support/TrackingPublisherTest.php @@ -267,12 +267,16 @@ function trackingPublisherUseStops(Collection $stops, array|Throwable $tracker = $unsaved = trackingPublisherOrder(['uuid' => null]); $unsaved->status = 'started'; $unsaved->syncChanges(); - expect($publisher->orderChanged($unsaved))->toBeFalse(); + expect($publisher->orderChanged($unsaved))->toBeFalse() + // an edited route queues its own update; an unsaved order has nothing to publish + ->and($publisher->routeChanged($order))->toBeTrue() + ->and($publisher->routeChanged($unsaved))->toBeFalse(); expect(array_map(fn ($dispatch) => [$dispatch['job'], ...$dispatch['arguments']], DispatchRecorder::$dispatched))->toBe([ [PublishTrackingUpdate::class, 'order-started', 'eta', 'mysql'], [PublishTrackingUpdate::class, 'order-started', 'assignment', 'mysql'], [PublishTrackingUpdate::class, 'order-started', 'status', 'mysql'], + [PublishTrackingUpdate::class, 'order-started', 'route', 'mysql'], ]); });