@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
@@ -94,6 +94,42 @@ class FleetOpsRecordingDriverFilterBuilder
94
94
  }
95
95
  }
96
96
 
97
+ /**
98
+ * A filter whose request resolves to an internal console route.
99
+ *
100
+ * `Http::isInternalRequest()` reads the resolved route's uri, not the request
101
+ * path, so a filter built without a route resolver is always public — which is
102
+ * why the console's uuid branch needs a route to be reached at all.
103
+ */
104
+ function fleetopsInternalDriverFilter(FleetOpsRecordingDriverFilterBuilder $builder): DriverFilter
105
+ {
106
+ $uri = 'int/v1/fleet-ops/drivers';
107
+ $request = Request::create('/' . $uri, 'GET');
108
+ $session = app('session.store');
109
+ $session->put('company', 'company_test');
110
+ $request->setLaravelSession($session);
111
+ $request->setRouteResolver(fn () => new class($uri) {
112
+ public array $action = [];
113
+
114
+ public function __construct(private string $uri)
115
+ {
116
+ }
117
+
118
+ public function uri(): string
119
+ {
120
+ return $this->uri;
121
+ }
122
+ });
123
+
124
+ $filter = new DriverFilter($request);
125
+ $reflection = new ReflectionClass($filter);
126
+ $property = $reflection->getParentClass()->getProperty('builder');
127
+ $property->setAccessible(true);
128
+ $property->setValue($filter, $builder);
129
+
130
+ return $filter;
131
+ }
132
+
97
133
  function fleetopsDriverFilter(FleetOpsRecordingDriverFilterBuilder $builder, array $query = []): DriverFilter
