@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.
Files changed (91) hide show
  1. package/RELEASE.md +25 -0
  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/vehicle/details/schedules.hbs +2 -2
  16. package/addon/components/vehicle/details/schedules.js +7 -1
  17. package/addon/components/widget/live-fleet.js +7 -7
  18. package/addon/components/zone/details.hbs +1 -1
  19. package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
  20. package/addon/controllers/management/drivers/index.js +22 -1
  21. package/addon/controllers/settings/map.js +16 -0
  22. package/addon/helpers/leaflet-tile-url.js +19 -0
  23. package/addon/services/map-settings.js +22 -0
  24. package/addon/services/vehicle-actions.js +4 -2
  25. package/addon/templates/management/drivers/index.hbs +67 -33
  26. package/addon/templates/settings/map.hbs +10 -0
  27. package/addon/utils/leaflet-tile-url.js +31 -0
  28. package/addon/utils/prepare-place-for-save.js +13 -0
  29. package/app/components/driver/card.js +1 -0
  30. package/app/helpers/leaflet-tile-url.js +1 -0
  31. package/app/utils/leaflet-tile-url.js +1 -0
  32. package/composer.json +1 -1
  33. package/extension.json +1 -1
  34. package/index.js +11 -0
  35. package/package.json +2 -1
  36. package/server/config/fleetops.php +44 -0
  37. package/server/config/geocoder.php +1 -1
  38. package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
  39. package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
  40. package/server/src/Http/Controllers/Api/v1/DriverController.php +277 -5
  41. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
  42. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  43. package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
  44. package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
  45. package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
  46. package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
  47. package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
  48. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  49. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  50. package/server/src/Http/Filter/IssueFilter.php +29 -0
  51. package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
  52. package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
  53. package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
  54. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
  55. package/server/src/Http/Requests/CreatePartRequest.php +39 -1
  56. package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
  57. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  58. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  59. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  60. package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
  61. package/server/src/Models/FuelReport.php +21 -0
  62. package/server/src/Models/Place.php +1 -1
  63. package/server/src/Models/Sensor.php +24 -0
  64. package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
  65. package/server/src/routes.php +22 -1
  66. package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
  67. package/server/tests/ApiDriverControllerContractsTest.php +65 -0
  68. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  69. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  70. package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
  71. package/server/tests/CompactResourceSerializationTest.php +99 -0
  72. package/server/tests/CustomerEndpointTest.php +4 -1
  73. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
  74. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  75. package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
  76. package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
  77. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
  78. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  79. package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
  80. package/server/tests/RequestContractsTest.php +102 -5
  81. package/server/tests/SettingControllerContractsTest.php +33 -0
  82. package/server/tests/SmallControllerContractsTest.php +28 -4
  83. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  84. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  85. package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
  86. package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
  87. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  88. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  89. package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
  90. package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
  91. package/translations/en-us.yaml +4 -0
@@ -1400,6 +1400,12 @@ test('order config resource projects public flow activities', function () {
1400
1400
  'complete' => false,
1401
1401
  'pod_method' => 'photo',
1402
1402
  'require_pod' => true,
1403
+ // The flow's shape now rides along so consumers can sequence
1404
+ // it. An activity that declares none of it still publishes the
1405
+ // keys, so the payload shape does not vary per activity.
1406
+ 'sequence' => null,
1407
+ 'activities' => [],
1408
+ 'logic' => null,
1403
1409
  ],
1404
1410
  ],
1405
1411
  ]);
@@ -2697,6 +2703,10 @@ test('tracking number and waypoint webhook resources serialize tracking contract
2697
2703
  'scheduled_at' => '2026-07-30 09:00:00',
2698
2704
  ]);
2699
2705
 
