@fleetbase/fleetops-engine 0.6.59 → 0.6.61

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 (91) hide show
  1. package/RELEASE.md +25 -0
  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/vehicle/details/schedules.hbs +2 -2
  16. package/addon/components/vehicle/details/schedules.js +7 -1
  17. package/addon/components/widget/live-fleet.js +7 -7
  18. package/addon/components/zone/details.hbs +1 -1
  19. package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
  20. package/addon/controllers/management/drivers/index.js +22 -1
  21. package/addon/controllers/settings/map.js +16 -0
  22. package/addon/helpers/leaflet-tile-url.js +19 -0
  23. package/addon/services/map-settings.js +22 -0
  24. package/addon/services/vehicle-actions.js +4 -2
  25. package/addon/templates/management/drivers/index.hbs +67 -33
  26. package/addon/templates/settings/map.hbs +10 -0
  27. package/addon/utils/leaflet-tile-url.js +31 -0
  28. package/addon/utils/prepare-place-for-save.js +13 -0
  29. package/app/components/driver/card.js +1 -0
  30. package/app/helpers/leaflet-tile-url.js +1 -0
  31. package/app/utils/leaflet-tile-url.js +1 -0
  32. package/composer.json +1 -1
  33. package/extension.json +1 -1
  34. package/index.js +11 -0
  35. package/package.json +2 -1
  36. package/server/config/fleetops.php +44 -0
  37. package/server/config/geocoder.php +1 -1
  38. package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
  39. package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
  40. package/server/src/Http/Controllers/Api/v1/DriverController.php +277 -5
  41. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
  42. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  43. package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
  44. package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
  45. package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
  46. package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
  47. package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
  48. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  49. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  50. package/server/src/Http/Filter/IssueFilter.php +29 -0
  51. package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
  52. package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
  53. package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
  54. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
  55. package/server/src/Http/Requests/CreatePartRequest.php +39 -1
  56. package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
  57. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  58. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  59. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  60. package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
  61. package/server/src/Models/FuelReport.php +21 -0
  62. package/server/src/Models/Place.php +1 -1
  63. package/server/src/Models/Sensor.php +24 -0
  64. package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
  65. package/server/src/routes.php +22 -1
  66. package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
  67. package/server/tests/ApiDriverControllerContractsTest.php +65 -0
  68. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  69. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  70. package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
  71. package/server/tests/CompactResourceSerializationTest.php +99 -0
  72. package/server/tests/CustomerEndpointTest.php +4 -1
  73. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
  74. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  75. package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
  76. package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
  77. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
  78. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  79. package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
  80. package/server/tests/RequestContractsTest.php +102 -5
  81. package/server/tests/SettingControllerContractsTest.php +33 -0
  82. package/server/tests/SmallControllerContractsTest.php +28 -4
  83. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  84. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  85. package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
  86. package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
  87. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  88. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  89. package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
  90. package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
  91. package/translations/en-us.yaml +4 -0
@@ -36,6 +36,18 @@ if (!Request::hasMacro('resolveFilesFromIds')) {
36
36
  Request::macro('resolveFilesFromIds', fn () => FleetOpsInternalPlaceEndpointsState::$files);
37
37
  }
38
38
 
39
+ if (!Request::hasMacro('or')) {
40
+ Request::macro('or', function (array $params = [], $default = null) {
41
+ foreach ($params as $param) {
42
+ if ($this->has($param)) {
43
+ return $this->input($param);
44
+ }
45
+ }
46
+
47
+ return $default;
48
+ });
49
+ }
50
+
39
51
  class FleetOpsInternalPlaceEndpointsState
40
52
  {
41
53
  public static array $files = [];
@@ -81,6 +93,14 @@ class FleetOpsInternalPlaceEndpointsPlaceFake extends Place
81
93
  }
82
94
  }
83
95
 
96
+ class FleetOpsInternalPlaceUpdateFake extends Place
97
+ {
98
+ public function applyDirectivesToQuery(Request $request, $builder)
99
+ {
100
+ return $builder;
101
+ }
102
+ }
103
+
84
104
  class FleetOpsInternalPlaceEndpointsProbe extends PlaceController
