@fleetbase/fleetops-engine 0.6.60 → 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.
Files changed (57) hide show
  1. package/RELEASE.md +9 -20
  2. package/addon/components/ai/route-preview-map.js +1 -5
  3. package/addon/components/customer/orders.js +2 -1
  4. package/addon/components/driver/card.hbs +49 -0
  5. package/addon/components/driver/card.js +30 -0
  6. package/addon/components/map/leaflet-live-map.js +4 -19
  7. package/addon/components/modals/place-details.hbs +1 -1
  8. package/addon/components/modals/point-map.hbs +1 -1
  9. package/addon/components/orchestrator-workbench.js +2 -1
  10. package/addon/components/order/form/route.js +3 -0
  11. package/addon/components/order-tracking-lookup.js +2 -1
  12. package/addon/components/place/details.hbs +1 -1
  13. package/addon/components/positions-replay.js +4 -1
  14. package/addon/components/service-area/details.hbs +1 -1
  15. package/addon/components/widget/live-fleet.js +7 -7
  16. package/addon/components/zone/details.hbs +1 -1
  17. package/addon/controllers/management/drivers/index.js +22 -1
  18. package/addon/controllers/settings/map.js +16 -0
  19. package/addon/helpers/leaflet-tile-url.js +19 -0
  20. package/addon/services/map-settings.js +22 -0
  21. package/addon/templates/management/drivers/index.hbs +67 -33
  22. package/addon/templates/settings/map.hbs +10 -0
  23. package/addon/utils/leaflet-tile-url.js +31 -0
  24. package/addon/utils/prepare-place-for-save.js +13 -0
  25. package/app/components/driver/card.js +1 -0
  26. package/app/helpers/leaflet-tile-url.js +1 -0
  27. package/app/utils/leaflet-tile-url.js +1 -0
  28. package/composer.json +1 -1
  29. package/extension.json +1 -1
  30. package/index.js +11 -0
  31. package/package.json +2 -1
  32. package/server/src/Http/Controllers/Api/v1/DriverController.php +220 -3
  33. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  34. package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
  35. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  36. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  37. package/server/src/Http/Filter/IssueFilter.php +29 -0
  38. package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
  39. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  40. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  41. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  42. package/server/src/Models/Place.php +1 -1
  43. package/server/src/routes.php +21 -0
  44. package/server/tests/ApiDriverControllerContractsTest.php +33 -0
  45. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  46. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  47. package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
  48. package/server/tests/CompactResourceSerializationTest.php +13 -7
  49. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  50. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  51. package/server/tests/RequestContractsTest.php +5 -0
  52. package/server/tests/SettingControllerContractsTest.php +33 -0
  53. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  54. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  55. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  56. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  57. package/translations/en-us.yaml +4 -0
@@ -10,6 +10,7 @@ use Illuminate\Database\ConnectionResolver;
10
10
  use Illuminate\Database\Eloquent\Model as EloquentModel;
11
11
  use Illuminate\Database\SQLiteConnection;
12
12
  use Illuminate\Http\Request;
13
+ use Illuminate\Support\Facades\Hash;
13
14
 
14
15
  /**
15
16
  * Covers the API DriverController protected session/persistence helpers
@@ -73,6 +74,35 @@ function fleetopsDriverSessionHelpersBoot(): SQLiteConnection
73
74
  });
74
75
  Illuminate\Support\Facades\DB::clearResolvedInstance('db');
75
76
 
77
+ /*
78
+ * Only the hashing contract ships with this package, not an implementation,
79
+ * so the User password mutator has nothing to call. Bind PHP's own hashing
80
+ * behind the contract: the point of the assertions below is that a password
81
+ * is stored hashed and verifies, not which algorithm did it.
82
+ */
83
+ app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
84
+ public function info($hashedValue): array
85
+ {
86
+ return password_get_info($hashedValue);
87
+ }
88
+
89
+ public function make($value, array $options = []): string
90
+ {
91
+ return password_hash($value, PASSWORD_BCRYPT);
92
+ }
93
+
94
+ public function check($value, $hashedValue, array $options = []): bool
95
+ {
96
+ return is_string($hashedValue) && password_verify($value, $hashedValue);
97
+ }
98
+
99
+ public function needsRehash($hashedValue, array $options = []): bool
100
+ {
101
+ return false;
102
+ }
103
+ });
104
+ Hash::clearResolvedInstance('hash');
105
+
76
106
  $schema = $connection->getSchemaBuilder();
