@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,461 @@
1
+ <?php
2
+
3
+ require_once __DIR__ . '/../../Support/TelemetryTestEnvironment.php';
4
+ require_once __DIR__ . '/../../Support/ExampleTelemetryProvider.php';
5
+
6
+ use Fleetbase\FleetOps\Jobs\PollTelematicTelemetry;
7
+ use Fleetbase\FleetOps\Jobs\ProcessTelematicDelivery;
8
+ use Fleetbase\FleetOps\Jobs\SyncTelematicDevicesJob;
9
+ use Fleetbase\FleetOps\Models\Device;
10
+ use Fleetbase\FleetOps\Models\DeviceEvent;
11
+ use Fleetbase\FleetOps\Models\Telematic;
12
+ use Fleetbase\FleetOps\Support\Telematics\TelematicProviderRegistry;
13
+ use Fleetbase\FleetOps\Support\Telematics\TelematicService;
14
+ use Fleetbase\FleetOps\Support\Telematics\Telemetry\Inbox;
15
+ use Fleetbase\FleetOps\Support\Telematics\Telemetry\Ingestor;
16
+ use Illuminate\Database\ConnectionResolver;
17
+ use Illuminate\Database\Eloquent\Model;
18
+ use Illuminate\Database\SQLiteConnection;
19
+ use Illuminate\Support\Carbon;
20
+ use Illuminate\Support\Facades\Cache;
21
+ use Illuminate\Support\Facades\Crypt;
22
+ use Illuminate\Support\Facades\DB;
23
+ use Illuminate\Support\Facades\Schema;
24
+ use Illuminate\Validation\ValidationException;
25
+
26
+ if (!function_exists('Fleetbase\\FleetOps\\Support\\Telematics\\Telemetry\\broadcast')) {
27
+ eval('namespace Fleetbase\\FleetOps\\Support\\Telematics\\Telemetry; function broadcast($event) { $GLOBALS["afaqy_broadcasts"][] = $event; }');
28
+ }
29
+ if (!function_exists('Fleetbase\\FleetOps\\Support\\Telematics\\broadcast')) {
30
+ eval('namespace Fleetbase\\FleetOps\\Support\\Telematics; function broadcast($event) { $GLOBALS["afaqy_broadcasts"][] = $event; }');
31
+ }
32
+
33
+ function manualTelemetryFixture(): Telematic
34
+ {
35
+ $pdo = new PDO('sqlite::memory:');
36
+ $geometry = function ($text, $srid = 0, ...$rest) {
37
+ preg_match('/POINT\(([\d.e+\-]+) ([\d.e+\-]+)\)/i', $text, $coordinates);
38
+
39
+ return pack('V', $srid) . chr(1) . pack('V', 1) . pack('e2', (float) $coordinates[1], (float) $coordinates[2]);
40
+ };
41
+ $pdo->sqliteCreateFunction('ST_GeomFromText', $geometry);
42
+ $pdo->sqliteCreateFunction('ST_PointFromText', $geometry);
43
+ $connection = new SQLiteConnection($pdo, '', '', ['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('m', 32), 'aes-256-cbc'));
69
+ Crypt::clearResolvedInstance('encrypter');
70
+ Cache::flush();
71
+ Carbon::setTestNow('2026-09-15 12:00:00 UTC');
72
+ config(['telematics.afaqy.webhooks_enabled' => true, 'telematics.afaqy.polling_enabled' => true]);
73
+ config(['telematics.providers' => (require __DIR__ . '/../../../config/telematics.php')['providers']]);
74
+ app()->instance(TelematicProviderRegistry::class, new TelematicProviderRegistry());
75
+ $GLOBALS['afaqy_broadcasts'] = [];
76
+ // This persistence fixture excludes model listeners and external transports.
77
+ foreach ([
78
+ 'telematics' => ['uuid', 'public_id', 'company_uuid', 'provider', 'status', 'meta', 'credentials'],
79
+ 'vehicles' => ['uuid', 'public_id', 'company_uuid', 'name', 'plate_number', 'location', 'speed', 'heading', 'altitude', 'odometer', 'online', 'telematics'],
80
+ 'sensors' => ['uuid', 'public_id', 'company_uuid', 'device_uuid', 'telematic_uuid', 'type', 'internal_id', 'name', 'unit', 'last_value', 'last_reading_at', 'last_position', 'status', 'meta'],
81
+ 'positions' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'coordinates', 'speed', 'heading', 'bearing', 'altitude', 'order_uuid', 'destination_uuid'],
82
+ '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'],
83
+ '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'],
84
+ ] as $table => $columns) {
85
+ Schema::create($table, function ($schema) use ($columns) {
86
+ $schema->increments('id');
87
+ foreach ($columns as $column) {
88
+ $schema->text($column)->nullable();
89
+ }
90
+ $schema->timestamps();
91
+ $schema->timestamp('deleted_at')->nullable();
92
+ $schema->index('uuid');
93
+ });
94
+ }
95
+ (require __DIR__ . '/../../../migrations/2026_09_15_000001_create_telematic_telemetry_tables.php')->up();
96
+ Schema::table('device_events', fn ($table) => $table->index('_key'));
97
+ DB::table('telematics')->insert(['uuid' => 'integration-1', 'public_id' => 'telematic_1', 'company_uuid' => 'company-1', 'provider' => 'example-manual', 'status' => 'active', 'meta' => '{}']);
98
+
99
+ return Telematic::withoutGlobalScopes()->first();
100
+ }
101
+
102
+ class ManualTelemetryProvider extends ExampleTelemetryProvider
103
+ {
104
+ public array $requests = [];
105
+ public bool $fail = false;
106
+ public ?int $failOnCall = null;
107
+
108
+ public function telemetryOptions(): array
109
+ {
110
+ return array_merge(parent::telemetryOptions(), ['manual_batch_sync' => true, 'batch_size' => 1, 'webhooks_enabled' => false]);
111
+ }
112
+
113
+ public function fetchDevices(array $options = []): array
114
+ {
115
+ $this->requests[] = $options;
116
+ if ($this->fail || $this->failOnCall === count($this->requests)) {
117
+ throw new RuntimeException('Provider unavailable');
118
+ }
119
+
120
+ return parent::fetchDevices($options);
121
+ }
122
+ }
123
+
124
+ class ManualTelemetryRegistry extends TelematicProviderRegistry
125
+ {
126
+ public function __construct(public ManualTelemetryProvider $provider)
127
+ {
128
+ }
129
+
130
+ public function resolve(string $key): Fleetbase\FleetOps\Contracts\TelematicProviderInterface
131
+ {
132
+ return $this->provider;
133
+ }
134
+ }
135
+
136
+ function manualTelemetrySetup(): array
137
+ {
138
+ $connection = manualTelemetryFixture();
139
+ $provider = new ManualTelemetryProvider();
140
+ $registry = new ManualTelemetryRegistry($provider);
141
+ app()->instance(TelematicProviderRegistry::class, $registry);
142
+ config(['cache.default' => 'array', 'cache.stores.array' => ['driver' => 'array']]);
143
+ Cache::swap(new Illuminate\Cache\CacheManager(app()));
144
+ DB::table('devices')->insert(['uuid' => 'device-1', 'public_id' => 'device_1', 'company_uuid' => 'company-1', 'telematic_uuid' => $connection->uuid, 'device_id' => 'unit-1', 'name' => 'Tracker', 'status' => 'offline', 'meta' => '{}']);
145
+ $GLOBALS['manual_telemetry_jobs'] = [];
146
+ $GLOBALS['manual_telemetry_fail_dispatch'] = false;
147
+ $dispatcher = new class(app()) extends Illuminate\Bus\Dispatcher {
148
+ public function dispatch($command)
149
+ {
150
+ if ($GLOBALS['manual_telemetry_fail_dispatch']) {
151
+ throw new RuntimeException('Broker unavailable');
152
+ }
153
+ $GLOBALS['manual_telemetry_jobs'][] = $command;
154
+
155
+ return null;
156
+ }
157
+ };
158
+ app()->instance(Illuminate\Contracts\Bus\Dispatcher::class, $dispatcher);
159
+ ExampleTelemetryProvider::$connections = 0;
160
+ ExampleTelemetryProvider::$pages = [];
161
+
162
+ return [$connection, $provider, $registry, new TelematicService($registry)];
163
+ }
164
+
165
+ function manualTelemetrySample(string $time = '2026-09-15T11:59:00Z', float $latitude = 24.0): array
166
+ {
167
+ return ['tracker' => 'unit-1', 'measured' => $time, 'received' => $time, 'point' => [46.7, $latitude]];
168
+ }
169
+
170
+ function manualTelemetryProcessPending(TelematicService $service): void
171
+ {
172
+ foreach (DB::table('telematic_deliveries')->where('status', 'pending')->pluck('uuid') as $id) {
173
+ (new ProcessTelematicDelivery($id))->handle(new Ingestor(), $service);
174
+ }
175
+ }
176
+
177
+ afterEach(function () {
178
+ Carbon::setTestNow();
179
+ unset($GLOBALS['telemetry_test_clock']);
180
+ });
181
+
182
+ test('manual telemetry sync queues once and completes only after durable ingestion', function () {
183
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
184
+ $id = $service->discoverDevices($connection, ['filters' => ['group' => 4], 'limit' => 2]);
185
+ expect($service->discoverDevices($connection))->toBe($id);
186
+ expect(count($GLOBALS['manual_telemetry_jobs']))->toBe(1);
187
+ expect(ExampleTelemetryProvider::$connections)->toBe(0);
188
+ $job = $GLOBALS['manual_telemetry_jobs'][0];
189
+ expect($job)->toBeInstanceOf(PollTelematicTelemetry::class)->and($job->queue)->toBe('default')->and($job->timeout)->toBeLessThan(90);
190
+ expect(data_get($connection->fresh()->meta, 'last_sync_result'))->toBe('queued');
191
+ ExampleTelemetryProvider::$pages = [['devices' => [manualTelemetrySample()], 'has_more' => false, 'next_cursor' => null]];
192
+ $job->handle($registry, new Inbox());
193
+ expect($provider->requests[0]['filters'])->toBe(['group' => 4])->and($provider->requests[0]['refresh_inventory'])->toBeTrue();
194
+ expect(data_get($connection->fresh()->meta, 'last_sync_phase'))->toBe('ingesting');
195
+ expect(DeviceEvent::withoutGlobalScopes()->count())->toBe(0);
196
+ manualTelemetryProcessPending($service);
197
+ $fresh = $connection->fresh();
198
+ expect($fresh->status)->toBe('active')->and(data_get($fresh->meta, 'last_sync_result'))->toBe('success')
199
+ ->and(data_get($fresh->meta, 'last_sync_total'))->toBe(1)->and(data_get($fresh->meta, 'last_sync_failed_total'))->toBe(0);
200
+ });
201
+
202
+ test('old queued discovery jobs delegate without entering their legacy lock or HTTP work', function () {
203
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
204
+ $lock = Cache::lock('fleetops:sync-telematic-devices:' . $connection->uuid, 3660);
205
+ $lock->get();
206
+ try {
207
+ (new SyncTelematicDevicesJob($connection, [], 'old-job'))->handle($registry, $service);
208
+ expect(count($GLOBALS['manual_telemetry_jobs']))->toBe(1)
209
+ ->and($GLOBALS['manual_telemetry_jobs'][0]->manualJobId)->toBe('old-job')
210
+ ->and(ExampleTelemetryProvider::$connections)->toBe(0);
211
+ } finally {
212
+ $lock->release();
213
+ }
214
+ });
215
+
216
+ test('old queued discovery jobs finish quietly when a scheduled poll already holds the request', function () {
217
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
218
+ expect(Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue::dispatch(new PollTelematicTelemetry($connection->uuid)))->toBeTrue();
219
+ (new SyncTelematicDevicesJob($connection, [], 'old-job'))->handle($registry, $service);
220
+ $fresh = $connection->fresh();
221
+ // A thrown ValidationException would fail the tries=1 job and mark this connection as errored.
222
+ expect(count($GLOBALS['manual_telemetry_jobs']))->toBe(1)
223
+ ->and($fresh->status)->toBe('active')
224
+ ->and(data_get($fresh->meta, 'last_sync_job_id'))->toBeNull();
225
+ });
226
+
227
+ test('broker failure leaves the request unqueued and permits a later manual retry', function () {
228
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
229
+ $GLOBALS['manual_telemetry_fail_dispatch'] = true;
230
+ expect(fn () => $service->discoverDevices($connection))->toThrow(RuntimeException::class, 'Broker unavailable');
231
+ expect($connection->fresh()->status)->toBe('active')->and(data_get($connection->fresh()->meta, 'last_sync_result'))->toBeNull();
232
+ $GLOBALS['manual_telemetry_fail_dispatch'] = false;
233
+ $service->discoverDevices($connection);
234
+ expect(count($GLOBALS['manual_telemetry_jobs']))->toBe(1)->and(data_get($connection->fresh()->meta, 'last_sync_result'))->toBe('queued');
235
+ });
236
+
237
+ test('disabled or tenantless connections cannot start manual or scheduled polling', function ($attributes) {
238
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
239
+ DB::table('telematics')->update($attributes);
240
+ $connection->refresh();
241
+ expect(fn () => $service->discoverDevices($connection))->toThrow(ValidationException::class);
242
+ (new PollTelematicTelemetry($connection->uuid))->handle($registry, new Inbox());
243
+ expect(count($GLOBALS['manual_telemetry_jobs']))->toBe(0)->and(ExampleTelemetryProvider::$connections)->toBe(0);
244
+ })->with([[['status' => 'disabled']], [['company_uuid' => null]]]);
245
+
246
+ test('manual fetch failures remain retryable and successful retry finishes the matching request', function () {
247
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
248
+ $service->discoverDevices($connection);
249
+ $job = $GLOBALS['manual_telemetry_jobs'][0];
250
+ $provider->fail = true;
251
+ expect(fn () => $job->handle($registry, new Inbox()))->toThrow(RuntimeException::class);
252
+ expect(data_get($connection->fresh()->meta, 'last_sync_result'))->toBe('retrying');
253
+ $job->failed(new RuntimeException('Exhausted'));
254
+ expect($connection->fresh()->status)->toBe('error');
255
+ $provider->fail = false;
256
+ ExampleTelemetryProvider::$pages = [['devices' => [manualTelemetrySample()], 'has_more' => false, 'next_cursor' => null]];
257
+ $job->handle($registry, new Inbox());
258
+ manualTelemetryProcessPending($service);
259
+ expect($connection->fresh()->status)->toBe('active')->and(data_get($connection->fresh()->meta, 'last_sync_result'))->toBe('success');
260
+ });
261
+
262
+ test('older delivery completion cannot finalize a newer manual request', function () {
263
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
264
+ $service->discoverDevices($connection);
265
+ ExampleTelemetryProvider::$pages = [['devices' => [manualTelemetrySample()], 'has_more' => false, 'next_cursor' => null]];
266
+ $GLOBALS['manual_telemetry_jobs'][0]->handle($registry, new Inbox());
267
+ $fresh = $connection->fresh();
268
+ $fresh->meta = array_merge($fresh->meta, ['last_sync_job_id' => 'new-request', 'last_sync_result' => 'queued']);
269
+ $fresh->save();
270
+ manualTelemetryProcessPending($service);
271
+ expect(data_get($connection->fresh()->meta, 'last_sync_job_id'))->toBe('new-request')
272
+ ->and(data_get($connection->fresh()->meta, 'last_sync_result'))->toBe('queued');
273
+ });
274
+
275
+ test('scheduled reconciliation deduplicates current samples and retains newest position', function () {
276
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
277
+ foreach ([manualTelemetrySample(), manualTelemetrySample(), manualTelemetrySample('2026-09-15T11:40:00Z', 10)] as $sample) {
278
+ ExampleTelemetryProvider::$pages = [['devices' => [$sample], 'has_more' => false, 'next_cursor' => null]];
279
+ (new PollTelematicTelemetry($connection->uuid))->handle($registry, new Inbox());
280
+ manualTelemetryProcessPending($service);
281
+ }
282
+ expect(DeviceEvent::withoutGlobalScopes()->count())->toBe(2)
283
+ ->and(Device::withoutGlobalScopes()->first()->last_position->getLat())->toBe(24.0);
284
+ });
285
+
286
+ test('polling budget includes durable inbox writes and never extends to the Redis reservation', function () {
287
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
288
+ ExampleTelemetryProvider::$pages = [['devices' => [manualTelemetrySample()], 'has_more' => false, 'next_cursor' => null]];
289
+ $GLOBALS['telemetry_test_clock'] = [1000.0, 1000.0, 1001.0, 1001.0, 1060.0];
290
+ expect(fn () => (new PollTelematicTelemetry($connection->uuid))->handle($registry, new Inbox()))->toThrow(RuntimeException::class, 'time budget');
291
+ expect(DB::table('telematic_sync_runs')->value('status'))->toBe('incomplete');
292
+ expect(DB::table('telematic_deliveries')->count())->toBe(1);
293
+ });
294
+
295
+ class ManualPollingQueueJob extends Illuminate\Queue\Jobs\SyncJob
296
+ {
297
+ public int $reservations = 1;
298
+ public int $releases = 0;
299
+ public int $failures = 0;
300
+ public ?PollTelematicTelemetry $command = null;
301
+
302
+ public function attempts()
303
+ {
304
+ return $this->reservations;
305
+ }
306
+
307
+ public function release($delay = 0)
308
+ {
309
+ $this->releases++;
310
+ }
311
+
312
+ public function fail($error = null)
313
+ {
314
+ $this->failures++;
315
+ $this->command?->failed($error);
316
+ }
317
+ }
318
+
319
+ function manualPollingWorker(): Illuminate\Queue\Worker
320
+ {
321
+ $reflection = new ReflectionClass(Illuminate\Queue\Worker::class);
322
+ $worker = $reflection->newInstanceWithoutConstructor();
323
+ $reflection->getProperty('cache')->setValue($worker, Cache::store());
324
+
325
+ return $worker;
326
+ }
327
+
328
+ function manualPollingQueuePayload(PollTelematicTelemetry $job): ManualPollingQueueJob
329
+ {
330
+ $queueJob = new ManualPollingQueueJob(app(), json_encode([
331
+ 'uuid' => 'manual-poll-queue-job', 'maxTries' => $job->tries,
332
+ 'maxExceptions' => $job->maxExceptions, 'retryUntil' => $job->retryUntil()->getTimestamp(),
333
+ ]), 'test', 'default');
334
+ $queueJob->command = $job;
335
+ $job->setJob($queueJob);
336
+
337
+ return $queueJob;
338
+ }
339
+
340
+ test('healthy inbox waiting can exceed five reservations without exhausting provider retries', function () {
341
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
342
+ $service->discoverDevices($connection);
343
+ $job = $GLOBALS['manual_telemetry_jobs'][0];
344
+ $queueJob = manualPollingQueuePayload($job);
345
+ (new Inbox())->accept($connection, [manualTelemetrySample()], 'poll');
346
+ $worker = manualPollingWorker();
347
+ $check = new ReflectionMethod($worker, 'markJobAsFailedIfAlreadyExceedsMaxAttempts');
348
+ for ($attempt = 1; $attempt <= 10; $attempt++) {
349
+ $queueJob->reservations = $attempt;
350
+ $check->invoke($worker, 'test', $queueJob, 1);
351
+ $job->handle($registry, new Inbox());
352
+ Carbon::setTestNow(now()->addSeconds(15));
353
+ }
354
+ expect($queueJob->releases)->toBe(10)->and($queueJob->failures)->toBe(0)
355
+ ->and(ExampleTelemetryProvider::$connections)->toBe(0)
356
+ ->and(data_get($connection->fresh()->meta, 'last_sync_result'))->toBe('retrying');
357
+ });
358
+
359
+ test('five actual provider exceptions fail the manual request independently of waiting reservations', function () {
360
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
361
+ $service->discoverDevices($connection);
362
+ $job = $GLOBALS['manual_telemetry_jobs'][0];
363
+ $queueJob = manualPollingQueuePayload($job);
364
+ $worker = manualPollingWorker();
365
+ $check = new ReflectionMethod($worker, 'markJobAsFailedIfWillExceedMaxExceptions');
366
+ $provider->fail = true;
367
+ for ($attempt = 1; $attempt <= 5; $attempt++) {
368
+ expect(fn () => $job->handle($registry, new Inbox()))->toThrow(RuntimeException::class, 'Provider unavailable');
369
+ $check->invoke($worker, 'test', $queueJob, new RuntimeException('Provider unavailable'));
370
+ expect($queueJob->failures)->toBe($attempt === 5 ? 1 : 0);
371
+ }
372
+ expect($connection->fresh()->status)->toBe('error')
373
+ ->and(data_get($connection->fresh()->meta, 'last_sync_result'))->toBe('failed');
374
+ });
375
+
376
+ test('absolute retry deadline survives serialization and stops stalled requests', function () {
377
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
378
+ $service->discoverDevices($connection);
379
+ $job = unserialize(serialize($GLOBALS['manual_telemetry_jobs'][0]));
380
+ $deadline = $job->retryUntil()->getTimestamp();
381
+ $queueJob = manualPollingQueuePayload($job);
382
+ Carbon::setTestNow(now()->addMinutes(16));
383
+ expect($job->retryUntil()->getTimestamp())->toBe($deadline);
384
+ $job->handle($registry, new Inbox());
385
+ expect($queueJob->failures)->toBe(1)->and(ExampleTelemetryProvider::$connections)->toBe(0)
386
+ ->and(data_get($connection->fresh()->meta, 'last_sync_result'))->toBe('failed');
387
+ });
388
+
389
+ test('legacy serialized poll jobs share a fixed fallback retry deadline', function () {
390
+ [$connection] = manualTelemetrySetup();
391
+ $job = new PollTelematicTelemetry($connection->uuid);
392
+ $job->retryDeadline = null;
393
+ $deadline = $job->retryUntil()->getTimestamp();
394
+ Carbon::setTestNow(now()->addMinutes(5));
395
+ $restored = new PollTelematicTelemetry($connection->uuid);
396
+ $restored->retryDeadline = null;
397
+ expect($restored->retryUntil()->getTimestamp())->toBe($deadline);
398
+ });
399
+
400
+ test('incomplete sweep deliveries do not finish a manual request awaiting provider retry', function () {
401
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
402
+ $id = $service->discoverDevices($connection);
403
+ ExampleTelemetryProvider::$pages = [['devices' => [manualTelemetrySample()], 'has_more' => true, 'next_cursor' => 1]];
404
+ $provider->failOnCall = 2;
405
+ $job = $GLOBALS['manual_telemetry_jobs'][0];
406
+ expect(fn () => $job->handle($registry, new Inbox()))->toThrow(RuntimeException::class);
407
+ manualTelemetryProcessPending($service);
408
+ expect(data_get($connection->fresh()->meta, 'last_sync_result'))->toBe('retrying');
409
+ expect($service->discoverDevices($connection->fresh()))->toBe($id);
410
+ expect(DB::table('telematic_sync_runs')->value('status'))->toBe('incomplete');
411
+ });
412
+
413
+ test('expired scheduled polls stop without provider work when no queue job is attached', function () {
414
+ [$connection, $provider, $registry] = manualTelemetrySetup();
415
+ $job = new PollTelematicTelemetry($connection->uuid);
416
+ Carbon::setTestNow(now()->addMinutes(16));
417
+ $job->handle($registry, new Inbox());
418
+ // Scheduled polls have no manual request to fail; the next tick schedules a fresh attempt.
419
+ expect(ExampleTelemetryProvider::$connections)->toBe(0)
420
+ ->and(DB::table('telematic_sync_runs')->count())->toBe(0)
421
+ ->and($connection->fresh()->status)->toBe('active');
422
+ });
423
+
424
+ test('manual polls release instead of dropping the request while another sweep holds the poll lock', function () {
425
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
426
+ $service->discoverDevices($connection);
427
+ $job = $GLOBALS['manual_telemetry_jobs'][0];
428
+ $queueJob = manualPollingQueuePayload($job);
429
+ $lock = Cache::lock('telemetry:poll:' . $connection->uuid, 85);
430
+ expect($lock->get())->toBeTrue();
431
+ try {
432
+ $job->handle($registry, new Inbox());
433
+ } finally {
434
+ $lock->release();
435
+ }
436
+ expect($queueJob->releases)->toBe(1)->and(ExampleTelemetryProvider::$connections)->toBe(0)
437
+ ->and(DB::table('telematic_sync_runs')->count())->toBe(0);
438
+ });
439
+
440
+ test('superseded manual polls neither report progress nor fail the newer request', function () {
441
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
442
+ $current = $service->discoverDevices($connection);
443
+ $stale = new PollTelematicTelemetry($connection->uuid, 'superseded-job');
444
+ ExampleTelemetryProvider::$pages = [['devices' => [manualTelemetrySample()], 'has_more' => false, 'next_cursor' => null]];
445
+ $stale->handle($registry, new Inbox());
446
+ $stale->failed(new RuntimeException('Provider unavailable'));
447
+ $fresh = $connection->fresh();
448
+ expect(data_get($fresh->meta, 'last_sync_job_id'))->toBe($current)
449
+ ->and(data_get($fresh->meta, 'last_sync_result'))->toBe('queued')
450
+ ->and(data_get($fresh->meta, 'last_sync_run_uuid'))->toBeNull()
451
+ ->and($fresh->status)->toBe('synchronizing');
452
+ });
453
+
454
+ test('manual sync re-checks the locked connection before queueing', function () {
455
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
456
+ // The caller's model is stale: the connection was disabled after it was loaded.
457
+ DB::table('telematics')->update(['status' => 'disabled']);
458
+ expect(fn () => $service->queueTelemetrySync($connection))->toThrow(ValidationException::class);
459
+ expect(count($GLOBALS['manual_telemetry_jobs']))->toBe(0)
460
+ ->and(DB::table('telematics')->value('status'))->toBe('disabled');
461
+ });