@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,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
+ });