@fleetbase/fleetops-engine 0.6.61 → 0.6.63

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 (68) hide show
  1. package/RELEASE.md +5 -14
  2. package/addon/controllers/management/drivers/index.js +4 -1
  3. package/addon/routes/management/fleets/index/details.js +1 -1
  4. package/addon/routes/management/fleets/index/edit.js +1 -1
  5. package/addon/routes/management/fleets/index.js +3 -1
  6. package/addon/services/map-settings.js +4 -1
  7. package/composer.json +1 -1
  8. package/extension.json +1 -1
  9. package/package.json +1 -1
  10. package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
  11. package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
  12. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
  13. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
  14. package/server/src/Http/Controllers/Api/v1/DriverController.php +93 -57
  15. package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
  16. package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
  17. package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
  18. package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
  19. package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
  20. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
  21. package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
  22. package/server/src/Http/Controllers/Api/v1/VehicleController.php +134 -35
  23. package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
  24. package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
  25. package/server/src/Http/Filter/DriverFilter.php +71 -15
  26. package/server/src/Http/Filter/FleetFilter.php +32 -33
  27. package/server/src/Http/Filter/VehicleFilter.php +57 -19
  28. package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
  29. package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
  30. package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
  31. package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
  32. package/server/src/Http/Requests/CreateVehicleRequest.php +150 -31
  33. package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
  34. package/server/src/Http/Resources/v1/Driver.php +22 -2
  35. package/server/src/Http/Resources/v1/Fleet.php +38 -25
  36. package/server/src/Http/Resources/v1/Vehicle.php +46 -4
  37. package/server/src/Models/Driver.php +2 -2
  38. package/server/src/routes.php +8 -0
  39. package/server/tests/ApiDriverControllerContractsTest.php +284 -13
  40. package/server/tests/ApiEntityControllerContractsTest.php +1 -1
  41. package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
  42. package/server/tests/ApiFleetControllerContractsTest.php +354 -21
  43. package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
  44. package/server/tests/ApiIssueControllerContractsTest.php +1 -1
  45. package/server/tests/ApiOrderControllerContractsTest.php +1 -1
  46. package/server/tests/ApiPartControllerContractsTest.php +2 -2
  47. package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
  48. package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
  49. package/server/tests/ApiVehicleControllerContractsTest.php +175 -8
  50. package/server/tests/ControllerFilterContractsTest.php +132 -13
  51. package/server/tests/ControllerHelperContractsTest.php +6 -3
  52. package/server/tests/DriverFilterExecutionTest.php +130 -6
  53. package/server/tests/ExceptionContractsTest.php +15 -0
  54. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
  55. package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
  56. package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
  57. package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
  58. package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
  59. package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
  60. package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
  61. package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
  62. package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
  63. package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
  64. package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
  65. package/server/tests/FleetHierarchyResourceTest.php +56 -8
  66. package/server/tests/RequestContractsTest.php +100 -7
  67. package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
  68. package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
@@ -3,23 +3,60 @@
3
3
  use Fleetbase\FleetOps\Http\Controllers\Api\v1\FleetController;
4
4
  use Fleetbase\FleetOps\Http\Requests\CreateFleetRequest;
5
5
  use Fleetbase\FleetOps\Http\Requests\UpdateFleetRequest;
6
+ use Fleetbase\FleetOps\Models\Driver;
6
7
  use Fleetbase\FleetOps\Models\Fleet;
8
+ use Fleetbase\FleetOps\Models\ServiceArea;
9
+ use Fleetbase\FleetOps\Models\Vehicle;
10
+ use Fleetbase\FleetOps\Models\Vendor;
11
+ use Fleetbase\FleetOps\Models\Zone;
12
+ use Fleetbase\Models\File;
7
13
  use Illuminate\Database\Eloquent\ModelNotFoundException;
8
14
  use Illuminate\Http\Request;
9
15
 
10
16
  class FleetOpsApiFleetControllerProbe extends FleetController
