@fleetbase/fleetops-engine 0.6.62 → 0.6.64
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 +4 -29
- 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/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +1 -1
- package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +29 -3
- package/server/src/Http/Resources/v1/Fleet.php +3 -1
- package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +23 -0
- package/server/tests/Unit/Http/Resources/FleetResourceTest.php +17 -0
- package/server/tests/VehicleResourceContractsTest.php +39 -0
package/RELEASE.md
CHANGED
|
@@ -1,38 +1,13 @@
|
|
|
1
|
-
> v0.6.
|
|
2
|
-
|
|
3
|
-
---
|
|
4
|
-
## Highlights
|
|
5
|
-
The public v1 API exposed a small subset of what the Fleet, Vehicle and Driver records can hold, and the gaps were silent: a caller sending a field the controller did not copy received a `200` and a response body that looked correct while the value was discarded. This release closes that gap, adds fleet hierarchies and fleet membership to the public API, and makes it possible to record a driver who has no email address or phone number.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
## Features
|
|
9
|
-
- **Fleet create and update accept every safe field.** `name`, `color`, `task`, `status`, and the `service_area`, `zone`, `vendor` and `parent_fleet` relationships. Only `name` and `service_area` were reachable before, so a fleet hierarchy could not be built through the API at all. `"parent_fleet": null` clears a parent; a fleet may not be its own parent, nor sit beneath one of its own descendants.
|
|
10
|
-
- **Fleet membership has public endpoints.** `POST` and `DELETE` on `/v1/fleets/{fleet}/vehicles/{vehicle}` and `/v1/fleets/{fleet}/drivers/{driver}`, all four taking public IDs and sharing one response shape. Assignment is idempotent and restores a soft-deleted membership rather than duplicating it; removal is a safe no-op and touches only the pivot, never the driver, the vehicle, the driver's current vehicle, or any other fleet.
|
|
11
|
-
- **The Vehicle contract covers the whole record.** The input projection accepted 21 of the model's 99 fields; it now accepts all 90 safe ones — identity, odometer and measurement, body, capacity and dimensions, lifecycle and financing, regulatory and engine specifications, structured `specs`/`details`/`meta`, and orchestrator constraints — each with type-appropriate validation. `vendor`, `category`, `warranty` and `photo` resolve from public IDs.
|
|
12
|
-
- **A driver can be recorded without credentials.** `email` and `phone` are optional on create; an operational record may legitimately have neither. Nothing is invented to fill the gap and no invitation is sent when there is nowhere to send one. Such a driver cannot sign in to Navigator until credentials are supplied.
|
|
13
|
-
- **Relationships are readable back as public IDs.** A caller that writes `parent_fleet: "fleet_abc"` can now read the assignment back. Asking for a relation through `?with=` still returns the nested object it always did, and internal console responses are unchanged.
|
|
1
|
+
> v0.6.64 ~ "Order responses no longer fail when a vehicle is assigned"
|
|
14
2
|
|
|
15
3
|
---
|
|
16
4
|
## Bug Fixes
|
|
17
|
-
-
|
|
18
|
-
- **Driver input was a blocklist, not an allowlist.** Anything nobody had thought to exclude reached `Driver::create()` intact, including `auth_token`, `user_uuid` and `company_uuid`, while `location`, `heading`, `altitude`, `speed` and `meta` were dropped on every write.
|
|
19
|
-
- **Driver photo upload wrote `photo_uuid` to `users`,** which has no such column. `User` guards mass assignment by fillable, so every photo uploaded through the public API was discarded without a word. It now writes `avatar_uuid`.
|
|
20
|
-
- **A partial vehicle update took the vehicle offline.** The create-time `online` default was applied on update too, so a plate correction or an odometer reading silently reset it.
|
|
21
|
-
- **Relationship filters could never match.** `?vendor=`, `?fleet=`, `?driver=` and the fleet hierarchy filters compared caller-supplied public IDs against uuid columns. `FleetFilter::query()` searched a `user` relation Fleet does not have, `DriverFilter::phone()` a `phone` relation that does not exist, and `FleetFilter::zone()` a `zone_uuid` column `zones` does not have — the last three raised rather than filtered.
|
|
22
|
-
- **`VehicleFilter` had no `internal_id` filter,** which is the lookup an importer keys on to decide whether a vehicle already exists.
|
|
23
|
-
- **Cross-company relationship IDs were accepted and silently dropped.** Relationship inputs were validated with unscoped `exists` rules, so another organization's public ID passed validation and then resolved to nothing. They are now scoped at validation and again at resolution, and a cross-company ID is answered exactly as a missing one — so a response cannot be used to probe another organization's data.
|
|
24
|
-
- **The Fleet resource ran four `count()` queries per fleet on every public request** and discarded the results, because `when()` evaluates a plain value argument eagerly.
|
|
25
|
-
- **The Fleet webhook payload gated `parent_fleet` on `serviceArea`,** so a subfleet with no service area never reported its parent.
|
|
5
|
+
- **Orders with an assigned vehicle serialize again when expansions are requested.** The Vehicle and Fleet resources read the request's `with` list and loaded it onto the model they wrap. That is correct on their own endpoints, where the controller has already allowlisted the list, but both resources are also rendered nested inside Order and Driver responses. There the same request carries the parent's expansions, so `GET /v1/orders?with=payload` on an order with a vehicle assigned failed during serialisation with `RelationNotFoundException: Call to undefined relationship [payload] on model Vehicle`. Navigator order fetches and the console scheduler both hit this. Nested resources now keep only the relations the wrapped model actually defines; direct endpoint behaviour is unchanged.
|
|
26
6
|
|
|
27
7
|
---
|
|
28
8
|
## Testing
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
- Data-driven parity tests prove every newly exposed Fleet, Vehicle and Driver field is accepted and persisted, and that tenancy, authentication and generated columns still are not.
|
|
32
|
-
|
|
33
|
-
---
|
|
34
|
-
## Continuous Integration
|
|
35
|
-
- The PHP, Ember and Postman contract workflows now run on pull requests targeting `release/v*`. `release.yml` learned that branch name in v0.6.61's follow-up, but the three check workflows did not — so a PR retargeted onto a release branch ran no checks at all.
|
|
9
|
+
- Added resource-level regressions for parent expansions reaching a nested Vehicle or Fleet resource, for string and empty `with` forms, and for resources wrapping plain data.
|
|
10
|
+
- Added end-to-end checks resolving a real vehicle and a real fleet against an order-shaped request.
|
|
36
11
|
|
|
37
12
|
---
|
|
38
13
|
## Need help?
|
|
@@ -241,7 +241,10 @@ export default class ManagementDriversIndexController extends Controller {
|
|
|
241
241
|
this.notifications.serverError(err);
|
|
242
242
|
}
|
|
243
243
|
},
|
|
244
|
-
|
|
244
|
+
// Driver list responses already include the vendor_name accessor,
|
|
245
|
+
// while the vendor relationship itself is not eager loaded. Use
|
|
246
|
+
// the scalar so assigned vendors do not render as blank cells.
|
|
247
|
+
valuePath: 'vendor_name',
|
|
245
248
|
modelNamePath: 'name',
|
|
246
249
|
resizable: true,
|
|
247
250
|
filterable: true,
|
|
@@ -24,6 +24,6 @@ export default class ManagementFleetsIndexDetailsRoute extends Route {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
model({ public_id }) {
|
|
27
|
-
return this.store.queryRecord('fleet', { public_id, single: true, with: ['
|
|
27
|
+
return this.store.queryRecord('fleet', { public_id, single: true, with: ['parentFleet', 'serviceArea', 'zone', 'subFleets', 'drivers', 'vehicles'] });
|
|
28
28
|
}
|
|
29
29
|
}
|
|
@@ -24,6 +24,6 @@ export default class ManagementFleetsIndexEditRoute extends Route {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
model({ public_id }) {
|
|
27
|
-
return this.store.queryRecord('fleet', { public_id, single: true, with: ['
|
|
27
|
+
return this.store.queryRecord('fleet', { public_id, single: true, with: ['parentFleet', 'serviceArea', 'zone'] });
|
|
28
28
|
}
|
|
29
29
|
}
|
|
@@ -24,6 +24,8 @@ export default class ManagementFleetsIndexRoute extends Route {
|
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
model(params) {
|
|
27
|
-
|
|
27
|
+
// Internal Fleet-Ops requests go directly to Eloquent's relation loader,
|
|
28
|
+
// so these must be the model method names rather than public API aliases.
|
|
29
|
+
return this.store.query('fleet', { ...params, with: ['parentFleet', 'serviceArea', 'zone'] });
|
|
28
30
|
}
|
|
29
31
|
}
|
package/composer.json
CHANGED
package/extension.json
CHANGED
package/package.json
CHANGED
|
@@ -22,8 +22,13 @@ trait ResolvesPublicRelationFields
|
|
|
22
22
|
/**
|
|
23
23
|
* The relations the caller asked for, as real Eloquent relation names.
|
|
24
24
|
*
|
|
25
|
-
* The controller
|
|
26
|
-
*
|
|
25
|
+
* The controller normalises, maps and allowlists `with` / `expand` for the
|
|
26
|
+
* resource it returns. That guarantee does not travel: this resource is also
|
|
27
|
+
* rendered nested inside Order and Driver responses, where the request's
|
|
28
|
+
* `with` names the parent's relations (`payload`, `driverAssigned.vehicle`).
|
|
29
|
+
* A nested resource re-reads the same request, so only relations the wrapped
|
|
30
|
+
* model actually defines are kept — a parent's expansion must never reach
|
|
31
|
+
* this model's loader, where it raises RelationNotFoundException.
|
|
27
32
|
*
|
|
28
33
|
* @return array<int, string>
|
|
29
34
|
*/
|
|
@@ -39,7 +44,28 @@ trait ResolvesPublicRelationFields
|
|
|
39
44
|
return [];
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
$with = array_values(array_filter(array_map('strval', $with), 'strlen'));
|
|
48
|
+
|
|
49
|
+
return array_values(array_filter($with, fn (string $relation): bool => $this->definesRelation($relation)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether the wrapped model defines the root of a relation path.
|
|
54
|
+
*
|
|
55
|
+
* Only the first segment is checked: nested segments belong to the related
|
|
56
|
+
* model, and Eloquent validates those itself once the root exists.
|
|
57
|
+
*/
|
|
58
|
+
private function definesRelation(string $relation): bool
|
|
59
|
+
{
|
|
60
|
+
$resource = $this->resource;
|
|
61
|
+
|
|
62
|
+
if (!is_object($resource) || !method_exists($resource, 'loadMissing')) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
$root = explode('.', $relation, 2)[0];
|
|
67
|
+
|
|
68
|
+
return $root !== '' && method_exists($resource, $root);
|
|
43
69
|
}
|
|
44
70
|
|
|
45
71
|
/**
|
|
@@ -24,7 +24,9 @@ class Fleet extends FleetbaseResource
|
|
|
24
24
|
// dropped anything outside the public allowlist, so they are safe to
|
|
25
25
|
// hand to the loader. `subfleets` used to arrive here spelled exactly
|
|
26
26
|
// that and reach `load('subfleets')`, which is not the relation —
|
|
27
|
-
// the documented expansion raised instead of resolving.
|
|
27
|
+
// the documented expansion raised instead of resolving. Relations the
|
|
28
|
+
// fleet does not define are dropped as well: nested under another
|
|
29
|
+
// response the request's `with` describes the parent, not the fleet.
|
|
28
30
|
$with = $this->requestedRelations($request);
|
|
29
31
|
|
|
30
32
|
if ($with !== []) {
|
|
@@ -452,3 +452,26 @@ test('an unsupported expansion on retrieve cannot reach eloquent', function () {
|
|
|
452
452
|
->and($payload)->not->toHaveKey('not_a_relation')
|
|
453
453
|
->and($request->input('with'))->toBe(['vendor']);
|
|
454
454
|
});
|
|
455
|
+
|
|
456
|
+
test('a vehicle nested in an order response ignores the order expansions', function () {
|
|
457
|
+
fleetopsVehicleContractBoot();
|
|
458
|
+
|
|
459
|
+
$controller = new VehicleController();
|
|
460
|
+
$createRequest = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', ['make' => 'Ford', 'driver' => 'driver_contract1']);
|
|
461
|
+
$created = $controller->create($createRequest)->resolve($createRequest);
|
|
462
|
+
$vehicle = Vehicle::where('public_id', $created['id'])->firstOrFail();
|
|
463
|
+
|
|
464
|
+
// An order fetch expands the order's own relations. The Order resource nests
|
|
465
|
+
// the assigned vehicle as a full Vehicle resource, which re-reads the same
|
|
466
|
+
// request: `payload` is not a vehicle relation and used to reach load(),
|
|
467
|
+
// answering the whole order request with a RelationNotFoundException.
|
|
468
|
+
$orderRequest = fleetopsVehicleContractRequest(Request::class, 'GET', ['with' => ['payload', 'driverAssigned.vehicle', 'driver']], 'v1/orders');
|
|
469
|
+
$payload = (new Fleetbase\FleetOps\Http\Resources\v1\Vehicle($vehicle))->resolve($orderRequest);
|
|
470
|
+
|
|
471
|
+
expect($payload['id'])->toBe($created['id'])
|
|
472
|
+
->and($payload)->not->toHaveKey('payload')
|
|
473
|
+
->and($vehicle->relationLoaded('payload'))->toBeFalse()
|
|
474
|
+
// The vehicle's own expansion in the same list still resolves.
|
|
475
|
+
->and($payload['driver'])->toBeObject()
|
|
476
|
+
->and($payload['driver_id'])->toBe('driver_contract1');
|
|
477
|
+
});
|
|
@@ -144,3 +144,20 @@ test('fleet resource accepts the explicit nested expansion spelling', function (
|
|
|
144
144
|
expect($subFleet->relationLoaded('drivers'))->toBeTrue()
|
|
145
145
|
->and($subFleet->relationLoaded('vehicles'))->toBeFalse();
|
|
146
146
|
});
|
|
147
|
+
|
|
148
|
+
test('fleet resource ignores expansions that describe a parent resource', function () {
|
|
149
|
+
fleetopsFleetResourceBoot();
|
|
150
|
+
|
|
151
|
+
$fleet = Fleet::where('uuid', 'fleet-parent-1')->first();
|
|
152
|
+
|
|
153
|
+
// Rendered inside another response, the request's `with` is the parent's
|
|
154
|
+
// list. `payload` is not a fleet relation and used to raise from load();
|
|
155
|
+
// the fleet's own relation in the same list still loads.
|
|
156
|
+
$request = Request::create('/v1/orders', 'GET', ['with' => ['payload', 'drivers']]);
|
|
157
|
+
$resolved = (new FleetResource($fleet))->resolve($request);
|
|
158
|
+
|
|
159
|
+
expect($resolved['name'])->toBe('Parent Fleet')
|
|
160
|
+
->and($resolved)->not->toHaveKey('payload')
|
|
161
|
+
->and($fleet->relationLoaded('payload'))->toBeFalse()
|
|
162
|
+
->and($fleet->relationLoaded('drivers'))->toBeTrue();
|
|
163
|
+
});
|
|
@@ -311,3 +311,42 @@ test('vehicle without driver resource omits driver relationships and serializes
|
|
|
311
311
|
])
|
|
312
312
|
->and($webhook)->not->toHaveKey('driver');
|
|
313
313
|
});
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Resolve the request's `with` list the way a nested resource does.
|
|
317
|
+
*
|
|
318
|
+
* @return array<int, string>
|
|
319
|
+
*/
|
|
320
|
+
function fleetopsVehicleResourceRequestedRelations(mixed $resource, array $query): array
|
|
321
|
+
{
|
|
322
|
+
$request = Request::create('/api/v1/fleet-ops/orders', 'GET', $query);
|
|
323
|
+
$method = new ReflectionMethod(VehicleResource::class, 'requestedRelations');
|
|
324
|
+
$method->setAccessible(true);
|
|
325
|
+
|
|
326
|
+
return $method->invoke(new VehicleResource($resource), $request);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
test('requested relations keep only what the wrapped model defines', function () {
|
|
330
|
+
$vehicle = new class(['uuid' => 'vehicle-uuid']) extends FleetOpsVehicleResourceFixture {
|
|
331
|
+
public function driver(): void
|
|
332
|
+
{
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Nested under an Order, the request's `with` describes the order:
|
|
337
|
+
// `payload` and `driverAssigned.vehicle` are not vehicle relations and must
|
|
338
|
+
// never reach this model's loader. The vehicle's own relations still pass,
|
|
339
|
+
// including a nested path whose root the vehicle defines.
|
|
340
|
+
expect(fleetopsVehicleResourceRequestedRelations($vehicle, ['with' => ['payload', 'driverAssigned.vehicle', 'driver', 'driver.user', '']]))->toBe(['driver', 'driver.user'])
|
|
341
|
+
->and(fleetopsVehicleResourceRequestedRelations($vehicle, ['with' => 'payload,driver']))->toBe(['driver'])
|
|
342
|
+
->and(fleetopsVehicleResourceRequestedRelations($vehicle, ['with' => ['.driver']]))->toBe([])
|
|
343
|
+
->and(fleetopsVehicleResourceRequestedRelations($vehicle, []))->toBe([]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('requested relations are empty when the resource cannot load any', function () {
|
|
347
|
+
// Webhook fixtures and compact serializers hand the resource plain data;
|
|
348
|
+
// there is no loader to feed, so nothing is requested from it.
|
|
349
|
+
expect(fleetopsVehicleResourceRequestedRelations(['uuid' => 'vehicle-uuid'], ['with' => ['driver']]))->toBe([])
|
|
350
|
+
->and(fleetopsVehicleResourceRequestedRelations((object) ['uuid' => 'vehicle-uuid'], ['with' => ['driver']]))->toBe([])
|
|
351
|
+
->and(fleetopsVehicleResourceRequestedRelations(fleetopsVehicleResourceFixture(), ['with' => ['driver']]))->toBe([]);
|
|
352
|
+
});
|