diff --git a/addon/controllers/operations/scheduler/index.js b/addon/controllers/operations/scheduler/index.js index 1e6ff2bfe..ba91409d8 100644 --- a/addon/controllers/operations/scheduler/index.js +++ b/addon/controllers/operations/scheduler/index.js @@ -4,6 +4,7 @@ import { inject as service } from '@ember/service'; import { action, computed } from '@ember/object'; import { isNone } from '@ember/utils'; import { isValid as isValidDate } from 'date-fns'; +import { later } from '@ember/runloop'; import { task } from 'ember-concurrency'; import isObject from '@fleetbase/ember-core/utils/is-object'; import isJson from '@fleetbase/ember-core/utils/is-json'; @@ -11,6 +12,9 @@ import createFullCalendarEventFromOrder from '../../../utils/create-full-calenda import createFullCalendarEventFromScheduleItem from '../../../utils/create-full-calendar-event-from-schedule-item'; import toCalendarDate from '../../../utils/to-calendar-date'; +// The ember-core socket service subscribes ~300 ms after listen() is called. +const SOCKET_SUBSCRIBE_SETTLE_MS = 500; + /** * OperationsSchedulerIndexController * @@ -722,28 +726,55 @@ export default class OperationsSchedulerIndexController extends Controller { // Real-Time Socket Subscriptions // ------------------------------------------------------------------------- + // Names of the socket channels this board opened, so teardown closes only those + // and leaves every other subscription (chat, notifications, ...) alone. + _socketChannelNames = new Set(); + @action async subscribeToRealTimeUpdates() { - const orgId = this.currentUser?.companyId ?? this.currentUser?.company?.id; - if (!orgId) return; - await this.socket.listen(`company.${orgId}.orders`, (payload) => this._handleOrderSocketEvent(payload)); - this.drivers.forEach(async (driver) => { - await this.socket.listen(`driver.${driver.id}`, (payload) => this._handleDriverSocketEvent(payload)); - }); + const drivers = this.drivers ?? []; + await Promise.all( + drivers.map((driver) => { + if (!driver?.id) return; + const channelName = `driver.${driver.id}`; + if (this._socketChannelNames.has(channelName)) return; + this._socketChannelNames.add(channelName); + return this.socket.listen(channelName, (payload) => this._handleDriverSocketEvent(payload)); + }) + ); } @action unsubscribeFromRealTimeUpdates() { - if (this.socket && typeof this.socket.closeChannels === 'function') { - this.socket.closeChannels(); + const names = this._socketChannelNames; + this._socketChannelNames = new Set(); + if (!this.socket || names.size === 0) return; + + if (typeof this.socket.closeChannel === 'function') { + names.forEach((name) => this.socket.closeChannel(name)); + return; } + + const remaining = this._closeOpenedChannels(names); + if (remaining.size === 0) return; + + // The socket service subscribes after a short delay, so a channel requested just before + // teardown may not exist yet: sweep once more after that delay. + later(this, () => this._closeOpenedChannels(remaining), SOCKET_SUBSCRIBE_SETTLE_MS); } - _handleOrderSocketEvent({ data } = {}) { - if (!data?.id) return; - try { - this.store.pushPayload('order', { order: data }); - } catch { - /* ignore */ + /** + * Close the socket's channels with the given names, except any the board has opened again + * since. Returns the names that had no channel yet. + */ + _closeOpenedChannels(names) { + const remaining = new Set([...names].filter((name) => !this._socketChannelNames.has(name))); + const channels = Array.isArray(this.socket.channels) ? this.socket.channels : []; + for (const channel of channels) { + if (!names.has(channel?.name) || this._socketChannelNames.has(channel.name)) continue; + remaining.delete(channel.name); + channel.close(); } + + return remaining; } _handleDriverSocketEvent({ event, data } = {}) { diff --git a/addon/routes/operations/scheduler/index.js b/addon/routes/operations/scheduler/index.js index 8f8f1d7d4..5f3f76d4f 100644 --- a/addon/routes/operations/scheduler/index.js +++ b/addon/routes/operations/scheduler/index.js @@ -61,7 +61,7 @@ export default class OperationsSchedulerIndexRoute extends Route { } resetController(controller) { - // Close all socket channels when the dispatcher navigates away + // Close the board's own socket channels when the dispatcher navigates away // to prevent memory leaks and stale event handlers. controller.unsubscribeFromRealTimeUpdates(); } diff --git a/composer.json b/composer.json index b7a38393e..aada759a1 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "barryvdh/laravel-dompdf": "^3.1", "brick/geo": "0.7.2", "cknow/laravel-money": "^7.1", - "fleetbase/core-api": ">=1.6.65", + "fleetbase/core-api": "^1.6.69", "geocoder-php/google-maps-places-provider": "^1.4", "giggsey/libphonenumber-for-php": "^8.13", "league/geotools": "^1.1.0", diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php index 1fbacbe1d..82d0d6b16 100644 --- a/server/src/Http/Controllers/Api/v1/DriverController.php +++ b/server/src/Http/Controllers/Api/v1/DriverController.php @@ -24,6 +24,7 @@ use Fleetbase\FleetOps\Support\GeofenceIntersectionService; use Fleetbase\FleetOps\Support\OSRM; use Fleetbase\FleetOps\Support\ProfileAccountManager; +use Fleetbase\FleetOps\Support\TrackingPublisher; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Http\Requests\SwitchOrganizationRequest; @@ -374,6 +375,7 @@ public function track(string $id, Request $request) } broadcast(new DriverLocationChanged($driver)); + app(TrackingPublisher::class)->driverMoved($driver); // ---------------------------------------------------------------- // Geofence intersection detection diff --git a/server/src/Http/Controllers/Api/v1/VehicleController.php b/server/src/Http/Controllers/Api/v1/VehicleController.php index 04f293d1a..099d1b9ab 100644 --- a/server/src/Http/Controllers/Api/v1/VehicleController.php +++ b/server/src/Http/Controllers/Api/v1/VehicleController.php @@ -18,6 +18,7 @@ use Fleetbase\FleetOps\Models\Vendor; use Fleetbase\FleetOps\Models\Warranty; use Fleetbase\FleetOps\Support\GeofenceIntersectionService; +use Fleetbase\FleetOps\Support\TrackingPublisher; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; use Fleetbase\LaravelMysqlSpatial\Types\Point; @@ -298,6 +299,7 @@ public function track(string $id, Request $request) $vehicle->createPosition($positionData); broadcast(new VehicleLocationChanged($vehicle)); + app(TrackingPublisher::class)->vehicleMoved($vehicle); try { $newLocation = new Point($latitude, $longitude); diff --git a/server/src/Http/Controllers/Internal/v1/PositionController.php b/server/src/Http/Controllers/Internal/v1/PositionController.php index df12743e9..0d6865e9c 100644 --- a/server/src/Http/Controllers/Internal/v1/PositionController.php +++ b/server/src/Http/Controllers/Internal/v1/PositionController.php @@ -6,6 +6,10 @@ use Fleetbase\FleetOps\Jobs\ReplayPositions; use Fleetbase\FleetOps\Models\Position; use Fleetbase\FleetOps\Support\Utils; +use Fleetbase\Models\User; +use Fleetbase\Support\SocketCluster\ChannelAuthorizer; +use Fleetbase\Support\SocketCluster\SocketPrincipal; +use Fleetbase\Support\SocketCluster\SocketToken; use Illuminate\Http\Request; class PositionController extends FleetOpsController @@ -37,6 +41,11 @@ public function replay(Request $request) return response()->error('Position IDs are required'); } + // With socket authentication on, the replay may only publish to a channel the user could subscribe to. + if (SocketToken::enabled() && !$this->canReplayTo($request, $channelId)) { + return response()->error('You are not allowed to replay positions to this channel.', 403); + } + $positions = Position::whereIn('uuid', $positionIds) ->where('company_uuid', session('company')) ->orderBy('created_at') @@ -57,6 +66,19 @@ public function replay(Request $request) ]); } + /** + * Whether the requesting user may subscribe to, and so receive a replay on, the channel. + */ + protected function canReplayTo(Request $request, mixed $channelId): bool + { + $user = $request->user(); + if (!$user instanceof User || !is_string($channelId) || $channelId === '' || strlen($channelId) > 255 || preg_match('/\s/', $channelId)) { + return false; + } + + return app(ChannelAuthorizer::class)->authorize(SocketPrincipal::forUser($user, session('company')), $channelId)->allow; + } + /** * Get position statistics/metrics. * diff --git a/server/src/Jobs/PublishTrackingUpdate.php b/server/src/Jobs/PublishTrackingUpdate.php new file mode 100644 index 000000000..63dba3e4c --- /dev/null +++ b/server/src/Jobs/PublishTrackingUpdate.php @@ -0,0 +1,50 @@ +dbConnection)->where('uuid', $this->orderUuid)->first(); + if (!$order) { + return 0; + } + + if ($this->reason !== TrackingUpdate::REASON_LOCATION) { + return $publisher->publishOrder($order, $this->reason); + } + + if (Cache::add('fleetops:tracking:eta:' . $order->uuid, 1, TrackingPublisher::ETA_INTERVAL)) { + return $publisher->publishOrder($order, TrackingUpdate::REASON_ETA); + } + + return $publisher->publishLocation($order); + } +} diff --git a/server/src/Listeners/PublishTrackingUpdates.php b/server/src/Listeners/PublishTrackingUpdates.php new file mode 100644 index 000000000..20a4b1e81 --- /dev/null +++ b/server/src/Listeners/PublishTrackingUpdates.php @@ -0,0 +1,27 @@ +publisher ??= app(TrackingPublisher::class); + } + + /** + * @param \Fleetbase\FleetOps\Events\WaypointActivityChanged|\Fleetbase\FleetOps\Events\WaypointCompleted|\Fleetbase\FleetOps\Events\EntityActivityChanged|\Fleetbase\FleetOps\Events\EntityCompleted $event + */ + public function handle(object $event): bool + { + return $this->publisher->stopChanged($event->waypoint ?? $event->entity ?? null); + } +} diff --git a/server/src/Observers/OrderObserver.php b/server/src/Observers/OrderObserver.php index 0bd432c4e..83c8f4ead 100644 --- a/server/src/Observers/OrderObserver.php +++ b/server/src/Observers/OrderObserver.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Support\LiveCacheService; +use Fleetbase\FleetOps\Support\TrackingPublisher; use Illuminate\Support\Facades\Cache; class OrderObserver @@ -46,6 +47,9 @@ public function updated(Order $order) } $this->invalidateCache($order); + + // Public tracking channels follow status, assignment and ETA changes. + app(TrackingPublisher::class)->orderChanged($order); } /** diff --git a/server/src/Providers/EventServiceProvider.php b/server/src/Providers/EventServiceProvider.php index 83c19771c..9afc23ac4 100644 --- a/server/src/Providers/EventServiceProvider.php +++ b/server/src/Providers/EventServiceProvider.php @@ -23,6 +23,17 @@ class EventServiceProvider extends ServiceProvider \Fleetbase\FleetOps\Events\OrderFailed::class => [\Fleetbase\Listeners\SendResourceLifecycleWebhook::class, \Fleetbase\FleetOps\Listeners\NotifyOrderEvent::class], \Fleetbase\FleetOps\Events\OrderReady::class => [\Fleetbase\FleetOps\Listeners\HandleOrderReady::class], + /* + * Stop Events + * + * Public tracking channels follow waypoint and entity activity. Order status, assignment + * and ETA changes are published from the order observer; positions from the tracking endpoints. + */ + \Fleetbase\FleetOps\Events\WaypointActivityChanged::class => [\Fleetbase\FleetOps\Listeners\PublishTrackingUpdates::class], + \Fleetbase\FleetOps\Events\WaypointCompleted::class => [\Fleetbase\FleetOps\Listeners\PublishTrackingUpdates::class], + \Fleetbase\FleetOps\Events\EntityActivityChanged::class => [\Fleetbase\FleetOps\Listeners\PublishTrackingUpdates::class], + \Fleetbase\FleetOps\Events\EntityCompleted::class => [\Fleetbase\FleetOps\Listeners\PublishTrackingUpdates::class], + /* * Geofence Events * diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index bb91d464a..32ddd9bdc 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -159,6 +159,7 @@ public function boot() }); $this->registerNotifications(); $this->registerAiCapabilities(); + $this->registerSocketChannels(); $this->registerExpansionsFrom(__DIR__ . '/../Expansions'); // Register built-in orchestration engines. @@ -265,6 +266,16 @@ public function registerNotifications() ]); } + /** + * Register who may subscribe to FleetOps realtime channels, and the driver socket principal. + */ + protected function registerSocketChannels(): void + { + $this->callAfterResolving(\Fleetbase\Support\SocketCluster\SocketChannelRegistry::class, function (\Fleetbase\Support\SocketCluster\SocketChannelRegistry $registry) { + \Fleetbase\FleetOps\Support\SocketChannels::register($registry); + }); + } + protected function registerAiCapabilities(): void { if (!Utils::classExists(\Fleetbase\Ai\Support\AiCapabilityRegistry::class)) { diff --git a/server/src/Support/SocketChannels.php b/server/src/Support/SocketChannels.php new file mode 100644 index 000000000..84c7590c2 --- /dev/null +++ b/server/src/Support/SocketChannels.php @@ -0,0 +1,346 @@ + Order::class, + 'driver' => Driver::class, + 'driverAssigned' => Driver::class, + 'vehicle' => Vehicle::class, + 'trailer' => Trailer::class, + 'device' => Device::class, + 'waypoint' => Waypoint::class, + 'entity' => Entity::class, + 'place' => Place::class, + 'contact' => Contact::class, + 'fleet' => Fleet::class, + 'zone' => Zone::class, + 'service_area' => ServiceArea::class, + 'service_rate' => ServiceRate::class, + 'service_quote' => ServiceQuote::class, + 'purchase_rate' => PurchaseRate::class, + 'tracking_status' => TrackingStatus::class, + 'tracking_number' => TrackingNumber::class, + 'payload' => Payload::class, + ]; + + /** + * Prefixes whose id may name more than one kind of record, with the models tried in order. + */ + public const MORPH_PREFIXES = [ + 'customer' => [Contact::class, Vendor::class], + 'vendor' => [Vendor::class, IntegratedVendor::class], + 'facilitator' => [Vendor::class, IntegratedVendor::class, Contact::class], + ]; + + /** + * Register every FleetOps prefix and the driver principal resolver. + */ + public static function register(SocketChannelRegistry $registry): void + { + foreach (self::MODEL_PREFIXES as $prefix => $modelClass) { + $registry->registerModel($prefix, $modelClass, static::narrowFor($prefix)); + } + + foreach (self::MORPH_PREFIXES as $prefix => $modelClasses) { + $registry->register($prefix, static::resolver($modelClasses, [static::class, 'narrowSelf'])); + } + + // Positions have no public_id, so they are looked up by uuid only. + $registry->register('position', static::resolver([Position::class], [static::class, 'narrowPosition'], false)); + + // tracking.{opaque} is reachable only through a tracking token's exact scope. + $registry->register(TrackingChannel::PREFIX, static fn (): bool => false); + + $registry->registerPrincipalResolver([static::class, 'driverPrincipal']); + } + + /** + * The narrow rule used for driver and customer principals on a generic model prefix. + */ + public static function narrowFor(string $prefix): ?callable + { + return match ($prefix) { + 'order' => [static::class, 'narrowOrder'], + 'driver', 'driverAssigned' => [static::class, 'narrowDriver'], + 'vehicle' => [static::class, 'narrowVehicle'], + 'device' => [static::class, 'narrowDevice'], + 'contact' => [static::class, 'narrowSelf'], + default => null, + }; + } + + /** + * A resolver over one or more models: company match for user and API principals, the narrow + * rule for driver and customer principals, deny for everyone else. + * + * @param array> $modelClasses + */ + public static function resolver(array $modelClasses, ?callable $narrow = null, bool $byPublicId = true): \Closure + { + return static function (SocketPrincipal $principal, string $id, string $channel = '') use ($modelClasses, $narrow, $byPublicId): bool { + $narrowed = $principal->kind === 'driver' || $principal->kind === 'customer'; + if ((!$principal->isCompanyScoped() && !$narrowed) || $id === '') { + return false; + } + + $model = static::find($modelClasses, $principal, $id, $byPublicId); + if ($model === null) { + return false; + } + + if ($principal->isCompanyScoped()) { + return $principal->cid !== null && $model->company_uuid === $principal->cid; + } + + return $narrow !== null && (bool) $narrow($principal, $model); + }; + } + + /** + * The first of the models whose uuid (or public_id) is the id, in the principal's environment. + * + * @param array> $modelClasses + */ + public static function find(array $modelClasses, SocketPrincipal $principal, string $id, bool $byPublicId = true): ?Model + { + foreach ($modelClasses as $modelClass) { + $model = $modelClass::on(static::connection($principal)) + ->where(function ($query) use ($id, $byPublicId) { + $query->where('uuid', $id); + if ($byPublicId) { + $query->orWhere('public_id', $id); + } + }) + ->first(); + + if ($model) { + return $model; + } + } + + return null; + } + + /** + * The database connection for the principal's environment; null is the default one. + */ + public static function connection(SocketPrincipal $principal): ?string + { + return $principal->env === 'test' ? 'sandbox' : null; + } + + /** + * Orders: a driver's when assigned to them (directly or on one of its payload entities), a customer's own. + */ + public static function narrowOrder(SocketPrincipal $principal, Model $order): bool + { + if ($principal->kind === 'customer') { + return static::isSelf($principal, $order->customer_uuid); + } + + if ($principal->kind !== 'driver') { + return false; + } + + if (static::isSelf($principal, $order->driver_assigned_uuid)) { + return true; + } + + return !empty($order->payload_uuid) + && Entity::on($order->getConnectionName()) + ->where('payload_uuid', $order->payload_uuid) + ->where('driver_assigned_uuid', $principal->sub) + ->exists(); + } + + /** + * Drivers: a driver only themselves; a customer the driver of one of their active orders. + */ + public static function narrowDriver(SocketPrincipal $principal, Model $driver): bool + { + if ($principal->kind === 'driver') { + return static::isSelf($principal, $driver->uuid); + } + + return $principal->kind === 'customer' + && static::activeCustomerOrders($principal, $driver->getConnectionName()) + ->where('driver_assigned_uuid', $driver->uuid) + ->exists(); + } + + /** + * Vehicles: a driver their current vehicle; a customer the vehicle of one of their active orders, + * whether assigned to the order or driven by the order's driver. + */ + public static function narrowVehicle(SocketPrincipal $principal, Model $vehicle): bool + { + if ($principal->kind === 'driver') { + return static::driverVehicleUuid($principal, $vehicle->getConnectionName()) === $vehicle->uuid; + } + + if ($principal->kind !== 'customer') { + return false; + } + + $connection = $vehicle->getConnectionName(); + $drivers = Driver::on($connection)->where('vehicle_uuid', $vehicle->uuid)->select('uuid')->toBase(); + + return static::activeCustomerOrders($principal, $connection) + ->where(function ($query) use ($vehicle, $drivers) { + $query->where('vehicle_assigned_uuid', $vehicle->uuid)->orWhereIn('driver_assigned_uuid', $drivers); + }) + ->exists(); + } + + /** + * Devices: a driver those attached to themselves or to their current vehicle. + */ + public static function narrowDevice(SocketPrincipal $principal, Model $device): bool + { + return $principal->kind === 'driver' && static::ownsDriverSubject($principal, $device->attachable_uuid, $device->getConnectionName()); + } + + /** + * Positions: a driver those recorded for themselves or for their current vehicle. + */ + public static function narrowPosition(SocketPrincipal $principal, Model $position): bool + { + return $principal->kind === 'driver' && static::ownsDriverSubject($principal, $position->subject_uuid, $position->getConnectionName()); + } + + /** + * Contacts, customers, vendors, facilitators: a customer only themselves. + */ + public static function narrowSelf(SocketPrincipal $principal, Model $model): bool + { + return $principal->kind === 'customer' && static::isSelf($principal, $model->uuid); + } + + /** + * Claim a Sanctum-authenticated user who is a driver of the current company as a driver principal. + */ + public static function driverPrincipal(Request $request, $user): ?SocketPrincipal + { + if (!$user instanceof User || !$user->uuid) { + return null; + } + + $companyUuid = session('company') ?: $user->company_uuid; + if (!$companyUuid) { + return null; + } + + $driver = Driver::where('user_uuid', $user->uuid)->where('company_uuid', $companyUuid)->first(); + if (!$driver) { + return null; + } + + $connection = $driver->getConnectionName(); + $cpid = Company::on($connection)->where('uuid', $companyUuid)->value('public_id'); + + return new SocketPrincipal( + kind: 'driver', + sub: (string) $driver->uuid, + cid: (string) $companyUuid, + cpid: is_string($cpid) && $cpid !== '' ? $cpid : null, + env: $connection === 'sandbox' ? 'test' : 'live', + ids: array_values(array_filter([$driver->uuid, $driver->public_id, $user->uuid, $user->public_id], fn ($id) => is_string($id) && $id !== '')), + adm: false, + ); + } + + /** + * The customer's orders that are not completed or canceled. + */ + protected static function activeCustomerOrders(SocketPrincipal $principal, ?string $connection): Builder + { + return Order::on($connection) + ->whereIn('customer_uuid', array_values(array_unique([$principal->sub, ...$principal->ids]))) + ->whereNotIn('status', self::TERMINAL_ORDER_STATUSES); + } + + /** + * Whether a record's subject is the driver principal or the driver's current vehicle. + */ + protected static function ownsDriverSubject(SocketPrincipal $principal, ?string $subjectUuid, ?string $connection): bool + { + if (!$subjectUuid) { + return false; + } + + return static::isSelf($principal, $subjectUuid) || static::driverVehicleUuid($principal, $connection) === $subjectUuid; + } + + /** + * Whether an id is the principal's own (its subject or one of its ids). + */ + protected static function isSelf(SocketPrincipal $principal, mixed $id): bool + { + return is_string($id) && $id !== '' && ($id === $principal->sub || $principal->owns($id)); + } + + protected static function driverVehicleUuid(SocketPrincipal $principal, ?string $connection): ?string + { + $vehicleUuid = Driver::on($connection)->where('uuid', $principal->sub)->value('vehicle_uuid'); + + return is_string($vehicleUuid) && $vehicleUuid !== '' ? $vehicleUuid : null; + } +} diff --git a/server/src/Support/Telematics/TelematicService.php b/server/src/Support/Telematics/TelematicService.php index b1541f88d..2f7f10ce1 100644 --- a/server/src/Support/Telematics/TelematicService.php +++ b/server/src/Support/Telematics/TelematicService.php @@ -13,6 +13,7 @@ use Fleetbase\FleetOps\Models\Telematic; use Fleetbase\FleetOps\Models\Trailer; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Support\TrackingPublisher; use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Crypt; @@ -811,6 +812,7 @@ protected function updateVehicleTelemetry(Vehicle $vehicle, array $location, arr 'position_at' => $event->occurred_at?->toISOString(), ]); $this->broadcastTelemetry($broadcast, $eventData['_ordered_telemetry'] ?? false); + app(TrackingPublisher::class)->vehicleMoved($vehicle); } protected function broadcastTelemetry(object $event, bool $afterCommit): void diff --git a/server/src/Support/TrackingChannel.php b/server/src/Support/TrackingChannel.php new file mode 100644 index 000000000..0298c38ac --- /dev/null +++ b/server/src/Support/TrackingChannel.php @@ -0,0 +1,55 @@ +order_uuid, $scope->customer_type, $scope->customer_uuid); + } + + /** + * Mint the token (kind `tracking`, 1800 s) that may subscribe to this scope's channel and nothing else. + * + * The caller (the public tracking API) checks its own grant for the scope first. + * + * @return array{token: string, expires_in: int, expires_at: string} + * + * @throws \RuntimeException when socket authentication is not configured + */ + public static function token(TrackingScope $scope): array + { + return SocketToken::forTracking($scope); + } +} diff --git a/server/src/Support/TrackingPublisher.php b/server/src/Support/TrackingPublisher.php new file mode 100644 index 000000000..5a9ab4679 --- /dev/null +++ b/server/src/Support/TrackingPublisher.php @@ -0,0 +1,249 @@ + TrackingUpdate::REASON_STATUS, + 'started' => TrackingUpdate::REASON_STATUS, + 'dispatched' => TrackingUpdate::REASON_STATUS, + 'driver_assigned_uuid' => TrackingUpdate::REASON_ASSIGNMENT, + 'vehicle_assigned_uuid' => TrackingUpdate::REASON_ASSIGNMENT, + 'time' => TrackingUpdate::REASON_ETA, + 'distance' => TrackingUpdate::REASON_ETA, + ]; + + public static function enabled(): bool + { + return SocketToken::enabled(); + } + + /** + * An order was saved: queue an update when a published attribute changed. + */ + public function orderChanged(Order $order): bool + { + if (!static::enabled() || !$order->uuid) { + return false; + } + + $changed = array_intersect_key(self::ORDER_ATTRIBUTES, $order->getChanges()); + if ($changed === []) { + return false; + } + + $reasons = array_values($changed); + $reason = in_array(TrackingUpdate::REASON_STATUS, $reasons, true) ? TrackingUpdate::REASON_STATUS : $reasons[0]; + + return $this->queue($order->uuid, $reason, $order->getConnectionName()); + } + + /** + * A waypoint or entity activity changed: queue an update for its order. + */ + public function stopChanged(Waypoint|Entity|null $subject): bool + { + if (!static::enabled() || !$subject || !$subject->payload_uuid) { + return false; + } + + $connection = $subject->getConnectionName(); + $orderUuid = Order::on($connection)->where('payload_uuid', $subject->payload_uuid)->value('uuid'); + + return $orderUuid ? $this->queue($orderUuid, TrackingUpdate::REASON_STOP, $connection) : false; + } + + /** + * A driver reported a position: queue a location update for each of their en-route orders. + */ + public function driverMoved(Driver $driver): int + { + if (!static::enabled() || !$driver->uuid || !Cache::add('fleetops:tracking:driver:' . $driver->uuid, 1, self::LOCATION_INTERVAL)) { + return 0; + } + + $connection = $driver->getConnectionName(); + + return $this->queueLocations($this->enRouteOrders($connection)->where('driver_assigned_uuid', $driver->uuid)->limit(self::MAX_ORDERS_PER_SUBJECT)->pluck('uuid'), $connection); + } + + /** + * A vehicle reported a position: queue a location update for each en-route order it serves. + */ + public function vehicleMoved(Vehicle $vehicle): int + { + if (!static::enabled() || !$vehicle->uuid || !Cache::add('fleetops:tracking:vehicle:' . $vehicle->uuid, 1, self::LOCATION_INTERVAL)) { + return 0; + } + + $connection = $vehicle->getConnectionName(); + $drivers = Driver::on($connection)->where('vehicle_uuid', $vehicle->uuid)->select('uuid')->toBase(); + $orders = $this->enRouteOrders($connection) + ->where(function ($query) use ($vehicle, $drivers) { + $query->where('vehicle_assigned_uuid', $vehicle->uuid)->orWhereIn('driver_assigned_uuid', $drivers); + }) + ->limit(self::MAX_ORDERS_PER_SUBJECT) + ->pluck('uuid'); + + return $this->queueLocations($orders, $connection); + } + + /** + * Publish the full update to every customer of the order. Returns the channels published to. + */ + public function publishOrder(Order $order, string $reason = TrackingUpdate::REASON_STATUS): int + { + if (!static::enabled()) { + return 0; + } + + $stops = $this->stopsFor($order); + $tracker = $this->trackerFor($order); + $sent = 0; + + foreach (TrackingScope::allForOrder($order, $this->waypointsOf($stops)) as $scope) { + $update = TrackingUpdate::for($order, $scope, $stops, $tracker); + $sent += (int) $this->send(TrackingChannel::name($scope), $update->toArray($reason)); + } + + return $sent; + } + + /** + * Publish the position to every customer of the order who may see it. Returns the channels published to. + */ + public function publishLocation(Order $order): int + { + if (!static::enabled()) { + return 0; + } + + $stops = $this->stopsFor($order); + $sent = 0; + + foreach (TrackingScope::allForOrder($order, $this->waypointsOf($stops)) as $scope) { + $payload = (new TrackingUpdate($order, $scope, $stops))->locationPayload(); + if ($payload !== null) { + $sent += (int) $this->send(TrackingChannel::name($scope), $payload); + } + } + + return $sent; + } + + /** + * Every stop of the order in route order, with completion state. + * + * @return Collection + */ + protected function stopsFor(Order $order): Collection + { + return app(TrackingContextBuilder::class)->build($order, TrackingOptions::fromArray([], $order->company_uuid))->stops; + } + + /** + * The tracking intelligence result for the order's ETAs; empty when it cannot be computed. + */ + protected function trackerFor(Order $order): array + { + if (in_array(strtolower((string) $order->status), TrackingUpdate::TERMINAL_STATUSES, true)) { + return []; + } + + try { + return app(TrackingIntelligenceService::class)->track($order); + } catch (\Throwable) { + return []; + } + } + + protected function send(string $channel, array $payload): bool + { + return SocketClusterService::publish($channel, $payload); + } + + protected function queue(string $orderUuid, string $reason, ?string $connection = null): bool + { + PublishTrackingUpdate::dispatch($orderUuid, $reason, $connection); + + return true; + } + + /** + * Queue a location update for each order not updated within the location interval. + */ + protected function queueLocations(iterable $orderUuids, ?string $connection): int + { + $queued = 0; + foreach ($orderUuids as $orderUuid) { + if (Cache::add('fleetops:tracking:location:' . $orderUuid, 1, self::LOCATION_INTERVAL)) { + $queued += (int) $this->queue($orderUuid, TrackingUpdate::REASON_LOCATION, $connection); + } + } + + return $queued; + } + + /** + * Orders that have started and are not yet completed or canceled. + */ + protected function enRouteOrders(?string $connection) + { + return Order::on($connection) + ->whereNotIn('status', TrackingUpdate::TERMINAL_STATUSES) + ->where(function ($query) { + $query->where('started', 1)->orWhereNotNull('started_at')->orWhere('status', 'started'); + }); + } + + /** + * @param Collection $stops + * + * @return array + */ + protected function waypointsOf(Collection $stops): array + { + return $stops->map(fn (TrackingStop $stop) => $stop->waypoint)->filter()->values()->all(); + } +} diff --git a/server/src/Support/TrackingScope.php b/server/src/Support/TrackingScope.php new file mode 100644 index 000000000..9259c4153 --- /dev/null +++ b/server/src/Support/TrackingScope.php @@ -0,0 +1,182 @@ +order_uuid = $orderUuid; + $this->customer_type = $customerType; + $this->customer_uuid = $customerUuid; + $this->order = $order && $order->uuid === $orderUuid ? $order : null; + $this->company_uuid = $this->order?->company_uuid ?: null; + } + + /** + * The scope of the order's own customer, or null when the order has none. + */ + public static function forOrder(Order $order): ?self + { + return static::make($order, $order->customer_type, $order->customer_uuid); + } + + /** + * The scope a stop belongs to: the waypoint's customer, else the order's customer. + */ + public static function forStop(Order $order, ?Waypoint $waypoint = null): ?self + { + return static::make($order, $waypoint?->customer_type, $waypoint?->customer_uuid) ?? static::forOrder($order); + } + + /** + * The scope of an explicit customer on an order. + */ + public static function forCustomer(Order $order, Model $customer): self + { + return new self((string) $order->uuid, $customer->getMorphClass(), (string) $customer->uuid, $order); + } + + /** + * Every customer scope of an order: the order's customer first, then each distinct waypoint customer. + * + * @param iterable|null $waypoints the order's waypoints; loaded from the payload when null + * + * @return array + */ + public static function allForOrder(Order $order, ?iterable $waypoints = null): array + { + if ($waypoints === null) { + $waypoints = $order->payload_uuid ? Waypoint::on($order->getConnectionName())->where('payload_uuid', $order->payload_uuid)->without(['place'])->get(['uuid', 'payload_uuid', 'customer_uuid', 'customer_type']) : []; + } + + $scopes = []; + if ($scope = static::forOrder($order)) { + $scopes[$scope->key()] = $scope; + } + + foreach ($waypoints as $waypoint) { + $scope = $waypoint instanceof Waypoint ? static::make($order, $waypoint->customer_type, $waypoint->customer_uuid) : null; + if ($scope && !isset($scopes[$scope->key()])) { + $scopes[$scope->key()] = $scope; + } + } + + return array_values($scopes); + } + + /** + * The message the channel id is derived from: "{order_uuid}:{customer_type}:{customer_uuid}". + */ + public function key(): string + { + return $this->order_uuid . ':' . $this->customer_type . ':' . $this->customer_uuid; + } + + /** + * Whether this scope is the given customer (type and uuid). + */ + public function is(?string $customerType, ?string $customerUuid): bool + { + return $customerUuid !== null + && $customerUuid !== '' + && $customerUuid === $this->customer_uuid + && static::normalizeType((string) $customerType) === $this->customer_type; + } + + /** + * Whether this scope is the order's own customer. + */ + public function isOrderCustomer(Order $order): bool + { + return $order->uuid === $this->order_uuid && $this->is($order->customer_type, $order->customer_uuid); + } + + /** + * Whether a stop belongs to this scope: its waypoint's customer, else the order's customer. + */ + public function ownsStop(Order $order, ?Waypoint $waypoint = null): bool + { + if ($waypoint && $waypoint->customer_uuid && $waypoint->customer_type) { + return $this->is($waypoint->customer_type, $waypoint->customer_uuid); + } + + return $this->isOrderCustomer($order); + } + + /** + * The order this scope is about, loaded once when not given. + */ + public function order(): ?Order + { + return $this->order ??= Order::where('uuid', $this->order_uuid)->first(); + } + + public function toArray(): array + { + return [ + 'order_uuid' => $this->order_uuid, + 'customer_type' => $this->customer_type, + 'customer_uuid' => $this->customer_uuid, + ]; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * A morph type as a model class without a leading backslash; morph map aliases resolve to their class. + */ + public static function normalizeType(string $type): string + { + $type = ltrim(trim($type), '\\'); + + return ltrim((string) (Relation::getMorphedModel($type) ?? $type), '\\'); + } + + private static function make(Order $order, ?string $customerType, ?string $customerUuid): ?self + { + if (!$order->uuid || !$customerType || !$customerUuid) { + return null; + } + + return new self((string) $order->uuid, $customerType, $customerUuid, $order); + } +} diff --git a/server/src/Support/TrackingUpdate.php b/server/src/Support/TrackingUpdate.php new file mode 100644 index 000000000..fdcdffc6a --- /dev/null +++ b/server/src/Support/TrackingUpdate.php @@ -0,0 +1,400 @@ + + */ + private Collection $stops; + + /** + * @param iterable $stops every stop of the order, in route order + * @param array $etas ETA per stop sequence: seconds, at, distance_m + * @param array> $timelines timeline entries per tracking number uuid + */ + public function __construct( + private Order $order, + private TrackingScope $scope, + iterable $stops = [], + private array $etas = [], + private array $timelines = [], + ) { + $this->stops = collect($stops)->filter(fn ($stop) => $stop instanceof TrackingStop)->values(); + } + + /** + * Build the update for a scope from the order's stops and, optionally, a tracking intelligence result. + * + * @param iterable $stops + * @param array $tracker a TrackingIntelligenceService::track() result; its route legs give the ETAs + */ + public static function for(Order $order, TrackingScope $scope, iterable $stops, array $tracker = []): self + { + $update = new self($order, $scope, $stops, static::etasFromTracker($tracker)); + + $update->timelines = static::loadTimelines($update->timelineTrackingNumbers(), $order->getConnectionName()); + + return $update; + } + + /** + * The full update: order state, route counts, this customer's stops, driver and position. + */ + public function toArray(string $reason = self::REASON_STATUS): array + { + return $this->envelope(self::EVENT_UPDATED, $reason, [ + 'order' => $this->orderState(), + 'route' => $this->route(), + 'stops' => $this->ownStops()->map(fn (TrackingStop $stop) => $this->stop($stop))->values()->all(), + 'driver' => $this->driver(), + 'location' => $this->location(), + ]); + } + + /** + * The position-only update, or null when this customer may not see the vehicle right now. + */ + public function locationPayload(): ?array + { + $location = $this->location(); + if ($location === null) { + return null; + } + + return $this->envelope(self::EVENT_LOCATION, self::REASON_LOCATION, [ + 'driver' => $this->driver(), + 'location' => $location, + ]); + } + + /** + * This customer's stops. + * + * @return Collection + */ + public function ownStops(): Collection + { + return $this->stops->filter(fn (TrackingStop $stop) => $this->scope->ownsStop($this->order, $stop->waypoint))->values(); + } + + public function isTerminal(): bool + { + return in_array(strtolower((string) $this->order->status), self::TERMINAL_STATUSES, true); + } + + /** + * Whether the order is under way: started and not yet completed or canceled. + */ + public function isEnRoute(): bool + { + if ($this->isTerminal()) { + return false; + } + + return (bool) $this->order->started || !empty($this->order->started_at) || strtolower((string) $this->order->status) === 'started'; + } + + public function customerHasIncompleteStop(): bool + { + return $this->ownStops()->contains(fn (TrackingStop $stop) => !$stop->completed); + } + + /** + * Whether the driver and the vehicle position may be shown to this customer now. + */ + public function showsVehicle(): bool + { + return $this->isEnRoute() && $this->customerHasIncompleteStop(); + } + + /** + * Whole-route counts: stops in total, stops done, and stops still ahead of this customer's next one. + */ + public function route(): array + { + $next = $this->ownStops()->first(fn (TrackingStop $stop) => !$stop->completed); + + return [ + 'total_stops' => $this->stops->count(), + 'completed_stops' => $this->stops->filter(fn (TrackingStop $stop) => $stop->completed)->count(), + 'stops_before' => $next ? $this->stops->filter(fn (TrackingStop $stop) => !$stop->completed && (int) $stop->sequence < (int) $next->sequence)->count() : 0, + ]; + } + + /** + * The driver as this customer may see them: first name, vehicle label, online flag. + */ + public function driver(): ?array + { + $driver = $this->assignedDriver(); + if (!$driver || !$this->showsVehicle()) { + return null; + } + + return [ + 'name' => static::firstName($driver->name), + 'vehicle' => $this->vehicleLabel(), + 'online' => (bool) $driver->online, + ]; + } + + /** + * The vehicle position rounded to ~10 m, its heading and when it was seen. + */ + public function location(): ?array + { + if (!$this->showsVehicle()) { + return null; + } + + $source = $this->locationSource(); + if (!$source) { + return null; + } + + [$subject, $point] = $source; + + return [ + 'latitude' => round($point->getLat(), self::COORDINATE_PRECISION), + 'longitude' => round($point->getLng(), self::COORDINATE_PRECISION), + 'heading' => is_numeric($subject->heading) ? (int) round((float) $subject->heading) : null, + 'seen_at' => $subject->updated_at ? Carbon::parse($subject->updated_at)->toISOString() : null, + ]; + } + + /** + * Tracking numbers whose timeline this customer may read: their stops', plus the order's own for the order customer. + * + * @return array + */ + public function timelineTrackingNumbers(): array + { + $uuids = $this->ownStops()->map(fn (TrackingStop $stop) => $stop->trackingNumberUuid)->all(); + + if ($this->scope->isOrderCustomer($this->order)) { + $uuids[] = $this->order->tracking_number_uuid; + } + + return array_values(array_unique(array_filter($uuids, fn ($uuid) => is_string($uuid) && $uuid !== ''))); + } + + /** + * ETAs keyed by stop sequence from a tracking intelligence result's route legs. + */ + public static function etasFromTracker(array $tracker): array + { + $etas = []; + foreach ((array) data_get($tracker, 'route.legs', []) as $leg) { + $sequence = data_get($leg, 'stop.sequence'); + if ($sequence === null) { + continue; + } + + $etas[(int) $sequence] = [ + 'seconds' => is_numeric(data_get($leg, 'eta_seconds')) ? (int) round((float) data_get($leg, 'eta_seconds')) : null, + 'at' => data_get($leg, 'eta_at'), + 'distance_m' => is_numeric(data_get($leg, 'distance_m')) ? (int) round((float) data_get($leg, 'distance_m')) : null, + ]; + } + + return $etas; + } + + /** + * Timeline entries per tracking number: status, code, complete and time only. + * + * @param array $trackingNumberUuids + * + * @return array> + */ + public static function loadTimelines(array $trackingNumberUuids, ?string $connection = null): array + { + if ($trackingNumberUuids === []) { + return []; + } + + return TrackingStatus::on($connection) + ->whereIn('tracking_number_uuid', $trackingNumberUuids) + ->orderBy('created_at') + ->get(['tracking_number_uuid', 'status', 'code', 'complete', 'created_at']) + ->groupBy('tracking_number_uuid') + ->map(fn (Collection $statuses) => $statuses->take(-self::TIMELINE_LIMIT)->map(fn (TrackingStatus $status) => static::timelineEntry($status))->values()->all()) + ->all(); + } + + /** + * The first word of a name. + */ + public static function firstName(?string $name): ?string + { + $name = trim((string) $name); + + return $name === '' ? null : Str::before($name, ' '); + } + + private function envelope(string $event, string $reason, array $data): array + { + return [ + 'event' => $event, + 'reason' => $reason, + 'sent_at' => Carbon::now()->toISOString(), + 'data' => $data, + ]; + } + + private function orderState(): array + { + $status = strtolower((string) $this->order->status); + + return [ + 'status' => $status !== '' ? $status : null, + 'en_route' => $this->isEnRoute(), + 'completed' => $status === 'completed', + 'canceled' => $status === 'canceled', + 'timeline' => $this->scope->isOrderCustomer($this->order) ? $this->timeline($this->order->tracking_number_uuid) : [], + ]; + } + + private function stop(TrackingStop $stop): array + { + $eta = $stop->completed ? null : ($this->etas[(int) $stop->sequence] ?? null); + + return [ + 'id' => $stop->waypoint?->public_id, + 'type' => $stop->type, + 'sequence' => $stop->sequence, + 'status' => $stop->status, + 'complete' => $stop->completed, + 'eta' => $eta, + 'timeline' => $this->timeline($stop->trackingNumberUuid), + ]; + } + + private function timeline(?string $trackingNumberUuid): array + { + return $trackingNumberUuid ? ($this->timelines[$trackingNumberUuid] ?? []) : []; + } + + private static function timelineEntry(TrackingStatus $status): array + { + $createdAt = $status->getRawOriginal('created_at') ?? $status->created_at; + + return [ + 'status' => $status->status, + 'code' => $status->code !== null ? strtolower((string) $status->code) : null, + 'complete' => (bool) $status->complete, + 'created_at' => $createdAt ? Carbon::parse($createdAt)->toISOString() : null, + ]; + } + + private function assignedDriver(): ?Driver + { + $driver = $this->order->driverAssigned; + + return $driver instanceof Driver ? $driver : null; + } + + private function assignedVehicle(): ?Vehicle + { + $vehicle = $this->order->vehicleAssigned ?? $this->assignedDriver()?->vehicle; + + return $vehicle instanceof Vehicle ? $vehicle : null; + } + + private function vehicleLabel(): ?string + { + $label = trim((string) $this->assignedVehicle()?->display_name); + + return $label === '' ? null : $label; + } + + /** + * The most recently updated of the driver and the vehicle that has a usable position. + * + * @return array{0: Driver|Vehicle, 1: \Fleetbase\LaravelMysqlSpatial\Types\Point}|null + */ + private function locationSource(): ?array + { + $candidates = []; + foreach ([$this->assignedDriver(), $this->assignedVehicle()] as $subject) { + $point = $subject ? static::usablePoint($subject) : null; + if ($point) { + $candidates[] = [$subject, $point]; + } + } + + usort($candidates, fn (array $a, array $b) => static::seenAt($b[0]) <=> static::seenAt($a[0])); + + return $candidates[0] ?? null; + } + + private static function seenAt(Driver|Vehicle $subject): int + { + return $subject->updated_at ? Carbon::parse($subject->updated_at)->getTimestamp() : 0; + } + + private static function usablePoint(Driver|Vehicle $subject) + { + try { + $point = $subject->location ? Utils::getPointFromMixed($subject->location) : null; + } catch (\Throwable) { + return null; + } + + if (!$point) { + return null; + } + + $lat = (float) $point->getLat(); + $lng = (float) $point->getLng(); + $valid = is_finite($lat) && is_finite($lng) && abs($lat) <= 90 && abs($lng) <= 180 && !(abs($lat) < 0.000001 && abs($lng) < 0.000001); + + return $valid ? $point : null; + } +} diff --git a/server/tests/Feature/Http/Internal/PositionControllerReplayChannelTest.php b/server/tests/Feature/Http/Internal/PositionControllerReplayChannelTest.php new file mode 100644 index 000000000..261a5b323 --- /dev/null +++ b/server/tests/Feature/Http/Internal/PositionControllerReplayChannelTest.php @@ -0,0 +1,131 @@ +checked[] = [$principal, $channel]; + + return in_array($channel, $this->allowed, true) ? ChannelDecision::allowed('resolver') : ChannelDecision::denied('forbidden'); + } +} + +function positionReplayChannelBoot(bool $enabled): PositionReplayChannelAuthorizerFake +{ + config(['broadcasting.connections.socketcluster.auth_key' => $enabled ? 'fleetops-position-replay-test-key-0123456789' : null]); + + $pdo = new PDO('sqlite::memory:'); + $pdo->sqliteCreateFunction('ST_PointFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt); + $connection = new SQLiteConnection($pdo); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + $schema = $connection->getSchemaBuilder(); + foreach (['positions' => ['uuid', 'company_uuid', 'subject_uuid', 'coordinates', 'speed'], 'companies' => ['uuid', 'public_id']] as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + $connection->table('companies')->insert(['uuid' => 'company-1', 'public_id' => 'company_one']); + $connection->table('positions')->insert(['uuid' => 'pos-1', 'company_uuid' => 'company-1', 'subject_uuid' => 'vehicle-1', 'created_at' => '2026-10-06 08:00:00']); + + session(['company' => 'company-1']); + DispatchRecorder::$dispatched = []; + + $authorizer = new PositionReplayChannelAuthorizerFake(['vehicle.vehicle-1']); + app()->instance(ChannelAuthorizer::class, $authorizer); + + return $authorizer; +} + +function positionReplayChannelRequest(mixed $channel, ?User $user = null): Request +{ + $request = Request::create('/int/v1/positions/replay', 'POST', ['channel_id' => $channel, 'position_ids' => ['pos-1']]); + $request->setUserResolver(fn () => $user); + + return $request; +} + +function positionReplayChannelUser(): User +{ + $user = (new ReflectionClass(User::class))->newInstanceWithoutConstructor(); + $user->setRawAttributes(['uuid' => 'user-1', 'public_id' => 'user_one', 'company_uuid' => 'company-1', 'type' => 'user'], true); + + return $user; +} + +afterEach(function () { + config(['broadcasting.connections.socketcluster.auth_key' => null]); +}); + +test('a replay to a channel the user may subscribe to is started', function () { + $authorizer = positionReplayChannelBoot(true); + + $response = (new PositionController())->replay(positionReplayChannelRequest('vehicle.vehicle-1', positionReplayChannelUser())); + + [$principal, $channel] = $authorizer->checked[0]; + expect($response->getData(true)['status'])->toBe('ok') + ->and(DispatchRecorder::$dispatched)->toHaveCount(1) + ->and($channel)->toBe('vehicle.vehicle-1') + ->and($principal->kind)->toBe('user') + ->and($principal->sub)->toBe('user-1') + ->and($principal->cid)->toBe('company-1') + ->and($principal->adm)->toBeFalse(); +}); + +test('a replay to any other channel is refused before anything is queued', function () { + $authorizer = positionReplayChannelBoot(true); + $controller = new PositionController(); + $user = positionReplayChannelUser(); + + foreach ([ + positionReplayChannelRequest('chat_channel.someone-elses', $user), + positionReplayChannelRequest('vehicle.vehicle-1', null), + positionReplayChannelRequest('vehicle. vehicle-1', $user), + positionReplayChannelRequest(str_repeat('x', 256), $user), + positionReplayChannelRequest(['vehicle.vehicle-1'], $user), + ] as $request) { + $response = $controller->replay($request); + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['error'])->toContain('not allowed'); + } + + expect(DispatchRecorder::$dispatched)->toBe([]) + ->and(array_column($authorizer->checked, 1))->toBe(['chat_channel.someone-elses']); +}); + +test('without socket authentication the replay endpoint does not check the channel', function () { + $authorizer = positionReplayChannelBoot(false); + + $response = (new PositionController())->replay(positionReplayChannelRequest('anything.at-all')); + + expect($response->getData(true)['status'])->toBe('ok') + ->and($authorizer->checked)->toBe([]); +}); diff --git a/server/tests/Unit/Support/SocketChannelsTest.php b/server/tests/Unit/Support/SocketChannelsTest.php new file mode 100644 index 000000000..b404cbfca --- /dev/null +++ b/server/tests/Unit/Support/SocketChannelsTest.php @@ -0,0 +1,394 @@ + 'order', + 'drivers' => 'driver', + 'vehicles' => 'vehicle', + 'assets' => 'trailer', + 'devices' => 'device', + 'waypoints' => 'waypoint', + 'entities' => 'entity', + 'places' => 'place', + 'contacts' => 'contact', + 'vendors' => 'vendor', + 'integrated_vendors' => 'integrated_vendor', + 'fleets' => 'fleet', + 'zones' => 'zone', + 'service_areas' => 'service_area', + 'service_rates' => 'service_rate', + 'service_quotes' => 'service_quote', + 'purchase_rates' => 'purchase_rate', + 'tracking_statuses' => 'tracking_status', + 'tracking_numbers' => 'tracking_number', + 'payloads' => 'payload', + 'positions' => 'position', + 'companies' => 'company', + 'users' => 'user', +]; + +const SOCKET_CHANNELS_COLUMNS = [ + 'uuid', 'public_id', 'company_uuid', 'customer_uuid', 'customer_type', 'driver_assigned_uuid', 'vehicle_assigned_uuid', + 'payload_uuid', 'vehicle_uuid', 'user_uuid', 'attachable_uuid', 'attachable_type', 'subject_uuid', 'subject_type', + 'status', 'name', 'email', 'type', 'asset_class', 'started', 'started_at', 'place_uuid', 'service_area_uuid', '_key', +]; + +function socketChannelsBoot(): SQLiteConnection +{ + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection, 'sandbox' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + $schema = $connection->getSchemaBuilder(); + foreach (array_keys(SOCKET_CHANNELS_TABLES) as $table) { + $schema->create($table, function ($blueprint) use ($table) { + $blueprint->increments('id'); + foreach (SOCKET_CHANNELS_COLUMNS as $column) { + // Positions have no public_id, as in the real schema. + if ($table === 'positions' && $column === 'public_id') { + continue; + } + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + // One record of every kind in each of two companies. + foreach (['a', 'b'] as $company) { + $connection->table('companies')->insert(['uuid' => "company-{$company}", 'public_id' => "company_{$company}"]); + $connection->table('users')->insert(['uuid' => "drivers-user-{$company}", 'public_id' => "user_drivers_{$company}", 'company_uuid' => "company-{$company}"]); + foreach (SOCKET_CHANNELS_TABLES as $table => $name) { + if (in_array($table, ['companies', 'users'], true)) { + continue; + } + + $row = ['uuid' => "{$table}-{$company}", 'company_uuid' => "company-{$company}"]; + if ($table !== 'positions') { + $row['public_id'] = "{$name}_{$company}"; + } + if ($table === 'drivers') { + $row['user_uuid'] = "drivers-user-{$company}"; + } + if ($table === 'assets') { + $row['asset_class'] = Trailer::ASSET_CLASS; + } + $connection->table($table)->insert($row); + } + } + + // Company A's operation: drivers, their vehicles, customers and orders. + $connection->table('users')->insert([ + ['uuid' => 'user-driver-a1', 'public_id' => 'user_drivera1', 'company_uuid' => 'company-a'], + ['uuid' => 'user-driver-a2', 'public_id' => 'user_drivera2', 'company_uuid' => 'company-a'], + ['uuid' => 'user-driver-a3', 'public_id' => 'user_drivera3', 'company_uuid' => 'company-a'], + ['uuid' => 'user-office-a', 'public_id' => 'user_officea', 'company_uuid' => 'company-a'], + ]); + $connection->table('drivers')->insert([ + ['uuid' => 'driver-a1', 'public_id' => 'driver_a1', 'company_uuid' => 'company-a', 'user_uuid' => 'user-driver-a1', 'vehicle_uuid' => 'vehicle-a1'], + ['uuid' => 'driver-a2', 'public_id' => 'driver_a2', 'company_uuid' => 'company-a', 'user_uuid' => 'user-driver-a2', 'vehicle_uuid' => 'vehicle-a2'], + ['uuid' => 'driver-a3', 'public_id' => 'driver_a3', 'company_uuid' => 'company-a', 'user_uuid' => 'user-driver-a3', 'vehicle_uuid' => 'vehicle-a3'], + ]); + $connection->table('vehicles')->insert([ + ['uuid' => 'vehicle-a1', 'public_id' => 'vehicle_a1', 'company_uuid' => 'company-a'], + ['uuid' => 'vehicle-a2', 'public_id' => 'vehicle_a2', 'company_uuid' => 'company-a'], + ['uuid' => 'vehicle-a3', 'public_id' => 'vehicle_a3', 'company_uuid' => 'company-a'], + ]); + $connection->table('contacts')->insert([ + ['uuid' => 'contact-a1', 'public_id' => 'contact_a1', 'company_uuid' => 'company-a', 'type' => 'customer'], + ['uuid' => 'contact-a2', 'public_id' => 'contact_a2', 'company_uuid' => 'company-a', 'type' => 'customer'], + ]); + $connection->table('orders')->insert([ + // driver-a1 drives contact-a1's active order in vehicle-a1 + ['uuid' => 'order-a1', 'public_id' => 'order_a1', 'company_uuid' => 'company-a', 'customer_uuid' => 'contact-a1', 'driver_assigned_uuid' => 'driver-a1', 'vehicle_assigned_uuid' => 'vehicle-a1', 'status' => 'started'], + // driver-a2's order for contact-a2, on which driver-a1 carries an entity + ['uuid' => 'order-a2', 'public_id' => 'order_a2', 'company_uuid' => 'company-a', 'customer_uuid' => 'contact-a2', 'driver_assigned_uuid' => 'driver-a2', 'vehicle_assigned_uuid' => 'vehicle-a2', 'status' => 'dispatched', 'payload_uuid' => 'payload-a2'], + // contact-a1's completed order with driver-a2 + ['uuid' => 'order-a3', 'public_id' => 'order_a3', 'company_uuid' => 'company-a', 'customer_uuid' => 'contact-a1', 'driver_assigned_uuid' => 'driver-a2', 'vehicle_assigned_uuid' => 'vehicle-a2', 'status' => 'completed'], + // driver-a2's order with nothing to do with driver-a1 + ['uuid' => 'order-a4', 'public_id' => 'order_a4', 'company_uuid' => 'company-a', 'customer_uuid' => 'contact-a2', 'driver_assigned_uuid' => 'driver-a2', 'status' => 'started', 'payload_uuid' => 'payload-a4'], + // contact-a1's active order with driver-a3 and no vehicle assigned to the order + ['uuid' => 'order-a5', 'public_id' => 'order_a5', 'company_uuid' => 'company-a', 'customer_uuid' => 'contact-a1', 'driver_assigned_uuid' => 'driver-a3', 'status' => 'created'], + ]); + $connection->table('entities')->insert([ + ['uuid' => 'entity-a2', 'public_id' => 'entity_a2', 'company_uuid' => 'company-a', 'payload_uuid' => 'payload-a2', 'driver_assigned_uuid' => 'driver-a1'], + ['uuid' => 'entity-a4', 'public_id' => 'entity_a4', 'company_uuid' => 'company-a', 'payload_uuid' => 'payload-a4', 'driver_assigned_uuid' => 'driver-a2'], + ]); + $connection->table('devices')->insert([ + ['uuid' => 'device-a1', 'public_id' => 'device_a1', 'company_uuid' => 'company-a', 'attachable_uuid' => 'vehicle-a1'], + ['uuid' => 'device-a2', 'public_id' => 'device_a2', 'company_uuid' => 'company-a', 'attachable_uuid' => 'vehicle-a2'], + ['uuid' => 'device-loose', 'public_id' => 'device_loose', 'company_uuid' => 'company-a', 'attachable_uuid' => null], + ]); + $connection->table('positions')->insert([ + ['uuid' => 'position-own', 'company_uuid' => 'company-a', 'subject_uuid' => 'driver-a1'], + ['uuid' => 'position-vehicle', 'company_uuid' => 'company-a', 'subject_uuid' => 'vehicle-a1'], + ['uuid' => 'position-other', 'company_uuid' => 'company-a', 'subject_uuid' => 'driver-a2'], + ]); + + $registry = new SocketChannelRegistry(); + SocketChannels::register($registry); + app()->instance(SocketChannelRegistry::class, $registry); + session(['company' => null]); + + return $connection; +} + +function socketChannelsPrincipal(string $kind, string $sub, array $claims = []): SocketPrincipal +{ + return SocketPrincipal::fromClaims(array_merge([ + 'kind' => $kind, + 'sub' => $sub, + 'cid' => 'company-a', + 'cpid' => 'company_a', + 'env' => 'live', + 'ids' => [$sub], + ], $claims)); +} + +function socketChannelsDecide(SocketPrincipal $principal, string $channel): ChannelDecision +{ + return app(ChannelAuthorizer::class)->authorize($principal, $channel); +} + +function socketChannelsAllows(SocketPrincipal $principal, string $channel): bool +{ + $decision = socketChannelsDecide($principal, $channel); + + // A resolver error would also deny: make sure every denial here is a real decision. + expect($decision->reason)->not->toBe('resolver_error'); + + return $decision->allow; +} + +function socketChannelsDriver(): SocketPrincipal +{ + return socketChannelsPrincipal('driver', 'driver-a1', ['ids' => ['driver-a1', 'driver_a1', 'user-driver-a1', 'user_drivera1']]); +} + +function socketChannelsCustomer(): SocketPrincipal +{ + return socketChannelsPrincipal('customer', 'contact-a1', ['ids' => ['contact-a1', 'contact_a1']]); +} + +test('every fleetops prefix is registered and tracking channels are denied to everyone', function () { + socketChannelsBoot(); + $registry = app(SocketChannelRegistry::class); + + foreach ([...array_keys(SocketChannels::MODEL_PREFIXES), ...array_keys(SocketChannels::MORPH_PREFIXES), 'position', 'tracking'] as $prefix) { + expect($registry->resolve($prefix))->not->toBeNull(); + } + + $user = socketChannelsPrincipal('user', 'user-office-a', ['ids' => ['user-office-a']]); + expect(socketChannelsAllows($user, 'tracking.abcdefghijklmnopqrstuvwxyz'))->toBeFalse() + ->and(socketChannelsAllows(socketChannelsDriver(), 'tracking.abcdefghijklmnopqrstuvwxyz'))->toBeFalse() + ->and(socketChannelsAllows(socketChannelsCustomer(), 'tracking.abcdefghijklmnopqrstuvwxyz'))->toBeFalse(); +}); + +test('console users and api credentials reach every record of their own company and nothing of another', function () { + socketChannelsBoot(); + $user = socketChannelsPrincipal('user', 'user-office-a', ['ids' => ['user-office-a', 'user_officea']]); + $api = socketChannelsPrincipal('api', 'api-credential-a', ['ids' => ['api-credential-a']]); + + $prefixes = [ + ...array_map(fn ($class) => (new $class())->getTable(), SocketChannels::MODEL_PREFIXES), + 'customer' => 'contacts', + 'vendor' => 'vendors', + 'facilitator' => 'integrated_vendors', + ]; + + foreach ($prefixes as $prefix => $table) { + $name = SOCKET_CHANNELS_TABLES[$table]; + foreach ([$user, $api] as $principal) { + expect(socketChannelsAllows($principal, "{$prefix}.{$table}-a"))->toBeTrue("{$principal->kind} {$prefix} by uuid") + ->and(socketChannelsAllows($principal, "{$prefix}.{$name}_a"))->toBeTrue("{$principal->kind} {$prefix} by public id") + ->and(socketChannelsAllows($principal, "{$prefix}.{$table}-b"))->toBeFalse("{$principal->kind} {$prefix} of another company") + ->and(socketChannelsAllows($principal, "{$prefix}.missing-record"))->toBeFalse("{$principal->kind} {$prefix} unknown id"); + } + } + + // Morph prefixes try each kind of record in turn. + expect(socketChannelsAllows($user, 'customer.vendors-a'))->toBeTrue() + ->and(socketChannelsAllows($user, 'facilitator.vendors-a'))->toBeTrue() + ->and(socketChannelsAllows($user, 'facilitator.contacts-a'))->toBeTrue() + ->and(socketChannelsAllows($user, 'vendor.integrated_vendors-b'))->toBeFalse(); + + // Positions have no public id and are found by uuid only. + expect(socketChannelsAllows($user, 'position.positions-a'))->toBeTrue() + ->and(socketChannelsAllows($api, 'position.positions-b'))->toBeFalse(); + + // Sandbox principals look records up on the sandbox connection. + $sandbox = socketChannelsPrincipal('user', 'user-office-a', ['env' => 'test']); + expect(socketChannelsAllows($sandbox, 'customer.contacts-a'))->toBeTrue() + ->and(SocketChannels::connection($sandbox))->toBe('sandbox') + ->and(SocketChannels::connection($user))->toBeNull(); + + // A company-scoped principal without a company sees nothing. + $orphan = SocketPrincipal::fromClaims(['kind' => 'user', 'sub' => 'user-orphan', 'ids' => ['user-orphan']]); + expect(socketChannelsAllows($orphan, 'customer.contacts-a'))->toBeFalse() + ->and(socketChannelsAllows($orphan, 'position.positions-a'))->toBeFalse(); +}); + +test('a driver reaches only their own driver record, vehicle, devices, positions and assigned orders', function () { + socketChannelsBoot(); + $driver = socketChannelsDriver(); + + // Orders: assigned directly, or through an entity on the payload; never someone else's. + expect(socketChannelsAllows($driver, 'order.order-a1'))->toBeTrue() + ->and(socketChannelsAllows($driver, 'order.order_a1'))->toBeTrue() + ->and(socketChannelsAllows($driver, 'order.order-a2'))->toBeTrue() + ->and(socketChannelsAllows($driver, 'order.order-a4'))->toBeFalse() + ->and(socketChannelsAllows($driver, 'order.order-a3'))->toBeFalse() + ->and(socketChannelsAllows($driver, 'order.orders-b'))->toBeFalse(); + + // Their own driver record, under either channel the order events use. + expect(socketChannelsAllows($driver, 'driver.driver-a1'))->toBeTrue() + ->and(socketChannelsAllows($driver, 'driverAssigned.driver-a1'))->toBeTrue() + ->and(socketChannelsAllows($driver, 'driverAssigned.driver_a1'))->toBeTrue() + ->and(socketChannelsAllows($driver, 'driverAssigned.driver-a2'))->toBeFalse() + ->and(socketChannelsAllows($driver, 'driver.driver-a2'))->toBeFalse(); + + // Their current vehicle, its devices and positions; not another driver's. + expect(socketChannelsAllows($driver, 'vehicle.vehicle-a1'))->toBeTrue() + ->and(socketChannelsAllows($driver, 'vehicle.vehicle-a2'))->toBeFalse() + ->and(socketChannelsAllows($driver, 'device.device-a1'))->toBeTrue() + ->and(socketChannelsAllows($driver, 'device.device-a2'))->toBeFalse() + ->and(socketChannelsAllows($driver, 'device.device-loose'))->toBeFalse() + ->and(socketChannelsAllows($driver, 'position.position-own'))->toBeTrue() + ->and(socketChannelsAllows($driver, 'position.position-vehicle'))->toBeTrue() + ->and(socketChannelsAllows($driver, 'position.position-other'))->toBeFalse(); + + // Nothing without a narrow rule, and no customer records. + expect(socketChannelsAllows($driver, 'waypoint.waypoints-a'))->toBeFalse() + ->and(socketChannelsAllows($driver, 'place.places-a'))->toBeFalse() + ->and(socketChannelsAllows($driver, 'contact.contact-a1'))->toBeFalse() + ->and(socketChannelsAllows($driver, 'customer.contact-a1'))->toBeFalse() + ->and(socketChannelsAllows($driver, 'vendor.vendors-a'))->toBeFalse(); + + // A driver with no current vehicle owns no vehicle records. + $walker = socketChannelsPrincipal('driver', 'driver-without-vehicle'); + expect(socketChannelsAllows($walker, 'vehicle.vehicles-a'))->toBeFalse(); +}); + +test('a customer reaches their own orders and the driver and vehicle of their active orders', function () { + socketChannelsBoot(); + $customer = socketChannelsCustomer(); + + expect(socketChannelsAllows($customer, 'order.order-a1'))->toBeTrue() + ->and(socketChannelsAllows($customer, 'order.order_a1'))->toBeTrue() + ->and(socketChannelsAllows($customer, 'order.order-a3'))->toBeTrue() + ->and(socketChannelsAllows($customer, 'order.order-a2'))->toBeFalse() + ->and(socketChannelsAllows($customer, 'order.orders-b'))->toBeFalse(); + + // driver-a1 drives an active order of theirs; driver-a2 only a completed one. + expect(socketChannelsAllows($customer, 'driver.driver-a1'))->toBeTrue() + ->and(socketChannelsAllows($customer, 'driverAssigned.driver_a1'))->toBeTrue() + ->and(socketChannelsAllows($customer, 'driver.driver-a3'))->toBeTrue() + ->and(socketChannelsAllows($customer, 'driver.driver-a2'))->toBeFalse(); + + // vehicle-a1 is assigned to an active order; vehicle-a3 is driven by an active order's driver. + expect(socketChannelsAllows($customer, 'vehicle.vehicle-a1'))->toBeTrue() + ->and(socketChannelsAllows($customer, 'vehicle.vehicle-a3'))->toBeTrue() + ->and(socketChannelsAllows($customer, 'vehicle.vehicle-a2'))->toBeFalse(); + + // Themselves, and no other contact, device or position. + expect(socketChannelsAllows($customer, 'contact.contact-a1'))->toBeTrue() + ->and(socketChannelsAllows($customer, 'customer.contact_a1'))->toBeTrue() + ->and(socketChannelsAllows($customer, 'contact.contact-a2'))->toBeFalse() + ->and(socketChannelsAllows($customer, 'customer.contact-a2'))->toBeFalse() + ->and(socketChannelsAllows($customer, 'device.device-a1'))->toBeFalse() + ->and(socketChannelsAllows($customer, 'position.position-vehicle'))->toBeFalse() + ->and(socketChannelsAllows($customer, 'waypoint.waypoints-a'))->toBeFalse(); +}); + +test('other principal kinds are denied by the fleetops resolvers', function () { + socketChannelsBoot(); + $checkout = socketChannelsPrincipal('checkout', 'checkout-a'); + $resolver = SocketChannels::resolver([Order::class], fn () => true); + + expect($resolver($checkout, 'order-a1', 'order.order-a1'))->toBeFalse() + ->and($resolver(socketChannelsDriver(), '', 'order.'))->toBeFalse() + ->and(SocketChannels::resolver([Order::class])(socketChannelsDriver(), 'order-a1'))->toBeFalse() + ->and(SocketChannels::narrowFor('waypoint'))->toBeNull(); + + $order = Order::where('uuid', 'order-a1')->first(); + expect(SocketChannels::narrowOrder($checkout, $order))->toBeFalse() + ->and(SocketChannels::narrowDriver($checkout, $order))->toBeFalse() + ->and(SocketChannels::narrowVehicle($checkout, $order))->toBeFalse() + ->and(SocketChannels::narrowSelf($checkout, $order))->toBeFalse(); +}); + +test('a sanctum user who drives for the current company gets a driver principal', function () { + socketChannelsBoot(); + $request = Request::create('/v1/socket/token', 'POST'); + $user = User::where('uuid', 'user-driver-a1')->first(); + + session(['company' => 'company-a']); + $principal = SocketChannels::driverPrincipal($request, $user); + + expect($principal)->toBeInstanceOf(SocketPrincipal::class) + ->and($principal->kind)->toBe('driver') + ->and($principal->sub)->toBe('driver-a1') + ->and($principal->ids)->toBe(['driver-a1', 'driver_a1', 'user-driver-a1', 'user_drivera1']) + ->and($principal->cid)->toBe('company-a') + ->and($principal->cpid)->toBe('company_a') + ->and($principal->env)->toBe('live') + ->and($principal->adm)->toBeFalse(); + + // The registry offers the same principal to the token endpoint. + expect(app(SocketChannelRegistry::class)->resolvePrincipal($request, $user)?->sub)->toBe('driver-a1'); + + // Without a session company the user's own company is used. + session(['company' => null]); + expect(SocketChannels::driverPrincipal($request, $user)?->sub)->toBe('driver-a1'); + + // A driver of another company, an office user, a stranger object: no claim. + session(['company' => 'company-b']); + expect(SocketChannels::driverPrincipal($request, $user))->toBeNull(); + + session(['company' => 'company-a']); + expect(SocketChannels::driverPrincipal($request, User::where('uuid', 'user-office-a')->first()))->toBeNull() + ->and(SocketChannels::driverPrincipal($request, (object) ['uuid' => 'user-driver-a1']))->toBeNull() + ->and(SocketChannels::driverPrincipal($request, new User()))->toBeNull(); + + session(['company' => null]); + $homeless = User::where('uuid', 'user-driver-a1')->first(); + $homeless->setRawAttributes(array_merge($homeless->getAttributes(), ['company_uuid' => null])); + expect(SocketChannels::driverPrincipal($request, $homeless))->toBeNull(); +}); + +test('the service provider registers the fleetops channels when the registry is built', function () { + socketChannelsBoot(); + app()->forgetInstance(SocketChannelRegistry::class); + + $provider = new FleetOpsServiceProvider(app()); + $register = new ReflectionMethod(FleetOpsServiceProvider::class, 'registerSocketChannels'); + $register->setAccessible(true); + $register->invoke($provider); + + $registry = app(SocketChannelRegistry::class); + + expect($registry->resolve('order'))->not->toBeNull() + ->and($registry->resolve('position'))->not->toBeNull() + ->and($registry->resolve('tracking'))->not->toBeNull(); +}); diff --git a/server/tests/Unit/Support/TrackingChannelTest.php b/server/tests/Unit/Support/TrackingChannelTest.php new file mode 100644 index 000000000..ef612beef --- /dev/null +++ b/server/tests/Unit/Support/TrackingChannelTest.php @@ -0,0 +1,228 @@ +newInstanceWithoutConstructor(); + $model->setRawAttributes($attributes, true); + + return $model; +} + +function trackingChannelOrder(array $attributes = []): Order +{ + return trackingChannelModel(Order::class, array_merge([ + 'uuid' => 'order-1111', + 'company_uuid' => 'company-1111', + 'customer_type' => 'Fleetbase\\FleetOps\\Models\\Contact', + 'customer_uuid' => 'contact-1111', + 'payload_uuid' => 'payload-1111', + ], $attributes)); +} + +function trackingChannelWaypoint(?string $customerUuid, string $customerType = 'Fleetbase\\FleetOps\\Models\\Contact'): Waypoint +{ + return trackingChannelModel(Waypoint::class, ['uuid' => 'waypoint-' . ($customerUuid ?? 'none'), 'customer_uuid' => $customerUuid, 'customer_type' => $customerUuid ? $customerType : null]); +} + +/** + * Independent base32 (RFC 4648, lowercase, unpadded) to check the channel id against. + */ +function trackingChannelBase32(string $bytes): string +{ + $alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; + $buffer = 0; + $bits = 0; + $out = ''; + foreach (unpack('C*', $bytes) as $byte) { + $buffer = ($buffer << 8) | $byte; + $bits += 8; + while ($bits >= 5) { + $bits -= 5; + $out .= $alphabet[($buffer >> $bits) & 31]; + } + } + if ($bits > 0) { + $out .= $alphabet[($buffer << (5 - $bits)) & 31]; + } + + return $out; +} + +function trackingChannelEnable(?string $key = TRACKING_CHANNEL_KEY): void +{ + config(['broadcasting.connections.socketcluster.auth_key' => $key]); +} + +afterEach(function () { + trackingChannelEnable(null); +}); + +test('a tracking scope identifies one customer of one order', function () { + $order = trackingChannelOrder(); + $scope = TrackingScope::forOrder($order); + + expect($scope->order_uuid)->toBe('order-1111') + ->and($scope->customer_type)->toBe('Fleetbase\\FleetOps\\Models\\Contact') + ->and($scope->customer_uuid)->toBe('contact-1111') + ->and($scope->company_uuid)->toBe('company-1111') + ->and($scope->key())->toBe('order-1111:Fleetbase\\FleetOps\\Models\\Contact:contact-1111') + ->and($scope->toArray())->toBe(['order_uuid' => 'order-1111', 'customer_type' => 'Fleetbase\\FleetOps\\Models\\Contact', 'customer_uuid' => 'contact-1111']) + ->and(json_encode($scope))->toBe(json_encode($scope->toArray())) + ->and($scope->order())->toBe($order) + ->and($scope->isOrderCustomer($order))->toBeTrue(); + + // A leading backslash on a stored morph type does not change the scope. + expect((new TrackingScope('order-1111', '\\Fleetbase\\FleetOps\\Models\\Contact', 'contact-1111'))->key())->toBe($scope->key()) + ->and($scope->is('\\Fleetbase\\FleetOps\\Models\\Contact', 'contact-1111'))->toBeTrue() + ->and($scope->is('Fleetbase\\FleetOps\\Models\\Vendor', 'contact-1111'))->toBeFalse() + ->and($scope->is('Fleetbase\\FleetOps\\Models\\Contact', null))->toBeFalse(); + + // An order without a customer has no order scope. + expect(TrackingScope::forOrder(trackingChannelOrder(['customer_uuid' => null])))->toBeNull(); + + expect(fn () => new TrackingScope('order-1111', '', 'contact-1111'))->toThrow(InvalidArgumentException::class); +}); + +test('each stop belongs to its waypoint customer, else to the order customer', function () { + $order = trackingChannelOrder(); + $own = trackingChannelWaypoint(null); + $neighbour = trackingChannelWaypoint('contact-2222'); + $vendorStop = trackingChannelWaypoint('vendor-3333', 'Fleetbase\\FleetOps\\Models\\Vendor'); + + $orderScope = TrackingScope::forOrder($order); + $neighbourScope = TrackingScope::forStop($order, $neighbour); + + expect(TrackingScope::forStop($order, $own)->key())->toBe($orderScope->key()) + ->and(TrackingScope::forStop($order)->key())->toBe($orderScope->key()) + ->and($neighbourScope->customer_uuid)->toBe('contact-2222') + ->and($orderScope->ownsStop($order, $own))->toBeTrue() + ->and($orderScope->ownsStop($order, null))->toBeTrue() + ->and($orderScope->ownsStop($order, $neighbour))->toBeFalse() + ->and($neighbourScope->ownsStop($order, $neighbour))->toBeTrue() + ->and($neighbourScope->ownsStop($order, $own))->toBeFalse() + ->and($neighbourScope->isOrderCustomer($order))->toBeFalse(); + + $scopes = TrackingScope::allForOrder($order, [$own, $neighbour, $vendorStop, $neighbour, 'not a waypoint']); + expect(array_map(fn (TrackingScope $scope) => $scope->customer_uuid, $scopes))->toBe(['contact-1111', 'contact-2222', 'vendor-3333']) + ->and($scopes[2]->customer_type)->toBe(Vendor::class); + + // An explicit customer model. + $contact = trackingChannelModel(Contact::class, ['uuid' => 'contact-2222']); + expect(TrackingScope::forCustomer($order, $contact)->key())->toBe($neighbourScope->key()); +}); + +test('a tracking scope loads its order and its waypoints when not given', function () { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + $schema = $connection->getSchemaBuilder(); + foreach (['orders', 'waypoints'] as $table) { + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'place_uuid', 'customer_uuid', 'customer_type'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + $connection->table('orders')->insert(['uuid' => 'order-1111', 'company_uuid' => 'company-1111', 'customer_uuid' => 'contact-1111', 'customer_type' => 'Fleetbase\\FleetOps\\Models\\Contact', 'payload_uuid' => 'payload-1111']); + $connection->table('waypoints')->insert([ + ['uuid' => 'waypoint-1', 'payload_uuid' => 'payload-1111', 'customer_uuid' => 'contact-2222', 'customer_type' => 'Fleetbase\\FleetOps\\Models\\Contact'], + ['uuid' => 'waypoint-2', 'payload_uuid' => 'payload-1111', 'customer_uuid' => null, 'customer_type' => null], + ['uuid' => 'waypoint-3', 'payload_uuid' => 'payload-other', 'customer_uuid' => 'contact-9999', 'customer_type' => 'Fleetbase\\FleetOps\\Models\\Contact'], + ]); + + $scope = new TrackingScope('order-1111', 'Fleetbase\\FleetOps\\Models\\Contact', 'contact-1111'); + expect($scope->company_uuid)->toBeNull() + ->and($scope->order()?->uuid)->toBe('order-1111'); + + $order = $scope->order(); + expect(array_map(fn (TrackingScope $scope) => $scope->customer_uuid, TrackingScope::allForOrder($order)))->toBe(['contact-1111', 'contact-2222']) + ->and(TrackingScope::allForOrder(trackingChannelOrder(['payload_uuid' => null])))->toHaveCount(1); + + // A scope given another order's model ignores it. + expect((new TrackingScope('order-1111', 'Fleetbase\\FleetOps\\Models\\Contact', 'contact-1111', trackingChannelOrder(['uuid' => 'order-other'])))->company_uuid)->toBeNull(); +}); + +test('the channel id is an unguessable keyed hmac of the scope, distinct per customer', function () { + trackingChannelEnable(); + $order = trackingChannelOrder(); + $scope = TrackingScope::forOrder($order); + $neighbour = TrackingScope::forStop($order, trackingChannelWaypoint('contact-2222')); + + $trackingKey = hash_hmac('sha256', 'fleetbase-socket:tracking', TRACKING_CHANNEL_KEY); + $expected = substr(trackingChannelBase32(hash_hmac('sha256', 'order-1111:Fleetbase\\FleetOps\\Models\\Contact:contact-1111', $trackingKey, true)), 0, 26); + + expect(TrackingChannel::opaqueId($scope))->toBe($expected) + ->and(TrackingChannel::opaqueId($scope))->toMatch('/^[a-z2-7]{26}$/') + ->and(TrackingChannel::name($scope))->toBe('tracking.' . $expected) + ->and(TrackingChannel::name(TrackingScope::forOrder(trackingChannelOrder())))->toBe('tracking.' . $expected) + ->and(TrackingChannel::name($neighbour))->not->toBe(TrackingChannel::name($scope)) + ->and(trackingChannelBase32('foobar'))->toBe('mzxw6ytboi'); + + // Another key yields other channels: the id cannot be computed without the key. + trackingChannelEnable('another-fleetops-tracking-channel-key-987654321'); + expect(TrackingChannel::opaqueId($scope))->not->toBe($expected); +}); + +test('channels and tokens need socket authentication to be configured', function () { + trackingChannelEnable(null); + $scope = TrackingScope::forOrder(trackingChannelOrder()); + + expect(fn () => TrackingChannel::name($scope))->toThrow(RuntimeException::class) + ->and(fn () => TrackingChannel::token($scope))->toThrow(RuntimeException::class); +}); + +test('a tracking token may subscribe to its own channel only', function () { + trackingChannelEnable(); + $scope = TrackingScope::forOrder(trackingChannelOrder()); + + $minted = TrackingChannel::token($scope); + $principal = SocketToken::verify($minted['token']); + + expect($minted['expires_in'])->toBe(1800) + ->and($minted['expires_at'])->toBeString() + ->and($principal->kind)->toBe('tracking') + ->and($principal->sub)->toBe(TrackingChannel::opaqueId($scope)) + ->and($principal->scp)->toBe([TrackingChannel::name($scope)]) + ->and($principal->cid)->toBe('company-1111') + ->and($principal->adm)->toBeFalse(); + + // A scope without its order model still mints a token scoped to its channel. + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + $connection->getSchemaBuilder()->create('orders', function ($blueprint) { + $blueprint->increments('id'); + $blueprint->string('uuid')->nullable(); + $blueprint->string('company_uuid')->nullable(); + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + $connection->table('orders')->insert(['uuid' => 'order-2222', 'company_uuid' => 'company-2222']); + + $scope = new TrackingScope('order-2222', Contact::class, 'contact-1111'); + $loaded = SocketToken::verify(TrackingChannel::token($scope)['token']); + expect($loaded->cid)->toBe('company-2222') + ->and($loaded->scp)->toBe([TrackingChannel::name($scope)]); +}); diff --git a/server/tests/Unit/Support/TrackingPublisherTest.php b/server/tests/Unit/Support/TrackingPublisherTest.php new file mode 100644 index 000000000..c913840f8 --- /dev/null +++ b/server/tests/Unit/Support/TrackingPublisherTest.php @@ -0,0 +1,417 @@ +sent[] = [$channel, $payload]; + + return $this->accepts; + } + + public function realSend(string $channel, array $payload): bool + { + return parent::send($channel, $payload); + } + + public function tracker(Order $order): array + { + return $this->trackerFor($order); + } +} + +class TrackingPublisherContextBuilderFake extends TrackingContextBuilder +{ + public function __construct(public Collection $stops) + { + } + + public function build(Order $order, TrackingOptions $options): TrackingContext + { + return new TrackingContext( + order: $order, + payload: null, + driver: null, + origin: null, + driverLocation: null, + stops: $this->stops, + completedStops: collect(), + remainingStops: collect(), + activeStop: null, + nextStop: null, + driverLocationAgeSeconds: null, + ); + } +} + +class TrackingPublisherIntelligenceFake extends TrackingIntelligenceService +{ + public int $calls = 0; + + public function __construct(public array|Throwable $result) + { + } + + public function track(Order $order, array|TrackingOptions $options = []): array + { + $this->calls++; + if ($this->result instanceof Throwable) { + throw $this->result; + } + + return $this->result; + } +} + +const TRACKING_PUBLISHER_KEY = 'fleetops-tracking-publisher-test-key-0123456789'; +const TRACKING_PUBLISHER_CONTACT = 'Fleetbase\\FleetOps\\Models\\Contact'; + +function trackingPublisherBoot(bool $enabled = true): SQLiteConnection +{ + config(['broadcasting.connections.socketcluster.auth_key' => $enabled ? TRACKING_PUBLISHER_KEY : null]); + config(['broadcasting.connections.socketcluster.publish_url' => 'http://socket.test:8001']); + config(['broadcasting.connections.socketcluster.options' => ['host' => 'socket.test', 'port' => 8000, 'secure' => false, 'path' => '/socketcluster/', 'query' => []]]); + Cache::flush(); + DispatchRecorder::$dispatched = []; + + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'orders' => ['uuid', 'public_id', 'company_uuid', 'customer_uuid', 'customer_type', 'driver_assigned_uuid', 'vehicle_assigned_uuid', 'payload_uuid', 'tracking_number_uuid', 'status', 'started', 'started_at'], + 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'vehicle_uuid'], + 'users' => ['uuid', 'public_id', 'company_uuid', 'name'], + 'tracking_statuses' => ['uuid', 'tracking_number_uuid', 'status', 'code', 'complete'], + ]; + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $connection->table('users')->insert([['uuid' => 'user-1', 'company_uuid' => 'company-1'], ['uuid' => 'user-2', 'company_uuid' => 'company-1']]); + $connection->table('drivers')->insert([ + ['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-1', 'vehicle_uuid' => 'vehicle-1'], + ['uuid' => 'driver-2', 'company_uuid' => 'company-1', 'user_uuid' => 'user-2', 'vehicle_uuid' => 'vehicle-2'], + ]); + $connection->table('orders')->insert([ + ['uuid' => 'order-started', 'payload_uuid' => 'payload-1', 'driver_assigned_uuid' => 'driver-1', 'status' => 'enroute', 'started' => '1'], + ['uuid' => 'order-status', 'payload_uuid' => 'payload-2', 'driver_assigned_uuid' => 'driver-1', 'status' => 'started', 'started' => null], + ['uuid' => 'order-timestamp', 'payload_uuid' => 'payload-3', 'driver_assigned_uuid' => 'driver-1', 'status' => 'arrived', 'started' => '0', 'started_at' => '2026-10-06 09:00:00'], + ['uuid' => 'order-completed', 'payload_uuid' => 'payload-4', 'driver_assigned_uuid' => 'driver-1', 'status' => 'completed', 'started' => '1'], + ['uuid' => 'order-waiting', 'payload_uuid' => 'payload-5', 'driver_assigned_uuid' => 'driver-1', 'status' => 'dispatched', 'started' => '0'], + ['uuid' => 'order-other-driver', 'payload_uuid' => 'payload-6', 'driver_assigned_uuid' => 'driver-2', 'status' => 'started', 'started' => '1'], + ['uuid' => 'order-vehicle', 'payload_uuid' => 'payload-7', 'driver_assigned_uuid' => null, 'vehicle_assigned_uuid' => 'vehicle-1', 'status' => 'started', 'started' => '1'], + ]); + $connection->table('orders')->insert(['uuid' => 'order-job', 'company_uuid' => 'company-1', 'customer_uuid' => 'contact-1111', 'customer_type' => TRACKING_PUBLISHER_CONTACT, 'payload_uuid' => 'payload-8', 'status' => 'enroute', 'started' => '1']); + + return $connection; +} + +function trackingPublisherModel(string $class, array $attributes): EloquentModel +{ + $model = (new ReflectionClass($class))->newInstanceWithoutConstructor(); + $model->setRawAttributes($attributes, true); + + return $model; +} + +function trackingPublisherWaypoint(string $publicId, ?string $customerUuid): Waypoint +{ + return trackingPublisherModel(Waypoint::class, ['uuid' => 'uuid-' . $publicId, 'public_id' => $publicId, 'payload_uuid' => 'payload-1', 'customer_uuid' => $customerUuid, 'customer_type' => $customerUuid ? TRACKING_PUBLISHER_CONTACT : null]); +} + +function trackingPublisherStops(bool $janeDone = false): Collection +{ + $stop = fn (string $type, int $sequence, bool $completed, ?Waypoint $waypoint) => new TrackingStop( + uuid: 'place-' . $sequence, + publicId: 'place_' . $sequence, + type: $type, + status: $completed ? 'completed' : 'enroute', + place: null, + waypoint: $waypoint, + completed: $completed, + sequence: $sequence, + trackingNumberUuid: 'tn-' . $sequence, + ); + + return collect([ + $stop('pickup', 1, true, null), + $stop('waypoint', 2, $janeDone, trackingPublisherWaypoint('waypoint_jane', 'contact-2222')), + $stop('waypoint', 3, false, trackingPublisherWaypoint('waypoint_own', null)), + ]); +} + +function trackingPublisherOrder(array $attributes = []): Order +{ + $driver = trackingPublisherModel(Driver::class, ['uuid' => 'driver-1', 'online' => true, 'heading' => 90, 'location' => new Point(1.352083, 103.819836), 'updated_at' => Carbon::parse('2026-10-06 10:00:00')]); + $driver->setRelation('user', trackingPublisherModel(User::class, ['name' => 'Ana Lopez'])); + $driver->setRelation('vehicle', null); + + $order = trackingPublisherModel(Order::class, array_merge([ + 'uuid' => 'order-started', + 'company_uuid' => 'company-1', + 'customer_type' => TRACKING_PUBLISHER_CONTACT, + 'customer_uuid' => 'contact-1111', + 'tracking_number_uuid' => 'tn-order', + 'status' => 'enroute', + 'started' => true, + ], $attributes)); + $order->setRelation('driverAssigned', $driver); + $order->setRelation('vehicleAssigned', null); + + return $order; +} + +function trackingPublisherUseStops(Collection $stops, array|Throwable $tracker = []): TrackingPublisherIntelligenceFake +{ + app()->instance(TrackingContextBuilder::class, new TrackingPublisherContextBuilderFake($stops)); + $intelligence = new TrackingPublisherIntelligenceFake($tracker); + app()->instance(TrackingIntelligenceService::class, $intelligence); + + return $intelligence; +} + +afterEach(function () { + config(['broadcasting.connections.socketcluster.auth_key' => null]); + Http::swap(new HttpFactory()); +}); + +test('nothing is queued or published while socket authentication is off', function () { + trackingPublisherBoot(false); + $publisher = new TrackingPublisherProbe(); + $order = trackingPublisherOrder(); + $order->status = 'completed'; + $order->syncChanges(); + + expect(TrackingPublisher::enabled())->toBeFalse() + ->and($publisher->orderChanged($order))->toBeFalse() + ->and($publisher->stopChanged(trackingPublisherWaypoint('waypoint_own', null)))->toBeFalse() + ->and($publisher->driverMoved(trackingPublisherModel(Driver::class, ['uuid' => 'driver-1'])))->toBe(0) + ->and($publisher->vehicleMoved(trackingPublisherModel(Vehicle::class, ['uuid' => 'vehicle-1'])))->toBe(0) + ->and($publisher->publishOrder($order))->toBe(0) + ->and($publisher->publishLocation($order))->toBe(0) + ->and(DispatchRecorder::$dispatched)->toBe([]) + ->and($publisher->sent)->toBe([]); +}); + +test('order changes queue an update with the most significant reason', function () { + trackingPublisherBoot(); + $publisher = new TrackingPublisher(); + $order = trackingPublisherOrder(); + $save = function (array $changes) use ($order, $publisher): bool { + foreach ($changes as $key => $value) { + $order->{$key} = $value; + } + $order->syncChanges(); + $queued = $publisher->orderChanged($order); + $order->syncOriginal(); + + return $queued; + }; + + expect($save(['time' => 900, 'distance' => 4000]))->toBeTrue() + ->and($save(['driver_assigned_uuid' => 'driver-2', 'time' => 950]))->toBeTrue() + ->and($save(['status' => 'completed', 'distance' => 1]))->toBeTrue() + ->and($save(['notes' => 'Leave at the door']))->toBeFalse(); + + $unsaved = trackingPublisherOrder(['uuid' => null]); + $unsaved->status = 'started'; + $unsaved->syncChanges(); + expect($publisher->orderChanged($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'], + ]); +}); + +test('waypoint and entity activity queue an update for their order through the listener', function () { + trackingPublisherBoot(); + $listener = new PublishTrackingUpdates(); + + $waypointEvent = (new ReflectionClass(WaypointActivityChanged::class))->newInstanceWithoutConstructor(); + $waypointEvent->waypoint = trackingPublisherWaypoint('waypoint_own', null); + $entityEvent = (new ReflectionClass(EntityCompleted::class))->newInstanceWithoutConstructor(); + $entityEvent->entity = trackingPublisherModel(Entity::class, ['uuid' => 'entity-1', 'payload_uuid' => 'payload-2']); + $orphanEvent = (new ReflectionClass(EntityCompleted::class))->newInstanceWithoutConstructor(); + $orphanEvent->entity = trackingPublisherModel(Entity::class, ['uuid' => 'entity-2', 'payload_uuid' => 'payload-none']); + $looseEvent = (new ReflectionClass(EntityCompleted::class))->newInstanceWithoutConstructor(); + $looseEvent->entity = trackingPublisherModel(Entity::class, ['uuid' => 'entity-3', 'payload_uuid' => null]); + + expect($listener->handle($waypointEvent))->toBeTrue() + ->and($listener->handle($entityEvent))->toBeTrue() + ->and($listener->handle($orphanEvent))->toBeFalse() + ->and($listener->handle($looseEvent))->toBeFalse() + ->and($listener->handle(new stdClass()))->toBeFalse() + ->and(array_map(fn ($dispatch) => $dispatch['arguments'], DispatchRecorder::$dispatched))->toBe([ + ['order-started', 'stop', 'mysql'], + ['order-status', 'stop', 'mysql'], + ]); +}); + +test('positions queue at most one location update per order and interval', function () { + trackingPublisherBoot(); + $publisher = new TrackingPublisher(); + $driver = Driver::where('uuid', 'driver-1')->first(); + + // Every en-route order of the driver: started by flag, by status or by timestamp. + expect($publisher->driverMoved($driver))->toBe(3) + ->and(array_map(fn ($dispatch) => $dispatch['arguments'], DispatchRecorder::$dispatched))->toBe([ + ['order-started', 'location', 'mysql'], + ['order-status', 'location', 'mysql'], + ['order-timestamp', 'location', 'mysql'], + ]); + + // The same driver again within the interval: no lookup at all. + expect($publisher->driverMoved($driver))->toBe(0); + + // Another report for the same orders within the interval is dropped per order. + Cache::forget('fleetops:tracking:driver:driver-1'); + expect($publisher->driverMoved($driver))->toBe(0); + + // The vehicle reaches orders assigned to it and orders of drivers driving it. + Cache::flush(); + DispatchRecorder::$dispatched = []; + expect($publisher->vehicleMoved(trackingPublisherModel(Vehicle::class, ['uuid' => 'vehicle-1'])))->toBe(4) + ->and(array_column(array_column(DispatchRecorder::$dispatched, 'arguments'), 0))->toEqualCanonicalizing(['order-started', 'order-status', 'order-timestamp', 'order-vehicle']) + ->and($publisher->vehicleMoved(trackingPublisherModel(Vehicle::class, ['uuid' => 'vehicle-1'])))->toBe(0); + + // Records without a uuid are ignored. + expect($publisher->driverMoved(trackingPublisherModel(Driver::class, [])))->toBe(0) + ->and($publisher->vehicleMoved(trackingPublisherModel(Vehicle::class, [])))->toBe(0); +}); + +test('a full update goes to every customer of the order with their own view', function () { + trackingPublisherBoot(); + $intelligence = trackingPublisherUseStops(trackingPublisherStops(), ['route' => ['legs' => [['stop' => ['sequence' => 3], 'eta_seconds' => 600, 'eta_at' => '2026-10-06T10:11:00.000000Z']]]]); + $publisher = new TrackingPublisherProbe(); + $order = trackingPublisherOrder(); + + expect($publisher->publishOrder($order, 'stop'))->toBe(2) + ->and($intelligence->calls)->toBe(1); + + [$ownChannel, $ownPayload] = $publisher->sent[0]; + [$janeChannel, $janePayload] = $publisher->sent[1]; + + expect($ownChannel)->toBe(TrackingChannel::name(TrackingScope::forOrder($order))) + ->and($janeChannel)->toBe(TrackingChannel::name(TrackingScope::forStop($order, trackingPublisherWaypoint('waypoint_jane', 'contact-2222')))) + ->and($ownPayload['reason'])->toBe('stop') + ->and(array_column($ownPayload['data']['stops'], 'id'))->toBe([null, 'waypoint_own']) + ->and($ownPayload['data']['stops'][1]['eta']['seconds'])->toBe(600) + ->and(array_column($janePayload['data']['stops'], 'id'))->toBe(['waypoint_jane']) + ->and(json_encode($janePayload))->not->toContain('waypoint_own'); + + // A publish the socket server refuses is not counted. + $publisher->accepts = false; + expect($publisher->publishOrder($order))->toBe(0); +}); + +test('a position update goes only to customers still waiting for the vehicle', function () { + trackingPublisherBoot(); + trackingPublisherUseStops(trackingPublisherStops(true)); + $publisher = new TrackingPublisherProbe(); + $order = trackingPublisherOrder(); + + expect($publisher->publishLocation($order))->toBe(1) + ->and($publisher->sent[0][0])->toBe(TrackingChannel::name(TrackingScope::forOrder($order))) + ->and($publisher->sent[0][1]['event'])->toBe('tracking.location') + ->and($publisher->sent[0][1]['data']['location'])->toBe(['latitude' => 1.3521, 'longitude' => 103.8198, 'heading' => 90, 'seen_at' => '2026-10-06T10:00:00.000000Z']); +}); + +test('etas come from tracking intelligence unless the order is over or it fails', function () { + trackingPublisherBoot(); + $publisher = new TrackingPublisherProbe(); + + $intelligence = trackingPublisherUseStops(collect(), ['route' => ['legs' => []]]); + expect($publisher->tracker(trackingPublisherOrder()))->toBe(['route' => ['legs' => []]]); + + expect($publisher->tracker(trackingPublisherOrder(['status' => 'Canceled'])))->toBe([]) + ->and($intelligence->calls)->toBe(1); + + trackingPublisherUseStops(collect(), new RuntimeException('routing provider down')); + expect($publisher->tracker(trackingPublisherOrder()))->toBe([]); +}); + +test('updates are published through the socket server publish endpoint', function () { + trackingPublisherBoot(); + Http::fake(['*' => Http::response(['published' => 1], 202)]); + + expect((new TrackingPublisherProbe())->realSend('tracking.abcdefghijklmnopqrstuvwxyz', ['event' => 'tracking.updated']))->toBeTrue(); + + Http::assertSent(fn ($request) => str_contains((string) $request->url(), '/publish') && str_contains($request->body(), 'tracking.abcdefghijklmnopqrstuvwxyz')); +}); + +test('the queued job publishes the right kind of update', function () { + trackingPublisherBoot(); + trackingPublisherUseStops(trackingPublisherStops()); + $publisher = new TrackingPublisherProbe(); + + // A missing order publishes nothing. + expect((new PublishTrackingUpdate('order-gone'))->handle($publisher))->toBe(0); + + // A status update publishes the full update to both customers. + expect((new PublishTrackingUpdate('order-job', 'status', 'mysql'))->handle($publisher))->toBe(2) + ->and(array_column(array_column($publisher->sent, 1), 'reason'))->toBe(['status', 'status']); + + // The first position in an ETA interval refreshes the full update... + $publisher->sent = []; + expect((new PublishTrackingUpdate('order-job', 'location'))->handle($publisher))->toBe(2) + ->and(array_column(array_column($publisher->sent, 1), 'reason'))->toBe(['eta', 'eta']); + + // ...later ones publish the position only, and this order has no vehicle to show. + $publisher->sent = []; + expect((new PublishTrackingUpdate('order-job', 'location'))->handle($publisher))->toBe(0) + ->and($publisher->sent)->toBe([]); + + $job = new PublishTrackingUpdate('order-job'); + expect($job->reason)->toBe('status') + ->and($job->dbConnection)->toBeNull() + ->and($job->tries)->toBe(1); +}); diff --git a/server/tests/Unit/Support/TrackingUpdateTest.php b/server/tests/Unit/Support/TrackingUpdateTest.php new file mode 100644 index 000000000..85e1df085 --- /dev/null +++ b/server/tests/Unit/Support/TrackingUpdateTest.php @@ -0,0 +1,363 @@ +newInstanceWithoutConstructor(); + $model->setRawAttributes($attributes, true); + + return $model; +} + +function trackingUpdatePlace(string $name, string $address): Place +{ + return trackingUpdateModel(Place::class, ['uuid' => 'place-' . md5($name), 'public_id' => 'place_' . substr(md5($name), 0, 7), 'name' => $name, 'street1' => $address, 'location' => new Point(1.4, 103.9)]); +} + +function trackingUpdateWaypoint(string $publicId, ?string $customerUuid): Waypoint +{ + return trackingUpdateModel(Waypoint::class, [ + 'uuid' => 'uuid-' . $publicId, + 'public_id' => $publicId, + 'customer_uuid' => $customerUuid, + 'customer_type' => $customerUuid ? TRACKING_UPDATE_CONTACT : null, + ]); +} + +function trackingUpdateStop(string $type, int $sequence, bool $completed, ?Waypoint $waypoint, string $trackingNumber, string $placeName): TrackingStop +{ + return new TrackingStop( + uuid: 'place-' . $sequence, + publicId: 'place_' . $sequence, + type: $type, + status: $completed ? 'completed' : 'enroute', + place: trackingUpdatePlace($placeName, $placeName . ' Street'), + waypoint: $waypoint, + completed: $completed, + sequence: $sequence, + trackingNumberUuid: $trackingNumber, + ); +} + +/** + * A started order for contact-1111 sharing its route with contact-2222: + * pickup (theirs, done), Jane's stop, their own drop, Jane's second stop. + */ +function trackingUpdateStops(bool $janeDone = false): array +{ + return [ + trackingUpdateStop('pickup', 1, true, null, 'tn-pickup', 'Acme Warehouse'), + trackingUpdateStop('waypoint', 2, $janeDone, trackingUpdateWaypoint('waypoint_jane1', 'contact-2222'), 'tn-jane1', 'Jane Home'), + trackingUpdateStop('waypoint', 3, false, trackingUpdateWaypoint('waypoint_own', null), 'tn-own', 'Own Office'), + trackingUpdateStop('waypoint', 4, $janeDone, trackingUpdateWaypoint('waypoint_jane2', 'contact-2222'), 'tn-jane2', 'Jane Gym'), + ]; +} + +function trackingUpdateDriver(array $attributes = [], string $class = Driver::class): Driver +{ + $driver = trackingUpdateModel($class, array_merge([ + 'uuid' => 'driver-1111', + 'public_id' => 'driver_1111', + 'online' => true, + 'heading' => 87.6, + 'location' => new Point(1.352083, 103.819836), + 'phone' => '+6591234567', + 'updated_at' => Carbon::parse('2026-10-06 10:00:30'), + ], $attributes)); + $driver->setRelation('user', trackingUpdateModel(User::class, ['name' => 'Ana Lopez', 'phone' => '+6591234567', 'avatar_url' => 'https://cdn.example/ana.jpg'])); + $driver->setRelation('vehicle', null); + + return $driver; +} + +function trackingUpdateVehicle(array $attributes = []): Vehicle +{ + return trackingUpdateModel(Vehicle::class, array_merge([ + 'uuid' => 'vehicle-1111', + 'name' => 'Van 7', + 'plate_number' => 'SGX1234A', + 'heading' => 10, + 'location' => new Point(1.30001, 103.80001), + 'updated_at' => Carbon::parse('2026-10-06 10:00:00'), + ], $attributes)); +} + +function trackingUpdateOrder(array $attributes = [], ?Driver $driver = null, ?Vehicle $vehicle = null): Order +{ + $order = trackingUpdateModel(Order::class, array_merge([ + 'uuid' => 'order-1111', + 'company_uuid' => 'company-1111', + 'customer_type' => TRACKING_UPDATE_CONTACT, + 'customer_uuid' => 'contact-1111', + 'tracking_number_uuid' => 'tn-order', + 'status' => 'started', + 'started' => true, + ], $attributes)); + $order->setRelation('driverAssigned', $driver); + $order->setRelation('vehicleAssigned', $vehicle); + + return $order; +} + +function trackingUpdateTimelines(): array +{ + $entry = fn (string $status, string $code) => ['status' => $status, 'code' => $code, 'complete' => false, 'created_at' => '2026-10-06T09:00:00.000000Z']; + + return [ + 'tn-order' => [$entry('Order started', 'started')], + 'tn-pickup' => [$entry('Picked up', 'picked_up')], + 'tn-own' => [$entry('Driver en route', 'enroute')], + 'tn-jane1' => [$entry('Arriving at Jane Home', 'enroute')], + 'tn-jane2' => [$entry('Queued for Jane Gym', 'pending')], + ]; +} + +function trackingUpdateEtas(): array +{ + return [ + 2 => ['seconds' => 300, 'at' => '2026-10-06T10:05:00.000000Z', 'distance_m' => 1200], + 3 => ['seconds' => 600, 'at' => '2026-10-06T10:10:00.000000Z', 'distance_m' => 2500], + 4 => ['seconds' => 900, 'at' => '2026-10-06T10:15:00.000000Z', 'distance_m' => 4100], + ]; +} + +beforeEach(function () { + Carbon::setTestNow('2026-10-06 10:01:00'); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +test('the order customer sees only their own stops with whole-route counts', function () { + $order = trackingUpdateOrder([], trackingUpdateDriver(), trackingUpdateVehicle()); + $scope = TrackingScope::forOrder($order); + $update = new TrackingUpdate($order, $scope, trackingUpdateStops(), trackingUpdateEtas(), trackingUpdateTimelines()); + + $payload = $update->toArray(TrackingUpdate::REASON_STOP); + + expect($payload['event'])->toBe('tracking.updated') + ->and($payload['reason'])->toBe('stop') + ->and($payload['sent_at'])->toBe('2026-10-06T10:01:00.000000Z') + ->and($payload['data']['order'])->toBe([ + 'status' => 'started', + 'en_route' => true, + 'completed' => false, + 'canceled' => false, + 'timeline' => trackingUpdateTimelines()['tn-order'], + ]) + ->and($payload['data']['route'])->toBe(['total_stops' => 4, 'completed_stops' => 1, 'stops_before' => 1]) + ->and($payload['data']['stops'])->toBe([ + ['id' => null, 'type' => 'pickup', 'sequence' => 1, 'status' => 'completed', 'complete' => true, 'eta' => null, 'timeline' => trackingUpdateTimelines()['tn-pickup']], + ['id' => 'waypoint_own', 'type' => 'waypoint', 'sequence' => 3, 'status' => 'enroute', 'complete' => false, 'eta' => trackingUpdateEtas()[3], 'timeline' => trackingUpdateTimelines()['tn-own']], + ]) + ->and($payload['data']['driver'])->toBe(['name' => 'Ana', 'vehicle' => 'Van 7', 'online' => true]) + ->and($payload['data']['location'])->toBe(['latitude' => 1.3521, 'longitude' => 103.8198, 'heading' => 88, 'seen_at' => '2026-10-06T10:00:30.000000Z']) + ->and($update->timelineTrackingNumbers())->toBe(['tn-pickup', 'tn-own', 'tn-order']); + + // Nothing of the other customer's stops, places or people, and nothing personal about the driver. + $json = json_encode($payload); + foreach (['waypoint_jane1', 'waypoint_jane2', 'Jane', 'contact-2222', 'Acme Warehouse', 'Own Office', 'Street', 'Lopez', '+6591234567', 'ana.jpg', 'SGX1234A', 'tn-'] as $forbidden) { + expect($json)->not->toContain($forbidden); + } +}); + +test('a waypoint customer sees only their stops and never the order timeline', function () { + $order = trackingUpdateOrder([], trackingUpdateDriver(), trackingUpdateVehicle()); + $scope = TrackingScope::forStop($order, trackingUpdateWaypoint('waypoint_jane1', 'contact-2222')); + $update = new TrackingUpdate($order, $scope, trackingUpdateStops(), trackingUpdateEtas(), trackingUpdateTimelines()); + + $payload = $update->toArray(); + + expect($payload['reason'])->toBe('status') + ->and(array_column($payload['data']['stops'], 'id'))->toBe(['waypoint_jane1', 'waypoint_jane2']) + ->and(array_column($payload['data']['stops'], 'eta'))->toBe([trackingUpdateEtas()[2], trackingUpdateEtas()[4]]) + ->and($payload['data']['stops'][0]['timeline'])->toBe(trackingUpdateTimelines()['tn-jane1']) + ->and($payload['data']['order']['timeline'])->toBe([]) + ->and($payload['data']['route'])->toBe(['total_stops' => 4, 'completed_stops' => 1, 'stops_before' => 0]) + ->and($update->timelineTrackingNumbers())->toBe(['tn-jane1', 'tn-jane2']) + ->and($payload['data']['location'])->not->toBeNull(); + + $json = json_encode($payload); + foreach (['waypoint_own', 'contact-1111', 'Order started', 'Picked up', 'Driver en route', 'Acme', 'Own Office', 'Lopez', '+6591234567', 'ana.jpg'] as $forbidden) { + expect($json)->not->toContain($forbidden); + } +}); + +test('the driver and position are shown only while en route to a stop of the viewer', function () { + $driver = trackingUpdateDriver(); + $vehicle = trackingUpdateVehicle(); + + // Not started yet: no driver and no position. + $order = trackingUpdateOrder(['status' => 'dispatched', 'started' => false], $driver, $vehicle); + $update = new TrackingUpdate($order, TrackingScope::forOrder($order), trackingUpdateStops()); + expect($update->isEnRoute())->toBeFalse() + ->and($update->driver())->toBeNull() + ->and($update->location())->toBeNull() + ->and($update->locationPayload())->toBeNull(); + + // Started by status or timestamp alone. + $byStatus = trackingUpdateOrder(['status' => 'started', 'started' => false], $driver, $vehicle); + $byTime = trackingUpdateOrder(['status' => 'enroute', 'started' => false, 'started_at' => Carbon::parse('2026-10-06 09:00:00')], $driver, $vehicle); + expect((new TrackingUpdate($byStatus, TrackingScope::forOrder($byStatus), trackingUpdateStops()))->isEnRoute())->toBeTrue() + ->and((new TrackingUpdate($byTime, TrackingScope::forOrder($byTime), trackingUpdateStops()))->isEnRoute())->toBeTrue(); + + // Delivered or canceled: hidden, and the order state says so. + foreach (['completed', 'canceled'] as $status) { + $done = trackingUpdateOrder(['status' => $status], $driver, $vehicle); + $payload = (new TrackingUpdate($done, TrackingScope::forOrder($done), trackingUpdateStops()))->toArray(); + expect($payload['data']['driver'])->toBeNull() + ->and($payload['data']['location'])->toBeNull() + ->and($payload['data']['order'][$status])->toBeTrue() + ->and($payload['data']['order']['en_route'])->toBeFalse(); + } + + // En route, but every stop of this customer is done: hidden for them, shown to the other. + $order = trackingUpdateOrder([], $driver, $vehicle); + $jane = new TrackingUpdate($order, TrackingScope::forStop($order, trackingUpdateWaypoint('waypoint_jane1', 'contact-2222')), trackingUpdateStops(true)); + $own = new TrackingUpdate($order, TrackingScope::forOrder($order), trackingUpdateStops(true)); + expect($jane->customerHasIncompleteStop())->toBeFalse() + ->and($jane->locationPayload())->toBeNull() + ->and($jane->route()['stops_before'])->toBe(0) + ->and($own->locationPayload()['event'])->toBe('tracking.location') + ->and($own->locationPayload()['reason'])->toBe('location') + ->and($own->locationPayload()['data'])->toBe([ + 'driver' => ['name' => 'Ana', 'vehicle' => 'Van 7', 'online' => true], + 'location' => ['latitude' => 1.3521, 'longitude' => 103.8198, 'heading' => 88, 'seen_at' => '2026-10-06T10:00:30.000000Z'], + ]); + + // A customer with no stops on the route sees no vehicle. + $stranger = new TrackingUpdate($order, new TrackingScope('order-1111', TRACKING_UPDATE_CONTACT, 'contact-9999'), trackingUpdateStops()); + expect($stranger->ownStops())->toHaveCount(0) + ->and($stranger->location())->toBeNull() + ->and($stranger->toArray()['data']['stops'])->toBe([]); +}); + +test('the position comes from the most recently seen of driver and vehicle', function () { + $stops = trackingUpdateStops(); + + // The vehicle reported last. + $order = trackingUpdateOrder([], trackingUpdateDriver(['updated_at' => Carbon::parse('2026-10-06 09:00:00')]), trackingUpdateVehicle(['updated_at' => Carbon::parse('2026-10-06 10:00:50')])); + expect((new TrackingUpdate($order, TrackingScope::forOrder($order), $stops))->location()) + ->toBe(['latitude' => 1.3, 'longitude' => 103.8, 'heading' => 10, 'seen_at' => '2026-10-06T10:00:50.000000Z']); + + // The driver's position is unusable (null island), so the vehicle's is used. + $order = trackingUpdateOrder([], trackingUpdateDriver(['location' => new Point(0, 0)]), trackingUpdateVehicle(['heading' => 'n/a', 'updated_at' => null])); + expect((new TrackingUpdate($order, TrackingScope::forOrder($order), $stops))->location()) + ->toBe(['latitude' => 1.3, 'longitude' => 103.8, 'heading' => null, 'seen_at' => null]); + + // Unreadable or missing positions everywhere: no position, but the driver is still named. + $order = trackingUpdateOrder([], trackingUpdateDriver([], TrackingUpdateExplodingDriver::class), trackingUpdateVehicle(['location' => null])); + $update = new TrackingUpdate($order, TrackingScope::forOrder($order), $stops); + expect($update->location())->toBeNull() + ->and($update->locationPayload())->toBeNull() + ->and($update->driver()['name'])->toBe('Ana'); + + // Without a driver, the assigned vehicle alone gives the position. + $order = trackingUpdateOrder([], null, trackingUpdateVehicle()); + $update = new TrackingUpdate($order, TrackingScope::forOrder($order), $stops); + expect($update->driver())->toBeNull() + ->and($update->location()['latitude'])->toBe(1.3); + + // Without an assigned vehicle the driver's current vehicle is labelled; an unnamed one has no label. + $driver = trackingUpdateDriver(); + $driver->setRelation('vehicle', trackingUpdateVehicle(['name' => 'Truck 9'])); + $order = trackingUpdateOrder([], $driver, null); + expect((new TrackingUpdate($order, TrackingScope::forOrder($order), $stops))->driver()['vehicle'])->toBe('Truck 9'); + + $driver->setRelation('vehicle', trackingUpdateVehicle(['name' => null])); + expect((new TrackingUpdate($order, TrackingScope::forOrder($order), $stops))->driver()['vehicle'])->toBeNull(); + + // Nobody assigned at all. + $order = trackingUpdateOrder([], null, null); + expect((new TrackingUpdate($order, TrackingScope::forOrder($order), $stops))->location())->toBeNull(); +}); + +test('order state, names and etas tolerate missing values', function () { + $order = trackingUpdateOrder(['status' => null, 'started' => false], null, null); + $update = new TrackingUpdate($order, TrackingScope::forOrder($order), ['not a stop', ...trackingUpdateStops()]); + + expect($update->toArray()['data']['order']['status'])->toBeNull() + ->and($update->route()['total_stops'])->toBe(4) + ->and($update->isTerminal())->toBeFalse() + ->and(TrackingUpdate::firstName(null))->toBeNull() + ->and(TrackingUpdate::firstName(' '))->toBeNull() + ->and(TrackingUpdate::firstName('Cher'))->toBe('Cher') + ->and(TrackingUpdate::firstName(' Ana Maria Lopez '))->toBe('Ana'); + + expect(TrackingUpdate::etasFromTracker([]))->toBe([]) + ->and(TrackingUpdate::etasFromTracker(['route' => ['legs' => [ + ['stop' => null, 'eta_seconds' => 10], + ['stop' => ['sequence' => 2], 'eta_seconds' => 299.6, 'eta_at' => '2026-10-06T10:05:00.000000Z', 'distance_m' => 1199.7], + ['stop' => ['sequence' => '3'], 'eta_seconds' => null, 'eta_at' => null], + ]]]))->toBe([ + 2 => ['seconds' => 300, 'at' => '2026-10-06T10:05:00.000000Z', 'distance_m' => 1200], + 3 => ['seconds' => null, 'at' => null, 'distance_m' => null], + ]); +}); + +test('timelines are loaded for the viewer\'s tracking numbers only, without free-text details', function () { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + $connection->getSchemaBuilder()->create('tracking_statuses', function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'tracking_number_uuid', 'status', 'details', 'code', 'complete'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamp('created_at')->nullable(); + $blueprint->timestamp('updated_at')->nullable(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + $connection->table('tracking_statuses')->insert([ + ['uuid' => 's1', 'tracking_number_uuid' => 'tn-order', 'status' => 'Order created', 'details' => 'Created by Jane', 'code' => 'CREATED', 'complete' => '0', 'created_at' => '2026-10-06 08:00:00'], + ['uuid' => 's2', 'tracking_number_uuid' => 'tn-order', 'status' => 'Order started', 'details' => 'Driver left Acme', 'code' => 'started', 'complete' => '0', 'created_at' => '2026-10-06 09:00:00'], + ['uuid' => 's3', 'tracking_number_uuid' => 'tn-own', 'status' => 'Driver en route', 'details' => null, 'code' => null, 'complete' => '0', 'created_at' => null], + ['uuid' => 's4', 'tracking_number_uuid' => 'tn-jane1', 'status' => 'Arriving at Jane Home', 'details' => null, 'code' => 'enroute', 'complete' => '0', 'created_at' => '2026-10-06 09:30:00'], + ]); + + $order = trackingUpdateOrder([], trackingUpdateDriver(), trackingUpdateVehicle()); + $tracker = ['route' => ['legs' => [['stop' => ['sequence' => 3], 'eta_seconds' => 600, 'eta_at' => '2026-10-06T10:11:00.000000Z', 'distance_m' => 2500]]]]; + $update = TrackingUpdate::for($order, TrackingScope::forOrder($order), trackingUpdateStops(), $tracker); + $payload = $update->toArray(); + + expect($payload['data']['order']['timeline'])->toBe([ + ['status' => 'Order created', 'code' => 'created', 'complete' => false, 'created_at' => '2026-10-06T08:00:00.000000Z'], + ['status' => 'Order started', 'code' => 'started', 'complete' => false, 'created_at' => '2026-10-06T09:00:00.000000Z'], + ]) + ->and($payload['data']['stops'][1]['timeline'])->toBe([['status' => 'Driver en route', 'code' => null, 'complete' => false, 'created_at' => null]]) + ->and($payload['data']['stops'][1]['eta'])->toBe(['seconds' => 600, 'at' => '2026-10-06T10:11:00.000000Z', 'distance_m' => 2500]) + ->and(json_encode($payload))->not->toContain('Jane') + ->and(json_encode($payload))->not->toContain('Acme') + ->and(TrackingUpdate::loadTimelines([]))->toBe([]); +}); diff --git a/tests/unit/controllers/operations/scheduler/index-test.js b/tests/unit/controllers/operations/scheduler/index-test.js new file mode 100644 index 000000000..6dd83a66a --- /dev/null +++ b/tests/unit/controllers/operations/scheduler/index-test.js @@ -0,0 +1,131 @@ +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { settled } from '@ember/test-helpers'; +import { setupTest } from 'dummy/tests/helpers'; + +class SocketStub extends Service { + listened = []; + callbacks = {}; + channels = []; + closed = []; + // Channels named here are only "subscribed" when subscribeNow() runs, like the real + // service, which subscribes a moment after listen() returns. + deferred = new Set(); + + listen(name, callback) { + this.listened.push(name); + this.callbacks[name] = callback; + if (!this.deferred.has(name)) { + this.subscribeNow(name); + } + + return Promise.resolve(); + } + + subscribeNow(name) { + this.channels = [...this.channels, { name, close: () => this.closed.push(name) }]; + } + + closeChannels() { + throw new Error('the scheduler must not close every channel'); + } +} + +class StoreStub extends Service { + pushed = []; + + pushPayload(modelName, payload) { + this.pushed.push([modelName, payload]); + } + + peekAll() { + return []; + } +} + +module('Unit | Controller | operations/scheduler/index', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:socket', SocketStub); + this.owner.register('service:store', StoreStub); + this.socket = this.owner.lookup('service:socket'); + this.controller = this.owner.lookup('controller:operations/scheduler/index'); + this.controller.drivers = [{ id: 'driver_a' }, { id: 'driver_b' }, {}]; + }); + + test('it listens on each driver channel and not on a company orders channel', async function (assert) { + await this.controller.subscribeToRealTimeUpdates(); + await this.controller.subscribeToRealTimeUpdates(); + + assert.deepEqual(this.socket.listened, ['driver.driver_a', 'driver.driver_b'], 'one listener per driver, opened once'); + assert.notOk( + this.socket.listened.some((name) => name.startsWith('company.')), + 'the dead company orders channel is gone' + ); + }); + + test('teardown closes only the channels the board opened', async function (assert) { + this.socket.subscribeNow('chat_channel.abc'); + await this.controller.subscribeToRealTimeUpdates(); + + this.controller.unsubscribeFromRealTimeUpdates(); + await settled(); + + assert.deepEqual(this.socket.closed.sort(), ['driver.driver_a', 'driver.driver_b'], 'chat and other channels stay open'); + + this.controller.unsubscribeFromRealTimeUpdates(); + await settled(); + assert.strictEqual(this.socket.closed.length, 2, 'a second teardown has nothing to close'); + }); + + test('teardown also closes a channel the socket subscribed after teardown began', async function (assert) { + this.socket.deferred.add('driver.driver_b'); + await this.controller.subscribeToRealTimeUpdates(); + + this.controller.unsubscribeFromRealTimeUpdates(); + assert.deepEqual(this.socket.closed, ['driver.driver_a']); + + this.socket.subscribeNow('driver.driver_b'); + await settled(); + + assert.deepEqual(this.socket.closed, ['driver.driver_a', 'driver.driver_b']); + }); + + test('the delayed sweep leaves a channel the board opened again', async function (assert) { + this.socket.deferred.add('driver.driver_b'); + await this.controller.subscribeToRealTimeUpdates(); + this.controller.unsubscribeFromRealTimeUpdates(); + + // The board is entered again before the sweep runs. + await this.controller.subscribeToRealTimeUpdates(); + this.socket.subscribeNow('driver.driver_b'); + await settled(); + + assert.deepEqual(this.socket.closed, ['driver.driver_a']); + }); + + test('teardown prefers closing channels by name when the socket service supports it', async function (assert) { + const closedByName = []; + this.socket.closeChannel = (name) => closedByName.push(name); + await this.controller.subscribeToRealTimeUpdates(); + + this.controller.unsubscribeFromRealTimeUpdates(); + await settled(); + + assert.deepEqual(closedByName, ['driver.driver_a', 'driver.driver_b']); + assert.deepEqual(this.socket.closed, []); + }); + + test('driver events update the store but location pings do not', async function (assert) { + const store = this.owner.lookup('service:store'); + await this.controller.subscribeToRealTimeUpdates(); + + const callback = this.socket.callbacks['driver.driver_a']; + callback({ event: 'driver.location_changed', data: { id: 'driver_a' } }); + callback({ event: 'driver.updated', data: {} }); + callback({ event: 'driver.updated', data: { id: 'driver_a', status: 'active' } }); + + assert.deepEqual(store.pushed, [['driver', { driver: { id: 'driver_a', status: 'active' } }]]); + }); +});