77
107
  app()->instance('db.schema', $schema);
78
108
  $tables = [
@@ -163,6 +193,18 @@ test('user and driver persistence helpers write records', function () {
163
193
  expect($user)->toBeInstanceOf(User::class)
164
194
  ->and($connection->table('users')->where('name', 'New User')->count())->toBe(1);
165
195
 
196
+ /*
197
+ * `password` is guarded on User, so mass assignment drops it without a
198
+ * word. The create endpoint has always documented and validated one, so
199
+ * unless the helper sets it explicitly a driver created through the API
200
+ * can never sign in with the password chosen for them.
201
+ */
202
+ $withPassword = $probe->callHelper('createUser', ['name' => 'Password User', 'password' => 'seeded-password']);
203
+ $stored = $connection->table('users')->where('name', 'Password User')->value('password');
204
+ expect(Hash::check('seeded-password', $withPassword->password))->toBeTrue()
205
+ ->and($stored)->not->toBeNull()
206
+ ->and($stored)->not->toBe('seeded-password');
207
+
166
208
  $driver = $probe->callHelper('createDriver', ['company_uuid' => '22222222-2222-4222-8222-222222222222', 'user_uuid' => 'user-1']);
167
209
  expect($driver)->toBeInstanceOf(Driver::class);
168
210
 
@@ -173,6 +215,25 @@ test('user and driver persistence helpers write records', function () {
173
215
  ->and($connection->table('user_devices')->count())->toBe(1);
174
216
  });
175
217
 
218
+ test('the driver user loader reads the password column the relation omits', function () {
219
+ $connection = fleetopsDriverSessionHelpersBoot();
220
+ $probe = new FleetOpsDriverSessionHelpersProbe();
221
+
222
+ $connection->table('users')->where('uuid', 'user-1')->update(['password' => 'stored-hash']);
223
+
224
+ /*
225
+ * The `user` relation selects a named subset of columns and `password` is
226
+ * not among them, so a password comparison made through it fails for
227
+ * everyone regardless of what they typed. Anything checking a password has
228
+ * to load the record itself.
229
+ */
230
+ $driver = Driver::where('public_id', 'driver_one')->first();
231
+ expect($probe->callHelper('findDriverUserRecord', $driver)->password)->toBe('stored-hash');
232
+
233
+ $unlinked = new Driver();
234
+ expect($probe->callHelper('findDriverUserRecord', $unlinked))->toBeNull();
235
+ });
236
+
176
237
  test('lookup utility and resource helpers resolve records and wrappers', function () {
177
238
  fleetopsDriverSessionHelpersBoot();
178
239
  $probe = new FleetOpsDriverSessionHelpersProbe();
@@ -36,6 +36,18 @@ if (!Request::hasMacro('resolveFilesFromIds')) {
36
36
  Request::macro('resolveFilesFromIds', fn () => FleetOpsInternalPlaceEndpointsState::$files);
37
37
  }
38
38
 
39
+ if (!Request::hasMacro('or')) {
40
+ Request::macro('or', function (array $params = [], $default = null) {
41
+ foreach ($params as $param) {
42
+ if ($this->has($param)) {
43
+ return $this->input($param);
44
+ }
45
+ }
46
+
47
+ return $default;
48
+ });
49
+ }
50
+
39
51
  class FleetOpsInternalPlaceEndpointsState
40
52
  {
41
53
  public static array $files = [];
@@ -81,6 +93,14 @@ class FleetOpsInternalPlaceEndpointsPlaceFake extends Place
81
93
  }
82
94
  }
83
95
 
96
+ class FleetOpsInternalPlaceUpdateFake extends Place
97
+ {
98
+ public function applyDirectivesToQuery(Request $request, $builder)
99
+ {
100
+ return $builder;
101
+ }
102
+ }
103
+
84
104
  class FleetOpsInternalPlaceEndpointsProbe extends PlaceController
85
105
  {
86
106
  public function callProtected(string $method, array $arguments = []): mixed
@@ -128,6 +148,7 @@ function fleetopsInternalPlaceEndpointsBoot(): SQLiteConnection
128
148
  $table->string('postal_code')->nullable();
129
149
  $table->string('location')->nullable();
130
150
  $table->timestamp('created_at')->nullable();
151
+ $table->timestamp('updated_at')->nullable();
131
152
  $table->timestamp('deleted_at')->nullable();
132
153
  });
133
154
  $schema->create('files', function ($table) {
@@ -250,3 +271,27 @@ test('sync custom field values helper delegates to the place model', function ()
250
271
 
251
272
  expect($place->syncedValues)->toBe([['priority' => 'high']]);
252
273
  });
274
+
275
+ test('place updates accept serialized display-only attributes', function () {
276
+ $connection = fleetopsInternalPlaceEndpointsBoot();
277
+ $connection->table('places')->insert([
278
+ 'uuid' => '77777777-7777-4777-8777-777777777777',
279
+ 'public_id' => 'place_display1',
280
+ 'company_uuid' => 'company-1',
281
+ 'name' => 'Original place',
282
+ 'street1' => '205 Dostyk Avenue',
283
+ ]);
284
+
285
+ $place = (new FleetOpsInternalPlaceUpdateFake())->updateRecordFromRequest(Request::create('/int/v1/places/place_display1', 'PUT', [
286
+ 'place' => [
287
+ 'name' => 'Updated place',
288
+ 'street2' => 'Entrance 2',
289
+ 'avatar_value' => 'basic-building',
290
+ 'eta' => '12 minutes',
291
+ ],
292
+ ]), 'place_display1', options: ['return_object' => true]);
293
+
294
+ expect($place->name)->toBe('Updated place')
295
+ ->and($place->street2)->toBe('Entrance 2')
296
+ ->and($connection->table('places')->where('public_id', 'place_display1')->value('name'))->toBe('Updated place');
297
+ });
@@ -300,6 +300,11 @@ namespace {
300
300
  ->and($vehicleRules['longitude'])->toBe(['nullable', 'required_with:latitude'])
301
301
  ->and(ruleStrings($vehicleRules['status']))->toContain('nullable')
302
302
  ->and(implode('|', ruleStrings($vehicleRules['status'])))->toContain('operational')
303
+ // The model casts odometer to an integer, so an unvalidated string
304
+ // would land as 0 — a vehicle reporting zero miles rather than an
305
+ // error. These rules are what stop that.
306
+ ->and($vehicleRules['odometer'])->toBe('nullable|numeric|min:0')
307
+ ->and($vehicleRules['odometer_unit'])->toBe('nullable|string|max:12')
303
308
  ->and($fuelReportRules['driver'])->toBe(['required'])
304
309
  ->and($fuelReportRules['odometer'])->toBe(['required'])
305
310
  ->and($fuelReportRules['volume'])->toBe(['required'])
@@ -408,3 +408,36 @@ test('setting controller handles map scheduling orchestrator and card field sett
408
408
  'settings' => ['standard' => ['tracking'], 'byConfig' => [], 'meta' => ['dense' => true]],
409
409
  ]);
410
410
  });
411
+
412
+ test('setting controller sanitizes leaflet tile provider urls', function () {
413
+ $controller = new FleetOpsSettingControllerProbe();
414
+
415
+ $saved = fleetopsJsonPayload($controller->saveMapSettings(new Request([
416
+ 'settings' => [
417
+ 'leafletTileUrl' => ' https://tile.openstreetmap.org/{z}/{x}/{y}.png ',
418
+ 'leafletDarkTileUrl' => 'javascript:alert(1)',
419
+ ],
420
+ ])));
421
+
422
+ expect($saved['leafletTileUrl'])->toBe('https://tile.openstreetmap.org/{z}/{x}/{y}.png')
423
+ ->and($saved['leafletDarkTileUrl'])->toBe('')
424
+ ->and($controller->configuredCompany['fleet-ops.map-settings'])->toMatchArray([
425
+ 'leafletTileUrl' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
426
+ 'leafletDarkTileUrl' => '',
427
+ ]);
428
+
429
+ $savedNonString = fleetopsJsonPayload($controller->saveMapSettings(new Request([
430
+ 'settings' => [
431
+ 'leafletTileUrl' => ['not' => 'a-string'],
432
+ 'leafletDarkTileUrl' => 'ftp://tiles.example.com/{z}/{x}/{y}.png',
433
+ ],
434
+ ])));
435
+
436
+ expect($savedNonString['leafletTileUrl'])->toBe('')
437
+ ->and($savedNonString['leafletDarkTileUrl'])->toBe('');
438
+
439
+ $defaults = fleetopsJsonPayload((new FleetOpsSettingControllerProbe())->getMapSettings());
440
+
441
+ expect($defaults['leafletTileUrl'])->toBe('')
442
+ ->and($defaults['leafletDarkTileUrl'])->toBe('');
443
+ });
@@ -0,0 +1,135 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Http\Filter\FuelReportFilter;
4
+ use Fleetbase\Http\Filter\Filter;
5
+ use Illuminate\Http\Request;
6
+
7
+ /**
8
+ * Covers the FuelReportFilter query branches against a recording builder fake:
9
+ * base scoping, keyword search, priority and status constraints, and the
10
+ * assignee, reporter, driver and vehicle relation lookups routing by uuid,
11
+ * public id, or fallback search.
12
+ */
13
+ class FleetOpsFuelReportFilterUnitQuery
14
+ {
15
+ public array $calls = [];
16
+
17
+ public function where(...$arguments): self
18
+ {
19
+ if (isset($arguments[0]) && is_callable($arguments[0])) {
20
+ $nested = new self();
21
+ $arguments[0]($nested);
22
+ $this->calls[] = ['whereNested', $nested->calls];
23
+
24
+ return $this;
25
+ }
26
+
27
+ $this->calls[] = ['where', $arguments];
28
+
29
+ return $this;
30
+ }
31
+
32
+ public function whereHas(string $relation, ?callable $callback = null): self
33
+ {
34
+ $nested = new self();
35
+ if ($callback) {
36
+ $callback($nested);
37
+ }
38
+ $this->calls[] = ['whereHas', $relation, $nested->calls];
39
+
40
+ return $this;
41
+ }
42
+
43
+ public function search(?string $query): self
44
+ {
45
+ $this->calls[] = ['search', $query];
46
+
47
+ return $this;
48
+ }
49
+
50
+ public function whereIn(string $column, mixed $values): self
51
+ {
52
+ $this->calls[] = ['whereIn', $column, $values];
53
+
54
+ return $this;
55
+ }
56
+
57
+ public function whereBetween(string $column, mixed $bounds): self
58
+ {
59
+ $this->calls[] = ['whereBetween', $column, $bounds];
60
+
61
+ return $this;
62
+ }
63
+
64
+ public function whereDate(string $column, mixed $value): self
65
+ {
66
+ $this->calls[] = ['whereDate', $column, $value];
67
+
68
+ return $this;
69
+ }
70
+
71
+ public function __call($method, $arguments): self
72
+ {
73
+ $this->calls[] = [$method, $arguments];
74
+
75
+ return $this;
76
+ }
77
+ }
78
+
79
+ function fleetopsFuelReportFilterUnitFilter(FleetOpsFuelReportFilterUnitQuery $builder, ?Request $request = null): FuelReportFilter
80
+ {
81
+ $filter = (new ReflectionClass(FuelReportFilter::class))->newInstanceWithoutConstructor();
82
+
83
+ foreach ([
84
+ 'builder' => $builder,
85
+ 'session' => new class {
86
+ public function get(string $key): ?string
87
+ {
88
+ return $key === 'company' ? 'company-uuid' : null;
89
+ }
90
+ },
91
+ 'request' => $request ?? new Request(),
92
+ ] as $property => $value) {
93
+ $reflection = new ReflectionProperty(Filter::class, $property);
94
+ $reflection->setAccessible(true);
95
+ $reflection->setValue($filter, $value);
96
+ }
97
+
98
+ return $filter;
99
+ }
100
+
101
+ test('fuel report filter scopes by driver_uuid and driver_assigned, not just driver', function () {
102
+ foreach (['driverUuid', 'driverAssigned'] as $method) {
103
+ $builder = new FleetOpsFuelReportFilterUnitQuery();
104
+ $filter = fleetopsFuelReportFilterUnitFilter($builder);
105
+
106
+ $filter->{$method}('driver_filterone');
107
+
108
+ $whereHas = collect($builder->calls)->where(0, 'whereHas')->firstWhere(1, 'driver');
109
+ expect($whereHas)->not->toBeNull("{$method} must constrain the driver relation")
110
+ ->and($whereHas[2][0][1][0])->toBe('public_id');
111
+ }
112
+ });
113
+
114
+ test('fuel report filter driver aliases route uuids the same way driver does', function () {
115
+ $builder = new FleetOpsFuelReportFilterUnitQuery();
116
+ $filter = fleetopsFuelReportFilterUnitFilter($builder);
117
+
118
+ $filter->driverUuid('33333333-3333-4333-8333-333333333333');
119
+
120
+ $whereHas = collect($builder->calls)->where(0, 'whereHas')->firstWhere(1, 'driver');
121
+ expect($whereHas[2][0][1][0])->toBe('uuid');
122
+ });
123
+
124
+ test('fuel report filter scopes by vehicle_uuid as well as vehicle', function () {
125
+ $builder = new FleetOpsFuelReportFilterUnitQuery();
126
+ $filter = fleetopsFuelReportFilterUnitFilter($builder);
127
+
128
+ $filter->vehicleUuid('44444444-4444-4444-8444-444444444444');
129
+ $filter->vehicleUuid('vehicle_filterone');
130
+
131
+ $whereHasCalls = collect($builder->calls)->where(0, 'whereHas')->where(1, 'vehicle')->values();
132
+ expect($whereHasCalls)->toHaveCount(2)
133
+ ->and($whereHasCalls[0][2][0][1][0])->toBe('uuid')
134
+ ->and($whereHasCalls[1][2][0][1][0])->toBe('public_id');
135
+ });
@@ -127,3 +127,46 @@ test('issue filter relation branches route uuids public ids and searches', funct
127
127
  $searchNested = $whereHasCalls->filter(fn ($call) => collect($call[2])->contains(fn ($inner) => $inner[0] === 'search'));
128
128
  expect($searchNested->count())->toBeGreaterThanOrEqual(3);
129
129
  });
130
+
131
+ test('issue filter scopes by driver_uuid and driver_assigned, not just driver', function () {
132
+ /*
133
+ * The base filter resolves a query parameter to a method of the same name
134
+ * and silently ignores what it cannot match. `driver_uuid` matched nothing,
135
+ * so the filter was dropped and the list came back scoped only by company —
136
+ * every driver's issues, with a 200 and no sign the request had been
137
+ * narrowed. For a driver app that is a disclosure, not a nuisance.
138
+ */
139
+ foreach (['driverUuid', 'driverAssigned'] as $method) {
140
+ $builder = new FleetOpsIssueFilterUnitQuery();
141
+ $filter = fleetopsIssueFilterUnitFilter($builder);
142
+
143
+ $filter->{$method}('driver_filterone');
144
+
145
+ $whereHas = collect($builder->calls)->where(0, 'whereHas')->firstWhere(1, 'driver');
146
+ expect($whereHas)->not->toBeNull("{$method} must constrain the driver relation")
147
+ ->and($whereHas[2][0][1][0])->toBe('public_id');
148
+ }
149
+ });
150
+
151
+ test('issue filter driver aliases route uuids the same way driver does', function () {
152
+ $builder = new FleetOpsIssueFilterUnitQuery();
153
+ $filter = fleetopsIssueFilterUnitFilter($builder);
154
+
155
+ $filter->driverUuid('33333333-3333-4333-8333-333333333333');
156
+
157
+ $whereHas = collect($builder->calls)->where(0, 'whereHas')->firstWhere(1, 'driver');
158
+ expect($whereHas[2][0][1][0])->toBe('uuid');
159
+ });
160
+
161
+ test('issue filter scopes by vehicle_uuid as well as vehicle', function () {
162
+ $builder = new FleetOpsIssueFilterUnitQuery();
163
+ $filter = fleetopsIssueFilterUnitFilter($builder);
164
+
165
+ $filter->vehicleUuid('44444444-4444-4444-8444-444444444444');
166
+ $filter->vehicleUuid('vehicle_filterone');
167
+
168
+ $whereHasCalls = collect($builder->calls)->where(0, 'whereHas')->where(1, 'vehicle')->values();
169
+ expect($whereHasCalls)->toHaveCount(2)
170
+ ->and($whereHasCalls[0][2][0][1][0])->toBe('uuid')
171
+ ->and($whereHasCalls[1][2][0][1][0])->toBe('public_id');
172
+ });
@@ -0,0 +1,129 @@
1
+ <?php
2
+
3
+ /*
4
+ * The models resolve their connection through a namespaced `config()`, which
5
+ * does not exist without the full application. Stubbed so a resource can be
6
+ * exercised on its own — the projection is the thing under test, not Eloquent.
7
+ */
8
+ if (!function_exists('Fleetbase\Traits\config')) {
9
+ eval('namespace Fleetbase\Traits; function config($key = null, $default = null) { return $key === "api.cache.enabled" ? false : $default; }');
10
+ }
11
+
12
+ if (!function_exists('Fleetbase\Models\config')) {
13
+ eval('namespace Fleetbase\Models; function config($key = null, $default = null) { return $default; }');
14
+ }
15
+
16
+ if (!function_exists('Fleetbase\FleetOps\Models\config')) {
17
+ eval('namespace Fleetbase\FleetOps\Models; function config($key = null, $default = null) { return $default; }');
18
+ }
19
+
20
+ use Fleetbase\FleetOps\Http\Resources\v1\Manifest as ManifestResource;
21
+ use Fleetbase\FleetOps\Http\Resources\v1\ManifestStop as ManifestStopResource;
22
+ use Fleetbase\FleetOps\Models\Manifest;
23
+ use Fleetbase\FleetOps\Models\ManifestStop;
24
+ use Illuminate\Http\Request;
25
+
26
+ if (!class_exists('FleetOpsSupportRequestState')) {
27
+ class FleetOpsSupportRequestState
28
+ {
29
+ public static ?Request $request = null;
30
+ }
31
+ }
32
+
33
+ if (!function_exists('Fleetbase\Support\request')) {
34
+ eval('namespace Fleetbase\Support; function request($key = null, $default = null) { return \FleetOpsSupportRequestState::$request; }');
35
+ }
36
+
37
+ /**
38
+ * The manifest's stop counts are accessors that query the database, and the
39
+ * driver and vehicle names walk relations. None of that is what a resource test
40
+ * is for, so they are answered directly — the projection is under test, not
41
+ * Eloquent.
42
+ */
43
+ class FleetOpsManifestFake extends Manifest
44
+ {
45
+ public function getCompletedStopsAttribute(): int
46
+ {
47
+ return 3;
48
+ }
49
+
50
+ public function getPendingStopsAttribute(): int
51
+ {
52
+ return 5;
53
+ }
54
+
55
+ public function getDriverNameAttribute(): ?string
56
+ {
57
+ return 'Ron';
58
+ }
59
+
60
+ public function getVehicleNameAttribute(): ?string
61
+ {
62
+ return 'EAS-01';
63
+ }
64
+ }
65
+
66
+ test('manifest resource publishes what a driver needs to run a route', function () {
67
+ $request = Request::create('/v1/manifests/manifest_public', 'GET');
68
+ FleetOpsSupportRequestState::$request = $request;
69
+
70
+ $manifest = new FleetOpsManifestFake();
71
+ $manifest->setRawAttributes([
72
+ 'id' => 3,
73
+ 'uuid' => 'manifest-uuid',
74
+ 'public_id' => 'manifest_public',
75
+ 'status' => 'in_progress',
76
+ 'total_distance_m' => 41200,
77
+ 'total_duration_s' => 5400,
78
+ 'stop_count' => 8,
79
+ 'notes' => 'Cold chain first',
80
+ ], true);
81
+
82
+ $payload = (new ManifestResource($manifest))->resolve($request);
83
+
84
+ expect($payload['id'])->toBe('manifest_public')
85
+ ->and($payload['status'])->toBe('in_progress')
86
+ ->and($payload['total_distance_m'])->toBe(41200)
87
+ ->and($payload['stop_count'])->toBe(8)
88
+ ->and($payload['notes'])->toBe('Cold chain first')
89
+ ->and($payload['completed_stops'])->toBe(3)
90
+ ->and($payload['pending_stops'])->toBe(5)
91
+ ->and($payload['driver_name'])->toBe('Ron');
92
+ });
93
+
94
+ test('manifest resource omits stops when they were not loaded, so a list stays a list', function () {
95
+ // A driver's manifest list on a busy fleet must not drag every stop of
96
+ // every route along with it.
97
+ $request = Request::create('/v1/drivers/driver_public/manifests', 'GET');
98
+ FleetOpsSupportRequestState::$request = $request;
99
+
100
+ $manifest = new FleetOpsManifestFake();
101
+ $manifest->setRawAttributes(['id' => 4, 'uuid' => 'm-uuid', 'public_id' => 'manifest_public', 'status' => 'pending'], true);
102
+
103
+ $payload = (new ManifestResource($manifest))->resolve($request);
104
+
105
+ expect($payload)->not->toHaveKey('stops');
106
+ });
107
+
108
+ test('manifest stop resource carries the sequence a re-sequence rewrites', function () {
109
+ $request = Request::create('/v1/manifest-stops/stop_public', 'GET');
110
+ FleetOpsSupportRequestState::$request = $request;
111
+
112
+ $stop = new ManifestStop();
113
+ $stop->setRawAttributes([
114
+ 'id' => 9,
115
+ 'uuid' => 'stop-uuid',
116
+ 'public_id' => 'stop_public',
117
+ 'status' => 'pending',
118
+ 'sequence' => 4,
119
+ 'distance_from_prev_m' => 1800,
120
+ 'duration_from_prev_s' => 240,
121
+ ], true);
122
+
123
+ $payload = (new ManifestStopResource($stop))->resolve($request);
124
+
125
+ expect($payload['id'])->toBe('stop_public')
126
+ ->and($payload['sequence'])->toBe(4)
127
+ ->and($payload['status'])->toBe('pending')
128
+ ->and($payload['distance_from_prev_m'])->toBe(1800);
129
+ });
@@ -45,3 +45,94 @@ test('order config resource projects empty and invalid flow as an empty list', f
45
45
  expect($payload['id'])->toBe('order_config_public')
46
46
  ->and($payload['flow'])->toBe([]);
47
47
  });
