@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.
- package/RELEASE.md +9 -8
- package/addon/components/fuel-integration/form.hbs +2 -2
- package/addon/components/fuel-integration/form.js +8 -0
- package/addon/components/fuel-integration/hub.js +1 -1
- package/addon/components/telematic/details.hbs +1 -1
- package/addon/components/telematic/telemetry-status.hbs +93 -34
- package/addon/components/widget/radar.hbs +1 -1
- package/addon/components/widget/radar.js +7 -0
- package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
- package/addon/utils/fuel-transaction.js +2 -0
- package/composer.json +2 -1
- package/docs/AFAQY.md +71 -17
- package/docs/SAFEE.md +130 -0
- package/docs/TELEMATICS_QUEUES.md +230 -0
- package/extension.json +1 -1
- package/package.json +1 -1
- package/server/config/fuel-providers.php +53 -0
- package/server/config/safee.php +10 -0
- package/server/config/telematics.php +8 -1
- package/server/config/telemetry.php +4 -1
- package/server/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php +90 -0
- package/server/src/Console/Commands/SyncTelematics.php +11 -5
- package/server/src/Events/DeviceTelemetryUpdated.php +2 -1
- package/server/src/Events/TrailerLocationChanged.php +2 -0
- package/server/src/Events/VehicleLocationChanged.php +5 -0
- package/server/src/Jobs/PollTelematicTelemetry.php +163 -12
- package/server/src/Jobs/ProcessTelematicDelivery.php +64 -10
- package/server/src/Jobs/SyncTelematicDevicesJob.php +77 -1
- package/server/src/Jobs/TestTelematicConnectionJob.php +1 -0
- package/server/src/Providers/FleetOpsServiceProvider.php +5 -2
- package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
- package/server/src/Support/Telematics/Providers/AfaqyProvider.php +65 -10
- package/server/src/Support/Telematics/Providers/SafeeProvider.php +213 -139
- package/server/src/Support/Telematics/Safee/Transport.php +210 -0
- package/server/src/Support/Telematics/TelematicService.php +45 -1
- package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
- package/server/src/Support/Telematics/Telemetry/Inbox.php +49 -3
- package/server/src/Support/Telematics/Telemetry/Ingestor.php +1 -1
- package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +147 -58
- package/server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php +106 -0
- package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +545 -0
- package/server/tests/Feature/Http/SafeeRealtimeIngestionTest.php +208 -0
- package/server/tests/Feature/Http/TelematicDeliveryCheckpointTest.php +198 -0
- package/server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php +166 -0
- package/server/tests/ProviderContractsTest.php +50 -1
- package/server/tests/Support/AfaqyTestCrypto.php +6 -23
- package/server/tests/SupportJobAndAiCoverageTest.php +102 -5
- package/server/tests/TelematicsHardeningTest.php +18 -13
- package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +140 -0
- package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
- package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +132 -0
- package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +1 -1
- package/server/tests/Unit/Support/Telematics/Providers/SafeeLiveContractTest.php +335 -0
- package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +17 -27
- 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
|
|
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
|
|
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
|
|
429
|
+
$device = Device::withoutGlobalScopes()->first();
|
|
430
430
|
$device->meta = ['telemetry' => ['position_at' => '2026-09-15T11:59:00Z'], 'private' => 'not broadcast'];
|
|
431
|
-
$event
|
|
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'
|
|
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
|
|
446
|
-
$request
|
|
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
|
|
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
|
|
473
|
-
$id
|
|
474
|
-
$job
|
|
475
|
-
$ingestor
|
|
476
|
-
$service
|
|
477
|
-
$lock
|
|
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
|
|
497
|
+
test('delivery worker retries only failed units and quarantines them after retries while counting invalid positions', function () {
|
|
498
498
|
$connection = afaqyDbFixture();
|
|
499
|
-
$service
|
|
500
|
-
$good
|
|
501
|
-
$bad
|
|
502
|
-
$failing
|
|
503
|
-
$id
|
|
504
|
-
$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
|
-
|
|
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
|
|
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
|
|
547
|
-
$ingestor
|
|
548
|
-
$provider
|
|
549
|
-
$missing
|
|
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
|
|
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
|
|
557
|
-
$future['_id']
|
|
558
|
+
$future = afaqyDbUnit('2026-09-16 12:00:00');
|
|
559
|
+
$future['_id'] = 'new-invalid-device';
|
|
558
560
|
$future['sensors'] = ['fuel' => 90];
|
|
559
|
-
$result
|
|
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
|
|
571
|
+
$provider = new class extends AfaqyProvider {
|
|
570
572
|
public function normalizeTelemetrySnapshot(array $payload): array
|
|
571
573
|
{
|
|
572
|
-
$snapshot
|
|
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
|
|
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
|
|
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
|
|
625
|
+
$registry = new TelematicProviderRegistry();
|
|
624
626
|
$controller = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\TelematicController(new TelematicService($registry), $registry);
|
|
625
|
-
$id
|
|
627
|
+
$id = (new Inbox())->accept($connection, afaqyDbUnit(), 'poll');
|
|
626
628
|
DB::table('telematic_deliveries')->where('uuid', $id)->update([
|
|
627
|
-
'status'
|
|
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
|
|
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
|
|
667
|
-
$registry
|
|
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,
|
|
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
|
|
691
|
-
$provider
|
|
692
|
-
$registry
|
|
693
|
-
$lock
|
|
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
|
|
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
|
|
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
|
|
736
|
+
$registry = new TelematicProviderRegistry();
|
|
734
737
|
$controller = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\TelematicController(new TelematicService($registry), $registry);
|
|
735
|
-
$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
|
|
754
|
-
$provider
|
|
755
|
-
$job
|
|
756
|
-
$GLOBALS['telemetry_test_clock'] = [1000.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'
|
|
786
|
-
'driver_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
|
|
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
|
+
});
|