@fleetbase/fleetops-engine 0.6.66 → 0.6.67

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 (47) hide show
  1. package/RELEASE.md +15 -8
  2. package/addon/components/telematic/details.hbs +1 -1
  3. package/addon/components/telematic/telemetry-status.hbs +93 -34
  4. package/addon/components/widget/radar.hbs +1 -1
  5. package/addon/components/widget/radar.js +7 -0
  6. package/composer.json +2 -1
  7. package/docs/AFAQY.md +71 -17
  8. package/docs/SAFEE.md +125 -0
  9. package/docs/TELEMATICS_QUEUES.md +179 -0
  10. package/extension.json +1 -1
  11. package/package.json +1 -1
  12. package/server/config/safee.php +10 -0
  13. package/server/config/telematics.php +8 -1
  14. package/server/config/telemetry.php +4 -1
  15. package/server/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php +90 -0
  16. package/server/src/Console/Commands/SyncTelematics.php +11 -5
  17. package/server/src/Events/DeviceTelemetryUpdated.php +2 -1
  18. package/server/src/Events/TrailerLocationChanged.php +2 -0
  19. package/server/src/Events/VehicleLocationChanged.php +5 -0
  20. package/server/src/Jobs/PollTelematicTelemetry.php +127 -12
  21. package/server/src/Jobs/ProcessTelematicDelivery.php +64 -10
  22. package/server/src/Jobs/SyncTelematicDevicesJob.php +77 -1
  23. package/server/src/Jobs/TestTelematicConnectionJob.php +1 -0
  24. package/server/src/Providers/FleetOpsServiceProvider.php +5 -2
  25. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +65 -10
  26. package/server/src/Support/Telematics/Providers/SafeeProvider.php +213 -139
  27. package/server/src/Support/Telematics/Safee/Transport.php +210 -0
  28. package/server/src/Support/Telematics/TelematicService.php +43 -1
  29. package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
  30. package/server/src/Support/Telematics/Telemetry/Inbox.php +31 -3
  31. package/server/src/Support/Telematics/Telemetry/Ingestor.php +1 -1
  32. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +147 -58
  33. package/server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php +106 -0
  34. package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +461 -0
  35. package/server/tests/Feature/Http/SafeeRealtimeIngestionTest.php +208 -0
  36. package/server/tests/Feature/Http/TelematicDeliveryCheckpointTest.php +198 -0
  37. package/server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php +166 -0
  38. package/server/tests/ProviderContractsTest.php +50 -1
  39. package/server/tests/Support/AfaqyTestCrypto.php +6 -23
  40. package/server/tests/SupportJobAndAiCoverageTest.php +102 -5
  41. package/server/tests/TelematicsHardeningTest.php +18 -13
  42. package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +140 -0
  43. package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +132 -0
  44. package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +1 -1
  45. package/server/tests/Unit/Support/Telematics/Providers/SafeeLiveContractTest.php +335 -0
  46. package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +17 -27
  47. package/server/tests/Unit/Support/Telematics/TelematicsQueueRoutingTest.php +36 -0
@@ -95,6 +95,7 @@ class FleetOpsProviderContractsAppFake
95
95
  class FleetOpsProviderContractsScheduledEventFake
96
96
  {
97
97
  public array $methods = [];
98
+ public int $expiresAt = 1440;
98
99
 
99
100
  public function __construct(public string $command)
100
101
  {
@@ -121,8 +122,9 @@ class FleetOpsProviderContractsScheduledEventFake
121
122
  return $this;
122
123
  }
123
124
 
124
- public function withoutOverlapping(): self
125
+ public function withoutOverlapping($expiresAt = 1440): self
125
126
  {
127
+ $this->expiresAt = $expiresAt;
126
128
  $this->methods[] = ['withoutOverlapping'];
127
129
 
128
130
  return $this;
@@ -274,9 +276,12 @@ test('fleetops service provider executes package registration and boot wiring',
274
276
  'fleetops:send-maintenance-reminders',
275
277
  'fleetops:process-operational-alerts',
276
278
  'fleetops:sync-telematics',
279
+ 'fleetops:drain-telematic-inbox',
277
280
  ])
278
281
  ->and($provider->schedule?->commands['fleetops:dispatch-orders']->methods)->toContain(['everyMinute'], ['withoutOverlapping'], ['storeOutputInDb'])
279
282
  ->and($provider->schedule?->commands['fleetops:update-estimations']->methods)->toContain(['everyTenMinutes'], ['withoutOverlapping'])
283
+ ->and($provider->schedule?->commands['fleetops:sync-telematics']->expiresAt)->toBe(2)
284
+ ->and($provider->schedule?->commands['fleetops:drain-telematic-inbox']->expiresAt)->toBe(2)
280
285
  ->and($orchestrationRegistry->has('vroom'))->toBeTrue()
281
286
  ->and($orchestrationRegistry->has('greedy'))->toBeTrue()
282
287
  ->and($orchestrationRegistry->has('capacity'))->toBeTrue()
@@ -407,3 +412,47 @@ test('event service provider maps order geofence and schedule listeners', functi
407
412
  NotifyOrderEvent::class
408
413
  );
409
414
  });
