@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.
- package/RELEASE.md +25 -0
- 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/vehicle/details/schedules.hbs +2 -2
- package/addon/components/vehicle/details/schedules.js +7 -1
- package/addon/components/widget/live-fleet.js +7 -7
- package/addon/components/zone/details.hbs +1 -1
- package/addon/controllers/maintenance/schedules/index/edit.js +10 -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 +22 -0
- package/addon/services/vehicle-actions.js +4 -2
- 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/config/fleetops.php +44 -0
- package/server/config/geocoder.php +1 -1
- package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
- package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
- package/server/src/Http/Controllers/Api/v1/DriverController.php +277 -5
- package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
- package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
- package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
- package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
- package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
- package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
- package/server/src/Http/Filter/FuelReportFilter.php +29 -0
- package/server/src/Http/Filter/IssueFilter.php +29 -0
- package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
- package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
- package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
- package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
- package/server/src/Http/Requests/CreatePartRequest.php +39 -1
- package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
- 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/TrackingNumber.php +33 -0
- package/server/src/Models/FuelReport.php +21 -0
- package/server/src/Models/Place.php +1 -1
- package/server/src/Models/Sensor.php +24 -0
- package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
- package/server/src/routes.php +22 -1
- package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
- package/server/tests/ApiDriverControllerContractsTest.php +65 -0
- package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
- package/server/tests/ApiManifestControllerContractsTest.php +599 -0
- package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
- package/server/tests/CompactResourceSerializationTest.php +99 -0
- package/server/tests/CustomerEndpointTest.php +4 -1
- package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
- package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
- package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
- package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
- package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
- package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
- package/server/tests/RequestContractsTest.php +102 -5
- package/server/tests/SettingControllerContractsTest.php +33 -0
- package/server/tests/SmallControllerContractsTest.php +28 -4
- 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/Filter/MaintenanceScheduleFilterTest.php +69 -0
- package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
- package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
- package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
- package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
- package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
- 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
|
-
|
|
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|
|
|
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
|
-
|
|
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'])->
|
|
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
|
|
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()
|