@fleetbase/fleetops-engine 0.6.59 → 0.6.60

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 (43) hide show
  1. package/RELEASE.md +36 -0
  2. package/addon/components/vehicle/details/schedules.hbs +2 -2
  3. package/addon/components/vehicle/details/schedules.js +7 -1
  4. package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
  5. package/addon/services/vehicle-actions.js +4 -2
  6. package/composer.json +1 -1
  7. package/extension.json +1 -1
  8. package/package.json +1 -1
  9. package/server/config/fleetops.php +44 -0
  10. package/server/config/geocoder.php +1 -1
  11. package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
  12. package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
  13. package/server/src/Http/Controllers/Api/v1/DriverController.php +57 -2
  14. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
  15. package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
  16. package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
  17. package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
  18. package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
  19. package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
  20. package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
  21. package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
  22. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
  23. package/server/src/Http/Requests/CreatePartRequest.php +39 -1
  24. package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
  25. package/server/src/Models/FuelReport.php +21 -0
  26. package/server/src/Models/Sensor.php +24 -0
  27. package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
  28. package/server/src/routes.php +1 -1
  29. package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
  30. package/server/tests/ApiDriverControllerContractsTest.php +32 -0
  31. package/server/tests/CompactResourceSerializationTest.php +93 -0
  32. package/server/tests/CustomerEndpointTest.php +4 -1
  33. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
  34. package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
  35. package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
  36. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
  37. package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
  38. package/server/tests/RequestContractsTest.php +97 -5
  39. package/server/tests/SmallControllerContractsTest.php +28 -4
  40. package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
  41. package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
  42. package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
  43. package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
@@ -960,3 +960,171 @@ test('api customer controller handles verification delivery fallbacks and profil
960
960
  'name' => 'explode',
961
961
  ]))))->toBe(['error' => 'update failed']);
962
962
  });
