@fleetbase/fleetops-engine 0.6.66 → 0.6.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/RELEASE.md +15 -8
  2. package/addon/components/telematic/details.hbs +1 -1
  3. package/addon/components/telematic/telemetry-status.hbs +93 -34
  4. package/addon/components/widget/radar.hbs +1 -1
  5. package/addon/components/widget/radar.js +7 -0
  6. package/composer.json +2 -1
  7. package/docs/AFAQY.md +71 -17
  8. package/docs/SAFEE.md +125 -0
  9. package/docs/TELEMATICS_QUEUES.md +179 -0
  10. package/extension.json +1 -1
  11. package/package.json +1 -1
  12. package/server/config/safee.php +10 -0
  13. package/server/config/telematics.php +8 -1
  14. package/server/config/telemetry.php +4 -1
  15. package/server/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php +90 -0
  16. package/server/src/Console/Commands/SyncTelematics.php +11 -5
  17. package/server/src/Events/DeviceTelemetryUpdated.php +2 -1
  18. package/server/src/Events/TrailerLocationChanged.php +2 -0
  19. package/server/src/Events/VehicleLocationChanged.php +5 -0
  20. package/server/src/Jobs/PollTelematicTelemetry.php +127 -12
  21. package/server/src/Jobs/ProcessTelematicDelivery.php +64 -10
  22. package/server/src/Jobs/SyncTelematicDevicesJob.php +77 -1
  23. package/server/src/Jobs/TestTelematicConnectionJob.php +1 -0
  24. package/server/src/Providers/FleetOpsServiceProvider.php +5 -2
  25. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +65 -10
  26. package/server/src/Support/Telematics/Providers/SafeeProvider.php +213 -139
  27. package/server/src/Support/Telematics/Safee/Transport.php +210 -0
  28. package/server/src/Support/Telematics/TelematicService.php +43 -1
  29. package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
  30. package/server/src/Support/Telematics/Telemetry/Inbox.php +31 -3
  31. package/server/src/Support/Telematics/Telemetry/Ingestor.php +1 -1
  32. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +147 -58
  33. package/server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php +106 -0
  34. package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +461 -0
  35. package/server/tests/Feature/Http/SafeeRealtimeIngestionTest.php +208 -0
  36. package/server/tests/Feature/Http/TelematicDeliveryCheckpointTest.php +198 -0
  37. package/server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php +166 -0
  38. package/server/tests/ProviderContractsTest.php +50 -1
  39. package/server/tests/Support/AfaqyTestCrypto.php +6 -23
  40. package/server/tests/SupportJobAndAiCoverageTest.php +102 -5
  41. package/server/tests/TelematicsHardeningTest.php +18 -13
  42. package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +140 -0
  43. package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +132 -0
  44. package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +1 -1
  45. package/server/tests/Unit/Support/Telematics/Providers/SafeeLiveContractTest.php +335 -0
  46. package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +17 -27
  47. package/server/tests/Unit/Support/Telematics/TelematicsQueueRoutingTest.php +36 -0