415
+
416
+ test('telemetry scheduler overlap leases expire after interruption without clearing unrelated locks', function () {
417
+ $originalNotifications = NotificationRegistry::$notifications;
418
+ $originalNotifiables = NotificationRegistry::$notifiables;
419
+ Illuminate\Support\Carbon::setTestNow('2026-09-17 12:00:00 UTC');
420
+
421
+ try {
422
+ $provider = new FleetOpsProviderContractsProviderProbe(new FleetOpsProviderContractsAppFake());
423
+ $provider->boot();
424
+ $cache = new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore());
425
+ $factory = new class($cache) implements Illuminate\Contracts\Cache\Factory {
426
+ public function __construct(private $cache)
427
+ {
428
+ }
429
+
430
+ public function store($name = null)
431
+ {
432
+ return $this->cache;
433
+ }
434
+ };
435
+ $mutex = new Illuminate\Console\Scheduling\CacheEventMutex($factory);
436
+ $events = [];
437
+ foreach (['fleetops:sync-telematics', 'fleetops:drain-telematic-inbox', 'fleetops:dispatch-orders'] as $command) {
438
+ $registered = $provider->schedule->commands[$command];
439
+ $event = new Illuminate\Console\Scheduling\Event($mutex, $command);
440
+ $event->withoutOverlapping($registered->expiresAt);
441
+ expect($mutex->create($event))->toBeTrue();
442
+ $events[$command] = $event;
443
+ }
444
+
445
+ // Simulate process loss: do not invoke the normal scheduler finish/cleanup.
446
+ Illuminate\Support\Carbon::setTestNow('2026-09-17 12:01:59 UTC');
447
+ expect($mutex->exists($events['fleetops:sync-telematics']))->toBeTrue()
448
+ ->and($mutex->exists($events['fleetops:drain-telematic-inbox']))->toBeTrue();
449
+ Illuminate\Support\Carbon::setTestNow('2026-09-17 12:02:01 UTC');
450
+ expect($mutex->create($events['fleetops:sync-telematics']))->toBeTrue()
451
+ ->and($mutex->create($events['fleetops:drain-telematic-inbox']))->toBeTrue()
452
+ ->and($mutex->exists($events['fleetops:dispatch-orders']))->toBeTrue();
453
+ } finally {
454
+ NotificationRegistry::$notifications = $originalNotifications;
455
+ NotificationRegistry::$notifiables = $originalNotifiables;
456
+ Illuminate\Support\Carbon::setTestNow();
457
+ }
458
+ });
@@ -1,27 +1,10 @@
1
1
  <?php
2
2
 