11
17
  {
12
- public ?Fleet $fleet = null;
13
- public array $createdFleets = [];
14
- public array $serviceAreaLookups = [];
15
- public mixed $queryResults = null;
16
- public bool $fleetNotFound = false;
18
+ public ?Fleet $fleet = null;
19
+ public ?Vehicle $vehicle = null;
20
+ public ?Driver $driver = null;
21
+ public array $createdFleets = [];
22
+ public array $relationLookups = [];
23
+ public array $unresolvable = [];
24
+ public array $parentChain = [];
25
+ public array $membershipCalls = [];
26
+ public mixed $queryResults = null;
27
+ public bool $fleetNotFound = false;
28
+ public bool $resourceNotFound = false;
29
+
30
+ public function inputForTest(Request $request): array
31
+ {
32
+ return $this->fleetInputFromRequest($request);
33
+ }
17
34
 
18
- protected function getServiceAreaUuid(string $table, array $where): ?string
35
+ /**
36
+ * Stand in for the company-scoped public-id lookup.
37
+ *
38
+ * Anything listed in `$unresolvable` behaves as a cross-company or missing
39
+ * identifier does in production: nothing is found inside the caller's own
40
+ * company, and the lookup raises.
41
+ */
42
+ protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
19
43
  {
20
- $this->serviceAreaLookups[] = [$table, $where];
44
+ if (empty($id)) {
45
+ return null;
46
+ }
47
+
48
+ $this->relationLookups[] = [$modelClass, $id];
21
49
 
22
- return 'service-area-uuid';
50
+ if (in_array($id, $this->unresolvable, true)) {
51
+ throw (new ModelNotFoundException())->setModel($modelClass, $id);
52
+ }
53
+
54
+ return $id . '-uuid';
55
+ }
56
+
57
+ protected function parentUuidOf(string $uuid): ?string
58
+ {
59
+ return $this->parentChain[$uuid] ?? null;
23
60
  }
24
61
 
25
62
  protected function createFleet(array $input): Fleet
@@ -27,7 +64,7 @@ class FleetOpsApiFleetControllerProbe extends FleetController
27
64
  $this->createdFleets[] = $input;
28
65
 
29
66
  $fleet = new FleetOpsApiFleetFake();
30
- $fleet->setRawAttributes(array_merge(['uuid' => 'created-fleet-uuid'], $input));
67
+ $fleet->setRawAttributes(array_merge(['uuid' => 'created-fleet-uuid', 'public_id' => 'fleet_created'], $input));
31
68
 
32
69
  return $fleet;
33
70
  }
@@ -43,6 +80,49 @@ class FleetOpsApiFleetControllerProbe extends FleetController
43
80
  return $this->fleet;
44
81
  }
45
82
 
83
+ protected function findVehicle(string $id): Vehicle
84
+ {
85
+ if ($this->resourceNotFound) {
86
+ throw new ModelNotFoundException();
87
+ }
88
+
89
+ return $this->vehicle;
90
+ }
91
+
92
+ protected function findDriver(string $id): Driver
93
+ {
94
+ if ($this->resourceNotFound) {
95
+ throw new ModelNotFoundException();
96
+ }
97
+
98
+ return $this->driver;
99
+ }
100
+
101
+ protected function withPublicRelations(Fleet $fleet): Fleet
102
+ {
103
+ return $fleet;
104
+ }
105
+
106
+ protected function assignVehicleToFleet(Fleet $fleet, Vehicle $vehicle): void
107
+ {
108
+ $this->membershipCalls[] = ['assign-vehicle', $fleet->public_id, $vehicle->public_id];
109
+ }
110
+
111
+ protected function removeVehicleFromFleet(Fleet $fleet, Vehicle $vehicle): void
112
+ {
113
+ $this->membershipCalls[] = ['remove-vehicle', $fleet->public_id, $vehicle->public_id];
114
+ }
115
+
116
+ protected function assignDriverToFleet(Fleet $fleet, Driver $driver): void
117
+ {
118
+ $this->membershipCalls[] = ['assign-driver', $fleet->public_id, $driver->public_id];
119
+ }
120
+
121
+ protected function removeDriverFromFleet(Fleet $fleet, Driver $driver): void
122
+ {
123
+ $this->membershipCalls[] = ['remove-driver', $fleet->public_id, $driver->public_id];
124
+ }
125
+
46
126
  protected function queryFleets(Request $request)
