@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.
Files changed (68) hide show
  1. package/RELEASE.md +5 -14
  2. package/addon/controllers/management/drivers/index.js +4 -1
  3. package/addon/routes/management/fleets/index/details.js +1 -1
  4. package/addon/routes/management/fleets/index/edit.js +1 -1
  5. package/addon/routes/management/fleets/index.js +3 -1
  6. package/addon/services/map-settings.js +4 -1
  7. package/composer.json +1 -1
  8. package/extension.json +1 -1
  9. package/package.json +1 -1
  10. package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
  11. package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
  12. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
  13. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
  14. package/server/src/Http/Controllers/Api/v1/DriverController.php +93 -57
  15. package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
  16. package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
  17. package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
  18. package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
  19. package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
  20. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
  21. package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
  22. package/server/src/Http/Controllers/Api/v1/VehicleController.php +134 -35
  23. package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
  24. package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
  25. package/server/src/Http/Filter/DriverFilter.php +71 -15
  26. package/server/src/Http/Filter/FleetFilter.php +32 -33
  27. package/server/src/Http/Filter/VehicleFilter.php +57 -19
  28. package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
  29. package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
  30. package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
  31. package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
  32. package/server/src/Http/Requests/CreateVehicleRequest.php +150 -31
  33. package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
  34. package/server/src/Http/Resources/v1/Driver.php +22 -2
  35. package/server/src/Http/Resources/v1/Fleet.php +38 -25
  36. package/server/src/Http/Resources/v1/Vehicle.php +46 -4
  37. package/server/src/Models/Driver.php +2 -2
  38. package/server/src/routes.php +8 -0
  39. package/server/tests/ApiDriverControllerContractsTest.php +284 -13
  40. package/server/tests/ApiEntityControllerContractsTest.php +1 -1
  41. package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
  42. package/server/tests/ApiFleetControllerContractsTest.php +354 -21
  43. package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
  44. package/server/tests/ApiIssueControllerContractsTest.php +1 -1
  45. package/server/tests/ApiOrderControllerContractsTest.php +1 -1
  46. package/server/tests/ApiPartControllerContractsTest.php +2 -2
  47. package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
  48. package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
  49. package/server/tests/ApiVehicleControllerContractsTest.php +175 -8
  50. package/server/tests/ControllerFilterContractsTest.php +132 -13
  51. package/server/tests/ControllerHelperContractsTest.php +6 -3
  52. package/server/tests/DriverFilterExecutionTest.php +130 -6
  53. package/server/tests/ExceptionContractsTest.php +15 -0
  54. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
  55. package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
  56. package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
  57. package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
  58. package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
  59. package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
  60. package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
  61. package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
  62. package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
  63. package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
  64. package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
  65. package/server/tests/FleetHierarchyResourceTest.php +56 -8
  66. package/server/tests/RequestContractsTest.php +100 -7
  67. package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
  68. package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
