@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
@@ -5,6 +5,9 @@ use Fleetbase\FleetOps\Http\Requests\CreateVehicleRequest;
5
5
  use Fleetbase\FleetOps\Http\Requests\UpdateVehicleRequest;
6
6
  use Fleetbase\FleetOps\Models\Driver;
7
7
  use Fleetbase\FleetOps\Models\Vehicle;
8
+ use Fleetbase\FleetOps\Models\Vendor;
9
+ use Fleetbase\FleetOps\Models\Warranty;
10
+ use Fleetbase\Models\Category;
8
11
  use Illuminate\Database\Eloquent\ModelNotFoundException;
9
12
  use Illuminate\Http\Request;
10
13
 
@@ -13,7 +16,8 @@ class FleetOpsApiVehicleCrudControllerProbe extends VehicleController
13
16
  public ?FleetOpsApiVehicleCrudFake $vehicle = null;
14
17
  public ?FleetOpsApiDriverCrudFake $driver = null;
15
18
  public array $createdVehicles = [];
16
- public array $vendorLookups = [];
19
+ public array $relationLookups = [];
20
+ public array $unresolvable = [];
17
21
  public mixed $queryResults = null;
18
22
  public bool $vehicleNotFound = false;
19
23
  public bool $driverNotFound = false;
@@ -23,11 +27,26 @@ class FleetOpsApiVehicleCrudControllerProbe extends VehicleController
23
27
  return $this->vehicleInputFromRequest($request);
24
28
  }
25
29
 
26
- protected function getVendorUuid(string $table, array $where): ?string
30
+ /**
31
+ * Stand in for the company-scoped public-id lookup.
32
+ *
33
+ * Anything listed in `$unresolvable` behaves as a cross-company or missing
34
+ * identifier does in production: the lookup finds nothing inside the
35
+ * caller's company and raises.
36
+ */
37
+ protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
27
38
  {
28
- $this->vendorLookups[] = [$table, $where];
39
+ if (empty($id)) {
40
+ return null;
41
+ }
42
+
43
+ $this->relationLookups[] = [$modelClass, $id];
44
+
45
+ if (in_array($id, $this->unresolvable, true)) {
46
+ throw (new ModelNotFoundException())->setModel($modelClass, $id);
47
+ }
29
48
 
30
- return 'vendor-uuid';
49
+ return strtolower(class_basename($modelClass)) . '-uuid';
31
50
  }
32
51
 
33
52
  protected function createVehicle(array $input): Vehicle
@@ -51,7 +70,7 @@ class FleetOpsApiVehicleCrudControllerProbe extends VehicleController
51
70
  return $this->vehicle;
52
71
  }
53
72
 
