@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
@@ -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();
@@ -178,4 +178,21 @@ test('fuel provider transaction real lookup scopes identifier and company', func
178
178
 
179
179
  expect($reflection->invoke($controller, 'fpt-real-1')->public_id)->toBe('fuel_provider_transaction_real1')
180
180
  ->and($reflection->invoke($controller, 'fuel_provider_transaction_real1')->uuid)->toBe('fpt-real-1');
181
+
182
+ // resolveProvider() falls back to the stored provider when a PUT omits it, so the
183
+ // uniqueness scope is never null — a null scope would compare the transaction id
184
+ // against the wrong set of rows. Needs a real connection, which is why this lives
185
+ // here rather than in RequestContractsTest.
186
+ $resolveProvider = new ReflectionMethod(Fleetbase\FleetOps\Http\Requests\CreateFuelTransactionRequest::class, 'resolveProvider');
187
+ $resolveProvider->setAccessible(true);
188
+
189
+ $withRouteId = Fleetbase\FleetOps\Http\Requests\CreateFuelTransactionRequest::create('/fleetops-test', 'PUT')
190
+ ->setRouteResolver(fn () => new class {
191
+ public function parameter($key, $default = null)
192
+ {
193
+ return $key === 'id' ? 'fuel_provider_transaction_real1' : $default;
194
+ }
195
+ });
196
+
197
+ expect($resolveProvider->invoke($withRouteId))->toBe('petroapp');
181
198
  });
@@ -76,6 +76,18 @@ namespace Illuminate\Validation {
76
76
  return $this;
77
77
  }
78
78
 
79
+ /**
80
+ * Recorded so tests can assert the ignore-self clause on update
81
+ * requests, including which column it matches on — the v1 routes
82
+ * pass a public_id, not a uuid.
83
+ */
84
+ public function ignore($id, $idColumn = null): self
85
+ {
86
+ $this->constraints[] = ['ignore', $id, $idColumn];
87
+
88
+ return $this;
89
+ }
90
+
79
91
  public function __toString(): string
80
92
  {
81
93
  return $this->rule;
@@ -245,11 +257,16 @@ namespace {
245
257
  });
246
258
 
