@fleetbase/fleetops-engine 0.6.60 → 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 (109) hide show
  1. package/RELEASE.md +21 -17
  2. package/addon/components/ai/route-preview-map.js +1 -5
  3. package/addon/components/customer/orders.js +2 -1
  4. package/addon/components/driver/card.hbs +49 -0
  5. package/addon/components/driver/card.js +30 -0
  6. package/addon/components/map/leaflet-live-map.js +4 -19
  7. package/addon/components/modals/place-details.hbs +1 -1
  8. package/addon/components/modals/point-map.hbs +1 -1
  9. package/addon/components/orchestrator-workbench.js +2 -1
  10. package/addon/components/order/form/route.js +3 -0
  11. package/addon/components/order-tracking-lookup.js +2 -1
  12. package/addon/components/place/details.hbs +1 -1
  13. package/addon/components/positions-replay.js +4 -1
  14. package/addon/components/service-area/details.hbs +1 -1
  15. package/addon/components/widget/live-fleet.js +7 -7
  16. package/addon/components/zone/details.hbs +1 -1
  17. package/addon/controllers/management/drivers/index.js +22 -1
  18. package/addon/controllers/settings/map.js +16 -0
  19. package/addon/helpers/leaflet-tile-url.js +19 -0
  20. package/addon/services/map-settings.js +26 -1
  21. package/addon/templates/management/drivers/index.hbs +67 -33
  22. package/addon/templates/settings/map.hbs +10 -0
  23. package/addon/utils/leaflet-tile-url.js +31 -0
  24. package/addon/utils/prepare-place-for-save.js +13 -0
  25. package/app/components/driver/card.js +1 -0
  26. package/app/helpers/leaflet-tile-url.js +1 -0
  27. package/app/utils/leaflet-tile-url.js +1 -0
  28. package/composer.json +1 -1
  29. package/extension.json +1 -1
  30. package/index.js +11 -0
  31. package/package.json +2 -1
  32. package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
  33. package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
  34. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
  35. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
  36. package/server/src/Http/Controllers/Api/v1/DriverController.php +312 -59
  37. package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
  38. package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
  39. package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
  40. package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
  41. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  42. package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
  43. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
  44. package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
  45. package/server/src/Http/Controllers/Api/v1/VehicleController.php +139 -34
  46. package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
  47. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  48. package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
  49. package/server/src/Http/Filter/DriverFilter.php +71 -15
  50. package/server/src/Http/Filter/FleetFilter.php +32 -33
  51. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  52. package/server/src/Http/Filter/IssueFilter.php +29 -0
  53. package/server/src/Http/Filter/VehicleFilter.php +57 -19
  54. package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
  55. package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
  56. package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
  57. package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
  58. package/server/src/Http/Requests/CreateVehicleRequest.php +150 -26
  59. package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
  60. package/server/src/Http/Resources/v1/Driver.php +22 -2
  61. package/server/src/Http/Resources/v1/Fleet.php +38 -25
  62. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  63. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  64. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  65. package/server/src/Http/Resources/v1/Vehicle.php +46 -4
  66. package/server/src/Models/Driver.php +2 -2
  67. package/server/src/Models/Place.php +1 -1
  68. package/server/src/routes.php +29 -0
  69. package/server/tests/ApiDriverControllerContractsTest.php +317 -13
  70. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  71. package/server/tests/ApiEntityControllerContractsTest.php +1 -1
  72. package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
  73. package/server/tests/ApiFleetControllerContractsTest.php +354 -21
  74. package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
  75. package/server/tests/ApiIssueControllerContractsTest.php +1 -1
  76. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  77. package/server/tests/ApiOrderControllerContractsTest.php +1 -1
  78. package/server/tests/ApiPartControllerContractsTest.php +2 -2
  79. package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
  80. package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
  81. package/server/tests/ApiVehicleControllerContractsTest.php +210 -8
  82. package/server/tests/CompactResourceSerializationTest.php +13 -7
  83. package/server/tests/ControllerFilterContractsTest.php +132 -13
  84. package/server/tests/ControllerHelperContractsTest.php +6 -3
  85. package/server/tests/DriverFilterExecutionTest.php +130 -6
  86. package/server/tests/ExceptionContractsTest.php +15 -0
  87. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
  88. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  89. package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
  90. package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
  91. package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
  92. package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
  93. package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
  94. package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
  95. package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
  96. package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
  97. package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
  98. package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
  99. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  100. package/server/tests/FleetHierarchyResourceTest.php +56 -8
  101. package/server/tests/RequestContractsTest.php +105 -7
  102. package/server/tests/SettingControllerContractsTest.php +33 -0
  103. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  104. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  105. package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
  106. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  107. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  108. package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
  109. package/translations/en-us.yaml +4 -0