98
134
  {
99
135
  $request = Request::create('/int/v1/drivers', 'GET', $query);
@@ -110,6 +146,63 @@ function fleetopsDriverFilter(FleetOpsRecordingDriverFilterBuilder $builder, arr
110
146
  return $filter;
111
147
  }
112
148
 
149
+ /**
150
+ * Relationship filters resolve public ids to uuids, which is a real query.
151
+ *
152
+ * @return array<string, array<string, string>>
153
+ */
154
+ function fleetopsDriverFilterRelationDatabase(): array
155
+ {
156
+ $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
157
+ $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
158
+ $resolver->setDefaultConnection('mysql');
159
+ Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
160
+ app()->instance('db', new class($connection) {
161
+ public function __construct(public Illuminate\Database\SQLiteConnection $c)
162
+ {
163
+ }
164
+
165
+ public function connection($name = null)
166
+ {
167
+ return $this->c;
168
+ }
169
+
170
+ public function __call($method, $arguments)
171
+ {
172
+ return $this->c->{$method}(...$arguments);
173
+ }
174
+ });
175
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
176
+
177
+ $schema = $connection->getSchemaBuilder();
178
+ foreach (['vendors', 'vehicles', 'fleets'] as $table) {
179
+ if ($schema->hasTable($table)) {
180
+ $schema->drop($table);
181
+ }
182
+
183
+ $schema->create($table, function ($blueprint) {
184
+ $blueprint->increments('id');
185
+ foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'name', '_key'] as $column) {
186
+ $blueprint->string($column)->nullable();
187
+ }
188
+ $blueprint->timestamps();
189
+ $blueprint->timestamp('deleted_at')->nullable();
190
+ });
191
+ }
192
+
193
+ $rows = [
194
+ 'vendors' => ['uuid' => '88888888-8888-4888-8888-888888888801', 'public_id' => 'vendor_dfilterone1'],
195
+ 'vehicles' => ['uuid' => '88888888-8888-4888-8888-888888888802', 'public_id' => 'vehicle_dfilteron'],
196
+ 'fleets' => ['uuid' => '88888888-8888-4888-8888-888888888803', 'public_id' => 'fleet_dfilterone1'],
197
+ ];
198
+
199
+ foreach ($rows as $table => $row) {
200
+ $connection->table($table)->insert($row + ['company_uuid' => 'company_test']);
201
+ }
202
+
203
+ return $rows;
204
+ }
205
+
113
206
  test('driver filter applies internal public and text search scopes', function () {
114
207
  $builder = new FleetOpsRecordingDriverFilterBuilder();
115
208
  $filter = fleetopsDriverFilter($builder);
@@ -128,27 +221,46 @@ test('driver filter applies internal public and text search scopes', function ()
128
221
  });
129
222
 
130
223
  test('driver filter assignment and identity filters execute uuid and public branches', function () {
224
+ $rows = fleetopsDriverFilterRelationDatabase();
131
225
  $builder = new FleetOpsRecordingDriverFilterBuilder();
132
226
  $filter = fleetopsDriverFilter($builder);
133
227
  $uuid = (string) Str::uuid();
134
228
 
135
- $filter->facilitator('vendor_uuid');
136
- $filter->vendor('vendor_uuid');
229
+ $filter->facilitator($rows['vendors']['public_id']);
230
+ $filter->vendor($rows['vendors']['public_id']);
137
231
  $filter->vehicle('unassigned');
232
+ // The console's uuid branch — this filter is constructed on an /int route.
138
233
  $filter->vehicle($uuid);
234
+ $filter->vehicle($rows['vehicles']['public_id']);
235
+ // An identifier that resolves to nothing still narrows by search rather
236
+ // than raising.
139
237
  $filter->vehicle('vehicle_public');
140
238
  $filter->driversLicenseNumber('DL-123');
141
239
  $filter->phone('+15551112222');
142
240
  $filter->country('SG,MY');
143
241
  $filter->country('MN');
144
242
  $filter->status('available,offline');
145
- $filter->fleet('fleet_uuid');
243
+ $filter->fleet($rows['fleets']['public_id']);
244
+
245
+ $whereIns = collect($builder->methodCalls('whereIn'))->map(fn ($call) => [$call[1], $call[2]])->all();
246
+ $nested = collect($builder->methodCalls('whereHas'))
247
+ ->flatMap(fn ($call) => $call[2]->calls)
248
+ ->filter(fn ($call) => $call[0] === 'whereIn')
249
+ ->map(fn ($call) => [$call[1], $call[2]])
250
+ ->all();
146
251
 
147
252
  expect($builder->called('whereNull'))->toBeTrue()
148
- ->and($builder->called('where'))->toBeTrue()
149
253
  ->and($builder->called('whereHas'))->toBeTrue()
150
- ->and($builder->called('whereIn'))->toBeTrue()
151
- ->and($builder->called('searchWhere'))->toBeTrue();
254
+ ->and($builder->called('searchWhere'))->toBeTrue()
255
+ // The vendor filter used to compare a public id against `vendor_uuid`
256
+ // directly, which could never match.
257
+ ->and($whereIns)->toContain(['vendor_uuid', [$rows['vendors']['uuid']]])
258
+ ->and($whereIns)->toContain(['vehicle_uuid', [$rows['vehicles']['uuid']]])
259
+ ->and($nested)->toContain(['fleet_uuid', [$rows['fleets']['uuid']]])
260
+ // `?phone=` reached for a relation that does not exist on Driver; it now
261
+ // searches the linked user.
262
+ ->and(collect($builder->methodCalls('whereHas'))->pluck(1)->all())->toContain('user')
263
+ ->and(collect($builder->methodCalls('whereHas'))->pluck(1)->all())->not->toContain('phone');
152
264
  });
153
265
 
154
266
  test('driver filter date and nearby coordinate filters execute query operations', function () {
@@ -261,3 +373,15 @@ test('driver filter covers alternate date branches company radius and address ne
261
373
  expect($addressBuilder->called('distanceSphere'))->toBeTrue()
262
374
  ->and($addressBuilder->called('distanceSphereValue'))->toBeTrue();
263
375
  });
376
+
377
+ test('driver filter keeps the console uuid branch for internal requests only', function () {
378
+ $uuid = (string) Str::uuid();
379
+
380
+ $internalBuilder = new FleetOpsRecordingDriverFilterBuilder();
381
+ fleetopsInternalDriverFilter($internalBuilder)->vehicle($uuid);
382
+
383
+ // The console sends a vehicle uuid and must keep matching on it directly,
384
+ // without a lookup.
385
+ expect(collect($internalBuilder->methodCalls('where'))->map(fn ($call) => [$call[1], $call[2]])->all())
386
+ ->toContain(['vehicle_uuid', [$uuid]]);
387
+ });
@@ -11,6 +11,7 @@ namespace Illuminate\Foundation\Auth {
11
11
  namespace {
12
12
  use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException;
13
13
  use Fleetbase\FleetOps\Exceptions\IntegratedVendorException;
14
+ use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException;
14
15
  use Fleetbase\FleetOps\Exceptions\TelematicProviderException;
15
16
  use Fleetbase\FleetOps\Exceptions\TelematicRateLimitExceededException;
16
17
  use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException;
@@ -78,4 +79,18 @@ namespace {
78
79
  'integratedVendorId' => 'vendor-1',
79
80
  ]);
80
81
  });
82
+
83
+ test('public relation exceptions name the input that failed to resolve', function () {
84
+ $previous = new Illuminate\Database\Eloquent\ModelNotFoundException();
85
+ $exception = new PublicRelationNotFoundException('parent_fleet', 'fleet_other_company', $previous);
86
+
87
+ // The message names the field in prose, so a client is told which input
88
+ // was at fault — while a cross-company id and a missing one produce the
89
+ // same answer, so neither can be used to probe another organization.
90
+ expect($exception->getMessage())->toBe('No parent fleet resource found for the identifier provided.')
91
+ ->and($exception->getRelation())->toBe('parent_fleet')
92
+ ->and($exception->getIdentifier())->toBe('fleet_other_company')
93
+ ->and($exception->getPrevious())->toBe($previous)
94
+ ->and((new PublicRelationNotFoundException('vendor'))->getIdentifier())->toBeNull();
95
+ });
81
96
  }