47
127
  {
48
128
  return $this->queryResults ?? [['uuid' => 'fleet-uuid']];
@@ -90,6 +170,14 @@ class FleetOpsApiFleetFake extends Fleet
90
170
  }
91
171
  }
92
172
 
173
+ class FleetOpsApiFleetVehicleFake extends Vehicle
174
+ {
175
+ }
176
+
177
+ class FleetOpsApiFleetDriverFake extends Driver
178
+ {
179
+ }
180
+
93
181
  function fleetopsCreateFleetRequest(array $input): CreateFleetRequest
94
182
  {
95
183
  return CreateFleetRequest::create('/api/v1/fleets', 'POST', $input);
@@ -100,6 +188,14 @@ function fleetopsUpdateFleetRequest(array $input): UpdateFleetRequest
100
188
  return UpdateFleetRequest::create('/api/v1/fleets/fleet-public', 'PUT', $input);
101
189
  }
102
190
 
191
+ function fleetopsFleetFake(array $attributes): FleetOpsApiFleetFake
192
+ {
193
+ $fleet = new FleetOpsApiFleetFake();
194
+ $fleet->setRawAttributes($attributes);
195
+
196
+ return $fleet;
197
+ }
198
+
103
199
  test('api fleet controller creates fleets with service area resolution', function () {
104
200
  session(['company' => 'company-uuid']);
105
201
 
@@ -111,28 +207,191 @@ test('api fleet controller creates fleets with service area resolution', functio
111
207
  'ignored' => 'not copied',
112
208
  ]));
113
209
 
210
+ // The pre-expansion contract — name plus service area — still behaves the
211
+ // way clients written against it expect.
114
212
  expect($response['resource'])->toBe('fleet')
115
- ->and($controller->serviceAreaLookups)->toBe([
116
- [
117
- 'service_areas',
118
- [
119
- 'public_id' => 'service-area-public',
120
- 'company_uuid' => 'company-uuid',
121
- ],
122
- ],
213
+ ->and($controller->relationLookups)->toBe([
214
+ [ServiceArea::class, 'service-area-public'],
123
215
  ])
124
216
  ->and($controller->createdFleets[0])->toBe([
125
217
  'name' => 'Downtown Fleet',
218
+ 'service_area_uuid' => 'service-area-public-uuid',
126
219
  'company_uuid' => 'company-uuid',
127
- 'service_area_uuid' => 'service-area-uuid',
128
220
  ]);
129
221
  });
130
222
 
