@fleetbase/fleetops-engine 0.6.53 → 0.6.55

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 (212) hide show
  1. package/addon/components/admin/routing-settings.hbs +2 -1
  2. package/addon/components/cell/attached-vehicle.hbs +10 -0
  3. package/addon/components/cell/attached-vehicle.js +58 -0
  4. package/addon/components/cell/device-identity.hbs +24 -0
  5. package/addon/components/cell/device-identity.js +129 -0
  6. package/addon/components/cell/driver-identity.hbs +33 -0
  7. package/addon/components/cell/driver-identity.js +143 -0
  8. package/addon/components/cell/equipment-identity.hbs +5 -0
  9. package/addon/components/cell/equipment-identity.js +48 -0
  10. package/addon/components/cell/part-identity.hbs +5 -0
  11. package/addon/components/cell/part-identity.js +63 -0
  12. package/addon/components/cell/telematic-device.hbs +38 -0
  13. package/addon/components/cell/telematic-device.js +44 -0
  14. package/addon/components/cell/telematic-provider.hbs +33 -16
  15. package/addon/components/cell/telematic-provider.js +50 -8
  16. package/addon/components/cell/vehicle-identity.hbs +33 -0
  17. package/addon/components/cell/vehicle-identity.js +138 -0
  18. package/addon/components/device/details.hbs +106 -135
  19. package/addon/components/device/details.js +174 -1
  20. package/addon/components/device/form.hbs +2 -2
  21. package/addon/components/device/form.js +7 -0
  22. package/addon/components/device/panel-header.hbs +46 -21
  23. package/addon/components/device/panel-header.js +49 -1
  24. package/addon/components/device/panel-tabs/events.hbs +30 -0
  25. package/addon/components/device/panel-tabs/events.js +128 -0
  26. package/addon/components/device/panel-tabs/sensors.hbs +30 -0
  27. package/addon/components/device/panel-tabs/sensors.js +93 -0
  28. package/addon/components/device/panel-tabs/vehicle.hbs +68 -0
  29. package/addon/components/device/panel-tabs/vehicle.js +90 -0
  30. package/addon/components/equipment/form.js +8 -1
  31. package/addon/components/layout/fleet-ops-sidebar/operations-monitor.js +76 -11
  32. package/addon/components/layout/fleet-ops-sidebar.hbs +1 -1
  33. package/addon/components/layout/fleet-ops-sidebar.js +101 -17
  34. package/addon/components/map/drawer/device-event-listing.js +2 -0
  35. package/addon/components/map/drawer/driver-listing.js +2 -0
  36. package/addon/components/map/drawer/place-listing.js +2 -0
  37. package/addon/components/map/drawer/position-listing.hbs +11 -6
  38. package/addon/components/map/drawer/position-listing.js +17 -4
  39. package/addon/components/map/drawer/vehicle-listing.js +2 -0
  40. package/addon/components/order/form/details.hbs +1 -43
  41. package/addon/components/order/form/details.js +18 -53
  42. package/addon/components/order/form/orchestrator-constraints.hbs +40 -0
  43. package/addon/components/order/form/orchestrator-constraints.js +61 -0
  44. package/addon/components/order/form/payload.js +48 -2
  45. package/addon/components/order/form/route.js +15 -0
  46. package/addon/components/order/form/service-rate.hbs +62 -11
  47. package/addon/components/order/form/service-rate.js +124 -2
  48. package/addon/components/order/form.hbs +2 -0
  49. package/addon/components/sensor/panel-header.hbs +47 -15
  50. package/addon/components/sensor/panel-header.js +59 -1
  51. package/addon/components/table/cell/dropdown/action-item.hbs +31 -0
  52. package/addon/components/table/cell/dropdown/action-item.js +62 -0
  53. package/addon/components/table/cell/dropdown.hbs +28 -0
  54. package/addon/components/table/cell/dropdown.js +91 -0
  55. package/addon/components/telematic/details.hbs +7 -1
  56. package/addon/components/telematic/form.js +16 -8
  57. package/addon/components/vendor/panel-header.hbs +82 -0
  58. package/addon/components/vendor/panel-header.js +48 -0
  59. package/addon/components/work-order/details.hbs +5 -1
  60. package/addon/components/work-order/form.hbs +26 -4
  61. package/addon/components/work-order/form.js +5 -1
  62. package/addon/controllers/connectivity/devices/index/details/events.js +244 -1
  63. package/addon/controllers/connectivity/devices/index/details/sensors.js +199 -0
  64. package/addon/controllers/connectivity/devices/index/details/vehicle.js +122 -0
  65. package/addon/controllers/connectivity/devices/index/details.js +12 -0
  66. package/addon/controllers/connectivity/devices/index.js +144 -129
  67. package/addon/controllers/connectivity/events/details.js +3 -0
  68. package/addon/controllers/connectivity/events/index.js +187 -11
  69. package/addon/controllers/connectivity/sensors/index.js +156 -14
  70. package/addon/controllers/connectivity/telematics/details/attachments.js +220 -54
  71. package/addon/controllers/connectivity/telematics/details/devices.js +170 -129
  72. package/addon/controllers/connectivity/telematics/details/events.js +39 -11
  73. package/addon/controllers/connectivity/telematics/details/sensors.js +39 -19
  74. package/addon/controllers/maintenance/equipment/index.js +1 -2
  75. package/addon/controllers/maintenance/parts/index.js +1 -2
  76. package/addon/controllers/maintenance/work-orders/index.js +18 -1
  77. package/addon/controllers/management/drivers/index.js +82 -42
  78. package/addon/controllers/management/fuel-reports/index.js +63 -4
  79. package/addon/controllers/management/issues/index.js +63 -4
  80. package/addon/controllers/management/vehicles/index.js +33 -6
  81. package/addon/routes/connectivity/devices/index/details/events.js +37 -1
  82. package/addon/routes/connectivity/devices/index/details/sensors.js +35 -0
  83. package/addon/routes/connectivity/devices/index/details/vehicle.js +29 -0
  84. package/addon/routes/connectivity/devices/index.js +7 -0
  85. package/addon/routes/connectivity/events/details.js +3 -0
  86. package/addon/routes/connectivity/events/index.js +6 -2
  87. package/addon/routes/connectivity/sensors/index.js +7 -2
  88. package/addon/routes/connectivity/telematics/details/attachments.js +72 -9
  89. package/addon/routes/connectivity/telematics/details/devices.js +6 -0
  90. package/addon/routes/connectivity/telematics/details/sensors.js +1 -0
  91. package/addon/routes/connectivity/telematics/details.js +1 -1
  92. package/addon/routes/connectivity/telematics/edit.js +1 -1
  93. package/addon/routes/maintenance/work-orders/index.js +1 -0
  94. package/addon/routes.js +2 -0
  95. package/addon/services/device-actions.js +30 -0
  96. package/addon/services/device-event-actions.js +1 -1
  97. package/addon/services/equipment-actions.js +1 -0
  98. package/addon/services/order-creation.js +54 -1
  99. package/addon/services/order-validation.js +3 -2
  100. package/addon/services/sensor-actions.js +3 -0
  101. package/addon/services/service-rate-actions.js +40 -1
  102. package/addon/services/vehicle-actions.js +24 -12
  103. package/addon/services/vendor-actions.js +3 -0
  104. package/addon/styles/fleetops-engine.css +28 -4
  105. package/addon/templates/connectivity/devices/index/details/events.hbs +85 -1
  106. package/addon/templates/connectivity/devices/index/details/sensors.hbs +73 -0
  107. package/addon/templates/connectivity/devices/index/details/vehicle.hbs +147 -0
  108. package/addon/templates/connectivity/telematics/details/attachments.hbs +198 -121
  109. package/addon/templates/connectivity/telematics/details/devices.hbs +4 -1
  110. package/addon/templates/connectivity/telematics/details/events.hbs +4 -1
  111. package/addon/templates/connectivity/telematics/details/sensors.hbs +3 -0
  112. package/addon/templates/management/vehicles/index/edit.hbs +1 -1
  113. package/addon/templates/management/vendors/index/details.hbs +2 -2
  114. package/addon/templates/settings/notifications.hbs +2 -1
  115. package/addon/templates/settings/routing.hbs +1 -0
  116. package/addon/utils/device-table-columns.js +288 -0
  117. package/addon/utils/fleet-ops-options.js +38 -0
  118. package/addon/utils/identity-cell-resource.js +20 -0
  119. package/addon/utils/map-drawer-dropdown-position.js +47 -0
  120. package/addon/utils/trackable-option.js +93 -0
  121. package/app/components/cell/attached-vehicle.js +1 -0
  122. package/app/components/cell/device-identity.js +1 -0
  123. package/app/components/cell/driver-identity.js +1 -0
  124. package/app/components/cell/equipment-identity.js +1 -0
  125. package/app/components/cell/part-identity.js +1 -0
  126. package/app/components/cell/telematic-device.js +1 -0
  127. package/app/components/cell/vehicle-identity.js +1 -0
  128. package/app/components/device/panel-tabs/events.hbs +30 -0
  129. package/app/components/device/panel-tabs/events.js +128 -0
  130. package/app/components/device/panel-tabs/sensors.hbs +30 -0
  131. package/app/components/device/panel-tabs/sensors.js +93 -0
  132. package/app/components/device/panel-tabs/vehicle.hbs +68 -0
  133. package/app/components/device/panel-tabs/vehicle.js +90 -0
  134. package/app/components/order/form/orchestrator-constraints.js +1 -0
  135. package/app/components/table/cell/dropdown/action-item.js +1 -0
  136. package/app/components/table/cell/dropdown.js +1 -0
  137. package/app/components/vendor/panel-header.js +1 -0
  138. package/app/controllers/connectivity/devices/index/details/sensors.js +1 -0
  139. package/app/controllers/connectivity/devices/index/details/vehicle.js +1 -0
  140. package/app/controllers/connectivity/events/{index/details.js → details.js} +1 -1
  141. package/app/routes/connectivity/devices/index/details/sensors.js +1 -0
  142. package/app/routes/connectivity/devices/index/details/vehicle.js +1 -0
  143. package/app/routes/connectivity/events/{index/details.js → details.js} +1 -1
  144. package/app/templates/connectivity/devices/index/details/sensors.js +1 -0
  145. package/app/templates/connectivity/devices/index/details/vehicle.js +1 -0
  146. package/app/templates/connectivity/events/{index/details.js → details.js} +1 -1
  147. package/app/utils/map-drawer-dropdown-position.js +1 -0
  148. package/app/utils/trackable-option.js +1 -0
  149. package/composer.json +1 -1
  150. package/extension.json +1 -1
  151. package/package.json +4 -4
  152. package/server/migrations/2025_08_28_054930_create_work_orders_table.php +1 -0
  153. package/server/migrations/2026_06_18_000001_add_category_to_work_orders_table.php +32 -0
  154. package/server/seeders/Testing/MaintenanceSeeder.php +5 -4
  155. package/server/src/Console/Commands/ProcessMaintenanceTriggers.php +1 -0
  156. package/server/src/Console/Commands/SyncTelematics.php +89 -0
  157. package/server/src/Exceptions/TelematicProviderException.php +13 -2
  158. package/server/src/Http/Controllers/Api/v1/OrderController.php +6 -21
  159. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +55 -1
  160. package/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +17 -6
  161. package/server/src/Http/Controllers/Internal/v1/FleetController.php +9 -0
  162. package/server/src/Http/Controllers/Internal/v1/LiveController.php +178 -1
  163. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +1 -0
  164. package/server/src/Http/Controllers/Internal/v1/OrderController.php +4 -32
  165. package/server/src/Http/Controllers/Internal/v1/SearchController.php +2 -2
  166. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +4 -4
  167. package/server/src/Http/Controllers/Internal/v1/ServiceRateController.php +82 -13
  168. package/server/src/Http/Filter/DeviceEventFilter.php +65 -0
  169. package/server/src/Http/Filter/DeviceFilter.php +80 -0
  170. package/server/src/Http/Filter/SensorFilter.php +125 -0
  171. package/server/src/Http/Filter/VehicleFilter.php +13 -0
  172. package/server/src/Http/Requests/CreateDriverRequest.php +14 -13
  173. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -0
  174. package/server/src/Http/Resources/v1/Driver.php +11 -2
  175. package/server/src/Http/Resources/v1/Index/Vehicle.php +19 -0
  176. package/server/src/Http/Resources/v1/WorkOrder.php +1 -0
  177. package/server/src/Jobs/FinalizeApiOrderCreation.php +47 -0
  178. package/server/src/Jobs/FinalizeInternalOrderCreation.php +35 -0
  179. package/server/src/Jobs/NotifyBulkAssignedDriver.php +47 -0
  180. package/server/src/Jobs/SyncTelematicDevicesJob.php +180 -123
  181. package/server/src/Models/Device.php +17 -1
  182. package/server/src/Models/DeviceEvent.php +82 -1
  183. package/server/src/Models/Driver.php +16 -0
  184. package/server/src/Models/Equipment.php +55 -0
  185. package/server/src/Models/Sensor.php +15 -2
  186. package/server/src/Models/Telematic.php +1 -1
  187. package/server/src/Models/Vehicle.php +5 -1
  188. package/server/src/Models/WorkOrder.php +5 -2
  189. package/server/src/Observers/DriverObserver.php +3 -3
  190. package/server/src/Observers/FleetObserver.php +23 -0
  191. package/server/src/Observers/VehicleObserver.php +3 -3
  192. package/server/src/Providers/FleetOpsServiceProvider.php +2 -0
  193. package/server/src/Support/LiveCacheService.php +1 -1
  194. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +143 -20
  195. package/server/src/Support/Telematics/Providers/FlespiProvider.php +89 -15
  196. package/server/src/Support/Telematics/Providers/GeotabProvider.php +123 -35
  197. package/server/src/Support/Telematics/Providers/SafeeProvider.php +70 -5
  198. package/server/src/Support/Telematics/Providers/SamsaraProvider.php +122 -14
  199. package/server/src/Support/Telematics/TelematicService.php +330 -30
  200. package/server/src/routes.php +1 -1
  201. package/server/tests/DeviceFilterTest.php +45 -0
  202. package/server/tests/DriverVehicleStatusDefaultTest.php +19 -2
  203. package/server/tests/EquipmentAssignmentMorphTypeTest.php +35 -0
  204. package/server/tests/OperationsMonitorEndpointTest.php +38 -0
  205. package/server/tests/OrderQueuedClosureRegressionTest.php +36 -0
  206. package/server/tests/ServiceRateRouteValidationTest.php +101 -0
  207. package/server/tests/TelematicsHardeningTest.php +598 -6
  208. package/server/tests/VehicleDriverAssignmentTest.php +27 -0
  209. package/translations/en-us.yaml +1 -0
  210. package/addon/controllers/connectivity/events/index/details.js +0 -3
  211. package/addon/routes/connectivity/events/index/details.js +0 -3
  212. /package/addon/templates/connectivity/events/{index/details.hbs → details.hbs} +0 -0