963
+
964
+ /**
965
+ * Run a callback with an app container that supports environment().
966
+ *
967
+ * The harness binds a bare Illuminate\Container\Container, which has no
968
+ * environment() — so CustomerController::verificationBypassMatches fatals with
969
+ * "Call to undefined method" without this. Mirrors the swap in
970
+ * NotificationAndMailContractsTest, but carries every existing binding across so
971
+ * the controller can still resolve config/request/db.
972
+ */
973
+ function fleetopsApiCustomerWithEnvironment(string $environment, callable $callback): mixed
974
+ {
975
+ $previousApp = Illuminate\Container\Container::getInstance();
976
+ $app = new class extends Illuminate\Container\Container {
977
+ public string $fleetopsEnvironment = 'testing';
978
+
979
+ public function environment(...$environments)
980
+ {
981
+ if (empty($environments)) {
982
+ return $this->fleetopsEnvironment;
983
+ }
984
+
985
+ $environments = is_array($environments[0]) ? $environments[0] : $environments;
986
+
987
+ return in_array($this->fleetopsEnvironment, $environments, true);
988
+ }
989
+
990
+ public function hasDebugModeEnabled()
991
+ {
992
+ return false;
993
+ }
994
+ };
995
+ $app->fleetopsEnvironment = $environment;
996
+
997
+ $reflection = new ReflectionClass(Illuminate\Container\Container::class);
998
+ foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'scopedInstances'] as $property) {
999
+ if (!$reflection->hasProperty($property)) {
1000
+ continue;
1001
+ }
1002
+ $handle = $reflection->getProperty($property);
1003
+ $handle->setAccessible(true);
1004
+ $handle->setValue($app, $handle->getValue($previousApp));
1005
+ }
1006
+
1007
+ Illuminate\Container\Container::setInstance($app);
1008
+
1009
+ try {
1010
+ return $callback();
1011
+ } finally {
1012
+ Illuminate\Container\Container::setInstance($previousApp);
1013
+ }
1014
+ }
1015
+
1016
+ test('api customer controller ignores the verification bypass unless it is configured', function () {
1017
+ // Unset and empty-string are both inert. Without this the bypass would be a
1018
+ // standing hole in every default install, which is the entire risk of shipping one.
1019
+ foreach ([null, ''] as $bypassCode) {
1020
+ config(['fleetops.customers.verification_bypass_code' => $bypassCode]);
1021
+
1022
+ fleetopsApiCustomerWithEnvironment('local', function () {
1023
+ $create = fleetopsApiCustomerController();
1024
+ $create->verificationExists = false;
1025
+ $verify = fleetopsApiCustomerController();
1026
+ $verify->verificationExists = false;
1027
+ $reset = fleetopsApiCustomerController();
1028
+ $reset->verificationCode = null;
1029
+
1030
+ expect(fleetopsApiCustomerJson($create->create(new CreateCustomerRequest([
1031
+ 'code' => '000000',
1032
+ 'identity' => 'jane@example.test',
1033
+ ]))))->toBe(['error' => 'Invalid verification code provided.'])
1034
+ ->and(fleetopsApiCustomerJson($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [
1035
+ 'identity' => 'jane@example.test',
1036
+ 'code' => '000000',
1037
+ ]))))->toBe(['error' => 'Invalid verification code.'])
1038
+ ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [
1039
+ 'identity' => 'jane@example.test',
1040
+ 'code' => '000000',
1041
+ 'password' => 'password-secret',
1042
+ ]))))->toBe(['error' => 'Invalid reset code.']);
1043
+ });
1044
+ }
1045
+
1046
+ config(['fleetops.customers.verification_bypass_code' => null]);
1047
+ });
1048
+
1049
+ test('api customer controller accepts a configured verification bypass for a listed review account', function () {
1050
+ config([
1051
+ 'fleetops.customers.verification_bypass_code' => '000000',
1052
+ 'fleetops.customers.review_accounts' => ['jane@example.test'],
1053
+ ]);
1054
+
1055
+ // Deliberately production. App store reviewers test a release build against
1056
+ // production, so refusing the bypass there made review impossible — that is the
1057
+ // behaviour this replaces. Safety now comes from the allowlist, not the environment.
1058
+ fleetopsApiCustomerWithEnvironment('production', function () {
1059
+ $create = fleetopsApiCustomerController();
1060
+ $create->verificationExists = false;
1061
+ $verify = fleetopsApiCustomerController();
1062
+ $verify->verificationExists = false;
1063
+ // No VerificationCode row on the bypass path — resetPassword must not fatal
1064
+ // calling ->delete() on null, and must still revoke existing sessions.
1065
+ $reset = fleetopsApiCustomerController();
1066
+ $reset->verificationCode = null;
1067
+ // A non-matching code is still rejected while the bypass is live.
1068
+ $wrong = fleetopsApiCustomerController();
1069
+ $wrong->verificationExists = false;
1070
+
1071
+ expect($create->create(new CreateCustomerRequest([
1072
+ 'code' => '000000',
1073
+ 'identity' => 'jane@example.test',
1074
+ 'name' => 'Jane',
1075
+ 'password' => 'password-secret',
1076
+ ])))->toMatchArray(['resource' => 'customer', 'token' => 'plain-token'])
1077
+ ->and($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [
1078
+ 'identity' => 'jane@example.test',
1079
+ 'code' => '000000',
1080
+ ])))->toMatchArray(['resource' => 'customer', 'token' => 'plain-token'])
1081
+ ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [
1082
+ 'identity' => 'jane@example.test',
1083
+ 'code' => '000000',
1084
+ 'password' => 'password-secret',
1085
+ ]))))->toBe(['status' => 'ok'])
1086
+ ->and($reset->genericUser->tokensDeleted)->toBeTrue()
1087
+ ->and(fleetopsApiCustomerJson($wrong->create(new CreateCustomerRequest([
1088
+ 'code' => '999999',
1089
+ 'identity' => 'jane@example.test',
1090
+ ]))))->toBe(['error' => 'Invalid verification code provided.']);
1091
+ });
1092
+
1093
+ config(['fleetops.customers.verification_bypass_code' => null, 'fleetops.customers.review_accounts' => []]);
1094
+ });
1095
+
1096
+ test('api customer controller refuses the verification bypass for an identity that is not listed', function () {
1097
+ // The point of the allowlist: holding the code is not sufficient. Previously
1098
+ // anyone who learned it could authenticate as any customer.
1099
+ foreach ([['someone-else@example.test'], []] as $reviewAccounts) {
1100
+ config([
1101
+ 'fleetops.customers.verification_bypass_code' => '000000',
1102
+ 'fleetops.customers.review_accounts' => $reviewAccounts,
1103
+ ]);
1104
+
1105
+ fleetopsApiCustomerWithEnvironment('local', function () {
1106
+ $create = fleetopsApiCustomerController();
1107
+ $create->verificationExists = false;
1108
+ $verify = fleetopsApiCustomerController();
1109
+ $verify->verificationExists = false;
1110
+ $reset = fleetopsApiCustomerController();
1111
+ $reset->verificationCode = null;
1112
+
1113
+ expect(fleetopsApiCustomerJson($create->create(new CreateCustomerRequest([
1114
+ 'code' => '000000',
1115
+ 'identity' => 'jane@example.test',
1116
+ ]))))->toBe(['error' => 'Invalid verification code provided.'])
1117
+ ->and(fleetopsApiCustomerJson($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [
1118
+ 'identity' => 'jane@example.test',
1119
+ 'code' => '000000',
1120
+ ]))))->toBe(['error' => 'Invalid verification code.'])
1121
+ ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [
1122
+ 'identity' => 'jane@example.test',
1123
+ 'code' => '000000',
1124
+ 'password' => 'password-secret',
1125
+ ]))))->toBe(['error' => 'Invalid reset code.']);
1126
+ });
1127
+ }
1128
+
1129
+ config(['fleetops.customers.verification_bypass_code' => null, 'fleetops.customers.review_accounts' => []]);
1130
+ });
@@ -550,6 +550,38 @@ test('api driver controller registers a device for the authenticated driver with
550
550
  ]);