247
259
  test('fuel transaction request requires provider identifiers on create', function () {
260
+ // The harness `session()` stand-in is a process-wide static, so seed it
261
+ // here rather than relying on an earlier test in the file having done so.
262
+ session(['company' => 'company-uuid']);
263
+ bindFleetOpsRequestSession(['api_credential' => 'credential-uuid', 'company' => 'company-uuid']);
264
+
248
265
  $createRules = requestRules(CreateFuelTransactionRequest::class);
249
266
  $patchRules = requestRules(CreateFuelTransactionRequest::class, 'PATCH');
250
267
 
251
268
  expect(ruleStrings($createRules['provider']))->toContain('required', 'string')
252
- ->and(ruleStrings($createRules['provider_transaction_id']))->toContain('required', 'string')
269
+ ->and(ruleStrings($createRules['provider_transaction_id']))->toContain('required', 'string', 'unique:fuel_provider_transactions,provider_transaction_id')
253
270
  ->and(ruleStrings($patchRules['provider']))->not->toContain('required')
254
271
  ->and(ruleStrings($patchRules['provider_transaction_id']))->not->toContain('required')
255
272
  ->and($createRules['station_latitude'])->toBe(['nullable', 'numeric'])
@@ -257,6 +274,19 @@ namespace {
257
274
  ->and($createRules['normalized_payload'])->toBe(['nullable', 'array'])
258
275
  ->and($createRules['raw_payload'])->toBe(['nullable', 'array'])
259
276
  ->and($createRules['meta'])->toBe(['nullable', 'array']);
277
+
278
+ // Mirrors fuel_provider_txn_company_provider_unique. The provider is part
279
+ // of the scope, and company_uuid is what stops one tenant's provider
280
+ // transaction id from colliding with another's — the old index was global.
281
+ $rulesWithProvider = CreateFuelTransactionRequest::create('/fleetops-test', 'POST', ['provider' => 'petroapp'])->rules();
282
+ $uniqueRule = collect($rulesWithProvider['provider_transaction_id'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule);
283
+
284
+ expect($uniqueRule)->not->toBeNull()
285
+ ->and($uniqueRule->constraints)->toBe([
286
+ ['where', 'company_uuid', 'company-uuid'],
287
+ ['where', 'provider', 'petroapp'],
288
+ ['whereNull', 'deleted_at'],
289
+ ]);
260
290
  });
261
291
 
262
292
  test('vehicle and fuel report requests expose core validation contracts', function () {
@@ -273,7 +303,13 @@ namespace {
273
303
  ->and($fuelReportRules['driver'])->toBe(['required'])
274
304
  ->and($fuelReportRules['odometer'])->toBe(['required'])
275
305
  ->and($fuelReportRules['volume'])->toBe(['required'])
276
- ->and($fuelReportUpdateRules['driver'])->toBe(['required'])
306
+ // The update request inherits these rules, so `required` made every partial
307
+ // update fail — PUT with just {"status": "approved"} answered "The driver
308
+ // field is required.", and the update action's $request->only() list does not
309
+ // even include `driver`. This assertion previously pinned that behaviour.
310
+ ->and($fuelReportUpdateRules['driver'])->toBe(['sometimes'])
311
+ ->and($fuelReportUpdateRules['odometer'])->toBe(['sometimes'])
312
+ ->and($fuelReportUpdateRules['volume'])->toBe(['sometimes'])
277
313
  ->and($internalDriverRules['location'][1])->toBeInstanceOf(ResolvablePoint::class)
278
314
  ->and($internalDriverRules['vehicle'][1])->toBeInstanceOf(ResolvableVehicle::class)
279
315
  ->and($internalDriverRules['latitude'])->toBe(['nullable', 'required_with:longitude', 'numeric'])
@@ -621,7 +657,7 @@ namespace {
621
657
 
622
658
  expect($request->authorize())->toBeTrue()
623
659
  ->and($rules['identity'])->toBe('required|string')
624
- ->and($rules['code'])->toBe('required|exists:verification_codes,code')
660
+ ->and($rules['code'])->toBe('required|string')
625
661
  ->and($rules['name'])->toBe('required|string')
626
662
  ->and($rules['password'])->toBe('required|string|min:8')
627
663
  ->and(ruleStrings($rules['email']))->toContain('email', 'nullable', 'unique:contacts')
@@ -649,13 +685,14 @@ namespace {
649
685
 
650
686
  expect($request->authorize())->toBeFalse();
651
687
 
652
- bindFleetOpsRequestSession(['api_credential' => 'credential-uuid']);
688
+ session(['company' => 'company-uuid']);
689
+ bindFleetOpsRequestSession(['api_credential' => 'credential-uuid', 'company' => 'company-uuid']);
653
690
 
654
691
  $createRules = $request->rules();
655
692
  $patchRules = CreatePartRequest::create('/fleetops-test', 'PATCH')->rules();
656
693
 
657
694
  expect($request->authorize())->toBeTrue()
658
- ->and($createRules['sku'])->toBe(['nullable', 'string'])
695
+ ->and(ruleStrings($createRules['sku']))->toContain('nullable', 'string', 'unique:parts,sku')
659
696
  ->and(ruleStrings($createRules['name']))->toContain('required', 'string')
660
697
  ->and(ruleStrings($patchRules['name']))->not->toContain('required')
661
698
  ->and($createRules['manufacturer'])->toBe(['nullable', 'string'])
@@ -671,11 +708,66 @@ namespace {
671
708
  ->and($createRules['specs'])->toBe(['nullable', 'array'])
672
709
  ->and($createRules['meta'])->toBe(['nullable', 'array']);
673
710
 
711
+ // The SKU uniqueness rule mirrors parts_company_uuid_active_sku_unique:
712
+ // scoped to the session company and ignoring soft-deleted parts, so a SKU
713
+ // freed by a deleted part — or held by another company — stays available.
714
+ // Without it a duplicate reached the driver and returned a 500.
715
+ $skuRule = collect($createRules['sku'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule);
716
+
717
+ expect($skuRule)->not->toBeNull()
718
+ ->and($skuRule->constraints)->toBe([
719
+ ['where', 'company_uuid', 'company-uuid'],
720
+ ['whereNull', 'deleted_at'],
721
+ ]);
722
+
723
+ // On update the record re-sends its own SKU, so it must not collide with
724
+ // itself. The v1 route parameter is a public_id, not a uuid.
725
+ $updateRules = CreatePartRequest::create('/fleetops-test', 'PUT')
726
+ ->setRouteResolver(fn () => new class {
727
+ public function parameter($key, $default = null)
728
+ {
729
+ return $key === 'id' ? 'part_abc123' : $default;
730
+ }
731
+ })
732
+ ->rules();
733
+
734
+ $updateSkuRule = collect($updateRules['sku'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule);
735
+
736
+ expect($updateSkuRule->constraints)->toContain(['ignore', 'part_abc123', 'public_id']);
737
+
674
738
  bindFleetOpsRequestSession(['is_sanctum_token' => true]);
675
739
 
676
740
  expect($request->authorize())->toBeTrue();
677
741
  });
678
742
 
743
+ test('duplicate-key requests name the offending field and ignore themselves on update', function () {
744
+ session(['company' => 'company-uuid']);
745
+ bindFleetOpsRequestSession(['api_credential' => 'credential-uuid', 'company' => 'company-uuid']);
746
+
747
+ // Without these the violation surfaces as the framework's generic "has already
748
+ // been taken", which does not say which record collided or on what.
749
+ expect(CreatePartRequest::create('/fleetops-test', 'POST')->messages())
750
+ ->toBe(['sku.unique' => 'A part with this SKU already exists.'])
751
+ ->and(CreateFuelTransactionRequest::create('/fleetops-test', 'POST')->messages())
752
+ ->toBe(['provider_transaction_id.unique' => 'A fuel transaction with this provider transaction id already exists for this provider.']);
753
+
754
+ // `provider` is supplied here, so resolveProvider() short-circuits on the input
755
+ // and this exercises only the ignore-self clause. The stored-provider fallback
756
+ // needs a database and is covered in FuelProviderTransactionControllerContractsTest.
757
+ $updateRules = CreateFuelTransactionRequest::create('/fleetops-test', 'PUT', ['provider' => 'petroapp'])
758
+ ->setRouteResolver(fn () => new class {
759
+ public function parameter($key, $default = null)
760
+ {
761
+ return $key === 'id' ? 'fuel_provider_transaction_abc123' : $default;
762
+ }
763
+ })
764
+ ->rules();
765
+
766
+ $updateRule = collect($updateRules['provider_transaction_id'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule);
767
+
768
+ expect($updateRule->constraints)->toContain(['ignore', 'fuel_provider_transaction_abc123', 'public_id']);
769
+ });
770
+
679
771
  test('entity equipment payload and simulation requests expose conditional contracts', function () {
680
772
  bindFleetOpsRequestSession();
681
773
 
@@ -227,19 +227,30 @@ class FleetOpsGettingStartedControllerProbe extends GettingStartedController
227
227
 
228
228
  class FleetOpsPublicNavigatorControllerProbe extends PublicNavigatorController
229
229
  {
230
- public mixed $settings = ['require_photo' => true];
231
- public array $lookups = [];
230
+ public mixed $settings = ['require_photo' => true];
231
+ public array $lookups = [];
232
+ public bool $companyMiss = false;
232
233
 
233
234
  protected function findCompanyByPublicId(string $companyId): ?Company
234
235
  {
236
+ $this->lookups[] = $companyId;
237
+
238
+ // Mirrors the real lookup returning null for an unknown public id.
239
+ if ($this->companyMiss) {
240
+ return null;
241
+ }
242
+
235
243
  $company = new Company();
236
244
  $company->setRawAttributes(['uuid' => 'company-uuid', 'public_id' => $companyId], true);
237
245
 
238
- $this->lookups[] = $companyId;
239
-
240
246
  return $company;
241
247
  }
242
248
 
249
+ protected function errorResponse(string $message, int $statusCode): JsonResponse
250
+ {
251
+ return new JsonResponse(['errors' => [$message]], $statusCode);
252
+ }
253
+
243
254
  protected function driverOnboardSetting(string $companyUuid): mixed
244
255
  {
245
256
  $this->lookups[] = $companyUuid;
@@ -412,6 +423,19 @@ test('public navigator controller returns configured or default driver onboardin
412
423
  ]);
413
424
  });
414
425
 
426
+ test('public navigator controller answers 404 for an organization public id that does not resolve', function () {
427
+ $controller = new FleetOpsPublicNavigatorControllerProbe();
428
+ $controller->companyMiss = true;
429
+
430
+ $response = $controller->getDriverOnboardSettings('company_does_not_exist');
431
+
432
+ // Regression: the unresolved company used to be dereferenced anyway, which threw a
433
+ // TypeError out of the controller and rendered an HTML stack trace with a 500.
434
+ expect($response->getStatusCode())->toBe(404)
435
+ ->and($response->getData(true))->toBe(['errors' => ['Organization not found.']])
436
+ ->and($controller->lookups)->toBe(['company_does_not_exist']);
437
+ });
438
+
415
439
  test('public contact controller normalizes create input and preserves update fields', function () {
416
440
  $controller = new FleetOpsContactControllerProbe();
417
441
 
@@ -0,0 +1,69 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Http\Filter\MaintenanceScheduleFilter;
4
+ use Fleetbase\FleetOps\Models\Vehicle;
5
+ use Fleetbase\Http\Filter\Filter;
6
+
7
+ class FleetOpsMaintenanceScheduleFilterQuery
8
+ {
9
+ public array $wheres = [];
10
+
11
+ public function where(...$arguments): self
12
+ {
13
+ $this->wheres[] = $arguments;
14
+
15
+ return $this;
16
+ }
17
+ }
18
+
19
+ function fleetopsMaintenanceScheduleFilter(FleetOpsMaintenanceScheduleFilterQuery $builder): MaintenanceScheduleFilter
20
+ {
21
+ $filter = (new ReflectionClass(MaintenanceScheduleFilter::class))->newInstanceWithoutConstructor();
22
+
23
+ foreach ([
24
+ 'builder' => $builder,
25
+ 'session' => new class {
26
+ public function get(string $key): ?string
27
+ {
28
+ return $key === 'company' ? 'company-uuid' : null;
29
+ }
30
+ },
31
+ ] as $property => $value) {
32
+ $reflection = new ReflectionProperty(Filter::class, $property);
33
+ $reflection->setAccessible(true);
34
+ $reflection->setValue($filter, $value);
35
+ }
36
+
37
+ return $filter;
38
+ }
39
+
40
+ test('maintenance schedule filter scopes companies and resolves subject aliases', function () {
41
+ $builder = new FleetOpsMaintenanceScheduleFilterQuery();
42
+ $filter = fleetopsMaintenanceScheduleFilter($builder);
43
+
44
+ $filter->queryForInternal();
45
+ $filter->queryForPublic();
46
+ $filter->subjectType('vehicle');
47
+ $filter->subjectType('fleet-ops:vehicle');
48
+ $filter->subjectType(Vehicle::class);
49
+ $filter->subjectUuid('vehicle-uuid');
50
+
51
+ expect($builder->wheres)->toBe([
52
+ ['company_uuid', 'company-uuid'],
53
+ ['company_uuid', 'company-uuid'],
54
+ ['subject_type', Vehicle::class],
55
+ ['subject_type', Vehicle::class],
56
+ ['subject_type', Vehicle::class],
57
+ ['subject_uuid', 'vehicle-uuid'],
58
+ ]);
59
+ });
60
+
61
+ test('maintenance schedule filter ignores empty subject filters', function () {
62
+ $builder = new FleetOpsMaintenanceScheduleFilterQuery();
63
+ $filter = fleetopsMaintenanceScheduleFilter($builder);
64
+
65
+ $filter->subjectType(null);
66
+ $filter->subjectUuid(null);
67
+
68
+ expect($builder->wheres)->toBe([]);
69
+ });
@@ -10,6 +10,7 @@ namespace Illuminate\Foundation\Http {
10
10
 
11
11
  namespace {
12
12
  use Fleetbase\FleetOps\Http\Requests\CreateFuelReportRequest;
13
+ use Fleetbase\FleetOps\Http\Requests\UpdateFuelReportRequest;
13
14
 
14
15
  class FleetOpsCreateFuelReportSessionStore
15
16
  {
@@ -39,6 +40,26 @@ namespace {
39
40
  return CreateFuelReportRequest::create('/fleet-ops/fuel-reports', 'POST');
40
41
  }
41
42
 
43
+ test('fuel report create only fields are required on POST and optional on update', function () {
44
+ FleetOpsCreateFuelReportRequestState::$session = new FleetOpsCreateFuelReportSessionStore(['api_credential' => 'api-credential-uuid']);
45
+
46
+ // UpdateFuelReportRequest extends this class, so a flat `required` made every
47
+ // partial update fail — PUT with just {"status": "approved"} answered "The driver
48
+ // field is required." `driver` is the plainest case: the update action's
49
+ // $request->only() list does not include it, so the field was demanded and then
50
+ // discarded.
51
+ $create = CreateFuelReportRequest::create('/fleet-ops/fuel-reports', 'POST')->rules();
52
+ $update = UpdateFuelReportRequest::create('/fleet-ops/fuel-reports/report_1', 'PUT')->rules();
53
+
54
+ foreach (['driver', 'odometer', 'volume'] as $field) {
55
+ expect($create[$field])->toBe(['required'])
56
+ ->and($update[$field])->toBe(['sometimes']);
57
+ }
58
+
59
+ expect($create['metric_unit'])->toBe(['nullable'])
60
+ ->and($create['amount'])->toBe(['nullable']);
61
+ });
62
+
42
63
  test('create fuel report authorization accepts api credentials or sanctum sessions', function () {
43
64
  expect(fleetopsCreateFuelReportRequestWithSession([])->authorize())->toBeFalse()
44
65
  ->and(fleetopsCreateFuelReportRequestWithSession(['api_credential' => 'api-credential-uuid'])->authorize())->toBeTrue()
@@ -180,4 +180,11 @@ test('navigator driver onboard settings endpoint resolves company settings', fun
180
180
  $connection->table('companies')->insert(['uuid' => 'company-2', 'public_id' => 'company_two', 'name' => 'Beta']);
181
181
  $empty = (new NavigatorController())->getDriverOnboardSettings('company_two');
182
182
  expect($empty->getData(true)['driverOnboardSettings'])->toBe([]);
183
+
184
+ // An unresolved public id is a client error. This drives the real
185
+ // errorResponse() seam, which the probe in SmallControllerContractsTest
186
+ // replaces — only the status is asserted, because the harness `response()`
187
+ // shim envelopes errors differently from the core-api macro it stands in for.
188
+ $missing = (new NavigatorController())->getDriverOnboardSettings('company_missing');
189
+ expect($missing->getStatusCode())->toBe(404);
183
190
  });
@@ -0,0 +1,130 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Models\Sensor;
4
+ use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint;
5
+ use Illuminate\Database\ConnectionResolver;
6
+ use Illuminate\Database\Eloquent\Model as EloquentModel;
7
+ use Illuminate\Database\SQLiteConnection;
8
+ use Illuminate\Http\Request;
9
+ use Illuminate\Support\Str;
10
+
11
+ /**
12
+ * Covers Sensor::boot()'s creating hook, which defaults `last_position` to
13
+ * POINT(0,0).
14
+ *
15
+ * The same migration that made devices.last_position NOT NULL did it to sensors
16
+ * too, but only Device had a default. Creating a sensor through the API failed
17
+ * with "Field 'last_position' doesn't have a default value" — the Postman
18
+ * contract run caught it, having never been able to create a sensor at all.
19
+ *
20
+ * As with the device fixtures, the dispatcher is set up front so the model event
21
+ * actually fires; without it the hook is never registered and its body never runs.
22
+ */
23
+ if (!function_exists('Fleetbase\Observers\event')) {
24
+ eval('namespace Fleetbase\Observers; function event($event = null, $payload = []) { return []; }');
25
+ }
26
+
27
+ if (!function_exists('Fleetbase\FleetOps\Observers\event')) {
28
+ eval('namespace Fleetbase\FleetOps\Observers; function event($event = null, $payload = []) { return []; }');
29
+ }
30
+
31
+ if (!function_exists('Fleetbase\Support\session')) {
32
+ eval('namespace Fleetbase\Support; function session($key = null, $default = null) { if ($key === null) { return new class { public function has($k) { return \session($k) !== null; } public function get($k, $d = null) { return \session($k, $d); } }; } return \session($key, $default); }');
33
+ }
34
+
35
+ if (!function_exists('Fleetbase\Models\session')) {
36
+ eval('namespace Fleetbase\Models; function session($key = null, $default = null) { if ($key === null) { return new class { public function has($k) { return \session($k) !== null; } public function get($k, $d = null) { return \session($k, $d); } public function missing($k) { return \session($k) === null; } }; } return \session($key, $default); }');
37
+ }
38
+
39
+ if (!Str::hasMacro('humanize')) {
40
+ Str::macro('humanize', fn ($value) => ucfirst(str_replace(['_', '-'], ' ', Str::snake((string) $value))));
41
+ }
42
+
43
+ function fleetopsSensorDefaultBoot(): SQLiteConnection
44
+ {
45
+ $pdo = new PDO('sqlite::memory:');
46
+ $pdo->sqliteCreateFunction('ST_PointFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt);
47
+ $pdo->sqliteCreateFunction('ST_GeomFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt);
48
+ $connection = new SQLiteConnection($pdo);
49
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
50
+ $resolver->setDefaultConnection('mysql');
51
+ EloquentModel::setConnectionResolver($resolver);
52
+
53
+ // The creating hook is registered against whichever dispatcher is present
54
+ // when the model boots, so it has to exist before Device is ever touched.
55
+ if (!EloquentModel::getEventDispatcher()) {
56
+ EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher());
57
+ }
58
+
59
+ app()->instance('db', new class($connection) {
60
+ public function __construct(public SQLiteConnection $c)
61
+ {
62
+ }
63
+
64
+ public function connection($name = null): SQLiteConnection
65
+ {
66
+ return $this->c;
67
+ }
68
+
69
+ public function __call($method, $arguments)
70
+ {
71
+ return $this->c->{$method}(...$arguments);
72
+ }
73
+ });
74
+ app()->instance('db.schema', $connection->getSchemaBuilder());
75
+ app()->instance('responsecache', new class {
76
+ public function __call($method, $arguments)
77
+ {
78
+ return null;
79
+ }
80
+ });
81
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
82
+ app()->instance('request', Request::create('/v1/sensors'));
83
+
84
+ config()->set('activitylog.enabled', false);
85
+ config()->set('activitylog.default_auth_driver', 'web');
86
+ app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config());
87
+
88
+ $schema = $connection->getSchemaBuilder();
89
+ $schema->create('sensors', function ($blueprint) {
90
+ $blueprint->increments('id');
91
+ foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'telematic_uuid', 'device_uuid', 'name', 'slug', 'type', 'status', 'online', 'unit', 'last_value', 'last_reading_at', 'min_threshold', 'max_threshold', 'location', 'last_position', 'meta', 'data', 'options', 'notes', '_key'] as $column) {
92
+ $blueprint->string($column)->nullable();
93
+ }
94
+ $blueprint->timestamps();
95
+ $blueprint->timestamp('deleted_at')->nullable();
96
+ });
97
+
98
+ session(['company' => 'company-1']);
99
+
100
+ return $connection;
101
+ }
102
+
103
+ test('creating a sensor without a position defaults it to the null island', function () {
104
+ fleetopsSensorDefaultBoot();
105
+
106
+ $sensor = Sensor::create([
107
+ 'company_uuid' => 'company-1',
108
+ 'name' => 'Defaulted Sensor',
109
+ 'type' => 'temperature',
110
+ ]);
111
+
112
+ expect($sensor->last_position)->toBeInstanceOf(SpatialPoint::class)
113
+ ->and($sensor->last_position->getLat())->toBe(0.0)
114
+ ->and($sensor->last_position->getLng())->toBe(0.0);
115
+ });
116
+
117
+ test('creating a sensor with a position leaves it untouched', function () {
118
+ fleetopsSensorDefaultBoot();
119
+
120
+ $sensor = Sensor::create([
121
+ 'company_uuid' => 'company-1',
122
+ 'name' => 'Positioned Sensor',
123
+ 'type' => 'temperature',
124
+ 'last_position' => new SpatialPoint(1.3, 103.8),
125
+ ]);
126
+
127
+ expect($sensor->last_position)->toBeInstanceOf(SpatialPoint::class)
128
+ ->and($sensor->last_position->getLat())->toBe(1.3)
129
+ ->and($sensor->last_position->getLng())->toBe(103.8);
130
+ });