@@ -0,0 +1,454 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Http\Controllers\Api\v1\VehicleController;
4
+ use Fleetbase\FleetOps\Http\Requests\CreateVehicleRequest;
5
+ use Fleetbase\FleetOps\Http\Requests\UpdateVehicleRequest;
6
+ use Fleetbase\FleetOps\Models\Vehicle;
7
+ use Illuminate\Database\ConnectionResolver;
8
+ use Illuminate\Database\Eloquent\Model as EloquentModel;
9
+ use Illuminate\Database\SQLiteConnection;
10
+ use Illuminate\Http\Request;
11
+ use Illuminate\Support\Facades\DB;
12
+
13
+ /**
14
+ * Write/read parity for the public Vehicle contract, proved against a real
15
+ * database and the real controller rather than by inspecting the input array.
16
+ *
17
+ * A field that validates, persists, and then never appears in a response is the
18
+ * failure this file exists to catch: the caller gets a 200 and a body that looks
19
+ * right, and only discovers the loss when something downstream reads the record
20
+ * back. Asserting that a key entered `$request->only()` does not catch it.
21
+ */
22
+ if (!function_exists('Fleetbase\Observers\event')) {
23
+ eval('namespace Fleetbase\Observers; function event($event = null) { return $event; }');
24
+ }
25
+
26
+ if (!function_exists('Fleetbase\Support\auth')) {
27
+ eval('namespace Fleetbase\Support; function auth() { return new class { public function user() { return null; } public function id() { return null; } }; }');
28
+ }
29
+
30
+ if (!function_exists('Fleetbase\Support\session')) {
31
+ 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); }');
32
+ }
33
+
34
+ if (!Request::hasMacro('getController')) {
35
+ Request::macro('getController', fn () => new VehicleController());
36
+ }
37
+
38
+ if (!Request::hasMacro('or')) {
39
+ Request::macro('or', function (array $params = [], $default = null) {
40
+ foreach ($params as $param) {
41
+ if ($this->has($param)) {
42
+ return $this->input($param);
43
+ }
44
+ }
45
+
46
+ return $default;
47
+ });
48
+ }
49
+
50
+ class FleetOpsVehicleContractRoute
51
+ {
52
+ public array $action = [];
53
+
54
+ public function __construct(private string $uri)
55
+ {
56
+ }
57
+
58
+ public function uri(): string
59
+ {
60
+ return $this->uri;
61
+ }
62
+
63
+ public function getAction($key = null): string
64
+ {
65
+ return VehicleController::class . '@query';
66
+ }
67
+
68
+ public function getActionMethod(): string
69
+ {
70
+ return 'query';
71
+ }
72
+
73
+ public function getName(): string
74
+ {
75
+ return 'api.v1.vehicles.query';
76
+ }
77
+
78
+ public function parameters(): array
79
+ {
80
+ return [];
81
+ }
82
+ }
83
+
84
+ function fleetopsVehicleContractBoot(): SQLiteConnection
85
+ {
86
+ $pdo = new PDO('sqlite::memory:');
87
+
88
+ // The spatial grammar wraps a point in ST_GeomFromText even on sqlite, so the
89
+ // function has to exist for a vehicle carrying coordinates to save at all.
90
+ foreach (['ST_PointFromText', 'ST_GeomFromText'] as $fn) {
91
+ $pdo->sqliteCreateFunction($fn, fn ($wkt, $srid = 0, $axisOrder = null) => $wkt);
92
+ }
93
+
94
+ $connection = new SQLiteConnection($pdo);
95
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
96
+ $resolver->setDefaultConnection('mysql');
97
+ EloquentModel::setConnectionResolver($resolver);
98
+
99
+ // Without a dispatcher the uuid and public_id creating hooks never fire and
100
+ // every record comes back with a null id — which reads as a serialization
101
+ // bug rather than a missing fixture. Memoised: a fresh dispatcher would drop
102
+ // the hooks of models booted earlier in the process.
103
+ if (!EloquentModel::getEventDispatcher()) {
104
+ EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher());
105
+ }
106
+
107
+ if (!app()->bound('responsecache')) {
108
+ app()->instance('responsecache', new class {
109
+ public function __call($method, $arguments)
110
+ {
111
+ return null;
112
+ }
113
+ });
114
+ }
115
+
116
+ if (!Illuminate\Support\Str::hasMacro('humanize')) {
117
+ Illuminate\Support\Str::macro('humanize', fn ($value, $uppercase = true) => str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value)));
118
+ }
119
+
120
+ config()->set('activitylog.enabled', false);
121
+ config()->set('activitylog.default_auth_driver', 'web');
122
+ app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config());
123
+
124
+ app()->instance('db', new class($connection) {
125
+ public function __construct(public SQLiteConnection $c)
126
+ {
127
+ }
128
+
129
+ public function connection($name = null): SQLiteConnection
130
+ {
131
+ return $this->c;
132
+ }
133
+
134
+ public function __call($method, $arguments)
135
+ {
136
+ return $this->c->{$method}(...$arguments);
137
+ }
138
+ });
139
+ app()->instance('db.schema', $connection->getSchemaBuilder());
140
+ DB::clearResolvedInstance('db');
141
+
142
+ $schema = $connection->getSchemaBuilder();
143
+ $vehicle = new Vehicle();
144
+
145
+ $schema->create('vehicles', function ($blueprint) use ($vehicle) {
146
+ $blueprint->increments('id');
147
+ foreach (array_unique(array_merge($vehicle->getFillable(), ['uuid', 'public_id', '_key', 'telematic_uuid'])) as $column) {
148
+ $blueprint->text($column)->nullable();
149
+ }
150
+ $blueprint->timestamps();
151
+ $blueprint->timestamp('deleted_at')->nullable();
152
+ });
153
+
154
+ foreach (['vendors', 'categories', 'warranties', 'files', 'drivers', 'users', 'directives', 'orders', 'custom_field_values', 'custom_fields', 'places', 'contacts', 'vendor_personnels', 'integrated_vendors'] as $table) {
155
+ $schema->create($table, function ($blueprint) {
156
+ $blueprint->increments('id');
157
+ foreach ([
158
+ 'uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'vehicle_uuid',
159
+ 'name', '_key', 'for', 'owner_uuid', 'owner_type', 'subject_uuid', 'subject_type',
160
+ 'permission_uuid', 'key', 'rules', 'disk', 'path', 'bucket', 'type', 'original_filename',
161
+ 'provider', 'policy_number', 'status', 'vehicle_assigned_uuid', 'driver_assigned_uuid', 'tracking',
162
+ 'custom_field_uuid', 'value', 'value_type', 'label', 'contact_uuid', 'vendor_uuid', 'place_uuid',
163
+ ] as $column) {
164
+ $blueprint->text($column)->nullable();
165
+ }
166
+ $blueprint->timestamps();
167
+ $blueprint->timestamp('deleted_at')->nullable();
168
+ });
169
+ }
170
+
171
+ session(['company' => 'company-uuid']);
172
+ $connection->table('vendors')->insert(['uuid' => 'vendor-uuid', 'public_id' => 'vendor_contract1', 'company_uuid' => 'company-uuid', 'name' => 'Acme']);
173
+ $connection->table('users')->insert(['uuid' => 'user-uuid', 'company_uuid' => 'company-uuid']);
174
+ $connection->table('drivers')->insert(['uuid' => 'driver-uuid', 'public_id' => 'driver_contract1', 'company_uuid' => 'company-uuid', 'user_uuid' => 'user-uuid']);
175
+
176
+ return $connection;
177
+ }
178
+
179
+ function fleetopsVehicleContractRequest(string $class, string $method, array $payload, string $uri = 'v1/vehicles')
180
+ {
181
+ $request = $class::create('/' . $uri, $method, $payload);
182
+ $store = app('session.store');
183
+ $store->put('company', 'company-uuid');
184
+ $request->setLaravelSession($store);
185
+ $request->setRouteResolver(fn () => new FleetOpsVehicleContractRoute($uri));
186
+ app()->instance('request', $request);
187
+
188
+ return $request;
189
+ }
190
+
191
+ /**
192
+ * Every safe writable field, with a value whose type exercises its cast.
193
+ *
194
+ * @return array<string, mixed>
195
+ */
196
+ function fleetopsVehicleContractPayload(): array
197
+ {
198
+ return [
199
+ 'internal_id' => 'VEH-9001', 'name' => 'Depot Van', 'description' => 'City route van',
200
+ 'make' => 'Ford', 'model' => 'Transit', 'model_type' => 'Custom', 'year' => 2024,
201
+ 'trim' => 'Trend', 'color' => 'White', 'type' => 'van', 'class' => 'N1',
202
+ 'plate_number' => 'SG-9001', 'vin' => '1FTBW3XG8NKA00001', 'serial_number' => 'SER-9001',
203
+ 'call_sign' => 'DEPOT-1', 'fuel_card_number' => 'FC-9001',
204
+ 'odometer' => 41000, 'odometer_unit' => 'km', 'odometer_at_purchase' => 12,
205
+ 'measurement_system' => 'metric', 'fuel_type' => 'diesel', 'fuel_volume_unit' => 'l',
206
+ 'online' => true, 'status' => 'available',
207
+ 'transmission' => 'automatic', 'body_type' => 'panel_van', 'body_sub_type' => 'lwb',
208
+ 'usage_type' => 'commercial', 'ownership_type' => 'owned', 'cargo_volume' => 11.5,
209
+ 'passenger_volume' => 3.2, 'interior_volume' => 14.7, 'weight' => 2100.5, 'width' => 2.06,
210
+ 'length' => 5.98, 'height' => 2.54, 'towing_capacity' => 2500, 'payload_capacity' => 1400,
211
+ 'seating_capacity' => 3, 'ground_clearance' => 0.18, 'bed_length' => 3.4, 'fuel_capacity' => 70,
212
+ 'financing_status' => 'financed', 'loan_number_of_payments' => 48,
213
+ 'loan_first_payment' => '2026-01-15', 'loan_amount' => 32000, 'currency' => 'SGD',
214
+ 'estimated_service_life_distance_unit' => 'km', 'estimated_service_life_distance' => 400000,
215
+ 'estimated_service_life_months' => 96, 'insurance_value' => 41000, 'depreciation_rate' => 12.5,
216
+ 'current_value' => 38000, 'acquisition_cost' => 52000,
217
+ 'purchased_at' => '2026-01-02', 'lease_expires_at' => '2029-01-02',
218
+ 'emission_standard' => 'euro6', 'dpf_equipped' => true, 'scr_equipped' => false,
219
+ 'gvwr' => 3500, 'gcwr' => 6000, 'engine_number' => 'ENG-9001', 'engine_model' => 'EcoBlue',
220
+ 'engine_make' => 'Ford', 'engine_family' => 'Puma', 'engine_configuration' => 'inline',
221
+ 'engine_displacement' => 2.0, 'engine_size' => 1995, 'horsepower' => 168,
222
+ 'horsepower_rpm' => 3500, 'torque' => 405, 'torque_rpm' => 1750,
223
+ 'number_of_cylinders' => 4, 'cylinder_arrangement' => 'I4',
224
+ 'specs' => ['doors' => 4], 'details' => ['liftgate' => true], 'notes' => 'City pool',
225
+ 'meta' => ['depot' => 'north'],
226
+ 'skills' => ['tail_lift'], 'payload_capacity_volume' => 11.25,
227
+ 'payload_capacity_pallets' => 6, 'payload_capacity_parcels' => 320, 'max_tasks' => 40,
228
+ 'time_window_start' => '08:00', 'time_window_end' => '18:00', 'return_to_depot' => true,
229
+ 'altitude' => 15, 'heading' => 180, 'speed' => 42,
230
+ ];
231
+ }
232
+
233
+ /**
234
+ * Compare a serialized response against what was sent, allowing for the casts
235
+ * the model applies on the way through.
236
+ *
237
+ * @return array<int, string> fields whose round trip did not hold
238
+ */
239
+ function fleetopsVehicleContractMismatches(array $sent, array $payload): array
240
+ {
241
+ $mismatches = [];
242
+
243
+ foreach ($sent as $field => $expected) {
244
+ if (!array_key_exists($field, $payload)) {
245
+ $mismatches[] = $field . ' (absent from the response)';
246
+ continue;
247
+ }
248
+
249
+ $actual = $payload[$field];
250
+
251
+ if (is_array($expected)) {
252
+ if (json_encode($actual) !== json_encode($expected)) {
253
+ $mismatches[] = $field;
254
+ }
255
+ continue;
256
+ }
257
+
258
+ if (is_bool($expected)) {
259
+ if ((bool) $actual !== $expected) {
260
+ $mismatches[] = $field;
261
+ }
262
+ continue;
263
+ }
264
+
265
+ if (is_numeric($expected) && is_numeric($actual)) {
266
+ // Decimal casts return strings; the value is what matters, not the shape.
267
+ if (abs(((float) $actual) - ((float) $expected)) > 0.0001) {
268
+ $mismatches[] = $field;
269
+ }
270
+ continue;
271
+ }
272
+
273
+ // Dates and datetimes are the documented canonical transformation: a
274
+ // `2026-01-02` goes in and an ISO-8601 instant comes back out.
275
+ if ($actual instanceof DateTimeInterface) {
276
+ if ($actual->format('Y-m-d') !== substr((string) $expected, 0, 10)) {
277
+ $mismatches[] = $field . ' (' . $actual->format('c') . ' != ' . $expected . ')';
278
+ }
279
+ continue;
280
+ }
281
+
282
+ $actualString = is_scalar($actual) ? (string) $actual : trim((string) json_encode($actual), '"');
283
+
284
+ // Times come back with seconds appended.
285
+ if ($actualString !== '' && str_starts_with($actualString, (string) $expected)) {
286
+ continue;
287
+ }
288
+
289
+ if ($actualString !== (string) $expected) {
290
+ $mismatches[] = $field . ' (' . $actualString . ' != ' . $expected . ')';
291
+ }
292
+ }
293
+
294
+ return $mismatches;
295
+ }
296
+
297
+ test('every safe writable vehicle field survives create retrieve update and query', function () {
298
+ fleetopsVehicleContractBoot();
299
+
300
+ $controller = new VehicleController();
301
+ $sent = fleetopsVehicleContractPayload();
302
+
303
+ // ---- create ----
304
+ $createRequest = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', $sent + ['vendor' => 'vendor_contract1']);
305
+ $created = $controller->create($createRequest)->resolve($createRequest);
306
+
307
+ expect(fleetopsVehicleContractMismatches($sent, $created))->toBe([])
308
+ ->and($created['id'])->toMatch('/^vehicle_/')
309
+ ->and($created['vendor_id'])->toBe('vendor_contract1');
310
+
311
+ $publicId = $created['id'];
312
+
313
+ // ---- retrieve ----
314
+ $findRequest = fleetopsVehicleContractRequest(Request::class, 'GET', []);
315
+ $found = $controller->find($publicId, $findRequest)->resolve($findRequest);
316
+
317
+ expect(fleetopsVehicleContractMismatches($sent, $found))->toBe([]);
318
+
319
+ // ---- update: every field again, with different values ----
320
+ $updated = [
321
+ 'name' => 'Depot Van 2', 'odometer' => 41250, 'color' => 'Silver',
322
+ 'seating_capacity' => 5, 'weight' => 2200.25, 'purchased_at' => '2026-02-03',
323
+ 'loan_first_payment' => '2026-03-15', 'loan_amount' => 31000,
324
+ 'insurance_value' => 40000, 'depreciation_rate' => 11.5, 'current_value' => 37000,
325
+ 'acquisition_cost' => 51000, 'specs' => ['doors' => 5], 'details' => ['liftgate' => false],
326
+ 'notes' => 'Regional pool', 'meta' => ['depot' => 'south'], 'skills' => ['refrigerated'],
327
+ 'max_tasks' => 30, 'time_window_start' => '07:30', 'time_window_end' => '17:30',
328
+ 'return_to_depot' => false, 'fuel_card_number' => 'FC-9002', 'online' => false,
329
+ ];
330
+ $updateRequest = fleetopsVehicleContractRequest(UpdateVehicleRequest::class, 'PUT', $updated);
331
+ $updateResponse = $controller->update($publicId, $updateRequest)->resolve($updateRequest);
332
+
333
+ expect(fleetopsVehicleContractMismatches($updated, $updateResponse))->toBe([])
334
+ // A partial update leaves everything it did not name alone.
335
+ ->and($updateResponse['vin'])->toBe('1FTBW3XG8NKA00001')
336
+ ->and($updateResponse['make'])->toBe('Ford')
337
+ ->and($updateResponse['vendor_id'])->toBe('vendor_contract1');
338
+
339
+ // ---- query ----
340
+ $queryRequest = fleetopsVehicleContractRequest(Request::class, 'GET', []);
341
+ $collection = $controller->query($queryRequest)->resolve($queryRequest);
342
+ $queried = $collection[0];
343
+
344
+ expect(fleetopsVehicleContractMismatches($updated, $queried))->toBe([])
345
+ // The four operations agree on the same business-field contract.
346
+ ->and(array_diff(array_keys($created), array_keys($queried)))->toBe([]);
347
+ });
348
+
349
+ test('vehicle status normalisation and coordinate canonicalisation are the documented transformations', function () {
350
+ fleetopsVehicleContractBoot();
351
+
352
+ $controller = new VehicleController();
353
+ $request = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', [
354
+ 'make' => 'Ford',
355
+ 'status' => 'active',
356
+ 'latitude' => 40.7484,
357
+ 'longitude' => -73.9857,
358
+ ]);
359
+
360
+ $payload = $controller->create($request)->resolve($request);
361
+
362
+ // `active` is accepted and stored as `available`; the coordinates arrive as
363
+ // two scalars and come back canonically inside `location`.
364
+ expect($payload['status'])->toBe('available')
365
+ ->and($payload)->toHaveKey('location')
366
+ ->and($payload['location'])->not->toBeNull();
367
+ });
368
+
369
+ test('vehicle relationships are additive: the id is new, the object keeps its shape', function () {
370
+ $connection = fleetopsVehicleContractBoot();
371
+ $controller = new VehicleController();
372
+
373
+ $request = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', [
374
+ 'make' => 'Ford',
375
+ 'vendor' => 'vendor_contract1',
376
+ 'driver' => 'driver_contract1',
377
+ ]);
378
+ $created = $controller->create($request)->resolve($request);
379
+
380
+ expect($created['vendor_id'])->toBe('vendor_contract1')
381
+ ->and($created['driver_id'])->toBe('driver_contract1')
382
+ ->and($created['category_id'])->toBeNull()
383
+ ->and($created['warranty_id'])->toBeNull()
384
+ ->and($created['photo_id'])->toBeNull()
385
+ // Unexpanded, the object keys stay absent — never a string under them.
386
+ ->and($created)->not->toHaveKeys(['vendor', 'driver', 'category', 'warranty', 'photo'])
387
+ ->and($created)->not->toHaveKeys(['uuid', 'public_id', 'company_uuid', 'vendor_uuid', 'photo_uuid']);
388
+
389
+ // Expansion adds the object and leaves the identifier untouched.
390
+ $expandRequest = fleetopsVehicleContractRequest(Request::class, 'GET', ['with' => ['vendor']]);
391
+ $expanded = $controller->find($created['id'], $expandRequest)->resolve($expandRequest);
392
+
393
+ expect($expanded['vendor'])->toBeObject()
394
+ ->and($expanded['vendor_id'])->toBe('vendor_contract1')
395
+ ->and($expanded['vendor']->resolve()['id'])->toBe($expanded['vendor_id'])
396
+ ->and($expanded)->not->toHaveKey('driver');
397
+
398
+ // Clearing a relationship reports a null identifier.
399
+ $clearRequest = fleetopsVehicleContractRequest(UpdateVehicleRequest::class, 'PUT', ['vendor' => null]);
400
+ $cleared = $controller->update($created['id'], $clearRequest)->resolve($clearRequest);
401
+
402
+ expect($cleared['vendor_id'])->toBeNull()
403
+ ->and($connection->table('vehicles')->count())->toBe(1);
404
+ });
405
+
406
+ test('the internal vehicle resource keeps its counters and its nested driver', function () {
407
+ $connection = fleetopsVehicleContractBoot();
408
+ $controller = new VehicleController();
409
+
410
+ $createRequest = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', [
411
+ 'make' => 'Ford',
412
+ 'driver' => 'driver_contract1',
413
+ ]);
414
+ $created = $controller->create($createRequest)->resolve($createRequest);
415
+
416
+ $vehicleUuid = $connection->table('vehicles')->where('public_id', $created['id'])->value('uuid');
417
+ $connection->table('orders')->insert([
418
+ ['uuid' => 'order-1', 'public_id' => 'order_1', 'company_uuid' => 'company-uuid', 'vehicle_assigned_uuid' => $vehicleUuid, 'tracking' => 'TRK-1'],
419
+ ['uuid' => 'order-2', 'public_id' => 'order_2', 'company_uuid' => 'company-uuid', 'vehicle_assigned_uuid' => $vehicleUuid, 'tracking' => 'TRK-2'],
420
+ ]);
421
+
422
+ $internalRequest = fleetopsVehicleContractRequest(Request::class, 'GET', [], 'int/v1/fleet-ops/vehicles');
423
+ $vehicle = Vehicle::where('public_id', $created['id'])->firstOrFail();
424
+ $payload = (new Fleetbase\FleetOps\Http\Resources\v1\Vehicle($vehicle))->resolve($internalRequest);
425
+
426
+ // The console reads these; the public contract does not, and no longer pays
427
+ // for them — `when()` evaluates a plain value argument eagerly, so both
428
+ // counters used to run a query on every public read and discard it.
429
+ expect($payload)->toHaveKeys(['uuid', 'public_id', 'assigned_orders_count', 'current_order_reference'])
430
+ ->and($payload['assigned_orders_count'])->toBe(2)
431
+ ->and($payload['uuid'])->toBe($vehicleUuid)
432
+ // Internal keeps the whenLoaded object shape it has always had.
433
+ ->and($payload['driver'])->toBeObject();
434
+ });
435
+
436
+ test('an unsupported expansion on retrieve cannot reach eloquent', function () {
437
+ fleetopsVehicleContractBoot();
438
+
439
+ $controller = new VehicleController();
440
+ $createRequest = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', ['make' => 'Ford']);
441
+ $created = $controller->create($createRequest)->resolve($createRequest);
442
+
443
+ // Retrieve is the endpoint a hand-written client is most likely to hand a
444
+ // stale relation name. The request parameter carries a default, and
445
+ // Laravel's dispatcher skips injecting a type-hinted dependency that has
446
+ // one — so the controller reads the container's request instead. Without
447
+ // that, `not_a_relation` reached load() and answered 500.
448
+ $request = fleetopsVehicleContractRequest(Request::class, 'GET', ['with' => ['vendor', 'not_a_relation']]);
449
+ $payload = $controller->find($created['id'])->resolve($request);
450
+
451
+ expect($payload['id'])->toBe($created['id'])
452
+ ->and($payload)->not->toHaveKey('not_a_relation')
453
+ ->and($request->input('with'))->toBe(['vendor']);
454
+ });
@@ -44,7 +44,7 @@ class FleetOpsApiWorkOrderControllerProbe extends WorkOrderController
44
44
  ];