551
551
  });
552
552
 
553
+ test('api driver controller falls back to the container request when the router injects null', function () {
554
+ // Laravel's ResolvesRouteDependencies::transformDependency() returns null — it does
555
+ // NOT resolve from the container — for any class-typed parameter that declares a
556
+ // default value. registerDevice() has to declare one, because the internal controller
557
+ // delegates with an id only, so every routed call arrived with $request === null.
558
+ // Every other test here passes a request explicitly, which is why they stayed green
559
+ // while POST /v1/drivers/{id}/register-device answered 500.
560
+ $driver = new FleetOpsApiDriverFake();
561
+ $driver->setRawAttributes([
562
+ 'uuid' => 'driver-uuid',
563
+ 'public_id' => 'driver_public',
564
+ 'user_uuid' => 'user-uuid',
565
+ ], true);
566
+
567
+ $controller = new FleetOpsApiDriverControllerProbe();
568
+ $controller->driver = $driver;
569
+
570
+ $bound = new FleetOpsApiDriverRegisterDeviceRequest(['token' => 'push-token', 'platform' => 'android']);
571
+ app()->instance('request', $bound);
572
+
573
+ expect($controller->registerDevice('driver_public', null))->toBe([
574
+ 'json' => ['device' => 'device_public'],
575
+ 'status' => 200,
576
+ ])
577
+ ->and($controller->deviceCreates)->toBe([
578
+ [
579
+ ['token' => 'push-token', 'platform' => 'android'],
580
+ ['user_uuid' => 'user-uuid', 'platform' => 'android', 'token' => 'push-token', 'status' => 'active'],
581
+ ],
582
+ ]);
583
+ });
584
+
553
585
  test('api driver controller reports missing driver branches', function () {
554
586
  $controller = new FleetOpsApiDriverControllerProbe();
555
587
  $controller->driverNotFound = true;
@@ -2697,6 +2697,10 @@ test('tracking number and waypoint webhook resources serialize tracking contract
2697
2697
  'scheduled_at' => '2026-07-30 09:00:00',
2698
2698
  ]);
2699
2699
 
2700
+ // The decoded QR content is a debug-only field and must never ride along on a webhook,
2701
+ // which is a separate literal payload rather than a filtered toArray().
2702
+ expect(array_key_exists('qr_code_content', (new TrackingNumberResource($trackingNumber))->toWebhookPayload()))->toBeFalse();
2703
+
2700
2704
  expect((new TrackingNumberResource($trackingNumber))->toWebhookPayload())->toMatchArray([
2701
2705
  'id' => 'fixture_public',
2702
2706
  'tracking_number' => 'TN-ZERO',
@@ -2743,3 +2747,92 @@ test('tracking number and waypoint webhook resources serialize tracking contract
2743
2747
  'meta' => ['sequence' => 2],
2744
2748
  ]);
2745
2749
  });
