@fleetbase/fleetops-engine 0.6.68 → 0.6.70

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 (294) hide show
  1. package/RELEASE.md +44 -9
  2. package/addon/components/admin/telematics-settings.hbs +143 -0
  3. package/addon/components/admin/telematics-settings.js +145 -0
  4. package/addon/components/contact/form.hbs +13 -2
  5. package/addon/components/customer/details.hbs +17 -17
  6. package/addon/components/customer/form.hbs +13 -2
  7. package/addon/components/customer/form.js +0 -56
  8. package/addon/components/driver/details.hbs +13 -19
  9. package/addon/components/driver/form.hbs +20 -35
  10. package/addon/components/driver/form.js +0 -58
  11. package/addon/components/fuel-integration/hub.hbs +1 -1
  12. package/addon/components/layout/fleet-ops-sidebar.js +37 -17
  13. package/addon/components/map/leaflet-live-map.hbs +4 -242
  14. package/addon/components/map/leaflet-live-map.js +2 -1
  15. package/addon/components/map/marker-card/driver.hbs +57 -0
  16. package/addon/components/map/marker-card/driver.js +3 -0
  17. package/addon/components/map/marker-card/vehicle.hbs +78 -0
  18. package/addon/components/map/marker-card/vehicle.js +3 -0
  19. package/addon/components/modals/driver-form.hbs +2 -2
  20. package/addon/components/modals/reset-profile-credentials.hbs +23 -0
  21. package/addon/components/modals/reset-profile-credentials.js +70 -0
  22. package/addon/components/profile-identity-hint.hbs +13 -0
  23. package/addon/components/profile-identity-hint.js +49 -0
  24. package/addon/components/telematic/hub.hbs +1 -1
  25. package/addon/components/widget/live-fleet.hbs +8 -40
  26. package/addon/controllers/analytics/reports/index.js +5 -0
  27. package/addon/controllers/connectivity/devices/index.js +4 -0
  28. package/addon/controllers/connectivity/fuel-providers/index.js +6 -4
  29. package/addon/controllers/connectivity/sensors/index.js +4 -0
  30. package/addon/controllers/connectivity/telematics/index.js +4 -0
  31. package/addon/controllers/maintenance/equipment/index.js +11 -4
  32. package/addon/controllers/maintenance/inspection-forms/index.js +2 -2
  33. package/addon/controllers/maintenance/inspection-submissions/index.js +8 -2
  34. package/addon/controllers/maintenance/maintenances/index.js +4 -0
  35. package/addon/controllers/maintenance/parts/index.js +11 -4
  36. package/addon/controllers/maintenance/schedules/index.js +17 -4
  37. package/addon/controllers/maintenance/work-orders/index.js +11 -4
  38. package/addon/controllers/management/contacts/index.js +4 -0
  39. package/addon/controllers/management/drivers/index.js +8 -0
  40. package/addon/controllers/management/fleets/index.js +4 -0
  41. package/addon/controllers/management/fuel-reports/index.js +4 -0
  42. package/addon/controllers/management/fuel-transactions/index.js +9 -7
  43. package/addon/controllers/management/issues/index.js +4 -0
  44. package/addon/controllers/management/places/index.js +4 -0
  45. package/addon/controllers/management/vehicles/index.js +4 -0
  46. package/addon/controllers/management/vendors/index.js +4 -0
  47. package/addon/controllers/operations/orders/index/details.js +30 -1
  48. package/addon/controllers/operations/orders/index.js +7 -0
  49. package/addon/controllers/operations/service-rates/index.js +3 -0
  50. package/addon/controllers/settings/index.js +13 -0
  51. package/addon/controllers/settings/telematics.js +113 -0
  52. package/addon/extension.js +42 -8
  53. package/addon/routes/analytics/index.js +14 -1
  54. package/addon/routes/analytics/reports/index/details.js +1 -1
  55. package/addon/routes/analytics/reports/index/edit.js +1 -1
  56. package/addon/routes/analytics/reports/index/new.js +14 -1
  57. package/addon/routes/analytics/reports.js +14 -1
  58. package/addon/routes/connectivity/devices/index/new.js +14 -1
  59. package/addon/routes/connectivity/devices.js +14 -1
  60. package/addon/routes/connectivity/events.js +14 -1
  61. package/addon/routes/connectivity/fuel-providers/details/transactions.js +12 -0
  62. package/addon/routes/connectivity/fuel-providers/details.js +9 -0
  63. package/addon/routes/connectivity/fuel-providers/edit.js +9 -0
  64. package/addon/routes/connectivity/fuel-providers/new.js +14 -1
  65. package/addon/routes/connectivity/fuel-providers.js +14 -1
  66. package/addon/routes/connectivity/sensors/index/new.js +14 -1
  67. package/addon/routes/connectivity/sensors.js +14 -1
  68. package/addon/routes/connectivity/telematics/new.js +14 -1
  69. package/addon/routes/connectivity/telematics.js +14 -1
  70. package/addon/routes/connectivity/tracking.js +14 -1
  71. package/addon/routes/maintenance/equipment/index/details.js +2 -2
  72. package/addon/routes/maintenance/equipment/index/edit.js +2 -2
  73. package/addon/routes/maintenance/equipment/index/new.js +14 -1
  74. package/addon/routes/maintenance/equipment.js +14 -1
  75. package/addon/routes/maintenance/index.js +14 -1
  76. package/addon/routes/maintenance/inspection-forms/index/details.js +10 -1
  77. package/addon/routes/maintenance/inspection-forms/index/edit.js +10 -1
  78. package/addon/routes/maintenance/inspection-forms/index/new.js +14 -1
  79. package/addon/routes/maintenance/inspection-forms.js +14 -1
  80. package/addon/routes/maintenance/inspection-submissions/index/details.js +10 -1
  81. package/addon/routes/maintenance/inspection-submissions/index/edit.js +10 -1
  82. package/addon/routes/maintenance/inspection-submissions/index/new.js +14 -1
  83. package/addon/routes/maintenance/inspection-submissions.js +14 -1
  84. package/addon/routes/maintenance/maintenances/index/details.js +2 -2
  85. package/addon/routes/maintenance/maintenances/index/edit.js +2 -2
  86. package/addon/routes/maintenance/maintenances/index/new.js +14 -1
  87. package/addon/routes/maintenance/maintenances.js +14 -1
  88. package/addon/routes/maintenance/parts/index/details.js +2 -2
  89. package/addon/routes/maintenance/parts/index/edit.js +2 -2
  90. package/addon/routes/maintenance/parts/index/new.js +14 -1
  91. package/addon/routes/maintenance/parts.js +14 -1
  92. package/addon/routes/maintenance/schedules/index/details.js +2 -2
  93. package/addon/routes/maintenance/schedules/index/edit.js +2 -2
  94. package/addon/routes/maintenance/schedules/index/new.js +15 -1
  95. package/addon/routes/maintenance/schedules.js +15 -1
  96. package/addon/routes/maintenance/work-orders/index/details.js +2 -2
  97. package/addon/routes/maintenance/work-orders/index/edit.js +2 -2
  98. package/addon/routes/maintenance/work-orders/index/new.js +14 -1
  99. package/addon/routes/maintenance/work-orders.js +14 -1
  100. package/addon/routes/management/fuel-transactions/index/details.js +9 -0
  101. package/addon/routes/management/fuel-transactions.js +14 -1
  102. package/addon/routes/management/index.js +13 -0
  103. package/addon/routes/management/trailers.js +16 -0
  104. package/addon/routes/management/vendors/integrated/details.js +14 -1
  105. package/addon/routes/management/vendors/integrated/edit.js +14 -1
  106. package/addon/routes/management/vendors/integrated/new.js +14 -1
  107. package/addon/routes/management/vendors/integrated.js +14 -1
  108. package/addon/routes/operations/orders.js +40 -1
  109. package/addon/routes/operations/routes/index/details.js +14 -1
  110. package/addon/routes/operations/routes/index/new.js +13 -0
  111. package/addon/routes/operations/routes.js +14 -1
  112. package/addon/routes/operations/service-rates/index/new.js +11 -0
  113. package/addon/routes/operations/service-rates.js +14 -1
  114. package/addon/routes/settings/avatars.js +14 -1
  115. package/addon/routes/settings/custom-fields.js +14 -1
  116. package/addon/routes/settings/map.js +14 -1
  117. package/addon/routes/settings/notifications.js +11 -0
  118. package/addon/routes/settings/orchestrator.js +1 -1
  119. package/addon/routes/settings/payments/index.js +1 -1
  120. package/addon/routes/settings/routing.js +14 -1
  121. package/addon/routes/settings/scheduling.js +14 -1
  122. package/addon/routes/settings/telematics.js +21 -0
  123. package/addon/routes/virtual.js +11 -0
  124. package/addon/routes.js +1 -0
  125. package/addon/services/contact-actions.js +50 -89
  126. package/addon/services/driver-actions.js +98 -0
  127. package/addon/templates/settings/telematics.hbs +70 -0
  128. package/addon/utils/profile-login-actions.js +172 -0
  129. package/app/components/admin/telematics-settings.js +1 -0
  130. package/app/components/{modals/reset-customer-credentials.js → map/marker-card/driver.js} +1 -1
  131. package/app/components/map/marker-card/vehicle.js +1 -0
  132. package/app/components/modals/reset-profile-credentials.js +1 -0
  133. package/app/components/profile-identity-hint.js +1 -0
  134. package/app/controllers/settings/telematics.js +1 -0
  135. package/app/routes/settings/telematics.js +1 -0
  136. package/app/templates/settings/telematics.js +1 -0
  137. package/composer.json +2 -2
  138. package/docs/AFAQY.md +1 -1
  139. package/docs/TELEMATICS_QUEUES.md +1 -1
  140. package/docs/TELEMETRY_ARCHITECTURE.md +72 -1
  141. package/extension.json +1 -1
  142. package/package.json +3 -3
  143. package/scripts/pest-bootstrap.php +12 -0
  144. package/server/config/telemetry.php +12 -0
  145. package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
  146. package/server/migrations/2026_09_23_000001_add_retention_index_to_device_events.php +40 -0
  147. package/server/migrations/2026_09_23_000002_add_retention_index_to_positions.php +40 -0
  148. package/server/migrations/2026_09_23_000003_add_retention_index_to_telematic_sync_runs.php +40 -0
  149. package/server/resources/views/mail/driver-credentials.blade.php +21 -0
  150. package/server/src/Auth/Schemas/FleetOps.php +104 -5
  151. package/server/src/Console/Commands/DrainTelematicInbox.php +3 -13
  152. package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
  153. package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
  154. package/server/src/Console/Commands/ProcessOperationalAlerts.php +3 -3
  155. package/server/src/Console/Commands/PruneTelematicsData.php +318 -0
  156. package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
  157. package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
  158. package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
  159. package/server/src/Http/Controllers/Api/v1/OrchestrationController.php +9 -0
  160. package/server/src/Http/Controllers/FleetOpsController.php +21 -0
  161. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +11 -0
  162. package/server/src/Http/Controllers/Internal/v1/ContactController.php +16 -17
  163. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +54 -4
  164. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +13 -0
  165. package/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +11 -0
  166. package/server/src/Http/Controllers/Internal/v1/DriverController.php +204 -162
  167. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
  168. package/server/src/Http/Controllers/Internal/v1/FuelProviderConnectionController.php +13 -0
  169. package/server/src/Http/Controllers/Internal/v1/FuelProviderTransactionController.php +15 -0
  170. package/server/src/Http/Controllers/Internal/v1/FuelReportController.php +9 -3
  171. package/server/src/Http/Controllers/Internal/v1/InspectionFormController.php +15 -0
  172. package/server/src/Http/Controllers/Internal/v1/LiveController.php +16 -0
  173. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +15 -0
  174. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +15 -0
  175. package/server/src/Http/Controllers/Internal/v1/ManifestController.php +15 -0
  176. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +11 -0
  177. package/server/src/Http/Controllers/Internal/v1/NavigatorController.php +42 -1
  178. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +12 -0
  179. package/server/src/Http/Controllers/Internal/v1/OrderController.php +178 -27
  180. package/server/src/Http/Controllers/Internal/v1/PaymentController.php +13 -0
  181. package/server/src/Http/Controllers/Internal/v1/RadarController.php +19 -0
  182. package/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php +9 -3
  183. package/server/src/Http/Controllers/Internal/v1/SettingController.php +465 -14
  184. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +21 -0
  185. package/server/src/Http/Controllers/Internal/v1/TrailerController.php +21 -0
  186. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +21 -0
  187. package/server/src/Http/Controllers/Internal/v1/VendorController.php +17 -0
  188. package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +11 -0
  189. package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
  190. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
  191. package/server/src/Http/Resources/v1/Contact.php +3 -0
  192. package/server/src/Http/Resources/v1/Driver.php +3 -0
  193. package/server/src/Jobs/DispatchTelematicsRetentionJobs.php +70 -0
  194. package/server/src/Jobs/PruneTelematicsDataJob.php +74 -0
  195. package/server/src/Listeners/NotifyDriverOnShiftChange.php +3 -3
  196. package/server/src/Mail/CustomerCredentialsMail.php +1 -1
  197. package/server/src/Mail/DriverCredentialsMail.php +55 -0
  198. package/server/src/Models/Contact.php +55 -90
  199. package/server/src/Models/Sensor.php +3 -5
  200. package/server/src/Observers/ContactObserver.php +2 -22
  201. package/server/src/Observers/DriverObserver.php +5 -6
  202. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +9 -1
  203. package/server/src/Providers/FleetOpsServiceProvider.php +18 -0
  204. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
  205. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
  206. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
  207. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
  208. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
  209. package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
  210. package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
  211. package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
  212. package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
  213. package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
  214. package/server/src/Support/Analytics/LiveFleet.php +18 -2
  215. package/server/src/Support/Authorization.php +103 -0
  216. package/server/src/Support/Database/TableIndexes.php +66 -0
  217. package/server/src/Support/OrderTracker.php +2 -2
  218. package/server/src/Support/ProfileAccountManager.php +405 -0
  219. package/server/src/Support/Reporting/FleetOpsReportSchema.php +476 -116
  220. package/server/src/Support/Telematics/Retention/RetentionPolicy.php +300 -0
  221. package/server/src/Support/Telematics/Retention/TelemetryActivity.php +40 -0
  222. package/server/src/Support/Telematics/TelematicService.php +12 -2
  223. package/server/src/Support/Telematics/Telemetry/Ingestor.php +4 -2
  224. package/server/src/Tracking/TrackingIntelligenceService.php +1 -1
  225. package/server/src/Tracking/TrackingOptions.php +2 -2
  226. package/server/src/Traits/AuthorizesMethods.php +37 -0
  227. package/server/src/routes.php +14 -1
  228. package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
  229. package/server/tests/AnalyticsRoutesTest.php +29 -2
  230. package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
  231. package/server/tests/ApiDriverControllerContractsTest.php +102 -20
  232. package/server/tests/CommandContractsTest.php +4 -4
  233. package/server/tests/ControllerHelperContractsTest.php +3 -11
  234. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +21 -1
  235. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
  236. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
  237. package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
  238. package/server/tests/Feature/Http/Api/TelematicsRetentionIndexMigrationTest.php +115 -0
  239. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
  240. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
  241. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
  242. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
  243. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
  244. package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
  245. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
  246. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
  247. package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
  248. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
  249. package/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php +71 -0
  250. package/server/tests/Feature/Http/Internal/TelematicsStorageDeadlinesTest.php +119 -0
  251. package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
  252. package/server/tests/InternalImportExportControllerContractsTest.php +5 -8
  253. package/server/tests/ModelAccessorContractsTest.php +82 -20
  254. package/server/tests/NavigatorControllerContractsTest.php +36 -4
  255. package/server/tests/NotificationAndMailContractsTest.php +31 -0
  256. package/server/tests/ObserverContractsTest.php +28 -3
  257. package/server/tests/OrchestratorConsumableApiTest.php +42 -0
  258. package/server/tests/ProviderContractsTest.php +10 -0
  259. package/server/tests/ReportSchemaContractsTest.php +120 -6
  260. package/server/tests/RequestContractsTest.php +19 -11
  261. package/server/tests/SettingControllerContractsTest.php +429 -18
  262. package/server/tests/SmallControllerContractsTest.php +8 -5
  263. package/server/tests/Support/PruneTelematicsDataProbe.php +55 -0
  264. package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
  265. package/server/tests/TelematicControllerContractsTest.php +22 -4
  266. package/server/tests/Unit/Console/Commands/ProcessOperationalAlertsCommandTest.php +2 -2
  267. package/server/tests/Unit/Console/PruneTelematicsDataCommandTest.php +353 -0
  268. package/server/tests/Unit/Jobs/DispatchTelematicsRetentionJobsTest.php +114 -0
  269. package/server/tests/Unit/Jobs/PruneTelematicsDataJobTest.php +60 -0
  270. package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
  271. package/server/tests/Unit/Models/ContactTest.php +48 -5
  272. package/server/tests/Unit/Models/SensorTest.php +2 -2
  273. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
  274. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
  275. package/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php +20 -0
  276. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
  277. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
  278. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
  279. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
  280. package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
  281. package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
  282. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
  283. package/server/tests/Unit/Support/Analytics/LiveFleetTest.php +6 -0
  284. package/server/tests/Unit/Support/AuthorizationTest.php +142 -0
  285. package/server/tests/Unit/Support/MaintainableAndOperationalAlertsTest.php +12 -1
  286. package/server/tests/Unit/Support/MoreQuerySeamsTest.php +11 -2
  287. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
  288. package/server/tests/Unit/Support/Telematics/Retention/RetentionPolicyTest.php +190 -0
  289. package/server/tests/Unit/Support/Telematics/Retention/TelemetryActivityTest.php +68 -0
  290. package/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php +109 -0
  291. package/translations/en-us.yaml +85 -2
  292. package/translations/uk-ua.yaml +3475 -0
  293. package/addon/components/modals/reset-customer-credentials.hbs +0 -18
  294. package/addon/components/modals/reset-customer-credentials.js +0 -55