45
45
  }
46
46
 
47
- protected function resolveModel(string $modelClass, string $id): Model
47
+ protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): Model
48
48
  {
49
49
  $key = $modelClass . ':' . $id;
50
50
 
@@ -1,12 +1,60 @@
1
1
  <?php
2
2
 
3
+ use Fleetbase\FleetOps\Http\Controllers\Api\v1\FleetController;
4
+ use Illuminate\Http\Request;
5
+
6
+ /**
7
+ * Subfleet expansion, asserted through the mapping the request actually goes
8
+ * through rather than through the shape of the source file.
9
+ *
10
+ * The public name and the relation differ only in case — `subfleets` against
11
+ * `subFleets` — and the automatic camelCase normalisation upstream leaves
12
+ * `subfleets` untouched, so the documented expansion reached `load('subfleets')`
13
+ * and raised. The resource used to paper over that with a hand-written special
14
+ * case for two nested paths; the mapping handles every path instead.
15
+ */
16
+ function fleetopsHierarchyExpansions(array $query): array
17
+ {
18
+ $controller = new FleetController();
19
+ $reflection = new ReflectionMethod(FleetController::class, 'resolvePublicExpansions');
20
+ $reflection->setAccessible(true);
21
+
22
+ return $reflection->invoke($controller, new Request($query), FleetController::EXPANDABLE);
23
+ }
24
+
25
+ test('fleet subfleet expansion resolves to the relation name eloquent actually has', function () {
26
+ expect(fleetopsHierarchyExpansions(['with' => 'subfleets']))->toBe(['subFleets'])
27
+ ->and(fleetopsHierarchyExpansions(['with' => ['subfleets']]))->toBe(['subFleets'])
28
+ // The camelCase spelling names the same relation.
29
+ ->and(fleetopsHierarchyExpansions(['with' => 'subFleets']))->toBe(['subFleets']);
30
+ });
31
+
3
32
  test('fleet resource expands subfleet drivers and vehicles for hierarchy payloads', function () {
4
- $resource = file_get_contents(__DIR__ . '/../src/Http/Resources/v1/Fleet.php');
5
-
6
- expect($resource)
7
- ->toContain("if (in_array('subfleets', \$with, true))")
8
- ->toContain("if (in_array('drivers', \$with, true))")
9
- ->toContain("\$this->loadMissing('subFleets.drivers')")
10
- ->toContain("if (in_array('vehicles', \$with, true))")
11
- ->toContain("\$this->loadMissing('subFleets.vehicles')");
33
+ expect(fleetopsHierarchyExpansions(['with' => 'subfleets.drivers']))->toBe(['subFleets.drivers'])
34
+ ->and(fleetopsHierarchyExpansions(['with' => 'subfleets.vehicles']))->toBe(['subFleets.vehicles'])
35
+ ->and(fleetopsHierarchyExpansions(['with' => ['subfleets.drivers', 'subfleets.vehicles']]))
36
+ ->toBe(['subFleets.drivers', 'subFleets.vehicles']);
37
+ });
38
+
39
+ test('fleet expansion refuses a nested path that would recurse or reach an unlisted relation', function () {
40
+ // Nothing under a subfleet re-opens the tree, so an expansion cannot be made
41
+ // to walk the hierarchy indefinitely.
42
+ expect(fleetopsHierarchyExpansions(['with' => 'subfleets.subfleets']))->toBe([])
43
+ ->and(fleetopsHierarchyExpansions(['with' => 'subfleets.drivers.user']))->toBe([])
44
+ ->and(fleetopsHierarchyExpansions(['with' => 'company']))->toBe([]);
45
+ });
46
+
47
+ test('fleet expansion input normalisation handles every malformed shape without raising', function () {
48
+ // Postman and generated clients produce all of these. None may reach
49
+ // Eloquent, and none may raise: the parameter is a convenience, not a
50
+ // reason to fail an otherwise valid request.
51
+ expect(fleetopsHierarchyExpansions(['with' => ['vendor', ['nested', 'array']]]))->toBe(['vendor'])
52
+ ->and(fleetopsHierarchyExpansions(['with' => 'subfleets.']))->toBe([])
53
+ ->and(fleetopsHierarchyExpansions(['with' => '.drivers']))->toBe([])
54
+ ->and(fleetopsHierarchyExpansions(['with' => ' vendor , , zone ']))->toBe(['vendor', 'zone'])
55
+ ->and(fleetopsHierarchyExpansions(['with' => 'vendor,vendor']))->toBe(['vendor'])
56
+ ->and(fleetopsHierarchyExpansions(['with' => '']))->toBe([])
57
+ ->and(fleetopsHierarchyExpansions([]))->toBe([])
58
+ // `expand` is the documented alias and takes over when `with` is empty.
59
+ ->and(fleetopsHierarchyExpansions(['with' => '', 'expand' => 'vendor']))->toBe(['vendor']);
12
60
  });