@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
@@ -0,0 +1,119 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Http\Controllers\Internal\v1\SettingController;
4
+ use Illuminate\Database\MySqlConnection;
5
+ use Illuminate\Database\QueryException;
6
+ use Illuminate\Support\Facades\DB;
7
+
8
+ class FleetOpsStorageDeadlineConnection extends MySqlConnection
9
+ {
10
+ public array $queries = [];
11
+ public array $responses = [];
12
+ public bool $maria = false;
13
+
14
+ public function getReadPdo()
15
+ {
16
+ return new class($this->maria) extends PDO {
17
+ public function __construct(private bool $maria)
18
+ {
19
+ }
20
+
21
+ public function getAttribute(int $attribute): mixed
22
+ {
23
+ if ($attribute !== PDO::ATTR_SERVER_VERSION) {
24
+ throw new LogicException('Only the database server version should be read');
25
+ }
26
+
27
+ return $this->maria ? '10.11.8-MariaDB' : '8.0.40';
28
+ }
29
+ };
30
+ }
31
+
32
+ public function select($query, $bindings = [], $useReadPdo = true)
33
+ {
34
+ $this->queries[] = [$query, $bindings];
35
+ $response = array_shift($this->responses) ?? [];
36
+ if ($response instanceof Throwable) {
37
+ throw $response;
38
+ }
39
+
40
+ return $response;
41
+ }
42
+ }
43
+
44
+ function fleetopsStorageQueryError(int $code): QueryException
45
+ {
46
+ $error = new PDOException('Database query failed');
47
+ $error->errorInfo = ['HY000', $code, 'Database query failed'];
48
+
49
+ return new QueryException('mysql', 'select history', [], $error);
50
+ }
51
+
52
+ beforeEach(function () {
53
+ $this->originalDb = app()->bound('db') ? app('db') : null;
54
+ $this->connection = new FleetOpsStorageDeadlineConnection(new PDO('sqlite::memory:'), '', '', ['driver' => 'mysql']);
55
+ $connection = $this->connection;
56
+ app()->instance('db', new class($connection) {
57
+ public function __construct(public $database)
58
+ {
59
+ }
60
+
61
+ public function connection($name = null)
62
+ {
63
+ return $this->database;
64
+ }
65
+
66
+ public function __call($method, $args)
67
+ {
68
+ return $this->database->{$method}(...$args);
69
+ }
70
+ });
71
+ DB::clearResolvedInstance('db');
72
+ $this->controller = new SettingController();
73
+ $this->invoke = fn ($method, ...$arguments) => (new ReflectionMethod(SettingController::class, $method))->invoke($this->controller, ...$arguments);
74
+ $this->scope = fn ($query) => $query->where('company_uuid', 'company-a');
75
+ });
76
+
77
+ afterEach(function () {
78
+ if ($this->originalDb) {
79
+ app()->instance('db', $this->originalDb);
80
+ } else {
81
+ app()->forgetInstance('db');
82
+ }
83
+ DB::clearResolvedInstance('db');
84
+ });
85
+
86
+ test('storage scans enforce driver specific deadlines while preserving bindings', function (bool $maria) {
87
+ $this->connection->maria = $maria;
88
+ $this->connection->responses = [[(object) ['row_count' => '4', 'oldest' => '2026-01-01']], [(object) ['rows' => 4]]];
89
+ expect(($this->invoke)('tableUsage', 'device_events', $this->scope, 'created_at'))->toBe(['rows' => 4, 'oldest' => '2026-01-01']);
90
+ ($this->invoke)('storageUsageSelect', 'select * from device_events where company_uuid = ?', ['company-a'], true);
91
+ expect($this->connection->queries[0][1])->toBe(['company-a'])
92
+ ->and($this->connection->queries[0][0])->toStartWith($maria ? 'SET STATEMENT max_statement_time=1 FOR select' : 'SELECT /*+ MAX_EXECUTION_TIME(1000) */')
93
+ ->and($this->connection->queries[1][0])->toStartWith($maria ? 'SET STATEMENT max_statement_time=1 FOR EXPLAIN select' : 'EXPLAIN SELECT /*+ MAX_EXECUTION_TIME(1000) */')
94
+ ->and($this->connection->queries[1][1])->toBe(['company-a']);
95
+ })->with([false, true]);
96
+
97
+ test('timed out counts use a scoped query plan estimate instead of another history scan', function () {
98
+ $this->connection->responses = [fleetopsStorageQueryError(3024), [(object) ['rows' => 101, 'filtered' => 25]]];
99
+ expect(($this->invoke)('tableUsage', 'device_events', $this->scope, 'created_at'))->toBe(['rows' => 25, 'oldest' => null, 'rows_estimated' => true])
100
+ ->and($this->connection->queries[1][0])->toStartWith('EXPLAIN SELECT')
101
+ ->and($this->connection->queries[1][1])->toBe(['company-a']);
102
+ $this->connection->responses = [fleetopsStorageQueryError(1969), fleetopsStorageQueryError(1969)];
103
+ expect(($this->invoke)('tableUsage', 'device_events', $this->scope, 'created_at'))->toBe(['rows' => null, 'oldest' => null, 'rows_estimated' => true]);
104
+ });
105
+
106
+ test('storage queries suppress only timeout errors and retain real database failures', function () {
107
+ foreach (['tableUsage', 'tableOldest'] as $method) {
108
+ $this->connection->responses = [fleetopsStorageQueryError(1146)];
109
+ expect(fn () => ($this->invoke)($method, 'device_events', $this->scope, 'created_at'))->toThrow(QueryException::class);
110
+ }
111
+ $this->connection->responses = [fleetopsStorageQueryError(3024), fleetopsStorageQueryError(1146)];
112
+ expect(fn () => ($this->invoke)('tableUsage', 'device_events', $this->scope, 'created_at'))->toThrow(QueryException::class);
113
+ $this->connection->responses = [fleetopsStorageQueryError(3024)];
114
+ expect(($this->invoke)('tableOldest', 'device_events', $this->scope, 'created_at'))->toBeNull();
115
+ $this->connection->responses = [fleetopsStorageQueryError(1969)];
116
+ expect(($this->invoke)('storageTableStatistics', ['device_events']))->toBe([]);
117
+ $this->connection->responses = [fleetopsStorageQueryError(1146)];
118
+ expect(fn () => ($this->invoke)('storageTableStatistics', ['device_events']))->toThrow(QueryException::class);
119
+ });
@@ -122,11 +122,12 @@ function fleetopsInternalCustomer(): FleetOpsInternalCustomerContactFake
122
122
  return $customer;
