@fleetbase/fleetops-engine 0.6.61 → 0.6.62

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 (64) hide show
  1. package/RELEASE.md +23 -8
  2. package/addon/services/map-settings.js +4 -1
  3. package/composer.json +1 -1
  4. package/extension.json +1 -1
  5. package/package.json +1 -1
  6. package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
  7. package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
  8. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
  9. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
  10. package/server/src/Http/Controllers/Api/v1/DriverController.php +93 -57
  11. package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
  12. package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
  13. package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
  14. package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
  15. package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
  16. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
  17. package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
  18. package/server/src/Http/Controllers/Api/v1/VehicleController.php +134 -35
  19. package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
  20. package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
  21. package/server/src/Http/Filter/DriverFilter.php +71 -15
  22. package/server/src/Http/Filter/FleetFilter.php +32 -33
  23. package/server/src/Http/Filter/VehicleFilter.php +57 -19
  24. package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
  25. package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
  26. package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
  27. package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
  28. package/server/src/Http/Requests/CreateVehicleRequest.php +150 -31
  29. package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
  30. package/server/src/Http/Resources/v1/Driver.php +22 -2
  31. package/server/src/Http/Resources/v1/Fleet.php +38 -25
  32. package/server/src/Http/Resources/v1/Vehicle.php +46 -4
  33. package/server/src/Models/Driver.php +2 -2
  34. package/server/src/routes.php +8 -0
  35. package/server/tests/ApiDriverControllerContractsTest.php +284 -13
  36. package/server/tests/ApiEntityControllerContractsTest.php +1 -1
  37. package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
  38. package/server/tests/ApiFleetControllerContractsTest.php +354 -21
  39. package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
  40. package/server/tests/ApiIssueControllerContractsTest.php +1 -1
  41. package/server/tests/ApiOrderControllerContractsTest.php +1 -1
  42. package/server/tests/ApiPartControllerContractsTest.php +2 -2
  43. package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
  44. package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
  45. package/server/tests/ApiVehicleControllerContractsTest.php +175 -8
  46. package/server/tests/ControllerFilterContractsTest.php +132 -13
  47. package/server/tests/ControllerHelperContractsTest.php +6 -3
  48. package/server/tests/DriverFilterExecutionTest.php +130 -6
  49. package/server/tests/ExceptionContractsTest.php +15 -0
  50. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
  51. package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
  52. package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
  53. package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
  54. package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
  55. package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
  56. package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
  57. package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
  58. package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
  59. package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
  60. package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
  61. package/server/tests/FleetHierarchyResourceTest.php +56 -8
  62. package/server/tests/RequestContractsTest.php +100 -7
  63. package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
  64. package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
@@ -290,6 +290,8 @@ namespace {
290
290
  });
291
291
 
