@fleetbase/fleetops-engine 0.6.68 → 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 (100) hide show
  1. package/RELEASE.md +11 -9
  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/modals/driver-form.hbs +2 -2
  10. package/addon/components/modals/reset-profile-credentials.hbs +23 -0
  11. package/addon/components/modals/reset-profile-credentials.js +70 -0
  12. package/addon/components/profile-identity-hint.hbs +13 -0
  13. package/addon/components/profile-identity-hint.js +49 -0
  14. package/addon/controllers/management/drivers/index.js +4 -0
  15. package/addon/extension.js +4 -2
  16. package/addon/services/contact-actions.js +50 -89
  17. package/addon/services/driver-actions.js +98 -0
  18. package/addon/utils/profile-login-actions.js +172 -0
  19. package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
  20. package/app/components/profile-identity-hint.js +1 -0
  21. package/composer.json +1 -1
  22. package/extension.json +1 -1
  23. package/package.json +3 -3
  24. package/scripts/pest-bootstrap.php +12 -0
  25. package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
  26. package/server/resources/views/mail/driver-credentials.blade.php +21 -0
  27. package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
  28. package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
  29. package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
  30. package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
  31. package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
  32. package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
  33. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
  34. package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
  35. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
  36. package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
  37. package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
  38. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
  39. package/server/src/Http/Resources/v1/Contact.php +3 -0
  40. package/server/src/Http/Resources/v1/Driver.php +3 -0
  41. package/server/src/Mail/DriverCredentialsMail.php +55 -0
  42. package/server/src/Models/Contact.php +55 -90
  43. package/server/src/Models/Sensor.php +3 -5
  44. package/server/src/Observers/ContactObserver.php +2 -22
  45. package/server/src/Observers/DriverObserver.php +5 -6
  46. package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
  47. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
  48. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
  49. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
  50. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
  51. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
  52. package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
  53. package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
  54. package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
  55. package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
  56. package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
  57. package/server/src/Support/ProfileAccountManager.php +405 -0
  58. package/server/src/routes.php +5 -0
  59. package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
  60. package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
  61. package/server/tests/ApiDriverControllerContractsTest.php +102 -20
  62. package/server/tests/CommandContractsTest.php +4 -4
  63. package/server/tests/ControllerHelperContractsTest.php +3 -11
  64. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
  65. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
  66. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
  67. package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
  68. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
  69. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
  70. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
  71. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
  72. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
  73. package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
  74. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
  75. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
  76. package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
  77. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
  78. package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
  79. package/server/tests/ModelAccessorContractsTest.php +82 -20
  80. package/server/tests/ObserverContractsTest.php +27 -2
  81. package/server/tests/RequestContractsTest.php +19 -11
  82. package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
  83. package/server/tests/TelematicControllerContractsTest.php +22 -4
  84. package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
  85. package/server/tests/Unit/Models/ContactTest.php +48 -5
  86. package/server/tests/Unit/Models/SensorTest.php +2 -2
  87. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
  88. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
  89. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
  90. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
  91. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
  92. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
  93. package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
  94. package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
  95. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
  96. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
  97. package/translations/en-us.yaml +37 -2
  98. package/translations/uk-ua.yaml +3475 -0
  99. package/addon/components/modals/reset-customer-credentials.hbs +0 -18
  100. package/addon/components/modals/reset-customer-credentials.js +0 -55
@@ -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
+ });
@@ -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
+ });