85
105
  {
86
106
  public function callProtected(string $method, array $arguments = []): mixed
@@ -128,6 +148,7 @@ function fleetopsInternalPlaceEndpointsBoot(): SQLiteConnection
128
148
  $table->string('postal_code')->nullable();
129
149
  $table->string('location')->nullable();
130
150
  $table->timestamp('created_at')->nullable();
151
+ $table->timestamp('updated_at')->nullable();
131
152
  $table->timestamp('deleted_at')->nullable();
132
153
  });
133
154
  $schema->create('files', function ($table) {
@@ -250,3 +271,27 @@ test('sync custom field values helper delegates to the place model', function ()
250
271
 
251
272
  expect($place->syncedValues)->toBe([['priority' => 'high']]);
252
273
  });
274
+
275
+ test('place updates accept serialized display-only attributes', function () {
276
+ $connection = fleetopsInternalPlaceEndpointsBoot();
277
+ $connection->table('places')->insert([
278
+ 'uuid' => '77777777-7777-4777-8777-777777777777',
279
+ 'public_id' => 'place_display1',
280
+ 'company_uuid' => 'company-1',
281
+ 'name' => 'Original place',
282
+ 'street1' => '205 Dostyk Avenue',
283
+ ]);
284
+
285
+ $place = (new FleetOpsInternalPlaceUpdateFake())->updateRecordFromRequest(Request::create('/int/v1/places/place_display1', 'PUT', [
286
+ 'place' => [
287
+ 'name' => 'Updated place',
288
+ 'street2' => 'Entrance 2',
289
+ 'avatar_value' => 'basic-building',
290
+ 'eta' => '12 minutes',
291
+ ],
292
+ ]), 'place_display1', options: ['return_object' => true]);
293
+
294
+ expect($place->name)->toBe('Updated place')
295
+ ->and($place->street2)->toBe('Entrance 2')
296
+ ->and($connection->table('places')->where('public_id', 'place_display1')->value('name'))->toBe('Updated place');
297
+ });
@@ -178,4 +178,21 @@ test('fuel provider transaction real lookup scopes identifier and company', func
178
178
 
179
179
  expect($reflection->invoke($controller, 'fpt-real-1')->public_id)->toBe('fuel_provider_transaction_real1')
180
180
  ->and($reflection->invoke($controller, 'fuel_provider_transaction_real1')->uuid)->toBe('fpt-real-1');
181
+
182
+ // resolveProvider() falls back to the stored provider when a PUT omits it, so the
183
+ // uniqueness scope is never null — a null scope would compare the transaction id
184
+ // against the wrong set of rows. Needs a real connection, which is why this lives
185
+ // here rather than in RequestContractsTest.
186
+ $resolveProvider = new ReflectionMethod(Fleetbase\FleetOps\Http\Requests\CreateFuelTransactionRequest::class, 'resolveProvider');
187
+ $resolveProvider->setAccessible(true);
188
+
189
+ $withRouteId = Fleetbase\FleetOps\Http\Requests\CreateFuelTransactionRequest::create('/fleetops-test', 'PUT')
190
+ ->setRouteResolver(fn () => new class {
191
+ public function parameter($key, $default = null)
192
+ {
193
+ return $key === 'id' ? 'fuel_provider_transaction_real1' : $default;
194
+ }
195
+ });
196
+
197
+ expect($resolveProvider->invoke($withRouteId))->toBe('petroapp');
181
198
  });
@@ -76,6 +76,18 @@ namespace Illuminate\Validation {
76
76
  return $this;
77
77
  }
78
78
 
79
+ /**
80
+ * Recorded so tests can assert the ignore-self clause on update
81
+ * requests, including which column it matches on — the v1 routes
82
+ * pass a public_id, not a uuid.
83
+ */
84
+ public function ignore($id, $idColumn = null): self
85
+ {
86
+ $this->constraints[] = ['ignore', $id, $idColumn];
87
+
88
+ return $this;
89
+ }
90
+
79
91
  public function __toString(): string
80
92
  {
81
93
  return $this->rule;
@@ -245,11 +257,16 @@ namespace {
245
257
  });
246
258
 
