@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.
- package/RELEASE.md +15 -8
- 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/composer.json +2 -1
- package/docs/AFAQY.md +71 -17
- package/docs/SAFEE.md +125 -0
- package/docs/TELEMATICS_QUEUES.md +179 -0
- package/extension.json +1 -1
- package/package.json +1 -1
- 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 +127 -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/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 +43 -1
- package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
- package/server/src/Support/Telematics/Telemetry/Inbox.php +31 -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 +461 -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/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
|
@@ -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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
26
|
-
public bool $saved
|
|
27
|
-
public int $saveCount
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
|
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(
|
|
132
|
+
->toThrow(Fleetbase\FleetOps\Exceptions\TelematicProviderException::class, 'Safee authentication did not return an access token.');
|
|
133
133
|
});
|