2750
+
2751
+ test('tracking number resource publishes the qr code content only in debug mode', function () {
2752
+ // qr_code is a base64 PNG generated from owner_uuid, and the endpoints that consume a
2753
+ // scanned code match on that uuid. Debug builds publish the value beside the image so
2754
+ // an automated client can follow the flow without decoding a PNG; production must not.
2755
+ $trackingNumber = fleetopsCompactResourceFixture([
2756
+ 'tracking_number' => 'TN-DEBUG',
2757
+ 'owner_uuid' => 'owner-uuid-under-the-qr',
2758
+ 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order',
2759
+ 'region' => 'sg',
2760
+ 'qr_code' => 'qr-data',
2761
+ 'barcode' => 'barcode-data',
2762
+ 'last_status' => 'created',
2763
+ 'last_status_code' => 'CREATED',
2764
+ ]);
2765
+ $request = Request::create('/v1/tracking-numbers', 'GET');
2766
+
2767
+ // The full resource builds a console url, which needs app()->environment(). The bare
2768
+ // container this suite runs on has no such method, which is why the tests above only
2769
+ // cover the index resource and the webhook payload.
2770
+ $previousContainer = Illuminate\Container\Container::getInstance();
2771
+ $container = new class extends Illuminate\Container\Container {
2772
+ public bool $debugMode = false;
2773
+
2774
+ public function environment(...$environments)
2775
+ {
2776
+ return in_array('testing', $environments, true) || $environments === [] ? 'testing' : false;
2777
+ }
2778
+
2779
+ // Stands in for the framework's Application::hasDebugModeEnabled().
2780
+ public function hasDebugModeEnabled()
2781
+ {
2782
+ return $this->debugMode;
2783
+ }
2784
+ };
2785
+ $container->instance('config', new Illuminate\Config\Repository([
2786
+ 'fleetbase' => ['console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]],
2787
+ ]));
2788
+ Illuminate\Container\Container::setInstance($container);
2789
+
2790
+ // Drive the REAL accessor through the container rather than overriding it, so the
2791
+ // debug check itself is exercised and not just the resource's use of it.
2792
+ $container->debugMode = true;
2793
+ $withDebug = new TrackingNumberResource($trackingNumber);
2794
+
2795
+ try {
2796
+ $debugPayload = $withDebug->resolve($request);
2797
+
2798
+ $container->debugMode = false;
2799
+ $productionPayload = (new TrackingNumberResource($trackingNumber))->resolve($request);
2800
+ } finally {
2801
+ Illuminate\Container\Container::setInstance($previousContainer);
2802
+ }
2803
+
2804
+ // Present and exactly the value the QR was generated from — not a public id.
2805
+ expect($debugPayload['qr_code_content'])->toBe('owner-uuid-under-the-qr')
2806
+ ->and($debugPayload['qr_code'])->toBe('qr-data')
2807
+ // Absent, not null: `when()` drops the key entirely, so a production response is
2808
+ // byte-identical to one from before the field existed.
2809
+ ->and(array_key_exists('qr_code_content', $productionPayload))->toBeFalse()
2810
+ ->and($productionPayload['qr_code'])->toBe('qr-data');
2811
+ });
2812
+
2813
+ test('tracking number resource fails closed when the debug state cannot be determined', function () {
2814
+ // Any failure to resolve the debug state must answer false rather than defaulting to
2815
+ // exposure. The real accessor is exercised here with no usable application bound.
2816
+ $expose = new ReflectionMethod(TrackingNumberResource::class, 'exposesQrCodeContent');
2817
+ $expose->setAccessible(true);
2818
+
2819
+ $previous = Illuminate\Container\Container::getInstance();
2820
+
2821
+ try {
2822
+ // A container that is not an Application has no hasDebugModeEnabled().
2823
+ Illuminate\Container\Container::setInstance(new Illuminate\Container\Container());
2824
+ expect($expose->invoke(null))->toBeFalse();
2825
+
2826
+ // And an accessor that throws must also answer false rather than propagating —
2827
+ // a resource must not be able to fail serialization over a debug check.
2828
+ Illuminate\Container\Container::setInstance(new class extends Illuminate\Container\Container {
2829
+ public function hasDebugModeEnabled()
2830
+ {
2831
+ throw new RuntimeException('debug state unavailable');
2832
+ }
2833
+ });
2834
+ expect($expose->invoke(null))->toBeFalse();
2835
+ } finally {
2836
+ Illuminate\Container\Container::setInstance($previous);
2837
+ }
2838
+ });
@@ -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);
@@ -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
  });