@@ -67,6 +67,17 @@ class FleetOpsSearchResourcesCapabilityProbe extends SearchResourcesCapability
67
67
  public array $queries = [];
68
68
  public array $appliedLikes = [];
69
69
 
70
+ public array $failing = [];
71
+
72
+ public function exposeSearchTerms(string $prompt): array
73
+ {
74
+ return $this->searchTerms($prompt);
75
+ }
76
+
77
+ protected function reportSearchFailure(string $resource, Throwable $e): void
78
+ {
79
+ }
80
+
70
81
  public function exposePromptMatches(string $prompt): bool
71
82
  {
72
83
  return $this->matchesPrompt($prompt);
@@ -103,6 +114,10 @@ class FleetOpsSearchResourcesCapabilityProbe extends SearchResourcesCapability
103
114
 
104
115
  protected function genericSearchQuery(string $modelClass)
105
116
  {
117
+ if ($modelClass === Sensor::class && in_array('sensors', $this->failing, true)) {
118
+ throw new RuntimeException("SQLSTATE[42S22]: Column not found: 1054 Unknown column 'sensor_type'");
119
+ }
120
+
106
121
  return match ($modelClass) {
107
122
  WorkOrder::class => $this->queries['work_orders'],
108
123
  Maintenance::class => $this->queries['maintenances'],
@@ -197,7 +212,7 @@ test('search resources resolve returns authorized resource summaries across all
197
212
  $result = $capability->resolve(new AiTask(['prompt' => 'Find order ORDER-1 vehicle VEH-1 driver DRV-1 device DEV-1 sensor SNS-1 telematic TEL-1']));
198
213
 
199
214
  expect($capability->exposePromptMatches('look up work order WO-1'))->toBeTrue()
200
- ->and($result['query_terms'])->toBe(['ORDER-1', 'VEH-1', 'DRV-1', 'device', 'DEV-1', 'sensor'])
215
+ ->and($result['query_terms'])->toBe(['ORDER-1', 'VEH-1', 'DRV-1', 'DEV-1', 'SNS-1', 'TEL-1'])
201
216
  ->and($result['results'])->toHaveKeys(['orders', 'vehicles', 'drivers', 'work_orders', 'maintenances', 'devices', 'sensors', 'telematics'])
202
217
  ->and($result['results']['orders'][0])->toMatchArray([
203
218
  'id' => 'order_public',
@@ -235,3 +250,72 @@ test('search resources resolve returns authorized resource summaries across all
235
250
  ->and($capability->queries['orders']->calls)->toContain(['limit', 5], ['get'])
236
251
  ->and($capability->appliedLikes)->not->toBeEmpty();
237
252
  });
253
+
254
+ function fleetopsSearchResourcesEmptyQueries(): array
255
+ {
256
+ return collect(['orders', 'vehicles', 'drivers', 'work_orders', 'maintenances', 'devices', 'sensors', 'telematics'])
257
+ ->mapWithKeys(fn ($key) => [$key => new FleetOpsSearchResourcesQueryFake()])
258
+ ->all();
259
+ }
260
+
261
+ test('search terms ignore ordinary words from real production prompts', function () {
262
+ $capability = new FleetOpsSearchResourcesCapabilityProbe();
263
+
264
+ expect($capability->exposeSearchTerms('i want turn this into a platform where i add cisterns and on a different page BUSINESS leave they orders'))->toBe([])
265
+ ->and($capability->exposeSearchTerms('Create a few dummy orders so we can test with it'))->toBe([])
266
+ ->and($capability->exposeSearchTerms('download order import template'))->toBe([])
267
+ ->and($capability->exposeSearchTerms('config driver app ยังไง'))->toBe([])
268
+ ->and($capability->exposeSearchTerms('como activar un conductor'))->toBe([]);
269
+ });
270
+
271
+ test('search terms keep record references such as public ids, plates, emails, quoted and capitalized names', function () {
272
+ $capability = new FleetOpsSearchResourcesCapabilityProbe();
273
+
274
+ expect($capability->exposeSearchTerms('status of order order_yhkejdnzgz'))->toBe(['order_yhkejdnzgz'])
275
+ ->and($capability->exposeSearchTerms('vehicle plate SBA1234Z'))->toBe(['SBA1234Z'])
276
+ ->and($capability->exposeSearchTerms('driver with email ada@example.com'))->toBe(['ada@example.com'])
277
+ ->and($capability->exposeSearchTerms('where is driver Jane Doe right now'))->toBe(['Jane', 'Doe'])
278
+ ->and($capability->exposeSearchTerms('find "north depot" vehicles'))->toBe(['north depot']);
279
+ });
280
+
281
+ test('search resources skips querying when the prompt has no record reference', function () {
282
+ $capability = new FleetOpsSearchResourcesCapabilityProbe();
283
+ $capability->allowedPermissions = $capability->permissions();
284
+ $capability->queries = fleetopsSearchResourcesEmptyQueries();
285
+
286
+ $result = $capability->resolve(new AiTask(['prompt' => 'how do I create a new order']));
287
+
288
+ expect($result['query_terms'])->toBe([])
289
+ ->and($result['results'])->toBe([])
290
+ ->and($capability->appliedLikes)->toBe([]);
291
+ });
292
+
293
+ test('search resources never searches the dropped sensor_type column or status/uuid columns', function () {
294
+ $capability = new FleetOpsSearchResourcesCapabilityProbe();
295
+ $capability->allowedPermissions = $capability->permissions();
296
+ $capability->queries = fleetopsSearchResourcesEmptyQueries();
297
+
298
+ $capability->resolve(new AiTask(['prompt' => 'find SNS-1']));
299
+
300
+ $columns = collect($capability->appliedLikes)->flatMap(fn ($like) => $like[0])->unique()->all();
301
+
302
+ expect($columns)->not->toContain('sensor_type')
303
+ ->and($columns)->not->toContain('status')
304
+ ->and($columns)->not->toContain('uuid')
305
+ ->and($columns)->not->toContain('type');
306
+ });
307
+
308
+ test('a failing resource search does not discard the other results or leak the error', function () {
309
+ $vehicle = fleetopsSearchResourcesModel(Vehicle::class, ['public_id' => 'vehicle_public', 'uuid' => 'vehicle-uuid', 'name' => 'Van 12']);
310
+
311
+ $capability = new FleetOpsSearchResourcesCapabilityProbe();
312
+ $capability->allowedPermissions = $capability->permissions();
313
+ $capability->failing = ['sensors'];
314
+ $capability->queries = array_merge(fleetopsSearchResourcesEmptyQueries(), ['vehicles' => new FleetOpsSearchResourcesQueryFake([$vehicle])]);
315
+
316
+ $result = $capability->resolve(new AiTask(['prompt' => 'find VEH-12']));
317
+
318
+ expect($result['results'])->toHaveKey('vehicles')
319
+ ->and($result['unavailable_search'])->toBe(['sensors'])
320
+ ->and(json_encode($result))->not->toContain('SQLSTATE');
321
+ });
@@ -0,0 +1,31 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Support\Ai\FleetOpsAiConsoleCommands;
4
+
5
+ test('fleet-ops console commands are unique, complete, and only navigate or call allowlisted services', function () {
6
+ $commands = collect(FleetOpsAiConsoleCommands::all());
7
+
8
+ expect($commands->pluck('id')->duplicates())->toBeEmpty()
9
+ ->and($commands->every(fn ($command) => filled($command['label']) && filled($command['breadcrumb']) && filled($command['description']) && !empty($command['steps'])))->toBeTrue()
10
+ ->and($commands->every(fn ($command) => collect($command['steps'])->every(fn ($step) => $step['type'] === 'navigate'
11
+ ? str_starts_with($step['route'], 'console.fleet-ops.')
12
+ : $step['type'] === 'service' && $step['engine'] === FleetOpsAiConsoleCommands::ENGINE && preg_match('/^[a-zA-Z]+$/', $step['method']))))->toBeTrue()
13
+ ->and($commands->every(fn ($command) => ($command['audience'] ?? 'end_user') === 'end_user'))->toBeTrue();
14
+ });
15
+
16
+ test('fleet-ops console commands cover create, import, view, and settings', function () {
17
+ $commands = collect(FleetOpsAiConsoleCommands::all())->keyBy('id');
18
+
19
+ expect($commands['fleet-ops.orders.create']['steps'])->toBe([['type' => 'navigate', 'route' => 'console.fleet-ops.operations.orders.index.new']])
20
+ ->and($commands['fleet-ops.orders.create']['permissions'])->toBe(['fleet-ops create order'])
21
+ ->and($commands['fleet-ops.orders.import']['steps'][1])->toBe(['type' => 'service', 'engine' => '@fleetbase/fleetops-engine', 'service' => 'order-actions', 'method' => 'importOrders'])
22
+ ->and($commands['fleet-ops.customers.import']['steps'][1]['service'])->toBe('customer-actions')
23
+ ->and($commands['fleet-ops.customers.import']['permissions'])->toBe(['fleet-ops import contact'])
24
+ ->and($commands)->not->toHaveKey('fleet-ops.fleets.import')
25
+ ->and($commands['fleet-ops.drivers.view']['steps'][0]['models'])->toBe(['public_id'])
26
+ ->and($commands['fleet-ops.drivers.view']['params'])->toHaveKey('public_id')
27
+ ->and($commands['fleet-ops.settings.map.open']['steps'][0]['route'])->toBe('console.fleet-ops.settings.map')
28
+ ->and($commands['fleet-ops.settings.map.open']['permissions'])->toBe(['fleet-ops view map-settings'])
29
+ ->and($commands['fleet-ops.settings.map.open']['keywords'])->toContain('google maps', 'view maps')
30
+ ->and($commands['fleet-ops.settings.scheduling.open']['permissions'])->toBe([]);
31
+ });
@@ -0,0 +1,246 @@
1
+ <?php
2
+
3
+ use Fleetbase\Ai\Models\AiTask;
4
+ use Fleetbase\Ai\Support\AiAudience;
5
+ use Fleetbase\Ai\Support\AiToolContext;
6
+ use Fleetbase\FleetOps\Models\OrderConfig;
7
+ use Fleetbase\FleetOps\Support\Ai\Tools\CreateOrderTool;
8
+ use Fleetbase\FleetOps\Support\Ai\Tools\SearchResourcesTool;
9
+
10
+ class FleetOpsCreateOrderToolProbe extends CreateOrderTool
11
+ {
12
+ public array $resolvedPlaces = [];
13
+
14
+ public function exposeBuildDraft(AiTask $task, array $input = []): array
15
+ {
16
+ return $this->buildDraft($task, $input);
17
+ }
18
+
19
+ protected function can(string $permission): bool
20
+ {
21
+ return true;
22
+ }
23
+
24
+ protected function draftFromPrompt(string $prompt): array
25
+ {
26
+ return array_filter([
27
+ 'order_config_uuid' => 'order-config-uuid',
28
+ 'type' => 'transport',
29
+ 'payload' => $prompt !== '' ? ['pickup_query' => 'Parsed from prompt'] : [],
30
+ 'dispatched' => false,
31
+ ], fn ($value) => $value !== []) + ['payload' => []];
32
+ }
33
+
34
+ protected function resolvePlace(?string $query): ?array
35
+ {
36
+ return $this->resolvedPlaces[$query] ?? null;
37
+ }
38
+
39
+ protected function resolveOrderConfig(array $draft): ?OrderConfig
40
+ {
41
+ $config = new OrderConfig();
42
+ $config->setRawAttributes(['uuid' => 'order-config-uuid', 'key' => 'transport', 'name' => 'Transport'], true);
43
+
44
+ return $config;
45
+ }
46
+
47
+ protected function resolveDriver(array $draft): ?Fleetbase\FleetOps\Models\Driver
48
+ {
49
+ return null;
50
+ }
51
+
52
+ protected function resolveVehicle(array $draft): ?Fleetbase\FleetOps\Models\Vehicle
53
+ {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ class FleetOpsSearchResourcesToolProbe extends SearchResourcesTool
59
+ {
60
+ public array $searched = [];
61
+
62
+ protected function searchAll(array $terms, ?array $types = null): array
63
+ {
64
+ $this->searched[] = [$terms, $types];
65
+
66
+ return ['query_terms' => $terms, 'results' => []];
67
+ }
68
+ }
69
+
70
+ test('create order tool prepares one draft preview per call from structured fields without parsing the prompt', function () {
71
+ $tool = new FleetOpsCreateOrderToolProbe();
72
+ $task = new AiTask(['prompt' => 'Create some dummy orders with made up data', 'metadata' => []]);
73
+ $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops create order']));
74
+
75
+ $first = $tool->invoke($task, [
76
+ 'pickup' => 'Kabelweg 57, Amsterdam',
77
+ 'dropoff' => 'Danzigerkade 15, Amsterdam',
78
+ 'scheduled_at' => '2026-09-16T09:30:00+02:00',
79
+ 'driver' => ' ',
80
+ 'notes' => 'Test order',
81
+ ], $context);
82
+ $second = $tool->invoke($task, ['pickup' => 'Diemerhof 32, Diemen', 'dropoff' => 'Herikerbergweg 238, Amsterdam', 'dispatch' => true], $context);
83
+
84
+ expect($first['preview_id'])->toBe('preview-1')
85
+ ->and($second['preview_id'])->toBe('preview-2')
86
+ ->and($first['status'])->toBe('draft_shown_to_user')
87
+ ->and($first['message'])->toContain('does not exist until the user reviews the card')
88
+ ->and($context->actionPreviews)->toHaveCount(2)
89
+ ->and($context->actionPreviews[0]['draft']['payload']['pickup_query'])->toBe('Kabelweg 57, Amsterdam')
90
+ ->and($context->actionPreviews[0]['draft']['scheduled_at'])->toBe('2026-09-16T09:30:00+02:00')
91
+ ->and($context->actionPreviews[0]['draft']['notes'])->toBe('Test order')
92
+ ->and($context->actionPreviews[0]['draft'])->not->toHaveKey('driver_query')
93
+ ->and($context->actionPreviews[0]['draft']['dispatched'])->toBeFalse()
94
+ ->and($context->actionPreviews[1]['draft']['payload']['pickup_query'])->toBe('Diemerhof 32, Diemen')
95
+ ->and($context->actionPreviews[1]['draft']['dispatched'])->toBeTrue()
96
+ ->and(json_encode($context->actionPreviews))->not->toContain('Parsed from prompt')
97
+ ->and($tool->toolName())->toBe('propose_create_order')
98
+ ->and($tool->key())->toBe('fleet-ops.create_order')
99
+ ->and($tool->toolDescription())->toContain('NOT created by this tool')
100
+ ->and($tool->toolParameters()['required'])->toBe(['pickup', 'dropoff'])
101
+ ->and($tool->availableFor($context))->toBeTrue()
102
+ ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, []))))->toBeFalse();
103
+ });
104
+
105
+ test('create order drafts refresh from the edited preview and still parse prompts in keyword mode', function () {
106
+ $tool = new FleetOpsCreateOrderToolProbe();
107
+ $task = new AiTask(['prompt' => 'create order', 'metadata' => ['action_previews' => [['draft' => ['notes' => 'first preview']]]]]);
108
+
109
+ $refreshed = $tool->exposeBuildDraft($task, ['existing_draft' => ['notes' => 'second preview'], 'draft' => ['dispatched' => true]]);
110
+ $keyword = $tool->exposeBuildDraft($task, ['dispatched' => false]);
111
+
112
+ expect($refreshed['notes'])->toBe('second preview')
113
+ ->and($refreshed['dispatched'])->toBeTrue()
114
+ ->and($refreshed['payload']['pickup_query'])->toBe('Parsed from prompt')
115
+ ->and($keyword['notes'])->toBe('first preview')
116
+ ->and($keyword)->not->toHaveKey('existing_draft');
117
+ });
118
+
119
+ test('search tool searches the requested phrase and identifiers for the chosen record types', function () {
120
+ $tool = new FleetOpsSearchResourcesToolProbe();
121
+ $task = new AiTask(['prompt' => 'where is Jane']);
122
+ $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops see driver']));
123
+
124
+ $tool->invoke($task, ['query' => 'Jane Doe', 'types' => ['drivers', 'pets']], $context);
125
+ $tool->invoke($task, ['query' => 'order order_yhkejdnzgz'], $context);
126
+
127
+ expect($tool->searched[0])->toBe([['Jane Doe', 'Doe'], ['drivers']])
128
+ ->and($tool->searched[1])->toBe([['order order_yhkejdnzgz', 'order_yhkejdnzgz'], null])
129
+ ->and($tool->invoke($task, ['query' => ' x '], $context))->toBe(['error' => 'Provide an identifier or name to search for.'])
130
+ ->and($tool->toolName())->toBe('fleetops_search')
131
+ ->and($tool->key())->toBe('fleet-ops.search_resources')
132
+ ->and($tool->toolDescription())->toContain('particular record')
133
+ ->and($tool->toolParameters()['properties']['types']['items']['enum'])->toBe(SearchResourcesTool::TYPES)
134
+ ->and($tool->availableFor($context))->toBeTrue()
135
+ ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, []))))->toBeFalse();
136
+ });
137
+
138
+ test('search tool narrows the real search to the requested types', function () {
139
+ $tool = new class extends SearchResourcesTool {
140
+ public array $called = [];
141
+
142
+ protected function orders(array $terms): array
143
+ {
144
+ $this->called[] = 'orders';
145
+
146
+ return [['id' => 'order_1']];
147
+ }
148
+
149
+ protected function drivers(array $terms): array
150
+ {
151
+ $this->called[] = 'drivers';
152
+
153
+ return [];
154
+ }
155
+ };
156
+
157
+ $result = $tool->invoke(new AiTask(['prompt' => '']), ['query' => 'ORDER-1', 'types' => ['orders']], new AiToolContext(new AiTask(), new AiAudience(true)));
158
+
159
+ expect($tool->called)->toBe(['orders'])
160
+ ->and($result['results'])->toBe(['orders' => [['id' => 'order_1']]]);
161
+ });
162
+
163
+ class FleetOpsOptimizeOrderRouteToolProbe extends Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool
164
+ {
165
+ public array $previewedPrompts = [];
166
+
167
+ public function preview(AiTask $task, array $input = []): array
168
+ {
169
+ $this->previewedPrompts[] = $task->prompt;
170
+
171
+ return [
172
+ 'action' => $this->key(),
173
+ 'ready' => true,
174
+ 'message' => 'Fleetbase AI prepared an optimized waypoint sequence. Review it before applying.',
175
+ 'missing_fields' => [],
176
+ 'fields' => [['label' => 'Order', 'value' => 'order_abc']],
177
+ ];
178
+ }
179
+ }
180
+
181
+ test('optimize route tool proposes a reviewable preview for the order the model named', function () {
182
+ $tool = new FleetOpsOptimizeOrderRouteToolProbe();
183
+ $task = new AiTask(['prompt' => 'can you optimise the route please', 'metadata' => []]);
184
+ $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops optimize order', 'fleet-ops update-route-for order']));
185
+
186
+ $result = $tool->invoke($task, ['order' => 'order_abc'], $context);
187
+
188
+ expect($result['preview_id'])->toBe('preview-1')
189
+ ->and($result['ready'])->toBeTrue()
190
+ ->and($result['status'])->toBe('proposal_shown_to_user')
191
+ ->and($result['fields'])->toBe([['label' => 'Order', 'value' => 'order_abc']])
192
+ ->and($result['missing_fields'])->toBe([])
193
+ // The capability finds the order in the prompt text, so the tool hands it the id the model gave.
194
+ ->and($tool->previewedPrompts)->toBe(['order_abc'])
195
+ ->and($task->prompt)->toBe('can you optimise the route please')
196
+ ->and($context->actionPreviews)->toHaveCount(1)
197
+ ->and($tool->toolName())->toBe('propose_optimize_order_route')
198
+ ->and($tool->key())->toBe('fleet-ops.optimize_order_route')
199
+ ->and($tool->toolDescription())->toContain('NOT changed by this tool')
200
+ ->and($tool->toolParameters()['required'])->toBe(['order'])
201
+ ->and($tool->availableFor($context))->toBeTrue()
202
+ ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, ['fleet-ops optimize order']))))->toBeFalse();
203
+ });
204
+
205
+ test('optimize route tool refuses to build a preview without an order', function () {
206
+ $tool = new FleetOpsOptimizeOrderRouteToolProbe();
207
+ $task = new AiTask(['prompt' => 'optimise a route', 'metadata' => []]);
208
+ $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops optimize order', 'fleet-ops update-route-for order']));
209
+
210
+ expect($tool->invoke($task, ['order' => ' '], $context))->toBe([
211
+ 'error' => 'invalid_arguments',
212
+ 'message' => 'An order id is required to optimize a route.',
213
+ ])
214
+ ->and($tool->invoke($task, [], $context)['error'])->toBe('invalid_arguments')
215
+ ->and($context->actionPreviews)->toBe([])
216
+ ->and($tool->previewedPrompts)->toBe([]);
217
+ });
218
+
219
+ class FleetOpsImportOrdersToolProbe extends Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool
220
+ {
221
+ protected function can(string $permission): bool
222
+ {
223
+ return true;
224
+ }
225
+ }
226
+
227
+ test('import tool reports what an import needs and never claims to have imported anything', function () {
228
+ $tool = new FleetOpsImportOrdersToolProbe();
229
+ $task = new AiTask(['prompt' => 'can I import orders from a spreadsheet', 'metadata' => []]);
230
+ $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops import order']));
231
+
232
+ $result = $tool->invoke($task, [], $context);
233
+
234
+ expect($result['accepted_sources'])->toBe(['xlsx', 'csv'])
235
+ ->and($result['minimum_columns'])->toContain('pickup address or pickup place')
236
+ ->and($result['can_import_here'])->toBeFalse()
237
+ ->and($result['message'])->toContain('will not process uploaded spreadsheets')
238
+ ->and($context->actionPreviews)->toBe([])
239
+ ->and($tool->toolName())->toBe('fleetops_import_requirements')
240
+ ->and($tool->key())->toBe('fleet-ops.import_orders_preview')
241
+ ->and($tool->toolDescription())->toContain('does NOT import anything')
242
+ ->and($tool->toolParameters()['properties'])->toBeInstanceOf(stdClass::class)
243
+ ->and(json_encode($tool->toolParameters()['properties']))->toBe('{}')
244
+ ->and($tool->availableFor($context))->toBeTrue()
245
+ ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, []))))->toBeFalse();
246
+ });
@@ -96,7 +96,7 @@ test('permission checks for non admin users delegate to the gate', function () {
96
96
  ->and(fn () => fleetopsAiPermissionsHelper('canAll')->invoke($capability, ['fleet-ops list order']))->toThrow(TypeError::class);
97
97
  });
