@fleetbase/fleetops-engine 0.6.59 → 0.6.61
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 +25 -0
- package/addon/components/ai/route-preview-map.js +1 -5
- package/addon/components/customer/orders.js +2 -1
- package/addon/components/driver/card.hbs +49 -0
- package/addon/components/driver/card.js +30 -0
- package/addon/components/map/leaflet-live-map.js +4 -19
- package/addon/components/modals/place-details.hbs +1 -1
- package/addon/components/modals/point-map.hbs +1 -1
- package/addon/components/orchestrator-workbench.js +2 -1
- package/addon/components/order/form/route.js +3 -0
- package/addon/components/order-tracking-lookup.js +2 -1
- package/addon/components/place/details.hbs +1 -1
- package/addon/components/positions-replay.js +4 -1
- package/addon/components/service-area/details.hbs +1 -1
- package/addon/components/vehicle/details/schedules.hbs +2 -2
- package/addon/components/vehicle/details/schedules.js +7 -1
- package/addon/components/widget/live-fleet.js +7 -7
- package/addon/components/zone/details.hbs +1 -1
- package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
- package/addon/controllers/management/drivers/index.js +22 -1
- package/addon/controllers/settings/map.js +16 -0
- package/addon/helpers/leaflet-tile-url.js +19 -0
- package/addon/services/map-settings.js +22 -0
- package/addon/services/vehicle-actions.js +4 -2
- package/addon/templates/management/drivers/index.hbs +67 -33
- package/addon/templates/settings/map.hbs +10 -0
- package/addon/utils/leaflet-tile-url.js +31 -0
- package/addon/utils/prepare-place-for-save.js +13 -0
- package/app/components/driver/card.js +1 -0
- package/app/helpers/leaflet-tile-url.js +1 -0
- package/app/utils/leaflet-tile-url.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/index.js +11 -0
- package/package.json +2 -1
- package/server/config/fleetops.php +44 -0
- package/server/config/geocoder.php +1 -1
- package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
- package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
- package/server/src/Http/Controllers/Api/v1/DriverController.php +277 -5
- package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
- package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
- package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
- package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
- package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
- package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
- package/server/src/Http/Filter/FuelReportFilter.php +29 -0
- package/server/src/Http/Filter/IssueFilter.php +29 -0
- package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
- package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
- package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
- package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
- package/server/src/Http/Requests/CreatePartRequest.php +39 -1
- package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
- package/server/src/Http/Resources/v1/Manifest.php +52 -0
- package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
- package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
- package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
- package/server/src/Models/FuelReport.php +21 -0
- package/server/src/Models/Place.php +1 -1
- package/server/src/Models/Sensor.php +24 -0
- package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
- package/server/src/routes.php +22 -1
- package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
- package/server/tests/ApiDriverControllerContractsTest.php +65 -0
- package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
- package/server/tests/ApiManifestControllerContractsTest.php +599 -0
- package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
- package/server/tests/CompactResourceSerializationTest.php +99 -0
- package/server/tests/CustomerEndpointTest.php +4 -1
- package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
- package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
- package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
- package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
- package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
- package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
- package/server/tests/RequestContractsTest.php +102 -5
- package/server/tests/SettingControllerContractsTest.php +33 -0
- package/server/tests/SmallControllerContractsTest.php +28 -4
- package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
- package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
- package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
- package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
- package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
- package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
- package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
- package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
- package/translations/en-us.yaml +4 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Locally `illuminate/foundation` is not installed, so the controller's base
|
|
5
|
+
* class cannot load. CI installs it and these guards do nothing there; here
|
|
6
|
+
* they let the controller's own logic be exercised, which is what the file is
|
|
7
|
+
* about. Same approach the driver contracts test already takes for
|
|
8
|
+
* Illuminate\Foundation\Auth\User.
|
|
9
|
+
*/
|
|
10
|
+
if (!trait_exists('Illuminate\Foundation\Auth\Access\AuthorizesRequests')) {
|
|
11
|
+
eval('namespace Illuminate\Foundation\Auth\Access; trait AuthorizesRequests {}');
|
|
12
|
+
}
|
|
13
|
+
if (!trait_exists('Illuminate\Foundation\Bus\DispatchesJobs')) {
|
|
14
|
+
eval('namespace Illuminate\Foundation\Bus; trait DispatchesJobs {}');
|
|
15
|
+
}
|
|
16
|
+
if (!trait_exists('Illuminate\Foundation\Validation\ValidatesRequests')) {
|
|
17
|
+
eval('namespace Illuminate\Foundation\Validation; trait ValidatesRequests {}');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* Model connection resolution reaches for these; absent locally. */
|
|
21
|
+
if (!function_exists('Fleetbase\Traits\app')) {
|
|
22
|
+
eval('namespace Fleetbase\\Traits; function app($abstract = null) { return new class { public function hasDebugModeEnabled() { return false; } public function environment() { return "testing"; } }; }');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!function_exists('Fleetbase\Traits\config')) {
|
|
26
|
+
eval('namespace Fleetbase\\Traits; function config($key = null, $default = null) { return $key === "api.cache.enabled" ? false : $default; }');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!function_exists('Fleetbase\Models\config')) {
|
|
30
|
+
eval('namespace Fleetbase\\Models; function config($key = null, $default = null) { return $default; }');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!function_exists('Fleetbase\FleetOps\Models\config')) {
|
|
34
|
+
eval('namespace Fleetbase\\FleetOps\\Models; function config($key = null, $default = null) { return $default; }');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `response()` is a foundation helper, absent locally for the same reason. The
|
|
39
|
+
* stub answers the two shapes this controller uses — `apiError()` and `json()`
|
|
40
|
+
* — so a refusal can be asserted on its status code.
|
|
41
|
+
*/
|
|
42
|
+
if (!class_exists('FleetOpsTestResponse')) {
|
|
43
|
+
class FleetOpsTestResponse
|
|
44
|
+
{
|
|
45
|
+
public function __construct(public mixed $payload = null, public int $status = 200)
|
|
46
|
+
{
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public function getStatusCode(): int
|
|
50
|
+
{
|
|
51
|
+
return $this->status;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public function getData(): mixed
|
|
55
|
+
{
|
|
56
|
+
return $this->payload;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class FleetOpsTestResponseFactory
|
|
61
|
+
{
|
|
62
|
+
public function apiError(string $message, int $status = 400): FleetOpsTestResponse
|
|
63
|
+
{
|
|
64
|
+
return new FleetOpsTestResponse(['error' => $message], $status);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public function json(mixed $payload = null, int $status = 200): FleetOpsTestResponse
|
|
68
|
+
{
|
|
69
|
+
return new FleetOpsTestResponse($payload, $status);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!function_exists('Fleetbase\FleetOps\Http\Controllers\Api\v1\response')) {
|
|
75
|
+
eval('namespace Fleetbase\\FleetOps\\Http\\Controllers\\Api\\v1; function response() { return new \\FleetOpsTestResponseFactory(); }');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
use Fleetbase\FleetOps\Http\Controllers\Api\v1\ManifestController;
|
|
79
|
+
use Fleetbase\FleetOps\Models\Driver;
|
|
80
|
+
use Fleetbase\FleetOps\Models\Manifest;
|
|
81
|
+
use Fleetbase\FleetOps\Models\ManifestStop;
|
|
82
|
+
use Illuminate\Http\Request;
|
|
83
|
+
|
|
84
|
+
/** A stop that records what was done to it instead of touching a database. */
|
|
85
|
+
class FleetOpsManifestStopFake extends ManifestStop
|
|
86
|
+
{
|
|
87
|
+
public array $marks = [];
|
|
88
|
+
public array $updates = [];
|
|
89
|
+
public ?object $placeForTest = null;
|
|
90
|
+
|
|
91
|
+
public function markArrived(): ManifestStop
|
|
92
|
+
{
|
|
93
|
+
$this->marks[] = 'arrived';
|
|
94
|
+
|
|
95
|
+
return $this;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public function markCompleted(): ManifestStop
|
|
99
|
+
{
|
|
100
|
+
$this->marks[] = 'completed';
|
|
101
|
+
|
|
102
|
+
return $this;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public function markSkipped(): ManifestStop
|
|
106
|
+
{
|
|
107
|
+
$this->marks[] = 'skipped';
|
|
108
|
+
|
|
109
|
+
return $this;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public function update(array $attributes = [], array $options = []): bool
|
|
113
|
+
{
|
|
114
|
+
$this->updates[] = $attributes;
|
|
115
|
+
foreach ($attributes as $key => $value) {
|
|
116
|
+
$this->attributes[$key] = $value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public function load($relations): self
|
|
123
|
+
{
|
|
124
|
+
return $this;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `attributes` is protected, so tests read the sequence through here. */
|
|
128
|
+
public function currentSequence(): ?int
|
|
129
|
+
{
|
|
130
|
+
return $this->attributes['sequence'] ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
public function getAttribute($key)
|
|
134
|
+
{
|
|
135
|
+
if ($key === 'place') {
|
|
136
|
+
return $this->placeForTest;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return parent::getAttribute($key);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
public function relationLoaded($key): bool
|
|
143
|
+
{
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A manifest whose stops and appended counts are supplied, not queried. */
|
|
149
|
+
class FleetOpsManifestFakeRecord extends Manifest
|
|
150
|
+
{
|
|
151
|
+
public $stopsForTest = null;
|
|
152
|
+
|
|
153
|
+
public function getCompletedStopsAttribute(): int
|
|
154
|
+
{
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public function getPendingStopsAttribute(): int
|
|
159
|
+
{
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
public function getDriverNameAttribute(): ?string
|
|
164
|
+
{
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
public function getVehicleNameAttribute(): ?string
|
|
169
|
+
{
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
public function load($relations): self
|
|
174
|
+
{
|
|
175
|
+
return $this;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public function getAttribute($key)
|
|
179
|
+
{
|
|
180
|
+
if ($key === 'stops') {
|
|
181
|
+
return $this->stopsForTest;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return parent::getAttribute($key);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
public function relationLoaded($key): bool
|
|
188
|
+
{
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Replaces every database and routing seam with something inspectable. */
|
|
194
|
+
class FleetOpsManifestControllerProbe extends ManifestController
|
|
195
|
+
{
|
|
196
|
+
public static ?Driver $driver = null;
|
|
197
|
+
public static ?Manifest $manifest = null;
|
|
198
|
+
public static ?ManifestStop $stop = null;
|
|
199
|
+
public static array $distances = [];
|
|
200
|
+
public static array $distanceCalls = [];
|
|
201
|
+
|
|
202
|
+
protected static function findDriverRecord(string $id): ?Driver
|
|
203
|
+
{
|
|
204
|
+
return static::$driver;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
protected static function findManifest(string $id): ?Manifest
|
|
208
|
+
{
|
|
209
|
+
return static::$manifest;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
protected static function findStop(string $id): ?ManifestStop
|
|
213
|
+
{
|
|
214
|
+
return static::$stop;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
protected static function distanceBetween(array $from, array $to): float
|
|
218
|
+
{
|
|
219
|
+
static::$distanceCalls[] = [$from, $to];
|
|
220
|
+
|
|
221
|
+
// Real geometry unless a test pins a pair, so the ordering assertions
|
|
222
|
+
// read as coordinates rather than as a lookup table.
|
|
223
|
+
$key = round($from['lat'], 4) . ',' . round($from['lon'], 4) . '->' . round($to['lat'], 4) . ',' . round($to['lon'], 4);
|
|
224
|
+
|
|
225
|
+
return static::$distances[$key] ?? parent::distanceBetween($from, $to);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
protected static function manifestsFor(Driver $driver)
|
|
229
|
+
{
|
|
230
|
+
return static::$query;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
public static ?FleetOpsManifestQueryFake $query = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Records the filters applied, and answers with whatever the test supplied. */
|
|
237
|
+
class FleetOpsManifestQueryFake
|
|
238
|
+
{
|
|
239
|
+
public array $calls = [];
|
|
240
|
+
|
|
241
|
+
public function __construct(private array $results = [])
|
|
242
|
+
{
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
public function whereIn(string $column, $values): self
|
|
246
|
+
{
|
|
247
|
+
$this->calls[] = ['whereIn', $column, $values];
|
|
248
|
+
|
|
249
|
+
return $this;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
public function whereDate(string $column, $value): self
|
|
253
|
+
{
|
|
254
|
+
$this->calls[] = ['whereDate', $column, $value];
|
|
255
|
+
|
|
256
|
+
return $this;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
public function limit(int $limit): self
|
|
260
|
+
{
|
|
261
|
+
$this->calls[] = ['limit', $limit];
|
|
262
|
+
|
|
263
|
+
return $this;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
public function get(): \Illuminate\Support\Collection
|
|
267
|
+
{
|
|
268
|
+
return collect($this->results);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* A stand-in for a Place. The coordinate lives on `location`, as a Point does
|
|
274
|
+
* on the real model — the controller reads `place.location`, and a fixture that
|
|
275
|
+
* exposed getLat()/getLng() at the top level silently produced null coordinates
|
|
276
|
+
* and an unmeasured route.
|
|
277
|
+
*/
|
|
278
|
+
function fleetopsManifestPlace(float $lat, float $lon): object
|
|
279
|
+
{
|
|
280
|
+
$point = new class($lat, $lon) {
|
|
281
|
+
public function __construct(private float $lat, private float $lon)
|
|
282
|
+
{
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
public function getLat(): float
|
|
286
|
+
{
|
|
287
|
+
return $this->lat;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
public function getLng(): float
|
|
291
|
+
{
|
|
292
|
+
return $this->lon;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
return new class($point) {
|
|
297
|
+
public function __construct(public object $location)
|
|
298
|
+
{
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function fleetopsManifestStop(string $publicId, string $status, int $sequence, ?object $place = null): FleetOpsManifestStopFake
|
|
304
|
+
{
|
|
305
|
+
$stop = new FleetOpsManifestStopFake();
|
|
306
|
+
$stop->setRawAttributes([
|
|
307
|
+
'uuid' => $publicId . '-uuid',
|
|
308
|
+
'public_id' => $publicId,
|
|
309
|
+
'status' => $status,
|
|
310
|
+
'sequence' => $sequence,
|
|
311
|
+
], true);
|
|
312
|
+
$stop->placeForTest = $place;
|
|
313
|
+
|
|
314
|
+
return $stop;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
beforeEach(function () {
|
|
318
|
+
FleetOpsManifestControllerProbe::$driver = null;
|
|
319
|
+
FleetOpsManifestControllerProbe::$manifest = null;
|
|
320
|
+
FleetOpsManifestControllerProbe::$stop = null;
|
|
321
|
+
FleetOpsManifestControllerProbe::$distances = [];
|
|
322
|
+
FleetOpsManifestControllerProbe::$distanceCalls = [];
|
|
323
|
+
FleetOpsManifestControllerProbe::$query = null;
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('manifest controller reports a missing driver', function () {
|
|
327
|
+
$controller = new FleetOpsManifestControllerProbe();
|
|
328
|
+
$response = $controller->forDriver(new Request(), 'driver_missing');
|
|
329
|
+
|
|
330
|
+
expect($response->getStatusCode())->toBe(404);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('manifest controller reports a missing manifest stop', function () {
|
|
334
|
+
$controller = new FleetOpsManifestControllerProbe();
|
|
335
|
+
$response = $controller->updateStop(new Request(), 'stop_missing');
|
|
336
|
+
|
|
337
|
+
expect($response->getStatusCode())->toBe(404);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('manifest controller refuses a status it does not recognise, and changes nothing', function () {
|
|
341
|
+
$stop = fleetopsManifestStop('stop_a', 'pending', 1);
|
|
342
|
+
FleetOpsManifestControllerProbe::$stop = $stop;
|
|
343
|
+
$controller = new FleetOpsManifestControllerProbe();
|
|
344
|
+
|
|
345
|
+
$response = $controller->updateStop(new Request(['status' => 'teleported']), 'stop_a');
|
|
346
|
+
|
|
347
|
+
expect($response->getStatusCode())->toBe(422)
|
|
348
|
+
->and($stop->marks)->toBeEmpty();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('manifest controller routes each status through the model transition', function (string $status, string $expected) {
|
|
352
|
+
$stop = fleetopsManifestStop('stop_a', 'pending', 1);
|
|
353
|
+
FleetOpsManifestControllerProbe::$stop = $stop;
|
|
354
|
+
$controller = new FleetOpsManifestControllerProbe();
|
|
355
|
+
|
|
356
|
+
$controller->updateStop(new Request(['status' => $status]), 'stop_a');
|
|
357
|
+
|
|
358
|
+
expect($stop->marks)->toBe([$expected]);
|
|
359
|
+
})->with([
|
|
360
|
+
'arrived' => ['arrived', 'arrived'],
|
|
361
|
+
'completed' => ['completed', 'completed'],
|
|
362
|
+
'skipped' => ['skipped', 'skipped'],
|
|
363
|
+
]);
|
|
364
|
+
|
|
365
|
+
test('manifest controller stores meta when it is sent, and leaves the stop alone when it is not', function () {
|
|
366
|
+
$stop = fleetopsManifestStop('stop_a', 'pending', 1);
|
|
367
|
+
FleetOpsManifestControllerProbe::$stop = $stop;
|
|
368
|
+
$controller = new FleetOpsManifestControllerProbe();
|
|
369
|
+
|
|
370
|
+
$controller->updateStop(new Request(['meta' => ['note' => 'gate code 4821']]), 'stop_a');
|
|
371
|
+
expect($stop->updates)->toContain(['meta' => ['note' => 'gate code 4821']]);
|
|
372
|
+
|
|
373
|
+
$stop->updates = [];
|
|
374
|
+
$controller->updateStop(new Request(), 'stop_a');
|
|
375
|
+
expect($stop->updates)->toBeEmpty();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('manifest controller lists a driver\'s manifests, newest first, capped', function () {
|
|
379
|
+
$manifest = new FleetOpsManifestFakeRecord();
|
|
380
|
+
$manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a', 'status' => 'pending'], true);
|
|
381
|
+
|
|
382
|
+
FleetOpsManifestControllerProbe::$driver = new Driver();
|
|
383
|
+
FleetOpsManifestControllerProbe::$query = new FleetOpsManifestQueryFake([$manifest]);
|
|
384
|
+
|
|
385
|
+
$controller = new FleetOpsManifestControllerProbe();
|
|
386
|
+
$controller->forDriver(new Request(), 'driver_a');
|
|
387
|
+
|
|
388
|
+
// Default cap rather than a driver's whole history.
|
|
389
|
+
expect(collect(FleetOpsManifestControllerProbe::$query->calls)->firstWhere(0, 'limit'))->toBe(['limit', 30]);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('manifest controller applies the status and date filters only when asked', function () {
|
|
393
|
+
FleetOpsManifestControllerProbe::$driver = new Driver();
|
|
394
|
+
FleetOpsManifestControllerProbe::$query = new FleetOpsManifestQueryFake([]);
|
|
395
|
+
$controller = new FleetOpsManifestControllerProbe();
|
|
396
|
+
|
|
397
|
+
$controller->forDriver(new Request(), 'driver_a');
|
|
398
|
+
expect(collect(FleetOpsManifestControllerProbe::$query->calls)->where(0, 'whereIn'))->toBeEmpty();
|
|
399
|
+
expect(collect(FleetOpsManifestControllerProbe::$query->calls)->where(0, 'whereDate'))->toBeEmpty();
|
|
400
|
+
|
|
401
|
+
FleetOpsManifestControllerProbe::$query = new FleetOpsManifestQueryFake([]);
|
|
402
|
+
$controller->forDriver(new Request(['status' => 'pending,in_progress', 'on' => '2026-08-23', 'limit' => 5]), 'driver_a');
|
|
403
|
+
|
|
404
|
+
$calls = collect(FleetOpsManifestControllerProbe::$query->calls);
|
|
405
|
+
expect($calls->firstWhere(0, 'whereIn')[2])->toBe(['pending', 'in_progress'])
|
|
406
|
+
->and($calls->firstWhere(0, 'whereDate')[2])->toBe('2026-08-23')
|
|
407
|
+
->and($calls->firstWhere(0, 'limit'))->toBe(['limit', 5]);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('manifest controller returns the route when the manifest exists', function () {
|
|
411
|
+
$manifest = new FleetOpsManifestFakeRecord();
|
|
412
|
+
$manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a', 'status' => 'in_progress'], true);
|
|
413
|
+
FleetOpsManifestControllerProbe::$manifest = $manifest;
|
|
414
|
+
|
|
415
|
+
$controller = new FleetOpsManifestControllerProbe();
|
|
416
|
+
$resource = $controller->show('manifest_a');
|
|
417
|
+
|
|
418
|
+
expect($resource->resource)->toBe($manifest);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test('manifest controller will not optimise a route that offers no choice', function () {
|
|
422
|
+
/*
|
|
423
|
+
* Two stops have one order and one has none. Re-sequencing either is work
|
|
424
|
+
* with no possible result, so nothing is asked of the routing service.
|
|
425
|
+
*/
|
|
426
|
+
$manifest = new FleetOpsManifestFakeRecord();
|
|
427
|
+
$manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true);
|
|
428
|
+
$manifest->stopsForTest = collect([
|
|
429
|
+
fleetopsManifestStop('stop_a', 'pending', 1, fleetopsManifestPlace(1.30, 103.80)),
|
|
430
|
+
fleetopsManifestStop('stop_b', 'pending', 2, fleetopsManifestPlace(1.31, 103.81)),
|
|
431
|
+
]);
|
|
432
|
+
FleetOpsManifestControllerProbe::$manifest = $manifest;
|
|
433
|
+
|
|
434
|
+
$controller = new FleetOpsManifestControllerProbe();
|
|
435
|
+
$controller->optimize(new Request(), 'manifest_a');
|
|
436
|
+
|
|
437
|
+
expect(FleetOpsManifestControllerProbe::$distanceCalls)->toBeEmpty();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('manifest controller re-sequences pending stops nearest first, leaving finished ones alone', function () {
|
|
441
|
+
/*
|
|
442
|
+
* Declared in a deliberately poor order: the far stop first. Starting from
|
|
443
|
+
* the driver's position, the walk should take the near one, then the middle,
|
|
444
|
+
* then the far. The completed stop keeps the front of the sequence — a
|
|
445
|
+
* route already driven is not re-planned.
|
|
446
|
+
*/
|
|
447
|
+
$done = fleetopsManifestStop('stop_done', 'completed', 1, fleetopsManifestPlace(1.20, 103.70));
|
|
448
|
+
$far = fleetopsManifestStop('stop_far', 'pending', 2, fleetopsManifestPlace(1.50, 103.99));
|
|
449
|
+
$near = fleetopsManifestStop('stop_near', 'pending', 3, fleetopsManifestPlace(1.31, 103.81));
|
|
450
|
+
$mid = fleetopsManifestStop('stop_mid', 'pending', 4, fleetopsManifestPlace(1.40, 103.90));
|
|
451
|
+
|
|
452
|
+
$manifest = new FleetOpsManifestFakeRecord();
|
|
453
|
+
$manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true);
|
|
454
|
+
$manifest->stopsForTest = collect([$done, $far, $near, $mid]);
|
|
455
|
+
FleetOpsManifestControllerProbe::$manifest = $manifest;
|
|
456
|
+
|
|
457
|
+
FleetOpsManifestControllerProbe::$distances = [
|
|
458
|
+
// from the driver
|
|
459
|
+
'1.3,103.8->1.5,103.99' => 30000.0,
|
|
460
|
+
'1.3,103.8->1.31,103.81' => 1500.0,
|
|
461
|
+
'1.3,103.8->1.4,103.9' => 15000.0,
|
|
462
|
+
// from the near stop
|
|
463
|
+
'1.31,103.81->1.5,103.99' => 28000.0,
|
|
464
|
+
'1.31,103.81->1.4,103.9' => 13000.0,
|
|
465
|
+
// from the middle stop
|
|
466
|
+
'1.4,103.9->1.5,103.99' => 14000.0,
|
|
467
|
+
];
|
|
468
|
+
|
|
469
|
+
$controller = new FleetOpsManifestControllerProbe();
|
|
470
|
+
$controller->optimize(new Request(['latitude' => 1.30, 'longitude' => 103.80]), 'manifest_a');
|
|
471
|
+
|
|
472
|
+
expect($done->currentSequence())->toBe(1)
|
|
473
|
+
->and($near->currentSequence())->toBe(2)
|
|
474
|
+
->and($mid->currentSequence())->toBe(3)
|
|
475
|
+
->and($far->currentSequence())->toBe(4);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test('manifest controller starts from the first pending stop when given no position', function () {
|
|
479
|
+
$a = fleetopsManifestStop('stop_a', 'pending', 1, fleetopsManifestPlace(1.30, 103.80));
|
|
480
|
+
$b = fleetopsManifestStop('stop_b', 'pending', 2, fleetopsManifestPlace(1.31, 103.81));
|
|
481
|
+
$c = fleetopsManifestStop('stop_c', 'pending', 3, fleetopsManifestPlace(1.40, 103.90));
|
|
482
|
+
|
|
483
|
+
$manifest = new FleetOpsManifestFakeRecord();
|
|
484
|
+
$manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true);
|
|
485
|
+
$manifest->stopsForTest = collect([$a, $b, $c]);
|
|
486
|
+
FleetOpsManifestControllerProbe::$manifest = $manifest;
|
|
487
|
+
FleetOpsManifestControllerProbe::$distances = [
|
|
488
|
+
'1.3,103.8->1.3,103.8' => 0.0,
|
|
489
|
+
'1.3,103.8->1.31,103.81' => 1500.0,
|
|
490
|
+
'1.3,103.8->1.4,103.9' => 15000.0,
|
|
491
|
+
'1.31,103.81->1.4,103.9' => 13000.0,
|
|
492
|
+
];
|
|
493
|
+
|
|
494
|
+
$controller = new FleetOpsManifestControllerProbe();
|
|
495
|
+
$controller->optimize(new Request(), 'manifest_a');
|
|
496
|
+
|
|
497
|
+
expect(FleetOpsManifestControllerProbe::$distanceCalls[0][0])->toBe(['lat' => 1.30, 'lon' => 103.80]);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('manifest controller skips a stop with no place rather than failing the whole route', function () {
|
|
501
|
+
// A stop whose place never resolved should not take the optimise down with
|
|
502
|
+
// it; it simply cannot be measured against.
|
|
503
|
+
$a = fleetopsManifestStop('stop_a', 'pending', 1, fleetopsManifestPlace(1.30, 103.80));
|
|
504
|
+
$b = fleetopsManifestStop('stop_b', 'pending', 2, null);
|
|
505
|
+
$c = fleetopsManifestStop('stop_c', 'pending', 3, fleetopsManifestPlace(1.40, 103.90));
|
|
506
|
+
|
|
507
|
+
$manifest = new FleetOpsManifestFakeRecord();
|
|
508
|
+
$manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true);
|
|
509
|
+
$manifest->stopsForTest = collect([$a, $b, $c]);
|
|
510
|
+
FleetOpsManifestControllerProbe::$manifest = $manifest;
|
|
511
|
+
|
|
512
|
+
$controller = new FleetOpsManifestControllerProbe();
|
|
513
|
+
$controller->optimize(new Request(['latitude' => 1.30, 'longitude' => 103.80]), 'manifest_a');
|
|
514
|
+
|
|
515
|
+
// Every stop still gets a sequence, placeless one included.
|
|
516
|
+
expect([$a->currentSequence(), $b->currentSequence(), $c->currentSequence()])
|
|
517
|
+
->toEqualCanonicalizing([1, 2, 3]);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test('manifest controller reports a missing manifest on both read and optimise', function () {
|
|
521
|
+
$controller = new FleetOpsManifestControllerProbe();
|
|
522
|
+
|
|
523
|
+
expect($controller->show('nope')->getStatusCode())->toBe(404)
|
|
524
|
+
->and($controller->optimize(new Request(), 'nope')->getStatusCode())->toBe(404);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test('manifest controller measures distance as real geometry', function () {
|
|
528
|
+
$reflection = new ReflectionMethod(ManifestController::class, 'distanceBetween');
|
|
529
|
+
$reflection->setAccessible(true);
|
|
530
|
+
|
|
531
|
+
// Two points about 1.1km apart in Singapore, and a zero-length hop.
|
|
532
|
+
$near = $reflection->invoke(null, ['lat' => 1.3000, 'lon' => 103.8000], ['lat' => 1.3100, 'lon' => 103.8000]);
|
|
533
|
+
$same = $reflection->invoke(null, ['lat' => 1.3000, 'lon' => 103.8000], ['lat' => 1.3000, 'lon' => 103.8000]);
|
|
534
|
+
$far = $reflection->invoke(null, ['lat' => 1.3000, 'lon' => 103.8000], ['lat' => 1.4000, 'lon' => 103.9000]);
|
|
535
|
+
|
|
536
|
+
expect(round($near))->toBeGreaterThan(1000)
|
|
537
|
+
->and(round($near))->toBeLessThan(1200)
|
|
538
|
+
->and($same)->toBe(0.0)
|
|
539
|
+
->and($far)->toBeGreaterThan($near);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Reaches the real lookups, which the probe above replaces.
|
|
544
|
+
*
|
|
545
|
+
* They are one-line delegations to Eloquent, and the property worth asserting
|
|
546
|
+
* is that they *go to the database* — a lookup that swallowed a connection
|
|
547
|
+
* failure and returned null would make a broken database indistinguishable
|
|
548
|
+
* from a missing record, and the endpoint would answer 404 for both.
|
|
549
|
+
*/
|
|
550
|
+
class FleetOpsManifestRealLookupProbe extends ManifestController
|
|
551
|
+
{
|
|
552
|
+
public static function callFindManifest(string $id)
|
|
553
|
+
{
|
|
554
|
+
return parent::findManifest($id);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
public static function callFindStop(string $id)
|
|
558
|
+
{
|
|
559
|
+
return parent::findStop($id);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
public static function callFindDriverRecord(string $id)
|
|
563
|
+
{
|
|
564
|
+
return parent::findDriverRecord($id);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
public static function callManifestsFor(Driver $driver)
|
|
568
|
+
{
|
|
569
|
+
return parent::manifestsFor($driver);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
test('manifest controller lookups go to the database', function () {
|
|
574
|
+
/*
|
|
575
|
+
* Exercises the real lookups, which the probe above replaces. Whether the
|
|
576
|
+
* environment has a database or not, the call must reach it: a lookup that
|
|
577
|
+
* quietly returned null on a connection failure would make a broken
|
|
578
|
+
* database indistinguishable from a missing record, and both would answer
|
|
579
|
+
* 404. Either outcome is accepted here — what is asserted is that the
|
|
580
|
+
* lookup is a query and not a stub.
|
|
581
|
+
*/
|
|
582
|
+
$driver = new Driver();
|
|
583
|
+
$driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_a'], true);
|
|
584
|
+
|
|
585
|
+
$reached = function (callable $lookup): bool {
|
|
586
|
+
try {
|
|
587
|
+
$lookup();
|
|
588
|
+
} catch (\Throwable $e) {
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return true;
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
expect($reached(fn () => FleetOpsManifestRealLookupProbe::callFindManifest('manifest_a')))->toBeTrue()
|
|
596
|
+
->and($reached(fn () => FleetOpsManifestRealLookupProbe::callFindStop('stop_a')))->toBeTrue()
|
|
597
|
+
->and($reached(fn () => FleetOpsManifestRealLookupProbe::callFindDriverRecord('driver_a')))->toBeTrue()
|
|
598
|
+
->and($reached(fn () => FleetOpsManifestRealLookupProbe::callManifestsFor($driver)))->toBeTrue();
|
|
599
|
+
});
|
|
@@ -366,3 +366,38 @@ test('api vehicle controller reports missing vehicle and missing driver branches
|
|
|
366
366
|
'status' => 404,
|
|
367
367
|
]);
|
|
368
368
|
});
|
|
369
|
+
|
|
370
|
+
test('api vehicle controller keeps the odometer in the input it builds from a request', function () {
|
|
371
|
+
// Regression: `vehicleInputFromRequest` omitted odometer, so PUT
|
|
372
|
+
// /v1/vehicles/{id} answered 200 with a correct-looking body and discarded
|
|
373
|
+
// the reading. Recording mileage is the most common write a driver app
|
|
374
|
+
// makes against a vehicle, and a silent no-op is the worst possible answer.
|
|
375
|
+
$controller = new FleetOpsApiVehicleCrudControllerProbe();
|
|
376
|
+
|
|
377
|
+
$input = $controller->inputForTest(new Request([
|
|
378
|
+
'odometer' => 211098,
|
|
379
|
+
'odometer_unit' => 'km',
|
|
380
|
+
'plate_number' => 'SG-1234',
|
|
381
|
+
]));
|
|
382
|
+
|
|
383
|
+
expect($input)->toHaveKey('odometer')
|
|
384
|
+
->and($input['odometer'])->toBe(211098)
|
|
385
|
+
->and($input['odometer_unit'])->toBe('km')
|
|
386
|
+
->and($input['plate_number'])->toBe('SG-1234');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('api vehicle controller input still drops fields that are not part of the contract', function () {
|
|
390
|
+
// The projection is an allowlist and must stay one — adding odometer must
|
|
391
|
+
// not turn it into "whatever the caller sent".
|
|
392
|
+
$controller = new FleetOpsApiVehicleCrudControllerProbe();
|
|
393
|
+
|
|
394
|
+
$input = $controller->inputForTest(new Request([
|
|
395
|
+
'odometer' => 1,
|
|
396
|
+
'company_uuid' => 'someone-elses-company',
|
|
397
|
+
'uuid' => 'forged',
|
|
398
|
+
]));
|
|
399
|
+
|
|
400
|
+
expect($input)->toHaveKey('odometer')
|
|
401
|
+
->and($input)->not->toHaveKey('company_uuid')
|
|
402
|
+
->and($input)->not->toHaveKey('uuid');
|
|
403
|
+
});
|