2706
+ // The decoded QR content is a debug-only field and must never ride along on a webhook,
2707
+ // which is a separate literal payload rather than a filtered toArray().
2708
+ expect(array_key_exists('qr_code_content', (new TrackingNumberResource($trackingNumber))->toWebhookPayload()))->toBeFalse();
2709
+
2700
2710
  expect((new TrackingNumberResource($trackingNumber))->toWebhookPayload())->toMatchArray([
2701
2711
  'id' => 'fixture_public',
2702
2712
  'tracking_number' => 'TN-ZERO',
@@ -2743,3 +2753,92 @@ test('tracking number and waypoint webhook resources serialize tracking contract
2743
2753
  'meta' => ['sequence' => 2],
2744
2754
  ]);
2745
2755
  });
2756
+
2757
+ test('tracking number resource publishes the qr code content only in debug mode', function () {
2758
+ // qr_code is a base64 PNG generated from owner_uuid, and the endpoints that consume a
2759
+ // scanned code match on that uuid. Debug builds publish the value beside the image so
2760
+ // an automated client can follow the flow without decoding a PNG; production must not.
2761
+ $trackingNumber = fleetopsCompactResourceFixture([
2762
+ 'tracking_number' => 'TN-DEBUG',
2763
+ 'owner_uuid' => 'owner-uuid-under-the-qr',
2764
+ 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order',
2765
+ 'region' => 'sg',
2766
+ 'qr_code' => 'qr-data',
2767
+ 'barcode' => 'barcode-data',
2768
+ 'last_status' => 'created',
2769
+ 'last_status_code' => 'CREATED',
2770
+ ]);
2771
+ $request = Request::create('/v1/tracking-numbers', 'GET');
2772
+
2773
+ // The full resource builds a console url, which needs app()->environment(). The bare
2774
+ // container this suite runs on has no such method, which is why the tests above only
2775
+ // cover the index resource and the webhook payload.
2776
+ $previousContainer = Illuminate\Container\Container::getInstance();
2777
+ $container = new class extends Illuminate\Container\Container {
2778
+ public bool $debugMode = false;
2779
+
2780
+ public function environment(...$environments)
2781
+ {
2782
+ return in_array('testing', $environments, true) || $environments === [] ? 'testing' : false;
2783
+ }
2784
+
2785
+ // Stands in for the framework's Application::hasDebugModeEnabled().
2786
+ public function hasDebugModeEnabled()
2787
+ {
2788
+ return $this->debugMode;
2789
+ }
2790
+ };
2791
+ $container->instance('config', new Illuminate\Config\Repository([
2792
+ 'fleetbase' => ['console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]],
2793
+ ]));
2794
+ Illuminate\Container\Container::setInstance($container);
2795
+
2796
+ // Drive the REAL accessor through the container rather than overriding it, so the
2797
+ // debug check itself is exercised and not just the resource's use of it.
2798
+ $container->debugMode = true;
2799
+ $withDebug = new TrackingNumberResource($trackingNumber);
2800
+
2801
+ try {
2802
+ $debugPayload = $withDebug->resolve($request);
2803
+
2804
+ $container->debugMode = false;
2805
+ $productionPayload = (new TrackingNumberResource($trackingNumber))->resolve($request);
2806
+ } finally {
2807
+ Illuminate\Container\Container::setInstance($previousContainer);
2808
+ }
2809
+
2810
+ // Present and exactly the value the QR was generated from — not a public id.
2811
+ expect($debugPayload['qr_code_content'])->toBe('owner-uuid-under-the-qr')
2812
+ ->and($debugPayload['qr_code'])->toBe('qr-data')
2813
+ // Absent, not null: `when()` drops the key entirely, so a production response is
2814
+ // byte-identical to one from before the field existed.
2815
+ ->and(array_key_exists('qr_code_content', $productionPayload))->toBeFalse()
2816
+ ->and($productionPayload['qr_code'])->toBe('qr-data');
2817
+ });
2818
+
2819
+ test('tracking number resource fails closed when the debug state cannot be determined', function () {
2820
+ // Any failure to resolve the debug state must answer false rather than defaulting to
2821
+ // exposure. The real accessor is exercised here with no usable application bound.
2822
+ $expose = new ReflectionMethod(TrackingNumberResource::class, 'exposesQrCodeContent');
2823
+ $expose->setAccessible(true);
2824
+
2825
+ $previous = Illuminate\Container\Container::getInstance();
2826
+
2827
+ try {
2828
+ // A container that is not an Application has no hasDebugModeEnabled().
2829
+ Illuminate\Container\Container::setInstance(new Illuminate\Container\Container());
2830
+ expect($expose->invoke(null))->toBeFalse();
2831
+
2832
+ // And an accessor that throws must also answer false rather than propagating —
2833
+ // a resource must not be able to fail serialization over a debug check.
2834
+ Illuminate\Container\Container::setInstance(new class extends Illuminate\Container\Container {
2835
+ public function hasDebugModeEnabled()
2836
+ {
2837
+ throw new RuntimeException('debug state unavailable');
2838
+ }
2839
+ });
2840
+ expect($expose->invoke(null))->toBeFalse();
2841
+ } finally {
2842
+ Illuminate\Container\Container::setInstance($previous);
2843
+ }
2844
+ });
@@ -231,8 +231,11 @@ test('FormRequest validators are present and authorize via api credential', func
231
231
  $create = file_get_contents(dirname(__DIR__) . '/src/Http/Requests/CreateCustomerRequest.php');
232
232
  $verify = file_get_contents(dirname(__DIR__) . '/src/Http/Requests/VerifyCreateCustomerRequest.php');
233
233
 
234
+ // These match against source text, so they have to be updated whenever the rule
235
+ // strings change. `code` is presence-only by design — the controller's own check
236
+ // (code + for + meta->identity) is strictly stronger than exists:verification_codes.
234
237
  expect($create)
235
- ->toContain("'code' => 'required|exists:verification_codes,code'")
238
+ ->toContain("'code' => 'required|string'")
236
239
  ->toContain("'password' => 'required|string|min:8'")
237
240
  ->and($verify)->toContain("'mode' => 'required|in:email,sms'");
238
241
  });
