From 9b8c582d62e83e54687a5fe029666cc9fa648e93 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:07:04 +0800 Subject: [PATCH 1/3] feat(socket-auth): fleetops channel resolvers, driver socket tokens, per-customer tracking channel - Register who may subscribe to every FleetOps channel prefix with the core SocketChannelRegistry: company match for console users and API credentials; narrow rules for drivers (own record, current vehicle and its devices and positions, assigned orders incl. via payload entities) and customers (own orders, driver and vehicle of their active orders). tracking.* is denied by the registry (reachable only through a scoped tracking token). - Claim Sanctum users who drive for the current company as driver socket principals on v1/socket/token. - positions/replay validates channel_id with the ChannelAuthorizer when socket authentication is on. - Public tracking channel: TrackingScope (one customer of one order), TrackingChannel (name/opaque id/token via core-api), TrackingUpdate (sanitized per-customer payload) and TrackingPublisher (gated, throttled, queued) publishing on order, stop and position changes. - Require fleetbase/core-api ^1.6.69. --- composer.json | 2 +- .../Controllers/Api/v1/DriverController.php | 2 + .../Controllers/Api/v1/VehicleController.php | 2 + .../Internal/v1/PositionController.php | 22 + server/src/Jobs/PublishTrackingUpdate.php | 50 +++ .../src/Listeners/PublishTrackingUpdates.php | 27 ++ server/src/Observers/OrderObserver.php | 4 + server/src/Providers/EventServiceProvider.php | 11 + .../src/Providers/FleetOpsServiceProvider.php | 11 + server/src/Support/SocketChannels.php | 346 +++++++++++++++ .../Support/Telematics/TelematicService.php | 2 + server/src/Support/TrackingChannel.php | 55 +++ server/src/Support/TrackingPublisher.php | 249 +++++++++++ server/src/Support/TrackingScope.php | 182 ++++++++ server/src/Support/TrackingUpdate.php | 400 +++++++++++++++++ .../PositionControllerReplayChannelTest.php | 131 ++++++ .../tests/Unit/Support/SocketChannelsTest.php | 394 +++++++++++++++++ .../Unit/Support/TrackingChannelTest.php | 228 ++++++++++ .../Unit/Support/TrackingPublisherTest.php | 417 ++++++++++++++++++ .../tests/Unit/Support/TrackingUpdateTest.php | 363 +++++++++++++++ 20 files changed, 2897 insertions(+), 1 deletion(-) create mode 100644 server/src/Jobs/PublishTrackingUpdate.php create mode 100644 server/src/Listeners/PublishTrackingUpdates.php create mode 100644 server/src/Support/SocketChannels.php create mode 100644 server/src/Support/TrackingChannel.php create mode 100644 server/src/Support/TrackingPublisher.php create mode 100644 server/src/Support/TrackingScope.php create mode 100644 server/src/Support/TrackingUpdate.php create mode 100644 server/tests/Feature/Http/Internal/PositionControllerReplayChannelTest.php create mode 100644 server/tests/Unit/Support/SocketChannelsTest.php create mode 100644 server/tests/Unit/Support/TrackingChannelTest.php create mode 100644 server/tests/Unit/Support/TrackingPublisherTest.php create mode 100644 server/tests/Unit/Support/TrackingUpdateTest.php 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([]); +}); From 15ad8bd88f351a0a32cb1630ef077e06847d00ae Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:07:11 +0800 Subject: [PATCH 2/3] fix(socket-auth): scheduler listens only to driver channels and closes only its own The scheduler subscribed to company.{id}.orders, which nothing publishes to, and its teardown closed every socket channel, chat included. It now opens the per-driver channels only and on teardown closes just those (sweeping once more for subscriptions still pending). --- .../controllers/operations/scheduler/index.js | 59 ++++++-- addon/routes/operations/scheduler/index.js | 2 +- .../operations/scheduler/index-test.js | 128 ++++++++++++++++++ 3 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 tests/unit/controllers/operations/scheduler/index-test.js 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/tests/unit/controllers/operations/scheduler/index-test.js b/tests/unit/controllers/operations/scheduler/index-test.js new file mode 100644 index 000000000..539eb870c --- /dev/null +++ b/tests/unit/controllers/operations/scheduler/index-test.js @@ -0,0 +1,128 @@ +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' } }]]); + }); +}); From 4ba8680f01ce6ad4309fde5ad7defeec3d3cb0ea Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 15:21:05 +0800 Subject: [PATCH 3/3] test(socket-auth): match prettier line wrapping in the scheduler test --- tests/unit/controllers/operations/scheduler/index-test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/controllers/operations/scheduler/index-test.js b/tests/unit/controllers/operations/scheduler/index-test.js index 539eb870c..6dd83a66a 100644 --- a/tests/unit/controllers/operations/scheduler/index-test.js +++ b/tests/unit/controllers/operations/scheduler/index-test.js @@ -59,7 +59,10 @@ module('Unit | Controller | operations/scheduler/index', function (hooks) { 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'); + 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) {