292
292
  test('vehicle and fuel report requests expose core validation contracts', function () {
293
+ session(['company' => 'company-uuid']);
294
+
293
295
  $vehicleRules = requestRules(CreateVehicleRequest::class);
294
296
  $fuelReportRules = requestRules(CreateFuelReportRequest::class);
295
297
  $fuelReportUpdateRules = requestRules(UpdateFuelReportRequest::class, 'PATCH');
@@ -305,6 +307,53 @@ namespace {
305
307
  // error. These rules are what stop that.
306
308
  ->and($vehicleRules['odometer'])->toBe('nullable|numeric|min:0')
307
309
  ->and($vehicleRules['odometer_unit'])->toBe('nullable|string|max:12')
310
+ // Postman documented three statuses while the API accepted nineteen.
311
+ // This enum is the source of truth both must agree with; dropping a
312
+ // value would silently break an integration already sending it.
313
+ ->and(CreateVehicleRequest::STATUSES)->toContain(
314
+ 'active',
315
+ 'available',
316
+ 'in_use',
317
+ 'maintenance',
318
+ 'out_of_service',
319
+ 'reserved',
320
+ 'retired',
321
+ 'staging',
322
+ 'on_route',
323
+ 'idle',
324
+ 'cleaning',
325
+ 'awaiting_parts',
326
+ 'inspection_due',
327
+ 'inspection_failed',
328
+ 'accident',
329
+ 'compliance_hold',
330
+ 'stolen',
331
+ 'operational',
332
+ 'decommissioned',
333
+ )
334
+ // Every newly exposed vehicle field carries a type-appropriate rule.
335
+ ->and($vehicleRules['year'])->toBe('nullable|integer|min:1900|max:2100')
336
+ ->and($vehicleRules['dpf_equipped'])->toBe('nullable|boolean')
337
+ ->and($vehicleRules['purchased_at'])->toBe('nullable|date')
338
+ ->and($vehicleRules['currency'])->toBe('nullable|string|size:3')
339
+ ->and($vehicleRules['specs'])->toBe('nullable|array')
340
+ ->and($vehicleRules['meta'])->toBe('nullable|array')
341
+ ->and($vehicleRules['seating_capacity'])->toBe('nullable|integer|min:0')
342
+ ->and($vehicleRules['weight'])->toBe('nullable|numeric|min:0')
343
+ ->and($vehicleRules['return_to_depot'])->toBe('nullable|boolean')
344
+ // Relationship inputs are confined to the caller's own company.
345
+ ->and(ruleStrings($vehicleRules['vendor']))->toContain('exists:vendors,public_id')
346
+ ->and(ruleStrings($vehicleRules['driver']))->toContain('exists:drivers,public_id')
347
+ ->and(ruleStrings($vehicleRules['category']))->toContain('exists:categories,public_id')
348
+ ->and(ruleStrings($vehicleRules['warranty']))->toContain('exists:warranties,public_id')
349
+ ->and($vehicleRules['vendor'][2]->constraints)->toBe([
350
+ ['where', 'company_uuid', 'company-uuid'],
351
+ ['whereNull', 'deleted_at'],
352
+ ])
353
+ ->and($vehicleRules['driver'][2]->constraints)->toBe([
354
+ ['where', 'company_uuid', 'company-uuid'],
355
+ ['whereNull', 'deleted_at'],
356
+ ])
308
357
  ->and($fuelReportRules['driver'])->toBe(['required'])
309
358
  ->and($fuelReportRules['odometer'])->toBe(['required'])
310
359
  ->and($fuelReportRules['volume'])->toBe(['required'])
@@ -434,7 +483,30 @@ namespace {
434
483
  ])
435
484
  ->and(ruleStrings($fleetCreateRules['name']))->toContain('required')
436
485
  ->and(ruleStrings($fleetPatchRules['name']))->not->toContain('required')
437
- ->and($fleetCreateRules['service_area'])->toBe('exists:service_areas,public_id')
486
+ ->and(ruleStrings($fleetCreateRules['service_area']))->toContain('nullable', 'exists:service_areas,public_id')
487
+ ->and(ruleStrings($fleetCreateRules['zone']))->toContain('exists:zones,public_id')
488
+ ->and(ruleStrings($fleetCreateRules['vendor']))->toContain('exists:vendors,public_id')
489
+ ->and(ruleStrings($fleetCreateRules['parent_fleet']))->toContain('exists:fleets,public_id')
490
+ ->and($fleetCreateRules['color'])->toBe('nullable|string|max:64')
491
+ ->and($fleetCreateRules['task'])->toBe('nullable|string|max:191')
492
+ ->and($fleetCreateRules['status'])->toBe('nullable|string|max:64')
493
+ // Every fleet relationship input is confined to the caller's own
494
+ // company, so another organization's public id cannot be assigned
495
+ // — and cannot be probed for existence either.
496
+ ->and($fleetCreateRules['service_area'][2]->constraints)->toBe([
497
+ ['where', 'company_uuid', 'company-uuid'],
498
+ ['whereNull', 'deleted_at'],
499
+ ])
500
+ ->and($fleetCreateRules['parent_fleet'][2]->constraints)->toBe([
501
+ ['where', 'company_uuid', 'company-uuid'],
502
+ ['whereNull', 'deleted_at'],
503
+ ])
504
+ // A validation message naming `parent_fleet` reads worse than one
505
+ // naming "parent fleet".
506
+ ->and(CreateFleetRequest::create('/fleetops-test', 'POST')->attributes())->toBe([
507
+ 'service_area' => 'service area',
508
+ 'parent_fleet' => 'parent fleet',
509
+ ])
438
510
  ->and(requestRules(CancelOrderRequest::class))->toBe(['order' => 'required|exists:orders,uuid'])
439
511
  ->and(requestRules(DecodeTrackingNumberQR::class))->toBe(['code' => 'required|string'])
440
512
  ->and(requestRules(CreateServiceQuoteRequest::class))->toBe([]);
@@ -838,6 +910,9 @@ namespace {
838
910
  });
839
911
 
840
912
  test('driver request authorizes api sanctum and navigator sessions with identity rules', function () {
913
+ // The harness `session()` is a process-wide static independent of the
914
+ // bound store, and the company-scoped rule closures read it.
915
+ session(['company' => 'company-uuid']);
841
916
  bindFleetOpsRequestSession();
842
917
 
843
918
  $request = CreateDriverRequest::create('/fleetops-test', 'POST', [
@@ -854,15 +929,27 @@ namespace {
854
929
  expect($request->authorize())->toBeTrue()
855
930
  ->and(ruleStrings($createRules['name']))->toContain('required')
856
931
  ->and(ruleStrings($patchRules['name']))->not->toContain('required')
857
- ->and(ruleStrings($createRules['email']))->toContain('required', 'email', 'unique:users')
858
- ->and(ruleStrings($createRules['phone']))->toContain('required', 'unique:users')
932
+ // Contact details are optional but still validated and still unique:
933
+ // an operational driver record may legitimately have neither.
934
+ ->and(ruleStrings($createRules['email']))->toContain('nullable', 'email', 'unique:users')
935
+ ->and(ruleStrings($createRules['email']))->not->toContain('required')
936
+ ->and(ruleStrings($createRules['phone']))->toContain('nullable', 'unique:users')
937
+ ->and(ruleStrings($createRules['phone']))->not->toContain('required')
859
938
  ->and($createRules['password'])->toBe('nullable|string')
860
939
  ->and($createRules['country'])->toBe('nullable|size:2')
861
- ->and($createRules['vehicle'])->toBe('nullable|string|starts_with:vehicle_|exists:vehicles,public_id')
940
+ ->and(ruleStrings($createRules['vehicle']))->toContain('nullable', 'string', 'starts_with:vehicle_', 'exists:vehicles,public_id')
862
941
  ->and($createRules['license_expiry'])->toBe('nullable|date')
863
942
  ->and($createRules['status'])->toBe('nullable|string|in:active,available,inactive')
864
- ->and($createRules['vendor'])->toBe('nullable|exists:vendors,public_id')
865
- ->and($createRules['job'])->toBe('nullable|exists:orders,public_id')
943
+ ->and(ruleStrings($createRules['vendor']))->toContain('nullable', 'exists:vendors,public_id')
944
+ ->and(ruleStrings($createRules['job']))->toContain('nullable', 'exists:orders,public_id')
945
+ ->and($createRules['vehicle'][3]->constraints)->toBe([
946
+ ['where', 'company_uuid', 'company-uuid'],
947
+ ['whereNull', 'deleted_at'],
948
+ ])
949
+ ->and($createRules['internal_id'])->toBe('nullable|string|max:191')
950
+ ->and($createRules['meta'])->toBe('nullable|array')
951
+ ->and($createRules['current_status'])->toBe('nullable|string|max:64')
952
+ ->and($createRules['max_travel_time'])->toBe('nullable|integer|min:0')
866
953
  ->and($createRules['location'][1])->toBeInstanceOf(ResolvablePoint::class)
867
954
  ->and($createRules['latitude'])->toBe(['nullable', 'required_with:longitude'])
868
955
  ->and($createRules['longitude'])->toBe(['nullable', 'required_with:latitude'])
@@ -954,7 +1041,13 @@ namespace {
954
1041
  ->and(ruleStrings($rateRules['peak_hours_calculation_method']))->toContain('required', 'in:percentage,flat')
955
1042
  ->and(ruleStrings($rateRules['peak_hours_percent']))->toContain('required', 'integer')
956
1043
  ->and(ruleStrings($rateRules['peak_hours_start']))->toContain('required', 'date_format:H:i')
957
- ->and(ruleStrings($rateRules['peak_hours_end']))->toContain('required', 'date_format:H:i');
1044
+ ->and(ruleStrings($rateRules['peak_hours_end']))->toContain('required', 'date_format:H:i')
1045
+ // Both relationships are optional, so an empty one has to read as
1046
+ // absent. Without `nullable` the `exists` rule runs against the null
1047
+ // that ConvertEmptyStringsToNull produced and answers "the selected
1048
+ // service area is invalid" for a field the caller left blank.
1049
+ ->and(ruleStrings($rateRules['service_area']))->toContain('nullable')
1050
+ ->and(ruleStrings($rateRules['zone']))->toContain('nullable');
958
1051
 
959
1052
  // A street address alone satisfies the name requirement on create, and
960
1053
  // updates never require either field since the record already exists
@@ -1,5 +1,6 @@
1
1
  <?php
2
2
 
3
+ use Fleetbase\FleetOps\Http\Controllers\Api\v1\FleetController;
3
4
  use Fleetbase\FleetOps\Http\Resources\v1\Fleet as FleetResource;
4
5
  use Fleetbase\FleetOps\Models\Fleet;
5
6
  use Illuminate\Database\ConnectionResolver;
@@ -81,22 +82,65 @@ function fleetopsFleetResourceBoot(): SQLiteConnection
81
82
  return $connection;
82
83
  }
83
84
 
85
+ /**
86
+ * Resolve expansions the way a request does, then serialize.
87
+ */
88
+ function fleetopsFleetResourcePayload(Fleet $fleet, array $query): array
89
+ {
90
+ $request = Request::create('/v1/fleets/fleet_parentone', 'GET', $query);
91
+ $controller = new FleetController();
92
+ $reflection = new ReflectionMethod(FleetController::class, 'applyPublicExpansions');
93
+ $reflection->setAccessible(true);
94
+ $reflection->invoke($controller, $request, FleetController::EXPANDABLE);
95
+
96
+ return (new FleetResource($fleet))->resolve($request);
97
+ }
98
+
84
99
  test('fleet resource preloads requested subfleet driver and vehicle relations', function () {
85
100
  fleetopsFleetResourceBoot();
86
101
 
87
- $fleet = Fleet::where('uuid', 'fleet-parent-1')->first();
88
- $request = Request::create('/v1/fleets/fleet_parentone', 'GET', ['with' => ['subfleets', 'drivers', 'vehicles']]);
89
-
90
- $resolved = (new FleetResource($fleet))->resolve($request);
102
+ $fleet = Fleet::where('uuid', 'fleet-parent-1')->first();
103
+ $resolved = fleetopsFleetResourcePayload($fleet, ['with' => ['subfleets', 'drivers', 'vehicles']]);
91
104
 
105
+ // `subFleets` is the relation Eloquent actually has. The public name differs
106
+ // from it only in case, and PHP method calls are case-insensitive, so
107
+ // `load('subfleets')` used to succeed and store a *second* copy under the
108
+ // mis-cased key — which `whenLoaded('subFleets')` then could not see.
92
109
  expect($resolved['name'])->toBe('Parent Fleet')
93
- ->and($fleet->relationLoaded('subfleets'))->toBeTrue()
94
- ->and($fleet->relationLoaded('subFleets'))->toBeTrue();
110
+ ->and($fleet->relationLoaded('subFleets'))->toBeTrue()
111
+ ->and($resolved)->toHaveKey('subfleets');
95
112
 
96
113
  $subFleet = $fleet->getRelation('subFleets')->first();
114
+
115
+ // Asking for subfleets alongside drivers and vehicles still nests them, as
116
+ // the released contract did.
97
117
  expect($subFleet)->not->toBeNull()
98
118
  ->and($subFleet->relationLoaded('drivers'))->toBeTrue()
99
119
  ->and($subFleet->relationLoaded('vehicles'))->toBeTrue()
100
120
  ->and($subFleet->drivers)->toHaveCount(1)
101
121
  ->and($subFleet->vehicles)->toHaveCount(1);
102
122
  });
123
+
124
+ test('fleet resource resolves subfleets on its own, which the mis-cased load never did', function () {
125
+ fleetopsFleetResourceBoot();
126
+
127
+ $fleet = Fleet::where('uuid', 'fleet-parent-1')->first();
128
+ $resolved = fleetopsFleetResourcePayload($fleet, ['with' => 'subfleets']);
129
+
130
+ // Before the mapping, this returned no `subfleets` key at all: the load
131
+ // landed under `subfleets` and the resource looked for `subFleets`.
132
+ expect($resolved)->toHaveKey('subfleets')
133
+ ->and($fleet->relationLoaded('subFleets'))->toBeTrue();
134
+ });
135
+
136
+ test('fleet resource accepts the explicit nested expansion spelling', function () {
137
+ fleetopsFleetResourceBoot();
138
+
139
+ $fleet = Fleet::where('uuid', 'fleet-parent-1')->first();
140
+ fleetopsFleetResourcePayload($fleet, ['with' => ['subfleets.drivers']]);
141
+
142
+ $subFleet = $fleet->getRelation('subFleets')->first();
143
+
144
+ expect($subFleet->relationLoaded('drivers'))->toBeTrue()
145
+ ->and($subFleet->relationLoaded('vehicles'))->toBeFalse();
146
+ });
@@ -39,7 +39,7 @@ class FleetOpsVehicleControllerProbe extends VehicleController
39
39
  return $this->vehicle;
40
40
  }
41
41
 
42
- protected function findDriver(string $id): Driver
42
+ protected function findDriver(?string $id): Driver
43
43
  {
44
44
  $this->driverLookups[] = $id;
45
45