@@ -84,8 +84,59 @@ class FleetOpsDriverAuthMailerFake
84
84
  }
85
85
  }
86
86
 
87
+ /**
88
+ * Swap in a container that answers environment().
89
+ *
90
+ * verificationBypassMatches() asks app()->environment('production'), which the bare
91
+ * Illuminate\Container\Container used by this harness does not implement. Mirrors
92
+ * fleetopsCustomerHelperContainer() in CustomerControllerHelperSeamsTest.
93
+ */
94
+ function fleetopsDriverAuthContainer(): void
95
+ {
96
+ $current = Illuminate\Container\Container::getInstance();
97
+ if (method_exists($current, 'hasDebugModeEnabled')) {
98
+ return;
99
+ }
100
+
101
+ $replacement = new class extends Illuminate\Container\Container {
102
+ public function environment(...$environments)
103
+ {
104
+ if (empty($environments)) {
105
+ return 'testing';
106
+ }
107
+
108
+ $checks = is_array($environments[0]) ? $environments[0] : $environments;
109
+
110
+ return in_array('testing', $checks, true);
111
+ }
112
+
113
+ public function hasDebugModeEnabled()
114
+ {
115
+ return true;
116
+ }
117
+ };
118
+
119
+ foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'extenders', 'tags', 'contextual', 'scopedInstances', 'reboundCallbacks', 'globalBeforeResolvingCallbacks', 'globalResolvingCallbacks', 'globalAfterResolvingCallbacks', 'beforeResolvingCallbacks', 'resolvingCallbacks', 'afterResolvingCallbacks'] as $property) {
120
+ if (!property_exists(Illuminate\Container\Container::class, $property)) {
121
+ continue;
122
+ }
123
+ $reflection = new ReflectionProperty(Illuminate\Container\Container::class, $property);
124
+ $reflection->setAccessible(true);
125
+ if ($reflection->isInitialized($current)) {
126
+ $reflection->setValue($replacement, $reflection->getValue($current));
127
+ }
128
+ }
129
+
130
+ Illuminate\Container\Container::setInstance($replacement);
131
+ Illuminate\Support\Facades\Facade::setFacadeApplication($replacement);
132
+ }
133
+
87
134
  function fleetopsDriverAuthBoot(): SQLiteConnection
