@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.
- package/RELEASE.md +21 -17
- package/addon/components/ai/route-preview-map.js +1 -5
- package/addon/components/customer/orders.js +2 -1
- package/addon/components/driver/card.hbs +49 -0
- package/addon/components/driver/card.js +30 -0
- package/addon/components/map/leaflet-live-map.js +4 -19
- package/addon/components/modals/place-details.hbs +1 -1
- package/addon/components/modals/point-map.hbs +1 -1
- package/addon/components/orchestrator-workbench.js +2 -1
- package/addon/components/order/form/route.js +3 -0
- package/addon/components/order-tracking-lookup.js +2 -1
- package/addon/components/place/details.hbs +1 -1
- package/addon/components/positions-replay.js +4 -1
- package/addon/components/service-area/details.hbs +1 -1
- package/addon/components/widget/live-fleet.js +7 -7
- package/addon/components/zone/details.hbs +1 -1
- package/addon/controllers/management/drivers/index.js +22 -1
- package/addon/controllers/settings/map.js +16 -0
- package/addon/helpers/leaflet-tile-url.js +19 -0
- package/addon/services/map-settings.js +26 -1
- package/addon/templates/management/drivers/index.hbs +67 -33
- package/addon/templates/settings/map.hbs +10 -0
- package/addon/utils/leaflet-tile-url.js +31 -0
- package/addon/utils/prepare-place-for-save.js +13 -0
- package/app/components/driver/card.js +1 -0
- package/app/helpers/leaflet-tile-url.js +1 -0
- package/app/utils/leaflet-tile-url.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/index.js +11 -0
- package/package.json +2 -1
- package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
- package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
- package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
- package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +312 -59
- package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
- package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
- package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +139 -34
- package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
- package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
- package/server/src/Http/Filter/DriverFilter.php +71 -15
- package/server/src/Http/Filter/FleetFilter.php +32 -33
- package/server/src/Http/Filter/FuelReportFilter.php +29 -0
- package/server/src/Http/Filter/IssueFilter.php +29 -0
- package/server/src/Http/Filter/VehicleFilter.php +57 -19
- package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
- package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
- package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
- package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
- package/server/src/Http/Requests/CreateVehicleRequest.php +150 -26
- package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
- package/server/src/Http/Resources/v1/Driver.php +22 -2
- package/server/src/Http/Resources/v1/Fleet.php +38 -25
- package/server/src/Http/Resources/v1/Manifest.php +52 -0
- package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
- package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
- package/server/src/Http/Resources/v1/Vehicle.php +46 -4
- package/server/src/Models/Driver.php +2 -2
- package/server/src/Models/Place.php +1 -1
- package/server/src/routes.php +29 -0
- package/server/tests/ApiDriverControllerContractsTest.php +317 -13
- package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
- package/server/tests/ApiEntityControllerContractsTest.php +1 -1
- package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
- package/server/tests/ApiFleetControllerContractsTest.php +354 -21
- package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
- package/server/tests/ApiIssueControllerContractsTest.php +1 -1
- package/server/tests/ApiManifestControllerContractsTest.php +599 -0
- package/server/tests/ApiOrderControllerContractsTest.php +1 -1
- package/server/tests/ApiPartControllerContractsTest.php +2 -2
- package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
- package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
- package/server/tests/ApiVehicleControllerContractsTest.php +210 -8
- package/server/tests/CompactResourceSerializationTest.php +13 -7
- package/server/tests/ControllerFilterContractsTest.php +132 -13
- package/server/tests/ControllerHelperContractsTest.php +6 -3
- package/server/tests/DriverFilterExecutionTest.php +130 -6
- package/server/tests/ExceptionContractsTest.php +15 -0
- package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
- package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
- package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
- package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
- package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
- package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
- package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
- package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
- package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
- package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
- package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
- package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
- package/server/tests/FleetHierarchyResourceTest.php +56 -8
- package/server/tests/RequestContractsTest.php +105 -7
- package/server/tests/SettingControllerContractsTest.php +33 -0
- package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
- package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
- package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
- package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
- package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
- package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
- 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'])->
|
|
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
|
-
|
|
853
|
-
|
|
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'])->
|
|
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'])->
|
|
860
|
-
->and($createRules['job'])->
|
|
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
|
|
88
|
-
$
|
|
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('
|
|
94
|
-
->and($
|
|
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
|
+
});
|