247
259
  test('fuel transaction request requires provider identifiers on create', function () {
260
+ // The harness `session()` stand-in is a process-wide static, so seed it
261
+ // here rather than relying on an earlier test in the file having done so.
262
+ session(['company' => 'company-uuid']);
263
+ bindFleetOpsRequestSession(['api_credential' => 'credential-uuid', 'company' => 'company-uuid']);
264
+
248
265
  $createRules = requestRules(CreateFuelTransactionRequest::class);
249
266
  $patchRules = requestRules(CreateFuelTransactionRequest::class, 'PATCH');
250
267
 
251
268
  expect(ruleStrings($createRules['provider']))->toContain('required', 'string')
252
- ->and(ruleStrings($createRules['provider_transaction_id']))->toContain('required', 'string')
269
+ ->and(ruleStrings($createRules['provider_transaction_id']))->toContain('required', 'string', 'unique:fuel_provider_transactions,provider_transaction_id')
253
270
  ->and(ruleStrings($patchRules['provider']))->not->toContain('required')
254
271
  ->and(ruleStrings($patchRules['provider_transaction_id']))->not->toContain('required')
255
272
  ->and($createRules['station_latitude'])->toBe(['nullable', 'numeric'])
@@ -257,6 +274,19 @@ namespace {
257
274
  ->and($createRules['normalized_payload'])->toBe(['nullable', 'array'])
258
275
  ->and($createRules['raw_payload'])->toBe(['nullable', 'array'])
259
276
  ->and($createRules['meta'])->toBe(['nullable', 'array']);
277
+
278
+ // Mirrors fuel_provider_txn_company_provider_unique. The provider is part
279
+ // of the scope, and company_uuid is what stops one tenant's provider
280
+ // transaction id from colliding with another's — the old index was global.
281
+ $rulesWithProvider = CreateFuelTransactionRequest::create('/fleetops-test', 'POST', ['provider' => 'petroapp'])->rules();
282
+ $uniqueRule = collect($rulesWithProvider['provider_transaction_id'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule);
283
+
284
+ expect($uniqueRule)->not->toBeNull()
285
+ ->and($uniqueRule->constraints)->toBe([
286
+ ['where', 'company_uuid', 'company-uuid'],
287
+ ['where', 'provider', 'petroapp'],
288
+ ['whereNull', 'deleted_at'],
289
+ ]);
260
290
  });
261
291
 
262
292
  test('vehicle and fuel report requests expose core validation contracts', function () {
@@ -270,10 +300,21 @@ namespace {
270
300
  ->and($vehicleRules['longitude'])->toBe(['nullable', 'required_with:latitude'])
271
301
  ->and(ruleStrings($vehicleRules['status']))->toContain('nullable')
272
302
  ->and(implode('|', ruleStrings($vehicleRules['status'])))->toContain('operational')
303
+ // The model casts odometer to an integer, so an unvalidated string
304
+ // would land as 0 — a vehicle reporting zero miles rather than an
305
+ // error. These rules are what stop that.
306
+ ->and($vehicleRules['odometer'])->toBe('nullable|numeric|min:0')
307
+ ->and($vehicleRules['odometer_unit'])->toBe('nullable|string|max:12')
273
308
  ->and($fuelReportRules['driver'])->toBe(['required'])
274
309
  ->and($fuelReportRules['odometer'])->toBe(['required'])
275
310
  ->and($fuelReportRules['volume'])->toBe(['required'])
276
- ->and($fuelReportUpdateRules['driver'])->toBe(['required'])
311
+ // The update request inherits these rules, so `required` made every partial
312
+ // update fail — PUT with just {"status": "approved"} answered "The driver
313
+ // field is required.", and the update action's $request->only() list does not
314
+ // even include `driver`. This assertion previously pinned that behaviour.
315
+ ->and($fuelReportUpdateRules['driver'])->toBe(['sometimes'])
316
+ ->and($fuelReportUpdateRules['odometer'])->toBe(['sometimes'])
317
+ ->and($fuelReportUpdateRules['volume'])->toBe(['sometimes'])
277
318
  ->and($internalDriverRules['location'][1])->toBeInstanceOf(ResolvablePoint::class)
278
319
  ->and($internalDriverRules['vehicle'][1])->toBeInstanceOf(ResolvableVehicle::class)
279
320
  ->and($internalDriverRules['latitude'])->toBe(['nullable', 'required_with:longitude', 'numeric'])
@@ -621,7 +662,7 @@ namespace {
621
662
 
622
663
  expect($request->authorize())->toBeTrue()
623
664
  ->and($rules['identity'])->toBe('required|string')
624
- ->and($rules['code'])->toBe('required|exists:verification_codes,code')
665
+ ->and($rules['code'])->toBe('required|string')
625
666
  ->and($rules['name'])->toBe('required|string')
626
667
  ->and($rules['password'])->toBe('required|string|min:8')
627
668
  ->and(ruleStrings($rules['email']))->toContain('email', 'nullable', 'unique:contacts')
@@ -649,13 +690,14 @@ namespace {
649
690
 
650
691
  expect($request->authorize())->toBeFalse();
651
692
 
652
- bindFleetOpsRequestSession(['api_credential' => 'credential-uuid']);
693
+ session(['company' => 'company-uuid']);
694
+ bindFleetOpsRequestSession(['api_credential' => 'credential-uuid', 'company' => 'company-uuid']);
653
695
 
654
696
  $createRules = $request->rules();
655
697
  $patchRules = CreatePartRequest::create('/fleetops-test', 'PATCH')->rules();
656
698
 
657
699
  expect($request->authorize())->toBeTrue()
658
- ->and($createRules['sku'])->toBe(['nullable', 'string'])
700
+ ->and(ruleStrings($createRules['sku']))->toContain('nullable', 'string', 'unique:parts,sku')
659
701
  ->and(ruleStrings($createRules['name']))->toContain('required', 'string')
660
702
  ->and(ruleStrings($patchRules['name']))->not->toContain('required')
661
703
  ->and($createRules['manufacturer'])->toBe(['nullable', 'string'])
@@ -671,11 +713,66 @@ namespace {
671
713
  ->and($createRules['specs'])->toBe(['nullable', 'array'])
672
714
  ->and($createRules['meta'])->toBe(['nullable', 'array']);
673
715
 
716
+ // The SKU uniqueness rule mirrors parts_company_uuid_active_sku_unique:
717
+ // scoped to the session company and ignoring soft-deleted parts, so a SKU
718
+ // freed by a deleted part — or held by another company — stays available.
719
+ // Without it a duplicate reached the driver and returned a 500.
720
+ $skuRule = collect($createRules['sku'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule);
721
+
722
+ expect($skuRule)->not->toBeNull()
723
+ ->and($skuRule->constraints)->toBe([
724
+ ['where', 'company_uuid', 'company-uuid'],
725
+ ['whereNull', 'deleted_at'],
726
+ ]);
727
+
728
+ // On update the record re-sends its own SKU, so it must not collide with
729
+ // itself. The v1 route parameter is a public_id, not a uuid.
730
+ $updateRules = CreatePartRequest::create('/fleetops-test', 'PUT')
731
+ ->setRouteResolver(fn () => new class {
732
+ public function parameter($key, $default = null)
733
+ {
734
+ return $key === 'id' ? 'part_abc123' : $default;
735
+ }
736
+ })
737
+ ->rules();
738
+
739
+ $updateSkuRule = collect($updateRules['sku'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule);
740
+
741
+ expect($updateSkuRule->constraints)->toContain(['ignore', 'part_abc123', 'public_id']);
742
+
674
743
  bindFleetOpsRequestSession(['is_sanctum_token' => true]);
675
744
 
676
745
  expect($request->authorize())->toBeTrue();
677
746
  });
678
747
 
748
+ test('duplicate-key requests name the offending field and ignore themselves on update', function () {
749
+ session(['company' => 'company-uuid']);
750
+ bindFleetOpsRequestSession(['api_credential' => 'credential-uuid', 'company' => 'company-uuid']);
751
+
752
+ // Without these the violation surfaces as the framework's generic "has already
753
+ // been taken", which does not say which record collided or on what.
754
+ expect(CreatePartRequest::create('/fleetops-test', 'POST')->messages())
755
+ ->toBe(['sku.unique' => 'A part with this SKU already exists.'])
756
+ ->and(CreateFuelTransactionRequest::create('/fleetops-test', 'POST')->messages())
757
+ ->toBe(['provider_transaction_id.unique' => 'A fuel transaction with this provider transaction id already exists for this provider.']);
758
+
759
+ // `provider` is supplied here, so resolveProvider() short-circuits on the input
760
+ // and this exercises only the ignore-self clause. The stored-provider fallback
761
+ // needs a database and is covered in FuelProviderTransactionControllerContractsTest.
762
+ $updateRules = CreateFuelTransactionRequest::create('/fleetops-test', 'PUT', ['provider' => 'petroapp'])
763
+ ->setRouteResolver(fn () => new class {
764
+ public function parameter($key, $default = null)
765
+ {
766
+ return $key === 'id' ? 'fuel_provider_transaction_abc123' : $default;
767
+ }
768
+ })
769
+ ->rules();
770
+
771
+ $updateRule = collect($updateRules['provider_transaction_id'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule);
772
+
773
+ expect($updateRule->constraints)->toContain(['ignore', 'fuel_provider_transaction_abc123', 'public_id']);
774
+ });
775
+
679
776
  test('entity equipment payload and simulation requests expose conditional contracts', function () {
680
777
  bindFleetOpsRequestSession();
681
778
 
@@ -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
+ });
@@ -227,19 +227,30 @@ class FleetOpsGettingStartedControllerProbe extends GettingStartedController
227
227
 
228
228
  class FleetOpsPublicNavigatorControllerProbe extends PublicNavigatorController
229
229
  {
230
- public mixed $settings = ['require_photo' => true];
231
- public array $lookups = [];
230
+ public mixed $settings = ['require_photo' => true];
231
+ public array $lookups = [];
232
+ public bool $companyMiss = false;
232
233
 
233
234
  protected function findCompanyByPublicId(string $companyId): ?Company
234
235
  {
236
+ $this->lookups[] = $companyId;
237
+
238
+ // Mirrors the real lookup returning null for an unknown public id.
239
+ if ($this->companyMiss) {
240
+ return null;
241
+ }
242
+
235
243
  $company = new Company();
236
244
  $company->setRawAttributes(['uuid' => 'company-uuid', 'public_id' => $companyId], true);
237
245
 
238
- $this->lookups[] = $companyId;
239
-
240
246
  return $company;
241
247
  }
242
248
 
249
+ protected function errorResponse(string $message, int $statusCode): JsonResponse
250
+ {
251
+ return new JsonResponse(['errors' => [$message]], $statusCode);
252
+ }
253
+
243
254
  protected function driverOnboardSetting(string $companyUuid): mixed
244
255
  {
245
256
  $this->lookups[] = $companyUuid;
@@ -412,6 +423,19 @@ test('public navigator controller returns configured or default driver onboardin
412
423
  ]);
413
424
  });
414
425
 
426
+ test('public navigator controller answers 404 for an organization public id that does not resolve', function () {
427
+ $controller = new FleetOpsPublicNavigatorControllerProbe();
428
+ $controller->companyMiss = true;
429
+
430
+ $response = $controller->getDriverOnboardSettings('company_does_not_exist');
431
+
432
+ // Regression: the unresolved company used to be dereferenced anyway, which threw a
433
+ // TypeError out of the controller and rendered an HTML stack trace with a 500.
434
+ expect($response->getStatusCode())->toBe(404)
435
+ ->and($response->getData(true))->toBe(['errors' => ['Organization not found.']])
436
+ ->and($controller->lookups)->toBe(['company_does_not_exist']);
437
+ });
438
+
415
439
  test('public contact controller normalizes create input and preserves update fields', function () {
416
440
  $controller = new FleetOpsContactControllerProbe();
417
441
 
@@ -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
+ });
@@ -0,0 +1,69 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Http\Filter\MaintenanceScheduleFilter;
4
+ use Fleetbase\FleetOps\Models\Vehicle;
5
+ use Fleetbase\Http\Filter\Filter;
6
+
7
+ class FleetOpsMaintenanceScheduleFilterQuery
8
+ {
9
+ public array $wheres = [];
10
+
11
+ public function where(...$arguments): self
12
+ {
13
+ $this->wheres[] = $arguments;
14
+
15
+ return $this;
16
+ }
17
+ }
18
+
19
+ function fleetopsMaintenanceScheduleFilter(FleetOpsMaintenanceScheduleFilterQuery $builder): MaintenanceScheduleFilter
20
+ {
21
+ $filter = (new ReflectionClass(MaintenanceScheduleFilter::class))->newInstanceWithoutConstructor();
22
+
23
+ foreach ([
24
+ 'builder' => $builder,
25
+ 'session' => new class {
26
+ public function get(string $key): ?string
27
+ {
28
+ return $key === 'company' ? 'company-uuid' : null;
29
+ }
30
+ },
31
+ ] as $property => $value) {
32
+ $reflection = new ReflectionProperty(Filter::class, $property);
33
+ $reflection->setAccessible(true);
34
+ $reflection->setValue($filter, $value);
35
+ }
36
+
37
+ return $filter;
38
+ }
39
+
40
+ test('maintenance schedule filter scopes companies and resolves subject aliases', function () {
41
+ $builder = new FleetOpsMaintenanceScheduleFilterQuery();
42
+ $filter = fleetopsMaintenanceScheduleFilter($builder);
43
+
44
+ $filter->queryForInternal();
45
+ $filter->queryForPublic();
46
+ $filter->subjectType('vehicle');
47
+ $filter->subjectType('fleet-ops:vehicle');
48
+ $filter->subjectType(Vehicle::class);
49
+ $filter->subjectUuid('vehicle-uuid');
50
+
51
+ expect($builder->wheres)->toBe([
52
+ ['company_uuid', 'company-uuid'],
53
+ ['company_uuid', 'company-uuid'],
54
+ ['subject_type', Vehicle::class],
55
+ ['subject_type', Vehicle::class],
56
+ ['subject_type', Vehicle::class],
57
+ ['subject_uuid', 'vehicle-uuid'],
58
+ ]);
59
+ });
60
+
61
+ test('maintenance schedule filter ignores empty subject filters', function () {
62
+ $builder = new FleetOpsMaintenanceScheduleFilterQuery();
63
+ $filter = fleetopsMaintenanceScheduleFilter($builder);
64
+
65
+ $filter->subjectType(null);
66
+ $filter->subjectUuid(null);
67
+
68
+ expect($builder->wheres)->toBe([]);
69
+ });
@@ -10,6 +10,7 @@ namespace Illuminate\Foundation\Http {
10
10
 
11
11
  namespace {
12
12
  use Fleetbase\FleetOps\Http\Requests\CreateFuelReportRequest;
13
+ use Fleetbase\FleetOps\Http\Requests\UpdateFuelReportRequest;
13
14
 
14
15
  class FleetOpsCreateFuelReportSessionStore
15
16
  {
@@ -39,6 +40,26 @@ namespace {
39
40
  return CreateFuelReportRequest::create('/fleet-ops/fuel-reports', 'POST');
40
41
  }
41
42
 
43
+ test('fuel report create only fields are required on POST and optional on update', function () {
44
+ FleetOpsCreateFuelReportRequestState::$session = new FleetOpsCreateFuelReportSessionStore(['api_credential' => 'api-credential-uuid']);
45
+
46
+ // UpdateFuelReportRequest extends this class, so a flat `required` made every
47
+ // partial update fail — PUT with just {"status": "approved"} answered "The driver
48
+ // field is required." `driver` is the plainest case: the update action's
49
+ // $request->only() list does not include it, so the field was demanded and then
50
+ // discarded.
51
+ $create = CreateFuelReportRequest::create('/fleet-ops/fuel-reports', 'POST')->rules();
52
+ $update = UpdateFuelReportRequest::create('/fleet-ops/fuel-reports/report_1', 'PUT')->rules();
53
+
54
+ foreach (['driver', 'odometer', 'volume'] as $field) {
55
+ expect($create[$field])->toBe(['required'])
56
+ ->and($update[$field])->toBe(['sometimes']);
57
+ }
58
+
59
+ expect($create['metric_unit'])->toBe(['nullable'])
60
+ ->and($create['amount'])->toBe(['nullable']);
61
+ });
62
+
42
63
  test('create fuel report authorization accepts api credentials or sanctum sessions', function () {
43
64
  expect(fleetopsCreateFuelReportRequestWithSession([])->authorize())->toBeFalse()
44
65
  ->and(fleetopsCreateFuelReportRequestWithSession(['api_credential' => 'api-credential-uuid'])->authorize())->toBeTrue()