88
135
  {
136
+ // Must run before the app()->instance() calls below, so those bindings land on
137
+ // the replacement container rather than the one it supersedes.
138
+ fleetopsDriverAuthContainer();
139
+
89
140
  $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
90
141
  $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
91
142
  $resolver->setDefaultConnection('mysql');
@@ -231,6 +282,7 @@ test('code verification handles unknown users invalid codes bypass and success',
231
282
 
232
283
  // Bypass code from config authenticates without a stored code
233
284
  config()->set('fleetops.navigator.bypass_verification_code', '777777');
285
+ config()->set('fleetops.navigator.review_accounts', ['driver@example.test']);
234
286
  $bypassed = $controller->verifyCode(Request::create('/x', 'POST', ['identity' => 'driver@example.test', 'code' => '777777']));
235
287
  expect($bypassed)->toBeInstanceOf(DriverResource::class);
236
288
 
@@ -346,6 +398,7 @@ test('code verification reports token issuance failures', function () {
346
398
  // A bypass code authenticates without a stored verification code, so the
347
399
  // flow reaches token issuance; without the token table that send fails
348
400
  config()->set('fleetops.navigator.bypass_verification_code', '777777');
401
+ config()->set('fleetops.navigator.review_accounts', ['driver@example.test']);
349
402
  $connection->getSchemaBuilder()->drop('personal_access_tokens');
350
403
 
351
404
  $response = $controller->verifyCode(Request::create('/x', 'POST', [
@@ -400,6 +453,7 @@ test('token persistence failures are reported to sentry', function () {
400
453
  }
401
454
 
402
455
  config()->set('fleetops.navigator.bypass_verification_code', '777777');
456
+ config()->set('fleetops.navigator.review_accounts', ['driver@example.test']);
403
457
  $response = $controller->verifyCode(Request::create('/x', 'POST', [
404
458
  'identity' => 'driver@example.test',
405
459
  'code' => '777777',
@@ -416,14 +470,16 @@ test('current driver resolves the authenticated users driver profile', function
416
470
  $connection->table('users')->insert(['uuid' => 'user-current-1', 'public_id' => 'user_current1', 'company_uuid' => 'company-1', 'name' => 'Current Driver', 'email' => 'current@example.test', 'type' => 'driver']);
417
471
  $connection->table('drivers')->insert(['uuid' => 'driver-current-1', 'public_id' => 'driver_current1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-current-1']);
418
472
 
419
- // registerDevice without an id resolves the driver from the request user
473
+ // registerDevice without an id resolves the driver from the authenticated user
420
474
  // rather than a route parameter, so exercise the real seam here — the
421
475
  // contract probe overrides it and never runs this body
422
476
  $currentDriver = new ReflectionMethod(DriverController::class, 'currentDriver');
423
477
  $currentDriver->setAccessible(true);
424
478
 
425
479
  $request = Request::create('/v1/drivers/register-device', 'POST');
426
- $request->setUserResolver(fn () => $connection->table('users')->where('uuid', 'user-current-1')->first());
480
+ // Auth::getUserFromSession only accepts a User model from the resolver — anything
481
+ // else falls through to the session, so this must be a real model, not a row object.
482
+ $request->setUserResolver(fn () => User::query()->where('uuid', 'user-current-1')->first());
427
483
 
428
484
  $resolved = $currentDriver->invoke(new DriverController(), $request);
429
485
 
@@ -431,8 +487,31 @@ test('current driver resolves the authenticated users driver profile', function
431
487
  ->and($resolved->uuid)->toBe('driver-current-1');
432
488
  });
433
489
 
490
+ test('current driver resolves from the session when the request has no user resolver', function () {
491
+ $connection = fleetopsDriverAuthBoot();
492
+ $connection->table('users')->insert(['uuid' => 'user-session-1', 'public_id' => 'user_session1', 'company_uuid' => 'company-1', 'name' => 'Session Driver', 'email' => 'session@example.test', 'type' => 'driver']);
493
+ $connection->table('drivers')->insert(['uuid' => 'driver-session-1', 'public_id' => 'driver_session1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-session-1']);
494
+
495
+ // This is the production path for the public API. `fleetbase.api` authenticates with
496
+ // Auth::setSession($apiCredential), which writes session('user') but does NOT bind a
497
+ // user resolver — so reading $request->user() directly resolved nothing and
498
+ // POST /v1/drivers/register-device answered 404 for every consumer.
499
+ session(['user' => 'user-session-1']);
500
+
501
+ $currentDriver = new ReflectionMethod(DriverController::class, 'currentDriver');
502
+ $currentDriver->setAccessible(true);
503
+
504
+ $resolved = $currentDriver->invoke(new DriverController(), Request::create('/v1/drivers/register-device', 'POST'));
505
+
506
+ expect($resolved)->toBeInstanceOf(Driver::class)
507
+ ->and($resolved->uuid)->toBe('driver-session-1');
508
+ });
509
+
434
510
  test('current driver fails when the request has no matching driver', function () {
435
511
  fleetopsDriverAuthBoot();
512
+ // Explicit, because the session survives between tests and a leftover `user` key
513
+ // would silently resolve a driver and make this assertion vacuous.
514
+ session(['user' => null]);
436
515
 
437
516
  $currentDriver = new ReflectionMethod(DriverController::class, 'currentDriver');
438
517
  $currentDriver->setAccessible(true);
@@ -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();
@@ -1,6 +1,7 @@
1
1
  <?php
2
2
 
3
3
  use Fleetbase\FleetOps\Http\Controllers\Api\v1\GeofenceController;
4
+ use Fleetbase\FleetOps\Models\Driver;
4
5
  use Fleetbase\FleetOps\Models\GeofenceEventLog;
5
6
  use Illuminate\Http\Request;
6
7
  use Illuminate\Support\Carbon;
@@ -183,6 +184,16 @@ class FleetOpsGeofenceControllerFake extends GeofenceController
183
184
  public ?FleetOpsGeofenceQueryFake $nextEventQuery = null;
184
185
  public array $serializedEvents = [];
185
186
 
187
+ public ?Fleetbase\FleetOps\Models\Driver $historyDriver = null;
188
+ public array $historyDriverLookups = [];
189
+
190
+ protected function findDriverForHistory(string $driverId): ?Fleetbase\FleetOps\Models\Driver
191
+ {
192
+ $this->historyDriverLookups[] = $driverId;
193
+
194
+ return $this->historyDriver;
195
+ }
196
+
186
197
  protected function geofenceEventLogQuery(?string $companyUuid): mixed
187
198
  {
188
199
  $query = $this->nextEventQuery ?? new FleetOpsGeofenceQueryFake($companyUuid);
@@ -351,10 +362,17 @@ test('api geofence driver history applies driver filter and caps pagination', fu
351
362
  'per_page' => 75,
352
363
  ]);
353
364
 
354
- $controller->driverHistory($request, 'driver-9');
365
+ // The endpoint is addressed by public_id and resolves the driver itself; the query
366
+ // still filters on the driver's uuid.
367
+ $historyDriver = new Fleetbase\FleetOps\Models\Driver();
368
+ $historyDriver->setRawAttributes(['uuid' => 'driver-9', 'public_id' => 'driver_public9'], true);
369
+ $controller->historyDriver = $historyDriver;
370
+
371
+ $controller->driverHistory($request, 'driver_public9');
355
372
  $query = $controller->eventQueries[0];
356
373
 
357
- expect($query->companyUuid)->toBe('company-4')
374
+ expect($controller->historyDriverLookups)->toBe(['driver_public9'])
375
+ ->and($query->companyUuid)->toBe('company-4')
358
376
  ->and($query->calls)->toContain(
359
377
  ['where', 'driver_uuid', 'driver-9'],
360
378
  ['with', ['driver.vehicle', 'vehicle', 'order']],
@@ -362,3 +380,82 @@ test('api geofence driver history applies driver filter and caps pagination', fu
362
380
  ['paginate', 75],
363
381
  );
364
382
  });
383
+
384
+ test('driver history answers 404 when the driver does not resolve', function () {
385
+ // The endpoint's own not-found branch: findDriverForHistory returns null and the
386
+ // request must not proceed to a query keyed on nothing.
387
+ $controller = new FleetOpsGeofenceControllerFake();
388
+ $controller->historyDriver = null;
389
+
390
+ $response = $controller->driverHistory(Request::create('/geofences/driver/driver_missing/history', 'GET'), 'driver_missing');
391
+
392
+ expect($response->getStatusCode())->toBe(404)
393
+ ->and($response->getData(true))->toBe(['error' => 'Driver resource not found.'])
394
+ ->and($controller->historyDriverLookups)->toBe(['driver_missing'])
395
+ // and no event query was ever built
396
+ ->and($controller->eventQueries)->toBe([]);
397
+ });
398
+
399
+ test('driver history resolves by public id, and by uuid only for internal requests', function () {
400
+ // The contract tests above stub findDriverForHistory(), so the real lookup needs its
401
+ // own coverage. It is the whole point of the change: the public endpoint used to take
402
+ // a uuid, which the public API never issues.
403
+ $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
404
+ $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
405
+ $resolver->setDefaultConnection('mysql');
406
+ Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
407
+
408
+ $schema = $connection->getSchemaBuilder();
409
+ // Driver carries a global scope requiring a related user, so both tables are needed.
410
+ $schema->create('users', function ($table) {
411
+ $table->increments('id');
412
+ $table->string('uuid')->nullable();
413
+ $table->timestamp('deleted_at')->nullable();
414
+ $table->timestamps();
415
+ });
416
+ $schema->create('drivers', function ($table) {
417
+ $table->increments('id');
418
+ $table->string('uuid')->nullable();
419
+ $table->string('public_id')->nullable();
420
+ $table->string('company_uuid')->nullable();
421
+ $table->string('user_uuid')->nullable();
422
+ $table->timestamp('deleted_at')->nullable();
423
+ $table->timestamps();
424
+ });
425
+ $connection->table('users')->insert(['uuid' => 'user-uuid-1']);
426
+ $connection->table('drivers')->insert([
427
+ 'uuid' => 'driver-uuid-1',
428
+ 'public_id' => 'driver_public1',
429
+ 'user_uuid' => 'user-uuid-1',
430
+ ]);
431
+
432
+ $resolve = new ReflectionMethod(GeofenceController::class, 'findDriverForHistory');
433
+ $resolve->setAccessible(true);
434
+ $controller = new GeofenceController();
435
+
436
+ $byPublicId = $resolve->invoke($controller, 'driver_public1');
437
+ // A uuid is NOT accepted on a public request — that is the contract this restores.
438
+ $byUuidPublic = $resolve->invoke($controller, 'driver-uuid-1');
439
+ $missing = $resolve->invoke($controller, 'driver_nope');
440
+
441
+ expect($byPublicId)->toBeInstanceOf(Driver::class)
442
+ ->and($byPublicId->uuid)->toBe('driver-uuid-1')
443
+ ->and($byUuidPublic)->toBeNull()
444
+ ->and($missing)->toBeNull();
445
+
446
+ // The console reaches the same method through the internal route with a uuid.
447
+ // Http::isInternalRequest() decides from the ROUTE's uri — any route with an "int"
448
+ // segment — so the request has to carry one, not a header.
449
+ $internalRequest = Request::create('/fleet-ops/int/v1/geofences/driver/driver-uuid-1/history', 'GET');
450
+ $internalRequest->setRouteResolver(fn () => new Illuminate\Routing\Route(
451
+ ['GET'],
452
+ 'fleet-ops/int/v1/geofences/driver/{driverUuid}/history',
453
+ ['uses' => fn () => null]
454
+ ));
455
+ app()->instance('request', $internalRequest);
456
+
457
+ $byUuidInternal = $resolve->invoke($controller, 'driver-uuid-1');
458
+
459
+ expect($byUuidInternal)->toBeInstanceOf(Driver::class)
460
+ ->and($byUuidInternal->public_id)->toBe('driver_public1');
461
+ });
@@ -138,8 +138,14 @@ test('tracking number helpers look up owners create records and wrap resources',
138
138
  expect($qrModel)->not->toBeNull()
139
139
  ->and($helper('qrModelResource', $qrModel))->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Entity::class);
140
140
 
141
- // Tables with no eloquent mapping are skipped and fall back to a raw row
142
- // lookup, so the record still resolves but arrives unhydrated
141
+ // A table with no eloquent mapping resolves to null rather than a raw row.
142
+ //
143
+ // The raw-row fallback was removed: it passed the ARRAY of table names to
144
+ // DB::table(), which stringified it to the literal table "Array" and threw
145
+ // SQLSTATE[42S02], so every unresolvable QR code answered 500 instead of the 400 the
146
+ // caller already handles. It could not have helped either way — qrModelResource()
147
+ // serializes through a typed resource keyed on the model class, and a raw stdClass
148
+ // row has none.
143
149
  $connection->table('tracking_statuses')->insert([
144
150
  'uuid' => '44444444-4444-4444-8444-444444444403',
145
151
  'public_id' => 'tracking_status_tnhelper1',
@@ -147,8 +153,8 @@ test('tracking number helpers look up owners create records and wrap resources',
147
153
  'status' => 'Dispatched',
148
154
  ]);
149
155
 
150
- $unmapped = $helper('findQrModel', ['tracking_statuses'], ['public_id' => 'tracking_status_tnhelper1']);
151
- expect($unmapped)->not->toBeNull()
152
- ->and($unmapped)->not->toBeInstanceOf(EloquentModel::class)
153
- ->and($unmapped->uuid)->toBe('44444444-4444-4444-8444-444444444403');
156
+ expect($helper('findQrModel', ['tracking_statuses'], ['public_id' => 'tracking_status_tnhelper1']))->toBeNull()
157
+ // And a mapped table that simply matches nothing is also null, which is the path
158
+ // an unknown or unresolved code takes.
159
+ ->and($helper('findQrModel', ['entities', 'orders'], ['uuid' => '{{qr_code}}']))->toBeNull();
154
160
  });
@@ -307,6 +307,11 @@ class FleetOpsInternalDriverAuthControllerProbe extends DriverController
307
307
 
308
308
  public static function resetProbe(): void
309
309
  {
310
+ // verifyCode() defers to DriverController::verificationBypassMatches(), which
311
+ // asks app()->environment('production'). The bare container this harness binds
312
+ // has no such method, so swap it before any verify-code test runs.
313
+ fleetopsInternalDriverAuthContainer();
314
+
310
315
  static::$loginUser = null;
311
316
  static::$verificationUser = null;
312
317
  static::$loginDriver = null;
@@ -531,6 +536,53 @@ function fleetopsInternalDriverExportSelections(DriverExport $export): array
531
536
  return $property->getValue($export);
532
537
  }
533
538
 
539
+ /**
540
+ * Swap in a container that answers environment().
541
+ *
542
+ * Mirrors fleetopsCustomerHelperContainer() in CustomerControllerHelperSeamsTest and
543
+ * fleetopsDriverAuthContainer() in Api/DriverControllerAuthFlowsTest. Idempotent, so
544
+ * resetProbe() can call it per test.
545
+ */
546
+ function fleetopsInternalDriverAuthContainer(): void
547
+ {
548
+ $current = Illuminate\Container\Container::getInstance();
549
+ if (method_exists($current, 'hasDebugModeEnabled')) {
550
+ return;
551
+ }
552
+
553
+ $replacement = new class extends Illuminate\Container\Container {
554
+ public function environment(...$environments)
555
+ {
556
+ if (empty($environments)) {
557
+ return 'testing';
558
+ }
559
+
560
+ $checks = is_array($environments[0]) ? $environments[0] : $environments;
561
+
562
+ return in_array('testing', $checks, true);
563
+ }
564
+
565
+ public function hasDebugModeEnabled()
566
+ {
567
+ return true;
568
+ }
569
+ };
570
+
571
+ foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'extenders', 'tags', 'contextual', 'scopedInstances', 'reboundCallbacks', 'globalBeforeResolvingCallbacks', 'globalResolvingCallbacks', 'globalAfterResolvingCallbacks', 'beforeResolvingCallbacks', 'resolvingCallbacks', 'afterResolvingCallbacks'] as $property) {
572
+ if (!property_exists(Illuminate\Container\Container::class, $property)) {
573
+ continue;
574
+ }
575
+ $reflection = new ReflectionProperty(Illuminate\Container\Container::class, $property);
576
+ $reflection->setAccessible(true);
577
+ if ($reflection->isInitialized($current)) {
578
+ $reflection->setValue($replacement, $reflection->getValue($current));
579
+ }
580
+ }
581
+
582
+ Illuminate\Container\Container::setInstance($replacement);
583
+ Illuminate\Support\Facades\Facade::setFacadeApplication($replacement);
584
+ }
585
+
534
586
  function fleetopsInternalDriverUseHelperDatabase(): SQLiteConnection
535
587
  {
536
588
  $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
@@ -993,6 +1045,9 @@ test('internal driver controller login with phone normalizes identity and handle
993
1045
 
994
1046
  test('internal driver controller verify code covers missing user invalid code and missing driver branches', function () {
995
1047
  app('config')->set('fleetops.navigator.bypass_verification_code', '000000');
1048
+ // The bypass is only honoured for a listed identity. Both forms because
1049
+ // verifyCode normalises a non-email identity through static::phone().
1050
+ app('config')->set('fleetops.navigator.review_accounts', ['15551234567', '+15551234567', 'driver@example.test']);
996
1051
 
997
1052
  FleetOpsInternalDriverAuthControllerProbe::resetProbe();
998
1053
  $controller = new FleetOpsInternalDriverAuthControllerProbe();
@@ -1035,6 +1090,9 @@ test('internal driver controller verify code covers missing user invalid code an
1035
1090
 
1036
1091
  test('internal driver controller verify code returns driver resource and handles token errors', function () {
1037
1092
  app('config')->set('fleetops.navigator.bypass_verification_code', '000000');
1093
+ // The bypass is only honoured for a listed identity. Both forms because
1094
+ // verifyCode normalises a non-email identity through static::phone().
1095
+ app('config')->set('fleetops.navigator.review_accounts', ['15551234567', '+15551234567', 'driver@example.test']);
1038
1096
 
1039
1097
  FleetOpsInternalDriverAuthControllerProbe::resetProbe();
1040
1098
  FleetOpsInternalDriverAuthControllerProbe::$verificationUser = fleetopsInternalDriverAuthUser();