@@ -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');
@@ -300,6 +302,58 @@ namespace {
300
302
  ->and($vehicleRules['longitude'])->toBe(['nullable', 'required_with:latitude'])
301
303
  ->and(ruleStrings($vehicleRules['status']))->toContain('nullable')
302
304
  ->and(implode('|', ruleStrings($vehicleRules['status'])))->toContain('operational')
305
+ // The model casts odometer to an integer, so an unvalidated string
306
+ // would land as 0 — a vehicle reporting zero miles rather than an
307
+ // error. These rules are what stop that.
308
+ ->and($vehicleRules['odometer'])->toBe('nullable|numeric|min:0')
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
+ ])
303
357
  ->and($fuelReportRules['driver'])->toBe(['required'])
304
358
  ->and($fuelReportRules['odometer'])->toBe(['required'])
305
359
  ->and($fuelReportRules['volume'])->toBe(['required'])
@@ -429,7 +483,30 @@ namespace {
429
483
  ])
430
484
  ->and(ruleStrings($fleetCreateRules['name']))->toContain('required')
431
485
  ->and(ruleStrings($fleetPatchRules['name']))->not->toContain('required')
432
- ->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
+ ])
433
510
  ->and(requestRules(CancelOrderRequest::class))->toBe(['order' => 'required|exists:orders,uuid'])
434
511
  ->and(requestRules(DecodeTrackingNumberQR::class))->toBe(['code' => 'required|string'])
435
512
  ->and(requestRules(CreateServiceQuoteRequest::class))->toBe([]);
@@ -833,6 +910,9 @@ namespace {
833
910
  });
834
911
 
835
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']);
836
916
  bindFleetOpsRequestSession();
837
917
 
