@fleetbase/fleetops-engine 0.6.66 → 0.6.68

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 (55) hide show
  1. package/RELEASE.md +9 -8
  2. package/addon/components/fuel-integration/form.hbs +2 -2
  3. package/addon/components/fuel-integration/form.js +8 -0
  4. package/addon/components/fuel-integration/hub.js +1 -1
  5. package/addon/components/telematic/details.hbs +1 -1
  6. package/addon/components/telematic/telemetry-status.hbs +93 -34
  7. package/addon/components/widget/radar.hbs +1 -1
  8. package/addon/components/widget/radar.js +7 -0
  9. package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
  10. package/addon/utils/fuel-transaction.js +2 -0
  11. package/composer.json +2 -1
  12. package/docs/AFAQY.md +71 -17
  13. package/docs/SAFEE.md +130 -0
  14. package/docs/TELEMATICS_QUEUES.md +230 -0
  15. package/extension.json +1 -1
  16. package/package.json +1 -1
  17. package/server/config/fuel-providers.php +53 -0
  18. package/server/config/safee.php +10 -0
  19. package/server/config/telematics.php +8 -1
  20. package/server/config/telemetry.php +4 -1
  21. package/server/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php +90 -0
  22. package/server/src/Console/Commands/SyncTelematics.php +11 -5
  23. package/server/src/Events/DeviceTelemetryUpdated.php +2 -1
  24. package/server/src/Events/TrailerLocationChanged.php +2 -0
  25. package/server/src/Events/VehicleLocationChanged.php +5 -0
  26. package/server/src/Jobs/PollTelematicTelemetry.php +163 -12
  27. package/server/src/Jobs/ProcessTelematicDelivery.php +64 -10
  28. package/server/src/Jobs/SyncTelematicDevicesJob.php +77 -1
  29. package/server/src/Jobs/TestTelematicConnectionJob.php +1 -0
  30. package/server/src/Providers/FleetOpsServiceProvider.php +5 -2
  31. package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
  32. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +65 -10
  33. package/server/src/Support/Telematics/Providers/SafeeProvider.php +213 -139
  34. package/server/src/Support/Telematics/Safee/Transport.php +210 -0
  35. package/server/src/Support/Telematics/TelematicService.php +45 -1
  36. package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
  37. package/server/src/Support/Telematics/Telemetry/Inbox.php +49 -3
  38. package/server/src/Support/Telematics/Telemetry/Ingestor.php +1 -1
  39. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +147 -58
  40. package/server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php +106 -0
  41. package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +545 -0
  42. package/server/tests/Feature/Http/SafeeRealtimeIngestionTest.php +208 -0
  43. package/server/tests/Feature/Http/TelematicDeliveryCheckpointTest.php +198 -0
  44. package/server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php +166 -0
  45. package/server/tests/ProviderContractsTest.php +50 -1
  46. package/server/tests/Support/AfaqyTestCrypto.php +6 -23
  47. package/server/tests/SupportJobAndAiCoverageTest.php +102 -5
  48. package/server/tests/TelematicsHardeningTest.php +18 -13
  49. package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +140 -0
  50. package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
  51. package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +132 -0
  52. package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +1 -1
  53. package/server/tests/Unit/Support/Telematics/Providers/SafeeLiveContractTest.php +335 -0
  54. package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +17 -27
  55. package/server/tests/Unit/Support/Telematics/TelematicsQueueRoutingTest.php +36 -0
