@fleetbase/fleetops-engine 0.6.67 → 0.6.69

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 (115) hide show
  1. package/RELEASE.md +11 -15
  2. package/addon/components/contact/form.hbs +13 -2
  3. package/addon/components/customer/details.hbs +17 -17
  4. package/addon/components/customer/form.hbs +13 -2
  5. package/addon/components/customer/form.js +0 -56
  6. package/addon/components/driver/details.hbs +13 -19
  7. package/addon/components/driver/form.hbs +20 -35
  8. package/addon/components/driver/form.js +0 -58
  9. package/addon/components/fuel-integration/form.hbs +2 -2
  10. package/addon/components/fuel-integration/form.js +8 -0
  11. package/addon/components/fuel-integration/hub.js +1 -1
  12. package/addon/components/modals/driver-form.hbs +2 -2
  13. package/addon/components/modals/reset-profile-credentials.hbs +23 -0
  14. package/addon/components/modals/reset-profile-credentials.js +70 -0
  15. package/addon/components/profile-identity-hint.hbs +13 -0
  16. package/addon/components/profile-identity-hint.js +49 -0
  17. package/addon/controllers/management/drivers/index.js +4 -0
  18. package/addon/extension.js +4 -2
  19. package/addon/services/contact-actions.js +50 -89
  20. package/addon/services/driver-actions.js +98 -0
  21. package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
  22. package/addon/utils/fuel-transaction.js +2 -0
  23. package/addon/utils/profile-login-actions.js +172 -0
  24. package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
  25. package/app/components/profile-identity-hint.js +1 -0
  26. package/composer.json +1 -1
  27. package/docs/SAFEE.md +5 -0
  28. package/docs/TELEMATICS_QUEUES.md +59 -8
  29. package/extension.json +1 -1
  30. package/package.json +3 -3
  31. package/scripts/pest-bootstrap.php +12 -0
  32. package/server/config/fuel-providers.php +53 -0
  33. package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
  34. package/server/resources/views/mail/driver-credentials.blade.php +21 -0
  35. package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
  36. package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
  37. package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
  38. package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
  39. package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
  40. package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
  41. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
  42. package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
  43. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
  44. package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
  45. package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
  46. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
  47. package/server/src/Http/Resources/v1/Contact.php +3 -0
  48. package/server/src/Http/Resources/v1/Driver.php +3 -0
  49. package/server/src/Jobs/PollTelematicTelemetry.php +36 -0
  50. package/server/src/Jobs/SyncTelematicDevicesJob.php +2 -2
  51. package/server/src/Mail/DriverCredentialsMail.php +55 -0
  52. package/server/src/Models/Contact.php +55 -90
  53. package/server/src/Models/Sensor.php +3 -5
  54. package/server/src/Observers/ContactObserver.php +2 -22
  55. package/server/src/Observers/DriverObserver.php +5 -6
  56. package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
  57. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
  58. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
  59. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
  60. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
  61. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
  62. package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
  63. package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
  64. package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
  65. package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
  66. package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
  67. package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
  68. package/server/src/Support/ProfileAccountManager.php +405 -0
  69. package/server/src/Support/Telematics/TelematicService.php +6 -4
  70. package/server/src/Support/Telematics/Telemetry/Inbox.php +27 -9
  71. package/server/src/routes.php +5 -0
  72. package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
  73. package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
  74. package/server/tests/ApiDriverControllerContractsTest.php +102 -20
  75. package/server/tests/CommandContractsTest.php +4 -4
  76. package/server/tests/ControllerHelperContractsTest.php +3 -11
  77. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
  78. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
  79. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
  80. package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
  81. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
  82. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
  83. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
  84. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
  85. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
  86. package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
  87. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
  88. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
  89. package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
  90. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
  91. package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +88 -4
  92. package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
  93. package/server/tests/ModelAccessorContractsTest.php +82 -20
  94. package/server/tests/ObserverContractsTest.php +27 -2
  95. package/server/tests/RequestContractsTest.php +19 -11
  96. package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
  97. package/server/tests/TelematicControllerContractsTest.php +22 -4
  98. package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
  99. package/server/tests/Unit/Models/ContactTest.php +48 -5
  100. package/server/tests/Unit/Models/SensorTest.php +2 -2
  101. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
  102. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
  103. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
  104. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
  105. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
  106. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
  107. package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
  108. package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
  109. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
  110. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
  111. package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
  112. package/translations/en-us.yaml +37 -2
  113. package/translations/uk-ua.yaml +3475 -0
  114. package/addon/components/modals/reset-customer-credentials.hbs +0 -18
  115. package/addon/components/modals/reset-customer-credentials.js +0 -55
