@fleetbase/fleetops-engine 0.6.61 → 0.6.63
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 +5 -14
- package/addon/controllers/management/drivers/index.js +4 -1
- package/addon/routes/management/fleets/index/details.js +1 -1
- package/addon/routes/management/fleets/index/edit.js +1 -1
- package/addon/routes/management/fleets/index.js +3 -1
- package/addon/services/map-settings.js +4 -1
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +1 -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 +93 -57
- 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/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 +134 -35
- package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
- 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/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 -31
- 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/Vehicle.php +46 -4
- package/server/src/Models/Driver.php +2 -2
- package/server/src/routes.php +8 -0
- package/server/tests/ApiDriverControllerContractsTest.php +284 -13
- 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/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 +175 -8
- 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/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/FleetHierarchyResourceTest.php +56 -8
- package/server/tests/RequestContractsTest.php +100 -7
- package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
- package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
|
@@ -5,6 +5,9 @@ use Fleetbase\FleetOps\Http\Requests\CreateVehicleRequest;
|
|
|
5
5
|
use Fleetbase\FleetOps\Http\Requests\UpdateVehicleRequest;
|
|
6
6
|
use Fleetbase\FleetOps\Models\Driver;
|
|
7
7
|
use Fleetbase\FleetOps\Models\Vehicle;
|
|
8
|
+
use Fleetbase\FleetOps\Models\Vendor;
|
|
9
|
+
use Fleetbase\FleetOps\Models\Warranty;
|
|
10
|
+
use Fleetbase\Models\Category;
|
|
8
11
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
9
12
|
use Illuminate\Http\Request;
|
|
10
13
|
|
|
@@ -13,7 +16,8 @@ class FleetOpsApiVehicleCrudControllerProbe extends VehicleController
|
|
|
13
16
|
public ?FleetOpsApiVehicleCrudFake $vehicle = null;
|
|
14
17
|
public ?FleetOpsApiDriverCrudFake $driver = null;
|
|
15
18
|
public array $createdVehicles = [];
|
|
16
|
-
public array $
|
|
19
|
+
public array $relationLookups = [];
|
|
20
|
+
public array $unresolvable = [];
|
|
17
21
|
public mixed $queryResults = null;
|
|
18
22
|
public bool $vehicleNotFound = false;
|
|
19
23
|
public bool $driverNotFound = false;
|
|
@@ -23,11 +27,26 @@ class FleetOpsApiVehicleCrudControllerProbe extends VehicleController
|
|
|
23
27
|
return $this->vehicleInputFromRequest($request);
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Stand in for the company-scoped public-id lookup.
|
|
32
|
+
*
|
|
33
|
+
* Anything listed in `$unresolvable` behaves as a cross-company or missing
|
|
34
|
+
* identifier does in production: the lookup finds nothing inside the
|
|
35
|
+
* caller's company and raises.
|
|
36
|
+
*/
|
|
37
|
+
protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
|
|
27
38
|
{
|
|
28
|
-
|
|
39
|
+
if (empty($id)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
$this->relationLookups[] = [$modelClass, $id];
|
|
44
|
+
|
|
45
|
+
if (in_array($id, $this->unresolvable, true)) {
|
|
46
|
+
throw (new ModelNotFoundException())->setModel($modelClass, $id);
|
|
47
|
+
}
|
|
29
48
|
|
|
30
|
-
return '
|
|
49
|
+
return strtolower(class_basename($modelClass)) . '-uuid';
|
|
31
50
|
}
|
|
32
51
|
|
|
33
52
|
protected function createVehicle(array $input): Vehicle
|
|
@@ -51,7 +70,7 @@ class FleetOpsApiVehicleCrudControllerProbe extends VehicleController
|
|
|
51
70
|
return $this->vehicle;
|
|
52
71
|
}
|
|
53
72
|
|
|
54
|
-
protected function findDriver(string $id): Driver
|
|
73
|
+
protected function findDriver(?string $id): Driver
|
|
55
74
|
{
|
|
56
75
|
if ($this->driverNotFound) {
|
|
57
76
|
throw new ModelNotFoundException();
|
|
@@ -240,8 +259,8 @@ test('api vehicle controller creates vehicles with defaults relation lookups and
|
|
|
240
259
|
$vehicle = $response['vehicle'];
|
|
241
260
|
|
|
242
261
|
expect($response['resource'])->toBe('vehicle')
|
|
243
|
-
->and($controller->
|
|
244
|
-
[
|
|
262
|
+
->and($controller->relationLookups)->toBe([
|
|
263
|
+
[Vendor::class, 'vendor-public'],
|
|
245
264
|
])
|
|
246
265
|
->and($controller->createdVehicles[0])->toMatchArray([
|
|
247
266
|
'status' => 'active',
|
|
@@ -292,9 +311,11 @@ test('api vehicle controller updates queries finds deletes and tracks empty coor
|
|
|
292
311
|
'make' => 'Nissan',
|
|
293
312
|
'model' => 'NV350',
|
|
294
313
|
'vin' => 'NEWVIN',
|
|
295
|
-
'online' => 0,
|
|
296
314
|
'vendor_uuid' => 'vendor-uuid',
|
|
297
315
|
])
|
|
316
|
+
// A partial update must not carry an unrequested `online` default: the
|
|
317
|
+
// absent key means "leave it alone", not "take the vehicle offline".
|
|
318
|
+
->and($filledInput)->not->toHaveKey('online')
|
|
298
319
|
->and($filledInput)->toHaveKey('location')
|
|
299
320
|
->and($vehicle->unassignedDriver)->toBeTrue()
|
|
300
321
|
->and($vehicle->savedForTest)->toBeTrue()
|
|
@@ -401,3 +422,149 @@ test('api vehicle controller input still drops fields that are not part of the c
|
|
|
401
422
|
->and($input)->not->toHaveKey('company_uuid')
|
|
402
423
|
->and($input)->not->toHaveKey('uuid');
|
|
403
424
|
});
|
|
425
|
+
|
|
426
|
+
test('api vehicle controller accepts every safe vehicle field the model exposes', function () {
|
|
427
|
+
// Data-driven rather than one test per column: the point is parity between
|
|
428
|
+
// what the model can store and what the public contract will accept, so the
|
|
429
|
+
// assertion is over the whole set.
|
|
430
|
+
session(['company' => 'company-uuid']);
|
|
431
|
+
|
|
432
|
+
$payload = [
|
|
433
|
+
// Identity and description
|
|
434
|
+
'internal_id' => 'VEH-9001', 'name' => 'Depot Van', 'description' => 'City route van',
|
|
435
|
+
'make' => 'Ford', 'model' => 'Transit', 'model_type' => 'Custom', 'year' => 2024,
|
|
436
|
+
'trim' => 'Trend', 'color' => 'White', 'type' => 'van', 'class' => 'N1',
|
|
437
|
+
'plate_number' => 'SG-9001', 'vin' => '1FTBW3XG8NKA00001', 'serial_number' => 'SER-9001',
|
|
438
|
+
'call_sign' => 'DEPOT-1', 'fuel_card_number' => 'FC-9001',
|
|
439
|
+
// Measurement and operation
|
|
440
|
+
'odometer' => 41000, 'odometer_unit' => 'km', 'odometer_at_purchase' => 12,
|
|
441
|
+
'measurement_system' => 'metric', 'fuel_type' => 'diesel', 'fuel_volume_unit' => 'l',
|
|
442
|
+
'online' => true, 'status' => 'available',
|
|
443
|
+
// Body, capacity and dimensions
|
|
444
|
+
'transmission' => 'automatic', 'body_type' => 'panel_van', 'body_sub_type' => 'lwb',
|
|
445
|
+
'usage_type' => 'commercial', 'ownership_type' => 'owned', 'cargo_volume' => 11.5,
|
|
446
|
+
'passenger_volume' => 3.2, 'interior_volume' => 14.7, 'weight' => 2100.5, 'width' => 2.06,
|
|
447
|
+
'length' => 5.98, 'height' => 2.54, 'towing_capacity' => 2500, 'payload_capacity' => 1400,
|
|
448
|
+
'seating_capacity' => 3, 'ground_clearance' => 0.18, 'bed_length' => 3.4, 'fuel_capacity' => 70,
|
|
449
|
+
// Lifecycle and financing
|
|
450
|
+
'financing_status' => 'financed', 'loan_number_of_payments' => 48,
|
|
451
|
+
'loan_first_payment' => '2026-01-15', 'loan_amount' => 32000, 'currency' => 'SGD',
|
|
452
|
+
'estimated_service_life_distance_unit' => 'km', 'estimated_service_life_distance' => 400000,
|
|
453
|
+
'estimated_service_life_months' => 96, 'insurance_value' => 41000, 'depreciation_rate' => 12.5,
|
|
454
|
+
'current_value' => 38000, 'acquisition_cost' => 52000,
|
|
455
|
+
'purchased_at' => '2026-01-02', 'lease_expires_at' => '2029-01-02',
|
|
456
|
+
// Regulatory and engine specifications
|
|
457
|
+
'emission_standard' => 'euro6', 'dpf_equipped' => true, 'scr_equipped' => false,
|
|
458
|
+
'gvwr' => 3500, 'gcwr' => 6000, 'engine_number' => 'ENG-9001', 'engine_model' => 'EcoBlue',
|
|
459
|
+
'engine_make' => 'Ford', 'engine_family' => 'Puma', 'engine_configuration' => 'inline',
|
|
460
|
+
'engine_displacement' => 2.0, 'engine_size' => 1995, 'horsepower' => 168,
|
|
461
|
+
'horsepower_rpm' => 3500, 'torque' => 405, 'torque_rpm' => 1750,
|
|
462
|
+
'number_of_cylinders' => 4, 'cylinder_arrangement' => 'I4',
|
|
463
|
+
// Structured and descriptive
|
|
464
|
+
'specs' => ['doors' => 4], 'details' => ['liftgate' => true], 'notes' => 'City pool',
|
|
465
|
+
'meta' => ['depot' => 'north'],
|
|
466
|
+
// Orchestrator
|
|
467
|
+
'skills' => ['tail_lift'], 'payload_capacity_volume' => 11.25,
|
|
468
|
+
'payload_capacity_pallets' => 6, 'payload_capacity_parcels' => 320, 'max_tasks' => 40,
|
|
469
|
+
'time_window_start' => '08:00', 'time_window_end' => '18:00', 'return_to_depot' => true,
|
|
470
|
+
];
|
|
471
|
+
|
|
472
|
+
$controller = new FleetOpsApiVehicleCrudControllerProbe();
|
|
473
|
+
$input = $controller->inputForTest(new Request($payload));
|
|
474
|
+
|
|
475
|
+
$missing = array_values(array_diff(array_keys($payload), array_keys($input)));
|
|
476
|
+
|
|
477
|
+
expect($missing)->toBe([])
|
|
478
|
+
->and($input)->toMatchArray($payload);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('api vehicle controller resolves every public relationship input to a scoped uuid', function () {
|
|
482
|
+
session(['company' => 'company-uuid']);
|
|
483
|
+
|
|
484
|
+
$controller = new FleetOpsApiVehicleCrudControllerProbe();
|
|
485
|
+
$input = $controller->inputForTest(new Request([
|
|
486
|
+
'vendor' => 'vendor_abc',
|
|
487
|
+
'category' => 'category_abc',
|
|
488
|
+
'warranty' => 'warranty_abc',
|
|
489
|
+
'photo' => 'file_abc',
|
|
490
|
+
]));
|
|
491
|
+
|
|
492
|
+
expect($input)->toMatchArray([
|
|
493
|
+
'vendor_uuid' => 'vendor-uuid',
|
|
494
|
+
'category_uuid' => 'category-uuid',
|
|
495
|
+
'warranty_uuid' => 'warranty-uuid',
|
|
496
|
+
'photo_uuid' => 'file-uuid',
|
|
497
|
+
])
|
|
498
|
+
->and($controller->relationLookups)->toBe([
|
|
499
|
+
[Vendor::class, 'vendor_abc'],
|
|
500
|
+
[Category::class, 'category_abc'],
|
|
501
|
+
[Warranty::class, 'warranty_abc'],
|
|
502
|
+
[Fleetbase\Models\File::class, 'file_abc'],
|
|
503
|
+
]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('api vehicle controller clears a relationship when the input is sent empty', function () {
|
|
507
|
+
session(['company' => 'company-uuid']);
|
|
508
|
+
|
|
509
|
+
$controller = new FleetOpsApiVehicleCrudControllerProbe();
|
|
510
|
+
$input = $controller->inputForTest(fleetopsUpdateVehicleRequest(['vendor' => null]));
|
|
511
|
+
|
|
512
|
+
expect($input)->toHaveKey('vendor_uuid')
|
|
513
|
+
->and($input['vendor_uuid'])->toBeNull()
|
|
514
|
+
->and($controller->relationLookups)->toBe([]);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test('api vehicle controller rejects a relationship that belongs to another company', function () {
|
|
518
|
+
session(['company' => 'company-uuid']);
|
|
519
|
+
|
|
520
|
+
$controller = new FleetOpsApiVehicleCrudControllerProbe();
|
|
521
|
+
$controller->unresolvable = ['vendor_other_company'];
|
|
522
|
+
|
|
523
|
+
$created = $controller->create(fleetopsCreateVehicleRequest([
|
|
524
|
+
'make' => 'Ford',
|
|
525
|
+
'vendor' => 'vendor_other_company',
|
|
526
|
+
]));
|
|
527
|
+
|
|
528
|
+
$controller = new FleetOpsApiVehicleCrudControllerProbe();
|
|
529
|
+
$controller->vehicle = new FleetOpsApiVehicleCrudFake();
|
|
530
|
+
$controller->unresolvable = ['vendor_other_company'];
|
|
531
|
+
|
|
532
|
+
$updated = $controller->update('vehicle-public', fleetopsUpdateVehicleRequest([
|
|
533
|
+
'vendor' => 'vendor_other_company',
|
|
534
|
+
]));
|
|
535
|
+
|
|
536
|
+
// A cross-company identifier is answered exactly as a missing one, so the
|
|
537
|
+
// response cannot be used to discover what another organization holds.
|
|
538
|
+
expect($created)->toBe([
|
|
539
|
+
'json' => ['error' => 'No vendor resource found for the identifier provided.'],
|
|
540
|
+
'status' => 404,
|
|
541
|
+
])->and($updated)->toBe([
|
|
542
|
+
'json' => ['error' => 'No vendor resource found for the identifier provided.'],
|
|
543
|
+
'status' => 404,
|
|
544
|
+
]);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test('api vehicle controller input still excludes server managed and internal columns', function () {
|
|
548
|
+
$controller = new FleetOpsApiVehicleCrudControllerProbe();
|
|
549
|
+
|
|
550
|
+
$input = $controller->inputForTest(new Request([
|
|
551
|
+
'make' => 'Ford',
|
|
552
|
+
'company_uuid' => 'someone-elses-company',
|
|
553
|
+
'uuid' => 'forged',
|
|
554
|
+
'_key' => 'forged',
|
|
555
|
+
'public_id' => 'vehicle_forged',
|
|
556
|
+
'slug' => 'forged',
|
|
557
|
+
'vendor_uuid' => 'forged',
|
|
558
|
+
'category_uuid' => 'forged',
|
|
559
|
+
'warranty_uuid' => 'forged',
|
|
560
|
+
'photo_uuid' => 'forged',
|
|
561
|
+
'telematic_uuid' => 'forged',
|
|
562
|
+
// Written by the VIN decoder and by telematics ingestion respectively;
|
|
563
|
+
// a caller must not be able to overwrite either.
|
|
564
|
+
'vin_data' => ['manufacturer' => 'forged'],
|
|
565
|
+
'telematics' => ['speed' => 999],
|
|
566
|
+
'avatar_url' => 'forged',
|
|
567
|
+
]));
|
|
568
|
+
|
|
569
|
+
expect(array_keys($input))->toBe(['make']);
|
|
570
|
+
});
|
|
@@ -272,6 +272,91 @@ function fleetopsProtectedMethod(string $class, string $method): ReflectionMetho
|
|
|
272
272
|
return $reflection;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Boot an in-memory database holding one row per relationship a public filter
|
|
277
|
+
* can be asked about.
|
|
278
|
+
*
|
|
279
|
+
* Relationship filters no longer compare a caller-supplied value against a uuid
|
|
280
|
+
* column: the public API deals in public ids, so the filter resolves the id to
|
|
281
|
+
* the uuids it stands for first. That resolution is a real query, so these
|
|
282
|
+
* assertions need a real connection.
|
|
283
|
+
*
|
|
284
|
+
* @return array<string, string> uuids keyed by table
|
|
285
|
+
*/
|
|
286
|
+
function fleetopsFilterRelationDatabase(): array
|
|
287
|
+
{
|
|
288
|
+
$connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
|
|
289
|
+
$resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
|
|
290
|
+
$resolver->setDefaultConnection('mysql');
|
|
291
|
+
Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
|
|
292
|
+
|
|
293
|
+
app()->instance('db', new class($connection) {
|
|
294
|
+
public function __construct(public Illuminate\Database\SQLiteConnection $c)
|
|
295
|
+
{
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
public function connection($name = null): Illuminate\Database\SQLiteConnection
|
|
299
|
+
{
|
|
300
|
+
return $this->c;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
public function __call($method, $arguments)
|
|
304
|
+
{
|
|
305
|
+
return $this->c->{$method}(...$arguments);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
Illuminate\Support\Facades\DB::clearResolvedInstance('db');
|
|
309
|
+
|
|
310
|
+
$schema = $connection->getSchemaBuilder();
|
|
311
|
+
foreach (['fleets', 'vendors', 'zones', 'service_areas', 'drivers', 'vehicles', 'users'] as $table) {
|
|
312
|
+
if ($schema->hasTable($table)) {
|
|
313
|
+
$schema->drop($table);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
$schema->create($table, function ($blueprint) {
|
|
317
|
+
$blueprint->increments('id');
|
|
318
|
+
foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'name', '_key'] as $column) {
|
|
319
|
+
$blueprint->string($column)->nullable();
|
|
320
|
+
}
|
|
321
|
+
$blueprint->timestamps();
|
|
322
|
+
$blueprint->timestamp('deleted_at')->nullable();
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
$uuids = [
|
|
327
|
+
'fleets' => '99999999-9999-4999-8999-999999999001',
|
|
328
|
+
'vendors' => '99999999-9999-4999-8999-999999999002',
|
|
329
|
+
'zones' => '99999999-9999-4999-8999-999999999003',
|
|
330
|
+
'service_areas' => '99999999-9999-4999-8999-999999999004',
|
|
331
|
+
'drivers' => '99999999-9999-4999-8999-999999999005',
|
|
332
|
+
'vehicles' => '99999999-9999-4999-8999-999999999006',
|
|
333
|
+
'users' => '99999999-9999-4999-8999-999999999007',
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
$publicIds = [
|
|
337
|
+
'fleets' => 'fleet_filterone12',
|
|
338
|
+
'vendors' => 'vendor_filterone1',
|
|
339
|
+
'zones' => 'zone_filterone123',
|
|
340
|
+
'service_areas' => 'service_area_fone',
|
|
341
|
+
'drivers' => 'driver_filterone1',
|
|
342
|
+
'vehicles' => 'vehicle_filterone',
|
|
343
|
+
'users' => 'user_filterone123',
|
|
344
|
+
];
|
|
345
|
+
|
|
346
|
+
foreach ($uuids as $table => $uuid) {
|
|
347
|
+
$connection->table($table)->insert([
|
|
348
|
+
'uuid' => $uuid,
|
|
349
|
+
'public_id' => $publicIds[$table],
|
|
350
|
+
'company_uuid' => 'company-uuid',
|
|
351
|
+
]);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// The driver scope requires a linked user row to exist.
|
|
355
|
+
$connection->table('drivers')->where('uuid', $uuids['drivers'])->update(['user_uuid' => $uuids['users']]);
|
|
356
|
+
|
|
357
|
+
return ['uuids' => $uuids, 'publicIds' => $publicIds];
|
|
358
|
+
}
|
|
359
|
+
|
|
275
360
|
function fleetopsFilterWithBuilder(string $class, FleetOpsControllerFilterQuery $builder): object
|
|
276
361
|
{
|
|
277
362
|
$filter = (new ReflectionClass($class))->newInstanceWithoutConstructor();
|
|
@@ -456,6 +541,8 @@ test('device event controller skips processed scope when states are empty', func
|
|
|
456
541
|
});
|
|
457
542
|
|
|
458
543
|
test('vehicle filter records identity relationship fleet and telematic filters', function () {
|
|
544
|
+
['uuids' => $uuids, 'publicIds' => $publicIds] = fleetopsFilterRelationDatabase();
|
|
545
|
+
|
|
459
546
|
$query = new FleetOpsControllerFilterQuery();
|
|
460
547
|
$filter = fleetopsFilterWithBuilder(VehicleFilter::class, $query);
|
|
461
548
|
|
|
@@ -465,14 +552,17 @@ test('vehicle filter records identity relationship fleet and telematic filters',
|
|
|
465
552
|
$filter->display_name('sprinter');
|
|
466
553
|
$filter->vin('vin-123');
|
|
467
554
|
$filter->publicId('vehicle-public');
|
|
555
|
+
$filter->internalId('VEH-42');
|
|
468
556
|
$filter->plateNumber('ABC-123');
|
|
469
557
|
$filter->vehicleMake('Mercedes');
|
|
470
558
|
$filter->vehicleModel('Sprinter');
|
|
471
559
|
$filter->vehicleYear('2026');
|
|
472
560
|
$filter->driver('unassigned');
|
|
561
|
+
$filter->driver($publicIds['drivers']);
|
|
473
562
|
$filter->vendor(null);
|
|
563
|
+
$filter->vendor($publicIds['vendors']);
|
|
474
564
|
$filter->driverUuid('driver-uuid');
|
|
475
|
-
$filter->fleet('
|
|
565
|
+
$filter->fleet($publicIds['fleets']);
|
|
476
566
|
$filter->assignedFleet('false');
|
|
477
567
|
$filter->telematicUuid('telematic-uuid');
|
|
478
568
|
$filter->createdAt(['2026-01-01', '2026-01-31']);
|
|
@@ -482,20 +572,32 @@ test('vehicle filter records identity relationship fleet and telematic filters',
|
|
|
482
572
|
->and($query->calls)->toContain(['search', 'van'])
|
|
483
573
|
->and($query->calls)->toContain(['searchWhere', ['year', 'make', 'model', 'plate_number'], 'sprinter'])
|
|
484
574
|
->and($query->calls)->toContain(['searchWhere', 'vin', 'vin-123'])
|
|
485
|
-
|
|
575
|
+
// A public id names exactly one record, so a public lookup is exact.
|
|
576
|
+
->and($query->calls)->toContain(['where', ['public_id', '=', 'vehicle-public']])
|
|
577
|
+
// Exact, not partial: an importer asking whether VEH-42 exists must not
|
|
578
|
+
// be told yes because VEH-420 does.
|
|
579
|
+
->and($query->calls)->toContain(['where', ['internal_id', '=', 'VEH-42']])
|
|
486
580
|
->and($query->calls)->toContain(['searchWhere', 'plate_number', 'ABC-123'])
|
|
487
581
|
->and($query->calls)->toContain(['searchWhere', 'make', 'Mercedes'])
|
|
488
582
|
->and($query->calls)->toContain(['searchWhere', 'model', 'Sprinter'])
|
|
489
583
|
->and($query->calls)->toContain(['searchWhere', 'year', '2026'])
|
|
490
584
|
->and($query->calls)->toContain(['whereDoesntHave', 'driver'])
|
|
491
|
-
->and($query->calls)->toContain(['whereDoesntHave', 'fleets'])
|
|
585
|
+
->and($query->calls)->toContain(['whereDoesntHave', 'fleets'])
|
|
586
|
+
// Public ids are resolved to the uuids the columns actually hold.
|
|
587
|
+
->and($query->calls)->toContain(['whereIn', 'vendor_uuid', [$uuids['vendors']]]);
|
|
588
|
+
|
|
589
|
+
$nested = collect($query->calls)->where(0, 'whereHas')->flatMap(fn ($call) => $call[2])->values()->all();
|
|
492
590
|
|
|
493
591
|
expect(collect($query->calls)->where(0, 'whereHas')->pluck(1)->all())->toContain('driver', 'fleets', 'devices')
|
|
592
|
+
->and($nested)->toContain(['whereIn', 'uuid', [$uuids['drivers']]])
|
|
593
|
+
->and($nested)->toContain(['whereIn', 'fleet_uuid', [$uuids['fleets']]])
|
|
494
594
|
->and(collect($query->calls)->where(0, 'whereBetween')->values())->toHaveCount(1)
|
|
495
595
|
->and(collect($query->calls)->where(0, 'whereDate')->values())->toHaveCount(1);
|
|
496
596
|
});
|
|
497
597
|
|
|
498
598
|
test('fleet filter records hierarchy relationship scalar status and date filters', function () {
|
|
599
|
+
['uuids' => $uuids, 'publicIds' => $publicIds] = fleetopsFilterRelationDatabase();
|
|
600
|
+
|
|
499
601
|
$query = new FleetOpsControllerFilterQuery();
|
|
500
602
|
$filter = fleetopsFilterWithBuilder(FleetFilter::class, $query);
|
|
501
603
|
|
|
@@ -504,10 +606,12 @@ test('fleet filter records hierarchy relationship scalar status and date filters
|
|
|
504
606
|
$filter->query('dispatch');
|
|
505
607
|
$filter->parentsOnly(true);
|
|
506
608
|
$filter->parentsOnly(false);
|
|
507
|
-
$filter->serviceArea('
|
|
508
|
-
$filter->zone('
|
|
509
|
-
$filter->parentFleet('
|
|
510
|
-
$filter->vendor('
|
|
609
|
+
$filter->serviceArea($publicIds['service_areas']);
|
|
610
|
+
$filter->zone($publicIds['zones']);
|
|
611
|
+
$filter->parentFleet($publicIds['fleets']);
|
|
612
|
+
$filter->vendor($publicIds['vendors']);
|
|
613
|
+
// A blank identifier resolves to nothing without reaching the database.
|
|
614
|
+
$filter->vendor('');
|
|
511
615
|
$filter->publicId('fleet-public');
|
|
512
616
|
$filter->task('delivery');
|
|
513
617
|
$filter->name('North Fleet');
|
|
@@ -515,16 +619,27 @@ test('fleet filter records hierarchy relationship scalar status and date filters
|
|
|
515
619
|
$filter->createdAt('2026-01-01');
|
|
516
620
|
$filter->updatedAt(['2026-02-01', '2026-02-28']);
|
|
517
621
|
|
|
622
|
+
$nested = collect($query->calls)->where(0, 'where')->pluck(1)->all();
|
|
623
|
+
|
|
518
624
|
expect($query->calls)->toContain(['where', ['company_uuid', 'company-uuid']])
|
|
519
625
|
->and($query->calls)->toContain(['with', ['serviceArea', 'zone']])
|
|
520
626
|
->and($query->calls)->toContain(['whereNull', 'parent_fleet_uuid'])
|
|
521
|
-
->and($query->calls)->toContain(['
|
|
522
|
-
->and($query->calls)->toContain(['searchWhere', 'public_id', 'fleet-public'])
|
|
627
|
+
->and($query->calls)->toContain(['where', ['public_id', '=', 'fleet-public']])
|
|
523
628
|
->and($query->calls)->toContain(['searchWhere', 'task', 'delivery'])
|
|
524
629
|
->and($query->calls)->toContain(['searchWhere', 'name', 'North Fleet'])
|
|
525
|
-
->and($query->calls)->toContain(['whereIn', 'status', ['active', 'inactive']])
|
|
526
|
-
|
|
527
|
-
|
|
630
|
+
->and($query->calls)->toContain(['whereIn', 'status', ['active', 'inactive']])
|
|
631
|
+
// Every hierarchy and relationship filter now resolves a public id to
|
|
632
|
+
// the uuid column it stands for, rather than comparing the public id
|
|
633
|
+
// against a uuid — which never matched — or against a `zone_uuid`
|
|
634
|
+
// column that does not exist on `zones`.
|
|
635
|
+
->and($query->calls)->toContain(['whereIn', 'service_area_uuid', [$uuids['service_areas']]])
|
|
636
|
+
->and($query->calls)->toContain(['whereIn', 'zone_uuid', [$uuids['zones']]])
|
|
637
|
+
->and($query->calls)->toContain(['whereIn', 'parent_fleet_uuid', [$uuids['fleets']]])
|
|
638
|
+
->and($query->calls)->toContain(['whereIn', 'vendor_uuid', [$uuids['vendors']]])
|
|
639
|
+
->and($query->calls)->toContain(['whereIn', 'vendor_uuid', []])
|
|
640
|
+
// `?query=` searches the fleet's own columns; it used to reach for a
|
|
641
|
+
// `user` relation Fleet does not have.
|
|
642
|
+
->and(collect($query->calls)->where(0, 'whereHas')->pluck(1)->all())->not->toContain('user')
|
|
528
643
|
->and(collect($query->calls)->where(0, 'whereDate')->values())->toHaveCount(1)
|
|
529
644
|
->and(collect($query->calls)->where(0, 'whereBetween')->values())->toHaveCount(1);
|
|
530
645
|
});
|
|
@@ -1224,9 +1339,13 @@ test('place filter alternate date branches and spatial area scopes execute', fun
|
|
|
1224
1339
|
test('vehicle and fuel report filters cover alternate identity and date branches', function () {
|
|
1225
1340
|
// Vehicle filter: unassigned drivers, blank vendor early return, inverse date
|
|
1226
1341
|
// branches, fleet unassignment and blank telematic early return
|
|
1342
|
+
fleetopsFilterRelationDatabase();
|
|
1343
|
+
|
|
1227
1344
|
$query = new FleetOpsControllerFilterQuery();
|
|
1228
1345
|
$filter = fleetopsFilterWithBuilder(VehicleFilter::class, $query);
|
|
1229
1346
|
$filter->driver('unassigned');
|
|
1347
|
+
// A public request may not pass a uuid, so nothing resolves and the filter
|
|
1348
|
+
// matches no vehicle rather than silently matching all of them.
|
|
1230
1349
|
$filter->driver('c4c4c4c4-4444-4444-8444-444444444444');
|
|
1231
1350
|
$filter->vendor(null);
|
|
1232
1351
|
$filter->createdAt('2026-02-01');
|
|
@@ -1240,7 +1359,7 @@ test('vehicle and fuel report filters cover alternate identity and date branches
|
|
|
1240
1359
|
->and($query->calls)->toContain(['whereDoesntHave', 'fleets'])
|
|
1241
1360
|
->and(collect($query->calls)->where(0, 'whereDate')->values())->toHaveCount(1)
|
|
1242
1361
|
->and(collect($query->calls)->where(0, 'whereBetween')->values())->toHaveCount(1)
|
|
1243
|
-
->and($nestedVehicle)->toContain(['
|
|
1362
|
+
->and($nestedVehicle)->toContain(['whereIn', 'uuid', []]);
|
|
1244
1363
|
|
|
1245
1364
|
// Fuel report filter: uuid, public-id and free-text identity branches
|
|
1246
1365
|
// plus the inverse date branches
|
|
@@ -1093,7 +1093,9 @@ test('api vehicle controller exposes input defaults and tracking payload helpers
|
|
|
1093
1093
|
'time_window_start' => '08:00',
|
|
1094
1094
|
'time_window_end' => '17:00',
|
|
1095
1095
|
'return_to_depot' => true,
|
|
1096
|
-
|
|
1096
|
+
// `vendor` is deliberately absent: it is no longer a scalar in this
|
|
1097
|
+
// projection but a public id resolved against the caller's company,
|
|
1098
|
+
// which is asserted in ApiVehicleControllerContractsTest.
|
|
1097
1099
|
'driver' => 'driver-public',
|
|
1098
1100
|
]);
|
|
1099
1101
|
$locationInput = $controller->callHelper('withCoordinateLocation', [], new Request([
|
|
@@ -1102,8 +1104,8 @@ test('api vehicle controller exposes input defaults and tracking payload helpers
|
|
|
1102
1104
|
]));
|
|
1103
1105
|
$tracking = $controller->callHelper('positionDataFromTrackingInput', 1.2816, 103.851, 12, 180, 55);
|
|
1104
1106
|
|
|
1107
|
+
// Key order follows the allowlist, not the request body.
|
|
1105
1108
|
expect($controller->callHelper('vehicleInputFromRequest', $request))->toBe([
|
|
1106
|
-
'status' => 'active',
|
|
1107
1109
|
'make' => 'Toyota',
|
|
1108
1110
|
'model' => 'HiAce',
|
|
1109
1111
|
'year' => 2025,
|
|
@@ -1111,13 +1113,14 @@ test('api vehicle controller exposes input defaults and tracking payload helpers
|
|
|
1111
1113
|
'type' => 'van',
|
|
1112
1114
|
'plate_number' => 'SG-1234',
|
|
1113
1115
|
'vin' => 'VIN123',
|
|
1114
|
-
'meta' => ['temperature' => 'ambient'],
|
|
1115
1116
|
'online' => false,
|
|
1117
|
+
'status' => 'active',
|
|
1116
1118
|
'location' => ['latitude' => 1.30, 'longitude' => 103.80],
|
|
1117
1119
|
'altitude' => 10,
|
|
1118
1120
|
'heading' => 90,
|
|
1119
1121
|
'speed' => 45,
|
|
1120
1122
|
'payload_capacity' => 1200,
|
|
1123
|
+
'meta' => ['temperature' => 'ambient'],
|
|
1121
1124
|
'payload_capacity_volume' => 8,
|
|
1122
1125
|
'payload_capacity_pallets' => 2,
|
|
1123
1126
|
'payload_capacity_parcels' => 80,
|