223
+ test('api fleet controller accepts every safe fleet field and resolves each relationship', function () {
224
+ session(['company' => 'company-uuid']);
225
+
226
+ $controller = new FleetOpsApiFleetControllerProbe();
227
+
228
+ $controller->create(fleetopsCreateFleetRequest([
229
+ 'name' => 'Carpool',
230
+ 'color' => '#2563EB',
231
+ 'task' => 'Employee transport',
232
+ 'status' => 'active',
233
+ 'parent_fleet' => 'fleet_parent123',
234
+ 'vendor' => 'vendor_123',
235
+ 'service_area' => 'service_area_123',
236
+ 'zone' => 'zone_123',
237
+ 'photo' => 'file_123',
238
+ ]));
239
+
240
+ expect($controller->createdFleets[0])->toBe([
241
+ 'name' => 'Carpool',
242
+ 'color' => '#2563EB',
243
+ 'task' => 'Employee transport',
244
+ 'status' => 'active',
245
+ 'service_area_uuid' => 'service_area_123-uuid',
246
+ 'zone_uuid' => 'zone_123-uuid',
247
+ 'vendor_uuid' => 'vendor_123-uuid',
248
+ 'parent_fleet_uuid' => 'fleet_parent123-uuid',
249
+ 'image_uuid' => 'file_123-uuid',
250
+ 'company_uuid' => 'company-uuid',
251
+ ])->and($controller->relationLookups)->toBe([
252
+ [ServiceArea::class, 'service_area_123'],
253
+ [Zone::class, 'zone_123'],
254
+ [Vendor::class, 'vendor_123'],
255
+ [Fleet::class, 'fleet_parent123'],
256
+ [File::class, 'file_123'],
257
+ ]);
258
+ });
259
+
260
+ test('api fleet controller input excludes tenancy raw relation and generated columns', function () {
261
+ session(['company' => 'company-uuid']);
262
+
263
+ $controller = new FleetOpsApiFleetControllerProbe();
264
+
265
+ $input = $controller->inputForTest(new Request([
266
+ 'name' => 'Carpool',
267
+ 'company_uuid' => 'someone-elses-company',
268
+ 'uuid' => 'forged',
269
+ '_key' => 'forged',
270
+ 'public_id' => 'fleet_forged',
271
+ 'slug' => 'forged',
272
+ 'service_area_uuid' => 'forged',
273
+ 'zone_uuid' => 'forged',
274
+ 'vendor_uuid' => 'forged',
275
+ 'parent_fleet_uuid' => 'forged',
276
+ 'image_uuid' => 'forged',
277
+ ]));
278
+
279
+ expect(array_keys($input))->toBe(['name']);
280
+ });
281
+
282
+ test('api fleet controller creates a root fleet when no parent is supplied', function () {
283
+ session(['company' => 'company-uuid']);
284
+
285
+ $controller = new FleetOpsApiFleetControllerProbe();
286
+ $controller->create(fleetopsCreateFleetRequest(['name' => 'Root Fleet']));
287
+
288
+ expect($controller->createdFleets[0])->not->toHaveKey('parent_fleet_uuid');
289
+ });
290
+
291
+ test('api fleet controller clears the parent fleet when it is sent null', function () {
292
+ session(['company' => 'company-uuid']);
293
+
294
+ $controller = new FleetOpsApiFleetControllerProbe();
295
+ $controller->fleet = fleetopsFleetFake([
296
+ 'uuid' => 'child-uuid',
297
+ 'public_id' => 'fleet_child',
298
+ 'parent_fleet_uuid' => 'parent-uuid',
299
+ ]);
300
+
301
+ $controller->update('fleet_child', fleetopsUpdateFleetRequest(['parent_fleet' => null]));
302
+
303
+ expect($controller->fleet->updates[0])->toBe(['parent_fleet_uuid' => null])
304
+ ->and($controller->relationLookups)->toBe([]);
305
+ });
306
+
307
+ test('api fleet controller rejects a fleet that names itself as its parent', function () {
308
+ session(['company' => 'company-uuid']);
309
+
310
+ $controller = new FleetOpsApiFleetControllerProbe();
311
+ $controller->fleet = fleetopsFleetFake(['uuid' => 'fleet_self-uuid', 'public_id' => 'fleet_self']);
312
+
313
+ $response = $controller->update('fleet_self', fleetopsUpdateFleetRequest(['parent_fleet' => 'fleet_self']));
314
+
315
+ expect($response)->toBe([
316
+ 'json' => ['error' => 'A fleet cannot be its own parent fleet.'],
317
+ 'status' => 422,
318
+ ])->and($controller->fleet->updates)->toBe([]);
319
+ });
320
+
321
+ test('api fleet controller rejects a parent that sits below the fleet in the tree', function () {
322
+ session(['company' => 'company-uuid']);
323
+
324
+ $controller = new FleetOpsApiFleetControllerProbe();
325
+ $controller->fleet = fleetopsFleetFake(['uuid' => 'root-uuid', 'public_id' => 'fleet_root']);
326
+
327
+ // grandchild -> child -> root: making the grandchild the root's parent
328
+ // would close the loop.
329
+ $controller->parentChain = [
330
+ 'fleet_grandchild-uuid' => 'fleet_child-uuid',
331
+ 'fleet_child-uuid' => 'root-uuid',
332
+ ];
333
+
334
+ $response = $controller->update('fleet_root', fleetopsUpdateFleetRequest([
335
+ 'parent_fleet' => 'fleet_grandchild',
336
+ ]));
337
+
338
+ expect($response)->toBe([
339
+ 'json' => ['error' => 'A fleet cannot be assigned beneath one of its own subfleets.'],
340
+ 'status' => 422,
341
+ ])->and($controller->fleet->updates)->toBe([]);
342
+ });
343
+
344
+ test('api fleet controller accepts a parent that is not an ancestor of the fleet', function () {
345
+ session(['company' => 'company-uuid']);
346
+
347
+ $controller = new FleetOpsApiFleetControllerProbe();
348
+ $controller->fleet = fleetopsFleetFake(['uuid' => 'fleet-uuid', 'public_id' => 'fleet_child']);
349
+
350
+ $controller->parentChain = ['fleet_parent-uuid' => 'unrelated-root-uuid'];
351
+
352
+ $controller->update('fleet_child', fleetopsUpdateFleetRequest(['parent_fleet' => 'fleet_parent']));
353
+
354
+ expect($controller->fleet->updates[0])->toBe(['parent_fleet_uuid' => 'fleet_parent-uuid']);
355
+ });
356
+
357
+ test('api fleet controller rejects relationships that belong to another company', function () {
358
+ session(['company' => 'company-uuid']);
359
+
360
+ foreach (['parent_fleet', 'vendor', 'zone', 'service_area'] as $relation) {
361
+ $expected = [
362
+ 'json' => ['error' => 'No ' . str_replace('_', ' ', $relation) . ' resource found for the identifier provided.'],
363
+ 'status' => 404,
364
+ ];
365
+
366
+ $controller = new FleetOpsApiFleetControllerProbe();
367
+ $controller->unresolvable = ['other_company_id'];
368
+
369
+ $created = $controller->create(fleetopsCreateFleetRequest([
370
+ 'name' => 'Carpool',
371
+ $relation => 'other_company_id',
372
+ ]));
373
+
374
+ $updateController = new FleetOpsApiFleetControllerProbe();
375
+ $updateController->unresolvable = ['other_company_id'];
376
+ $updateController->fleet = fleetopsFleetFake(['uuid' => 'fleet-uuid', 'public_id' => 'fleet_123']);
377
+
378
+ $updated = $updateController->update('fleet_123', fleetopsUpdateFleetRequest([
379
+ $relation => 'other_company_id',
380
+ ]));
381
+
382
+ // A cross-company identifier is answered exactly as a missing one, so
383
+ // the response cannot be used to discover another organization's data.
384
+ // Update answers the same way create does, and writes nothing.
385
+ expect($created)->toBe($expected)
386
+ ->and($updated)->toBe($expected)
387
+ ->and($updateController->fleet->updates)->toBe([]);
388
+ }
389
+ });
390
+
131
391
  test('api fleet controller updates queries finds and deletes fleets', function () {
132
392
  session(['company' => 'company-uuid']);
133
393
 
134
- $fleet = new FleetOpsApiFleetFake();
135
- $fleet->setRawAttributes(['uuid' => 'fleet-uuid', 'name' => 'Old Fleet']);
394
+ $fleet = fleetopsFleetFake(['uuid' => 'fleet-uuid', 'name' => 'Old Fleet']);
136
395
 
137
396
  $controller = new FleetOpsApiFleetControllerProbe();
138
397
  $controller->fleet = $fleet;
@@ -149,7 +408,7 @@ test('api fleet controller updates queries finds and deletes fleets', function (
149
408
  expect($updated)->toBe(['resource' => 'fleet', 'fleet' => $fleet])
150
409
  ->and($fleet->updates[0])->toBe([
151
410
  'name' => 'Updated Fleet',
152
- 'service_area_uuid' => 'service-area-uuid',
411
+ 'service_area_uuid' => 'service-area-public-uuid',
153
412
  ])
154
413
  ->and($query)->toBe([
155
414
  'collection' => 'fleet',
@@ -174,3 +433,77 @@ test('api fleet controller returns missing fleet responses for update find and d
174
433
  ->and($controller->find('missing-fleet', new Request()))->toBe($expected)
175
434
  ->and($controller->delete('missing-fleet', new Request()))->toBe($expected);
176
435
  });
436
+
437
+ test('api fleet controller answers membership changes with a stable public id shape', function () {
438
+ session(['company' => 'company-uuid']);
439
+
440
+ $vehicle = new FleetOpsApiFleetVehicleFake();
441
+ $vehicle->setRawAttributes(['uuid' => 'vehicle-uuid', 'public_id' => 'vehicle_123']);
442
+
443
+ $driver = new FleetOpsApiFleetDriverFake();
444
+ $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_123']);
445
+
446
+ $controller = new FleetOpsApiFleetControllerProbe();
447
+ $controller->fleet = fleetopsFleetFake(['uuid' => 'fleet-uuid', 'public_id' => 'fleet_123']);
448
+ $controller->vehicle = $vehicle;
449
+ $controller->driver = $driver;
450
+
451
+ // All four operations answer in the same shape, and never with a uuid.
452
+ expect($controller->assignVehicle('fleet_123', 'vehicle_123'))->toBe([
453
+ 'json' => ['fleet' => 'fleet_123', 'vehicle' => 'vehicle_123', 'assigned' => true],
454
+ 'status' => 200,
455
+ ])->and($controller->removeVehicle('fleet_123', 'vehicle_123'))->toBe([
456
+ 'json' => ['fleet' => 'fleet_123', 'vehicle' => 'vehicle_123', 'assigned' => false],
457
+ 'status' => 200,
458
+ ])->and($controller->assignDriver('fleet_123', 'driver_123'))->toBe([
459
+ 'json' => ['fleet' => 'fleet_123', 'driver' => 'driver_123', 'assigned' => true],
460
+ 'status' => 200,
461
+ ])->and($controller->removeDriver('fleet_123', 'driver_123'))->toBe([
462
+ 'json' => ['fleet' => 'fleet_123', 'driver' => 'driver_123', 'assigned' => false],
463
+ 'status' => 200,
464
+ ])->and($controller->membershipCalls)->toBe([
465
+ ['assign-vehicle', 'fleet_123', 'vehicle_123'],
466
+ ['remove-vehicle', 'fleet_123', 'vehicle_123'],
467
+ ['assign-driver', 'fleet_123', 'driver_123'],
468
+ ['remove-driver', 'fleet_123', 'driver_123'],
469
+ ]);
470
+ });
471
+
472
+ test('api fleet controller treats an unavailable fleet or resource as not found for membership', function () {
473
+ session(['company' => 'company-uuid']);
474
+
475
+ $missingFleet = new FleetOpsApiFleetControllerProbe();
476
+ $missingFleet->fleetNotFound = true;
477
+
478
+ $missingResource = new FleetOpsApiFleetControllerProbe();
479
+ $missingResource->fleet = fleetopsFleetFake(['uuid' => 'fleet-uuid', 'public_id' => 'fleet_123']);
480
+ $missingResource->resourceNotFound = true;
481
+
482
+ // A resource in another company is unavailable, not forbidden — the answer
483
+ // is the same one a caller gets for an id that does not exist at all.
484
+ expect($missingFleet->assignVehicle('fleet_other', 'vehicle_123'))->toBe([
485
+ 'json' => ['error' => 'Fleet or vehicle resource not found.'],
486
+ 'status' => 404,
487
+ ])->and($missingFleet->removeDriver('fleet_other', 'driver_123'))->toBe([
488
+ 'json' => ['error' => 'Fleet or driver resource not found.'],
489
+ 'status' => 404,
490
+ ])->and($missingResource->assignVehicle('fleet_123', 'vehicle_other'))->toBe([
491
+ 'json' => ['error' => 'Fleet or vehicle resource not found.'],
492
+ 'status' => 404,
493
+ ])->and($missingResource->assignDriver('fleet_123', 'driver_other'))->toBe([
494
+ 'json' => ['error' => 'Fleet or driver resource not found.'],
495
+ 'status' => 404,
496
+ ])
497
+ // Removal answers the same way assignment does when either side is
498
+ // unavailable, so all four operations stay consistent.
499
+ ->and($missingResource->removeVehicle('fleet_123', 'vehicle_other'))->toBe([
500
+ 'json' => ['error' => 'Fleet or vehicle resource not found.'],
501
+ 'status' => 404,
502
+ ])
503
+ ->and($missingResource->removeDriver('fleet_123', 'driver_other'))->toBe([
504
+ 'json' => ['error' => 'Fleet or driver resource not found.'],
505
+ 'status' => 404,
506
+ ])
507
+ ->and($missingFleet->membershipCalls)->toBe([])
508
+ ->and($missingResource->membershipCalls)->toBe([]);
509
+ });
@@ -17,7 +17,7 @@ class FleetOpsApiFuelReportControllerProbe extends FuelReportController
17
17
  public bool $driverNotFound = false;
18
18
  public bool $fuelReportNotFound = false;
19
19
 
20
- protected function findDriverRecord(string $id): Driver
20
+ protected function findDriverRecord(?string $id): Driver
21
21
  {
22
22
  if ($this->driverNotFound) {
23
23
  throw new ModelNotFoundException();
@@ -17,7 +17,7 @@ class FleetOpsApiIssueControllerProbe extends IssueController
17
17
  public bool $driverNotFound = false;
18
18
  public bool $issueNotFound = false;
19
19
 
20
- protected function findDriverRecord(string $id): Driver
20
+ protected function findDriverRecord(?string $id): Driver
21
21
  {
22
22
  if ($this->driverNotFound) {
23
23
  throw new ModelNotFoundException();
@@ -80,7 +80,7 @@ class FleetOpsApiOrderCrudControllerProbe extends OrderController
80
80
  return $this->payload = new FleetOpsApiOrderPayloadFake();
81
81
  }
82
82
 
83
- protected function findDriverByPublicId(string $publicId): ?Fleetbase\FleetOps\Models\Driver
83
+ protected function findDriverByPublicId(?string $publicId): ?Fleetbase\FleetOps\Models\Driver
84
84
  {
85
85
  $this->driver ??= new FleetOpsApiOrderDriverFake();
86
86
  $this->driver->setRawAttributes([
@@ -26,7 +26,7 @@ class FleetOpsApiPartControllerProbe extends PartController
26
26
  return $part;
27
27
  }
28
28
 
29
- protected function resolveModel(string $modelClass, string $id): Illuminate\Database\Eloquent\Model
29
+ protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): Illuminate\Database\Eloquent\Model
30
30
  {
31
31
  if ($this->partNotFound) {
32
32
  throw new ModelNotFoundException();
@@ -37,7 +37,7 @@ class FleetOpsApiPartControllerProbe extends PartController
37
37
  return $this->part;
38
38
  }
39
39
 
40
- protected function resolveUuid(string $modelClass, ?string $id): ?string
40
+ protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
41
41
  {
42
42
  $this->resolvedUuids[] = [$modelClass, $id];
43
43
 
@@ -23,7 +23,7 @@ class FleetOpsApiServiceAreaControllerProbe extends ApiServiceAreaController
23
23
  public bool $notFound = false;
24
24
  public bool $createThrows = false;
25
25
 
26
- protected function serviceAreaUuid(string $publicId, array $where): ?string
26
+ protected function serviceAreaUuid(?string $publicId, array $where): ?string
27
27
  {
28
28
  $this->uuidLookups[] = [$publicId, $where];
29
29
 
@@ -116,7 +116,7 @@ class FleetOpsApiZoneControllerProbe extends ApiZoneController
116
116
  public mixed $queryResults = null;
117
117
  public bool $notFound = false;
118
118
 
119
- protected function serviceAreaUuid(string $publicId, array $where): ?string
119
+ protected function serviceAreaUuid(?string $publicId, array $where): ?string
120
120
  {
121
121
  $this->uuidLookups[] = [$publicId, $where];
122
122
 
@@ -32,7 +32,7 @@ class FleetOpsApiTrackingStatusControllerProbe extends TrackingStatusController
32
32
  return 'tracking-number-uuid';
33
33
  }
34
34
 
35
- protected function getOrderTrackingNumberUuid(string $orderId): ?string
35
+ protected function getOrderTrackingNumberUuid(?string $orderId): ?string
36
36
  {
37
37
  $this->orderLookups[] = $orderId;
38
38