838
918
  $request = CreateDriverRequest::create('/fleetops-test', 'POST', [
@@ -849,15 +929,27 @@ namespace {
849
929
  expect($request->authorize())->toBeTrue()
850
930
  ->and(ruleStrings($createRules['name']))->toContain('required')
851
931
  ->and(ruleStrings($patchRules['name']))->not->toContain('required')
852
- ->and(ruleStrings($createRules['email']))->toContain('required', 'email', 'unique:users')
853
- ->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')
854
938
  ->and($createRules['password'])->toBe('nullable|string')
855
939
  ->and($createRules['country'])->toBe('nullable|size:2')
856
- ->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')
857
941
  ->and($createRules['license_expiry'])->toBe('nullable|date')
858
942
  ->and($createRules['status'])->toBe('nullable|string|in:active,available,inactive')
859
- ->and($createRules['vendor'])->toBe('nullable|exists:vendors,public_id')
860
- ->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')
861
953
  ->and($createRules['location'][1])->toBeInstanceOf(ResolvablePoint::class)
862
954
  ->and($createRules['latitude'])->toBe(['nullable', 'required_with:longitude'])
863
955
  ->and($createRules['longitude'])->toBe(['nullable', 'required_with:latitude'])
@@ -949,7 +1041,13 @@ namespace {
949
1041
  ->and(ruleStrings($rateRules['peak_hours_calculation_method']))->toContain('required', 'in:percentage,flat')
950
1042
  ->and(ruleStrings($rateRules['peak_hours_percent']))->toContain('required', 'integer')
951
1043
  ->and(ruleStrings($rateRules['peak_hours_start']))->toContain('required', 'date_format:H:i')
952
- ->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');
953
1051
 
954
1052
  // A street address alone satisfies the name requirement on create, and
955
1053
  // updates never require either field since the record already exists
@@ -408,3 +408,36 @@ test('setting controller handles map scheduling orchestrator and card field sett
408
408
  'settings' => ['standard' => ['tracking'], 'byConfig' => [], 'meta' => ['dense' => true]],
409
409
  ]);
410
410
  });
411
+
412
+ test('setting controller sanitizes leaflet tile provider urls', function () {
413
+ $controller = new FleetOpsSettingControllerProbe();
414
+
415
+ $saved = fleetopsJsonPayload($controller->saveMapSettings(new Request([
416
+ 'settings' => [
417
+ 'leafletTileUrl' => ' https://tile.openstreetmap.org/{z}/{x}/{y}.png ',
418
+ 'leafletDarkTileUrl' => 'javascript:alert(1)',
419
+ ],
420
+ ])));
421
+
422
+ expect($saved['leafletTileUrl'])->toBe('https://tile.openstreetmap.org/{z}/{x}/{y}.png')
423
+ ->and($saved['leafletDarkTileUrl'])->toBe('')
424
+ ->and($controller->configuredCompany['fleet-ops.map-settings'])->toMatchArray([
425
+ 'leafletTileUrl' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
426
+ 'leafletDarkTileUrl' => '',
427
+ ]);
428
+
429
+ $savedNonString = fleetopsJsonPayload($controller->saveMapSettings(new Request([
430
+ 'settings' => [
431
+ 'leafletTileUrl' => ['not' => 'a-string'],
432
+ 'leafletDarkTileUrl' => 'ftp://tiles.example.com/{z}/{x}/{y}.png',
433
+ ],
434
+ ])));
435
+
436
+ expect($savedNonString['leafletTileUrl'])->toBe('')
437
+ ->and($savedNonString['leafletDarkTileUrl'])->toBe('');
438
+
439
+ $defaults = fleetopsJsonPayload((new FleetOpsSettingControllerProbe())->getMapSettings());
440
+
441
+ expect($defaults['leafletTileUrl'])->toBe('')
442
+ ->and($defaults['leafletDarkTileUrl'])->toBe('');
443
+ });
@@ -0,0 +1,135 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Http\Filter\FuelReportFilter;
4
+ use Fleetbase\Http\Filter\Filter;
5
+ use Illuminate\Http\Request;
6
+
7
+ /**
8
+ * Covers the FuelReportFilter query branches against a recording builder fake:
9
+ * base scoping, keyword search, priority and status constraints, and the
10
+ * assignee, reporter, driver and vehicle relation lookups routing by uuid,
11
+ * public id, or fallback search.
12
+ */
13
+ class FleetOpsFuelReportFilterUnitQuery
14
+ {
15
+ public array $calls = [];
16
+
17
+ public function where(...$arguments): self
18
+ {
19
+ if (isset($arguments[0]) && is_callable($arguments[0])) {
20
+ $nested = new self();
21
+ $arguments[0]($nested);
22
+ $this->calls[] = ['whereNested', $nested->calls];
23
+
24
+ return $this;
25
+ }
26
+
27
+ $this->calls[] = ['where', $arguments];
28
+
29
+ return $this;
30
+ }
31
+
32
+ public function whereHas(string $relation, ?callable $callback = null): self
33
+ {
34
+ $nested = new self();
35
+ if ($callback) {
36
+ $callback($nested);
37
+ }
38
+ $this->calls[] = ['whereHas', $relation, $nested->calls];
39
+
40
+ return $this;
41
+ }
42
+
43
+ public function search(?string $query): self
44
+ {
45
+ $this->calls[] = ['search', $query];
46
+
47
+ return $this;
48
+ }
49
+
50
+ public function whereIn(string $column, mixed $values): self
51
+ {
52
+ $this->calls[] = ['whereIn', $column, $values];
53
+
54
+ return $this;
55
+ }
56
+
57
+ public function whereBetween(string $column, mixed $bounds): self
58
+ {
59
+ $this->calls[] = ['whereBetween', $column, $bounds];
60
+
61
+ return $this;
62
+ }
63
+
64
+ public function whereDate(string $column, mixed $value): self
65
+ {
66
+ $this->calls[] = ['whereDate', $column, $value];
67
+
68
+ return $this;
69
+ }
70
+
71
+ public function __call($method, $arguments): self
72
+ {
73
+ $this->calls[] = [$method, $arguments];
74
+
75
+ return $this;
76
+ }
77
+ }
78
+
79
+ function fleetopsFuelReportFilterUnitFilter(FleetOpsFuelReportFilterUnitQuery $builder, ?Request $request = null): FuelReportFilter
80
+ {
81
+ $filter = (new ReflectionClass(FuelReportFilter::class))->newInstanceWithoutConstructor();
82
+
83
+ foreach ([
84
+ 'builder' => $builder,
85
+ 'session' => new class {
86
+ public function get(string $key): ?string
87
+ {
88
+ return $key === 'company' ? 'company-uuid' : null;
89
+ }
90
+ },
91
+ 'request' => $request ?? new Request(),
92
+ ] as $property => $value) {
93
+ $reflection = new ReflectionProperty(Filter::class, $property);
94
+ $reflection->setAccessible(true);
95
+ $reflection->setValue($filter, $value);
96
+ }
97
+
98
+ return $filter;
99
+ }
100
+
101
+ test('fuel report filter scopes by driver_uuid and driver_assigned, not just driver', function () {
102
+ foreach (['driverUuid', 'driverAssigned'] as $method) {
103
+ $builder = new FleetOpsFuelReportFilterUnitQuery();
104
+ $filter = fleetopsFuelReportFilterUnitFilter($builder);
105
+
106
+ $filter->{$method}('driver_filterone');
107
+
108
+ $whereHas = collect($builder->calls)->where(0, 'whereHas')->firstWhere(1, 'driver');
109
+ expect($whereHas)->not->toBeNull("{$method} must constrain the driver relation")
110
+ ->and($whereHas[2][0][1][0])->toBe('public_id');
111
+ }
112
+ });
113
+
114
+ test('fuel report filter driver aliases route uuids the same way driver does', function () {
115
+ $builder = new FleetOpsFuelReportFilterUnitQuery();
116
+ $filter = fleetopsFuelReportFilterUnitFilter($builder);
117
+
118
+ $filter->driverUuid('33333333-3333-4333-8333-333333333333');
119
+
120
+ $whereHas = collect($builder->calls)->where(0, 'whereHas')->firstWhere(1, 'driver');
121
+ expect($whereHas[2][0][1][0])->toBe('uuid');
122
+ });
123
+
124
+ test('fuel report filter scopes by vehicle_uuid as well as vehicle', function () {
125
+ $builder = new FleetOpsFuelReportFilterUnitQuery();
126
+ $filter = fleetopsFuelReportFilterUnitFilter($builder);
127
+
128
+ $filter->vehicleUuid('44444444-4444-4444-8444-444444444444');
129
+ $filter->vehicleUuid('vehicle_filterone');
130
+
131
+ $whereHasCalls = collect($builder->calls)->where(0, 'whereHas')->where(1, 'vehicle')->values();
132
+ expect($whereHasCalls)->toHaveCount(2)
133
+ ->and($whereHasCalls[0][2][0][1][0])->toBe('uuid')
134
+ ->and($whereHasCalls[1][2][0][1][0])->toBe('public_id');
135
+ });
@@ -127,3 +127,46 @@ test('issue filter relation branches route uuids public ids and searches', funct
127
127
  $searchNested = $whereHasCalls->filter(fn ($call) => collect($call[2])->contains(fn ($inner) => $inner[0] === 'search'));
128
128
  expect($searchNested->count())->toBeGreaterThanOrEqual(3);
129
129
  });