48
+
49
+ test('order config resource publishes the flow graph so consumers can sequence it', function () {
50
+ $request = Request::create('/v1/fleet-ops/order-configs/order_config_public', 'GET');
51
+ FleetOpsOrderConfigResourceRequestState::$request = $request;
52
+ FleetOpsSupportRequestState::$request = $request;
53
+
54
+ // Declared deliberately out of workflow order, which is how flows are
55
+ // stored: `completed` before the step that leads to it, `dispatched` last.
56
+ $config = new OrderConfig();
57
+ $config->setRawAttributes([
58
+ 'id' => 8,
59
+ 'uuid' => 'order-config-uuid',
60
+ 'public_id' => 'order_config_public',
61
+ 'key' => 'transport',
62
+ 'name' => 'Transport',
63
+ 'flow' => [
64
+ ['code' => 'completed', 'status' => 'Order Completed', 'complete' => true, 'activities' => []],
65
+ ['code' => 'created', 'status' => 'Order Created', 'activities' => ['dispatched']],
66
+ ['code' => 'dispatched', 'status' => 'Order Dispatched', 'activities' => ['enroute'], 'sequence' => 2],
67
+ [
68
+ 'code' => 'enroute',
69
+ 'status' => 'Driver Enroute',
70
+ 'activities' => ['completed', 'failed'],
71
+ 'logic' => [['type' => 'and', 'conditions' => []]],
72
+ ],
73
+ ],
74
+ ], true);
75
+
76
+ $flow = (new OrderConfigResource($config))->resolve($request)['flow'];
77
+ $byCode = collect($flow)->keyBy('code');
78
+
79
+ expect($byCode['created']['activities'])->toBe(['dispatched'])
80
+ ->and($byCode['dispatched']['sequence'])->toBe(2)
81
+ ->and($byCode['enroute']['activities'])->toBe(['completed', 'failed'])
82
+ ->and($byCode['enroute']['logic'])->toBe([['type' => 'and', 'conditions' => []]])
83
+ ->and($byCode['completed']['complete'])->toBeTrue();
84
+ });
85
+
86
+ test('order config resource normalises transitions written as objects', function () {
87
+ $request = Request::create('/v1/fleet-ops/order-configs/order_config_public', 'GET');
88
+ FleetOpsOrderConfigResourceRequestState::$request = $request;
89
+ FleetOpsSupportRequestState::$request = $request;
90
+
91
+ // Flows have been authored both ways; a consumer should not have to guess.
92
+ $config = new OrderConfig();
93
+ $config->setRawAttributes([
94
+ 'id' => 9,
95
+ 'uuid' => 'order-config-uuid',
96
+ 'public_id' => 'order_config_public',
97
+ 'key' => 'transport',
98
+ 'name' => 'Transport',
99
+ 'flow' => [
100
+ ['code' => 'created', 'activities' => [['code' => 'dispatched'], 'enroute', ['label' => 'no code here']]],
101
+ // A flow hand-edited down to a single transition can leave a bare
102
+ // string where the list belongs. Publish an empty list rather than
103
+ // letting the shape vary — a consumer iterating the field should
104
+ // never have to type-check it.
105
+ ['code' => 'dispatched', 'activities' => 'enroute'],
106
+ ],
107
+ ], true);
108
+
109
+ $flow = (new OrderConfigResource($config))->resolve($request)['flow'];
110
+
111
+ expect($flow[0]['activities'])->toBe(['dispatched', 'enroute'])
112
+ ->and($flow[1]['activities'])->toBe([]);
113
+ });
114
+
115
+ test('order config resource keeps flow fields absent from a legacy activity null rather than missing', function () {
116
+ $request = Request::create('/v1/fleet-ops/order-configs/order_config_public', 'GET');
117
+ FleetOpsOrderConfigResourceRequestState::$request = $request;
118
+ FleetOpsSupportRequestState::$request = $request;
119
+
120
+ $config = new OrderConfig();
121
+ $config->setRawAttributes([
122
+ 'id' => 10,
123
+ 'uuid' => 'order-config-uuid',
124
+ 'public_id' => 'order_config_public',
125
+ 'key' => 'transport',
126
+ 'name' => 'Transport',
127
+ 'flow' => [['code' => 'created', 'status' => 'Order Created']],
128
+ ], true);
129
+
130
+ $flow = (new OrderConfigResource($config))->resolve($request)['flow'];
131
+
132
+ // Present-and-null is a contract a client can read; absent is one it has to
133
+ // feel for.
134
+ expect($flow[0])->toHaveKeys(['sequence', 'activities', 'logic'])
135
+ ->and($flow[0]['sequence'])->toBeNull()
136
+ ->and($flow[0]['activities'])->toBe([])
137
+ ->and($flow[0]['logic'])->toBeNull();
138
+ });
@@ -2006,6 +2006,10 @@ settings:
2006
2006
  map-provider: Map Provider
2007
2007
  map-provider-help-text: Choose the map engine used to render the live operations map. Leaflet uses OpenStreetMap tiles and requires no API key. Google Maps requires a valid Google Maps JavaScript API key.
2008
2008
  select-map-provider: Select map provider
2009
+ leaflet-tile-url: Leaflet Tile Provider URL
2010
+ leaflet-tile-url-help-text: Optional custom XYZ tile provider URL template used by the Leaflet map. Leave blank to use the default OpenStreetMap tiles which require no API key. Use this to point Leaflet at your own tile provider, including keyed providers such as CARTO.
2011
+ leaflet-dark-tile-url: Leaflet Dark Mode Tile Provider URL
2012
+ leaflet-dark-tile-url-help-text: Optional tile provider URL used when the console is in dark mode. Leave blank to fall back to the tile provider URL above, or the default OpenStreetMap tiles.
2009
2013
  google-maps-map-type: Map Type
2010
2014
  google-maps-map-type-help-text: The default map style to display when Google Maps is active.
2011
2015
  select-map-type: Select map type