3
- // Package fixtures use authenticated OpenSSL encryption at the Crypt boundary.
4
- // Production resolves Laravel's encrypter from the host application.
5
- function afaqyTestCrypto(): object
6
- {
7
- return new class {
8
- public function encryptString(string $value): string
9
- {
10
- $iv = random_bytes(12);
11
- $cipher = openssl_encrypt($value, 'aes-256-gcm', str_repeat('t', 32), OPENSSL_RAW_DATA, $iv, $tag);
12
-
13
- return base64_encode($iv . $tag . $cipher);
14
- }
3
+ use Illuminate\Encryption\Encrypter;
15
4
 
16
- public function decryptString(string $value): string
17
- {
18
- $bytes = base64_decode($value, true);
19
- $plain = openssl_decrypt(substr($bytes, 28), 'aes-256-gcm', str_repeat('t', 32), OPENSSL_RAW_DATA, substr($bytes, 0, 12), substr($bytes, 12, 16));
20
- if ($plain === false) {
21
- throw new RuntimeException('Invalid encrypted fixture.');
22
- }
23
-
24
- return $plain;
25
- }
26
- };
5
+ // Exercise the same authenticated payload format and exceptions as Laravel.
6
+ // This key is synthetic and used only by isolated test fixtures.
7
+ function afaqyTestCrypto(): Encrypter
8
+ {
9
+ return new Encrypter(str_repeat('t', 32), 'aes-256-cbc');
27
10
  }
@@ -22,9 +22,10 @@ use Illuminate\Validation\ValidationException;
22
22
 
23
23
  class FleetOpsSyncTelematicJobFake extends Telematic
24
24
  {
25
- public bool $refreshed = false;
26
- public bool $saved = false;
27
- public int $saveCount = 0;
25
+ public bool $refreshed = false;
26
+ public bool $saved = false;
27
+ public int $saveCount = 0;
28
+ public array $savedStates = [];
28
29
 
29
30
  public function refresh()
30
31
  {
@@ -37,6 +38,7 @@ class FleetOpsSyncTelematicJobFake extends Telematic
37
38
  {
38
39
  $this->saved = true;
39
40
  $this->saveCount++;
41
+ $this->savedStates[] = ['status' => $this->status, 'meta' => $this->meta];
40
42
 
41
43
  return true;
42
44
  }
@@ -191,7 +193,8 @@ class FleetOpsTelematicSyncRegistryFake extends TelematicProviderRegistry
191
193
 
192
194
  class FleetOpsTelematicSyncServiceFake extends TelematicService
193
195
  {
194
- public array $ingested = [];
196
+ public array $ingested = [];
197
+ public ?Closure $beforeIngest = null;
195
198
 
196
199
  public function __construct(public array $results = [], public array $failFor = [])
197
200
  {
@@ -200,6 +203,9 @@ class FleetOpsTelematicSyncServiceFake extends TelematicService
200
203
  public function ingestDeviceSnapshot(Telematic $telematic, Fleetbase\FleetOps\Contracts\TelematicProviderInterface $provider, array $payload): array
201
204
  {
202
205
  $this->ingested[] = $payload;
206
+ if ($this->beforeIngest) {
207
+ ($this->beforeIngest)($telematic, $payload, count($this->ingested));
208
+ }
203
209
 
204
210
  if (in_array($payload['_id'] ?? $payload['id'] ?? null, $this->failFor, true)) {
205
211
  throw ValidationException::withMessages(['device_id' => ['missing provider identity']]);
@@ -610,7 +616,6 @@ test('sync telematic devices job handles paginated inventory enrichment skips an
610
616
  ->and($service->ingested)->toHaveCount(6)
611
617
  ->and($lock->releaseCount)->toBe(1)
612
618
  ->and($telematic->saved)->toBeTrue()
613
- ->and($telematic->saveCount)->toBe(2)
614
619
  ->and($telematic->status)->toBe('active')
615
620
  ->and($telematic->meta)->toMatchArray([
616
621
  'existing' => 'kept',
@@ -677,6 +682,98 @@ test('sync telematic devices job handles paginated inventory enrichment skips an
677
682
  }
678
683
  });
679
684
 
685
+ test('manual sync persists fetched pages before ingestion and bounded applied checkpoints', function () {
686
+ Carbon::setTestNow('2026-07-25 12:00:00 UTC');
687
+ $originalCache = Cache::getFacadeRoot();
688
+
689
+ try {
690
+ $telematic = new FleetOpsSyncTelematicJobFake();
691
+ $telematic->setRawAttributes(['uuid' => 'progress-connection', 'provider' => 'safee', 'status' => 'synchronizing', 'meta' => ['last_sync_result' => 'queued', 'unrelated' => 'preserved']], true);
692
+ $devices = array_map(fn ($number) => ['_id' => 'unit-' . $number, 'device_id' => 'unit-' . $number], range(1, 52));
693
+ $provider = new FleetOpsTelematicSyncProviderFake([
694
+ ['devices' => $devices, 'pagination' => ['allCount' => 52, 'filtersCount' => 52], 'has_more' => false],
695
+ ]);
696
+ $service = new FleetOpsTelematicSyncServiceFake();
697
+ $observed = [];
698
+ $service->beforeIngest = function ($connection, $payload, $number) use (&$observed) {
699
+ if (in_array($number, [1, 26, 51], true)) {
700
+ $observed[$number] = end($connection->savedStates);
701
+ }
702
+ };
703
+ $lock = new FleetOpsTelematicSyncLockFake(true);
704
+ Cache::swap(new FleetOpsTelematicSyncCacheFake($lock));
705
+
706
+ (new SyncTelematicDevicesJob($telematic, [], 'progress-job'))->handle(new FleetOpsTelematicSyncRegistryFake($provider), $service);
707
+
708
+ expect($observed[1]['status'])->toBe('synchronizing');
709
+ expect($observed[1]['meta'])->toMatchArray([
710
+ 'last_sync_job_id' => 'progress-job', 'last_sync_result' => 'running', 'last_sync_phase' => 'ingesting_inventory',
711
+ 'last_sync_fetched_total' => 52, 'last_sync_inventory_total' => 52, 'last_sync_provider_total' => 52,
712
+ 'last_sync_page_count' => 1, 'last_sync_linked_total' => 0, 'last_sync_progress_at' => '2026-07-25 12:00:00',
713
+ ]);
714
+ expect($observed[26]['meta']['last_sync_linked_total'])->toBe(25)
715
+ ->and($observed[51]['meta']['last_sync_linked_total'])->toBe(50);
716
+ expect($telematic->meta)->toMatchArray(['last_sync_result' => 'success', 'last_sync_phase' => 'completed', 'last_sync_linked_total' => 52, 'unrelated' => 'preserved']);
717
+ expect($telematic->saveCount)->toBeLessThan(20);
718
+ expect($lock->releaseCount)->toBe(1);
719
+ } finally {
720
+ Cache::swap($originalCache);
721
+ Carbon::setTestNow();
722
+ }
723
+ });
724
+
725
+ test('manual sync checkpoints elapsed progress and retains partial counters when an item throws a PHP error', function () {
726
+ Carbon::setTestNow('2026-07-25 12:00:00 UTC');
727
+ $originalCache = Cache::getFacadeRoot();
728
+
729
+ try {
730
+ $telematic = new FleetOpsSyncTelematicJobFake();
731
+ $telematic->setRawAttributes(['uuid' => 'partial-connection', 'provider' => 'safee', 'status' => 'synchronizing', 'meta' => ['last_sync_result' => 'queued']], true);
732
+ $devices = array_map(fn ($number) => ['_id' => 'unit-' . $number, 'device_id' => 'unit-' . $number], range(1, 3));
733
+ $provider = new FleetOpsTelematicSyncProviderFake([
734
+ ['devices' => $devices, 'pagination' => ['allCount' => 3], 'has_more' => false],
735
+ ]);
736
+ $service = new FleetOpsTelematicSyncServiceFake();
737
+ $checkpointBeforeFailure = null;
738
+ $service->beforeIngest = function ($connection, $payload, $number) use (&$checkpointBeforeFailure) {
739
+ if ($number === 1) {
740
+ Carbon::setTestNow('2026-07-25 12:00:06 UTC');
741
+
742
+ return;
743
+ }
744
+ $checkpointBeforeFailure = end($connection->savedStates)['meta'];
745
+ throw new TypeError('Invalid normalized unit field');
746
+ };
747
+ $lock = new FleetOpsTelematicSyncLockFake(true);
748
+ Cache::swap(new FleetOpsTelematicSyncCacheFake($lock));
749
+ $job = new SyncTelematicDevicesJob($telematic, [], 'partial-job');
750
+
751
+ expect(fn () => $job->handle(new FleetOpsTelematicSyncRegistryFake($provider), $service))->toThrow(TypeError::class, 'Invalid normalized unit field');
752
+ expect($checkpointBeforeFailure)->toMatchArray([
753
+ 'last_sync_result' => 'running', 'last_sync_phase' => 'ingesting_inventory',
754
+ 'last_sync_fetched_total' => 3, 'last_sync_linked_total' => 1, 'last_sync_inventory_linked_total' => 1,
755
+ 'last_sync_page_count' => 1, 'last_sync_progress_at' => '2026-07-25 12:00:06',
756
+ ]);
757
+ expect(end($telematic->savedStates)['meta'])->toMatchArray([
758
+ 'last_sync_result' => 'failed', 'last_sync_error_type' => 'TypeError',
759
+ 'last_sync_fetched_total' => 3, 'last_sync_inventory_total' => 3,
760
+ 'last_sync_linked_total' => 1, 'last_sync_inventory_linked_total' => 1,
761
+ 'last_sync_page_count' => 1, 'last_sync_error' => 'Invalid normalized unit field',
762
+ ]);
763
+ expect($telematic->status)->toBe('error')->and($lock->releaseCount)->toBe(1);
764
+
765
+ // Queue failure callbacks receive persisted checkpoints, not the handler's local counters.
766
+ $job->failed(new TimeoutExceededException('Queue timeout'));
767
+ expect($telematic->meta)->toMatchArray([
768
+ 'last_sync_result' => 'failed', 'last_sync_failed_reason' => 'job_timeout',
769
+ 'last_sync_fetched_total' => 3, 'last_sync_linked_total' => 1, 'last_sync_page_count' => 1,
770
+ ]);
771
+ } finally {
772
+ Cache::swap($originalCache);
773
+ Carbon::setTestNow();
774
+ }
775
+ });
776
+
680
777
  test('sync telematics command filters pollable providers and exits cleanly without work', function () {
681
778
  $registry = new TelematicProviderRegistry();
682
779
  $registry->register(new TelematicProviderDescriptor([
@@ -1065,9 +1065,9 @@ test('safee endpoint helpers account for identities enrichment and failures', fu
1065
1065
  ]);
1066
1066
 
1067
1067
  $provider->queuePostResponse('/api/v2/vehicle/last-state', [
1068
+ 'code' => 0,
1068
1069
  'result' => [
1069
1070
  ['vehicle' => ['id' => 105], 'status' => 'online'],
1070
- 'ignored',
1071
1071
  ['id' => 106, 'status' => 'offline'],
1072
1072
  ],
1073
1073
  ]);
@@ -1078,15 +1078,13 @@ test('safee endpoint helpers account for identities enrichment and failures', fu
1078
1078
  expect($lastStates['105']['status'])->toBe('online')
1079
1079
  ->and($lastStates['106']['status'])->toBe('offline');
1080
1080
 
1081
+ // Live polling must not report malformed, unrequested or failed batches as an empty fleet.
1082
+ $provider->queuePostResponse('/api/v2/vehicle/last-state', ['code' => 0, 'result' => [['id' => 105], 'ignored']]);
1083
+ expect(fn () => $provider->fetchLastStatesByVehicleForTest([105], $stats))->toThrow(Fleetbase\FleetOps\Exceptions\TelematicProviderException::class, 'invalid list record');
1084
+ $provider->queuePostResponse('/api/v2/vehicle/last-state', ['code' => 0, 'result' => [['id' => 999]]]);
1085
+ expect(fn () => $provider->fetchLastStatesByVehicleForTest([105], $stats))->toThrow(Fleetbase\FleetOps\Exceptions\TelematicProviderException::class, 'unrequested');
1081
1086
  $provider->queuePostException('/api/v2/vehicle/last-state', new RuntimeException('token=abc123 password=secret failed'));
1082
- $failedStats = ['failures' => []];
1083
-
1084
- expect($provider->fetchLastStatesByVehicleForTest([107], $failedStats))->toBe([])
1085
- ->and($failedStats['failures'][0])->toMatchArray([
1086
- 'endpoint' => '/api/v2/vehicle/last-state',
1087
- 'vehicle_id' => null,
1088
- 'message' => 'token=[redacted] password=[redacted] failed',
1089
- ]);
1087
+ expect(fn () => $provider->fetchLastStatesByVehicleForTest([107], $stats))->toThrow(RuntimeException::class);
1090
1088
 
1091
1089
  $provider->queuePostResponse('/api/v2/vehicle/positions', [
1092
1090
  'result' => [
@@ -1152,16 +1150,23 @@ test('safee endpoint helpers account for identities enrichment and failures', fu
1152
1150
  test('safee normalization helpers cover status position sensor and timestamp variants', function () {
1153
1151
  $provider = new FleetOpsSafeeProviderProbe();
1154
1152
 
1153
+ // Enrichment only fills gaps unless its source timestamp is strictly newer than the live state.
1155
1154
  expect($provider->currentTelemetryPayloadForTest([
1156
1155
  '_safee' => [
1157
1156
  'current_state' => ['status' => 'offline', 'speed' => 10],
1158
1157
  'current_info' => ['speed' => 12, 'odometer' => 500],
1159
1158
  ],
1160
1159
  ]))->toBe([
1161
- 'status' => 'offline',
1162
- 'speed' => 12,
1160
+ 'speed' => 10,
1163
1161
  'odometer' => 500,
1162
+ 'status' => 'offline',
1164
1163
  ])
1164
+ ->and($provider->currentTelemetryPayloadForTest([
1165
+ '_safee' => [
1166
+ 'current_state' => ['date' => 1782206100, 'speed' => 10],
1167
+ 'current_info' => ['date' => 1782206140, 'speed' => 12],
1168
+ ],
1169
+ ]))->toBe(['date' => 1782206140, 'speed' => 12])
1165
1170
  ->and($provider->currentTelemetryPayloadForTest(['_safee' => ['current_info' => ['speed' => 7]]]))->toBe(['speed' => 7])
1166
1171
  ->and($provider->currentTelemetryPayloadForTest(['_safee' => ['current_state' => ['speed' => 6]]]))->toBe(['speed' => 6])
1167
1172
  ->and($provider->currentTelemetryPayloadForTest(['_safee' => []]))->toBeNull()
@@ -1172,7 +1177,7 @@ test('safee normalization helpers cover status position sensor and timestamp var
1172
1177
  ->and($provider->resolveVehicleNameForTest([], [], 108))->toBe('Safee Vehicle 108')
1173
1178
  ->and($provider->resolveVehicleNameForTest([], [], null))->toBe('Unknown Safee Vehicle')
1174
1179
  ->and($provider->sanitizeProviderMessageForTest('access_token=abc&password=secret client_secret=top'))->toBe('access_token=[redacted]&password=[redacted] client_secret=[redacted]')
1175
- ->and($provider->extractPositionForTest(['loc' => ['coordinates' => [55.2, 25.2]]]))->toBe(['lat' => 25.2, 'lng' => 55.2])
1180
+ ->and($provider->extractPositionForTest(['loc' => ['coordinates' => [55.2, 25.2]]]))->toBe(['lat' => 25.2, 'lng' => 55.2, 'alt' => null])
1176
1181
  ->and($provider->parseTimestampForTest(null))->toBeNull()
1177
1182
  ->and($provider->parseTimestampForTest(1782206140500))->toBe(Carbon::createFromTimestamp(1782206140.5)->toDateTimeString())
1178
1183
  ->and($provider->parseTimestampForTest('2026-06-23T09:15:40Z'))->toBe('2026-06-23 09:15:40')
@@ -1396,7 +1401,7 @@ test('afaqy sync stores compact device diagnostics and paginates complete units
1396
1401
  ->toContain('= 15;')
1397
1402
  ->toContain('protected int $connectionTestTimeout')
1398
1403
  ->toContain('= 30;')
1399
- ->toContain('protected int $connectionTestConnectTimeout = 10')
1404
+ ->toContain('protected int $connectionTestConnectTimeout = 10')
1400
1405
  ->toContain('->timeout($timeout)')
1401
1406
  ->toContain('->connectTimeout($connectTimeout)')
1402
1407
  ->toContain('ConnectionException')
@@ -1,5 +1,7 @@
1
1
  <?php
2
2
 
3
+ require_once __DIR__ . '/../../Support/ExampleTelemetryProvider.php';
4
+
3
5
  use Fleetbase\FleetOps\Console\Commands\SyncTelematics;
4
6
  use Fleetbase\FleetOps\Contracts\TelematicProviderDescriptor;
5
7
  use Fleetbase\FleetOps\Support\Telematics\TelematicProviderRegistry;
@@ -134,3 +136,141 @@ test('sync telematics filters providers by request and webhook flags', function
134
136
  expect($command->handle($registry))->toBe(0)
135
137
  ->and($command->messages)->toContain(['info', 'No pollable telematics providers found.']);
136
138
  });
139
+
140
+ test('sync telematics command releases its bounded lock and recovers an interrupted lease', function () {
141
+ fleetopsSyncTelematicsBoot();
142
+ $originalCache = Illuminate\Support\Facades\Cache::getFacadeRoot();
143
+ Illuminate\Support\Carbon::setTestNow('2026-09-17 12:00:00 UTC');
144
+
145
+ try {
146
+ $store = new class extends Illuminate\Cache\ArrayStore {
147
+ public array $requestedLocks = [];
148
+
149
+ public function lock($name, $seconds = 0, $owner = null)
150
+ {
151
+ $this->requestedLocks[] = [$name, $seconds];
152
+
153
+ return parent::lock($name, $seconds, $owner);
154
+ }
155
+ };
156
+ Illuminate\Support\Facades\Cache::swap(new Illuminate\Cache\Repository($store));
157
+ $command = new FleetOpsSyncTelematicsProbe();
158
+ $command->options['no-lock'] = false;
159
+ $registry = fleetopsSyncTelematicsRegistry([]);
160
+ expect($command->handle($registry))->toBe(0);
161
+ expect($store->requestedLocks[0])->toBe(['fleetops:sync-telematics', 120]);
162
+ expect($store->locks)->toBe([]);
163
+
164
+ // Leave a lease behind as if the previous command process had stopped.
165
+ [$key, $ttl] = $store->requestedLocks[0];
166
+ expect($store->lock($key, $ttl)->get())->toBeTrue();
167
+ $command->messages = [];
168
+ Illuminate\Support\Carbon::setTestNow('2026-09-17 12:01:59 UTC');
169
+ expect($command->handle($registry))->toBe(0)
170
+ ->and($command->messages)->toContain(['warn', 'Another telematics sync run appears to be in progress.']);
171
+
172
+ $command->messages = [];
173
+ Illuminate\Support\Carbon::setTestNow('2026-09-17 12:02:01 UTC');
174
+ expect($command->handle($registry))->toBe(0)
175
+ ->and($command->messages)->toContain(['info', 'No pollable telematics providers found.']);
176
+ expect($store->locks)->toBe([]);
177
+ } finally {
178
+ Illuminate\Support\Facades\Cache::swap($originalCache);
179
+ Illuminate\Support\Carbon::setTestNow();
180
+ }
181
+ });
182
+
183
+ test('sync telematics reports successful durable and legacy dispatches without counting coalesced polls', function () {
184
+ $connection = fleetopsSyncTelematicsBoot();
185
+ $connection->table('telematics')->insert([
186
+ ['uuid' => 'durable-connection', 'company_uuid' => 'company-1', 'provider' => 'example', 'status' => 'active'],
187
+ ['uuid' => 'legacy-connection', 'company_uuid' => 'company-1', 'provider' => 'traccar', 'status' => 'active'],
188
+ ]);
189
+ $registry = fleetopsSyncTelematicsRegistry([
190
+ ['key' => 'example', 'label' => 'Example', 'supports_discovery' => true, 'driver_class' => ExampleTelemetryProvider::class, 'metadata' => ['telemetry' => ['durable_ingestion' => true]]],
191
+ ['key' => 'traccar', 'label' => 'Traccar', 'supports_discovery' => true],
192
+ ]);
193
+ $originalCache = Illuminate\Support\Facades\Cache::getFacadeRoot();
194
+ $originalCacheConfig = config('cache', []);
195
+ $dispatcherContract = Illuminate\Contracts\Bus\Dispatcher::class;
196
+ $originalDispatcher = app()->bound($dispatcherContract) ? app($dispatcherContract) : null;
197
+
198
+ try {
199
+ config(['cache.default' => 'array', 'cache.stores.array' => ['driver' => 'array']]);
200
+ Illuminate\Support\Facades\Cache::swap(new Illuminate\Cache\CacheManager(app()));
201
+ $dispatcher = new class(app()) extends Illuminate\Bus\Dispatcher {
202
+ public array $jobs = [];
203
+
204
+ public function dispatch($command)
205
+ {
206
+ $this->jobs[] = $command;
207
+
208
+ return $command;
209
+ }
210
+ };
211
+ app()->instance($dispatcherContract, $dispatcher);
212
+ $command = new FleetOpsSyncTelematicsProbe();
213
+
214
+ expect($command->handle($registry))->toBe(0)
215
+ ->and($command->messages)->toContain(['info', 'Queued 2 telematics sync job(s).']);
216
+ expect($dispatcher->jobs)->toHaveCount(1)
217
+ ->and($dispatcher->jobs[0])->toBeInstanceOf(Fleetbase\FleetOps\Jobs\PollTelematicTelemetry::class)
218
+ ->and(DispatchRecorder::$dispatched)->toHaveCount(1);
219
+
220
+ // The real unique lock suppresses a second durable poll; legacy dispatch remains eligible.
221
+ $command->messages = [];
222
+ expect($command->handle($registry))->toBe(0)
223
+ ->and($command->messages)->toContain(['info', 'Queued 1 telematics sync job(s).']);
224
+ expect($dispatcher->jobs)->toHaveCount(1)
225
+ ->and(DispatchRecorder::$dispatched)->toHaveCount(2);
226
+
227
+ $command->messages = [];
228
+ $command->options['provider'] = ['example'];
229
+ expect($command->handle($registry))->toBe(0)
230
+ ->and($command->messages)->toContain(['info', 'Queued 0 telematics sync job(s).']);
231
+ expect($dispatcher->jobs)->toHaveCount(1);
232
+ } finally {
233
+ Illuminate\Support\Facades\Cache::swap($originalCache);
234
+ config(['cache' => $originalCacheConfig]);
235
+ if ($originalDispatcher) {
236
+ app()->instance($dispatcherContract, $originalDispatcher);
237
+ } else {
238
+ app()->offsetUnset($dispatcherContract);
239
+ }
240
+ }
241
+ });
242
+
243
+ class PausedBatchTelemetryProvider extends ExampleTelemetryProvider
244
+ {
245
+ public function telemetryOptions(): array
246
+ {
247
+ return ['polling_enabled' => false, 'manual_batch_sync' => true];
248
+ }
249
+ }
250
+
251
+ class PausedReconciledTelemetryProvider extends ExampleTelemetryProvider
252
+ {
253
+ public function telemetryOptions(): array
254
+ {
255
+ return ['polling_enabled' => false];
256
+ }
257
+ }
258
+
259
+ test('sync telematics pauses batch-only providers instead of queueing legacy syncs that would fail', function () {
260
+ $connection = fleetopsSyncTelematicsBoot();
261
+ $connection->table('telematics')->insert([
262
+ ['uuid' => 'batch-connection', 'company_uuid' => 'company-1', 'provider' => 'batch', 'status' => 'error'],
263
+ ['uuid' => 'reconciled-connection', 'company_uuid' => 'company-1', 'provider' => 'reconciled', 'status' => 'active'],
264
+ ]);
265
+ $registry = fleetopsSyncTelematicsRegistry([
266
+ ['key' => 'batch', 'label' => 'Batch', 'supports_discovery' => true, 'driver_class' => PausedBatchTelemetryProvider::class, 'metadata' => ['telemetry' => ['durable_ingestion' => true]]],
267
+ ['key' => 'reconciled', 'label' => 'Reconciled', 'supports_discovery' => true, 'driver_class' => PausedReconciledTelemetryProvider::class, 'metadata' => ['telemetry' => ['durable_ingestion' => true]]],
268
+ ]);
269
+ $command = new FleetOpsSyncTelematicsProbe();
270
+
271
+ // Providers without batch sync keep their existing legacy reconciliation while polling is paused.
272
+ expect($command->handle($registry))->toBe(0)
273
+ ->and(DispatchRecorder::$dispatched)->toHaveCount(1)
274
+ ->and(DispatchRecorder::$dispatched[0]['arguments'][0]->uuid)->toBe('reconciled-connection')
275
+ ->and($command->messages)->toContain(['info', 'Queued 1 telematics sync job(s).']);
276
+ });
@@ -22,6 +22,13 @@ use Illuminate\Support\Facades\Http;
22
22
  */
23
23
  class FleetOpsAfaqyTransportProbe extends AfaqyProvider
24
24
  {
25
+ public ?float $clock = null;
26
+
27
+ protected function requestTime(): float
28
+ {
29
+ return $this->clock ?? parent::requestTime();
30
+ }
31
+
25
32
  public function setCredentialsForTest(array $credentials): void
26
33
  {
27
34
  $this->credentials = $credentials;
@@ -144,3 +151,128 @@ test('transport errors and helper extractors resolve metadata', function () {
144
151
  expect($probe->callHelper('resolveSensorName', ['param' => 'fuel'], 'fallback'))->toBe('fuel')
145
152
  ->and($probe->callHelper('resolveSensorName', [], 'fallback'))->toBe('fallback');
146
153
  });
154
+
155
+ test('unreadable cached tokens are evicted even when reauthentication fails', function () {
156
+ fleetopsAfaqyTransportBoot();
157
+ $key = 'afaqy:token:' . hash('sha256', 'https://api.afaqy.sa|user') . ':' . hash('sha256', 'secret');
158
+ Illuminate\Support\Facades\Cache::put($key, 'not-an-encrypted-payload', 3600);
159
+ Http::preventStrayRequests();
160
+ Http::fake(['*/auth/login' => Http::response(['message' => 'denied'], 401)]);
161
+ $connection = new Fleetbase\FleetOps\Models\Telematic();
162
+ $connection->credentials = ['username' => 'user', 'password' => 'secret'];
163
+ expect(fn () => (new AfaqyProvider())->connect($connection))->toThrow(TelematicProviderException::class, 'authentication failed with status 401');
164
+ expect(Illuminate\Support\Facades\Cache::has($key))->toBeFalse();
165
+ expect(Illuminate\Support\Facades\Cache::get('afaqy:rate:' . hash('sha256', 'https://api.afaqy.sa|user')))->toHaveCount(1);
166
+ Http::assertSentCount(1);
167
+ });
168
+
169
+ test('rejected token refresh and retry share the original HTTP time budget', function () {
170
+ fleetopsAfaqyTransportBoot();
171
+ $probe = fleetopsAfaqyTransportProbe(['token' => 'stale-token', 'username' => 'user', 'password' => 'secret']);
172
+ $probe->clock = 100;
173
+ $timeouts = [];
174
+ Http::fake(function ($request, $options) use ($probe, &$timeouts) {
175
+ $timeouts[] = [$options['timeout'], $options['connect_timeout']];
176
+ if (count($timeouts) === 1) {
177
+ $probe->clock += 20;
178
+
179
+ return Http::response(['message' => 'expired'], 401);
180
+ }
181
+ if (str_ends_with($request->url(), '/auth/login')) {
182
+ $probe->clock += 10;
183
+
184
+ return Http::response(['data' => ['token' => 'refreshed-token']], 200);
185
+ }
186
+
187
+ return Http::response(['data' => ['ok' => true]], 200);
188
+ });
189
+
190
+ expect($probe->callHelper('afaqyPost', '/units/list', [], false, 45, 5))->toBe(['data' => ['ok' => true]])
191
+ ->and($timeouts)->toBe([[45, 5], [25, 15], [15, 5]]);
192
+ Http::assertSentCount(3);
193
+ });
194
+
195
+ test('exhausted refresh budget prevents a retry and is reset for the next request', function () {
196
+ fleetopsAfaqyTransportBoot();
197
+ $probe = fleetopsAfaqyTransportProbe(['token' => 'stale-token', 'username' => 'user', 'password' => 'secret']);
198
+ $probe->clock = 100;
199
+ $timeouts = [];
200
+ Http::fake(function ($request, $options) use ($probe, &$timeouts) {
201
+ $timeouts[] = $options['timeout'];
202
+ if (count($timeouts) === 1) {
203
+ $probe->clock += 20;
204
+
205
+ return Http::response(['message' => 'expired'], 401);
206
+ }
207
+ if (str_ends_with($request->url(), '/auth/login')) {
208
+ $probe->clock += 24.5;
209
+
210
+ return Http::response(['data' => ['token' => 'refreshed-token']], 200);
211
+ }
212
+
213
+ return Http::response(['data' => []], 200);
214
+ });
215
+
216
+ expect(fn () => $probe->callHelper('afaqyPost', '/units/list', [], false, 45, 5))
217
+ ->toThrow(TelematicProviderException::class, 'time budget exhausted');
218
+ Http::assertSentCount(2);
219
+ expect($timeouts)->toBe([45, 25]);
220
+
221
+ $probe->clock += 60;
222
+ expect($probe->callHelper('afaqyPost', '/units/list', [], false, 45, 5))->toBe(['data' => []]);
223
+ expect($timeouts)->toBe([45, 25, 45]);
224
+ Http::assertSentCount(3);
225
+ });
226
+
227
+ test('token lock wait consumes the refresh budget before authentication starts', function () {
228
+ fleetopsAfaqyTransportBoot();
229
+ $probe = fleetopsAfaqyTransportProbe(['token' => 'stale-token', 'username' => 'user', 'password' => 'secret']);
230
+ $probe->clock = 100;
231
+ $originalCache = Illuminate\Support\Facades\Cache::getFacadeRoot();
232
+ $cache = new class($originalCache, $probe) {
233
+ public array $waits = [];
234
+
235
+ public function __construct(public $repository, public FleetOpsAfaqyTransportProbe $probe)
236
+ {
237
+ }
238
+
239
+ public function lock(string $key, int $seconds)
240
+ {
241
+ return new class($this, $key) {
242
+ public function __construct(public $cache, public string $key)
243
+ {
244
+ }
245
+
246
+ public function block($seconds, $callback)
247
+ {
248
+ $this->cache->waits[] = $seconds;
249
+ if (str_starts_with($this->key, 'afaqy:token:')) {
250
+ $this->cache->probe->clock += 1.2;
251
+ }
252
+
253
+ return $callback();
254
+ }
255
+ };
256
+ }
257
+
258
+ public function __call($method, $arguments)
259
+ {
260
+ return $this->repository->{$method}(...$arguments);
261
+ }
262
+ };
263
+ Illuminate\Support\Facades\Cache::swap($cache);
264
+ try {
265
+ Http::fake(function () use ($probe) {
266
+ $probe->clock += 43;
267
+
268
+ return Http::response(['message' => 'expired'], 401);
269
+ });
270
+
271
+ expect(fn () => $probe->callHelper('afaqyPost', '/units/list', [], false, 45, 5))
272
+ ->toThrow(TelematicProviderException::class, 'time budget exhausted');
273
+ expect($cache->waits)->toBe([2, 2]);
274
+ Http::assertSentCount(1);
275
+ } finally {
276
+ Illuminate\Support\Facades\Cache::swap($originalCache);
277
+ }
278
+ });
@@ -129,5 +129,5 @@ test('safee authentication rejects a successful response that carries no token',
129
129
  ]);
130
130
 
131
131
  expect(fn () => $probe->callHelper('authenticate'))
132
- ->toThrow(RuntimeException::class, 'Safee authentication did not return an access token.');
132
+ ->toThrow(Fleetbase\FleetOps\Exceptions\TelematicProviderException::class, 'Safee authentication did not return an access token.');
133
133
  });