130
+
131
+ test('issue filter scopes by driver_uuid and driver_assigned, not just driver', function () {
132
+ /*
133
+ * The base filter resolves a query parameter to a method of the same name
134
+ * and silently ignores what it cannot match. `driver_uuid` matched nothing,
135
+ * so the filter was dropped and the list came back scoped only by company —
136
+ * every driver's issues, with a 200 and no sign the request had been
137
+ * narrowed. For a driver app that is a disclosure, not a nuisance.
138
+ */
139
+ foreach (['driverUuid', 'driverAssigned'] as $method) {
140
+ $builder = new FleetOpsIssueFilterUnitQuery();
141
+ $filter = fleetopsIssueFilterUnitFilter($builder);
142
+
143
+ $filter->{$method}('driver_filterone');
144
+
145
+ $whereHas = collect($builder->calls)->where(0, 'whereHas')->firstWhere(1, 'driver');
146
+ expect($whereHas)->not->toBeNull("{$method} must constrain the driver relation")
147
+ ->and($whereHas[2][0][1][0])->toBe('public_id');
148
+ }
149
+ });
150
+
151
+ test('issue filter driver aliases route uuids the same way driver does', function () {
152
+ $builder = new FleetOpsIssueFilterUnitQuery();
153
+ $filter = fleetopsIssueFilterUnitFilter($builder);
154
+
155
+ $filter->driverUuid('33333333-3333-4333-8333-333333333333');
156
+
157
+ $whereHas = collect($builder->calls)->where(0, 'whereHas')->firstWhere(1, 'driver');
158
+ expect($whereHas[2][0][1][0])->toBe('uuid');
159
+ });
160
+
161
+ test('issue filter scopes by vehicle_uuid as well as vehicle', function () {
162
+ $builder = new FleetOpsIssueFilterUnitQuery();
163
+ $filter = fleetopsIssueFilterUnitFilter($builder);
164
+
165
+ $filter->vehicleUuid('44444444-4444-4444-8444-444444444444');
166
+ $filter->vehicleUuid('vehicle_filterone');
167
+
168
+ $whereHasCalls = collect($builder->calls)->where(0, 'whereHas')->where(1, 'vehicle')->values();
169
+ expect($whereHasCalls)->toHaveCount(2)
170
+ ->and($whereHasCalls[0][2][0][1][0])->toBe('uuid')
171
+ ->and($whereHasCalls[1][2][0][1][0])->toBe('public_id');
172
+ });
@@ -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
+ });
@@ -0,0 +1,129 @@
1
+ <?php
2
+
3
+ /*
4
+ * The models resolve their connection through a namespaced `config()`, which
5
+ * does not exist without the full application. Stubbed so a resource can be
6
+ * exercised on its own — the projection is the thing under test, not Eloquent.
7
+ */
8
+ if (!function_exists('Fleetbase\Traits\config')) {
9
+ eval('namespace Fleetbase\Traits; function config($key = null, $default = null) { return $key === "api.cache.enabled" ? false : $default; }');
10
+ }
11
+
12
+ if (!function_exists('Fleetbase\Models\config')) {
13
+ eval('namespace Fleetbase\Models; function config($key = null, $default = null) { return $default; }');
14
+ }
15
+
16
+ if (!function_exists('Fleetbase\FleetOps\Models\config')) {
17
+ eval('namespace Fleetbase\FleetOps\Models; function config($key = null, $default = null) { return $default; }');
18
+ }
19
+
20
+ use Fleetbase\FleetOps\Http\Resources\v1\Manifest as ManifestResource;
21
+ use Fleetbase\FleetOps\Http\Resources\v1\ManifestStop as ManifestStopResource;
22
+ use Fleetbase\FleetOps\Models\Manifest;
23
+ use Fleetbase\FleetOps\Models\ManifestStop;
24
+ use Illuminate\Http\Request;
25
+
26
+ if (!class_exists('FleetOpsSupportRequestState')) {
27
+ class FleetOpsSupportRequestState
28
+ {
29
+ public static ?Request $request = null;
30
+ }
31
+ }
32
+
33
+ if (!function_exists('Fleetbase\Support\request')) {
34
+ eval('namespace Fleetbase\Support; function request($key = null, $default = null) { return \FleetOpsSupportRequestState::$request; }');
35
+ }
36
+
37
+ /**
38
+ * The manifest's stop counts are accessors that query the database, and the
39
+ * driver and vehicle names walk relations. None of that is what a resource test
40
+ * is for, so they are answered directly — the projection is under test, not
41
+ * Eloquent.
42
+ */
43
+ class FleetOpsManifestFake extends Manifest
44
+ {
45
+ public function getCompletedStopsAttribute(): int
46
+ {
47
+ return 3;
48
+ }
49
+
50
+ public function getPendingStopsAttribute(): int
51
+ {
52
+ return 5;
53
+ }
54
+
55
+ public function getDriverNameAttribute(): ?string
56
+ {
57
+ return 'Ron';
58
+ }
59
+
60
+ public function getVehicleNameAttribute(): ?string
61
+ {
62
+ return 'EAS-01';
63
+ }
64
+ }
65
+
66
+ test('manifest resource publishes what a driver needs to run a route', function () {
67
+ $request = Request::create('/v1/manifests/manifest_public', 'GET');
68
+ FleetOpsSupportRequestState::$request = $request;
69
+
70
+ $manifest = new FleetOpsManifestFake();
71
+ $manifest->setRawAttributes([
72
+ 'id' => 3,
73
+ 'uuid' => 'manifest-uuid',
74
+ 'public_id' => 'manifest_public',
75
+ 'status' => 'in_progress',
76
+ 'total_distance_m' => 41200,
77
+ 'total_duration_s' => 5400,
78
+ 'stop_count' => 8,
79
+ 'notes' => 'Cold chain first',
80
+ ], true);
81
+
82
+ $payload = (new ManifestResource($manifest))->resolve($request);
83
+
84
+ expect($payload['id'])->toBe('manifest_public')
85
+ ->and($payload['status'])->toBe('in_progress')
86
+ ->and($payload['total_distance_m'])->toBe(41200)
87
+ ->and($payload['stop_count'])->toBe(8)
88
+ ->and($payload['notes'])->toBe('Cold chain first')
89
+ ->and($payload['completed_stops'])->toBe(3)
90
+ ->and($payload['pending_stops'])->toBe(5)
91
+ ->and($payload['driver_name'])->toBe('Ron');
92
+ });
93
+
94
+ test('manifest resource omits stops when they were not loaded, so a list stays a list', function () {
95
+ // A driver's manifest list on a busy fleet must not drag every stop of
96
+ // every route along with it.
97
+ $request = Request::create('/v1/drivers/driver_public/manifests', 'GET');
98
+ FleetOpsSupportRequestState::$request = $request;
99
+
100
+ $manifest = new FleetOpsManifestFake();
101
+ $manifest->setRawAttributes(['id' => 4, 'uuid' => 'm-uuid', 'public_id' => 'manifest_public', 'status' => 'pending'], true);
102
+
103
+ $payload = (new ManifestResource($manifest))->resolve($request);
104
+
105
+ expect($payload)->not->toHaveKey('stops');
106
+ });
107
+
108
+ test('manifest stop resource carries the sequence a re-sequence rewrites', function () {
109
+ $request = Request::create('/v1/manifest-stops/stop_public', 'GET');
110
+ FleetOpsSupportRequestState::$request = $request;
111
+
112
+ $stop = new ManifestStop();
113
+ $stop->setRawAttributes([
114
+ 'id' => 9,
115
+ 'uuid' => 'stop-uuid',
116
+ 'public_id' => 'stop_public',
117
+ 'status' => 'pending',
118
+ 'sequence' => 4,
119
+ 'distance_from_prev_m' => 1800,
120
+ 'duration_from_prev_s' => 240,
121
+ ], true);
122
+
123
+ $payload = (new ManifestStopResource($stop))->resolve($request);
124
+
125
+ expect($payload['id'])->toBe('stop_public')
126
+ ->and($payload['sequence'])->toBe(4)
127
+ ->and($payload['status'])->toBe('pending')
128
+ ->and($payload['distance_from_prev_m'])->toBe(1800);
129
+ });