@@ -382,7 +382,7 @@ test('shared polling accepts opaque cursors from another provider and schema rol
382
382
 
383
383
  class AfaqyRecordingDispatcher extends Illuminate\Bus\Dispatcher
384
384
  {
385
- public array $jobs = [];
385
+ public array $jobs = [];
386
386
  public bool $unavailable = false;
387
387
 
388
388
  public function dispatch($command)
@@ -410,14 +410,14 @@ function afaqyQueueFixture(): AfaqyRecordingDispatcher
410
410
  test('telemetry dispatch coalesces duplicate jobs and releases uniqueness after broker failure', function () {
411
411
  afaqyDbFixture();
412
412
  $dispatcher = afaqyQueueFixture();
413
- $job = new ProcessTelematicDelivery('delivery-unique');
413
+ $job = new ProcessTelematicDelivery('delivery-unique');
414
414
  expect($job->uniqueId())->toBe('delivery-unique');
415
415
  expect(Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue::dispatch($job))->toBeTrue();
416
416
  expect(Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue::dispatch($job))->toBeFalse();
417
417
  expect($dispatcher->jobs)->toBe([$job]);
418
418
 
419
419
  $dispatcher->unavailable = true;
420
- $retry = new ProcessTelematicDelivery('delivery-retry');
420
+ $retry = new ProcessTelematicDelivery('delivery-retry');
421
421
  expect(fn () => Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue::dispatch($retry))->toThrow(RuntimeException::class, 'Broker unavailable');
422
422
  $dispatcher->unavailable = false;
423
423
  expect(Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue::dispatch($retry))->toBeTrue();
@@ -426,15 +426,15 @@ test('telemetry dispatch coalesces duplicate jobs and releases uniqueness after
426
426
 
427
427
  test('device telemetry broadcasts contain only the device snapshot and tenant scoped channels', function () {
428
428
  afaqyDbFixture();
429
- $device = Device::withoutGlobalScopes()->first();
429
+ $device = Device::withoutGlobalScopes()->first();
430
430
  $device->meta = ['telemetry' => ['position_at' => '2026-09-15T11:59:00Z'], 'private' => 'not broadcast'];
431
- $event = new Fleetbase\FleetOps\Events\DeviceTelemetryUpdated($device);
431
+ $event = new Fleetbase\FleetOps\Events\DeviceTelemetryUpdated($device);
432
432
  expect(array_map(fn ($channel) => $channel->name, $event->broadcastOn()))->toBe(['company.company-1', 'device.device-1']);
433
433
  expect($event->afterCommit)->toBeTrue();
434
434
  expect($event->broadcastAs())->toBe('device.telemetry_updated');
435
435
  expect($event->broadcastWith())->toBe([
436
436
  'event' => 'device.telemetry_updated',
437
- 'data' => ['id' => 'device-1', 'device_id' => 'device_1', 'telemetry' => ['position_at' => '2026-09-15T11:59:00Z']],
437
+ 'data' => ['id' => 'device-1', 'device_id' => 'device_1', 'telemetry' => ['position_at' => '2026-09-15T11:59:00Z']],
438
438
  ]);
439
439
  });
440
440
 
@@ -442,8 +442,8 @@ test('position webhook rejects malformed requests and backpressure without accep
442
442
  $connection = afaqyDbFixture();
443
443
  DB::table('telematic_webhook_credentials')->insert(['telematic_uuid' => $connection->uuid, 'token' => Crypt::encryptString('secret')]);
444
444
  $controller = new TelematicPositionWebhookController();
445
- $inbox = new Inbox();
446
- $request = fn ($body) => Request::create('/?telematic=telematic_1&key=secret', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], $body);
445
+ $inbox = new Inbox();
446
+ $request = fn ($body) => Request::create('/?telematic=telematic_1&key=secret', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], $body);
447
447
  expect($controller->handle(Request::create('/', 'GET'), $inbox, 'afaqy')->getStatusCode())->toBe(405);
448
448
  expect($controller->handle(Request::create('/', 'POST'), $inbox, 'afaqy')->getStatusCode())->toBe(403);
449
449
  config(['telematics.afaqy.webhooks_enabled' => false]);
@@ -457,7 +457,7 @@ test('position webhook rejects malformed requests and backpressure without accep
457
457
  config(['telematics.afaqy.max_pending_deliveries' => 0]);
458
458
  expect($controller->handle($request('{}'), $inbox, 'afaqy')->getStatusCode())->toBe(503);
459
459
  config(['telematics.afaqy.max_pending_deliveries' => 10000]);
460
- $failedInbox = new class() extends Inbox {
460
+ $failedInbox = new class extends Inbox {
461
461
  public function accept(Telematic $telematic, array $payload, string $source, ?string $run = null, ?string $receivedAt = null): string
462
462
  {
463
463
  throw new RuntimeException('Database unavailable');
@@ -469,12 +469,12 @@ test('position webhook rejects malformed requests and backpressure without accep
469
469
 
470
470
  test('delivery worker leaves leased and paused work untouched and quarantines exhausted or removed connections', function () {
471
471
  $connection = afaqyDbFixture();
472
- $inbox = new Inbox();
473
- $id = $inbox->accept($connection, afaqyDbUnit(), 'webhook');
474
- $job = new ProcessTelematicDelivery($id);
475
- $ingestor = new Ingestor();
476
- $service = new TelematicService(new TelematicProviderRegistry());
477
- $lock = Cache::lock('telemetry:delivery:' . $id, 90);
472
+ $inbox = new Inbox();
473
+ $id = $inbox->accept($connection, afaqyDbUnit(), 'webhook');
474
+ $job = new ProcessTelematicDelivery($id);
475
+ $ingestor = new Ingestor();
476
+ $service = new TelematicService(new TelematicProviderRegistry());
477
+ $lock = Cache::lock('telemetry:delivery:' . $id, 90);
478
478
  expect($lock->get())->toBeTrue();
479
479
  $job->handle($ingestor, $service);
480
480
  expect(DB::table('telematic_deliveries')->value('attempts'))->toBe(0);
@@ -494,14 +494,14 @@ test('delivery worker leaves leased and paused work untouched and quarantines ex
494
494
  expect(DeviceEvent::withoutGlobalScopes()->count())->toBe(0);
495
495
  });
496
496
 
497
- test('delivery worker retries only failed units and quarantines invalid positions after retries', function () {
497
+ test('delivery worker retries only failed units and quarantines them after retries while counting invalid positions', function () {
498
498
  $connection = afaqyDbFixture();
499
- $service = new TelematicService(new TelematicProviderRegistry());
500
- $good = afaqyDbUnit();
501
- $bad = afaqyDbUnit('2026-09-15 11:59:30', 999);
502
- $failing = array_replace($good, ['_id' => 'retry-unit']);
503
- $id = (new Inbox())->accept($connection, [$good, $bad, $failing], 'poll');
504
- $ingestor = new class() extends Ingestor {
499
+ $service = new TelematicService(new TelematicProviderRegistry());
500
+ $good = afaqyDbUnit();
501
+ $bad = afaqyDbUnit('2026-09-15 11:59:30', 999);
502
+ $failing = array_replace($good, ['_id' => 'retry-unit']);
503
+ $id = (new Inbox())->accept($connection, [$good, $bad, $failing], 'poll');
504
+ $ingestor = new class extends Ingestor {
505
505
  public function ingest(Telematic $telematic, Fleetbase\FleetOps\Contracts\TelemetryProviderInterface $provider, array $raw, TelematicService $service, ?string $receivedAt = null, string $source = 'poll'): array
506
506
  {
507
507
  if ($raw['_id'] === 'retry-unit') {
@@ -523,7 +523,9 @@ test('delivery worker retries only failed units and quarantines invalid position
523
523
  $job->handle($ingestor, $service);
524
524
  $row = DB::table('telematic_deliveries')->where('uuid', $id)->first();
525
525
  expect($row->status)->toBe('quarantined');
526
- expect($row->failed)->toBe(2);
526
+ // Only the exhausted unit is a failure; the polled invalid position remains counted separately.
527
+ expect($row->failed)->toBe(1);
528
+ expect($row->invalid_count)->toBe(1);
527
529
  expect($row->applied)->toBe(1);
528
530
  expect($row->processed_at)->not->toBeNull();
529
531
  });
@@ -533,7 +535,7 @@ test('shared service routes telemetry adapters through ordered ingestion and pre
533
535
  DB::table('vehicles')->insert(['uuid' => 'vehicle-newer', 'public_id' => 'vehicle_newer', 'company_uuid' => 'company-1', 'name' => 'Truck', 'telematics' => json_encode(['last_event_at' => '2026-09-15T12:00:00Z'])]);
534
536
  DB::table('devices')->update(['attachable_uuid' => 'vehicle-newer', 'attachable_type' => Fleetbase\FleetOps\Models\Vehicle::class]);
535
537
  $service = new TelematicService(new TelematicProviderRegistry());
536
- $result = $service->ingestDeviceSnapshot($connection, new AfaqyProvider(), afaqyDbUnit());
538
+ $result = $service->ingestDeviceSnapshot($connection, new AfaqyProvider(), afaqyDbUnit());
537
539
  expect($result['event'])->toBeInstanceOf(DeviceEvent::class);
538
540
  expect(DB::table('vehicles')->value('location'))->toBeNull();
539
541
  expect(json_decode(DB::table('vehicles')->value('telematics'), true)['last_event_at'])->toBe('2026-09-15T12:00:00Z');
@@ -543,20 +545,20 @@ test('shared service routes telemetry adapters through ordered ingestion and pre
543
545
 
544
546
  test('ingestion rejects missing identities and ignores future contact and sensor timestamps', function () {
545
547
  $connection = afaqyDbFixture();
546
- $service = new TelematicService(new TelematicProviderRegistry());
547
- $ingestor = new Ingestor();
548
- $provider = new AfaqyProvider();
549
- $missing = afaqyDbUnit();
548
+ $service = new TelematicService(new TelematicProviderRegistry());
549
+ $ingestor = new Ingestor();
550
+ $provider = new AfaqyProvider();
551
+ $missing = afaqyDbUnit();
550
552
  unset($missing['_id']);
551
553
  expect(fn () => $ingestor->ingest($connection, $provider, $missing, $service))->toThrow(InvalidArgumentException::class, 'Unit identity is required');
552
- $unit = afaqyDbUnit();
554
+ $unit = afaqyDbUnit();
553
555
  $unit['last_update']['dts'] = '2026-09-16 12:00:00';
554
556
  $ingestor->ingest($connection, $provider, $unit, $service);
555
557
  expect(Device::withoutGlobalScopes()->first()->last_online_at?->lte(now()) ?? true)->toBeTrue();
556
- $future = afaqyDbUnit('2026-09-16 12:00:00');
557
- $future['_id'] = 'new-invalid-device';
558
+ $future = afaqyDbUnit('2026-09-16 12:00:00');
559
+ $future['_id'] = 'new-invalid-device';
558
560
  $future['sensors'] = ['fuel' => 90];
559
- $result = $ingestor->ingest($connection, $provider, $future, $service);
561
+ $result = $ingestor->ingest($connection, $provider, $future, $service);
560
562
  expect($result['invalid_position'])->toBeTrue();
561
563
  expect($result['sensors'])->toBe(0);
562
564
  expect($result['device']->last_online_at)->toBeNull();
@@ -566,10 +568,10 @@ test('ingestion rejects missing identities and ignores future contact and sensor
566
568
  test('new GPS fixes keep a later contact watermark and null dated sensors are skipped', function () {
567
569
  $connection = afaqyDbFixture();
568
570
  DB::table('devices')->update(['last_online_at' => '2026-09-15 12:00:00']);
569
- $provider = new class() extends AfaqyProvider {
571
+ $provider = new class extends AfaqyProvider {
570
572
  public function normalizeTelemetrySnapshot(array $payload): array
571
573
  {
572
- $snapshot = parent::normalizeTelemetrySnapshot($payload);
574
+ $snapshot = parent::normalizeTelemetrySnapshot($payload);
573
575
  $snapshot['sensors'] = [['recorded_at' => null]];
574
576
 
575
577
  return $snapshot;
@@ -599,12 +601,12 @@ test('inbox completion waits for pending deliveries and aggregates quarantined f
599
601
  test('telemetry diagnostics report setup receipt backlog and degraded delivery states', function () {
600
602
  $connection = afaqyDbFixture();
601
603
  session(['company' => $connection->company_uuid]);
602
- $registry = new TelematicProviderRegistry();
604
+ $registry = new TelematicProviderRegistry();
603
605
  $controller = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\TelematicController(new TelematicService($registry), $registry);
604
606
  expect($controller->telemetryDiagnostics($connection->uuid)->getData(true)['webhook_state'])->toBe('not_configured');
605
607
  DB::table('telematic_webhook_credentials')->insert(['telematic_uuid' => $connection->uuid, 'token' => Crypt::encryptString('secret')]);
606
608
  expect($controller->telemetryDiagnostics($connection->uuid)->getData(true)['webhook_state'])->toBe('awaiting_first_delivery');
607
- $id = (new Inbox())->accept($connection, afaqyDbUnit(), 'webhook');
609
+ $id = (new Inbox())->accept($connection, afaqyDbUnit(), 'webhook');
608
610
  $diagnostics = $controller->telemetryDiagnostics($connection->uuid)->getData(true);
609
611
  expect($diagnostics['webhook_state'])->toBe('receiving');
610
612
  expect($diagnostics['delivery_counts'])->toBe(['pending' => 1]);
@@ -620,11 +622,11 @@ test('delivery replay resets failures within the current integration and survive
620
622
  $connection = afaqyDbFixture();
621
623
  session(['company' => $connection->company_uuid]);
622
624
  $dispatcher = afaqyQueueFixture();
623
- $registry = new TelematicProviderRegistry();
625
+ $registry = new TelematicProviderRegistry();
624
626
  $controller = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\TelematicController(new TelematicService($registry), $registry);
625
- $id = (new Inbox())->accept($connection, afaqyDbUnit(), 'poll');
627
+ $id = (new Inbox())->accept($connection, afaqyDbUnit(), 'poll');
626
628
  DB::table('telematic_deliveries')->where('uuid', $id)->update([
627
- 'status' => 'quarantined', 'attempts' => 5, 'retry_payload' => 'old-retry', 'failed' => 2, 'applied' => 3,
629
+ 'status' => 'quarantined', 'attempts' => 5, 'retry_payload' => 'old-retry', 'failed' => 2, 'applied' => 3,
628
630
  'invalid_count' => 1, 'error' => 'Old error', 'processed_at' => now(),
629
631
  ]);
630
632
  (new Illuminate\Bus\UniqueLock(Cache::store()))->release(new ProcessTelematicDelivery($id));
@@ -642,7 +644,7 @@ test('delivery replay resets failures within the current integration and survive
642
644
  class AfaqySyncCommandProbe extends Fleetbase\FleetOps\Console\Commands\SyncTelematics
643
645
  {
644
646
  public array $messages = [];
645
- public array $options = ['no-lock' => false, 'provider' => ['afaqy'], 'limit' => 500, 'exclude-webhook-providers' => true];
647
+ public array $options = ['no-lock' => false, 'provider' => ['afaqy'], 'limit' => 500, 'exclude-webhook-providers' => true];
646
648
 
647
649
  public function option($key = null)
648
650
  {
@@ -663,14 +665,15 @@ class AfaqySyncCommandProbe extends Fleetbase\FleetOps\Console\Commands\SyncTele
663
665
  test('scheduled telemetry sync dispatches durable polls and retries broker failures on the next tick', function () {
664
666
  $connection = afaqyDbFixture();
665
667
  $dispatcher = afaqyQueueFixture();
666
- $command = new AfaqySyncCommandProbe();
667
- $registry = app(TelematicProviderRegistry::class);
668
+ $command = new AfaqySyncCommandProbe();
669
+ $registry = app(TelematicProviderRegistry::class);
668
670
  expect($command->handle($registry))->toBe(0);
669
671
  expect($dispatcher->jobs)->toHaveCount(1);
670
672
  $job = $dispatcher->jobs[0];
671
673
  expect($job)->toBeInstanceOf(Fleetbase\FleetOps\Jobs\PollTelematicTelemetry::class);
672
674
  expect($job->uniqueId())->toBe($connection->uuid);
673
- expect($job->backoff())->toBe([15, 60, 180, 300]);
675
+ expect($job->backoff())->toBe([15, 30, 60, 60])
676
+ ->and((new Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid, 'manual-job'))->backoff())->toBe([15, 60, 180, 300]);
674
677
  expect($job->delay->betweenIncluded(now(), now()->addSeconds(9)))->toBeTrue();
675
678
  (new Illuminate\Bus\UniqueLock(Cache::store()))->release($job);
676
679
  $dispatcher->unavailable = true;
@@ -687,10 +690,10 @@ test('scheduled telemetry sync dispatches durable polls and retries broker failu
687
690
 
688
691
  test('polling honors connection locks and rejects stalled pagination and page exhaustion', function () {
689
692
  $connection = afaqyDbFixture();
690
- $job = new Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid);
691
- $provider = new AfaqyPollingFixtureProvider();
692
- $registry = new AfaqyPollingFixtureRegistry($provider);
693
- $lock = Cache::lock('telemetry:poll:' . $connection->uuid, 150);
693
+ $job = new Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid);
694
+ $provider = new AfaqyPollingFixtureProvider();
695
+ $registry = new AfaqyPollingFixtureRegistry($provider);
696
+ $lock = Cache::lock('telemetry:poll:' . $connection->uuid, 150);
694
697
  expect($lock->get())->toBeTrue();
695
698
  $job->handle($registry, new Inbox());
696
699
  expect(DB::table('telematic_sync_runs')->count())->toBe(0);
@@ -705,13 +708,13 @@ test('polling honors connection locks and rejects stalled pagination and page ex
705
708
 
706
709
  test('polling releases a rate-limited queue job using the provider retry delay', function () {
707
710
  $connection = afaqyDbFixture();
708
- $provider = new class() extends AfaqyPollingFixtureProvider {
711
+ $provider = new class extends AfaqyPollingFixtureProvider {
709
712
  public function fetchDevices(array $options = []): array
710
713
  {
711
714
  throw new Fleetbase\FleetOps\Exceptions\TelematicRateLimitExceededException('Slow down', ['retry_after' => 37]);
712
715
  }
713
716
  };
714
- $job = new Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid);
717
+ $job = new Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid);
715
718
  $queueJob = new class(app(), '{}', 'test', 'default') extends Illuminate\Queue\Jobs\SyncJob {
716
719
  public ?int $delay = null;
717
720
 
@@ -730,9 +733,9 @@ test('webhook provisioning reuses encrypted credentials and explicit rotation re
730
733
  $connection = afaqyDbFixture();
731
734
  session(['company' => $connection->company_uuid]);
732
735
  config(['app.env' => 'production']);
733
- $registry = new TelematicProviderRegistry();
736
+ $registry = new TelematicProviderRegistry();
734
737
  $controller = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\TelematicController(new TelematicService($registry), $registry);
735
- $url = $controller->telemetryWebhook(new Request(), $connection->uuid)->getData(true)['url'];
738
+ $url = $controller->telemetryWebhook(new Request(), $connection->uuid)->getData(true)['url'];
736
739
  expect($url)->toStartWith('https://api.example.test/webhooks/telematics/afaqy?');
737
740
  parse_str(parse_url($url, PHP_URL_QUERY), $credentials);
738
741
  expect($credentials['telematic'])->toBe($connection->public_id);
@@ -750,10 +753,10 @@ test('webhook provisioning reuses encrypted credentials and explicit rotation re
750
753
  });
751
754
 
752
755
  test('polling deadline records an incomplete sweep and releases the connection lease', function () {
753
- $connection = afaqyDbFixture();
754
- $provider = new AfaqyPollingFixtureProvider();
755
- $job = new Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid);
756
- $GLOBALS['telemetry_test_clock'] = [1000.0, 1091.0];
756
+ $connection = afaqyDbFixture();
757
+ $provider = new AfaqyPollingFixtureProvider();
758
+ $job = new Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid);
759
+ $GLOBALS['telemetry_test_clock'] = [1000.0, 1061.0];
757
760
  try {
758
761
  expect(fn () => $job->handle(new AfaqyPollingFixtureRegistry($provider), new Inbox()))->toThrow(RuntimeException::class, 'Polling time budget exceeded');
759
762
  expect(DB::table('telematic_sync_runs')->value('status'))->toBe('incomplete');
@@ -765,6 +768,42 @@ test('polling deadline records an incomplete sweep and releases the connection l
765
768
  }
766
769
  });
767
770
 
771
+ test('poll requests allow slow fleet responses while respecting the remaining sweep budget', function () {
772
+ $connection = afaqyDbFixture();
773
+ $provider = new class extends AfaqyPollingFixtureProvider {
774
+ public array $requests = [];
775
+
776
+ public function fetchDevices(array $options = []): array
777
+ {
778
+ $this->requests[] = $options;
779
+
780
+ return ['devices' => [], 'has_more' => count($this->requests) === 1, 'next_cursor' => count($this->requests) === 1 ? 1000 : null];
781
+ }
782
+ };
783
+ $GLOBALS['telemetry_test_clock'] = [1000.0, 1000.0, 1045.0, 1045.0, 1045.0, 1046.0, 1046.0];
784
+ try {
785
+ (new Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid))->handle(new AfaqyPollingFixtureRegistry($provider), new Inbox());
786
+ expect($provider->requests[0]['timeout'])->toBe(45);
787
+ expect($provider->requests[1]['timeout'])->toBe(15);
788
+ expect(DB::table('telematic_sync_runs')->value('status'))->toBe('completed');
789
+ } finally {
790
+ unset($GLOBALS['telemetry_test_clock']);
791
+ }
792
+ });
793
+
794
+ test('polling does not start a request with an unbounded zero-second timeout', function () {
795
+ $connection = afaqyDbFixture();
796
+ $provider = new AfaqyPollingFixtureProvider();
797
+ $GLOBALS['telemetry_test_clock'] = [1000.0, 1059.5];
798
+ try {
799
+ expect(fn () => (new Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid))->handle(new AfaqyPollingFixtureRegistry($provider), new Inbox()))
800
+ ->toThrow(RuntimeException::class, 'Polling time budget exceeded');
801
+ expect(DB::table('telematic_sync_runs')->value('status'))->toBe('incomplete');
802
+ } finally {
803
+ unset($GLOBALS['telemetry_test_clock']);
804
+ }
805
+ });
806
+
768
807
  test('inbox recovery quarantines unsupported adapters and completes drained sweeps', function () {
769
808
  $connection = afaqyDbFixture();
770
809
  afaqyQueueFixture();
@@ -782,11 +821,61 @@ test('inbox recovery quarantines unsupported adapters and completes drained swee
782
821
 
783
822
  // A descriptor cannot opt a legacy adapter into the durable queue contract.
784
823
  $registry->register(new Fleetbase\FleetOps\Contracts\TelematicProviderDescriptor([
785
- 'key' => 'legacy-durable', 'label' => 'Legacy adapter',
786
- 'driver_class' => Fleetbase\FleetOps\Support\Telematics\Providers\GeotabProvider::class,
824
+ 'key' => 'legacy-durable', 'label' => 'Legacy adapter',
825
+ 'driver_class' => Fleetbase\FleetOps\Support\Telematics\Providers\GeotabProvider::class,
787
826
  'supports_discovery' => true, 'metadata' => ['telemetry' => ['durable_ingestion' => true]],
788
827
  ]));
789
- $command = new AfaqySyncCommandProbe();
828
+ $command = new AfaqySyncCommandProbe();
790
829
  $command->options['provider'] = ['legacy-durable'];
791
830
  expect($command->handle($registry))->toBe(0);
792
831
  });
832
+
833
+ test('AFAQY sync recovers an unreadable cached token using persisted credentials', function (string $failure, string $mode) {
834
+ $connection = afaqyDbFixture();
835
+ $credentials = ['username' => 'test-account', 'password' => 'test-password'];
836
+ $connection->credentials = Crypt::encryptString(json_encode($credentials));
837
+ $connection->save();
838
+ $connection->refresh();
839
+ $persisted = $connection->getRawOriginal('credentials');
840
+ $key = 'afaqy:token:' . hash('sha256', 'https://api.afaqy.sa|test-account') . ':' . hash('sha256', 'test-password');
841
+ $stored = (new Illuminate\Encryption\Encrypter(str_repeat('o', 32), 'aes-256-cbc'))->encryptString('old-cached-token');
842
+ if ($failure === 'tampered-mac') {
843
+ $payload = json_decode(base64_decode(Crypt::encryptString('old-cached-token')), true);
844
+ $payload['mac'] = str_repeat('0', 64);
845
+ $stored = base64_encode(json_encode($payload));
846
+ }
847
+ expect(fn () => Crypt::decryptString($stored))->toThrow(Illuminate\Contracts\Encryption\DecryptException::class, 'The MAC is invalid.');
848
+ Cache::put($key, $stored, 3600);
849
+ Illuminate\Support\Facades\Http::swap(new Illuminate\Http\Client\Factory());
850
+ Illuminate\Support\Facades\Http::preventStrayRequests();
851
+ Illuminate\Support\Facades\Http::fake([
852
+ '*/auth/login' => Illuminate\Support\Facades\Http::response(['data' => ['token' => 'replacement-token']]),
853
+ '*/units/lists*' => Illuminate\Support\Facades\Http::response(['data' => [afaqyDbUnit()], 'pagination' => ['allCount' => 1, 'limit' => 1000, 'offset' => 0, 'resultCount' => 1]]),
854
+ ]);
855
+ $registry = app(TelematicProviderRegistry::class);
856
+ $sync = function () use ($connection, $registry, $mode) {
857
+ $service = new TelematicService($registry);
858
+ if ($mode === 'manual') {
859
+ (new Fleetbase\FleetOps\Jobs\SyncTelematicDevicesJob($connection))->handle($registry, $service);
860
+ expect($connection->refresh()->status)->toBe('active');
861
+ expect(data_get($connection->meta, 'last_sync_result'))->toBe('success');
862
+ expect(data_get($connection->meta, 'last_sync_linked_total'))->toBe(1);
863
+ } else {
864
+ (new Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid))->handle($registry, new Inbox());
865
+ foreach (DB::table('telematic_deliveries')->where('status', 'pending')->pluck('uuid') as $id) {
866
+ (new ProcessTelematicDelivery($id))->handle(new Ingestor(), $service);
867
+ }
868
+ expect(DB::table('telematic_sync_runs')->where('status', '!=', 'completed')->count())->toBe(0);
869
+ expect(DB::table('telematic_sync_runs')->count())->toBeGreaterThan(0);
870
+ }
871
+ };
872
+ $sync();
873
+ expect(DeviceEvent::withoutGlobalScopes()->count())->toBe(1);
874
+ expect(Crypt::decryptString(Cache::get($key)))->toBe('replacement-token');
875
+ expect($connection->getRawOriginal('credentials'))->toBe($persisted);
876
+ // A second run reuses the replacement token and deduplicates the same fix.
877
+ $sync();
878
+ expect(DeviceEvent::withoutGlobalScopes()->count())->toBe(1);
879
+ Illuminate\Support\Facades\Http::assertSentCount(3);
880
+ Illuminate\Support\Facades\Http::assertSent(fn ($request) => str_contains($request->url(), '/units/lists?token=replacement-token'));
881
+ })->with(['foreign-key', 'tampered-mac'])->with(['manual', 'scheduled']);
@@ -0,0 +1,106 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\SQLiteConnection;
4
+ use Illuminate\Support\Facades\Schema;
5
+
6
+ function fleetopsDeviceEventIndexMigrationDatabase(bool $createTable = true): SQLiteConnection
7
+ {
8
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:', '', ['driver' => 'sqlite']);
9
+ Schema::swap($connection->getSchemaBuilder());
10
+
11
+ if ($createTable) {
12
+ Schema::create('device_events', function ($table) {
13
+ $table->increments('id');
14
+ $table->uuid('uuid')->nullable();
15
+ $table->string('_key')->nullable()->index();
16
+ $table->uuid('company_uuid')->nullable();
17
+ $table->text('payload')->nullable();
18
+ });
19
+ }
20
+
21
+ return $connection;
22
+ }
23
+
24
+ function fleetopsDeviceEventIndexMigration()
25
+ {
26
+ return require dirname(__DIR__, 4) . '/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php';
27
+ }
28
+
29
+ function fleetopsDeviceEventIndexNames(SQLiteConnection $connection): array
30
+ {
31
+ return array_column($connection->select('PRAGMA index_list("device_events")'), 'name');
32
+ }
33
+
34
+ test('device event UUID migration indexes legacy lookups without changing data', function () {
35
+ $connection = fleetopsDeviceEventIndexMigrationDatabase();
36
+ $connection->table('device_events')->insert([
37
+ ['uuid' => 'event-1', 'payload' => 'first'],
38
+ ['uuid' => 'event-1', 'payload' => 'legacy duplicate'],
39
+ ['uuid' => null, 'payload' => 'legacy null'],
40
+ ]);
41
+ $before = $connection->table('device_events')->orderBy('id')->get()->all();
42
+ $migration = fleetopsDeviceEventIndexMigration();
43
+
44
+ $migration->up();
45
+ $migration->up();
46
+
47
+ expect(fleetopsDeviceEventIndexNames($connection))->toContain('device_events_uuid_lookup_index', 'device_events__key_index')
48
+ ->and($connection->table('device_events')->orderBy('id')->get()->all())->toEqual($before);
49
+ $plan = $connection->select('EXPLAIN QUERY PLAN SELECT * FROM device_events WHERE uuid = ? LIMIT 1', ['event-1']);
50
+ expect($plan[0]->detail)->toContain('USING INDEX device_events_uuid_lookup_index');
51
+
52
+ // Rollback is performed by a newly loaded migration, as in artisan migrate:rollback.
53
+ fleetopsDeviceEventIndexMigration()->down();
54
+ expect(fleetopsDeviceEventIndexNames($connection))->not->toContain('device_events_uuid_lookup_index')
55
+ ->toContain('device_events__key_index')
56
+ ->and($connection->table('device_events')->orderBy('id')->get()->all())->toEqual($before);
57
+ });
58
+
59
+ test('device event UUID migration preserves equivalent existing indexes', function (array $columns, bool $unique) {
60
+ $connection = fleetopsDeviceEventIndexMigrationDatabase();
61
+ Schema::table('device_events', function ($table) use ($columns, $unique) {
62
+ $unique ? $table->unique($columns, 'existing_event_identity') : $table->index($columns, 'existing_event_identity');
63
+ });
64
+ $before = fleetopsDeviceEventIndexNames($connection);
65
+
66
+ fleetopsDeviceEventIndexMigration()->up();
67
+ fleetopsDeviceEventIndexMigration()->down();
68
+
69
+ expect(fleetopsDeviceEventIndexNames($connection))->toBe($before);
70
+ })->with([
71
+ 'unique UUID' => [['uuid'], true],
72
+ 'UUID leading composite' => [['uuid', 'company_uuid'], false],
73
+ ]);
74
+
75
+ test('device event UUID migration recognizes a UUID primary key', function () {
76
+ $connection = fleetopsDeviceEventIndexMigrationDatabase(false);
77
+ Schema::create('device_events', function ($table) {
78
+ $table->uuid('uuid')->primary();
79
+ });
80
+ $before = fleetopsDeviceEventIndexNames($connection);
81
+
82
+ fleetopsDeviceEventIndexMigration()->up();
83
+ fleetopsDeviceEventIndexMigration()->down();
84
+
85
+ expect(fleetopsDeviceEventIndexNames($connection))->toBe($before);
86
+ });
87
+
88
+ test('device event UUID migration adds an index when UUID is not the leading column', function () {
89
+ $connection = fleetopsDeviceEventIndexMigrationDatabase();
90
+ Schema::table('device_events', fn ($table) => $table->index(['company_uuid', 'uuid'], 'existing_company_event'));
91
+
92
+ fleetopsDeviceEventIndexMigration()->up();
93
+ expect(fleetopsDeviceEventIndexNames($connection))->toContain('device_events_uuid_lookup_index', 'existing_company_event');
94
+
95
+ fleetopsDeviceEventIndexMigration()->down();
96
+ expect(fleetopsDeviceEventIndexNames($connection))->not->toContain('device_events_uuid_lookup_index')
97
+ ->toContain('existing_company_event');
98
+ });
99
+
100
+ test('device event UUID migration tolerates an absent legacy table', function () {
101
+ fleetopsDeviceEventIndexMigrationDatabase(false);
102
+ fleetopsDeviceEventIndexMigration()->up();
103
+ fleetopsDeviceEventIndexMigration()->down();
104
+
105
+ expect(Schema::hasTable('device_events'))->toBeFalse();
106
+ });