98
98
 
99
- test('search terms extract identifiers and fall back to the raw prompt', function () {
99
+ test('search terms extract identifiers and never fall back to the raw prompt', function () {
100
100
  fleetopsAiPermissionsBoot();
101
101
  $capability = new OperationalQueryCapability();
102
102
  $searchTerms = fleetopsAiPermissionsHelper('searchTerms');
@@ -105,8 +105,8 @@ test('search terms extract identifiers and fall back to the raw prompt', functio
105
105
  expect($terms)->toContain('TRK-12345', 'atlas99')
106
106
  ->and($terms)->not->toContain('find', 'order');
107
107
 
108
- // Stop-word-only prompts fall back to the trimmed prompt
109
- expect($searchTerms->invoke($capability, 'find order'))->toBe(['find order']);
108
+ // Prompts without a record reference produce no search terms, so nothing is searched
109
+ expect($searchTerms->invoke($capability, 'find order'))->toBe([]);
110
110
  });
111
111
 
112
112
  test('where like any matches across columns and terms', function () {
@@ -38,6 +38,12 @@ function fleetopsLiveFleetUseInMemoryConnection(): SQLiteConnection
38
38
  $connection->statement('create table drivers (uuid varchar(64) primary key, public_id varchar(64) null, user_uuid varchar(64) null, company_uuid varchar(64) null, vehicle_uuid varchar(64) null, current_job_uuid varchar(64) null, avatar_url varchar(255) null, location blob null, heading numeric null, online integer null, last_location_update_at datetime null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
39
39
  $connection->statement('create table vehicles (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, driver_uuid varchar(64) null, photo_uuid varchar(64) null, avatar_url varchar(255) null, name varchar(255) null, year integer null, make varchar(255) null, model varchar(255) null, trim varchar(255) null, plate_number varchar(64) null, location blob null, heading numeric null, online integer null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
40
40
  $connection->statement('create table orders (uuid varchar(64) primary key, public_id varchar(64) null, internal_id varchar(64) null, company_uuid varchar(64) null, driver_assigned_uuid varchar(64) null, status varchar(64) null, tracking_number_uuid varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
41
+ // Tables the markers' index-resource cards read: devices, coupled trailers and positions.
42
+ $connection->statement('create table devices (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, attachable_uuid varchar(64) null, attachable_type varchar(255) null, name varchar(255) null, device_id varchar(64) null, serial_number varchar(64) null, imei varchar(64) null, provider varchar(64) null, status varchar(64) null, online integer null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
43
+ $connection->statement('create table asset_connections (uuid varchar(64) primary key, company_uuid varchar(64) null, connector_uuid varchar(64) null, connector_type varchar(255) null, connected_uuid varchar(64) null, connected_type varchar(255) null, connected_at datetime null, disconnected_at datetime null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
44
+ $connection->statement('create table assets (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, asset_class varchar(64) null, name varchar(255) null, code varchar(64) null, type varchar(64) null, status varchar(64) null, plate_number varchar(64) null, online integer null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
45
+ $connection->statement('create table positions (uuid varchar(64) primary key, company_uuid varchar(64) null, subject_uuid varchar(64) null, subject_type varchar(255) null, order_uuid varchar(64) null, speed numeric null, heading numeric null, coordinates blob null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
46
+ $connection->statement('create table tracking_numbers (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, owner_uuid varchar(64) null, owner_type varchar(255) null, status_uuid varchar(64) null, tracking_number varchar(64) null, region varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
41
47
  $connection->statement('create table files (uuid varchar(64) primary key, type varchar(64) null, url varchar(255) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
42
48
 
43
49
  $resolver = new ConnectionResolver([
@@ -0,0 +1,142 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Support\Authorization;
4
+ use Fleetbase\FleetOps\Traits\AuthorizesMethods;
5
+ use Fleetbase\Models\User;
6
+ use Illuminate\Database\ConnectionResolver;
7
+ use Illuminate\Database\Eloquent\Model;
8
+ use Illuminate\Database\SQLiteConnection;
9
+ use Illuminate\Http\Exceptions\HttpResponseException;
10
+ use Illuminate\Http\Request;
11
+ use Illuminate\Routing\Controller;
12
+
13
+ if (!function_exists('Fleetbase\\Support\\auth')) {
14
+ eval('namespace Fleetbase\\Support; function auth() { return \\app("fleetops.authorization.test-auth"); }');
15
+ }
16
+
17
+ if (!function_exists('Fleetbase\\Support\\session')) {
18
+ eval('namespace Fleetbase\\Support; function session($key = null, $default = null) { return \\session($key, $default); }');
19
+ }
20
+
21
+ class FleetOpsAuthorizationUser extends User
22
+ {
23
+ public array $grantedPermissions = [];
24
+ public bool $permissionStoreUnavailable = false;
25
+
26
+ public function hasPermissionTo($permission, $guardName = null): bool
27
+ {
28
+ if ($this->permissionStoreUnavailable) {
29
+ throw new RuntimeException('Permission store unavailable');
30
+ }
31
+
32
+ return in_array($permission->name, $this->grantedPermissions, true);
33
+ }
34
+ }
35
+
36
+ beforeEach(function () {
37
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
38
+ $resolver = new ConnectionResolver(['mysql' => $connection]);
39
+ $resolver->setDefaultConnection('mysql');
40
+ Model::setConnectionResolver($resolver);
41
+ config()->set('auth.defaults.guard', 'sanctum');
42
+ config()->set('permission.table_names.permissions', 'permissions');
43
+ $connection->getSchemaBuilder()->create('permissions', function ($table) {
44
+ $table->string('id')->primary();
45
+ $table->string('name');
46
+ $table->string('guard_name')->default('sanctum');
47
+ });
48
+ $connection->table('permissions')->insert([
49
+ ['id' => 'optimize', 'name' => 'fleet-ops optimize order'],
50
+ ['id' => 'dispatch', 'name' => 'fleet-ops dispatch order'],
51
+ ['id' => 'resource', 'name' => 'fleet-ops * order'],
52
+ ['id' => 'service', 'name' => 'fleet-ops *'],
53
+ ]);
54
+ session(['user' => null]);
55
+ $this->user = new FleetOpsAuthorizationUser();
56
+ $this->user->setRawAttributes(['type' => 'user'], true);
57
+ $this->auth = new class($this->user) {
58
+ public bool $unavailable = false;
59
+
60
+ public function __construct(public ?User $currentUser)
61
+ {
62
+ }
63
+
64
+ public function user(): ?User
65
+ {
66
+ if ($this->unavailable) {
67
+ throw new RuntimeException('Session unavailable');
68
+ }
69
+
70
+ return $this->currentUser;
71
+ }
72
+ };
73
+ app()->instance('fleetops.authorization.test-auth', $this->auth);
74
+ });
75
+
76
+ test('operation authorization accepts exact and wildcard grants', function (string $grant) {
77
+ $this->user->grantedPermissions = [$grant];
78
+
79
+ expect(Authorization::canAny('optimize order'))->toBeTrue();
80
+ Authorization::authorize('optimize order');
81
+ })->with(['fleet-ops optimize order', 'fleet-ops * order', 'fleet-ops *']);
82
+
83
+ test('operation authorization accepts any requested permission and rejects missing grants', function () {
84
+ $this->user->grantedPermissions = ['fleet-ops dispatch order'];
85
+
86
+ expect(Authorization::canAny('optimize order'))->toBeFalse()
87
+ ->and(Authorization::canAny('optimize order', 'dispatch order'))->toBeTrue()
88
+ ->and(Authorization::canAny('list unseeded-resource'))->toBeTrue();
89
+ expect(fn () => Authorization::authorize('optimize order'))->toThrow(HttpResponseException::class);
90
+
91
+ $this->user->permissionStoreUnavailable = true;
92
+ expect(Authorization::canAny('dispatch order'))->toBeFalse();
93
+ });
94
+
95
+ test('platform administrators pass operations while system settings reject ordinary users', function () {
96
+ expect(fn () => Authorization::authorizeAdmin())->toThrow(HttpResponseException::class);
97
+ $this->user->setAttribute('type', 'admin');
98
+ expect(Authorization::canAny('optimize order'))->toBeTrue();
99
+ Authorization::authorizeAdmin();
100
+ });
101
+
102
+ test('authorization fails closed when the authenticated user is missing or cannot be read', function () {
103
+ $this->auth->currentUser = null;
104
+ expect(Authorization::canAny('optimize order'))->toBeFalse();
105
+ expect(fn () => Authorization::authorizeAdmin())->toThrow(HttpResponseException::class);
106
+ $this->auth->unavailable = true;
107
+ expect(Authorization::canAny('optimize order'))->toBeFalse();
108
+ });
109
+
110
+ test('controller middleware applies the mapped operation and administrator checks', function () {
111
+ $controller = new class extends Controller {
112
+ use AuthorizesMethods;
113
+
114
+ public function __construct()
115
+ {
116
+ $this->authorizeMethods(['optimize' => ['optimize order', 'dispatch order'], 'settings' => 'admin']);
117
+ }
118
+ };
119
+ $middleware = $controller->getMiddleware();
120
+ $request = Request::create('/');
121
+ $this->user->grantedPermissions = ['fleet-ops dispatch order'];
122
+
123
+ expect($middleware[0]['options']['only'])->toBe(['optimize'])
124
+ ->and($middleware[0]['middleware']($request, fn ($nextRequest) => $nextRequest))->toBe($request);
125
+ expect(fn () => $middleware[1]['middleware']($request, fn () => 'allowed'))->toThrow(HttpResponseException::class);
126
+ $this->user->setAttribute('type', 'admin');
127
+ expect($middleware[1]['middleware']($request, fn () => 'allowed'))->toBe('allowed');
128
+ });
129
+
130
+ test('analytics controllers enforce authorization before invoking their next middleware', function (string $class) {
131
+ $connection = Model::getConnectionResolver()->connection();
132
+ $connection->table('permissions')->insert(['id' => 'analytics', 'name' => 'fleet-ops view analytics']);
133
+ $controller = new $class();
134
+ $middleware = $controller->getMiddleware()[0]['middleware'];
135
+ $request = Request::create('/analytics');
136
+ expect(fn () => $middleware($request, fn () => 'private analytics'))->toThrow(HttpResponseException::class);
137
+ $this->user->grantedPermissions = ['fleet-ops view analytics'];
138
+ expect($middleware($request, fn ($forwarded) => $forwarded))->toBe($request);
139
+ })->with([
140
+ Fleetbase\FleetOps\Http\Controllers\Internal\v1\AnalyticsController::class,
141
+ Fleetbase\FleetOps\Http\Controllers\Internal\v1\MetricsController::class,
142
+ ]);
@@ -184,10 +184,21 @@ test('operational alert helpers query orders positions and settings', function (
184
184
  $order = Order::where('uuid', 'order-1')->withoutGlobalScopes()->first();
185
185
  expect($probe->callHelper('latestPositionForOrder', $order))->toBeInstanceOf(Position::class);
186
186
 
187
- $settings = $probe->callHelper('alertSettings');
187
+ $settings = $probe->callHelper('alertSettings', $order);
188
188
  expect($settings['late_departures']['enabled'])->toBeTrue()
189
189
  ->and($settings['route_deviations']['distance_threshold_meters'])->toBe(500)
190
190
  ->and($settings['prolonged_stoppages']['duration_threshold_minutes'])->toBe(30);
191
+
192
+ // Settings resolve from the order's company without a company session
193
+ $connection->table('settings')->insert([
194
+ 'key' => 'company.company-1.tracking',
195
+ 'value' => json_encode(['alerts' => ['late_departures' => ['enabled' => false], 'route_deviations' => ['distance_threshold_meters' => 750]]]),
196
+ ]);
197
+ session(['company' => null]);
198
+ $settings = $probe->callHelper('alertSettings', $order);
199
+ session(['company' => 'company-1']);
200
+ expect($settings['late_departures']['enabled'])->toBeFalse()
201
+ ->and($settings['route_deviations']['distance_threshold_meters'])->toBe(750);
191
202
  });
192
203
 
193
204
  test('route point collection normalizes pairs and measures distances', function () {
@@ -384,13 +384,22 @@ test('index driver resource counts assigned orders and names the current one', f
384
384
  });
385
385
 
386
386
  test('shift change listener reads company scheduling settings', function () {
387
- fleetopsMoreSeamBoot();
387
+ $connection = fleetopsMoreSeamBoot();
388
388
 
389
389
  $class = Fleetbase\FleetOps\Listeners\NotifyDriverOnShiftChange::class;
390
390
  $listener = (new ReflectionClass($class))->newInstanceWithoutConstructor();
391
391
 
392
392
  // With nothing stored the lookup falls back to the empty default
393
- expect(fleetopsMoreSeamInvoke($listener, $class, 'getSchedulingSettings'))->toBe([]);
393
+ expect(fleetopsMoreSeamInvoke($listener, $class, 'getSchedulingSettings', ['company-seam-2']))->toBe([]);
394
+
395
+ // Queued listeners have no company session, so the schedule's company is used
396
+ $connection->table('settings')->insert(['key' => 'company.company-seam-3.fleet-ops.scheduling-settings', 'value' => json_encode(['notify_drivers_on_shift_change' => true])]);
397
+ session(['company' => null]);
398
+ $settings = fleetopsMoreSeamInvoke($listener, $class, 'getSchedulingSettings', ['company-seam-3']);
399
+ session(['company' => 'company-seam-2']);
400
+
401
+ expect($settings)->toBe(['notify_drivers_on_shift_change' => true])
402
+ ->and(fleetopsMoreSeamInvoke($listener, $class, 'getSchedulingSettings', [null]))->toBe([]);
394
403
  });
395
404
 
396
405
  test('order insights capability builds its own company-scoped order query', function () {