54
- protected function findDriver(string $id): Driver
73
+ protected function findDriver(?string $id): Driver
55
74
  {
56
75
  if ($this->driverNotFound) {
57
76
  throw new ModelNotFoundException();
@@ -240,8 +259,8 @@ test('api vehicle controller creates vehicles with defaults relation lookups and
240
259
  $vehicle = $response['vehicle'];
241
260
 
242
261
  expect($response['resource'])->toBe('vehicle')
243
- ->and($controller->vendorLookups)->toBe([
244
- ['vendors', ['public_id' => 'vendor-public', 'company_uuid' => 'company-uuid']],
262
+ ->and($controller->relationLookups)->toBe([
263
+ [Vendor::class, 'vendor-public'],
245
264
  ])
246
265
  ->and($controller->createdVehicles[0])->toMatchArray([
247
266
  'status' => 'active',
@@ -292,9 +311,11 @@ test('api vehicle controller updates queries finds deletes and tracks empty coor
292
311
  'make' => 'Nissan',
293
312
  'model' => 'NV350',
294
313
  'vin' => 'NEWVIN',
295
- 'online' => 0,
296
314
  'vendor_uuid' => 'vendor-uuid',
297
315
  ])
316
+ // A partial update must not carry an unrequested `online` default: the
317
+ // absent key means "leave it alone", not "take the vehicle offline".
318
+ ->and($filledInput)->not->toHaveKey('online')
298
319
  ->and($filledInput)->toHaveKey('location')
299
320
  ->and($vehicle->unassignedDriver)->toBeTrue()
300
321
  ->and($vehicle->savedForTest)->toBeTrue()
@@ -401,3 +422,149 @@ test('api vehicle controller input still drops fields that are not part of the c
401
422
  ->and($input)->not->toHaveKey('company_uuid')
402
423
  ->and($input)->not->toHaveKey('uuid');
403
424
  });
425
+
426
+ test('api vehicle controller accepts every safe vehicle field the model exposes', function () {
427
+ // Data-driven rather than one test per column: the point is parity between
428
+ // what the model can store and what the public contract will accept, so the
429
+ // assertion is over the whole set.
430
+ session(['company' => 'company-uuid']);
431
+
432
+ $payload = [
433
+ // Identity and description
434
+ 'internal_id' => 'VEH-9001', 'name' => 'Depot Van', 'description' => 'City route van',
435
+ 'make' => 'Ford', 'model' => 'Transit', 'model_type' => 'Custom', 'year' => 2024,
436
+ 'trim' => 'Trend', 'color' => 'White', 'type' => 'van', 'class' => 'N1',
437
+ 'plate_number' => 'SG-9001', 'vin' => '1FTBW3XG8NKA00001', 'serial_number' => 'SER-9001',
438
+ 'call_sign' => 'DEPOT-1', 'fuel_card_number' => 'FC-9001',
439
+ // Measurement and operation
440
+ 'odometer' => 41000, 'odometer_unit' => 'km', 'odometer_at_purchase' => 12,
441
+ 'measurement_system' => 'metric', 'fuel_type' => 'diesel', 'fuel_volume_unit' => 'l',
442
+ 'online' => true, 'status' => 'available',
443
+ // Body, capacity and dimensions
444
+ 'transmission' => 'automatic', 'body_type' => 'panel_van', 'body_sub_type' => 'lwb',
445
+ 'usage_type' => 'commercial', 'ownership_type' => 'owned', 'cargo_volume' => 11.5,
446
+ 'passenger_volume' => 3.2, 'interior_volume' => 14.7, 'weight' => 2100.5, 'width' => 2.06,
447
+ 'length' => 5.98, 'height' => 2.54, 'towing_capacity' => 2500, 'payload_capacity' => 1400,
448
+ 'seating_capacity' => 3, 'ground_clearance' => 0.18, 'bed_length' => 3.4, 'fuel_capacity' => 70,
449
+ // Lifecycle and financing
450
+ 'financing_status' => 'financed', 'loan_number_of_payments' => 48,
451
+ 'loan_first_payment' => '2026-01-15', 'loan_amount' => 32000, 'currency' => 'SGD',
452
+ 'estimated_service_life_distance_unit' => 'km', 'estimated_service_life_distance' => 400000,
453
+ 'estimated_service_life_months' => 96, 'insurance_value' => 41000, 'depreciation_rate' => 12.5,
454
+ 'current_value' => 38000, 'acquisition_cost' => 52000,
455
+ 'purchased_at' => '2026-01-02', 'lease_expires_at' => '2029-01-02',
456
+ // Regulatory and engine specifications
457
+ 'emission_standard' => 'euro6', 'dpf_equipped' => true, 'scr_equipped' => false,
458
+ 'gvwr' => 3500, 'gcwr' => 6000, 'engine_number' => 'ENG-9001', 'engine_model' => 'EcoBlue',
459
+ 'engine_make' => 'Ford', 'engine_family' => 'Puma', 'engine_configuration' => 'inline',
460
+ 'engine_displacement' => 2.0, 'engine_size' => 1995, 'horsepower' => 168,
461
+ 'horsepower_rpm' => 3500, 'torque' => 405, 'torque_rpm' => 1750,
462
+ 'number_of_cylinders' => 4, 'cylinder_arrangement' => 'I4',
463
+ // Structured and descriptive
464
+ 'specs' => ['doors' => 4], 'details' => ['liftgate' => true], 'notes' => 'City pool',
465
+ 'meta' => ['depot' => 'north'],
466
+ // Orchestrator
467
+ 'skills' => ['tail_lift'], 'payload_capacity_volume' => 11.25,
468
+ 'payload_capacity_pallets' => 6, 'payload_capacity_parcels' => 320, 'max_tasks' => 40,
469
+ 'time_window_start' => '08:00', 'time_window_end' => '18:00', 'return_to_depot' => true,
470
+ ];
471
+
472
+ $controller = new FleetOpsApiVehicleCrudControllerProbe();
473
+ $input = $controller->inputForTest(new Request($payload));
474
+
475
+ $missing = array_values(array_diff(array_keys($payload), array_keys($input)));
476
+
477
+ expect($missing)->toBe([])
478
+ ->and($input)->toMatchArray($payload);
479
+ });
480
+
481
+ test('api vehicle controller resolves every public relationship input to a scoped uuid', function () {
482
+ session(['company' => 'company-uuid']);
483
+
484
+ $controller = new FleetOpsApiVehicleCrudControllerProbe();
485
+ $input = $controller->inputForTest(new Request([
486
+ 'vendor' => 'vendor_abc',
487
+ 'category' => 'category_abc',
488
+ 'warranty' => 'warranty_abc',
489
+ 'photo' => 'file_abc',
490
+ ]));
491
+
492
+ expect($input)->toMatchArray([
493
+ 'vendor_uuid' => 'vendor-uuid',
494
+ 'category_uuid' => 'category-uuid',
495
+ 'warranty_uuid' => 'warranty-uuid',
496
+ 'photo_uuid' => 'file-uuid',
497
+ ])
498
+ ->and($controller->relationLookups)->toBe([
499
+ [Vendor::class, 'vendor_abc'],
500
+ [Category::class, 'category_abc'],
501
+ [Warranty::class, 'warranty_abc'],
502
+ [Fleetbase\Models\File::class, 'file_abc'],
503
+ ]);
504
+ });
505
+
506
+ test('api vehicle controller clears a relationship when the input is sent empty', function () {
507
+ session(['company' => 'company-uuid']);
508
+
509
+ $controller = new FleetOpsApiVehicleCrudControllerProbe();
510
+ $input = $controller->inputForTest(fleetopsUpdateVehicleRequest(['vendor' => null]));
511
+
512
+ expect($input)->toHaveKey('vendor_uuid')
513
+ ->and($input['vendor_uuid'])->toBeNull()
514
+ ->and($controller->relationLookups)->toBe([]);
515
+ });
516
+
517
+ test('api vehicle controller rejects a relationship that belongs to another company', function () {
518
+ session(['company' => 'company-uuid']);
519
+
520
+ $controller = new FleetOpsApiVehicleCrudControllerProbe();
521
+ $controller->unresolvable = ['vendor_other_company'];
522
+
523
+ $created = $controller->create(fleetopsCreateVehicleRequest([
524
+ 'make' => 'Ford',
525
+ 'vendor' => 'vendor_other_company',
526
+ ]));
527
+
528
+ $controller = new FleetOpsApiVehicleCrudControllerProbe();
529
+ $controller->vehicle = new FleetOpsApiVehicleCrudFake();
530
+ $controller->unresolvable = ['vendor_other_company'];
531
+
532
+ $updated = $controller->update('vehicle-public', fleetopsUpdateVehicleRequest([
533
+ 'vendor' => 'vendor_other_company',
534
+ ]));
535
+
536
+ // A cross-company identifier is answered exactly as a missing one, so the
537
+ // response cannot be used to discover what another organization holds.
538
+ expect($created)->toBe([
539
+ 'json' => ['error' => 'No vendor resource found for the identifier provided.'],
540
+ 'status' => 404,
541
+ ])->and($updated)->toBe([
542
+ 'json' => ['error' => 'No vendor resource found for the identifier provided.'],
543
+ 'status' => 404,
544
+ ]);
545
+ });
546
+
547
+ test('api vehicle controller input still excludes server managed and internal columns', function () {
548
+ $controller = new FleetOpsApiVehicleCrudControllerProbe();
549
+
550
+ $input = $controller->inputForTest(new Request([
551
+ 'make' => 'Ford',
552
+ 'company_uuid' => 'someone-elses-company',
553
+ 'uuid' => 'forged',
554
+ '_key' => 'forged',
555
+ 'public_id' => 'vehicle_forged',
556
+ 'slug' => 'forged',
557
+ 'vendor_uuid' => 'forged',
558
+ 'category_uuid' => 'forged',
559
+ 'warranty_uuid' => 'forged',
560
+ 'photo_uuid' => 'forged',
561
+ 'telematic_uuid' => 'forged',
562
+ // Written by the VIN decoder and by telematics ingestion respectively;
563
+ // a caller must not be able to overwrite either.
564
+ 'vin_data' => ['manufacturer' => 'forged'],
565
+ 'telematics' => ['speed' => 999],
566
+ 'avatar_url' => 'forged',
567
+ ]));
568
+
569
+ expect(array_keys($input))->toBe(['make']);
570
+ });
@@ -272,6 +272,91 @@ function fleetopsProtectedMethod(string $class, string $method): ReflectionMetho
272
272
  return $reflection;
273
273
  }
274
274
 
275
+ /**
276
+ * Boot an in-memory database holding one row per relationship a public filter
277
+ * can be asked about.
278
+ *
279
+ * Relationship filters no longer compare a caller-supplied value against a uuid
280
+ * column: the public API deals in public ids, so the filter resolves the id to
281
+ * the uuids it stands for first. That resolution is a real query, so these
282
+ * assertions need a real connection.
283
+ *
284
+ * @return array<string, string> uuids keyed by table
285
+ */
286
+ function fleetopsFilterRelationDatabase(): array
287
+ {
288
+ $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
289
+ $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
290
+ $resolver->setDefaultConnection('mysql');
291
+ Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
292
+
293
+ app()->instance('db', new class($connection) {
294
+ public function __construct(public Illuminate\Database\SQLiteConnection $c)
295
+ {
296
+ }
297
+
298
+ public function connection($name = null): Illuminate\Database\SQLiteConnection
299
+ {
300
+ return $this->c;
301
+ }
302
+
303
+ public function __call($method, $arguments)
304
+ {
305
+ return $this->c->{$method}(...$arguments);
306
+ }
307
+ });
308
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
309
+
310
+ $schema = $connection->getSchemaBuilder();
311
+ foreach (['fleets', 'vendors', 'zones', 'service_areas', 'drivers', 'vehicles', 'users'] as $table) {
312
+ if ($schema->hasTable($table)) {
313
+ $schema->drop($table);
314
+ }
315
+
316
+ $schema->create($table, function ($blueprint) {
317
+ $blueprint->increments('id');
318
+ foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'name', '_key'] as $column) {
319
+ $blueprint->string($column)->nullable();
320
+ }
321
+ $blueprint->timestamps();
322
+ $blueprint->timestamp('deleted_at')->nullable();
323
+ });
324
+ }
325
+
326
+ $uuids = [
327
+ 'fleets' => '99999999-9999-4999-8999-999999999001',
328
+ 'vendors' => '99999999-9999-4999-8999-999999999002',
329
+ 'zones' => '99999999-9999-4999-8999-999999999003',
330
+ 'service_areas' => '99999999-9999-4999-8999-999999999004',
331
+ 'drivers' => '99999999-9999-4999-8999-999999999005',
332
+ 'vehicles' => '99999999-9999-4999-8999-999999999006',
333
+ 'users' => '99999999-9999-4999-8999-999999999007',
334
+ ];
335
+
336
+ $publicIds = [
337
+ 'fleets' => 'fleet_filterone12',
338
+ 'vendors' => 'vendor_filterone1',
339
+ 'zones' => 'zone_filterone123',
340
+ 'service_areas' => 'service_area_fone',
341
+ 'drivers' => 'driver_filterone1',
342
+ 'vehicles' => 'vehicle_filterone',
343
+ 'users' => 'user_filterone123',
344
+ ];
345
+
346
+ foreach ($uuids as $table => $uuid) {
347
+ $connection->table($table)->insert([
348
+ 'uuid' => $uuid,
349
+ 'public_id' => $publicIds[$table],
350
+ 'company_uuid' => 'company-uuid',
351
+ ]);
352
+ }
353
+
354
+ // The driver scope requires a linked user row to exist.
355
+ $connection->table('drivers')->where('uuid', $uuids['drivers'])->update(['user_uuid' => $uuids['users']]);
356
+
357
+ return ['uuids' => $uuids, 'publicIds' => $publicIds];
358
+ }
359
+
275
360
  function fleetopsFilterWithBuilder(string $class, FleetOpsControllerFilterQuery $builder): object
276
361
  {
277
362
  $filter = (new ReflectionClass($class))->newInstanceWithoutConstructor();
@@ -456,6 +541,8 @@ test('device event controller skips processed scope when states are empty', func
456
541
  });
457
542
 
458
543
  test('vehicle filter records identity relationship fleet and telematic filters', function () {
544
+ ['uuids' => $uuids, 'publicIds' => $publicIds] = fleetopsFilterRelationDatabase();
545
+
459
546
  $query = new FleetOpsControllerFilterQuery();
460
547
  $filter = fleetopsFilterWithBuilder(VehicleFilter::class, $query);
461
548
 
@@ -465,14 +552,17 @@ test('vehicle filter records identity relationship fleet and telematic filters',
465
552
  $filter->display_name('sprinter');
466
553
  $filter->vin('vin-123');
467
554
  $filter->publicId('vehicle-public');
555
+ $filter->internalId('VEH-42');
468
556
  $filter->plateNumber('ABC-123');
469
557
  $filter->vehicleMake('Mercedes');
470
558
  $filter->vehicleModel('Sprinter');
471
559
  $filter->vehicleYear('2026');
472
560
  $filter->driver('unassigned');
561
+ $filter->driver($publicIds['drivers']);
473
562
  $filter->vendor(null);
563
+ $filter->vendor($publicIds['vendors']);
474
564
  $filter->driverUuid('driver-uuid');
475
- $filter->fleet('fleet-uuid');
565
+ $filter->fleet($publicIds['fleets']);
476
566
  $filter->assignedFleet('false');
477
567
  $filter->telematicUuid('telematic-uuid');
478
568
  $filter->createdAt(['2026-01-01', '2026-01-31']);
@@ -482,20 +572,32 @@ test('vehicle filter records identity relationship fleet and telematic filters',
482
572
  ->and($query->calls)->toContain(['search', 'van'])
483
573
  ->and($query->calls)->toContain(['searchWhere', ['year', 'make', 'model', 'plate_number'], 'sprinter'])
484
574
  ->and($query->calls)->toContain(['searchWhere', 'vin', 'vin-123'])
485
- ->and($query->calls)->toContain(['searchWhere', 'public_id', 'vehicle-public'])
575
+ // A public id names exactly one record, so a public lookup is exact.
576
+ ->and($query->calls)->toContain(['where', ['public_id', '=', 'vehicle-public']])
577
+ // Exact, not partial: an importer asking whether VEH-42 exists must not
578
+ // be told yes because VEH-420 does.
579
+ ->and($query->calls)->toContain(['where', ['internal_id', '=', 'VEH-42']])
486
580
  ->and($query->calls)->toContain(['searchWhere', 'plate_number', 'ABC-123'])
487
581
  ->and($query->calls)->toContain(['searchWhere', 'make', 'Mercedes'])
488
582
  ->and($query->calls)->toContain(['searchWhere', 'model', 'Sprinter'])
489
583
  ->and($query->calls)->toContain(['searchWhere', 'year', '2026'])
490
584
  ->and($query->calls)->toContain(['whereDoesntHave', 'driver'])
491
- ->and($query->calls)->toContain(['whereDoesntHave', 'fleets']);
585
+ ->and($query->calls)->toContain(['whereDoesntHave', 'fleets'])
586
+ // Public ids are resolved to the uuids the columns actually hold.
587
+ ->and($query->calls)->toContain(['whereIn', 'vendor_uuid', [$uuids['vendors']]]);
588
+
589
+ $nested = collect($query->calls)->where(0, 'whereHas')->flatMap(fn ($call) => $call[2])->values()->all();
492
590
 
493
591
  expect(collect($query->calls)->where(0, 'whereHas')->pluck(1)->all())->toContain('driver', 'fleets', 'devices')
592
+ ->and($nested)->toContain(['whereIn', 'uuid', [$uuids['drivers']]])
593
+ ->and($nested)->toContain(['whereIn', 'fleet_uuid', [$uuids['fleets']]])
494
594
  ->and(collect($query->calls)->where(0, 'whereBetween')->values())->toHaveCount(1)
495
595
  ->and(collect($query->calls)->where(0, 'whereDate')->values())->toHaveCount(1);
496
596
  });
497
597
 
498
598
  test('fleet filter records hierarchy relationship scalar status and date filters', function () {
599
+ ['uuids' => $uuids, 'publicIds' => $publicIds] = fleetopsFilterRelationDatabase();
600
+
499
601
  $query = new FleetOpsControllerFilterQuery();
500
602
  $filter = fleetopsFilterWithBuilder(FleetFilter::class, $query);
501
603
 
@@ -504,10 +606,12 @@ test('fleet filter records hierarchy relationship scalar status and date filters
504
606
  $filter->query('dispatch');
505
607
  $filter->parentsOnly(true);
506
608
  $filter->parentsOnly(false);
507
- $filter->serviceArea('service-area-uuid');
508
- $filter->zone('zone-uuid');
509
- $filter->parentFleet('parent-fleet-uuid');
510
- $filter->vendor('vendor-uuid');
609
+ $filter->serviceArea($publicIds['service_areas']);
610
+ $filter->zone($publicIds['zones']);
611
+ $filter->parentFleet($publicIds['fleets']);
612
+ $filter->vendor($publicIds['vendors']);
613
+ // A blank identifier resolves to nothing without reaching the database.
614
+ $filter->vendor('');
511
615
  $filter->publicId('fleet-public');
512
616
  $filter->task('delivery');
513
617
  $filter->name('North Fleet');
@@ -515,16 +619,27 @@ test('fleet filter records hierarchy relationship scalar status and date filters
515
619
  $filter->createdAt('2026-01-01');
516
620
  $filter->updatedAt(['2026-02-01', '2026-02-28']);
517
621
 
622
+ $nested = collect($query->calls)->where(0, 'where')->pluck(1)->all();
623
+
518
624
  expect($query->calls)->toContain(['where', ['company_uuid', 'company-uuid']])
519
625
  ->and($query->calls)->toContain(['with', ['serviceArea', 'zone']])
520
626
  ->and($query->calls)->toContain(['whereNull', 'parent_fleet_uuid'])
521
- ->and($query->calls)->toContain(['searchWhere', 'parent_fleet_uuid', 'parent-fleet-uuid'])
522
- ->and($query->calls)->toContain(['searchWhere', 'public_id', 'fleet-public'])
627
+ ->and($query->calls)->toContain(['where', ['public_id', '=', 'fleet-public']])
523
628
  ->and($query->calls)->toContain(['searchWhere', 'task', 'delivery'])
524
629
  ->and($query->calls)->toContain(['searchWhere', 'name', 'North Fleet'])
525
- ->and($query->calls)->toContain(['whereIn', 'status', ['active', 'inactive']]);
526
-
527
- expect(collect($query->calls)->where(0, 'whereHas')->pluck(1)->all())->toContain('serviceArea', 'zone', 'parent_fleet', 'vendor')
630
+ ->and($query->calls)->toContain(['whereIn', 'status', ['active', 'inactive']])
631
+ // Every hierarchy and relationship filter now resolves a public id to
632
+ // the uuid column it stands for, rather than comparing the public id
633
+ // against a uuid — which never matched — or against a `zone_uuid`
634
+ // column that does not exist on `zones`.
635
+ ->and($query->calls)->toContain(['whereIn', 'service_area_uuid', [$uuids['service_areas']]])
636
+ ->and($query->calls)->toContain(['whereIn', 'zone_uuid', [$uuids['zones']]])
637
+ ->and($query->calls)->toContain(['whereIn', 'parent_fleet_uuid', [$uuids['fleets']]])
638
+ ->and($query->calls)->toContain(['whereIn', 'vendor_uuid', [$uuids['vendors']]])
639
+ ->and($query->calls)->toContain(['whereIn', 'vendor_uuid', []])
640
+ // `?query=` searches the fleet's own columns; it used to reach for a
641
+ // `user` relation Fleet does not have.
642
+ ->and(collect($query->calls)->where(0, 'whereHas')->pluck(1)->all())->not->toContain('user')
528
643
  ->and(collect($query->calls)->where(0, 'whereDate')->values())->toHaveCount(1)
529
644
  ->and(collect($query->calls)->where(0, 'whereBetween')->values())->toHaveCount(1);
530
645
  });
@@ -1224,9 +1339,13 @@ test('place filter alternate date branches and spatial area scopes execute', fun
1224
1339
  test('vehicle and fuel report filters cover alternate identity and date branches', function () {
1225
1340
  // Vehicle filter: unassigned drivers, blank vendor early return, inverse date
1226
1341
  // branches, fleet unassignment and blank telematic early return
1342
+ fleetopsFilterRelationDatabase();
1343
+
1227
1344
  $query = new FleetOpsControllerFilterQuery();
1228
1345
  $filter = fleetopsFilterWithBuilder(VehicleFilter::class, $query);
1229
1346
  $filter->driver('unassigned');
1347
+ // A public request may not pass a uuid, so nothing resolves and the filter
1348
+ // matches no vehicle rather than silently matching all of them.
1230
1349
  $filter->driver('c4c4c4c4-4444-4444-8444-444444444444');
1231
1350
  $filter->vendor(null);
1232
1351
  $filter->createdAt('2026-02-01');
@@ -1240,7 +1359,7 @@ test('vehicle and fuel report filters cover alternate identity and date branches
1240
1359
  ->and($query->calls)->toContain(['whereDoesntHave', 'fleets'])
1241
1360
  ->and(collect($query->calls)->where(0, 'whereDate')->values())->toHaveCount(1)
1242
1361
  ->and(collect($query->calls)->where(0, 'whereBetween')->values())->toHaveCount(1)
1243
- ->and($nestedVehicle)->toContain(['where', ['uuid', 'c4c4c4c4-4444-4444-8444-444444444444']]);
1362
+ ->and($nestedVehicle)->toContain(['whereIn', 'uuid', []]);
1244
1363
 
1245
1364
  // Fuel report filter: uuid, public-id and free-text identity branches
1246
1365
  // plus the inverse date branches
@@ -1093,7 +1093,9 @@ test('api vehicle controller exposes input defaults and tracking payload helpers
1093
1093
  'time_window_start' => '08:00',
1094
1094
  'time_window_end' => '17:00',
1095
1095
  'return_to_depot' => true,
1096
- 'vendor' => 'vendor-public',
1096
+ // `vendor` is deliberately absent: it is no longer a scalar in this
1097
+ // projection but a public id resolved against the caller's company,
1098
+ // which is asserted in ApiVehicleControllerContractsTest.
1097
1099
  'driver' => 'driver-public',
1098
1100
  ]);
1099
1101
  $locationInput = $controller->callHelper('withCoordinateLocation', [], new Request([
@@ -1102,8 +1104,8 @@ test('api vehicle controller exposes input defaults and tracking payload helpers
1102
1104
  ]));
1103
1105
  $tracking = $controller->callHelper('positionDataFromTrackingInput', 1.2816, 103.851, 12, 180, 55);
1104
1106
 
1107
+ // Key order follows the allowlist, not the request body.
1105
1108
  expect($controller->callHelper('vehicleInputFromRequest', $request))->toBe([
1106
- 'status' => 'active',
1107
1109
  'make' => 'Toyota',
1108
1110
  'model' => 'HiAce',
1109
1111
  'year' => 2025,
@@ -1111,13 +1113,14 @@ test('api vehicle controller exposes input defaults and tracking payload helpers
1111
1113
  'type' => 'van',
1112
1114
  'plate_number' => 'SG-1234',
1113
1115
  'vin' => 'VIN123',
1114
- 'meta' => ['temperature' => 'ambient'],
1115
1116
  'online' => false,
1117
+ 'status' => 'active',
1116
1118
  'location' => ['latitude' => 1.30, 'longitude' => 103.80],
1117
1119
  'altitude' => 10,
1118
1120
  'heading' => 90,
1119
1121
  'speed' => 45,
1120
1122
  'payload_capacity' => 1200,
1123
+ 'meta' => ['temperature' => 'ambient'],
1121
1124
  'payload_capacity_volume' => 8,
1122
1125
  'payload_capacity_pallets' => 2,
1123
1126
  'payload_capacity_parcels' => 80,