@fleetbase/fleetops-engine 0.6.52 → 0.6.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/addon/components/device/details.hbs +106 -135
  2. package/addon/components/device/details.js +174 -1
  3. package/addon/components/device/panel-header.hbs +2 -2
  4. package/addon/components/layout/fleet-ops-sidebar/operations-monitor.hbs +201 -0
  5. package/addon/components/layout/fleet-ops-sidebar/operations-monitor.js +654 -0
  6. package/addon/components/layout/fleet-ops-sidebar.hbs +19 -233
  7. package/addon/components/layout/fleet-ops-sidebar.js +364 -395
  8. package/addon/components/maintenance-schedule/form.js +1 -0
  9. package/addon/components/order/form/details.hbs +1 -43
  10. package/addon/components/order/form/details.js +18 -53
  11. package/addon/components/order/form/orchestrator-constraints.hbs +40 -0
  12. package/addon/components/order/form/orchestrator-constraints.js +61 -0
  13. package/addon/components/order/form/payload.js +50 -2
  14. package/addon/components/order/form/route.js +15 -0
  15. package/addon/components/order/form/service-rate.hbs +62 -11
  16. package/addon/components/order/form/service-rate.js +124 -2
  17. package/addon/components/order/form.hbs +2 -0
  18. package/addon/components/telematic/details.hbs +7 -1
  19. package/addon/components/telematic/form.js +16 -8
  20. package/addon/components/work-order/details.hbs +5 -1
  21. package/addon/components/work-order/form.hbs +26 -4
  22. package/addon/components/work-order/form.js +5 -1
  23. package/addon/controllers/connectivity/devices/index/details/events.js +244 -1
  24. package/addon/controllers/connectivity/devices/index/details/sensors.js +199 -0
  25. package/addon/controllers/connectivity/devices/index/details/vehicle.js +74 -0
  26. package/addon/controllers/connectivity/devices/index/details.js +12 -0
  27. package/addon/controllers/connectivity/devices/index.js +4 -1
  28. package/addon/controllers/connectivity/telematics/details/attachments.js +220 -54
  29. package/addon/controllers/connectivity/telematics/details/devices.js +90 -41
  30. package/addon/controllers/connectivity/telematics/details/events.js +39 -11
  31. package/addon/controllers/connectivity/telematics/details/sensors.js +39 -19
  32. package/addon/controllers/maintenance/index.js +100 -0
  33. package/addon/controllers/maintenance/work-orders/index.js +18 -1
  34. package/addon/controllers/management/fuel-transactions/index.js +1 -1
  35. package/addon/controllers/management/index.js +88 -0
  36. package/addon/controllers/settings/index.js +142 -0
  37. package/addon/extension.js +21 -0
  38. package/addon/routes/analytics/index.js +3 -0
  39. package/addon/routes/connectivity/devices/index/details/events.js +37 -1
  40. package/addon/routes/connectivity/devices/index/details/sensors.js +35 -0
  41. package/addon/routes/connectivity/devices/index/details/vehicle.js +7 -0
  42. package/addon/routes/connectivity/devices/index.js +3 -0
  43. package/addon/routes/connectivity/telematics/details/attachments.js +72 -9
  44. package/addon/routes/connectivity/telematics/details/devices.js +4 -0
  45. package/addon/routes/connectivity/telematics/details/sensors.js +1 -0
  46. package/addon/routes/connectivity/telematics/details.js +1 -1
  47. package/addon/routes/connectivity/telematics/edit.js +1 -1
  48. package/addon/routes/maintenance/index.js +3 -0
  49. package/addon/routes/maintenance/work-orders/index.js +3 -0
  50. package/addon/routes/settings/index.js +3 -0
  51. package/addon/routes.js +2 -0
  52. package/addon/services/order-creation.js +54 -1
  53. package/addon/services/order-validation.js +3 -2
  54. package/addon/services/service-rate-actions.js +1 -0
  55. package/addon/styles/fleetops-engine.css +1821 -6
  56. package/addon/templates/analytics/index.hbs +14 -0
  57. package/addon/templates/analytics/reports/index.hbs +4 -4
  58. package/addon/templates/connectivity/devices/index/details/events.hbs +85 -1
  59. package/addon/templates/connectivity/devices/index/details/sensors.hbs +73 -0
  60. package/addon/templates/connectivity/devices/index/details/vehicle.hbs +84 -0
  61. package/addon/templates/connectivity/telematics/details/attachments.hbs +198 -121
  62. package/addon/templates/connectivity/telematics/details/devices.hbs +4 -1
  63. package/addon/templates/connectivity/telematics/details/events.hbs +4 -1
  64. package/addon/templates/connectivity/telematics/details/sensors.hbs +3 -0
  65. package/addon/templates/maintenance/index.hbs +113 -0
  66. package/addon/templates/management/fuel-transactions/index/details.hbs +1 -1
  67. package/addon/templates/management/index.hbs +109 -73
  68. package/addon/templates/management/vehicles/index/edit.hbs +1 -1
  69. package/addon/templates/operations/orders/index.hbs +1 -1
  70. package/addon/templates/operations/service-rates/index.hbs +1 -1
  71. package/addon/templates/settings/index.hbs +83 -0
  72. package/addon/templates/settings/routing.hbs +2 -2
  73. package/addon/utils/fleet-ops-options.js +38 -0
  74. package/app/components/order/form/orchestrator-constraints.js +1 -0
  75. package/app/controllers/connectivity/devices/index/details/sensors.js +1 -0
  76. package/app/controllers/connectivity/devices/index/details/vehicle.js +1 -0
  77. package/app/controllers/maintenance/index.js +1 -0
  78. package/app/controllers/management/index.js +1 -0
  79. package/app/controllers/settings/index.js +1 -0
  80. package/app/routes/analytics/index.js +1 -0
  81. package/app/routes/connectivity/devices/index/details/sensors.js +1 -0
  82. package/app/routes/connectivity/devices/index/details/vehicle.js +1 -0
  83. package/app/routes/maintenance/index.js +1 -0
  84. package/app/routes/settings/index.js +1 -0
  85. package/app/templates/analytics/index.js +1 -0
  86. package/app/templates/connectivity/devices/index/details/sensors.js +1 -0
  87. package/app/templates/connectivity/devices/index/details/vehicle.js +1 -0
  88. package/app/templates/maintenance/index.js +1 -0
  89. package/app/templates/management/index.js +1 -0
  90. package/app/templates/settings/index.js +1 -0
  91. package/composer.json +1 -1
  92. package/extension.json +1 -1
  93. package/package.json +3 -3
  94. package/server/migrations/2025_08_28_054930_create_work_orders_table.php +1 -0
  95. package/server/migrations/2026_06_15_000001_normalize_device_attachable_vehicle_morph_types.php +30 -0
  96. package/server/migrations/2026_06_18_000001_add_category_to_work_orders_table.php +32 -0
  97. package/server/seeders/Testing/MaintenanceSeeder.php +5 -4
  98. package/server/src/Console/Commands/FixInvalidPolymorphicRelationTypeNamespaces.php +1 -0
  99. package/server/src/Console/Commands/ProcessMaintenanceTriggers.php +1 -0
  100. package/server/src/Console/Commands/SyncTelematics.php +89 -0
  101. package/server/src/Exceptions/TelematicProviderException.php +13 -2
  102. package/server/src/Http/Controllers/Api/v1/OrderController.php +6 -21
  103. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +112 -11
  104. package/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +16 -5
  105. package/server/src/Http/Controllers/Internal/v1/FleetController.php +9 -0
  106. package/server/src/Http/Controllers/Internal/v1/HubController.php +300 -0
  107. package/server/src/Http/Controllers/Internal/v1/LiveController.php +177 -0
  108. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +1 -0
  109. package/server/src/Http/Controllers/Internal/v1/OrderController.php +4 -32
  110. package/server/src/Http/Controllers/Internal/v1/SearchController.php +506 -0
  111. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +4 -4
  112. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +1 -1
  113. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +94 -10
  114. package/server/src/Http/Filter/DeviceEventFilter.php +44 -0
  115. package/server/src/Http/Filter/DeviceFilter.php +131 -0
  116. package/server/src/Http/Filter/DriverFilter.php +6 -0
  117. package/server/src/Http/Filter/SensorFilter.php +125 -0
  118. package/server/src/Http/Filter/VehicleFilter.php +19 -0
  119. package/server/src/Http/Requests/CreateDriverRequest.php +14 -13
  120. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -0
  121. package/server/src/Http/Resources/v1/Driver.php +11 -2
  122. package/server/src/Http/Resources/v1/WorkOrder.php +1 -0
  123. package/server/src/Jobs/FinalizeApiOrderCreation.php +47 -0
  124. package/server/src/Jobs/FinalizeInternalOrderCreation.php +35 -0
  125. package/server/src/Jobs/NotifyBulkAssignedDriver.php +47 -0
  126. package/server/src/Jobs/SyncTelematicDevicesJob.php +181 -66
  127. package/server/src/Models/Device.php +15 -1
  128. package/server/src/Models/DeviceEvent.php +1 -1
  129. package/server/src/Models/Driver.php +17 -1
  130. package/server/src/Models/Sensor.php +15 -2
  131. package/server/src/Models/Telematic.php +1 -1
  132. package/server/src/Models/Vehicle.php +5 -1
  133. package/server/src/Models/WorkOrder.php +5 -2
  134. package/server/src/Observers/DriverObserver.php +3 -3
  135. package/server/src/Observers/FleetObserver.php +23 -0
  136. package/server/src/Observers/VehicleObserver.php +3 -3
  137. package/server/src/Providers/FleetOpsServiceProvider.php +12 -0
  138. package/server/src/Support/LiveCacheService.php +1 -1
  139. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +188 -28
  140. package/server/src/Support/Telematics/Providers/SafeeProvider.php +44 -3
  141. package/server/src/Support/Telematics/TelematicService.php +330 -30
  142. package/server/src/routes.php +10 -0
  143. package/server/tests/DeviceFilterTest.php +36 -0
  144. package/server/tests/HubRoutesTest.php +62 -0
  145. package/server/tests/OperationsMonitorEndpointTest.php +38 -0
  146. package/server/tests/OrderQueuedClosureRegressionTest.php +36 -0
  147. package/server/tests/TelematicsHardeningTest.php +455 -3
  148. package/server/tests/VehicleDriverAssignmentTest.php +27 -0
  149. package/translations/ar-ae.yml +1 -1
  150. package/translations/en-us.yaml +1 -0
  151. package/translations/vi-vn.yaml +1 -1