@@ -0,0 +1,208 @@
1
+ <?php
2
+
3
+ require_once __DIR__ . '/../../Support/TelemetryTestEnvironment.php';
4
+
5
+ use Fleetbase\FleetOps\Jobs\ProcessTelematicDelivery;
6
+ use Fleetbase\FleetOps\Models\Device;
7
+ use Fleetbase\FleetOps\Models\DeviceEvent;
8
+ use Fleetbase\FleetOps\Models\Telematic;
9
+ use Fleetbase\FleetOps\Support\Telematics\TelematicProviderRegistry;
10
+ use Fleetbase\FleetOps\Support\Telematics\Providers\SafeeProvider;
11
+ use Fleetbase\FleetOps\Models\Vehicle;
12
+ use Fleetbase\FleetOps\Support\Telematics\TelematicService;
13
+ use Fleetbase\FleetOps\Support\Telematics\Telemetry\Ingestor;
14
+ use Illuminate\Database\ConnectionResolver;
15
+ use Illuminate\Database\Eloquent\Model;
16
+ use Illuminate\Database\SQLiteConnection;
17
+ use Illuminate\Support\Carbon;
18
+ use Illuminate\Support\Facades\Cache;
19
+ use Illuminate\Support\Facades\Crypt;
20
+ use Illuminate\Support\Facades\DB;
21
+ use Illuminate\Support\Facades\Schema;
22
+
23
+ if (!function_exists('Fleetbase\\FleetOps\\Support\\Telematics\\Telemetry\\broadcast')) {
24
+ eval('namespace Fleetbase\\FleetOps\\Support\\Telematics\\Telemetry; function broadcast($event) { $GLOBALS["afaqy_broadcasts"][] = $event; }');
25
+ }
26
+ if (!function_exists('Fleetbase\\FleetOps\\Support\\Telematics\\broadcast')) {
27
+ eval('namespace Fleetbase\\FleetOps\\Support\\Telematics; function broadcast($event) { $GLOBALS["afaqy_broadcasts"][] = $event; }');
28
+ }
29
+
30
+ function safeeDbFixture(): Telematic
31
+ {
32
+ $pdo = new PDO('sqlite::memory:');
33
+ $geometry = function ($text, $srid = 0, ...$rest) {
34
+ preg_match('/POINT\(([\d.e+\-]+) ([\d.e+\-]+)\)/i', $text, $coordinates);
35
+
36
+ return pack('V', $srid) . chr(1) . pack('V', 1) . pack('e2', (float) $coordinates[1], (float) $coordinates[2]);
37
+ };
38
+ $pdo->sqliteCreateFunction('ST_GeomFromText', $geometry);
39
+ $pdo->sqliteCreateFunction('ST_PointFromText', $geometry);
40
+ $connection = new SQLiteConnection($pdo, '', '', ['name' => 'mysql']);
41
+ $connection->setTransactionManager(new Illuminate\Database\DatabaseTransactionsManager());
42
+ $resolver = new ConnectionResolver(['mysql' => $connection]);
43
+ $resolver->setDefaultConnection('mysql');
44
+ Model::setConnectionResolver($resolver);
45
+ Model::unsetEventDispatcher();
46
+ Model::clearBootedModels();
47
+ app()->instance('db', new class($connection) {
48
+ public function __construct(public $connection)
49
+ {
50
+ }
51
+
52
+ public function connection($name = null)
53
+ {
54
+ return $this->connection;
55
+ }
56
+
57
+ public function __call($method, $args)
58
+ {
59
+ return $this->connection->{$method}(...$args);
60
+ }
61
+ });
62
+ app()->instance('db.schema', $connection->getSchemaBuilder());
63
+ DB::clearResolvedInstance('db');
64
+ Schema::clearResolvedInstance('db.schema');
65
+ app()->instance('encrypter', new Illuminate\Encryption\Encrypter(str_repeat('m', 32), 'aes-256-cbc'));
66
+ Crypt::clearResolvedInstance('encrypter');
67
+ Cache::flush();
68
+ Carbon::setTestNow('2026-09-15 12:00:00 UTC');
69
+ config(['telematics.afaqy.webhooks_enabled' => true, 'telematics.afaqy.polling_enabled' => true]);
70
+ config(['telematics.providers' => (require __DIR__ . '/../../../config/telematics.php')['providers']]);
71
+ app()->instance(TelematicProviderRegistry::class, new TelematicProviderRegistry());
72
+ $GLOBALS['afaqy_broadcasts'] = [];
73
+ // This persistence fixture excludes model listeners and external transports.
74
+ foreach ([
75
+ 'telematics' => ['uuid', 'public_id', 'company_uuid', 'provider', 'status', 'meta', 'credentials'],
76
+ 'vehicles' => ['uuid', 'public_id', 'company_uuid', 'name', 'plate_number', 'location', 'speed', 'heading', 'altitude', 'odometer', 'online', 'telematics'],
77
+ 'sensors' => ['uuid', 'public_id', 'company_uuid', 'device_uuid', 'telematic_uuid', 'type', 'internal_id', 'name', 'unit', 'last_value', 'last_reading_at', 'last_position', 'status', 'meta'],
78
+ 'positions' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'coordinates', 'speed', 'heading', 'bearing', 'altitude', 'order_uuid', 'destination_uuid'],
79
+ 'devices' => ['uuid', 'public_id', 'company_uuid', 'telematic_uuid', 'device_id', 'internal_id', 'name', 'model', 'provider', 'type', 'imei', 'imsi', 'serial_number', 'firmware_version', 'last_position', 'last_online_at', 'online', 'status', 'meta', 'attachable_uuid', 'attachable_type'],
80
+ 'device_events' => ['uuid', 'public_id', 'company_uuid', 'device_uuid', 'event_type', 'severity', 'message', 'provider', 'ident', 'code', 'state', 'reason', 'occurred_at', 'data', 'payload', '_key', 'meta', 'location'],
81
+ ] as $table => $columns) {
82
+ Schema::create($table, function ($schema) use ($columns) {
83
+ $schema->increments('id');
84
+ foreach ($columns as $column) {
85
+ $schema->text($column)->nullable();
86
+ }
87
+ $schema->timestamps();
88
+ $schema->timestamp('deleted_at')->nullable();
89
+ $schema->index('uuid');
90
+ });
91
+ }
92
+ (require __DIR__ . '/../../../migrations/2026_09_15_000001_create_telematic_telemetry_tables.php')->up();
93
+ Schema::table('device_events', fn ($table) => $table->index('_key'));
94
+ DB::table('telematics')->insert(['uuid' => 'integration-1', 'public_id' => 'telematic_1', 'company_uuid' => 'company-1', 'provider' => 'safee', 'status' => 'active', 'meta' => '{}']);
95
+
96
+ DB::table('devices')->insert(['uuid' => 'device-1', 'public_id' => 'device_1', 'company_uuid' => 'company-1', 'telematic_uuid' => 'integration-1', 'device_id' => '101', 'internal_id' => 'internal-original', 'name' => 'Existing tracker', 'imei' => 'existing-imei', 'status' => 'offline', 'meta' => '{"preserved":"yes"}']);
97
+
98
+ return Telematic::withoutGlobalScopes()->first();
99
+ }
100
+
101
+ function safeeDbSample(string $time = '2026-09-15T11:59:00Z', float $latitude = 24.0, array $identity = []): array
102
+ {
103
+ return [
104
+ 'id' => 101,
105
+ '_safee' => [
106
+ 'vehicle_id' => 101,
107
+ 'identity' => ['id' => 101] + $identity,
108
+ 'current_state' => ['vehicle' => ['id' => 101], 'date' => $time, 'position' => ['lat' => $latitude, 'lon' => 46.7], 'speed' => 20, 'heading' => 90],
109
+ ],
110
+ ];
111
+ }
112
+
113
+ afterEach(fn () => Carbon::setTestNow());
114
+
115
+ test('safee repeated and reversed polling samples preserve current device and attached vehicle positions', function () {
116
+ $connection = safeeDbFixture();
117
+ DB::table('vehicles')->insert(['uuid' => 'vehicle-1', 'public_id' => 'vehicle_1', 'company_uuid' => 'company-1', 'name' => 'Existing vehicle']);
118
+ DB::table('devices')->where('uuid', 'device-1')->update(['attachable_uuid' => 'vehicle-1', 'attachable_type' => Vehicle::class]);
119
+ $provider = new SafeeProvider();
120
+ $service = new TelematicService(new TelematicProviderRegistry());
121
+ $ingestor = new Ingestor();
122
+ $sample = safeeDbSample();
123
+ $ingestor->ingest($connection, $provider, $sample, $service);
124
+ $duplicate = $ingestor->ingest($connection, $provider, $sample, $service);
125
+ $ingestor->ingest($connection, $provider, safeeDbSample('2026-09-15T11:50:00Z', 12), $service);
126
+ $device = Device::withoutGlobalScopes()->firstOrFail();
127
+ $vehicle = Vehicle::withoutGlobalScopes()->firstOrFail();
128
+ expect($duplicate['duplicate'])->toBeTrue()
129
+ ->and(DeviceEvent::withoutGlobalScopes()->count())->toBe(2)
130
+ ->and($device->last_position->getLat())->toBe(24.0)
131
+ ->and($vehicle->location->getLat())->toBe(24.0)
132
+ ->and($device->last_online_at->toISOString())->toBe('2026-09-15T11:59:00.000000Z')
133
+ ->and($device->name)->toBe('Existing tracker')
134
+ ->and($device->imei)->toBe('existing-imei')
135
+ ->and($device->attachable_uuid)->toBe('vehicle-1')
136
+ ->and($device->attachable_type)->toBe(Vehicle::class)
137
+ ->and($device->internal_id)->toBe('internal-original')
138
+ ->and(data_get($device->meta, 'preserved'))->toBe('yes');
139
+ });
140
+
141
+ test('safee timestamps with offsets deduplicate the same UTC position', function () {
142
+ $connection = safeeDbFixture();
143
+ $service = new TelematicService(new TelematicProviderRegistry());
144
+ $ingestor = new Ingestor();
145
+ $provider = new SafeeProvider();
146
+ $ingestor->ingest($connection, $provider, safeeDbSample(), $service);
147
+ $same = $ingestor->ingest($connection, $provider, safeeDbSample('2026-09-15T14:59:00+03:00'), $service);
148
+ expect($same['duplicate'])->toBeTrue()
149
+ ->and(DeviceEvent::withoutGlobalScopes()->count())->toBe(1)
150
+ ->and(Device::withoutGlobalScopes()->first()->last_online_at->toISOString())->toBe('2026-09-15T11:59:00.000000Z');
151
+ });
152
+
153
+ test('safee missing or invalid positions cannot erase a valid current fix', function ($replacement) {
154
+ $connection = safeeDbFixture();
155
+ $service = new TelematicService(new TelematicProviderRegistry());
156
+ $ingestor = new Ingestor();
157
+ $provider = new SafeeProvider();
158
+ $ingestor->ingest($connection, $provider, safeeDbSample(), $service);
159
+ $invalid = safeeDbSample('2026-09-15T11:59:30Z');
160
+ $invalid['_safee']['current_state'] = $replacement;
161
+ $result = $ingestor->ingest($connection, $provider, $invalid, $service);
162
+ $device = Device::withoutGlobalScopes()->firstOrFail();
163
+ expect($result['invalid_position'])->toBeTrue()
164
+ ->and($device->last_position->getLat())->toBe(24.0)
165
+ ->and(data_get($device->meta, 'telemetry.position_at'))->toBe('2026-09-15T11:59:00.000000Z')
166
+ ->and(DeviceEvent::withoutGlobalScopes()->count())->toBe(1);
167
+ })->with([
168
+ 'missing state' => [null],
169
+ 'invalid coordinates' => [['date' => '2026-09-15T11:59:30Z', 'position' => ['lat' => 200, 'lon' => 46.7]]],
170
+ 'missing timestamp' => [['position' => ['lat' => 25, 'lon' => 46.7]]],
171
+ ]);
172
+
173
+ test('safee matching unit IDs remain isolated by integration and company', function () {
174
+ $first = safeeDbFixture();
175
+ DB::table('telematics')->insert(['uuid' => 'integration-2', 'public_id' => 'telematic_2', 'company_uuid' => 'company-2', 'provider' => 'safee', 'status' => 'active', 'meta' => '{}']);
176
+ DB::table('devices')->insert(['uuid' => 'device-2', 'public_id' => 'device_2', 'company_uuid' => 'company-2', 'telematic_uuid' => 'integration-2', 'device_id' => '101', 'name' => 'Other tracker', 'status' => 'offline', 'meta' => '{}']);
177
+ $second = Telematic::withoutGlobalScopes()->where('uuid', 'integration-2')->firstOrFail();
178
+ $service = new TelematicService(new TelematicProviderRegistry());
179
+ $ingestor = new Ingestor();
180
+ $provider = new SafeeProvider();
181
+ $ingestor->ingest($first, $provider, safeeDbSample(), $service);
182
+ $ingestor->ingest($second, $provider, safeeDbSample('2026-09-15T11:59:00Z', 25), $service);
183
+ $devices = Device::withoutGlobalScopes()->orderBy('company_uuid')->get();
184
+ expect($devices)->toHaveCount(2)
185
+ ->and($devices[0]->company_uuid)->toBe('company-1')
186
+ ->and($devices[0]->last_position->getLat())->toBe(24.0)
187
+ ->and($devices[1]->company_uuid)->toBe('company-2')
188
+ ->and($devices[1]->last_position->getLat())->toBe(25.0)
189
+ ->and(DeviceEvent::withoutGlobalScopes()->count())->toBe(2);
190
+ });
191
+
192
+ test('telematics broadcasts use the configured broadcast queue without changing unconfigured instances', function (?string $queue) {
193
+ $connection = safeeDbFixture();
194
+ config(['telematics.telemetry.broadcast_queue' => $queue]);
195
+ DB::table('vehicles')->insert(['uuid' => 'vehicle-1', 'public_id' => 'vehicle_1', 'company_uuid' => 'company-1', 'name' => 'Existing vehicle', 'telematics' => '{}']);
196
+ DB::table('devices')->where('uuid', 'device-1')->update(['attachable_uuid' => 'vehicle-1', 'attachable_type' => Vehicle::class]);
197
+ try {
198
+ (new Ingestor())->ingest($connection, new SafeeProvider(), safeeDbSample(), new TelematicService(new TelematicProviderRegistry()));
199
+ $classes = array_map('get_class', $GLOBALS['afaqy_broadcasts']);
200
+ expect($classes)->toContain(Fleetbase\FleetOps\Events\DeviceTelemetryUpdated::class)
201
+ ->and($classes)->toContain(Fleetbase\FleetOps\Events\VehicleLocationChanged::class);
202
+ foreach ($GLOBALS['afaqy_broadcasts'] as $event) {
203
+ expect($event->broadcastQueue)->toBe($queue);
204
+ }
205
+ } finally {
206
+ config(['telematics.telemetry.broadcast_queue' => null]);
207
+ }
208
+ })->with(['unconfigured' => [null], 'dedicated queue' => ['telematics-broadcasts']]);
@@ -0,0 +1,198 @@
1
+ <?php
2
+
3
+ require_once __DIR__ . '/../../Support/TelemetryTestEnvironment.php';
4
+ require_once __DIR__ . '/../../Support/ExampleTelemetryProvider.php';
5
+
6
+ use Fleetbase\FleetOps\Contracts\TelemetryProviderInterface;
7
+ use Fleetbase\FleetOps\Jobs\ProcessTelematicDelivery;
8
+ use Fleetbase\FleetOps\Models\Device;
9
+ use Fleetbase\FleetOps\Models\Telematic;
10
+ use Fleetbase\FleetOps\Support\Telematics\TelematicProviderRegistry;
11
+ use Fleetbase\FleetOps\Support\Telematics\TelematicService;
12
+ use Fleetbase\FleetOps\Support\Telematics\Telemetry\Ingestor;
13
+ use Illuminate\Database\ConnectionResolver;
14
+ use Illuminate\Database\Eloquent\Model;
15
+ use Illuminate\Database\SQLiteConnection;
16
+ use Illuminate\Support\Carbon;
17
+ use Illuminate\Support\Facades\Cache;
18
+ use Illuminate\Support\Facades\Crypt;
19
+ use Illuminate\Support\Facades\DB;
20
+ use Illuminate\Support\Facades\Schema;
21
+
22
+ class CheckpointTestIngestor extends Ingestor
23
+ {
24
+ public array $calls = [];
25
+ public array $fail = [];
26
+ public array $invalid = [];
27
+
28
+ public function ingest(Telematic $telematic, TelemetryProviderInterface $provider, array $raw, TelematicService $service, ?string $receivedAt = null, string $source = 'poll'): array
29
+ {
30
+ $this->calls[] = $raw['tracker'];
31
+ if (in_array($raw['tracker'], $this->fail, true)) {
32
+ throw new RuntimeException('Temporary device failure');
33
+ }
34
+ $device = new Device();
35
+ $device->meta = ['telemetry' => ['source_delay_seconds' => 1]];
36
+
37
+ return ['device' => $device, 'invalid_position' => in_array($raw['tracker'], $this->invalid, true)];
38
+ }
39
+ }
40
+
41
+ function checkpointDeliverySetup(int $count = 12, string $source = 'poll'): array
42
+ {
43
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'), '', '', ['name' => 'mysql']);
44
+ $connection->setTransactionManager(new Illuminate\Database\DatabaseTransactionsManager());
45
+ $resolver = new ConnectionResolver(['mysql' => $connection]);
46
+ $resolver->setDefaultConnection('mysql');
47
+ Model::setConnectionResolver($resolver);
48
+ Model::unsetEventDispatcher();
49
+ Model::clearBootedModels();
50
+ app()->instance('db', new class($connection) {
51
+ public function __construct(public $connection)
52
+ {
53
+ }
54
+
55
+ public function connection($name = null)
56
+ {
57
+ return $this->connection;
58
+ }
59
+
60
+ public function __call($method, $args)
61
+ {
62
+ return $this->connection->{$method}(...$args);
63
+ }
64
+ });
65
+ app()->instance('db.schema', $connection->getSchemaBuilder());
66
+ DB::clearResolvedInstance('db');
67
+ Schema::clearResolvedInstance('db.schema');
68
+ app()->instance('encrypter', new Illuminate\Encryption\Encrypter(str_repeat('c', 32), 'aes-256-cbc'));
69
+ Crypt::clearResolvedInstance('encrypter');
70
+ Cache::flush();
71
+ Carbon::setTestNow('2026-09-17 12:00:00 UTC');
72
+ Schema::create('telematics', function ($schema) {
73
+ $schema->increments('id');
74
+ foreach (['uuid', 'public_id', 'company_uuid', 'provider', 'status', 'meta'] as $column) {
75
+ $schema->text($column)->nullable();
76
+ }
77
+ $schema->timestamps();
78
+ $schema->timestamp('deleted_at')->nullable();
79
+ });
80
+ Schema::create('devices', function ($schema) {
81
+ foreach (['company_uuid', 'telematic_uuid', 'device_id'] as $column) {
82
+ $schema->text($column)->nullable();
83
+ }
84
+ });
85
+ (require __DIR__ . '/../../../migrations/2026_09_15_000001_create_telematic_telemetry_tables.php')->up();
86
+ DB::table('telematics')->insert(['uuid' => 'integration-checkpoint', 'public_id' => 'telematic_checkpoint', 'company_uuid' => 'company-1', 'provider' => 'example', 'status' => 'active', 'meta' => '{}']);
87
+ $registry = new class extends TelematicProviderRegistry {
88
+ public function resolve(string $key): Fleetbase\FleetOps\Contracts\TelematicProviderInterface
89
+ {
90
+ return new ExampleTelemetryProvider();
91
+ }
92
+ };
93
+ app()->instance(TelematicProviderRegistry::class, $registry);
94
+ $units = array_map(fn ($i) => ['tracker' => 'unit-' . $i, 'measured' => '2026-09-17T11:59:00Z', 'point' => [46.7, 24.0]], range(1, $count));
95
+ $payload = $source === 'webhook' ? ['signals' => $units] : $units;
96
+ DB::table('telematic_deliveries')->insert([
97
+ 'uuid' => 'delivery-checkpoint', 'telematic_uuid' => 'integration-checkpoint', 'source' => $source, 'status' => 'pending',
98
+ 'payload_hash' => hash('sha256', json_encode($payload)), 'payload' => Crypt::encryptString(json_encode($payload)),
99
+ 'received_at' => now(), 'available_at' => now(), 'created_at' => now(), 'updated_at' => now(),
100
+ ]);
101
+
102
+ return [new ProcessTelematicDelivery('delivery-checkpoint'), new CheckpointTestIngestor(), new TelematicService($registry), $connection];
103
+ }
104
+
105
+ function checkpointDeliveryRow(): object
106
+ {
107
+ return DB::table('telematic_deliveries')->where('uuid', 'delivery-checkpoint')->first();
108
+ }
109
+
110
+ afterEach(function () {
111
+ Carbon::setTestNow();
112
+ unset($GLOBALS['telemetry_test_clock']);
113
+ });
114
+
115
+ test('slow deliveries continue beyond five reservations without replaying applied units', function ($source) {
116
+ [$job, $ingestor, $service] = checkpointDeliverySetup(7, $source);
117
+ for ($pass = 0; $pass < 7; $pass++) {
118
+ $GLOBALS['telemetry_test_clock'] = [1000.0, 1041.0];
119
+ $job->handle($ingestor, $service);
120
+ $row = checkpointDeliveryRow();
121
+ expect((int) $row->applied)->toBe($pass + 1);
122
+ expect($row->status)->toBe($pass === 6 ? 'processed' : 'retry');
123
+ expect((int) $row->attempts)->toBe($pass === 6 ? 1 : 0);
124
+ }
125
+ expect($ingestor->calls)->toBe(array_map(fn ($i) => 'unit-' . $i, range(1, 7)));
126
+ expect((int) checkpointDeliveryRow()->failed)->toBe(0);
127
+ })->with(['poll', 'webhook']);
128
+
129
+ test('yield preserves failed items and invalid counts while reaching the untouched tail', function (string $source, int $invalidFailures, string $finalStatus) {
130
+ [$job, $ingestor, $service] = checkpointDeliverySetup(8, $source);
131
+ $ingestor->fail = ['unit-1'];
132
+ $ingestor->invalid = ['unit-2'];
133
+ for ($pass = 0; $pass < 8; $pass++) {
134
+ $GLOBALS['telemetry_test_clock'] = [1000.0, 1041.0];
135
+ $job->handle($ingestor, $service);
136
+ }
137
+ $row = checkpointDeliveryRow();
138
+ expect($row->status)->toBe('retry')->and((int) $row->attempts)->toBe(1)
139
+ ->and((int) $row->applied)->toBe(6)->and((int) $row->invalid_count)->toBe(1)->and((int) $row->failed)->toBe(1 + $invalidFailures);
140
+ expect(array_column(json_decode(Crypt::decryptString($row->retry_payload), true), 'tracker'))->toBe(['unit-1']);
141
+ expect($ingestor->calls)->toBe(array_map(fn ($i) => 'unit-' . $i, range(1, 8)));
142
+ $ingestor->fail = [];
143
+ Carbon::setTestNow(now()->addSeconds(16));
144
+ $GLOBALS['telemetry_test_clock'] = [1000.0, 1001.0];
145
+ $job->handle($ingestor, $service);
146
+ $row = checkpointDeliveryRow();
147
+ expect($row->status)->toBe($finalStatus)->and((int) $row->applied)->toBe(7)
148
+ ->and((int) $row->invalid_count)->toBe(1)->and((int) $row->failed)->toBe($invalidFailures)->and((int) $row->attempts)->toBe(2);
149
+ expect(end($ingestor->calls))->toBe('unit-1');
150
+ })->with([
151
+ // Polled units without a fix are counted but only pushed invalid samples are replayable failures.
152
+ 'poll' => ['poll', 0, 'processed'],
153
+ 'webhook' => ['webhook', 1, 'quarantined'],
154
+ ]);
155
+
156
+ test('worker restart resumes the committed checkpoint and accounts only the remaining units', function () {
157
+ [$job, $ingestor, $service, $connection] = checkpointDeliverySetup();
158
+ $connection->setEventDispatcher(new Illuminate\Events\Dispatcher());
159
+ $interrupted = false;
160
+ $connection->listen(function ($query) use (&$interrupted) {
161
+ if (!$interrupted && str_starts_with($query->sql, 'update ') && str_contains($query->sql, 'retry_payload')) {
162
+ $interrupted = true;
163
+ throw new RuntimeException('Simulated worker interruption after durable checkpoint');
164
+ }
165
+ });
166
+ $GLOBALS['telemetry_test_clock'] = array_fill(0, 13, 1000.0);
167
+ expect(fn () => $job->handle($ingestor, $service))->toThrow(RuntimeException::class, 'Simulated worker interruption');
168
+ $row = checkpointDeliveryRow();
169
+ expect($row->status)->toBe('processing')->and((int) $row->applied)->toBe(10)->and((int) $row->attempts)->toBe(1);
170
+ expect(array_column(json_decode(Crypt::decryptString($row->retry_payload), true)['remaining'], 'tracker'))->toBe(['unit-11', 'unit-12']);
171
+ Carbon::setTestNow(now()->addSeconds(121));
172
+ $GLOBALS['telemetry_test_clock'] = [1000.0, 1000.0, 1000.0];
173
+ (new ProcessTelematicDelivery('delivery-checkpoint'))->handle($ingestor, $service);
174
+ expect(checkpointDeliveryRow()->status)->toBe('processed')->and((int) checkpointDeliveryRow()->applied)->toBe(12)
175
+ ->and($ingestor->calls)->toBe(array_map(fn ($i) => 'unit-' . $i, range(1, 12)));
176
+ });
177
+
178
+ test('slow single-unit checkpoints limit replay before a ten-unit boundary', function () {
179
+ [$job, $ingestor, $service, $connection] = checkpointDeliverySetup(3);
180
+ $connection->setEventDispatcher(new Illuminate\Events\Dispatcher());
181
+ $connection->listen(function ($query) {
182
+ if (str_starts_with($query->sql, 'update ') && str_contains($query->sql, 'retry_payload')) {
183
+ throw new RuntimeException('Stop after first checkpoint');
184
+ }
185
+ });
186
+ $GLOBALS['telemetry_test_clock'] = [1000.0, 1003.0];
187
+ expect(fn () => $job->handle($ingestor, $service))->toThrow(RuntimeException::class, 'first checkpoint');
188
+ expect((int) checkpointDeliveryRow()->applied)->toBe(1)->and($ingestor->calls)->toBe(['unit-1']);
189
+ });
190
+
191
+ test('malformed delivery checkpoints are quarantined instead of partially replayed', function () {
192
+ [$job, $ingestor, $service] = checkpointDeliverySetup(3);
193
+ DB::table('telematic_deliveries')->where('uuid', 'delivery-checkpoint')->update(['retry_payload' => Crypt::encryptString(json_encode([
194
+ 'checkpoint_version' => 1, 'remaining' => ['tracker' => 'unit-1'], 'failed' => [], 'failure_types' => [],
195
+ ]))]);
196
+ $job->handle($ingestor, $service);
197
+ expect(checkpointDeliveryRow()->status)->toBe('quarantined')->and($ingestor->calls)->toBe([]);
198
+ });
@@ -0,0 +1,166 @@
1
+ <?php
2
+
3
+ require_once __DIR__ . '/../../Support/TelemetryTestEnvironment.php';
4
+
5
+ use Fleetbase\FleetOps\Jobs\ProcessTelematicDelivery;
6
+ use Fleetbase\FleetOps\Models\Device;
7
+ use Fleetbase\FleetOps\Models\DeviceEvent;
8
+ use Fleetbase\FleetOps\Models\Telematic;
9
+ use Fleetbase\FleetOps\Support\Telematics\TelematicProviderRegistry;
10
+ use Fleetbase\FleetOps\Support\Telematics\TelematicService;
11
+ use Fleetbase\FleetOps\Support\Telematics\Telemetry\Ingestor;
12
+ use Illuminate\Database\ConnectionResolver;
13
+ use Illuminate\Database\Eloquent\Model;
14
+ use Illuminate\Database\SQLiteConnection;
15
+ use Illuminate\Support\Carbon;
16
+ use Illuminate\Support\Facades\Cache;
17
+ use Illuminate\Support\Facades\Crypt;
18
+ use Illuminate\Support\Facades\DB;
19
+ use Illuminate\Support\Facades\Schema;
20
+
21
+ if (!function_exists('Fleetbase\\FleetOps\\Support\\Telematics\\Telemetry\\broadcast')) {
22
+ eval('namespace Fleetbase\\FleetOps\\Support\\Telematics\\Telemetry; function broadcast($event) { $GLOBALS["afaqy_broadcasts"][] = $event; }');
23
+ }
24
+ if (!function_exists('Fleetbase\\FleetOps\\Support\\Telematics\\broadcast')) {
25
+ eval('namespace Fleetbase\\FleetOps\\Support\\Telematics; function broadcast($event) { $GLOBALS["afaqy_broadcasts"][] = $event; }');
26
+ }
27
+
28
+ function mixedFleetDeliveryFixture(): Telematic
29
+ {
30
+ $pdo = new PDO('sqlite::memory:');
31
+ $geometry = function ($text, $srid = 0, ...$rest) {
32
+ preg_match('/POINT\(([\d.e+\-]+) ([\d.e+\-]+)\)/i', $text, $coordinates);
33
+
34
+ return pack('V', $srid) . chr(1) . pack('V', 1) . pack('e2', (float) $coordinates[1], (float) $coordinates[2]);
35
+ };
36
+ $pdo->sqliteCreateFunction('ST_GeomFromText', $geometry);
37
+ $pdo->sqliteCreateFunction('ST_PointFromText', $geometry);
38
+ $connection = new SQLiteConnection($pdo, '', '', ['name' => 'mysql']);
39
+ $connection->setTransactionManager(new Illuminate\Database\DatabaseTransactionsManager());
40
+ $resolver = new ConnectionResolver(['mysql' => $connection]);
41
+ $resolver->setDefaultConnection('mysql');
42
+ Model::setConnectionResolver($resolver);
43
+ Model::unsetEventDispatcher();
44
+ Model::clearBootedModels();
45
+ app()->instance('db', new class($connection) {
46
+ public function __construct(public $connection)
47
+ {
48
+ }
49
+
50
+ public function connection($name = null)
51
+ {
52
+ return $this->connection;
53
+ }
54
+
55
+ public function __call($method, $args)
56
+ {
57
+ return $this->connection->{$method}(...$args);
58
+ }
59
+ });
60
+ app()->instance('db.schema', $connection->getSchemaBuilder());
61
+ DB::clearResolvedInstance('db');
62
+ Schema::clearResolvedInstance('db.schema');
63
+ app()->instance('encrypter', new Illuminate\Encryption\Encrypter(str_repeat('m', 32), 'aes-256-cbc'));
64
+ Crypt::clearResolvedInstance('encrypter');
65
+ Cache::flush();
66
+ Carbon::setTestNow('2026-09-15 12:00:00 UTC');
67
+ config(['telematics.afaqy.webhooks_enabled' => true, 'telematics.afaqy.polling_enabled' => true]);
68
+ config(['telematics.providers' => (require __DIR__ . '/../../../config/telematics.php')['providers']]);
69
+ app()->instance(TelematicProviderRegistry::class, new TelematicProviderRegistry());
70
+ $GLOBALS['afaqy_broadcasts'] = [];
71
+ // This persistence fixture excludes model listeners and external transports.
72
+ foreach ([
73
+ 'telematics' => ['uuid', 'public_id', 'company_uuid', 'provider', 'status', 'meta', 'credentials'],
74
+ 'vehicles' => ['uuid', 'public_id', 'company_uuid', 'name', 'plate_number', 'location', 'speed', 'heading', 'altitude', 'odometer', 'online', 'telematics'],
75
+ 'sensors' => ['uuid', 'public_id', 'company_uuid', 'device_uuid', 'telematic_uuid', 'type', 'internal_id', 'name', 'unit', 'last_value', 'last_reading_at', 'last_position', 'status', 'meta'],
76
+ 'positions' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'coordinates', 'speed', 'heading', 'bearing', 'altitude', 'order_uuid', 'destination_uuid'],
77
+ 'devices' => ['uuid', 'public_id', 'company_uuid', 'telematic_uuid', 'device_id', 'internal_id', 'name', 'model', 'provider', 'type', 'imei', 'imsi', 'serial_number', 'firmware_version', 'last_position', 'last_online_at', 'online', 'status', 'meta', 'attachable_uuid', 'attachable_type'],
78
+ 'device_events' => ['uuid', 'public_id', 'company_uuid', 'device_uuid', 'event_type', 'severity', 'message', 'provider', 'ident', 'code', 'state', 'reason', 'occurred_at', 'data', 'payload', '_key', 'meta', 'location'],
79
+ ] as $table => $columns) {
80
+ Schema::create($table, function ($schema) use ($columns) {
81
+ $schema->increments('id');
82
+ foreach ($columns as $column) {
83
+ $schema->text($column)->nullable();
84
+ }
85
+ $schema->timestamps();
86
+ $schema->timestamp('deleted_at')->nullable();
87
+ $schema->index('uuid');
88
+ });
89
+ }
90
+ (require __DIR__ . '/../../../migrations/2026_09_15_000001_create_telematic_telemetry_tables.php')->up();
91
+ Schema::table('device_events', fn ($table) => $table->index('_key'));
92
+ DB::table('telematics')->insert(['uuid' => 'integration-1', 'public_id' => 'telematic_1', 'company_uuid' => 'company-1', 'provider' => 'afaqy', 'status' => 'active', 'meta' => '{}']);
93
+
94
+ return Telematic::withoutGlobalScopes()->first();
95
+ }
96
+
97
+ function mixedFleetPosition(string $id, float $lat): array
98
+ {
99
+ return ['_id' => $id, 'name' => $id, 'last_update' => ['dtt' => '2026-09-15 11:59:00', 'dts' => '2026-09-15 11:59:01', 'lat' => $lat, 'lng' => 46.7, 'spd' => 20, 'acc' => 1]];
100
+ }
101
+
102
+ function processMixedFleetDelivery(array $payload, string $source = 'poll'): object
103
+ {
104
+ DB::table('telematic_sync_runs')->insert(['uuid' => 'run-1', 'telematic_uuid' => 'integration-1', 'status' => 'ingesting', 'created_at' => now(), 'updated_at' => now()]);
105
+ DB::table('telematic_deliveries')->insert([
106
+ 'uuid' => 'delivery-1', 'telematic_uuid' => 'integration-1', 'run_uuid' => 'run-1', 'source' => $source,
107
+ 'status' => 'pending', 'payload_hash' => hash('sha256', json_encode($payload)),
108
+ 'payload' => Crypt::encryptString(json_encode($payload)), 'received_at' => now(), 'available_at' => now(),
109
+ 'created_at' => now(), 'updated_at' => now(),
110
+ ]);
111
+ (new ProcessTelematicDelivery('delivery-1'))->handle(new Ingestor(), new TelematicService(new TelematicProviderRegistry()));
112
+
113
+ return DB::table('telematic_deliveries')->where('uuid', 'delivery-1')->first();
114
+ }
115
+
116
+ afterEach(fn () => Carbon::setTestNow());
117
+
118
+ test('polling applies valid positions on both sides of a unit without a GPS fix', function ($missingUpdate) {
119
+ mixedFleetDeliveryFixture();
120
+ foreach (['first-unit', 'never-connected', 'last-unit'] as $id) {
121
+ DB::table('devices')->insert(['uuid' => 'device-' . $id, 'public_id' => 'device_' . $id, 'telematic_uuid' => 'integration-1', 'company_uuid' => 'company-1', 'device_id' => $id, 'name' => $id, 'status' => 'offline', 'meta' => '{}']);
122
+ }
123
+ $middle = ['_id' => 'never-connected', 'name' => 'Inactive tracker', 'active' => false] + $missingUpdate;
124
+ $payload = [mixedFleetPosition('first-unit', 24.1), $middle, mixedFleetPosition('last-unit', 24.3)];
125
+ $delivery = processMixedFleetDelivery($payload);
126
+
127
+ // A polled unit without a fix is inventory state, not a failed delivery to quarantine and replay.
128
+ expect($delivery->status)->toBe('processed')
129
+ ->and((int) $delivery->applied)->toBe(2)
130
+ ->and((int) $delivery->invalid_count)->toBe(1)
131
+ ->and((int) $delivery->failed)->toBe(0)
132
+ ->and((int) $delivery->attempts)->toBe(1);
133
+ expect(DeviceEvent::withoutGlobalScopes()->count())->toBe(2);
134
+ foreach (['first-unit' => 24.1, 'last-unit' => 24.3] as $id => $latitude) {
135
+ $device = Device::withoutGlobalScopes()->where('device_id', $id)->firstOrFail();
136
+ expect($device->last_position->getLat())->toBe($latitude)
137
+ ->and($device->last_online_at->toISOString())->toBe('2026-09-15T11:59:01.000000Z')
138
+ ->and(data_get($device->meta, 'telemetry.position_at'))->toBe('2026-09-15T11:59:00.000000Z');
139
+ }
140
+ $inactive = Device::withoutGlobalScopes()->where('device_id', 'never-connected')->firstOrFail();
141
+ expect($inactive->last_online_at)->toBeNull()
142
+ ->and(data_get($inactive->meta, 'telemetry.position_at'))->toBeNull();
143
+ $run = DB::table('telematic_sync_runs')->where('uuid', 'run-1')->first();
144
+ expect($run->status)->toBe('completed')->and((int) $run->applied)->toBe(2)->and((int) $run->failed)->toBe(0);
145
+ expect(json_decode(Crypt::decryptString($delivery->payload), true))->toBe($payload);
146
+ })->with(['missing last_update' => [[]], 'null last_update' => [['last_update' => null]]]);
147
+
148
+ test('unknown vehicle events remain quarantined as external deliveries', function () {
149
+ mixedFleetDeliveryFixture();
150
+ $event = mixedFleetPosition('event-unit', 24.1) + ['event' => 'zone_entry'];
151
+ $delivery = processMixedFleetDelivery([$event], 'webhook');
152
+
153
+ expect($delivery->status)->toBe('quarantined')
154
+ ->and((int) $delivery->applied)->toBe(0)
155
+ ->and($delivery->error)->toContain('Unsupported or unreadable position payload');
156
+ expect(Device::withoutGlobalScopes()->count())->toBe(0);
157
+ expect(DeviceEvent::withoutGlobalScopes()->count())->toBe(0);
158
+ });
159
+
160
+ test('poll deliveries still reject invalid internal envelopes', function () {
161
+ mixedFleetDeliveryFixture();
162
+ $delivery = processMixedFleetDelivery(['data' => [mixedFleetPosition('unit', 24.1)]]);
163
+
164
+ expect($delivery->status)->toBe('quarantined')->and((int) $delivery->applied)->toBe(0);
165
+ expect(Device::withoutGlobalScopes()->count())->toBe(0);
166
+ });