123
123
  }
124
124
 
125
- function fleetopsInternalCustomerUser(string $status = 'active'): FleetOpsInternalCustomerUserFake
125
+ function fleetopsInternalCustomerUser(string $status = 'active', string $type = 'customer'): FleetOpsInternalCustomerUserFake
126
126
  {
127
127
  $user = new FleetOpsInternalCustomerUserFake();
128
128
  $user->setRawAttributes([
129
129
  'uuid' => 'user-uuid',
130
+ 'type' => $type,
130
131
  'public_id' => 'user_public',
131
132
  'name' => 'Ada Customer',
132
133
  'email' => 'ada@example.test',
@@ -236,3 +237,33 @@ test('internal customer controller validates reset credential error branches', f
236
237
  'password_confirmation' => 'one',
237
238
  ])))['error'])->toBe('Unable to reset customer credentials');
238
239
  });
240
+
241
+ test('internal customer controller refuses login management for staff-linked customers', function () {
242
+ $staff = fleetopsInternalCustomerUser('active', 'user');
243
+ $controller = fleetopsInternalCustomerController($staff);
244
+ $message = 'This customer signs in with a team member account. Manage this login in IAM.';
245
+ $request = new Request(['customer' => 'customer_public']);
246
+
247
+ $responses = [
248
+ $controller->createPortalLogin($request),
249
+ $controller->sendCredentials($request),
250
+ $controller->deactivatePortalLogin($request),
251
+ $controller->reactivatePortalLogin($request),
252
+ $controller->resetCredentials(new Request([
253
+ 'customer' => 'customer_public',
254
+ 'password' => 'chosen-secret',
255
+ 'password_confirmation' => 'chosen-secret',
256
+ ])),
257
+ ];
258
+
259
+ foreach ($responses as $response) {
260
+ expect($response->getStatusCode())->toBe(422)
261
+ ->and(fleetopsInternalCustomerJson($response)['error'])->toBe($message);
262
+ }
263
+
264
+ // The team member's account is never touched
265
+ expect($staff->passwords)->toBe([])
266
+ ->and($staff->activations)->toBe(0)
267
+ ->and($staff->deactivations)->toBe(0)
268
+ ->and($controller->sentCredentials)->toBe([]);
269
+ });
@@ -269,14 +269,11 @@ test('internal fuel report controller syncs custom fields after save when provid
269
269
  $controller = new FleetOpsInternalFuelReportControllerProbe();
270
270
  $fuelReport = new FleetOpsInternalFuelReportAfterSaveFake();
271
271
 
272
- $controller->afterSave(new Request([
273
- 'fuel_report' => [
274
- 'custom_field_values' => [
275
- ['key' => 'receipt_number', 'value' => 'R-100'],
276
- ],
277
- ],
278
- ]), $fuelReport);
279
- $controller->afterSave(new Request(['fuel_report' => ['custom_field_values' => []]]), $fuelReport);
272
+ $input = ['custom_field_values' => [['key' => 'receipt_number', 'value' => 'R-100']]];
273
+ $controller->afterSave(new Request(['fuelReport' => $input]), $fuelReport, $input);
274
+ $controller->afterSave(new Request(), $fuelReport, ['custom_field_values' => []]);
275
+ $controller->afterSave(new Request(), $fuelReport, ['custom_field_values' => 'invalid']);
276
+ $controller->afterSave(new Request(), $fuelReport);
280
277
 
281
278
  expect($fuelReport->synced)->toBe([
282
279
  [
@@ -130,14 +130,51 @@ class FleetOpsContactSyncUserFake extends Fleetbase\Models\User
130
130
  return true;
131
131
  }
132
132
 
133
+ public function save(array $options = []): bool
134
+ {
135
+ $this->updates[] = $this->getDirty();
136
+ $this->syncOriginal();
137
+
138
+ return true;
139
+ }
140
+
133
141
  public function delete()
134
142
  {
135
143
  $this->deleted = true;
144
+ $this->setAttribute('deleted_at', '2026-01-01 00:00:00');
136
145
 
137
146
  return true;
138
147
  }
139
148
  }
140
149
 
150
+ function fleetopsModelAccessorsUseProfileAccountTables(): void
151
+ {
152
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
153
+ $schema = $connection->getSchemaBuilder();
154
+ $schema->create('users', function ($table) {
155
+ $table->string('uuid')->nullable();
156
+ $table->string('type')->nullable();
157
+ $table->string('email')->nullable();
158
+ $table->string('phone')->nullable();
159
+ $table->timestamp('deleted_at')->nullable();
160
+ });
161
+ foreach (['drivers', 'contacts'] as $profileTable) {
162
+ $schema->create($profileTable, function ($table) {
163
+ $table->string('uuid')->nullable();
164
+ $table->string('user_uuid')->nullable();
165
+ $table->string('company_uuid')->nullable();
166
+ $table->timestamp('deleted_at')->nullable();
167
+ });
168
+ }
169
+
170
+ $resolver = new ConnectionResolver([
171
+ 'default' => $connection,
172
+ 'mysql' => $connection,
173
+ ]);
174
+ $resolver->setDefaultConnection('mysql');
175
+ EloquentModel::setConnectionResolver($resolver);
176
+ }
177
+
141
178
  class FleetOpsContactAccessorFake extends Contact
142
179
  {
143
180
  public array $loadedMissing = [];
@@ -858,6 +895,8 @@ test('contact accessors imports notifications and customer identity helpers are
858
895
  });
859
896
 
860
897
  test('contact user sync lookup and deletion guards are stable', function () {
898
+ fleetopsModelAccessorsUseProfileAccountTables();
899
+
861
900
  $user = new FleetOpsContactSyncUserFake();
862
901
  $user->setRawAttributes([
863
902
  'uuid' => 'user-uuid',
@@ -868,22 +907,25 @@ test('contact user sync lookup and deletion guards are stable', function () {
868
907
  ], true);
869
908
 
870
909
  $contact = new FleetOpsContactAccessorFake();
910
+ $contact->exists = true;
871
911
  $contact->fakeUser = $user;
872
912
  $contact->setRawAttributes([
873
- 'uuid' => 'contact-uuid',
874
- 'user_uuid' => 'not-a-uuid',
875
- 'type' => 'customer',
876
- 'name' => 'Old Name',
877
- 'email' => 'old@example.com',
878
- 'phone' => '+15550000000',
879
- 'timezone' => 'UTC',
913
+ 'uuid' => 'contact-uuid',
914
+ 'company_uuid' => 'company-uuid',
915
+ 'user_uuid' => 'not-a-uuid',
916
+ 'type' => 'customer',
917
+ 'name' => 'Old Name',
918
+ 'email' => 'old@example.com',
919
+ 'phone' => '+15550000000',
920
+ 'timezone' => 'UTC',
880
921
  ], true);
881
922
 
882
923
  $contact->name = 'New Name';
883
- $contact->email = 'new@example.com';
884
- $contact->phone = '+15551112222';
924
+ $contact->email = 'New@Example.com';
925
+ $contact->phone = '+1 555-111-2222';
885
926
  $contact->timezone = 'Asia/Singapore';
886
927
 
928
+ // Proxy fields are normalized and pushed to the managed account
887
929
  expect($contact->syncWithUser())->toBeTrue()
888
930
  ->and($user->updates[0])->toBe([
889
931
  'name' => 'New Name',
@@ -895,26 +937,46 @@ test('contact user sync lookup and deletion guards are stable', function () {
895
937
  ->and($contact->hasUser())->toBeTrue()
896
938
  ->and($contact->doesntHaveUser())->toBeFalse();
897
939
 
898
- $deleteContact = new FleetOpsContactAccessorFake();
899
- $deleteContact->type = 'customer';
900
- $deleteContact->setRelation('user', $user);
940
+ // An unsaved contact's account was just resolved from these values
941
+ $newContact = new FleetOpsContactAccessorFake();
942
+ $newContact->fakeUser = $user;
943
+ $newContact->name = 'Brand New';
944
+
945
+ // A saved contact with no proxy changes has nothing to sync
946
+ $cleanContact = new FleetOpsContactAccessorFake();
947
+ $cleanContact->exists = true;
948
+ $cleanContact->fakeUser = $user;
949
+ $cleanContact->setRawAttributes(['name' => 'New Name', 'type' => 'customer'], true);
950
+
951
+ expect($newContact->syncWithUser())->toBeFalse()
952
+ ->and($cleanContact->syncWithUser())->toBeFalse()
953
+ ->and($user->updates)->toHaveCount(1);
954
+
955
+ // Deleting the only profile holding a managed account deletes the account
956
+ $deleteContact = new FleetOpsContactAccessorFake();
957
+ $deleteContact->type = 'customer';
958
+ $deleteContact->company_uuid = 'company-uuid';
959
+ $deleteContact->fakeUser = $user;
901
960
 
902
961
  expect($deleteContact->deleteUser())->toBeTrue()
903
- ->and($user->deleted)->toBeTrue()
904
- ->and($deleteContact->loadedMissing)->toBe(['user']);
962
+ ->and($user->deleted)->toBeTrue();
905
963
 
906
- $mismatchedUser = new FleetOpsContactSyncUserFake();
907
- $mismatchedUser->setRawAttributes(['type' => 'admin'], true);
964
+ // A team member's account linked to the contact is never deleted
965
+ $staffUser = new FleetOpsContactSyncUserFake();
966
+ $staffUser->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'admin'], true);
908
967
 
909
- $mismatchedContact = new FleetOpsContactAccessorFake();
910
- $mismatchedContact->type = 'customer';
911
- $mismatchedContact->setRelation('user', $mismatchedUser);
968
+ $staffContact = new FleetOpsContactAccessorFake();
969
+ $staffContact->type = 'customer';
970
+ $staffContact->fakeUser = $staffUser;
912
971
 
913
972
  $emptyContact = new FleetOpsContactAccessorFake();
973
+ $emptyContact->exists = true;
914
974
  $emptyContact->fakeUser = null;
915
975
  $emptyContact->setRawAttributes(['user_uuid' => 'not-a-uuid'], true);
976
+ $emptyContact->name = 'Changed';
916
977
 
917
- expect($mismatchedContact->deleteUser())->toBeFalse()
978
+ expect($staffContact->deleteUser())->toBeFalse()
979
+ ->and($staffUser->deleted)->toBeFalse()
918
980
  ->and($emptyContact->syncWithUser())->toBeFalse()
919
981
  ->and($emptyContact->getUser())->toBeNull()
920
982
  ->and($emptyContact->hasUser())->toBeFalse()
@@ -137,17 +137,24 @@ function fleetopsNavigatorCredential(string $key = 'flb_live_key'): ApiCredentia
137
137
  return $credential;
138
138
  }
139
139
 
140
+ function fleetopsNavigatorSignedQuery(): array
141
+ {
142
+ $expires = time() + 600;
143
+
144
+ return ['expires' => $expires, 'signature' => NavigatorController::linkSignature($expires)];
145
+ }
146
+
140
147
  test('navigator controller builds android and ios app link redirects', function () {
141
148
  $company = fleetopsNavigatorCompany();
142
149
  $controller = new FleetOpsNavigatorControllerProbe();
143
150
  $controller->adminUser = fleetopsNavigatorUser($company);
144
151
  $controller->apiCredential = fleetopsNavigatorCredential('flb_live_navigator');
145
152
 
146
- $android = $controller->linkApp(Request::create('/navigator/link-app', 'GET', [], [], [], [
153
+ $android = $controller->linkApp(Request::create('/navigator/link-app', 'GET', fleetopsNavigatorSignedQuery(), [], [], [
147
154
  'HTTP_USER_AGENT' => 'Mozilla/5.0 Android',
148
155
  ]))->getData(true);
149
156
 
150
- $ios = $controller->linkApp(Request::create('/navigator/link-app', 'GET', [], [], [], [
157
+ $ios = $controller->linkApp(Request::create('/navigator/link-app', 'GET', fleetopsNavigatorSignedQuery(), [], [], [
151
158
  'HTTP_USER_AGENT' => 'Mozilla/5.0 iPhone',
152
159
  ]))->getData(true);
153
160
 
@@ -169,11 +176,32 @@ test('navigator controller returns missing organization error when no admin comp
169
176
  $controller = new FleetOpsNavigatorControllerProbe();
170
177
  $controller->adminUser = fleetopsNavigatorUser(null);
171
178
 
172
- $response = $controller->linkApp(new Request());
179
+ $response = $controller->linkApp(Request::create('/navigator/link-app', 'GET', fleetopsNavigatorSignedQuery()));
173
180
 
174
181
  expect($response->getData(true))->toBe(['error' => 'Organization for linking not found.']);
175
182
  });
176
183
 
184
+ test('navigator controller refuses unsigned, tampered and expired app links before touching credentials', function () {
185
+ $company = fleetopsNavigatorCompany();
186
+ $controller = new FleetOpsNavigatorControllerProbe();
187
+ $controller->adminUser = fleetopsNavigatorUser($company);
188
+ $controller->apiCredential = fleetopsNavigatorCredential('flb_live_navigator');
189
+
190
+ $expired = time() - 60;
191
+ $requests = [
192
+ 'unsigned' => Request::create('/navigator/link-app', 'GET'),
193
+ 'tampered' => Request::create('/navigator/link-app', 'GET', ['signature' => 'forged'] + fleetopsNavigatorSignedQuery()),
194
+ 'expired' => Request::create('/navigator/link-app', 'GET', ['expires' => $expired, 'signature' => NavigatorController::linkSignature($expired)]),
195
+ ];
196
+
197
+ foreach ($requests as $request) {
198
+ $response = $controller->linkApp($request);
199
+ expect($response->getData(true))->toBe(['error' => 'This Navigator link is invalid or has expired. Generate a new one from the console.']);
200
+ }
201
+
202
+ expect($controller->credentialLookups)->toBe([]);
203
+ });
204
+
177
205
  test('navigator controller exposes link url settings and current organization token lookup branches', function () {
178
206
  $controller = new FleetOpsNavigatorControllerProbe();
179
207
  $controller->apiCredential = fleetopsNavigatorCredential('flb_test_key');
@@ -194,7 +222,11 @@ test('navigator controller exposes link url settings and current organization to
194
222
  ]);
195
223
  $missing = $controller->getCurrentOrganization($secretRequest);
196
224
 
197
- expect($linkUrl)->toBe(['linkUrl' => 'http://localhost/int/v1/fleet-ops/navigator/link-app'])
225
+ parse_str((string) parse_url($linkUrl['linkUrl'], PHP_URL_QUERY), $linkQuery);
226
+
227
+ expect($linkUrl['linkUrl'])->toStartWith('http://localhost/int/v1/fleet-ops/navigator/link-app?')
228
+ ->and($linkQuery['signature'])->toBe(NavigatorController::linkSignature((int) $linkQuery['expires']))
229
+ ->and((int) $linkQuery['expires'])->toBeGreaterThan(time())
198
230
  ->and($settings)->toBe(['enabled' => true, 'invite_code_required' => false])
199
231
  ->and($organization)->toBeInstanceOf(Organization::class)
200
232
  ->and($controller->credentialLookups)->toContain(
@@ -675,3 +675,34 @@ test('maintenance schedule reminder mail exposes schedule context', function ()
675
675
  'offsetDays' => 7,
676
676
  ]);
677
677
  });
678
+
679
+ test('customer credentials mail reads the portal slug from the customer company regardless of session', function (?string $sessionCompany) {
680
+ app('config')->set('fleetbase.console.host', 'console.fleetbase.test');
681
+ app('config')->set('fleetbase.console.secure', true);
682
+ app('config')->set('fleetbase.console.subdomain', null);
683
+ app('session.store')->put('company', $sessionCompany);
684
+
685
+ $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
686
+ $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
687
+ $resolver->setDefaultConnection('mysql');
688
+ Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
689
+ $connection->getSchemaBuilder()->create('settings', function ($blueprint) {
690
+ $blueprint->increments('id');
691
+ $blueprint->string('key')->nullable();
692
+ $blueprint->text('value')->nullable();
693
+ $blueprint->timestamps();
694
+ });
695
+ $connection->table('settings')->insert([
696
+ ['key' => 'company.company-portal.customer-portal-config', 'value' => json_encode(['accessUrlSlug' => 'acme-portal'])],
697
+ ['key' => 'company.other-company.customer-portal-config', 'value' => json_encode(['accessUrlSlug' => 'other-portal'])],
698
+ ]);
699
+
700
+ $customer = new FleetOpsNotificationContactFake();
701
+ $customer->userForTest = new User();
702
+ $customer->userForTest->setRawAttributes(['email' => 'customer@example.test'], true);
703
+ $customer->setRawAttributes(['company_uuid' => 'company-portal'], true);
704
+
705
+ $content = fleetOpsNotificationWithEnvironment(fn () => (new CustomerCredentialsMail('plain-secret', $customer))->content());
706
+
707
+ expect($content->with['customerPortalUrl'])->toBe('https://console.fleetbase.test/acme-portal');
708
+ })->with([null, 'other-company']);
@@ -263,6 +263,7 @@ class FleetOpsDriverObserverUserFake extends User
263
263
  public function delete()
264
264
  {
265
265
  $this->deleted = true;
266
+ $this->setAttribute('deleted_at', '2026-01-01 00:00:00');
266
267
 
267
268
  return true;
268
269
  }
@@ -453,7 +454,7 @@ class FleetOpsNotifyDriverOnShiftChangeProbe extends NotifyDriverOnShiftChange
453
454
  return $this->schedule;
454
455
  }
455
456
 
456
- protected function getSchedulingSettings(): array
457
+ protected function getSchedulingSettings(?string $companyUuid): array
457
458
  {
458
459
  return $this->settings;
459
460
  }
@@ -1149,14 +1150,30 @@ test('contact observer prevents changing existing customer contact type', functi
1149
1150
  });
1150
1151
 
1151
1152
  test('driver observer defaults location unassigns related records and deletes driver users', function () {
1153
+ // releaseForProfile looks for the account's remaining driver/contact profiles
1154
+ $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
1155
+ $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
1156
+ $resolver->setDefaultConnection('mysql');
1157
+ Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
1158
+ foreach (['drivers', 'contacts'] as $profileTable) {
1159
+ $connection->getSchemaBuilder()->create($profileTable, function ($table) {
1160
+ $table->string('uuid')->nullable();
1161
+ $table->string('user_uuid')->nullable();
1162
+ $table->string('company_uuid')->nullable();
1163
+ $table->timestamp('deleted_at')->nullable();
1164
+ });
1165
+ }
1166
+
1152
1167
  $driver = new Driver();
1153
1168
  $driver->setRawAttributes([
1154
- 'uuid' => 'driver-uuid',
1155
- 'user_uuid' => 'user-uuid',
1169
+ 'uuid' => 'driver-uuid',
1170
+ 'user_uuid' => 'user-uuid',
1171
+ 'company_uuid' => 'company-uuid',
1156
1172
  ], true);
1157
1173
 
1158
1174
  $observer = new FleetOpsDriverObserverProbe();
1159
1175
  $user = new FleetOpsDriverObserverUserFake();
1176
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
1160
1177
 
1161
1178
  $observer->user = $user;
1162
1179
  $observer->creating($driver);
@@ -1175,6 +1192,14 @@ test('driver observer defaults location unassigns related records and deletes dr
1175
1192
  ->and($observer->unassigned)->toBe(['driver-uuid'])
1176
1193
  ->and($user->deleted)->toBeTrue();
1177
1194
 
1195
+ // A team member's account linked to the driver is never deleted
1196
+ $staff = new FleetOpsDriverObserverUserFake();
1197
+ $staff->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'user'], true);
1198
+ $observer->user = $staff;
1199
+ $observer->deleted($driver);
1200
+
1201
+ expect($staff->deleted)->toBeFalse();
1202
+
1178
1203
  $driverWithLocation = new Driver();
1179
1204
  $location = new Fleetbase\LaravelMysqlSpatial\Types\Point(1.3, 103.8);
1180
1205
  $driverWithLocation->location = $location;
@@ -2,6 +2,8 @@
2
2
 
3
3
  use Fleetbase\FleetOps\Http\Controllers\Api\v1\OrchestrationController;
4
4
  use Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry;
5
+ use Illuminate\Http\Exceptions\HttpResponseException;
6
+ use Illuminate\Http\Request;
5
7
 
6
8
  function sanitize_orchestrator_payload(array $payload): array
7
9
  {
@@ -72,3 +74,43 @@ test('consumable orchestrator routes are registered under the public api group',
72
74
  expect($routes)->toContain("\$router->post('run', 'OrchestrationController@run');");
73
75
  expect($routes)->toContain("\$router->post('commit', 'OrchestrationController@commit');");
74
76
  });
77
+
78
+ test('public orchestration uses API authentication without inheriting console IAM middleware', function () {
79
+ $registry = new OrchestrationEngineRegistry();
80
+ $public = new OrchestrationController($registry);
81
+ $internal = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrchestrationController($registry);
82
+
83
+ expect($public->getMiddleware())->toBe([]);
84
+
85
+ // An API-key request has no console IAM role. The internal workbench must
86
+ // still refuse this actor; its middleware must not leak onto the API class.
87
+ app('session.store')->flush();
88
+ $request = Request::create('/int/v1/fleet-ops/orchestrator/run', 'POST');
89
+ app()->instance('request', $request);
90
+ foreach (['run', 'commit'] as $method) {
91
+ $guard = collect($internal->getMiddleware())->first(fn ($entry) => in_array($method, $entry['options']['only'], true));
92
+ expect($guard)->not->toBeNull();
93
+
94
+ try {
95
+ $guard['middleware']($request, fn () => throw new RuntimeException('Unauthorized request reached the workbench'));
96
+ test()->fail('The internal workbench must require a permitted user');
97
+ } catch (HttpResponseException $error) {
98
+ expect($error->getResponse()->getStatusCode())->toBe(401);
99
+ }
100
+ }
101
+ });
102
+
103
+ test('public orchestration resolves tenant scope from the authenticated session, never request input', function () {
104
+ $controller = new OrchestrationController(new OrchestrationEngineRegistry());
105
+ $company = new ReflectionMethod($controller, 'companyUuid');
106
+ $company->setAccessible(true);
107
+ $request = Request::create('/v1/orchestrator/run', 'POST', ['company_uuid' => 'other-company']);
108
+ app()->instance('request', $request);
109
+
110
+ session(['company' => 'credential-company']);
111
+ expect($company->invoke($controller))->toBe('credential-company');
112
+
113
+ session(['company' => 'next-credential-company']);
114
+ expect($company->invoke($controller))->toBe('next-credential-company');
115
+ app('session.store')->flush();
116
+ });
@@ -115,6 +115,13 @@ class FleetOpsProviderContractsScheduledEventFake
115
115
  return $this;
116
116
  }
117
117
 
118
+ public function everyFifteenMinutes(): self
119
+ {
120
+ $this->methods[] = ['everyFifteenMinutes'];
121
+
122
+ return $this;
123
+ }
124
+
118
125
  public function daily(): self
119
126
  {
120
127
  $this->methods[] = ['daily'];
@@ -277,11 +284,14 @@ test('fleetops service provider executes package registration and boot wiring',
277
284
  'fleetops:process-operational-alerts',
278
285
  'fleetops:sync-telematics',
279
286
  'fleetops:drain-telematic-inbox',
287
+ 'fleetops:prune-telematics-data',
280
288
  ])
281
289
  ->and($provider->schedule?->commands['fleetops:dispatch-orders']->methods)->toContain(['everyMinute'], ['withoutOverlapping'], ['storeOutputInDb'])
282
290
  ->and($provider->schedule?->commands['fleetops:update-estimations']->methods)->toContain(['everyTenMinutes'], ['withoutOverlapping'])
283
291
  ->and($provider->schedule?->commands['fleetops:sync-telematics']->expiresAt)->toBe(2)
284
292
  ->and($provider->schedule?->commands['fleetops:drain-telematic-inbox']->expiresAt)->toBe(2)
293
+ ->and($provider->schedule?->commands['fleetops:prune-telematics-data']->methods)->toContain(['everyFifteenMinutes'], ['withoutOverlapping'], ['storeOutputInDb'])
294
+ ->and($provider->schedule?->commands['fleetops:prune-telematics-data']->expiresAt)->toBe(14)
285
295
  ->and($orchestrationRegistry->has('vroom'))->toBeTrue()
286
296
  ->and($orchestrationRegistry->has('greedy'))->toBeTrue()
287
297
  ->and($orchestrationRegistry->has('capacity'))->toBeTrue()