@@ -41,7 +41,7 @@ class FleetOpsApiDeviceControllerEndpointProbe extends DeviceController
41
41
  return [['uuid' => 'device-a'], ['uuid' => 'device-b']];
42
42
  }
43
43
 
44
- protected function resolveModel(string $modelClass, string $id): EloquentModel
44
+ protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): EloquentModel
45
45
  {
46
46
  $key = $modelClass . ':' . $id;
47
47
 
@@ -0,0 +1,140 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\ConnectionResolver;
4
+ use Illuminate\Database\Eloquent\Model as EloquentModel;
5
+ use Illuminate\Database\SQLiteConnection;
6
+ use Illuminate\Support\Facades\DB;
7
+ use Illuminate\Support\Facades\Schema;
8
+
9
+ /**
10
+ * The membership uniqueness migration, run against a table that already holds
11
+ * the duplicates it exists to prevent.
12
+ *
13
+ * An index cannot be added on top of existing duplicates, so the migration has
14
+ * to decide which row survives. The policy has to be deterministic — a second
15
+ * run must be a no-op — and it must never reach past the pivot: no fleet,
16
+ * vehicle or driver is touched, and no surviving membership changes state.
17
+ */
18
+ function fleetopsMembershipMigrationBoot(): SQLiteConnection
19
+ {
20
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
21
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
22
+ $resolver->setDefaultConnection('mysql');
23
+ EloquentModel::setConnectionResolver($resolver);
24
+
25
+ app()->instance('db', new class($connection) {
26
+ public function __construct(public SQLiteConnection $c)
27
+ {
28
+ }
29
+
30
+ public function connection($name = null): SQLiteConnection
31
+ {
32
+ return $this->c;
33
+ }
34
+
35
+ public function getDatabaseName(): string
36
+ {
37
+ return 'main';
38
+ }
39
+
40
+ public function table($table, $as = null)
41
+ {
42
+ return $this->c->table($table, $as);
43
+ }
44
+
45
+ public function __call($method, $arguments)
46
+ {
47
+ return $this->c->{$method}(...$arguments);
48
+ }
49
+ });
50
+ app()->instance('db.schema', $connection->getSchemaBuilder());
51
+ DB::clearResolvedInstance('db');
52
+ Schema::clearResolvedInstance('db.schema');
53
+
54
+ $schema = $connection->getSchemaBuilder();
55
+ foreach (['fleet_vehicles' => 'vehicle_uuid', 'fleet_drivers' => 'driver_uuid'] as $table => $subjectColumn) {
56
+ $schema->create($table, function ($blueprint) use ($subjectColumn) {
57
+ $blueprint->increments('id');
58
+ $blueprint->string('uuid')->nullable();
59
+ $blueprint->string('fleet_uuid')->nullable();
60
+ $blueprint->string($subjectColumn)->nullable();
61
+ $blueprint->timestamps();
62
+ $blueprint->timestamp('deleted_at')->nullable();
63
+ });
64
+ }
65
+
66
+ return $connection;
67
+ }
68
+
69
+ /**
70
+ * Reach the migration's cleanup directly. `information_schema` does not exist on
71
+ * sqlite, so the index step is exercised separately in FleetMembershipTest.
72
+ */
73
+ function fleetopsRunMembershipCleanup(string $table, string $memberColumn): void
74
+ {
75
+ $migration = require dirname(__DIR__, 4) . '/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php';
76
+ $reflection = new ReflectionMethod($migration, 'removeDuplicateMemberships');
77
+ $reflection->setAccessible(true);
78
+ $reflection->invoke($migration, $table, $memberColumn);
79
+ }
80
+
81
+ test('duplicate cleanup keeps the active membership and drops the rest', function () {
82
+ $connection = fleetopsMembershipMigrationBoot();
83
+
84
+ $connection->table('fleet_vehicles')->insert([
85
+ // A tombstone recorded first, then the membership the fleet actually has.
86
+ ['id' => 1, 'uuid' => 'a', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => '2026-01-01 00:00:00'],
87
+ ['id' => 2, 'uuid' => 'b', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null],
88
+ ['id' => 3, 'uuid' => 'c', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null],
89
+ // A different pair, and an unrelated fleet, both untouched.
90
+ ['id' => 4, 'uuid' => 'd', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-2', 'deleted_at' => null],
91
+ ['id' => 5, 'uuid' => 'e', 'fleet_uuid' => 'fleet-2', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null],
92
+ ]);
93
+
94
+ fleetopsRunMembershipCleanup('fleet_vehicles', 'vehicle_uuid');
95
+
96
+ $survivors = $connection->table('fleet_vehicles')->orderBy('id')->pluck('uuid')->all();
97
+
98
+ // The live row wins over the tombstone, the lowest id wins among equals, and
99
+ // nothing outside the duplicated pair is affected.
100
+ expect($survivors)->toBe(['b', 'd', 'e'])
101
+ ->and($connection->table('fleet_vehicles')->where('uuid', 'b')->value('deleted_at'))->toBeNull();
102
+ });
103
+
104
+ test('duplicate cleanup keeps one restorable row when every duplicate is soft deleted', function () {
105
+ $connection = fleetopsMembershipMigrationBoot();
106
+
107
+ $connection->table('fleet_drivers')->insert([
108
+ ['id' => 1, 'uuid' => 'a', 'fleet_uuid' => 'fleet-1', 'driver_uuid' => 'driver-1', 'deleted_at' => '2026-01-01 00:00:00'],
109
+ ['id' => 2, 'uuid' => 'b', 'fleet_uuid' => 'fleet-1', 'driver_uuid' => 'driver-1', 'deleted_at' => '2026-02-01 00:00:00'],
110
+ ]);
111
+
112
+ fleetopsRunMembershipCleanup('fleet_drivers', 'driver_uuid');
113
+
114
+ // Removing both would turn a later re-assignment into a brand new row and
115
+ // lose the original membership's history.
116
+ expect($connection->table('fleet_drivers')->pluck('uuid')->all())->toBe(['a'])
117
+ ->and($connection->table('fleet_drivers')->where('uuid', 'a')->value('deleted_at'))->not->toBeNull();
118
+ });
119
+
120
+ test('duplicate cleanup is idempotent and leaves orphaned rows alone', function () {
121
+ $connection = fleetopsMembershipMigrationBoot();
122
+
123
+ $connection->table('fleet_vehicles')->insert([
124
+ ['id' => 1, 'uuid' => 'a', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null],
125
+ ['id' => 2, 'uuid' => 'b', 'fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1', 'deleted_at' => null],
126
+ // Orphaned data rather than a membership: a null side never collides in
127
+ // a unique index, so it is not the migration's business.
128
+ ['id' => 3, 'uuid' => 'c', 'fleet_uuid' => null, 'vehicle_uuid' => 'vehicle-9', 'deleted_at' => null],
129
+ ['id' => 4, 'uuid' => 'd', 'fleet_uuid' => null, 'vehicle_uuid' => 'vehicle-9', 'deleted_at' => null],
130
+ ]);
131
+
132
+ fleetopsRunMembershipCleanup('fleet_vehicles', 'vehicle_uuid');
133
+ $afterFirst = $connection->table('fleet_vehicles')->orderBy('id')->pluck('uuid')->all();
134
+
135
+ fleetopsRunMembershipCleanup('fleet_vehicles', 'vehicle_uuid');
136
+ $afterSecond = $connection->table('fleet_vehicles')->orderBy('id')->pluck('uuid')->all();
137
+
138
+ expect($afterFirst)->toBe(['a', 'c', 'd'])
139
+ ->and($afterSecond)->toBe($afterFirst);
140
+ });