@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
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Fleetbase\FleetOps\Http\Controllers\Api\v1\FleetController;
|
|
4
|
+
use Fleetbase\FleetOps\Http\Resources\v1\Fleet as FleetResource;
|
|
5
|
+
use Fleetbase\FleetOps\Models\Fleet;
|
|
6
|
+
use Illuminate\Http\Request;
|
|
7
|
+
|
|
8
|
+
// The query pipeline reaches Fleetbase\Support helpers that call the framework's
|
|
9
|
+
// auth() and session() helpers; neither exists in the bare-container harness.
|
|
10
|
+
if (!function_exists('Fleetbase\Support\auth')) {
|
|
11
|
+
eval('namespace Fleetbase\Support; function auth() { return new class { public function user() { return null; } public function id() { return null; } }; }');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (!function_exists('Fleetbase\Support\session')) {
|
|
15
|
+
eval('namespace Fleetbase\Support; function session($key = null, $default = null) { if ($key === null) { return new class { public function has($k) { return \session($k) !== null; } public function get($k, $d = null) { return \session($k, $d); } }; } return \session($key, $default); }');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!Request::hasMacro('getController')) {
|
|
19
|
+
Request::macro('getController', fn () => new FleetController());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!Request::hasMacro('or')) {
|
|
23
|
+
Request::macro('or', function (array $params = [], $default = null) {
|
|
24
|
+
foreach ($params as $param) {
|
|
25
|
+
if ($this->has($param)) {
|
|
26
|
+
return $this->input($param);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return $default;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class FleetOpsFleetResourceRouteFixture
|
|
35
|
+
{
|
|
36
|
+
public array $action = [];
|
|
37
|
+
|
|
38
|
+
public function __construct(private string $uri)
|
|
39
|
+
{
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
public function uri(): string
|
|
43
|
+
{
|
|
44
|
+
return $this->uri;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public function getAction($key = null): string
|
|
48
|
+
{
|
|
49
|
+
return FleetController::class . '@query';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public function getActionMethod(): string
|
|
53
|
+
{
|
|
54
|
+
return 'query';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public function getName(): string
|
|
58
|
+
{
|
|
59
|
+
return 'api.v1.fleets.query';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public function parameters(): array
|
|
63
|
+
{
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fleetopsFleetResourceRequest(bool $internal, array $query = []): Request
|
|
69
|
+
{
|
|
70
|
+
$uri = $internal ? 'api/int/v1/fleet-ops/fleets/fleet_123' : 'api/v1/fleets/fleet_123';
|
|
71
|
+
$request = Request::create('/' . $uri, 'GET', $query);
|
|
72
|
+
$request->setRouteResolver(fn () => new FleetOpsFleetResourceRouteFixture($uri));
|
|
73
|
+
app()->instance('request', $request);
|
|
74
|
+
|
|
75
|
+
return $request;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Boot an in-memory database holding one fleet with every relationship set.
|
|
80
|
+
*
|
|
81
|
+
* The resource resolves each assignment to a public id, which is a real read,
|
|
82
|
+
* and the custom-field merge and photo accessor both touch the database as
|
|
83
|
+
* well — so the shape of the public payload can only be asserted against a
|
|
84
|
+
* real connection.
|
|
85
|
+
*/
|
|
86
|
+
function fleetopsFleetResourceDatabase(): Illuminate\Database\SQLiteConnection
|
|
87
|
+
{
|
|
88
|
+
$connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
|
|
89
|
+
$resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
|
|
90
|
+
$resolver->setDefaultConnection('mysql');
|
|
91
|
+
Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
|
|
92
|
+
|
|
93
|
+
app()->instance('db', new class($connection) {
|
|
94
|
+
public function __construct(public Illuminate\Database\SQLiteConnection $c)
|
|
95
|
+
{
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public function connection($name = null): Illuminate\Database\SQLiteConnection
|
|
99
|
+
{
|
|
100
|
+
return $this->c;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public function __call($method, $arguments)
|
|
104
|
+
{
|
|
105
|
+
return $this->c->{$method}(...$arguments);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
app()->instance('db.schema', $connection->getSchemaBuilder());
|
|
109
|
+
Illuminate\Support\Facades\DB::clearResolvedInstance('db');
|
|
110
|
+
|
|
111
|
+
$schema = $connection->getSchemaBuilder();
|
|
112
|
+
|
|
113
|
+
$schema->create('fleets', function ($blueprint) {
|
|
114
|
+
$blueprint->increments('id');
|
|
115
|
+
foreach ([
|
|
116
|
+
'uuid', '_key', 'public_id', 'company_uuid', 'service_area_uuid', 'zone_uuid',
|
|
117
|
+
'vendor_uuid', 'parent_fleet_uuid', 'image_uuid', 'name', 'color', 'task', 'status', 'slug',
|
|
118
|
+
] as $column) {
|
|
119
|
+
$blueprint->string($column)->nullable();
|
|
120
|
+
}
|
|
121
|
+
$blueprint->timestamps();
|
|
122
|
+
$blueprint->timestamp('deleted_at')->nullable();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
foreach (['service_areas', 'zones', 'vendors'] as $table) {
|
|
126
|
+
$schema->create($table, function ($blueprint) {
|
|
127
|
+
$blueprint->increments('id');
|
|
128
|
+
foreach (['uuid', '_key', 'public_id', 'company_uuid', 'service_area_uuid', 'name', 'type', 'border', 'status'] as $column) {
|
|
129
|
+
$blueprint->string($column)->nullable();
|
|
130
|
+
}
|
|
131
|
+
$blueprint->timestamps();
|
|
132
|
+
$blueprint->timestamp('deleted_at')->nullable();
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
foreach (['fleet_drivers' => 'driver_uuid', 'fleet_vehicles' => 'vehicle_uuid'] as $table => $subject) {
|
|
137
|
+
$schema->create($table, function ($blueprint) use ($subject) {
|
|
138
|
+
$blueprint->increments('id');
|
|
139
|
+
$blueprint->string('uuid')->nullable();
|
|
140
|
+
$blueprint->string('fleet_uuid')->nullable();
|
|
141
|
+
$blueprint->string($subject)->nullable();
|
|
142
|
+
$blueprint->timestamps();
|
|
143
|
+
$blueprint->timestamp('deleted_at')->nullable();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
foreach (['drivers', 'vehicles', 'users'] as $table) {
|
|
148
|
+
$schema->create($table, function ($blueprint) {
|
|
149
|
+
$blueprint->increments('id');
|
|
150
|
+
foreach (['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name'] as $column) {
|
|
151
|
+
$blueprint->string($column)->nullable();
|
|
152
|
+
}
|
|
153
|
+
$blueprint->boolean('online')->nullable();
|
|
154
|
+
$blueprint->timestamps();
|
|
155
|
+
$blueprint->timestamp('deleted_at')->nullable();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Permission directives are consulted by the query pipeline.
|
|
160
|
+
$schema->create('directives', function ($blueprint) {
|
|
161
|
+
$blueprint->increments('id');
|
|
162
|
+
foreach (['uuid', 'company_uuid', 'permission_uuid', 'subject_type', 'subject_uuid', 'key', 'rules'] as $column) {
|
|
163
|
+
$blueprint->string($column)->nullable();
|
|
164
|
+
}
|
|
165
|
+
$blueprint->timestamps();
|
|
166
|
+
$blueprint->timestamp('deleted_at')->nullable();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// The fleet image relation is eager loaded alongside the others.
|
|
170
|
+
$schema->create('files', function ($blueprint) {
|
|
171
|
+
$blueprint->increments('id');
|
|
172
|
+
foreach (['uuid', 'public_id', 'company_uuid', 'disk', 'path', 'bucket', 'type', 'original_filename'] as $column) {
|
|
173
|
+
$blueprint->string($column)->nullable();
|
|
174
|
+
}
|
|
175
|
+
$blueprint->timestamps();
|
|
176
|
+
$blueprint->timestamp('deleted_at')->nullable();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
$schema->create('custom_field_values', function ($blueprint) {
|
|
180
|
+
$blueprint->increments('id');
|
|
181
|
+
foreach (['uuid', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type'] as $column) {
|
|
182
|
+
$blueprint->string($column)->nullable();
|
|
183
|
+
}
|
|
184
|
+
$blueprint->timestamps();
|
|
185
|
+
$blueprint->timestamp('deleted_at')->nullable();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
$connection->table('service_areas')->insert(['uuid' => 'service-area-uuid', 'public_id' => 'service_area_123', 'company_uuid' => 'company-uuid', 'name' => 'Central']);
|
|
189
|
+
$connection->table('zones')->insert(['uuid' => 'zone-uuid', 'public_id' => 'zone_123', 'company_uuid' => 'company-uuid', 'name' => 'Downtown']);
|
|
190
|
+
$connection->table('vendors')->insert(['uuid' => 'vendor-uuid', 'public_id' => 'vendor_123', 'company_uuid' => 'company-uuid', 'name' => 'Acme']);
|
|
191
|
+
$connection->table('fleets')->insert(['uuid' => 'parent-uuid', 'public_id' => 'fleet_parent123', 'company_uuid' => 'company-uuid', 'name' => 'Parent']);
|
|
192
|
+
|
|
193
|
+
return $connection;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function fleetopsFleetResourceFixture(array $overrides = []): Fleet
|
|
197
|
+
{
|
|
198
|
+
$connection = fleetopsFleetResourceDatabase();
|
|
199
|
+
|
|
200
|
+
$connection->table('fleets')->insert(array_merge([
|
|
201
|
+
'uuid' => 'fleet-uuid',
|
|
202
|
+
'public_id' => 'fleet_123',
|
|
203
|
+
'company_uuid' => 'company-uuid',
|
|
204
|
+
'name' => 'Carpool',
|
|
205
|
+
'color' => '#2563EB',
|
|
206
|
+
'task' => 'Employee transport',
|
|
207
|
+
'status' => 'active',
|
|
208
|
+
'service_area_uuid' => 'service-area-uuid',
|
|
209
|
+
'zone_uuid' => 'zone-uuid',
|
|
210
|
+
'vendor_uuid' => 'vendor-uuid',
|
|
211
|
+
'parent_fleet_uuid' => 'parent-uuid',
|
|
212
|
+
], $overrides));
|
|
213
|
+
|
|
214
|
+
return Fleet::where('uuid', 'fleet-uuid')->firstOrFail();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Resolve expansions the way a request does, then serialize.
|
|
219
|
+
*
|
|
220
|
+
* Going through the controller matters: it is what maps the public expansion
|
|
221
|
+
* name onto the relation name and drops anything outside the allowlist, and the
|
|
222
|
+
* resource is written to trust that mapping.
|
|
223
|
+
*/
|
|
224
|
+
function fleetopsFleetPayload(Fleet $fleet, bool $internal = false, array $query = []): array
|
|
225
|
+
{
|
|
226
|
+
$request = fleetopsFleetResourceRequest($internal, $query);
|
|
227
|
+
|
|
228
|
+
$controller = new FleetController();
|
|
229
|
+
$reflection = new ReflectionMethod(FleetController::class, 'applyPublicExpansions');
|
|
230
|
+
$reflection->setAccessible(true);
|
|
231
|
+
$reflection->invoke($controller, $request, FleetController::EXPANDABLE);
|
|
232
|
+
|
|
233
|
+
return (new FleetResource($fleet))->resolve($request);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
test('the public fleet resource reports every writable field and each relationship as an additive id', function () {
|
|
237
|
+
$payload = fleetopsFleetPayload(fleetopsFleetResourceFixture());
|
|
238
|
+
|
|
239
|
+
// The identifier is a new key beside the relationship, never a value under
|
|
240
|
+
// it. The SDK stores whatever the API returns verbatim, so a property that
|
|
241
|
+
// is an object on one call and a string on another breaks every consumer
|
|
242
|
+
// that dereferences it.
|
|
243
|
+
expect($payload)->toMatchArray([
|
|
244
|
+
'id' => 'fleet_123',
|
|
245
|
+
'name' => 'Carpool',
|
|
246
|
+
'color' => '#2563EB',
|
|
247
|
+
'task' => 'Employee transport',
|
|
248
|
+
'status' => 'active',
|
|
249
|
+
'service_area_id' => 'service_area_123',
|
|
250
|
+
'zone_id' => 'zone_123',
|
|
251
|
+
'vendor_id' => 'vendor_123',
|
|
252
|
+
'parent_fleet_id' => 'fleet_parent123',
|
|
253
|
+
])
|
|
254
|
+
// Unexpanded, the object keys stay absent exactly as they were before
|
|
255
|
+
// this contract gained the identifiers.
|
|
256
|
+
->and($payload)->not->toHaveKeys(['service_area', 'zone', 'vendor', 'parent_fleet', 'photo'])
|
|
257
|
+
->and($payload)->not->toHaveKeys([
|
|
258
|
+
'uuid',
|
|
259
|
+
'public_id',
|
|
260
|
+
'company_uuid',
|
|
261
|
+
'service_area_uuid',
|
|
262
|
+
'zone_uuid',
|
|
263
|
+
'vendor_uuid',
|
|
264
|
+
'parent_fleet_uuid',
|
|
265
|
+
'image_uuid',
|
|
266
|
+
]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('the public fleet resource reports a root fleet with null relationship ids', function () {
|
|
270
|
+
$fleet = fleetopsFleetResourceFixture(['parent_fleet_uuid' => null, 'zone_uuid' => null]);
|
|
271
|
+
|
|
272
|
+
$payload = fleetopsFleetPayload($fleet);
|
|
273
|
+
|
|
274
|
+
expect($payload)->toHaveKeys(['parent_fleet_id', 'zone_id'])
|
|
275
|
+
->and($payload['parent_fleet_id'])->toBeNull()
|
|
276
|
+
->and($payload['zone_id'])->toBeNull();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('the internal fleet resource keeps its nested relationship shape', function () {
|
|
280
|
+
$fleet = fleetopsFleetResourceFixture()->load(['serviceArea', 'zone', 'vendor', 'parentFleet']);
|
|
281
|
+
$payload = fleetopsFleetPayload($fleet, true);
|
|
282
|
+
|
|
283
|
+
// The console reads nested objects off these keys, and reads the counters;
|
|
284
|
+
// expanding the public contract must not reshape either.
|
|
285
|
+
expect($payload)->toHaveKeys(['uuid', 'public_id', 'drivers_count', 'vehicles_count'])
|
|
286
|
+
->and($payload['uuid'])->toBe('fleet-uuid')
|
|
287
|
+
->and($payload['service_area'])->toBeObject()
|
|
288
|
+
->and($payload['zone'])->toBeObject()
|
|
289
|
+
->and($payload['vendor'])->toBeObject()
|
|
290
|
+
->and($payload['parent_fleet'])->toBeObject();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('expanding a fleet relationship adds the object and leaves the id untouched', function () {
|
|
294
|
+
$payload = fleetopsFleetPayload(
|
|
295
|
+
fleetopsFleetResourceFixture(),
|
|
296
|
+
false,
|
|
297
|
+
['with' => ['service_area', 'parent_fleet']]
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
expect($payload['service_area'])->toBeObject()
|
|
301
|
+
->and($payload['parent_fleet'])->toBeObject()
|
|
302
|
+
// The identifier is unchanged by expansion, and agrees with the object.
|
|
303
|
+
->and($payload['service_area_id'])->toBe('service_area_123')
|
|
304
|
+
->and($payload['parent_fleet_id'])->toBe('fleet_parent123')
|
|
305
|
+
// The expanded object and the identifier agree. Asserted on the parent
|
|
306
|
+
// fleet: a ServiceArea serializes its border through GEOS, which the
|
|
307
|
+
// harness has no extension for.
|
|
308
|
+
->and($payload['parent_fleet']->resolve()['id'])->toBe($payload['parent_fleet_id'])
|
|
309
|
+
// A relation that was not asked for is still absent, not a string.
|
|
310
|
+
->and($payload)->not->toHaveKey('vendor')
|
|
311
|
+
->and($payload['vendor_id'])->toBe('vendor_123');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('fleet expansion accepts every documented input spelling', function () {
|
|
315
|
+
$scalar = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['with' => 'service_area']);
|
|
316
|
+
$array = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['with' => ['service_area']]);
|
|
317
|
+
$csv = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['with' => 'service_area,vendor']);
|
|
318
|
+
$expand = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['expand' => ['service_area']]);
|
|
319
|
+
$camel = fleetopsFleetPayload(fleetopsFleetResourceFixture(), false, ['with' => 'serviceArea']);
|
|
320
|
+
|
|
321
|
+
// ?with=x, ?with[]=x, ?with=x,y and ?expand=x all mean the same thing.
|
|
322
|
+
expect($scalar['service_area'])->toBeObject()
|
|
323
|
+
->and($array['service_area'])->toBeObject()
|
|
324
|
+
->and($csv['service_area'])->toBeObject()
|
|
325
|
+
->and($csv['vendor'])->toBeObject()
|
|
326
|
+
->and($expand['service_area'])->toBeObject()
|
|
327
|
+
->and($camel['service_area'])->toBeObject();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('an unsupported fleet expansion is ignored rather than reaching eloquent', function () {
|
|
331
|
+
// Core hands `with` straight to load(), so an unknown name would be a 500
|
|
332
|
+
// for what is only a typo. The allowlist drops it and the response is
|
|
333
|
+
// otherwise unchanged.
|
|
334
|
+
$payload = fleetopsFleetPayload(
|
|
335
|
+
fleetopsFleetResourceFixture(),
|
|
336
|
+
false,
|
|
337
|
+
['with' => ['not_a_relation', 'company', 'service_area']]
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
expect($payload['service_area'])->toBeObject()
|
|
341
|
+
->and($payload)->not->toHaveKeys(['not_a_relation', 'company'])
|
|
342
|
+
->and($payload['id'])->toBe('fleet_123');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('public fleet membership routes are registered with public id parameters', function () {
|
|
346
|
+
$routes = file_get_contents(dirname(__DIR__, 4) . '/src/routes.php');
|
|
347
|
+
|
|
348
|
+
expect($routes)
|
|
349
|
+
->toContain("\$router->post('{id}/vehicles/{vehicle}', 'FleetController@assignVehicle');")
|
|
350
|
+
->toContain("\$router->delete('{id}/vehicles/{vehicle}', 'FleetController@removeVehicle');")
|
|
351
|
+
->toContain("\$router->post('{id}/drivers/{driver}', 'FleetController@assignDriver');")
|
|
352
|
+
->toContain("\$router->delete('{id}/drivers/{driver}', 'FleetController@removeDriver');");
|
|
353
|
+
|
|
354
|
+
$fleetGroup = substr($routes, strpos($routes, '// fleets routes'));
|
|
355
|
+
$fleetGroup = substr($fleetGroup, 0, strpos($fleetGroup, '// labels routes'));
|
|
356
|
+
|
|
357
|
+
// The literal segments have to be declared before the `{id}` patterns, or
|
|
358
|
+
// `vehicles` is swallowed as a fleet id.
|
|
359
|
+
expect(strpos($fleetGroup, '{id}/vehicles/{vehicle}'))->toBeLessThan(strpos($fleetGroup, "\$router->get('{id}'"));
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('the public fleet controller exposes the membership actions', function () {
|
|
363
|
+
foreach (['create', 'query', 'find', 'update', 'delete', 'assignVehicle', 'removeVehicle', 'assignDriver', 'removeDriver'] as $method) {
|
|
364
|
+
expect(method_exists(FleetController::class, $method))->toBeTrue();
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('the fleet controller lookup query and eager-load helpers run their real bodies', function () {
|
|
369
|
+
$connection = fleetopsFleetResourceDatabase();
|
|
370
|
+
session(['company' => 'company-uuid']);
|
|
371
|
+
|
|
372
|
+
$connection->table('fleets')->insert([
|
|
373
|
+
['uuid' => 'fleet-uuid', 'public_id' => 'fleet_123', 'company_uuid' => 'company-uuid', 'name' => 'Haulers', 'service_area_uuid' => 'service-area-uuid', 'parent_fleet_uuid' => 'parent-uuid'],
|
|
374
|
+
['uuid' => 'other-company-fleet', 'public_id' => 'fleet_elsewhere', 'company_uuid' => 'another-company', 'name' => 'Theirs', 'service_area_uuid' => null, 'parent_fleet_uuid' => null],
|
|
375
|
+
]);
|
|
376
|
+
$connection->table('vehicles')->insert(['uuid' => 'vehicle-uuid', 'public_id' => 'vehicle_123', 'company_uuid' => 'company-uuid']);
|
|
377
|
+
$connection->table('users')->insert(['uuid' => 'user-uuid', 'company_uuid' => 'company-uuid']);
|
|
378
|
+
$connection->table('drivers')->insert(['uuid' => 'driver-uuid', 'public_id' => 'driver_123', 'company_uuid' => 'company-uuid', 'user_uuid' => 'user-uuid']);
|
|
379
|
+
|
|
380
|
+
$controller = new FleetController();
|
|
381
|
+
$call = function (string $method, ...$arguments) use ($controller) {
|
|
382
|
+
$reflection = new ReflectionMethod(FleetController::class, $method);
|
|
383
|
+
$reflection->setAccessible(true);
|
|
384
|
+
|
|
385
|
+
return $reflection->invoke($controller, ...$arguments);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
$fleet = $call('findFleet', 'fleet_123');
|
|
389
|
+
|
|
390
|
+
expect($fleet->uuid)->toBe('fleet-uuid')
|
|
391
|
+
->and($call('findVehicle', 'vehicle_123')->uuid)->toBe('vehicle-uuid')
|
|
392
|
+
->and($call('findDriver', 'driver_123')->uuid)->toBe('driver-uuid');
|
|
393
|
+
|
|
394
|
+
// A fleet in another company is unavailable, not forbidden.
|
|
395
|
+
expect(fn () => $call('findFleet', 'fleet_elsewhere'))
|
|
396
|
+
->toThrow(Illuminate\Database\Eloquent\ModelNotFoundException::class);
|
|
397
|
+
|
|
398
|
+
// The relations the public resource reports as public ids are eager loaded
|
|
399
|
+
// rather than resolved one query at a time.
|
|
400
|
+
$loaded = $call('withPublicRelations', $fleet);
|
|
401
|
+
|
|
402
|
+
expect($loaded->relationLoaded('serviceArea'))->toBeTrue()
|
|
403
|
+
->and($loaded->relationLoaded('zone'))->toBeTrue()
|
|
404
|
+
->and($loaded->relationLoaded('vendor'))->toBeTrue()
|
|
405
|
+
->and($loaded->relationLoaded('parentFleet'))->toBeTrue()
|
|
406
|
+
->and($loaded->relationLoaded('photo'))->toBeTrue()
|
|
407
|
+
->and($loaded->serviceArea->public_id)->toBe('service_area_123');
|
|
408
|
+
|
|
409
|
+
// The query pipeline scopes to the caller's company and eager loads the
|
|
410
|
+
// same relations for every row in the page.
|
|
411
|
+
$uri = 'v1/fleets';
|
|
412
|
+
$request = Request::create('/' . $uri, 'GET');
|
|
413
|
+
$store = app('session.store');
|
|
414
|
+
$store->put('company', 'company-uuid');
|
|
415
|
+
$request->setLaravelSession($store);
|
|
416
|
+
$request->setRouteResolver(fn () => new FleetOpsFleetResourceRouteFixture($uri));
|
|
417
|
+
app()->instance('request', $request);
|
|
418
|
+
|
|
419
|
+
$results = $call('queryFleets', $request);
|
|
420
|
+
|
|
421
|
+
$returned = $results->pluck('uuid')->all();
|
|
422
|
+
|
|
423
|
+
expect($returned)->toContain('fleet-uuid', 'parent-uuid')
|
|
424
|
+
->and($returned)->not->toContain('other-company-fleet')
|
|
425
|
+
->and($results->first()->relationLoaded('serviceArea'))->toBeTrue()
|
|
426
|
+
->and($results->first()->relationLoaded('parentFleet'))->toBeTrue();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('the fleet resource accepts a scalar with parameter it was handed directly', function () {
|
|
430
|
+
// The controller normalises `with` into an array before the resource runs,
|
|
431
|
+
// but the resource is also reachable from code that passes the raw scalar
|
|
432
|
+
// through — it must read the same list either way.
|
|
433
|
+
$request = fleetopsFleetResourceRequest(false, ['with' => 'serviceArea,parentFleet']);
|
|
434
|
+
$payload = (new FleetResource(fleetopsFleetResourceFixture()))->resolve($request);
|
|
435
|
+
|
|
436
|
+
expect($payload['service_area'])->toBeObject()
|
|
437
|
+
->and($payload['parent_fleet'])->toBeObject()
|
|
438
|
+
->and($payload['service_area_id'])->toBe('service_area_123');
|
|
439
|
+
});
|
|
@@ -44,14 +44,14 @@ class FleetOpsApiFuelTransactionControllerProbe extends FuelTransactionControlle
|
|
|
44
44
|
];
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
protected function resolveUuid(string $modelClass, ?string $id): ?string
|
|
47
|
+
protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
|
|
48
48
|
{
|
|
49
49
|
$this->resolvedUuids[] = [$modelClass, $id];
|
|
50
50
|
|
|
51
51
|
return filled($id) ? $id . '-uuid' : null;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
protected function resolveModel(string $modelClass, string $id): EloquentModel
|
|
54
|
+
protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): EloquentModel
|
|
55
55
|
{
|
|
56
56
|
$key = $modelClass . ':' . $id;
|
|
57
57
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Fleetbase\FleetOps\Http\Controllers\Api\v1\EntityController;
|
|
4
|
+
use Fleetbase\FleetOps\Http\Controllers\Api\v1\FuelReportController;
|
|
5
|
+
use Fleetbase\FleetOps\Http\Controllers\Api\v1\IssueController;
|
|
6
|
+
use Fleetbase\FleetOps\Http\Controllers\Api\v1\OrderController;
|
|
7
|
+
use Fleetbase\FleetOps\Http\Controllers\Api\v1\ServiceAreaController;
|
|
8
|
+
use Fleetbase\FleetOps\Http\Controllers\Api\v1\TrackingStatusController;
|
|
9
|
+
use Fleetbase\FleetOps\Http\Controllers\Api\v1\ZoneController;
|
|
10
|
+
use Illuminate\Database\ConnectionResolver;
|
|
11
|
+
use Illuminate\Database\Eloquent\Model as EloquentModel;
|
|
12
|
+
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
|
13
|
+
use Illuminate\Database\SQLiteConnection;
|
|
14
|
+
use Illuminate\Support\Facades\DB;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A relationship sent as `""` or `null` must never be a 5xx.
|
|
18
|
+
*
|
|
19
|
+
* `ConvertEmptyStringsToNull` is global middleware, so `"driver": ""` reaches
|
|
20
|
+
* the controller as null and `$request->has('driver')` is still true — `has()`
|
|
21
|
+
* means the key is present, not that it holds anything. Every public
|
|
22
|
+
* relationship input therefore arrives as null sooner or later, usually from a
|
|
23
|
+
* form that serialises an unselected dropdown as an empty string.
|
|
24
|
+
*
|
|
25
|
+
* That was fine for years because the lookups were plain queries:
|
|
26
|
+
* `where('public_id', null)` matches nothing and the caller's `if ($driver)`
|
|
27
|
+
* skips. It broke when a coverage pass extracted one of those queries into
|
|
28
|
+
* `findDriverByPublicId(string $publicId)` — the *return* type was made
|
|
29
|
+
* nullable, the parameter was not — turning "no driver" into a TypeError on
|
|
30
|
+
* `POST /v1/orders`.
|
|
31
|
+
*
|
|
32
|
+
* The static test below is the one that would have caught it: it holds the
|
|
33
|
+
* property rather than the instance, so the next extraction cannot reintroduce
|
|
34
|
+
* the class.
|
|
35
|
+
*/
|
|
36
|
+
function fleetopsNullRelationshipBoot(): SQLiteConnection
|
|
37
|
+
{
|
|
38
|
+
$connection = new SQLiteConnection(new PDO('sqlite::memory:'));
|
|
39
|
+
$resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
|
|
40
|
+
$resolver->setDefaultConnection('mysql');
|
|
41
|
+
EloquentModel::setConnectionResolver($resolver);
|
|
42
|
+
|
|
43
|
+
app()->instance('db', new class($connection) {
|
|
44
|
+
public function __construct(public SQLiteConnection $c)
|
|
45
|
+
{
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public function connection($name = null): SQLiteConnection
|
|
49
|
+
{
|
|
50
|
+
return $this->c;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public function __call($method, $arguments)
|
|
54
|
+
{
|
|
55
|
+
return $this->c->{$method}(...$arguments);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
app()->instance('db.schema', $connection->getSchemaBuilder());
|
|
59
|
+
DB::clearResolvedInstance('db');
|
|
60
|
+
|
|
61
|
+
$schema = $connection->getSchemaBuilder();
|
|
62
|
+
foreach (['drivers', 'users', 'payloads', 'orders', 'service_areas', 'entities', 'waypoints', 'places'] as $table) {
|
|
63
|
+
$schema->create($table, function ($blueprint) {
|
|
64
|
+
$blueprint->increments('id');
|
|
65
|
+
foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'tracking_number_uuid', 'payload_uuid', 'place_uuid', 'name', '_key'] as $column) {
|
|
66
|
+
$blueprint->string($column)->nullable();
|
|
67
|
+
}
|
|
68
|
+
$blueprint->timestamps();
|
|
69
|
+
$blueprint->timestamp('deleted_at')->nullable();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
session(['company' => 'company-uuid']);
|
|
74
|
+
$connection->table('users')->insert(['uuid' => 'user-uuid', 'company_uuid' => 'company-uuid']);
|
|
75
|
+
$connection->table('drivers')->insert(['uuid' => 'driver-uuid', 'public_id' => 'driver_real01', 'company_uuid' => 'company-uuid', 'user_uuid' => 'user-uuid']);
|
|
76
|
+
$connection->table('payloads')->insert(['uuid' => 'payload-uuid', 'public_id' => 'payload_real01', 'company_uuid' => 'company-uuid']);
|
|
77
|
+
$connection->table('orders')->insert(['uuid' => 'order-uuid', 'public_id' => 'order_real01', 'company_uuid' => 'company-uuid', 'tracking_number_uuid' => 'tracking-uuid']);
|
|
78
|
+
$connection->table('service_areas')->insert(['uuid' => 'sa-uuid', 'public_id' => 'service_area_r1', 'company_uuid' => 'company-uuid']);
|
|
79
|
+
|
|
80
|
+
return $connection;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function fleetopsInvokeSeam(object $controller, string $method, ...$arguments)
|
|
84
|
+
{
|
|
85
|
+
$reflection = new ReflectionMethod($controller, $method);
|
|
86
|
+
$reflection->setAccessible(true);
|
|
87
|
+
|
|
88
|
+
return $reflection->invoke($controller, ...$arguments);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test('every request-fed lookup seam answers rather than raising when handed nothing', function () {
|
|
92
|
+
fleetopsNullRelationshipBoot();
|
|
93
|
+
|
|
94
|
+
// Each of these is reached from `$request->input('<relationship>')`. A null
|
|
95
|
+
// means the caller sent an empty relationship, which is a question with an
|
|
96
|
+
// answer — "there is no such record" — not a programming error.
|
|
97
|
+
expect(fleetopsInvokeSeam(new OrderController(), 'findDriverByPublicId', null))->toBeNull()
|
|
98
|
+
->and(fleetopsInvokeSeam(new EntityController(), 'findPayloadByPublicId', null))->toBeNull()
|
|
99
|
+
->and(fleetopsInvokeSeam(new TrackingStatusController(), 'getOrderTrackingNumberUuid', null))->toBeNull()
|
|
100
|
+
->and(fleetopsInvokeSeam(new ServiceAreaController(), 'serviceAreaUuid', null, ['public_id' => null, 'company_uuid' => 'company-uuid']))->toBeNull()
|
|
101
|
+
->and(fleetopsInvokeSeam(new ZoneController(), 'serviceAreaUuid', null, ['public_id' => null, 'company_uuid' => 'company-uuid']))->toBeNull();
|
|
102
|
+
|
|
103
|
+
// Issue and FuelReport reach their seam only from `create`, where `driver`
|
|
104
|
+
// is `required` — validation answers 422 long before a null could arrive.
|
|
105
|
+
// Widening them is defence-in-depth for a future call site, so the claim
|
|
106
|
+
// here is only the one that matters: whatever they answer, it is not a
|
|
107
|
+
// TypeError. A TypeError would escape this block and fail the test.
|
|
108
|
+
foreach ([new IssueController(), new FuelReportController()] as $controller) {
|
|
109
|
+
try {
|
|
110
|
+
fleetopsInvokeSeam($controller, 'findDriverRecord', null);
|
|
111
|
+
} catch (ModelNotFoundException) {
|
|
112
|
+
// "No such driver" — the call sites already translate this into a 404.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a real identifier still resolves through the widened seams', function () {
|
|
118
|
+
fleetopsNullRelationshipBoot();
|
|
119
|
+
|
|
120
|
+
// Widening the parameter must not have widened the lookup: a present
|
|
121
|
+
// identifier resolves exactly as before, and one that does not exist still
|
|
122
|
+
// resolves to nothing.
|
|
123
|
+
expect(fleetopsInvokeSeam(new OrderController(), 'findDriverByPublicId', 'driver_real01')->uuid)->toBe('driver-uuid')
|
|
124
|
+
->and(fleetopsInvokeSeam(new OrderController(), 'findDriverByPublicId', 'driver_missing'))->toBeNull()
|
|
125
|
+
->and(fleetopsInvokeSeam(new EntityController(), 'findPayloadByPublicId', 'payload_real01')->uuid)->toBe('payload-uuid')
|
|
126
|
+
->and(fleetopsInvokeSeam(new TrackingStatusController(), 'getOrderTrackingNumberUuid', 'order_real01'))->toBe('tracking-uuid')
|
|
127
|
+
->and(fleetopsInvokeSeam(new ServiceAreaController(), 'serviceAreaUuid', 'service_area_r1', ['public_id' => 'service_area_r1', 'company_uuid' => 'company-uuid']))->toBe('sa-uuid');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('no public api lookup seam fed from the request refuses a null identifier', function () {
|
|
131
|
+
// The systemic guard. Rather than pinning the eight seams that exist today,
|
|
132
|
+
// it derives them: every call of the form `$this->someSeam($request->input(...))`
|
|
133
|
+
// in the public v1 controllers, checked for a nullable first parameter.
|
|
134
|
+
//
|
|
135
|
+
// An extraction that types a new seam `string` fails here, at the commit
|
|
136
|
+
// that introduces it, instead of in production on an empty dropdown.
|
|
137
|
+
// Exempt only where the *call site* already excludes null, and say which
|
|
138
|
+
// guard does it — an exemption without a named guard is how this test would
|
|
139
|
+
// rot into a rubber stamp.
|
|
140
|
+
$exempt = [
|
|
141
|
+
// Not an identifier lookup — it geocodes a free-text address, and a null
|
|
142
|
+
// address has no meaning to geocode. Its call sites guard with
|
|
143
|
+
// `$request->isString(...)`, which a null can never satisfy.
|
|
144
|
+
'PlaceController::createPlaceFromGeocodingLookup',
|
|
145
|
+
// Both call sites are behind `$request->isArray('payload')`. A payload
|
|
146
|
+
// sent as `""` fails that guard and is skipped, never unpacked.
|
|
147
|
+
'OrderController::payloadShapeFromArray',
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
$offenders = [];
|
|
151
|
+
$inspected = [];
|
|
152
|
+
|
|
153
|
+
foreach (glob(dirname(__DIR__, 4) . '/src/Http/Controllers/Api/v1/*.php') as $path) {
|
|
154
|
+
$source = file_get_contents($path);
|
|
155
|
+
$controller = basename($path, '.php');
|
|
156
|
+
|
|
157
|
+
preg_match_all('/\$this->([A-Za-z_]+)\(\s*\$request->(?:input|or)\(/', $source, $matches);
|
|
158
|
+
|
|
159
|
+
foreach (array_unique($matches[1]) as $seam) {
|
|
160
|
+
if (in_array($controller . '::' . $seam, $exempt, true)) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!preg_match('/(?:protected|public|private) function ' . preg_quote($seam, '/') . '\(([^),]*)/', $source, $signature)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
$firstParameter = trim($signature[1]);
|
|
169
|
+
$inspected[] = $controller . '::' . $seam;
|
|
170
|
+
|
|
171
|
+
// `mixed`, an untyped parameter, and anything explicitly nullable all
|
|
172
|
+
// accept null already; only a narrow non-nullable type is a problem.
|
|
173
|
+
$accepts = $firstParameter === ''
|
|
174
|
+
|| str_starts_with($firstParameter, '?')
|
|
175
|
+
|| str_starts_with($firstParameter, '$')
|
|
176
|
+
|| str_contains($firstParameter, 'null')
|
|
177
|
+
|| str_starts_with($firstParameter, 'mixed');
|
|
178
|
+
|
|
179
|
+
if (!$accepts) {
|
|
180
|
+
$offenders[] = $controller . '::' . $seam . '(' . $firstParameter . ')';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A guard that scans nothing passes for the wrong reason, so make the scan
|
|
186
|
+
// prove itself: the seam this bug was reported against has to be among what
|
|
187
|
+
// it looked at. If the glob or the pattern ever stops matching, this fails
|
|
188
|
+
// instead of going quietly green.
|
|
189
|
+
expect($inspected)->toContain('OrderController::findDriverByPublicId')
|
|
190
|
+
->and($offenders)->toBe([], 'these seams are handed a request value and would raise a TypeError on an empty relationship: ' . implode(', ', $offenders));
|
|
191
|
+
});
|