@fleetbase/fleetops-engine 0.6.61 → 0.6.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE.md +5 -14
- package/addon/controllers/management/drivers/index.js +4 -1
- package/addon/routes/management/fleets/index/details.js +1 -1
- package/addon/routes/management/fleets/index/edit.js +1 -1
- package/addon/routes/management/fleets/index.js +3 -1
- package/addon/services/map-settings.js +4 -1
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +1 -1
- package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
- package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
- package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
- package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +93 -57
- package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
- package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +134 -35
- package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
- package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
- package/server/src/Http/Filter/DriverFilter.php +71 -15
- package/server/src/Http/Filter/FleetFilter.php +32 -33
- package/server/src/Http/Filter/VehicleFilter.php +57 -19
- package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
- package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
- package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
- package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
- package/server/src/Http/Requests/CreateVehicleRequest.php +150 -31
- package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
- package/server/src/Http/Resources/v1/Driver.php +22 -2
- package/server/src/Http/Resources/v1/Fleet.php +38 -25
- package/server/src/Http/Resources/v1/Vehicle.php +46 -4
- package/server/src/Models/Driver.php +2 -2
- package/server/src/routes.php +8 -0
- package/server/tests/ApiDriverControllerContractsTest.php +284 -13
- package/server/tests/ApiEntityControllerContractsTest.php +1 -1
- package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
- package/server/tests/ApiFleetControllerContractsTest.php +354 -21
- package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
- package/server/tests/ApiIssueControllerContractsTest.php +1 -1
- package/server/tests/ApiOrderControllerContractsTest.php +1 -1
- package/server/tests/ApiPartControllerContractsTest.php +2 -2
- package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
- package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
- package/server/tests/ApiVehicleControllerContractsTest.php +175 -8
- package/server/tests/ControllerFilterContractsTest.php +132 -13
- package/server/tests/ControllerHelperContractsTest.php +6 -3
- package/server/tests/DriverFilterExecutionTest.php +130 -6
- package/server/tests/ExceptionContractsTest.php +15 -0
- package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
- package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
- package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
- package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
- package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
- package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
- package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
- package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
- package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
- package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
- package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
- package/server/tests/FleetHierarchyResourceTest.php +56 -8
- package/server/tests/RequestContractsTest.php +100 -7
- package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
- package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
|
@@ -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('
|
|
136
|
-
$filter->vendor('
|
|
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('
|
|
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('
|
|
151
|
-
|
|
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
|
+
});
|