@@ -1,6 +1,13 @@
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\FlespiProvider;
6
+ use Fleetbase\FleetOps\Support\Telematics\Providers\GeotabProvider;
7
+ use Fleetbase\FleetOps\Support\Telematics\Providers\SafeeProvider;
8
+ use Fleetbase\FleetOps\Support\Telematics\Providers\SamsaraProvider;
9
+ use Illuminate\Http\Client\ConnectionException;
10
+ use Illuminate\Support\Facades\Http;
4
11
 
5
12
  test('device event model and migration expose lifecycle fields used by telematics workflows', function () {
6
13
  $model = file_get_contents(__DIR__ . '/../src/Models/DeviceEvent.php');
@@ -17,6 +24,11 @@ test('device event model and migration expose lifecycle fields used by telematic
17
24
  ->toContain("'occurred_at'")
18
25
  ->toContain("'processed_at'")
19
26
  ->toContain("'data'")
27
+ ->toContain("'device_imei'")
28
+ ->toContain("'device_connection_status'")
29
+ ->toContain("'provider_descriptor'")
30
+ ->toContain('public function getDeviceImeiAttribute(): ?string')
31
+ ->toContain('public function getProviderDescriptorAttribute(): array')
20
32
  ->toContain("'occurred_at' => 'datetime'")
21
33
  ->toContain("'processed_at' => 'datetime'");
22
34
  });
@@ -41,7 +53,19 @@ test('telematics service requires provider identity and stores idempotent event
41
53
 
42
54
  expect($service)
43
55
  ->toContain('Provider device identity is required to link a telematics device.')
56
+ ->toContain('public function ingestDeviceSnapshot')
44
57
  ->toContain('DeviceEvent::firstOrNew([\'_key\' => $eventKey])')
58
+ ->toContain('reconcileDeviceTelemetry')
59
+ ->toContain('PROTECTED_DEVICE_STATUSES')
60
+ ->toContain("'provider_status'")
61
+ ->toContain("'telemetry_summary'")
62
+ ->toContain('connectionStatusForDevice')
63
+ ->toContain('applyDeviceEventTelemetry')
64
+ ->toContain('$event->createPosition($positionData)')
65
+ ->toContain('updateVehicleTelemetry')
66
+ ->toContain('broadcast(new VehicleLocationChanged')
67
+ ->toContain('storeSnapshotSensors')
68
+ ->toContain("\$payload['sensors'] ?? \$payload['sensors_last_val']")
45
69
  ->toContain('protected function makeEventKey')
46
70
  ->toContain('$telematic->public_id ?? $telematic->uuid')
47
71
  ->toContain('resolveWebhookTelematic')
@@ -49,21 +73,254 @@ test('telematics service requires provider identity and stores idempotent event
49
73
  ->toContain('meta->provider_account_id');
50
74
  });
51
75
 
76
+ test('device details render consistent telematics connection state and timestamp', function () {
77
+ $details = file_get_contents(__DIR__ . '/../../addon/components/device/details.hbs');
78
+ $header = file_get_contents(__DIR__ . '/../../addon/components/device/panel-header.hbs');
79
+
80
+ expect($details)
81
+ ->toContain('format-date-fns @resource.last_online_at "dd MMM yyyy, HH:mm"')
82
+ ->not->toContain('format-date @resource.last_online_at');
83
+
84
+ expect($header)
85
+ ->toContain('@resource.is_online')
86
+ ->not->toContain('@resource.online "online"');
87
+ });
88
+
52
89
  test('native providers normalize device payloads to canonical FleetOps keys', function () {
53
- $afaqy = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/AfaqyProvider.php');
54
- $safee = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/SafeeProvider.php');
90
+ $providers = [
91
+ file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/AfaqyProvider.php'),
92
+ file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/FlespiProvider.php'),
93
+ file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/GeotabProvider.php'),
94
+ file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/SafeeProvider.php'),
95
+ file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/SamsaraProvider.php'),
96
+ ];
55
97
 
56
- foreach ([$afaqy, $safee] as $provider) {
98
+ foreach ($providers as $provider) {
57
99
  expect($provider)
58
100
  ->toContain("'device_id'")
101
+ ->toContain("'external_id'")
59
102
  ->toContain("'name'")
60
103
  ->toContain("'provider'")
61
104
  ->toContain("'model'")
62
105
  ->toContain("'online'")
63
- ->toContain("'last_seen_at'");
106
+ ->toContain("'last_seen_at'")
107
+ ->toContain("'location'")
108
+ ->toContain("'speed'")
109
+ ->toContain("'heading'")
110
+ ->toContain("'altitude'");
64
111
  }
65
112
  });
66
113
 
114
+ test('flespi telemetry normalizes positional event fields', function () {
115
+ $event = (new FlespiProvider())->normalizeEvent([
116
+ 'id' => 'message-1',
117
+ 'device.id' => 'device-1',
118
+ 'timestamp' => 1781769600,
119
+ 'position.latitude' => 25.2048,
120
+ 'position.longitude' => 55.2708,
121
+ 'position.speed' => 42,
122
+ 'position.direction' => 91,
123
+ 'position.altitude' => 12,
124
+ 'vehicle.mileage' => 12345,
125
+ 'engine.ignition.status' => true,
126
+ 'fuel.level' => 67,
127
+ ]);
128
+
129
+ expect($event)->toMatchArray([
130
+ 'external_id' => 'message-1',
131
+ 'device_id' => 'device-1',
132
+ 'event_type' => 'telemetry_update',
133
+ 'online' => true,
134
+ 'location' => ['lat' => 25.2048, 'lng' => 55.2708],
135
+ 'speed' => 42,
136
+ 'heading' => 91,
137
+ 'altitude' => 12,
138
+ 'odometer' => 12345,
139
+ 'ignition' => true,
140
+ 'fuel_level' => 67,
141
+ ]);
142
+ expect($event['meta'])->toHaveKeys(['raw', 'provider_status']);
143
+ });
144
+
145
+ test('samsara telemetry variants normalize positional event fields', function () {
146
+ $event = (new SamsaraProvider())->normalizeEvent([
147
+ 'id' => 'event-1',
148
+ 'vehicle' => ['id' => 'vehicle-1'],
149
+ 'time' => '2026-06-18T08:00:00Z',
150
+ 'location' => [
151
+ 'latitude' => 25.2048,
152
+ 'longitude' => 55.2708,
153
+ 'speedMilesPerHour' => 30,
154
+ 'headingDegrees' => 180,
155
+ 'altitudeMeters' => 16,
156
+ ],
157
+ 'odometerMeters' => 1000,
158
+ 'fuelPercent' => 50,
159
+ 'gateway' => ['status' => 'connected', 'online' => true],
160
+ ]);
161
+
162
+ expect($event)->toMatchArray([
163
+ 'external_id' => 'event-1',
164
+ 'device_id' => 'vehicle-1',
165
+ 'event_type' => 'vehicle_update',
166
+ 'online' => true,
167
+ 'location' => ['lat' => 25.2048, 'lng' => 55.2708],
168
+ 'speed' => 30,
169
+ 'heading' => 180,
170
+ 'altitude' => 16,
171
+ 'odometer' => 1000,
172
+ 'fuel_level' => 50,
173
+ ]);
174
+ expect($event['meta']['provider_status'])->toMatchArray([
175
+ 'gateway_status' => 'connected',
176
+ 'online' => true,
177
+ ]);
178
+ });
179
+
180
+ test('safee telemetry includes online and altitude event fields', function () {
181
+ $event = (new SafeeProvider())->normalizeEvent([
182
+ 'id' => 'event-1',
183
+ 'deviceId' => 'device-1',
184
+ 'status' => 'online',
185
+ 'date' => '2026-06-18T08:00:00Z',
186
+ 'lat' => 25.2048,
187
+ 'lon' => 55.2708,
188
+ 'speed' => 30,
189
+ 'heading' => 100,
190
+ 'altitude' => 15,
191
+ ]);
192
+
193
+ expect($event)->toMatchArray([
194
+ 'external_id' => 'event-1',
195
+ 'device_id' => 'device-1',
196
+ 'online' => true,
197
+ 'location' => ['lat' => 25.2048, 'lng' => 55.2708],
198
+ 'speed' => 30,
199
+ 'heading' => 100,
200
+ 'altitude' => 15,
201
+ ]);
202
+ });
203
+
204
+ test('geotab latest log record drives device and event telemetry', function () {
205
+ $payload = [
206
+ 'id' => 'device-1',
207
+ 'name' => 'Truck 1',
208
+ 'deviceType' => 'GO9',
209
+ 'serialNumber' => 'serial-1',
210
+ 'vehicleIdentificationNumber' => 'VIN123',
211
+ 'latest_log_record' => [
212
+ 'id' => 'log-1',
213
+ 'dateTime' => '2026-06-18T08:00:00Z',
214
+ 'latitude' => 25.2048,
215
+ 'longitude' => 55.2708,
216
+ 'speed' => 55,
217
+ 'bearing' => 90,
218
+ 'altitude' => 20,
219
+ 'device' => ['id' => 'device-1'],
220
+ ],
221
+ ];
222
+
223
+ $provider = new GeotabProvider();
224
+ $device = $provider->normalizeDevice($payload);
225
+ $event = $provider->normalizeEvent($payload);
226
+
227
+ expect($device)->toMatchArray([
228
+ 'device_id' => 'device-1',
229
+ 'external_id' => 'device-1',
230
+ 'name' => 'Truck 1',
231
+ 'provider' => 'geotab',
232
+ 'model' => 'GO9',
233
+ 'imei' => 'serial-1',
234
+ 'vin' => 'VIN123',
235
+ 'serial_number' => 'serial-1',
236
+ 'online' => true,
237
+ 'location' => ['lat' => 25.2048, 'lng' => 55.2708],
238
+ 'speed' => 55,
239
+ 'heading' => 90,
240
+ 'altitude' => 20,
241
+ ]);
242
+
243
+ expect($event)->toMatchArray([
244
+ 'external_id' => 'log-1',
245
+ 'device_id' => 'device-1',
246
+ 'event_type' => 'status_data',
247
+ 'online' => true,
248
+ 'location' => ['lat' => 25.2048, 'lng' => 55.2708],
249
+ 'speed' => 55,
250
+ 'heading' => 90,
251
+ 'altitude' => 20,
252
+ ]);
253
+ });
254
+
255
+ test('geotab polling fetches recent log records and merges latest record into device snapshots', function () {
256
+ $requests = [];
257
+
258
+ Http::fake(function ($request) use (&$requests) {
259
+ $requests[] = $request;
260
+ $body = json_decode($request->body(), true);
261
+ $typeName = data_get($body, 'params.typeName');
262
+
263
+ if ($typeName === 'Device') {
264
+ return Http::response([
265
+ 'result' => [
266
+ ['id' => 'device-1', 'name' => 'Truck 1'],
267
+ ['id' => 'device-2', 'name' => 'Truck 2'],
268
+ ],
269
+ ], 200);
270
+ }
271
+
272
+ if ($typeName === 'LogRecord') {
273
+ return Http::response([
274
+ 'result' => [
275
+ ['id' => 'old-log', 'device' => ['id' => 'device-1'], 'dateTime' => '2026-06-18T07:00:00Z', 'latitude' => 1, 'longitude' => 2],
276
+ ['id' => 'new-log', 'device' => ['id' => 'device-1'], 'dateTime' => '2026-06-18T08:00:00Z', 'latitude' => 3, 'longitude' => 4],
277
+ ['id' => 'other-log', 'device' => ['id' => 'other-device'], 'dateTime' => '2026-06-18T08:00:00Z', 'latitude' => 5, 'longitude' => 6],
278
+ ],
279
+ ], 200);
280
+ }
281
+
282
+ return Http::response(['result' => []], 200);
283
+ });
284
+
285
+ $provider = new class extends GeotabProvider {
286
+ public function fetchDevicesForTest(): array
287
+ {
288
+ $this->credentials = [
289
+ 'database' => 'testing-db',
290
+ ];
291
+ $this->sessionId = 'testing-session';
292
+
293
+ return $this->fetchDevices(['limit' => 2, 'from_date' => '2026-06-18T00:00:00Z']);
294
+ }
295
+ };
296
+
297
+ $result = $provider->fetchDevicesForTest();
298
+
299
+ expect($result['devices'])->toHaveCount(2);
300
+ expect($result['devices'][0]['latest_log_record'])->toMatchArray([
301
+ 'id' => 'new-log',
302
+ 'latitude' => 3,
303
+ 'longitude' => 4,
304
+ ]);
305
+ expect($result['devices'][1])->not->toHaveKey('latest_log_record');
306
+ expect($requests)->toHaveCount(2);
307
+ expect($requests[0]->data())->toMatchArray([
308
+ 'method' => 'Get',
309
+ 'params' => [
310
+ 'typeName' => 'Device',
311
+ 'resultsLimit' => 2,
312
+ ],
313
+ ]);
314
+ expect($requests[1]->data())->toMatchArray([
315
+ 'method' => 'Get',
316
+ 'params' => [
317
+ 'typeName' => 'LogRecord',
318
+ 'search' => ['fromDate' => '2026-06-18T00:00:00Z'],
319
+ 'resultsLimit' => 100,
320
+ ],
321
+ ]);
322
+ });
323
+
67
324
  test('telematics details use public id for consumer webhook URLs and do not read ember uuid', function () {
68
325
  $component = file_get_contents(__DIR__ . '/../../addon/components/telematic/details.js');
69
326
  $template = file_get_contents(__DIR__ . '/../../addon/components/telematic/details.hbs');
@@ -133,6 +390,7 @@ test('afaqy sync stores compact device diagnostics and paginates complete units
133
390
  $provider = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/AfaqyProvider.php');
134
391
 
135
392
  expect($provider)
393
+ ->toContain('TelematicProviderException')
136
394
  ->toContain('compactLastUpdate')
137
395
  ->toContain("'provider_unit_id'")
138
396
  ->toContain("'plate_number'")
@@ -140,6 +398,29 @@ test('afaqy sync stores compact device diagnostics and paginates complete units
140
398
  ->not->toContain("'raw' => \$payload")
141
399
  ->not->toContain("'sensors',")
142
400
  ->toContain("'Authorization' => 'Bearer ' . \$token")
401
+ ->toContain('protected function authenticatedPost')
402
+ ->toContain('protected function refreshToken')
403
+ ->toContain('protected function isTokenRejected')
404
+ ->toContain('protected int $dataTimeout = 120')
405
+ ->toContain('protected int $connectTimeout = 15')
406
+ ->toContain('protected int $connectionTestTimeout = 30')
407
+ ->toContain('protected int $connectionTestConnectTimeout = 10')
408
+ ->toContain('->timeout($timeout)')
409
+ ->toContain('->connectTimeout($connectTimeout)')
410
+ ->toContain('ConnectionException')
411
+ ->toContain('transportErrorContext')
412
+ ->toContain('extractBytesReceived')
413
+ ->toContain("'requested_limit'")
414
+ ->toContain("'requested_offset'")
415
+ ->toContain("'bytes_received'")
416
+ ->toContain('AFAQY token rejected; refreshing token and retrying request')
417
+ ->toContain('AFAQY token rejected after refresh with status')
418
+ ->toContain('AFAQY token rejected and username/password credentials are required to refresh it.')
419
+ ->toContain('providerErrorContext')
420
+ ->toContain("'endpoint'")
421
+ ->toContain("'provider_code'")
422
+ ->toContain("'provider_message'")
423
+ ->toContain("'retry_attempted'")
143
424
  ->toContain('?? 500), 500')
144
425
  ->toContain('if (is_array($filters) && empty($filters))')
145
426
  ->toContain('$filters = new \stdClass();')
@@ -160,7 +441,195 @@ test('afaqy sync stores compact device diagnostics and paginates complete units
160
441
  ->toContain("'pagination' => [")
161
442
  ->toContain("'allCount'")
162
443
  ->toContain("'filtersCount'")
163
- ->toContain("'resultCount'");
444
+ ->toContain("'resultCount'")
445
+ ->toContain("'online' => \$payload['active'] ?? null")
446
+ ->toContain("'altitude' => \$lastUpdate['alt'] ?? null");
447
+ });
448
+
449
+ test('afaqy sync keeps default limit and uses extended data request path', function () {
450
+ $requests = [];
451
+
452
+ Http::fake(function ($request) use (&$requests) {
453
+ $requests[] = $request;
454
+
455
+ if (str_ends_with($request->url(), '/auth/login')) {
456
+ return Http::response(['data' => ['token' => 'testing-token']], 200);
457
+ }
458
+
459
+ return Http::response([
460
+ 'data' => [],
461
+ 'pagination' => ['resultCount' => 0],
462
+ ], 200);
463
+ });
464
+
465
+ $provider = new class extends AfaqyProvider {
466
+ public function fetchDevicesForTest(array $credentials): array
467
+ {
468
+ $this->credentials = $credentials;
469
+ $this->prepareAuthentication();
470
+
471
+ return $this->fetchDevices();
472
+ }
473
+ };
474
+
475
+ $result = $provider->fetchDevicesForTest([
476
+ 'base_url' => 'https://api.afaqy.test',
477
+ 'username' => 'testing-user',
478
+ 'password' => 'testing-password',
479
+ ]);
480
+
481
+ expect($result['pagination']['resultCount'])->toBe(0);
482
+ expect($requests)->toHaveCount(2);
483
+ expect($requests[0]->url())->toBe('https://api.afaqy.test/auth/login');
484
+ expect($requests[1]->url())->toContain('/units/lists?token=testing-token');
485
+ expect($requests[1]->body())->toContain('"limit":500');
486
+ });
487
+
488
+ test('afaqy sync timeout failures are converted to sanitized provider metadata', function () {
489
+ Http::fake(function ($request) {
490
+ if (str_ends_with($request->url(), '/auth/login')) {
491
+ return Http::response(['data' => ['token' => 'timeout-testing-token']], 200);
492
+ }
493
+
494
+ 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');
495
+ });
496
+
497
+ $provider = new class extends AfaqyProvider {
498
+ public function fetchDevicesForTest(array $credentials): array
499
+ {
500
+ $this->credentials = $credentials;
501
+ $this->prepareAuthentication();
502
+
503
+ return $this->fetchDevices();
504
+ }
505
+ };
506
+
507
+ try {
508
+ $provider->fetchDevicesForTest([
509
+ 'base_url' => 'https://api.afaqy.test',
510
+ 'username' => 'testing-user',
511
+ 'password' => 'testing-password',
512
+ ]);
513
+ } catch (Throwable $e) {
514
+ $result = [
515
+ 'success' => false,
516
+ 'message' => $e->getMessage(),
517
+ 'metadata' => method_exists($e, 'context') ? $e->context() : [],
518
+ ];
519
+ }
520
+
521
+ expect($result['success'])->toBeFalse();
522
+ expect($result['message'])->toBe('AFAQY API request timed out while waiting for provider response.');
523
+ expect($result['metadata'])->toMatchArray([
524
+ 'provider' => 'afaqy',
525
+ 'endpoint' => '/units/lists',
526
+ 'requested_limit' => 500,
527
+ 'requested_offset' => 0,
528
+ 'timeout' => 120,
529
+ 'connect_timeout' => 15,
530
+ 'bytes_received' => 1186621,
531
+ 'retry_attempted' => false,
532
+ 'transport_error' => 'connection_exception',
533
+ ]);
534
+ expect(json_encode($result))
535
+ ->not->toContain('timeout-testing-token')
536
+ ->not->toContain('token=')
537
+ ->not->toContain('testing-password')
538
+ ->not->toContain('testing-user');
539
+ });
540
+
541
+ test('afaqy credential test refreshes token once when units list rejects token', function () {
542
+ $authCount = 0;
543
+ $requests = [];
544
+
545
+ Http::fake(function ($request) use (&$authCount, &$requests) {
546
+ $requests[] = $request;
547
+
548
+ if (str_ends_with($request->url(), '/auth/login')) {
549
+ $authCount++;
550
+
551
+ return Http::response(['data' => ['token' => $authCount === 1 ? 'first-testing-token' : 'second-testing-token']], 200);
552
+ }
553
+
554
+ if (str_contains($request->url(), '/units/lists?token=first-testing-token')) {
555
+ return Http::response(['message' => 'Token expired', 'code' => 'TOKEN_EXPIRED'], 401);
556
+ }
557
+
558
+ return Http::response([
559
+ 'data' => [['_id' => 'unit-1', 'name' => 'Truck 1']],
560
+ 'pagination' => ['resultCount' => 1],
561
+ ], 200);
562
+ });
563
+
564
+ $result = (new AfaqyProvider())->testConnection([
565
+ 'base_url' => 'https://api.afaqy.test',
566
+ 'username' => 'testing-user',
567
+ 'password' => 'testing-password',
568
+ ]);
569
+
570
+ expect($result['success'])->toBeTrue();
571
+ expect($authCount)->toBe(2);
572
+ expect(collect($requests)->filter(fn ($request) => str_contains($request->url(), '/units/lists'))->count())->toBe(2);
573
+ expect($requests[1]->url())->toContain('/units/lists?token=first-testing-token');
574
+ expect($requests[2]->url())->toContain('/auth/login');
575
+ expect($requests[3]->url())->toContain('/units/lists?token=second-testing-token');
576
+ });
577
+
578
+ test('afaqy token rejection failure metadata is sanitized', function () {
579
+ $authCount = 0;
580
+
581
+ Http::fake(function ($request) use (&$authCount) {
582
+ if (str_ends_with($request->url(), '/auth/login')) {
583
+ $authCount++;
584
+
585
+ return Http::response(['data' => ['token' => 'rejected-testing-token-' . $authCount]], 200);
586
+ }
587
+
588
+ return Http::response(['message' => 'Token rejected', 'code' => 'TOKEN_REJECTED'], 401);
589
+ });
590
+
591
+ $result = (new AfaqyProvider())->testConnection([
592
+ 'base_url' => 'https://api.afaqy.test',
593
+ 'username' => 'testing-user',
594
+ 'password' => 'testing-password',
595
+ ]);
596
+
597
+ expect($result['success'])->toBeFalse();
598
+ expect($result['message'])->toBe('AFAQY token rejected after refresh with status 401');
599
+ expect($result['metadata'])->toMatchArray([
600
+ 'provider' => 'afaqy',
601
+ 'endpoint' => '/units/lists',
602
+ 'status_code' => 401,
603
+ 'provider_code' => 'TOKEN_REJECTED',
604
+ 'provider_message' => 'Token rejected',
605
+ 'retry_attempted' => true,
606
+ ]);
607
+ expect(json_encode($result))
608
+ ->not->toContain('rejected-testing-token')
609
+ ->not->toContain('testing-password')
610
+ ->not->toContain('testing-user');
611
+ });
612
+
613
+ test('afaqy supplied token rejection requires password credentials for refresh', function () {
614
+ Http::fake([
615
+ 'https://api.afaqy.test/units/lists?token=static-testing-token' => Http::response(['message' => 'Token rejected'], 401),
616
+ ]);
617
+
618
+ $result = (new AfaqyProvider())->testConnection([
619
+ 'base_url' => 'https://api.afaqy.test',
620
+ 'token' => 'static-testing-token',
621
+ ]);
622
+
623
+ expect($result['success'])->toBeFalse();
624
+ expect($result['message'])->toBe('AFAQY token rejected and username/password credentials are required to refresh it.');
625
+ expect($result['metadata'])->toMatchArray([
626
+ 'provider' => 'afaqy',
627
+ 'endpoint' => '/units/lists',
628
+ 'status_code' => 401,
629
+ 'provider_message' => 'Token rejected',
630
+ 'retry_attempted' => false,
631
+ ]);
632
+ expect(json_encode($result))->not->toContain('static-testing-token');
164
633
  });
165
634
 
166
635
  test('telematics device sync records provider pagination and skipped device counts', function () {
@@ -172,20 +641,59 @@ test('telematics device sync records provider pagination and skipped device coun
172
641
  ->not->toContain("'limit' => \$request->input('limit', 100)");
173
642
 
174
643
  expect($job)
644
+ ->toContain('public int $tries = 1')
175
645
  ->toContain("'limit' => \$this->options['limit'] ?? null")
646
+ ->toContain('Cache::lock($lockKey, $this->timeout + 60)')
647
+ ->toContain("'fleetops:sync-telematic-devices:' . \$this->telematic->uuid")
648
+ ->toContain('Device discovery skipped because another sync is already running')
649
+ ->toContain("'last_sync_skipped_reason'")
650
+ ->toContain("'sync_already_running'")
176
651
  ->toContain('$totalFetched')
177
652
  ->toContain('$totalLinked')
653
+ ->toContain('$totalEvents')
654
+ ->toContain('$totalSensors')
178
655
  ->toContain('$totalSkipped')
179
656
  ->toContain('$pageCount')
657
+ ->toContain('$service->ingestDeviceSnapshot($this->telematic, $provider, $devicePayload)')
180
658
  ->toContain('Device discovery page fetched')
181
659
  ->toContain("'provider_unit_id'")
182
660
  ->toContain("'last_sync_fetched_total'")
183
661
  ->toContain("'last_sync_linked_total'")
662
+ ->toContain("'last_sync_events_total'")
663
+ ->toContain("'last_sync_sensors_total'")
184
664
  ->toContain("'last_sync_skipped_total'")
185
665
  ->toContain("'last_sync_page_count'")
186
666
  ->toContain("'last_sync_provider_total'")
187
667
  ->toContain("'last_sync_provider_all_count'")
188
- ->toContain("'last_sync_provider_filters_count'");
668
+ ->toContain("'last_sync_provider_filters_count'")
669
+ ->toContain("'last_sync_error_context'")
670
+ ->toContain('safeSyncErrorMessage')
671
+ ->toContain('token=|password|client_secret|authorization')
672
+ ->toContain("method_exists(\$e, 'context') ? \$e->context() : []");
673
+ });
674
+
675
+ test('telematics polling command is registered and scheduled for discovery providers by default', function () {
676
+ $command = file_get_contents(__DIR__ . '/../src/Console/Commands/SyncTelematics.php');
677
+ $provider = file_get_contents(__DIR__ . '/../src/Providers/FleetOpsServiceProvider.php');
678
+ $details = file_get_contents(__DIR__ . '/../../addon/components/telematic/details.hbs');
679
+
680
+ expect($command)
681
+ ->toContain("protected \$signature = 'fleetops:sync-telematics")
682
+ ->toContain('{--exclude-webhook-providers : Skip providers that support webhooks}')
683
+ ->toContain('SyncTelematicDevicesJob::dispatch($telematic')
684
+ ->toContain('$excludeWebhookProviders = (bool) $this->option(\'exclude-webhook-providers\')')
685
+ ->toContain('!$excludeWebhookProviders || !$descriptor->supportsWebhooks')
686
+ ->toContain('$descriptor->supportsDiscovery')
687
+ ->toContain("whereIn('status', ['active', 'connected'])")
688
+ ->not->toContain('sync-webhook-providers');
689
+
690
+ expect($provider)
691
+ ->toContain('Console\\Commands\\SyncTelematics::class')
692
+ ->toContain("command('fleetops:sync-telematics')->everyMinute()");
693
+
694
+ expect($details)
695
+ ->toContain('Provider polling')
696
+ ->toContain('FleetOps polls this provider for device snapshots and telemetry updates.');
189
697
  });
190
698
 
191
699
  test('native endpoint fields are advanced optional overrides with provider defaults', function () {
@@ -206,6 +714,90 @@ test('native endpoint fields are advanced optional overrides with provider defau
206
714
  }
207
715
  });
208
716
 
717
+ test('safee credential test sends documented form auth request to custom server uri', function () {
718
+ $requests = [];
719
+
720
+ Http::fake(function ($request) use (&$requests) {
721
+ $requests[] = $request;
722
+
723
+ if (str_ends_with($request->url(), '/protocol/openid-connect/token')) {
724
+ return Http::response(['access_token' => 'testing-access-token'], 200);
725
+ }
726
+
727
+ return Http::response([
728
+ 'code' => 0,
729
+ 'time' => 1509946353.033,
730
+ 'status' => 'success',
731
+ 'message' => 'operation completed successfully',
732
+ ], 200);
733
+ });
734
+
735
+ $result = (new SafeeProvider())->testConnection([
736
+ 'server_uri' => ' https://fms.example.test/ ',
737
+ 'realm_id' => 'dsco',
738
+ 'client_id' => 'api',
739
+ 'client_secret' => 'testing-client-secret',
740
+ 'username' => 'testing-user',
741
+ 'password' => 'testing-password',
742
+ ]);
743
+
744
+ expect($result['success'])->toBeTrue();
745
+ expect($result['metadata'])
746
+ ->toMatchArray([
747
+ 'auth_host' => 'https://fms.example.test',
748
+ 'auth_path' => '/auth/realms/dsco/protocol/openid-connect/token',
749
+ 'realm_id' => 'dsco',
750
+ ]);
751
+
752
+ expect($requests)->toHaveCount(2);
753
+
754
+ $tokenRequest = $requests[0];
755
+ parse_str($tokenRequest->body(), $tokenBody);
756
+
757
+ expect($tokenRequest->method())->toBe('POST');
758
+ expect($tokenRequest->url())->toBe('https://fms.example.test/auth/realms/dsco/protocol/openid-connect/token');
759
+ expect(implode(' ', (array) $tokenRequest->header('Content-Type')))->toContain('application/x-www-form-urlencoded');
760
+ expect($tokenBody)->toMatchArray([
761
+ 'grant_type' => 'password',
762
+ 'client_secret' => 'testing-client-secret',
763
+ 'client_id' => 'api',
764
+ 'username' => 'testing-user',
765
+ 'password' => 'testing-password',
766
+ ]);
767
+
768
+ expect($requests[1]->method())->toBe('GET');
769
+ expect($requests[1]->url())->toBe('https://fms.example.test/api/v2/status');
770
+ expect(implode(' ', (array) $requests[1]->header('Authorization')))->toBe('Bearer testing-access-token');
771
+ });
772
+
773
+ test('safee credential test reports token endpoint 401 with sanitized auth context', function () {
774
+ Http::fake([
775
+ 'https://fms.example.test/auth/realms/dsco/protocol/openid-connect/token' => Http::response(['error' => 'unauthorized'], 401),
776
+ ]);
777
+
778
+ $result = (new SafeeProvider())->testConnection([
779
+ 'server_uri' => 'https://fms.example.test',
780
+ 'realm_id' => 'dsco',
781
+ 'client_id' => 'api',
782
+ 'client_secret' => 'testing-client-secret',
783
+ 'username' => 'testing-user',
784
+ 'password' => 'testing-password',
785
+ ]);
786
+
787
+ expect($result['success'])->toBeFalse();
788
+ expect($result['message'])->toBe('Safee authentication failed with status 401');
789
+ expect($result['metadata'])
790
+ ->toMatchArray([
791
+ 'auth_host' => 'https://fms.example.test',
792
+ 'auth_path' => '/auth/realms/dsco/protocol/openid-connect/token',
793
+ 'realm_id' => 'dsco',
794
+ ])
795
+ ->not->toHaveKey('client_secret')
796
+ ->not->toHaveKey('password')
797
+ ->not->toHaveKey('access_token')
798
+ ->not->toHaveKey('refresh_token');
799
+ });
800
+
209
801
  test('telematics activity logging excludes large json and spatial payloads', function () {
210
802
  $device = telematics_activity_log_method(file_get_contents(__DIR__ . '/../src/Models/Device.php'));
211
803
  $sensor = telematics_activity_log_method(file_get_contents(__DIR__ . '/../src/Models/Sensor.php'));