@@ -1,6 +1,10 @@
1
1
  <?php
2
2
 
3
3
  use Fleetbase\FleetOps\Contracts\TelematicProviderDescriptor;
4
+ use Fleetbase\FleetOps\Support\Telematics\Providers\AfaqyProvider;
5
+ use Fleetbase\FleetOps\Support\Telematics\Providers\SafeeProvider;
6
+ use Illuminate\Http\Client\ConnectionException;
7
+ use Illuminate\Support\Facades\Http;
4
8
 
5
9
  test('device event model and migration expose lifecycle fields used by telematics workflows', function () {
6
10
  $model = file_get_contents(__DIR__ . '/../src/Models/DeviceEvent.php');
@@ -41,7 +45,19 @@ test('telematics service requires provider identity and stores idempotent event
41
45
 
42
46
  expect($service)
43
47
  ->toContain('Provider device identity is required to link a telematics device.')
48
+ ->toContain('public function ingestDeviceSnapshot')
44
49
  ->toContain('DeviceEvent::firstOrNew([\'_key\' => $eventKey])')
50
+ ->toContain('reconcileDeviceTelemetry')
51
+ ->toContain('PROTECTED_DEVICE_STATUSES')
52
+ ->toContain("'provider_status'")
53
+ ->toContain("'telemetry_summary'")
54
+ ->toContain('connectionStatusForDevice')
55
+ ->toContain('applyDeviceEventTelemetry')
56
+ ->toContain('$event->createPosition($positionData)')
57
+ ->toContain('updateVehicleTelemetry')
58
+ ->toContain('broadcast(new VehicleLocationChanged')
59
+ ->toContain('storeSnapshotSensors')
60
+ ->toContain("\$payload['sensors'] ?? \$payload['sensors_last_val']")
45
61
  ->toContain('protected function makeEventKey')
46
62
  ->toContain('$telematic->public_id ?? $telematic->uuid')
47
63
  ->toContain('resolveWebhookTelematic')
@@ -49,6 +65,19 @@ test('telematics service requires provider identity and stores idempotent event
49
65
  ->toContain('meta->provider_account_id');
50
66
  });
51
67
 
68
+ test('device details render consistent telematics connection state and timestamp', function () {
69
+ $details = file_get_contents(__DIR__ . '/../../addon/components/device/details.hbs');
70
+ $header = file_get_contents(__DIR__ . '/../../addon/components/device/panel-header.hbs');
71
+
72
+ expect($details)
73
+ ->toContain('format-date-fns @resource.last_online_at "dd MMM yyyy, HH:mm"')
74
+ ->not->toContain('format-date @resource.last_online_at');
75
+
76
+ expect($header)
77
+ ->toContain('@resource.is_online')
78
+ ->not->toContain('@resource.online "online"');
79
+ });
80
+
52
81
  test('native providers normalize device payloads to canonical FleetOps keys', function () {
53
82
  $afaqy = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/AfaqyProvider.php');
54
83
  $safee = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/SafeeProvider.php');
@@ -129,19 +158,311 @@ test('device event mark processed action is routed and company scoped', function
129
158
  ->toContain("'Event was already processed.'");
130
159
  });
131
160
 
132
- test('afaqy sync stores compact device diagnostics and does not leak token URLs', function () {
161
+ test('afaqy sync stores compact device diagnostics and paginates complete units lists', function () {
133
162
  $provider = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/AfaqyProvider.php');
134
163
 
135
164
  expect($provider)
165
+ ->toContain('TelematicProviderException')
136
166
  ->toContain('compactLastUpdate')
137
167
  ->toContain("'provider_unit_id'")
138
168
  ->toContain("'plate_number'")
139
169
  ->toContain("'capabilities'")
140
170
  ->not->toContain("'raw' => \$payload")
141
171
  ->not->toContain("'sensors',")
142
- ->not->toContain("?token=' . urlencode")
143
172
  ->toContain("'Authorization' => 'Bearer ' . \$token")
144
- ->toContain("array_merge(['token' => \$this->credentials['token']], \$payload)");
173
+ ->toContain('protected function authenticatedPost')
174
+ ->toContain('protected function refreshToken')
175
+ ->toContain('protected function isTokenRejected')
176
+ ->toContain('protected int $dataTimeout = 120')
177
+ ->toContain('protected int $connectTimeout = 15')
178
+ ->toContain('protected int $connectionTestTimeout = 30')
179
+ ->toContain('protected int $connectionTestConnectTimeout = 10')
180
+ ->toContain('->timeout($timeout)')
181
+ ->toContain('->connectTimeout($connectTimeout)')
182
+ ->toContain('ConnectionException')
183
+ ->toContain('transportErrorContext')
184
+ ->toContain('extractBytesReceived')
185
+ ->toContain("'requested_limit'")
186
+ ->toContain("'requested_offset'")
187
+ ->toContain("'bytes_received'")
188
+ ->toContain('AFAQY token rejected; refreshing token and retrying request')
189
+ ->toContain('AFAQY token rejected after refresh with status')
190
+ ->toContain('AFAQY token rejected and username/password credentials are required to refresh it.')
191
+ ->toContain('providerErrorContext')
192
+ ->toContain("'endpoint'")
193
+ ->toContain("'provider_code'")
194
+ ->toContain("'provider_message'")
195
+ ->toContain("'retry_attempted'")
196
+ ->toContain('?? 500), 500')
197
+ ->toContain('if (is_array($filters) && empty($filters))')
198
+ ->toContain('$filters = new \stdClass();')
199
+ ->toContain("'limit' => \$limit")
200
+ ->toContain("'offset' => \$offset")
201
+ ->not->toContain("\n 'limit' => \$limit,\n 'offset' => \$offset,")
202
+ ->toContain("\$this->afaqyPost('/units/lists', [")
203
+ ->toContain('], true);')
204
+ ->toContain("http_build_query(['token' => \$this->credentials['token']])")
205
+ ->toContain("\$body = \$tokenInQuery ? \$payload : array_merge(['token' => \$this->credentials['token']], \$payload)")
206
+ ->toContain('$pageOffset = (int) ($pagination[\'offset\'] ?? $offset);')
207
+ ->toContain('$pageLimit = (int) ($pagination[\'limit\'] ?? $limit);')
208
+ ->toContain('$advanceBy = $resultCount > 0 ? $resultCount : max($pageLimit, count($devices), 1);')
209
+ ->toContain('$nextCursor = ($pageOffset + $advanceBy) < $total ? $pageOffset + $advanceBy : null;')
210
+ ->toContain("'requested_limit'")
211
+ ->toContain("'provider_limit'")
212
+ ->toContain("'provider_result_count'")
213
+ ->toContain("'pagination' => [")
214
+ ->toContain("'allCount'")
215
+ ->toContain("'filtersCount'")
216
+ ->toContain("'resultCount'")
217
+ ->toContain("'online' => \$payload['active'] ?? null")
218
+ ->toContain("'altitude' => \$lastUpdate['alt'] ?? null");
219
+ });
220
+
221
+ test('afaqy sync keeps default limit and uses extended data request path', function () {
222
+ $requests = [];
223
+
224
+ Http::fake(function ($request) use (&$requests) {
225
+ $requests[] = $request;
226
+
227
+ if (str_ends_with($request->url(), '/auth/login')) {
228
+ return Http::response(['data' => ['token' => 'testing-token']], 200);
229
+ }
230
+
231
+ return Http::response([
232
+ 'data' => [],
233
+ 'pagination' => ['resultCount' => 0],
234
+ ], 200);
235
+ });
236
+
237
+ $provider = new class extends AfaqyProvider {
238
+ public function fetchDevicesForTest(array $credentials): array
239
+ {
240
+ $this->credentials = $credentials;
241
+ $this->prepareAuthentication();
242
+
243
+ return $this->fetchDevices();
244
+ }
245
+ };
246
+
247
+ $result = $provider->fetchDevicesForTest([
248
+ 'base_url' => 'https://api.afaqy.test',
249
+ 'username' => 'testing-user',
250
+ 'password' => 'testing-password',
251
+ ]);
252
+
253
+ expect($result['pagination']['resultCount'])->toBe(0);
254
+ expect($requests)->toHaveCount(2);
255
+ expect($requests[0]->url())->toBe('https://api.afaqy.test/auth/login');
256
+ expect($requests[1]->url())->toContain('/units/lists?token=testing-token');
257
+ expect($requests[1]->body())->toContain('"limit":500');
258
+ });
259
+
260
+ test('afaqy sync timeout failures are converted to sanitized provider metadata', function () {
261
+ Http::fake(function ($request) {
262
+ if (str_ends_with($request->url(), '/auth/login')) {
263
+ return Http::response(['data' => ['token' => 'timeout-testing-token']], 200);
264
+ }
265
+
266
+ throw new ConnectionException('cURL error 28: Operation timed out after 30000 milliseconds with 1186621 bytes received for https://api.afaqy.test/units/lists?token=timeout-testing-token');
267
+ });
268
+
269
+ $provider = new class extends AfaqyProvider {
270
+ public function fetchDevicesForTest(array $credentials): array
271
+ {
272
+ $this->credentials = $credentials;
273
+ $this->prepareAuthentication();
274
+
275
+ return $this->fetchDevices();
276
+ }
277
+ };
278
+
279
+ try {
280
+ $provider->fetchDevicesForTest([
281
+ 'base_url' => 'https://api.afaqy.test',
282
+ 'username' => 'testing-user',
283
+ 'password' => 'testing-password',
284
+ ]);
285
+ } catch (Throwable $e) {
286
+ $result = [
287
+ 'success' => false,
288
+ 'message' => $e->getMessage(),
289
+ 'metadata' => method_exists($e, 'context') ? $e->context() : [],
290
+ ];
291
+ }
292
+
293
+ expect($result['success'])->toBeFalse();
294
+ expect($result['message'])->toBe('AFAQY API request timed out while waiting for provider response.');
295
+ expect($result['metadata'])->toMatchArray([
296
+ 'provider' => 'afaqy',
297
+ 'endpoint' => '/units/lists',
298
+ 'requested_limit' => 500,
299
+ 'requested_offset' => 0,
300
+ 'timeout' => 120,
301
+ 'connect_timeout' => 15,
302
+ 'bytes_received' => 1186621,
303
+ 'retry_attempted' => false,
304
+ 'transport_error' => 'connection_exception',
305
+ ]);
306
+ expect(json_encode($result))
307
+ ->not->toContain('timeout-testing-token')
308
+ ->not->toContain('token=')
309
+ ->not->toContain('testing-password')
310
+ ->not->toContain('testing-user');
311
+ });
312
+
313
+ test('afaqy credential test refreshes token once when units list rejects token', function () {
314
+ $authCount = 0;
315
+ $requests = [];
316
+
317
+ Http::fake(function ($request) use (&$authCount, &$requests) {
318
+ $requests[] = $request;
319
+
320
+ if (str_ends_with($request->url(), '/auth/login')) {
321
+ $authCount++;
322
+
323
+ return Http::response(['data' => ['token' => $authCount === 1 ? 'first-testing-token' : 'second-testing-token']], 200);
324
+ }
325
+
326
+ if (str_contains($request->url(), '/units/lists?token=first-testing-token')) {
327
+ return Http::response(['message' => 'Token expired', 'code' => 'TOKEN_EXPIRED'], 401);
328
+ }
329
+
330
+ return Http::response([
331
+ 'data' => [['_id' => 'unit-1', 'name' => 'Truck 1']],
332
+ 'pagination' => ['resultCount' => 1],
333
+ ], 200);
334
+ });
335
+
336
+ $result = (new AfaqyProvider())->testConnection([
337
+ 'base_url' => 'https://api.afaqy.test',
338
+ 'username' => 'testing-user',
339
+ 'password' => 'testing-password',
340
+ ]);
341
+
342
+ expect($result['success'])->toBeTrue();
343
+ expect($authCount)->toBe(2);
344
+ expect(collect($requests)->filter(fn ($request) => str_contains($request->url(), '/units/lists'))->count())->toBe(2);
345
+ expect($requests[1]->url())->toContain('/units/lists?token=first-testing-token');
346
+ expect($requests[2]->url())->toContain('/auth/login');
347
+ expect($requests[3]->url())->toContain('/units/lists?token=second-testing-token');
348
+ });
349
+
350
+ test('afaqy token rejection failure metadata is sanitized', function () {
351
+ $authCount = 0;
352
+
353
+ Http::fake(function ($request) use (&$authCount) {
354
+ if (str_ends_with($request->url(), '/auth/login')) {
355
+ $authCount++;
356
+
357
+ return Http::response(['data' => ['token' => 'rejected-testing-token-' . $authCount]], 200);
358
+ }
359
+
360
+ return Http::response(['message' => 'Token rejected', 'code' => 'TOKEN_REJECTED'], 401);
361
+ });
362
+
363
+ $result = (new AfaqyProvider())->testConnection([
364
+ 'base_url' => 'https://api.afaqy.test',
365
+ 'username' => 'testing-user',
366
+ 'password' => 'testing-password',
367
+ ]);
368
+
369
+ expect($result['success'])->toBeFalse();
370
+ expect($result['message'])->toBe('AFAQY token rejected after refresh with status 401');
371
+ expect($result['metadata'])->toMatchArray([
372
+ 'provider' => 'afaqy',
373
+ 'endpoint' => '/units/lists',
374
+ 'status_code' => 401,
375
+ 'provider_code' => 'TOKEN_REJECTED',
376
+ 'provider_message' => 'Token rejected',
377
+ 'retry_attempted' => true,
378
+ ]);
379
+ expect(json_encode($result))
380
+ ->not->toContain('rejected-testing-token')
381
+ ->not->toContain('testing-password')
382
+ ->not->toContain('testing-user');
383
+ });
384
+
385
+ test('afaqy supplied token rejection requires password credentials for refresh', function () {
386
+ Http::fake([
387
+ 'https://api.afaqy.test/units/lists?token=static-testing-token' => Http::response(['message' => 'Token rejected'], 401),
388
+ ]);
389
+
390
+ $result = (new AfaqyProvider())->testConnection([
391
+ 'base_url' => 'https://api.afaqy.test',
392
+ 'token' => 'static-testing-token',
393
+ ]);
394
+
395
+ expect($result['success'])->toBeFalse();
396
+ expect($result['message'])->toBe('AFAQY token rejected and username/password credentials are required to refresh it.');
397
+ expect($result['metadata'])->toMatchArray([
398
+ 'provider' => 'afaqy',
399
+ 'endpoint' => '/units/lists',
400
+ 'status_code' => 401,
401
+ 'provider_message' => 'Token rejected',
402
+ 'retry_attempted' => false,
403
+ ]);
404
+ expect(json_encode($result))->not->toContain('static-testing-token');
405
+ });
406
+
407
+ test('telematics device sync records provider pagination and skipped device counts', function () {
408
+ $job = file_get_contents(__DIR__ . '/../src/Jobs/SyncTelematicDevicesJob.php');
409
+ $controller = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/TelematicController.php');
410
+
411
+ expect($controller)
412
+ ->toContain("'limit' => \$request->input('limit')")
413
+ ->not->toContain("'limit' => \$request->input('limit', 100)");
414
+
415
+ expect($job)
416
+ ->toContain('public int $tries = 1')
417
+ ->toContain("'limit' => \$this->options['limit'] ?? null")
418
+ ->toContain('Cache::lock($lockKey, $this->timeout + 60)')
419
+ ->toContain("'fleetops:sync-telematic-devices:' . \$this->telematic->uuid")
420
+ ->toContain('Device discovery skipped because another sync is already running')
421
+ ->toContain("'last_sync_skipped_reason'")
422
+ ->toContain("'sync_already_running'")
423
+ ->toContain('$totalFetched')
424
+ ->toContain('$totalLinked')
425
+ ->toContain('$totalEvents')
426
+ ->toContain('$totalSensors')
427
+ ->toContain('$totalSkipped')
428
+ ->toContain('$pageCount')
429
+ ->toContain('$service->ingestDeviceSnapshot($this->telematic, $provider, $devicePayload)')
430
+ ->toContain('Device discovery page fetched')
431
+ ->toContain("'provider_unit_id'")
432
+ ->toContain("'last_sync_fetched_total'")
433
+ ->toContain("'last_sync_linked_total'")
434
+ ->toContain("'last_sync_events_total'")
435
+ ->toContain("'last_sync_sensors_total'")
436
+ ->toContain("'last_sync_skipped_total'")
437
+ ->toContain("'last_sync_page_count'")
438
+ ->toContain("'last_sync_provider_total'")
439
+ ->toContain("'last_sync_provider_all_count'")
440
+ ->toContain("'last_sync_provider_filters_count'")
441
+ ->toContain("'last_sync_error_context'")
442
+ ->toContain('safeSyncErrorMessage')
443
+ ->toContain('token=|password|client_secret|authorization')
444
+ ->toContain("method_exists(\$e, 'context') ? \$e->context() : []");
445
+ });
446
+
447
+ test('telematics polling command is registered and scheduled for no webhook providers', function () {
448
+ $command = file_get_contents(__DIR__ . '/../src/Console/Commands/SyncTelematics.php');
449
+ $provider = file_get_contents(__DIR__ . '/../src/Providers/FleetOpsServiceProvider.php');
450
+ $details = file_get_contents(__DIR__ . '/../../addon/components/telematic/details.hbs');
451
+
452
+ expect($command)
453
+ ->toContain("protected \$signature = 'fleetops:sync-telematics")
454
+ ->toContain('SyncTelematicDevicesJob::dispatch($telematic')
455
+ ->toContain('!$descriptor->supportsWebhooks')
456
+ ->toContain('$descriptor->supportsDiscovery')
457
+ ->toContain("whereIn('status', ['active', 'connected'])");
458
+
459
+ expect($provider)
460
+ ->toContain('Console\\Commands\\SyncTelematics::class')
461
+ ->toContain("command('fleetops:sync-telematics')->everyMinute()");
462
+
463
+ expect($details)
464
+ ->toContain('Provider polling')
465
+ ->toContain('FleetOps polls this provider for device snapshots and telemetry updates.');
145
466
  });
146
467
 
147
468
  test('native endpoint fields are advanced optional overrides with provider defaults', function () {
@@ -162,6 +483,90 @@ test('native endpoint fields are advanced optional overrides with provider defau
162
483
  }
163
484
  });
164
485
 
486
+ test('safee credential test sends documented form auth request to custom server uri', function () {
487
+ $requests = [];
488
+
489
+ Http::fake(function ($request) use (&$requests) {
490
+ $requests[] = $request;
491
+
492
+ if (str_ends_with($request->url(), '/protocol/openid-connect/token')) {
493
+ return Http::response(['access_token' => 'testing-access-token'], 200);
494
+ }
495
+
496
+ return Http::response([
497
+ 'code' => 0,
498
+ 'time' => 1509946353.033,
499
+ 'status' => 'success',
500
+ 'message' => 'operation completed successfully',
501
+ ], 200);
502
+ });
503
+
504
+ $result = (new SafeeProvider())->testConnection([
505
+ 'server_uri' => ' https://fms.example.test/ ',
506
+ 'realm_id' => 'dsco',
507
+ 'client_id' => 'api',
508
+ 'client_secret' => 'testing-client-secret',
509
+ 'username' => 'testing-user',
510
+ 'password' => 'testing-password',
511
+ ]);
512
+
513
+ expect($result['success'])->toBeTrue();
514
+ expect($result['metadata'])
515
+ ->toMatchArray([
516
+ 'auth_host' => 'https://fms.example.test',
517
+ 'auth_path' => '/auth/realms/dsco/protocol/openid-connect/token',
518
+ 'realm_id' => 'dsco',
519
+ ]);
520
+
521
+ expect($requests)->toHaveCount(2);
522
+
523
+ $tokenRequest = $requests[0];
524
+ parse_str($tokenRequest->body(), $tokenBody);
525
+
526
+ expect($tokenRequest->method())->toBe('POST');
527
+ expect($tokenRequest->url())->toBe('https://fms.example.test/auth/realms/dsco/protocol/openid-connect/token');
528
+ expect(implode(' ', (array) $tokenRequest->header('Content-Type')))->toContain('application/x-www-form-urlencoded');
529
+ expect($tokenBody)->toMatchArray([
530
+ 'grant_type' => 'password',
531
+ 'client_secret' => 'testing-client-secret',
532
+ 'client_id' => 'api',
533
+ 'username' => 'testing-user',
534
+ 'password' => 'testing-password',
535
+ ]);
536
+
537
+ expect($requests[1]->method())->toBe('GET');
538
+ expect($requests[1]->url())->toBe('https://fms.example.test/api/v2/status');
539
+ expect(implode(' ', (array) $requests[1]->header('Authorization')))->toBe('Bearer testing-access-token');
540
+ });
541
+
542
+ test('safee credential test reports token endpoint 401 with sanitized auth context', function () {
543
+ Http::fake([
544
+ 'https://fms.example.test/auth/realms/dsco/protocol/openid-connect/token' => Http::response(['error' => 'unauthorized'], 401),
545
+ ]);
546
+
547
+ $result = (new SafeeProvider())->testConnection([
548
+ 'server_uri' => 'https://fms.example.test',
549
+ 'realm_id' => 'dsco',
550
+ 'client_id' => 'api',
551
+ 'client_secret' => 'testing-client-secret',
552
+ 'username' => 'testing-user',
553
+ 'password' => 'testing-password',
554
+ ]);
555
+
556
+ expect($result['success'])->toBeFalse();
557
+ expect($result['message'])->toBe('Safee authentication failed with status 401');
558
+ expect($result['metadata'])
559
+ ->toMatchArray([
560
+ 'auth_host' => 'https://fms.example.test',
561
+ 'auth_path' => '/auth/realms/dsco/protocol/openid-connect/token',
562
+ 'realm_id' => 'dsco',
563
+ ])
564
+ ->not->toHaveKey('client_secret')
565
+ ->not->toHaveKey('password')
566
+ ->not->toHaveKey('access_token')
567
+ ->not->toHaveKey('refresh_token');
568
+ });
569
+
165
570
  test('telematics activity logging excludes large json and spatial payloads', function () {
166
571
  $device = telematics_activity_log_method(file_get_contents(__DIR__ . '/../src/Models/Device.php'));
167
572
  $sensor = telematics_activity_log_method(file_get_contents(__DIR__ . '/../src/Models/Sensor.php'));
@@ -260,6 +665,53 @@ test('telematics overview attention items render as full width stacked alerts',
260
665
  ->not->toContain('lg:grid-cols-2');
261
666
  });
262
667
 
668
+ test('device attachment morph types are normalized and legacy aliases are tolerated', function () {
669
+ $migration = file_get_contents(__DIR__ . '/../migrations/2026_06_15_000001_normalize_device_attachable_vehicle_morph_types.php');
670
+ $command = file_get_contents(__DIR__ . '/../src/Console/Commands/FixInvalidPolymorphicRelationTypeNamespaces.php');
671
+ $serviceProvider = file_get_contents(__DIR__ . '/../src/Providers/FleetOpsServiceProvider.php');
672
+
673
+ expect($migration)
674
+ ->toContain("whereNotNull('attachable_uuid')")
675
+ ->toContain("'Fleetbase\\\\Models\\\\Vehicle'")
676
+ ->toContain("'\\\\Fleetbase\\\\Models\\\\Vehicle'")
677
+ ->toContain("'attachable_type' => Vehicle::class")
678
+ ->toContain('Intentionally do not restore invalid legacy morph class names.');
679
+
680
+ expect($command)
681
+ ->toContain('\\Fleetbase\\FleetOps\\Models\\Device::class')
682
+ ->toContain("'columns' => ['attachable_type']");
683
+
684
+ expect($serviceProvider)
685
+ ->toContain('use Illuminate\Database\Eloquent\Relations\Relation;')
686
+ ->toContain('$this->registerMorphMap();')
687
+ ->toContain("'Fleetbase\\\\Models\\\\Vehicle' => \\Fleetbase\\FleetOps\\Models\\Vehicle::class")
688
+ ->toContain("'\\\\Fleetbase\\\\Models\\\\Vehicle' => \\Fleetbase\\FleetOps\\Models\\Vehicle::class");
689
+ });
690
+
691
+ test('internal device attachment endpoints return specific api errors instead of raw model misses', function () {
692
+ $vehicleController = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/VehicleController.php');
693
+ $deviceController = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/DeviceController.php');
694
+
695
+ expect($vehicleController)
696
+ ->toContain('$vehicle = $this->resolveVehicle($id);')
697
+ ->toContain('$device = $this->resolveDevice($deviceId);')
698
+ ->toContain('Vehicle not found or not available for this organization.')
699
+ ->toContain('Device not found or not available for this organization.')
700
+ ->toContain('Unable to attach device to vehicle. Please try again or contact support.')
701
+ ->toContain('Unable to detach device from vehicle. Please try again or contact support.')
702
+ ->toContain('logDeviceAttachmentLookupFailure')
703
+ ->toContain('logDeviceAttachmentFailure');
704
+
705
+ expect($deviceController)
706
+ ->toContain('$device = $this->resolveDevice($id);')
707
+ ->toContain('$vehicle = $this->resolveVehicle($vehicleId);')
708
+ ->toContain('Vehicle not found or not available for this organization.')
709
+ ->toContain('Device not found or not available for this organization.')
710
+ ->toContain('Unable to attach device to vehicle. Please try again or contact support.')
711
+ ->toContain('Unable to detach device from vehicle. Please try again or contact support.')
712
+ ->not->toContain('firstOrFail();');
713
+ });
714
+
263
715
  function telematics_activity_log_method(string $model): string
264
716
  {
265
717
  preg_match('/public function getActivitylogOptions\(\): LogOptions\s+\{(?P<body>.*?)\n \}/s', $model, $matches);
@@ -17,6 +17,33 @@ test('vehicle updates preserve driver assignment unless driver input is explicit
17
17
  ->toContain("Driver::where('vehicle_uuid', \$this->uuid)->update(['vehicle_uuid' => null])");
18
18
  });
19
19
 
20
+ test('driver vehicle assignment preserves license expiry', function () {
21
+ $apiController = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Api/v1/DriverController.php');
22
+ $internalController = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/DriverController.php');
23
+ $driverModel = file_get_contents(dirname(__DIR__) . '/src/Models/Driver.php');
24
+ $driverResource = file_get_contents(dirname(__DIR__) . '/src/Http/Resources/v1/Driver.php');
25
+
26
+ expect($apiController)
27
+ ->not->toContain('normalizeDriverUpdateInput')
28
+ ->not->toContain("array_key_exists('license_expiry', \$input)")
29
+ ->and($internalController)
30
+ ->not->toContain('normalizeDriverUpdateInput')
31
+ ->toContain('public function assignVehicle(Request $request, string $id): JsonResponse')
32
+ ->toContain('$driver->assignVehicle($vehicle)')
33
+ ->not->toContain("'license_expiry' => null")
34
+ ->and($driverModel)
35
+ ->toContain('public function setLicenseExpiryAttribute($value): void')
36
+ ->toContain("!empty(\$this->getOriginal('license_expiry'))")
37
+ ->toContain('Carbon::parse($value)->toDateString()')
38
+ ->toContain('public function assignVehicle(Vehicle $vehicle): self')
39
+ ->toContain('$this->setVehicle($vehicle)')
40
+ ->toContain('$this->save()')
41
+ ->not->toContain('$this->license_expiry = null')
42
+ ->and($driverResource)
43
+ ->toContain("'license_expiry' => \$this->formatDateOnly(\$this->license_expiry)")
44
+ ->toContain('protected function formatDateOnly($date): ?string');
45
+ });
46
+
20
47
  test('driver assigned-order workflows expose list and multi-unassign endpoints', function () {
21
48
  $routes = file_get_contents(dirname(__DIR__) . '/src/routes.php');
22
49
  $controller = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/DriverController.php');
@@ -1051,7 +1051,7 @@ fleet-ops:
1051
1051
  more-waypoints: المزيد من نقاط الطريق
1052
1052
  settings-window:
1053
1053
  success-message: تم حفظ الإعدادات بنجاح.
1054
- title: إعدادات FleetOps
1054
+ title: إعدادات Fleet-Ops
1055
1055
  save: حفظ التغييرات
1056
1056
  name: مسافة التنبيه الافتراضية للطلبات الفورية
1057
1057
  name-text: يحدد هذا الإعداد المسافة الافتراضية بالأمتار التي يجب أن تنبه الطلبات الفورية السائقين ضمن محيط نقطة استلام الطلب.
@@ -668,6 +668,7 @@ order:
668
668
  invalid-coordinates: 'Invalid coordinates!'
669
669
  items-drop: Items drop at
670
670
  loading-message: Loading service quotes...
671
+ updating-service-quotes-message: Updating service quotes...
671
672
  notes-placeholder: Enter order notes here....
672
673
  notes-title: Notes
673
674
  optimize-route: Optimize Route
@@ -1054,7 +1054,7 @@ fleet-ops:
1054
1054
  more-waypoints: thêm điểm dừng
1055
1055
  settings-window:
1056
1056
  success-message: Cài đặt đã được lưu thành công.
1057
- title: Cài đặt FleetOps
1057
+ title: Cài đặt Fleet-Ops
1058
1058
  save: Lưu Thay đổi
1059
1059
  name: Khoảng cách Ping Adhoc Mặc định
1060
1060
  name-text: Cài đặt này xác định khoảng cách mặc định tính bằng mét mà các đơn hàng adhoc nên ping tài xế trong khu vực của điểm đón đơn hàng.