@@ -116,7 +116,7 @@ test('sensor model exposes relation builders and logging options', function () {
116
116
 
117
117
  $sensor = new Sensor();
118
118
 
119
- expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', 'sensor_type'])
119
+ expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', 'type'])
120
120
  ->and($sensor->getSlugOptions()->slugField)->toBe('slug')
121
121
  ->and($sensor->getActivitylogOptions()->logOnlyDirty)->toBeTrue()
122
122
  ->and($sensor->telematic())->toBeInstanceOf(BelongsTo::class)
@@ -222,7 +222,7 @@ test('sensor scopes write expected query constraints', function () {
222
222
  ->and($sensor->scopeActive($query))->toBe($query)
223
223
  ->and($sensor->scopeWithRecentReadings($query, 30))->toBe($query)
224
224
  ->and($sensor->scopeOutOfThreshold($query))->toBe($query)
225
- ->and($query->calls[0])->toBe(['where', ['sensor_type', 'temperature']])
225
+ ->and($query->calls[0])->toBe(['where', ['type', 'temperature']])
226
226
  ->and($query->calls[1])->toBe(['where', ['status', 'active']])
227
227
  ->and($query->calls[2][0])->toBe('where')
228
228
  ->and($query->calls[2][1][0])->toBe('last_reading_at')
@@ -176,45 +176,51 @@ test('customer creation forces the customer type and scopes orders', function ()
176
176
  expect($customer->orders())->toBeInstanceOf(HasMany::class);
177
177
  });
178
178
 
179
- test('contact observer availability checks and deletion resolve against users', function () {
179
+ test('contact observer deletion releases the managed account and leaves team members alone', function () {
180
180
  $connection = fleetopsSmallModelBoot();
181
- $connection->table('users')->insert(['uuid' => 'user-2', 'company_uuid' => 'company-1', 'email' => 'taken@example.test', 'phone' => '+6512345678']);
181
+ config(['activitylog.enabled' => false]);
182
+ app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config());
183
+ $connection->getSchemaBuilder()->create('drivers', function ($blueprint) {
184
+ $blueprint->increments('id');
185
+ $blueprint->string('uuid')->nullable();
186
+ $blueprint->string('company_uuid')->nullable();
187
+ $blueprint->string('user_uuid')->nullable();
188
+ $blueprint->timestamp('deleted_at')->nullable();
189
+ });
190
+ $connection->table('users')->insert([
191
+ ['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'type' => 'contact'],
192
+ ['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'type' => 'user'],
193
+ ]);
182
194
 
183
- $observer = new ContactObserver();
184
- $reflection = new ReflectionClass($observer);
195
+ $observer = new ContactObserver();
185
196
 
186
197
  $contact = new Fleetbase\FleetOps\Models\Contact();
187
- $contact->setRawAttributes(['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-1', 'email' => 'taken@example.test', 'phone' => '+6512345678', 'type' => 'contact'], true);
198
+ $contact->setRawAttributes(['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111', 'type' => 'contact'], true);
188
199
  $contact->exists = true;
189
200
 
190
- $emailCheck = $reflection->getMethod('isEmailUnavailable');
191
- $emailCheck->setAccessible(true);
192
- expect($emailCheck->invoke($observer, $contact))->toBeTrue();
193
-
194
- $phoneCheck = $reflection->getMethod('isPhoneUnavailable');
195
- $phoneCheck->setAccessible(true);
196
- expect($phoneCheck->invoke($observer, $contact))->toBeTrue();
201
+ $staffContact = new Fleetbase\FleetOps\Models\Contact();
202
+ $staffContact->setRawAttributes(['uuid' => 'contact-2', 'company_uuid' => 'company-1', 'user_uuid' => '22222222-2222-4222-8222-222222222222', 'type' => 'contact'], true);
203
+ $staffContact->exists = true;
197
204
 
198
- $contact->email = 'free@example.test';
199
- $contact->phone = '+6599999999';
200
- expect($emailCheck->invoke($observer, $contact))->toBeFalse()
201
- ->and($phoneCheck->invoke($observer, $contact))->toBeFalse();
202
-
203
- // Deletion removes the associated user account
204
205
  $observer->deleted($contact);
205
- expect(true)->toBeTrue();
206
+ $observer->deleted($staffContact);
207
+
208
+ // The managed account had no other profile, so it is deleted
209
+ expect($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->value('deleted_at'))->not->toBeNull()
210
+ ->and($connection->table('users')->where('uuid', '22222222-2222-4222-8222-222222222222')->value('deleted_at'))->toBeNull();
206
211
  });
207
212
 
208
213
  test('contact observer rejects saves whose email or phone belongs to another account', function () {
209
214
  $connection = fleetopsSmallModelBoot();
210
- $connection->table('users')->insert(['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'email' => 'taken@example.test', 'phone' => '+6512345678']);
215
+ $connection->table('users')->insert([
216
+ ['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'type' => 'contact', 'email' => 'free@example.test', 'phone' => '+6599999999'],
217
+ ['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'type' => 'user', 'email' => 'taken@example.test', 'phone' => '+6512345678'],
218
+ ]);
211
219
 
212
220
  $observer = new ContactObserver();
213
221
 
214
- // The availability checks are private, so the guards can only be reached
215
- // through saving() with a contact that genuinely collides on a real table.
216
222
  // hasUser() keys off a well-formed uuid, so a real one keeps saving() from
217
- // detouring into account provisioning before it reaches the guards.
223
+ // detouring into account provisioning before it reaches the sync.
218
224
  $contact = new Fleetbase\FleetOps\Models\Contact();
219
225
  $contact->setRawAttributes([
220
226
  'uuid' => 'contact-collide',
@@ -226,23 +232,16 @@ test('contact observer rejects saves whose email or phone belongs to another acc
226
232
  ], true);
227
233
  $contact->exists = true;
228
234
 
229
- // wasChanged() reads the post-save change set, which setRawAttributes does
230
- // not populate — syncChanges promotes the pending edit into it.
231
235
  $contact->email = 'taken@example.test';
232
- $contact->syncChanges();
233
236
 
234
237
  expect(fn () => $observer->saving($contact))
235
- ->toThrow(Exception::class, 'Email attempting to update for contact is not available.');
238
+ ->toThrow(Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException::class, 'This email is already in use by another account.');
236
239
 
237
- // The phone guard sits behind the email one, so it only surfaces once the
238
- // email is back to an available value
239
- $contact->syncOriginal();
240
240
  $contact->email = 'free@example.test';
241
241
  $contact->phone = '+6512345678';
242
- $contact->syncChanges();
243
242
 
244
243
  expect(fn () => $observer->saving($contact))
245
- ->toThrow(Exception::class, 'Phone attempting to update for contact is not available.');
244
+ ->toThrow(Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException::class, 'This phone number is already in use by another account.');
246
245
  });
247
246
 
248
247
  test('tracking status request authorizes by session and rejects duplicates', function () {
@@ -98,11 +98,16 @@ test('driver observer seams unassign orders and resolve the driver user', functi
98
98
  ->and($connection->table('orders')->where('uuid', 'order-seam-1')->value('driver_assigned_uuid'))->toBeNull()
99
99
  ->and($connection->table('orders')->where('uuid', 'order-seam-2')->value('driver_assigned_uuid'))->toBe('driver-other');
100
100
 
101
- // The linked account resolves only when it is still a plain user
101
+ // The linked account resolves whatever its type: releaseForProfile decides
102
+ // whether a managed account is deleted and leaves a team member alone
102
103
  expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$driver])?->uuid)->toBe('user-seam-1');
103
104
 
105
+ $managedDriver = new Driver();
106
+ $managedDriver->setRawAttributes(['uuid' => 'driver-seam-2', 'user_uuid' => 'user-seam-2'], true);
107
+ expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$managedDriver])?->uuid)->toBe('user-seam-2');
108
+
104
109
  $driverWithoutUser = new Driver();
105
- $driverWithoutUser->setRawAttributes(['uuid' => 'driver-seam-2', 'user_uuid' => 'user-seam-2'], true);
110
+ $driverWithoutUser->setRawAttributes(['uuid' => 'driver-seam-3', 'user_uuid' => null], true);
106
111
  expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$driverWithoutUser]))->toBeNull();
107
112
  });
108
113
 
@@ -4,6 +4,10 @@ if (!class_exists('Fleetbase\Ai\Support\AiCapabilityRegistry', false)) {
4
4
  eval('namespace Fleetbase\Ai\Support; class AiCapabilityRegistry { public array $registered = []; public function register($capability) { $this->registered[] = $capability; return $this; } }');
5
5
  }
6
6
 
7
+ if (!class_exists('Fleetbase\Ai\Support\Commands\AiCommandRegistry', false)) {
8
+ eval('namespace Fleetbase\Ai\Support\Commands; class AiCommandRegistry { public array $registered = []; public function registerMany(array $commands) { $this->registered = array_merge($this->registered, $commands); return $this; } }');
9
+ }
10
+
7
11
  if (!class_exists('Fleetbase\Ai\Support\AiQueryRegistry', false)) {
8
12
  eval('namespace Fleetbase\Ai\Support; class AiQueryRegistry { public array $registered = []; public function register($resource = null) { $this->registered[] = $resource; return $this; } }');
9
13
  }
@@ -44,9 +48,18 @@ test('ai capability registration wires query resources and capabilities', functi
44
48
  $capabilityRegistry = app(Fleetbase\Ai\Support\AiCapabilityRegistry::class);
45
49
 
46
50
  fwrite(STDERR, "\nDBG ai: " . json_encode([is_object($queryRegistry) ? get_class($queryRegistry) : gettype($queryRegistry), is_object($capabilityRegistry) ? get_class($capabilityRegistry) : gettype($capabilityRegistry), is_object($capabilityRegistry) ? count($capabilityRegistry->registered) : null]) . "\n");
47
- expect($capabilityRegistry->registered)->toHaveCount(9)
51
+ expect($capabilityRegistry->registered)->toHaveCount(13)
48
52
  ->and(collect($capabilityRegistry->registered)->map(fn ($capability) => get_class($capability)))
49
- ->toContain(Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability::class);
53
+ ->toContain(
54
+ Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability::class,
55
+ Fleetbase\FleetOps\Support\Ai\Tools\SearchResourcesTool::class,
56
+ Fleetbase\FleetOps\Support\Ai\Tools\CreateOrderTool::class,
57
+ Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool::class,
58
+ Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool::class,
59
+ );
60
+
61
+ $commandRegistry = app(Fleetbase\Ai\Support\Commands\AiCommandRegistry::class);
62
+ expect(collect($commandRegistry->registered)->pluck('id'))->toContain('fleet-ops.orders.create', 'fleet-ops.settings.map.open');
50
63
 
51
64
  // The query resources helper registers the fleet-ops queryables directly
52
65
  $freshQueryRegistry = new Fleetbase\Ai\Support\AiQueryRegistry();
@@ -117,4 +117,46 @@ test('search capability queries scope companies with eager relations', function
117
117
  ->and($helper('vehicleSearchQuery')->count())->toBe(0)
118
118
  ->and($helper('driverSearchQuery')->count())->toBe(3)
119
119
  ->and($helper('genericSearchQuery', Fleetbase\FleetOps\Models\Contact::class)->count())->toBe(0);
120
+
121
+ foreach ([Fleetbase\FleetOps\Models\WorkOrder::class, Fleetbase\FleetOps\Models\Maintenance::class, Fleetbase\FleetOps\Models\Device::class, Fleetbase\FleetOps\Models\Sensor::class, Fleetbase\FleetOps\Models\Telematic::class] as $modelClass) {
122
+ expect($helper('genericSearchQuery', $modelClass))->toBeInstanceOf(Illuminate\Database\Eloquent\Builder::class);
123
+ }
124
+ });
125
+
126
+ test('asset status scoped queries resolve a list permission for every supported model', function () {
127
+ fleetopsAssetStatusCapabilityBoot();
128
+
129
+ $capability = (new ReflectionClass(AssetStatusCapability::class))->newInstanceWithoutConstructor();
130
+ $scoped = new ReflectionMethod(AssetStatusCapability::class, 'scopedQuery');
131
+ $scoped->setAccessible(true);
132
+
133
+ foreach ([Fleetbase\FleetOps\Models\Vehicle::class, Fleetbase\FleetOps\Models\Device::class, Fleetbase\FleetOps\Models\Sensor::class, Fleetbase\FleetOps\Models\Telematic::class, Fleetbase\FleetOps\Models\Contact::class] as $modelClass) {
134
+ expect($scoped->invoke($capability, $modelClass))->toBeInstanceOf(Illuminate\Database\Eloquent\Builder::class);
135
+ }
136
+ });
137
+
138
+ test('capability queries apply IAM directives for the list permission when the directive macro is available', function () {
139
+ fleetopsAssetStatusCapabilityBoot();
140
+
141
+ if (!Illuminate\Database\Eloquent\Builder::hasGlobalMacro('applyDirectivesForPermissions')) {
142
+ Illuminate\Database\Eloquent\Builder::macro('applyDirectivesForPermissions', function (string|array $names = []) {
143
+ $GLOBALS['fleetopsAppliedDirectivePermissions'][] = $names;
144
+
145
+ return $this;
146
+ });
147
+ }
148
+
149
+ $GLOBALS['fleetopsAppliedDirectivePermissions'] = [];
150
+
151
+ $capability = (new ReflectionClass(AssetStatusCapability::class))->newInstanceWithoutConstructor();
152
+ $scope = new ReflectionMethod(AssetStatusCapability::class, 'scopeToPermission');
153
+ $scope->setAccessible(true);
154
+ $plain = new stdClass();
155
+
156
+ expect($scope->invoke($capability, $plain, 'fleet-ops list driver'))->toBe($plain);
157
+
158
+ $builder = Fleetbase\FleetOps\Models\Driver::query();
159
+
160
+ expect($scope->invoke($capability, $builder, 'fleet-ops list driver'))->toBe($builder)
161
+ ->and($GLOBALS['fleetopsAppliedDirectivePermissions'])->toContain('fleet-ops list driver');
120
162
  });
@@ -123,6 +123,11 @@ class FleetOpsOrderInsightsCapabilityFake extends OrderInsightsCapability
123
123
  return $this->allowed && $permission === 'fleet-ops see order';
124
124
  }
125
125
 
126
+ protected function currency(): string
127
+ {
128
+ return 'USD';
129
+ }
130
+
126
131
  protected function orderQuery(?string $companyUuid): mixed
127
132
  {
128
133
  $this->companies[] = $companyUuid;
@@ -183,6 +188,7 @@ test('order insights resolve returns bounded aggregate order metrics', function
183
188
  'authorized' => true,
184
189
  'metric' => 'orders',
185
190
  'amount_threshold' => 125.50,
191
+ 'amount_currency' => 'USD',
186
192
  'count' => 7,
187
193
  'counts_by_status' => ['completed' => 5, 'active' => 2],
188
194
  'sample_order_ids' => ['order_1', 'order_2'],
@@ -196,7 +202,7 @@ test('order insights resolve returns bounded aggregate order metrics', function
196
202
  ->and($capability->companies)->toBe(['company-123'])
197
203
  ->and($capability->query->recordedCalls())->toContain(
198
204
  ['whereBetween', 'created_at', [$start, $end]],
199
- ['whereHas', 'transaction', [['where', 'amount', '>', 125.50]]],
205
+ ['whereHas', 'transaction', [['where', 'amount', '>', 12550.0]]],
200
206
  ['count'],
201
207
  ['selectRaw', 'status, count(*) as aggregate'],
202
208
  ['groupBy', 'status'],
@@ -224,3 +230,15 @@ test('order insights resolve omits optional filters when prompt has no date or a
224
230
  ->and($capability->query->recordedCalls())->not->toContain(['whereBetween'])
225
231
  ->and($capability->query->recordedCalls())->not->toContain(['whereHas']);
226
232
  });
233
+
234
+ test('order insights converts major-unit thresholds using the currency exponent', function () {
235
+ $capability = new FleetOpsOrderInsightsCapabilityFake();
236
+ $method = new ReflectionMethod(OrderInsightsCapability::class, 'toMinorUnits');
237
+ $method->setAccessible(true);
238
+
239
+ expect($method->invoke($capability, 500.0, 'USD'))->toBe(50000)
240
+ ->and($method->invoke($capability, 125.5, 'usd'))->toBe(12550)
241
+ ->and($method->invoke($capability, 1000.0, 'JPY'))->toBe(1000)
242
+ ->and($method->invoke($capability, 1.5, 'BHD'))->toBe(1500)
243
+ ->and($method->invoke($capability, 10.0, 'NOT-A-CURRENCY'))->toBe(1000);
244
+ });
@@ -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
+ });