@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,335 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Exceptions\TelematicProviderException;
4
+ use Fleetbase\FleetOps\Exceptions\TelematicRateLimitExceededException;
5
+ use Fleetbase\FleetOps\Support\Telematics\Providers\SafeeProvider;
6
+ use Fleetbase\FleetOps\Support\Telematics\Safee\Transport;
7
+ use Fleetbase\FleetOps\Support\Telematics\Telemetry\Sample;
8
+ use Illuminate\Encryption\Encrypter;
9
+ use Illuminate\Support\Carbon;
10
+ use Illuminate\Support\Facades\Cache;
11
+ use Illuminate\Support\Facades\Crypt;
12
+ use Illuminate\Support\Facades\Http;
13
+
14
+ class SafeeContractTransport extends Transport
15
+ {
16
+ public static float $clock = 1000;
17
+
18
+ public function reserve(): void
19
+ {
20
+ $this->reserveRequest(self::$clock + 5);
21
+ }
22
+
23
+ protected function time(): float
24
+ {
25
+ return self::$clock;
26
+ }
27
+
28
+ protected function wallTime(): float
29
+ {
30
+ return self::$clock;
31
+ }
32
+ }
33
+
34
+ class SafeeLiveContractProvider extends SafeeProvider
35
+ {
36
+ public function credentials(array $credentials): void
37
+ {
38
+ $this->credentials = $credentials;
39
+ $this->prepareAuthentication();
40
+ }
41
+
42
+ public function tokenCacheKey(): string
43
+ {
44
+ return 'safee:token:' . $this->transport()->fingerprint();
45
+ }
46
+
47
+ protected function transport(): Transport
48
+ {
49
+ return $this->transport ??= new SafeeContractTransport($this->baseUrl, $this->credentials);
50
+ }
51
+
52
+ protected function requestTime(): float
53
+ {
54
+ return SafeeContractTransport::$clock;
55
+ }
56
+ }
57
+
58
+ function safeeLiveProvider(array $credentials = []): SafeeLiveContractProvider
59
+ {
60
+ $provider = new SafeeLiveContractProvider();
61
+ $provider->credentials(array_replace(['server_uri' => 'https://safee.contract.test', 'access_token' => 'fixture-token'], $credentials));
62
+
63
+ return $provider;
64
+ }
65
+
66
+ function safeeLiveCredentials(): array
67
+ {
68
+ return ['access_token' => null, 'realm_id' => 'fixture', 'client_id' => 'fixture-client', 'client_secret' => 'fixture-secret', 'username' => 'fixture-user', 'password' => 'fixture-password'];
69
+ }
70
+
71
+ function safeeLiveState(int $id, array $overrides = []): array
72
+ {
73
+ return array_replace(['id' => 90000 + $id, 'vehicle' => ['id' => $id], 'date' => '2026-09-17T10:00:00Z', 'position' => ['lat' => 24, 'lon' => 46, 'alt' => 615], 'speed' => 12, 'heading' => 90, 'odometer' => '1234.5'], $overrides);
74
+ }
75
+
76
+ beforeEach(function () {
77
+ app()->instance('encrypter', new Encrypter(str_repeat('s', 32), 'aes-256-cbc'));
78
+ Crypt::clearResolvedInstance('encrypter');
79
+ Cache::flush();
80
+ Http::swap(new Illuminate\Http\Client\Factory());
81
+ SafeeContractTransport::$clock = 1000;
82
+ Carbon::setTestNow('2026-09-17 10:01:00 UTC');
83
+ });
84
+
85
+ afterEach(fn () => Carbon::setTestNow());
86
+
87
+ test('safee live polling uses documented inventory and batches all vehicle ids without history requests', function () {
88
+ $requests = [];
89
+ Http::fake(function ($request) use (&$requests) {
90
+ $requests[] = $request;
91
+ if (str_ends_with($request->url(), '/list-info')) {
92
+ expect($request->body())->toBe('{}');
93
+
94
+ return Http::response(['code' => 0, 'result' => array_map(fn ($id) => ['id' => $id, 'plateNo' => 'Truck ' . $id], range(1, 2001))]);
95
+ }
96
+ expect($request->url())->toEndWith('/last-state');
97
+ expect(array_keys($request->data()))->toBe(['live', 'endDate', 'vehicles']);
98
+ expect($request['live'])->toBeTrue()->and($request['endDate'])->toBeNull();
99
+ expect(count($request['vehicles']))->toBeLessThanOrEqual(1000);
100
+
101
+ return Http::response(['code' => 0, 'time' => 9999999999, 'result' => array_map('safeeLiveState', $request['vehicles'])]);
102
+ });
103
+ $provider = safeeLiveProvider();
104
+ $cursor = null;
105
+ $ids = [];
106
+ do {
107
+ $page = $provider->fetchDevices(['cursor' => $cursor, 'limit' => 5000]);
108
+ $ids = array_merge($ids, array_map(fn ($unit) => $provider->normalizeTelemetrySnapshot($unit)['device']['device_id'], $page['devices']));
109
+ $cursor = $page['next_cursor'];
110
+ } while ($page['has_more']);
111
+ expect($ids)->toBe(range(1, 2001))->and($page['pagination']['allCount'])->toBe(2001);
112
+ Http::assertSentCount(4);
113
+ expect($provider->telemetryOptions())->toMatchArray(['polling_enabled' => true, 'manual_batch_sync' => true, 'webhooks_enabled' => false]);
114
+ expect($provider->supportsWebhooks())->toBeFalse();
115
+ });
116
+
117
+ test('safee caches inventory per credentials and manual refresh replaces it', function () {
118
+ $inventoryCalls = 0;
119
+ Http::fake(function ($request) use (&$inventoryCalls) {
120
+ if (str_ends_with($request->url(), '/list-info')) {
121
+ $inventoryCalls++;
122
+
123
+ return Http::response(['code' => 0, 'result' => [['id' => 1, 'plateNo' => 'revision-' . $inventoryCalls]]]);
124
+ }
125
+
126
+ return Http::response(['code' => 0, 'result' => [safeeLiveState(1)]]);
127
+ });
128
+ expect(safeeLiveProvider()->fetchDevices()['devices'][0]['plateNo'])->toBe('revision-1');
129
+ expect(safeeLiveProvider()->fetchDevices()['devices'][0]['plateNo'])->toBe('revision-1');
130
+ expect(safeeLiveProvider()->fetchDevices(['refresh_inventory' => true])['devices'][0]['plateNo'])->toBe('revision-2');
131
+ expect(safeeLiveProvider(['access_token' => 'changed'])->fetchDevices()['devices'][0]['plateNo'])->toBe('revision-3');
132
+ expect($inventoryCalls)->toBe(3);
133
+ });
134
+
135
+ test('safee rejects malformed or unsuccessful fleet responses instead of returning an empty success', function ($response) {
136
+ Http::fakeSequence()->push($response);
137
+ expect(fn () => safeeLiveProvider()->fetchDevices())->toThrow(TelematicProviderException::class);
138
+ })->with([
139
+ 'non-json' => ['not json'],
140
+ 'error envelope' => [['code' => 4, 'result' => []]],
141
+ 'missing code' => [['result' => []]],
142
+ 'missing result' => [['code' => 0]],
143
+ 'object result' => [['code' => 0, 'result' => ['id' => 1]]],
144
+ 'scalar record' => [['code' => 0, 'result' => [false]]],
145
+ 'missing identity' => [['code' => 0, 'result' => [['plateNo' => 'unknown']]]],
146
+ 'duplicate identity' => [['code' => 0, 'result' => [['id' => 1], ['id' => 1]]]],
147
+ ]);
148
+
149
+ test('safee accepts an explicitly empty fleet and does not request states', function () {
150
+ Http::fakeSequence()->push(['code' => 0, 'result' => []]);
151
+ expect(safeeLiveProvider()->fetchDevices())->toMatchArray(['devices' => [], 'next_cursor' => null, 'has_more' => false]);
152
+ Http::assertSentCount(1);
153
+ });
154
+
155
+ test('safee isolates missing states while retaining both valid neighbors and canonical inventory identity', function () {
156
+ Http::fakeSequence()->push(['code' => 0, 'result' => [['id' => 1], ['id' => 2], ['id' => 3]]])
157
+ ->push(['code' => 0, 'result' => [safeeLiveState(1), safeeLiveState(3)]]);
158
+ $provider = safeeLiveProvider();
159
+ $page = $provider->fetchDevices();
160
+ $samples = array_map([$provider, 'normalizeTelemetrySnapshot'], $page['devices']);
161
+ expect(array_column(array_column($samples, 'device'), 'device_id'))->toBe([1, 2, 3]);
162
+ expect(array_map(fn ($sample) => Sample::validPosition($sample['event']), $samples))->toBe([true, false, true]);
163
+ expect($page['sync_meta']['safee_last_endpoint_counts']['missing_states'])->toBe(1);
164
+ });
165
+
166
+ test('safee refuses state responses that cannot be safely matched to the requested inventory', function ($states) {
167
+ Http::fakeSequence()->push(['code' => 0, 'result' => [['id' => 1]]])->push(['code' => 0, 'result' => $states]);
168
+ expect(fn () => safeeLiveProvider()->fetchDevices())->toThrow(TelematicProviderException::class);
169
+ })->with([
170
+ 'wrong vehicle' => [[['vehicleId' => 2]]],
171
+ 'duplicate vehicle' => [[['vehicleId' => 1], ['vehicleId' => 1]]],
172
+ 'missing identity' => [[['date' => 1]]],
173
+ 'scalar state' => [[42]],
174
+ ]);
175
+
176
+ test('safee normalizes UTC source time altitude and counters without inventing provider time or missing metadata', function () {
177
+ $provider = safeeLiveProvider();
178
+ $payload = ['_safee' => ['vehicle_id' => 1, 'identity' => ['id' => 1], 'current_state' => safeeLiveState(1, ['date' => '2026-09-17T13:00:00.125+03:00', 'speed' => 0, 'temperature' => ['Cargo' => 0]])]];
179
+ $sample = $provider->normalizeTelemetrySnapshot($payload);
180
+ expect($sample['event'])->toMatchArray(['device_id' => 1, 'occurred_at' => '2026-09-17T10:00:00.125000Z', 'last_seen_at' => '2026-09-17T10:00:00.125000Z', 'altitude' => 615, 'odometer' => '1234.5', 'speed' => 0]);
181
+ expect($sample['device'])->not->toHaveKeys(['name', 'imei', 'status', 'online']);
182
+ expect(data_get($sample, 'event.meta.telemetry.provider_at'))->toBeNull();
183
+ expect($sample['sensors'][0])->toMatchArray(['internal_id' => 'safee:1:temperature:Cargo', 'value' => 0, 'recorded_at' => '2026-09-17T10:00:00.125000Z']);
184
+ $payload['_safee']['current_info'] = safeeLiveState(1, ['date' => '2026-09-16T10:00:00Z', 'position' => ['lat' => 1, 'lon' => 2], 'speed' => 80]);
185
+ $latest = $provider->normalizeTelemetrySnapshot($payload);
186
+ expect($latest['event']['location'])->toBe(['lat' => 24, 'lng' => 46])->and($latest['event']['speed'])->toBe(0);
187
+ });
188
+
189
+ test('safee does not invent timestamps or overwrite omitted counters in a partial snapshot', function () {
190
+ $provider = safeeLiveProvider();
191
+ $sample = $provider->normalizeTelemetrySnapshot(['vehicleId' => 1, 'position' => ['lat' => 24, 'lon' => 46], 'date' => 'invalid']);
192
+ expect(Sample::validPosition($sample['event']))->toBeFalse();
193
+ expect($sample['event']['occurred_at'])->toBeNull();
194
+ expect($sample['device']['meta'])->not->toHaveKeys(['driver', 'plate_number']);
195
+ expect($sample['sensors'])->toBe([]);
196
+ expect($provider->normalizeTelemetrySnapshot(['id' => 1, 'date' => 0, 'position' => ['lat' => 0, 'lon' => 0]])['event']['occurred_at'])->toBeNull();
197
+ expect(fn () => $provider->telemetryUnits([safeeLiveState(1)]))->toThrow(InvalidArgumentException::class);
198
+ });
199
+
200
+ test('safee token cache is encrypted reusable credential scoped and recovers from foreign encryption', function () {
201
+ $authCalls = 0;
202
+ Http::fake(function ($request) use (&$authCalls) {
203
+ if (str_contains($request->url(), '/openid-connect/token')) {
204
+ $authCalls++;
205
+ expect($request['grant_type'])->toBe('password');
206
+
207
+ return Http::response(['access_token' => 'secret-token-' . $authCalls, 'expires_in' => 300]);
208
+ }
209
+
210
+ return Http::response(['code' => 0, 'result' => []]);
211
+ });
212
+ $a = safeeLiveProvider(safeeLiveCredentials());
213
+ Http::assertNothingSent();
214
+ $a->fetchDevices();
215
+ $stored = Cache::get($a->tokenCacheKey());
216
+ expect($stored)->not->toContain('secret-token');
217
+ expect(json_decode(Crypt::decryptString($stored), true)['access_token'])->toBe('secret-token-1');
218
+ safeeLiveProvider(safeeLiveCredentials())->fetchDevices(['refresh_inventory' => true]);
219
+ expect($authCalls)->toBe(1);
220
+ Cache::put($a->tokenCacheKey(), (new Encrypter(str_repeat('x', 32), 'aes-256-cbc'))->encryptString('foreign'), 60);
221
+ safeeLiveProvider(safeeLiveCredentials())->fetchDevices(['refresh_inventory' => true]);
222
+ safeeLiveProvider(array_replace(safeeLiveCredentials(), ['password' => 'changed']))->fetchDevices();
223
+ expect($authCalls)->toBe(3);
224
+ });
225
+
226
+ test('safee refreshes a rejected token once using the documented refresh grant', function () {
227
+ Http::fakeSequence()->push(['access_token' => 'old', 'refresh_token' => 'refresh-value', 'expires_in' => 300])
228
+ ->push([], 401)->push(['access_token' => 'new', 'expires_in' => 300])
229
+ ->push(['code' => 0, 'result' => []]);
230
+ safeeLiveProvider(safeeLiveCredentials())->fetchDevices();
231
+ Http::assertSentCount(4);
232
+ Http::assertSent(fn ($request) => ($request->data()['grant_type'] ?? null) === 'refresh_token' && ($request->data()['refresh_token'] ?? null) === 'refresh-value');
233
+ Http::assertSent(fn ($request) => str_ends_with($request->url(), '/list-info') && $request->hasHeader('Authorization', 'Bearer new'));
234
+ });
235
+
236
+ test('safee stops after a second token rejection', function () {
237
+ Http::fakeSequence()->push(['access_token' => 'old', 'expires_in' => 300])->push([], 401)
238
+ ->push(['access_token' => 'new', 'expires_in' => 300])->push([], 401);
239
+ expect(fn () => safeeLiveProvider(safeeLiveCredentials())->fetchDevices())->toThrow(TelematicProviderException::class, '401');
240
+ Http::assertSentCount(4);
241
+ });
242
+
243
+ test('safee refreshes tokens before their documented expiry and falls back once for revoked refresh tokens', function () {
244
+ Http::fakeSequence()->push(['access_token' => 'old', 'refresh_token' => 'revoked-refresh', 'expires_in' => 300, 'refresh_expires_in' => 600])
245
+ ->push(['code' => 0, 'result' => []])->push([], 400)
246
+ ->push(['access_token' => 'new', 'expires_in' => 300])->push(['code' => 0, 'result' => []]);
247
+ $provider = safeeLiveProvider(safeeLiveCredentials());
248
+ $provider->fetchDevices();
249
+ SafeeContractTransport::$clock += 296;
250
+ $provider->fetchDevices(['refresh_inventory' => true]);
251
+ $grants = collect(Http::recorded())->map(fn ($pair) => $pair[0]->data()['grant_type'] ?? null)->filter()->values()->all();
252
+ expect($grants)->toBe(['password', 'refresh_token', 'password']);
253
+ Http::assertSentCount(5);
254
+ });
255
+
256
+ test('safee enforces a shared atomic fifty per second account budget including credential variants', function () {
257
+ $a = new SafeeContractTransport('https://safee.contract.test', safeeLiveCredentials());
258
+ $b = new SafeeContractTransport('https://safee.contract.test', array_replace(safeeLiveCredentials(), ['password' => 'changed']));
259
+ for ($i = 0; $i < 50; $i++) {
260
+ ($i % 2 ? $a : $b)->reserve();
261
+ }
262
+ expect(fn () => $a->reserve())->toThrow(TelematicRateLimitExceededException::class);
263
+ SafeeContractTransport::$clock += 1.01;
264
+ $b->reserve();
265
+ });
266
+
267
+ test('safee respects Retry-After across instances without issuing more requests', function () {
268
+ Http::fakeSequence()->push([], 429, ['Retry-After' => '12']);
269
+ foreach (range(1, 2) as $attempt) {
270
+ try {
271
+ safeeLiveProvider()->fetchDevices();
272
+ test()->fail('Expected rate limiting.');
273
+ } catch (TelematicRateLimitExceededException $e) {
274
+ expect($e->context()['retry_after'])->toBe(12);
275
+ }
276
+ }
277
+ Http::assertSentCount(1);
278
+ });
279
+
280
+ test('safee page deadline includes authentication inventory and state retrieval', function () {
281
+ $timeouts = [];
282
+ Http::fake(function ($request, $options) use (&$timeouts) {
283
+ $timeouts[] = $options['timeout'];
284
+ expect($options['connect_timeout'])->toBeLessThanOrEqual(3);
285
+ SafeeContractTransport::$clock += 6;
286
+ if (str_contains($request->url(), '/openid-connect/token')) {
287
+ return Http::response(['access_token' => 'token', 'expires_in' => 300]);
288
+ }
289
+
290
+ return Http::response(['code' => 0, 'result' => [['id' => 1]]]);
291
+ });
292
+ expect(fn () => safeeLiveProvider(safeeLiveCredentials())->fetchDevices(['timeout' => 10, 'connect_timeout' => 3]))->toThrow(TelematicProviderException::class, 'time budget');
293
+ expect($timeouts)->toBe([10, 4]);
294
+ Http::assertSentCount(2);
295
+ });
296
+
297
+ test('safee historical failures do not advance the global history checkpoint', function () {
298
+ Http::fakeSequence()->push(['code' => 0, 'result' => ['vehicleId' => 1]])->push([], 503)->push(['code' => 0, 'result' => []]);
299
+ $result = safeeLiveProvider()->fetchDeviceTelemetrySnapshots([['id' => 1]], ['start_date' => 100, 'end_date' => 200]);
300
+ expect($result['sync_meta'])->not->toHaveKey('safee_last_telemetry_synced_at');
301
+ expect($result['sync_meta']['safee_last_enrichment_failures'])->toHaveCount(1);
302
+ });
303
+
304
+ test('safee rejects invalid inventory cursors and unsupported list filters before paging', function () {
305
+ Http::fake([
306
+ '*/list-info' => Http::response(['code' => 0, 'result' => [['id' => 1], ['id' => 2]]]),
307
+ '*/last-state' => Http::response(['code' => 0, 'result' => []]),
308
+ ]);
309
+ $provider = safeeLiveProvider();
310
+ expect(fn () => $provider->fetchDevices(['cursor' => 'next']))->toThrow(TelematicProviderException::class, 'cursor is invalid')
311
+ ->and(fn () => $provider->fetchDevices(['cursor' => -1]))->toThrow(TelematicProviderException::class, 'cursor is invalid')
312
+ ->and(fn () => $provider->fetchDevices(['filters' => ['status' => 'ACTIVE']]))->toThrow(TelematicProviderException::class, 'does not support filters')
313
+ ->and(fn () => $provider->fetchDevices(['cursor' => 3]))->toThrow(TelematicProviderException::class, 'exceeds the inventory size');
314
+ // Only the out-of-range cursor needed the inventory; invalid input fails before any request.
315
+ Http::assertSentCount(1);
316
+ });
317
+
318
+ test('safee reports data and authentication connection failures without leaking transport details', function (bool $authenticate) {
319
+ Http::fake(fn ($request) => throw new GuzzleHttp\Exception\ConnectException('SSL connection timeout', new GuzzleHttp\Psr7\Request('POST', (string) $request->url())));
320
+ $provider = safeeLiveProvider($authenticate ? safeeLiveCredentials() : []);
321
+ $message = $authenticate ? 'Safee authentication timed out or could not connect.' : 'Safee request timed out or could not connect.';
322
+ expect(fn () => $provider->fetchDevices())->toThrow(TelematicProviderException::class, $message);
323
+ })->with(['data request' => [false], 'token request' => [true]]);
324
+
325
+ test('safee does not refresh a rejected static token without password grant credentials', function () {
326
+ Http::fake(['*' => Http::response([], 401)]);
327
+ expect(fn () => safeeLiveProvider()->fetchDevices())->toThrow(TelematicProviderException::class, '401');
328
+ Http::assertSentCount(1);
329
+ });
330
+
331
+ test('safee requires realm client and user credentials when no access token is available', function () {
332
+ Http::fake();
333
+ expect(fn () => safeeLiveProvider(['access_token' => null])->fetchDevices())->toThrow(InvalidArgumentException::class, 'credentials are required');
334
+ Http::assertNothingSent();
335
+ });
@@ -1,6 +1,7 @@
1
1
  <?php
2
2
 
3
3
  use Fleetbase\FleetOps\Models\Telematic;
4
+ use Fleetbase\FleetOps\Exceptions\TelematicProviderException;
4
5
  use Fleetbase\FleetOps\Support\Telematics\Providers\SafeeProvider;
5
6
  use Illuminate\Http\Client\Factory as HttpFactory;
6
7
  use Illuminate\Support\Carbon;
@@ -43,7 +44,7 @@ class FleetOpsSafeeProviderUnitProbe extends SafeeProvider
43
44
 
44
45
  public function queuePostResponse(array|Throwable $response): void
45
46
  {
46
- $this->responses[] = $response;
47
+ $this->responses[] = is_array($response) ? array_replace(['code' => 0], $response) : $response;
47
48
  }
48
49
 
49
50
  protected function safeePost(string $endpoint, array|stdClass $payload = [], bool $dataEndpoint = false): array
@@ -136,10 +137,8 @@ test('safee provider authenticates through oidc and masks connection failures',
136
137
  'password' => 'secret',
137
138
  ]))->toBe([
138
139
  'success' => false,
139
- 'message' => 'not ready',
140
+ 'message' => 'Safee returned an invalid or unsuccessful response.',
140
141
  'metadata' => [
141
- 'status' => null,
142
- 'time' => null,
143
142
  'auth_host' => 'https://safee.example.test',
144
143
  'auth_path' => '/auth/realms/fleetbase/protocol/openid-connect/token',
145
144
  'realm_id' => 'fleetbase',
@@ -178,13 +177,11 @@ test('safee provider authenticates through oidc and masks connection failures',
178
177
  ]);
179
178
  });
180
179
 
181
- test('safee provider fetches devices with identity diagnostics and last state fallbacks', function () {
180
+ test('safee provider fetches bounded live state batches with identity diagnostics', function () {
182
181
  $provider = fleetopsSafeeProvider();
183
182
  $provider->queuePostResponse([
184
183
  'result' => [
185
184
  ['id' => 101, 'plateNo' => 'TRK-101'],
186
- ['id' => 101, 'plateNo' => 'TRK-101-DUP'],
187
- ['uuid' => 'missing-id'],
188
185
  ['_safee' => ['vehicle_id' => 202], 'plateNo' => 'TRK-202'],
189
186
  ],
190
187
  ]);
@@ -192,41 +189,34 @@ test('safee provider fetches devices with identity diagnostics and last state fa
192
189
  'result' => [
193
190
  ['vehicleId' => 101, 'status' => 'active', 'speed' => 44],
194
191
  ['vehicle' => ['id' => 202], 'status' => 'offline'],
195
- 'ignored-state',
196
192
  ],
197
193
  ]);
198
194
 
199
195
  $result = $provider->fetchDevices([
200
- 'filter' => (object) ['plateNo' => 'TRK'],
201
- 'page_size' => 50,
202
- 'page_index' => 2,
196
+ 'limit' => 50,
197
+ 'refresh_inventory' => true,
203
198
  ]);
204
199
 
205
- expect($provider->postCalls[0])->toBe([
206
- '/api/v2/vehicle/list-info',
207
- ['plateNo' => 'TRK', 'pageSize' => 50, 'pageIndex' => 2],
208
- true,
209
- ])
200
+ expect($provider->postCalls[0][0])->toBe('/api/v2/vehicle/list-info')
201
+ ->and($provider->postCalls[0][1])->toBeInstanceOf(stdClass::class)
210
202
  ->and($provider->postCalls[1])->toBe([
211
203
  '/api/v2/vehicle/last-state',
212
204
  [
213
205
  'live' => true,
214
- 'startDate' => null,
215
206
  'endDate' => null,
216
207
  'vehicles' => [101, 202],
217
208
  ],
218
209
  true,
219
210
  ])
220
- ->and($result['devices'])->toHaveCount(4)
211
+ ->and($result['devices'])->toHaveCount(2)
221
212
  ->and($result['devices'][0]['_safee']['current_state']['speed'])->toBe(44)
222
- ->and($result['devices'][3]['_safee']['current_state']['status'])->toBe('offline')
213
+ ->and($result['devices'][1]['_safee']['current_state']['status'])->toBe('offline')
223
214
  ->and($result['sync_meta']['safee_last_endpoint_counts'])->toMatchArray([
224
- 'vehicles_listed' => 4,
215
+ 'vehicles_listed' => 2,
225
216
  'unique_vehicle_ids' => 2,
226
- 'missing_vehicle_ids' => 1,
227
- 'duplicate_vehicle_ids' => ['101' => 2],
228
- 'list_info_page_size' => 50,
229
- 'list_info_requested_unpaginated' => false,
217
+ 'missing_vehicle_ids' => 0,
218
+ 'duplicate_vehicle_ids' => [],
219
+ 'list_info_requested_unpaginated' => true,
230
220
  'last_state_fetched' => 2,
231
221
  ]);
232
222
  });
@@ -475,13 +465,13 @@ test('safee transport helpers fetch details guard credentials and surface failur
475
465
  };
476
466
  Http::clearResolvedInstances();
477
467
  app()->forgetInstance(HttpFactory::class);
478
- Http::fake(['*' => Http::response(['result' => ['id' => 42, 'name' => 'Vehicle 42']], 200)]);
468
+ Http::fake(['*' => Http::response(['code' => 0, 'result' => ['id' => 42, 'name' => 'Vehicle 42']], 200)]);
479
469
  expect($raw->fetchDeviceDetails('42'))->toBe(['id' => 42, 'name' => 'Vehicle 42']);
480
470
 
481
471
  // Failed responses surface as runtime exceptions from both verbs
482
472
  Http::clearResolvedInstances();
483
473
  app()->forgetInstance(HttpFactory::class);
484
474
  Http::fake(['*' => Http::response(['error' => 'nope'], 500)]);
485
- expect(fn () => $rawInvoke('safeeGet', '/api/v2/broken'))->toThrow(RuntimeException::class)
486
- ->and(fn () => $rawInvoke('safeePost', '/api/v2/broken', ['x' => 1]))->toThrow(RuntimeException::class);
475
+ expect(fn () => $rawInvoke('safeeGet', '/api/v2/broken'))->toThrow(TelematicProviderException::class)
476
+ ->and(fn () => $rawInvoke('safeePost', '/api/v2/broken', ['x' => 1]))->toThrow(TelematicProviderException::class);
487
477
  });
@@ -0,0 +1,36 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Events\TrailerLocationChanged;
4
+ use Fleetbase\FleetOps\Jobs\SyncTelematicDevicesJob;
5
+ use Fleetbase\FleetOps\Jobs\TestTelematicConnectionJob;
6
+ use Fleetbase\FleetOps\Models\Telematic;
7
+ use Fleetbase\FleetOps\Support\Telematics\Telemetry\Configuration;
8
+
9
+ /**
10
+ * Covers per-instance queue routing for connection-level telematics jobs and
11
+ * telematics-originated broadcasts. Unset configuration must keep the defaults.
12
+ */
13
+ afterEach(fn () => config(['telematics.telemetry.poll_queue' => 'default', 'telematics.telemetry.broadcast_queue' => null]));
14
+
15
+ test('legacy discovery and connection test jobs follow the configured poll queue', function (?string $configured, string $expected) {
16
+ config(['telematics.telemetry.poll_queue' => $configured]);
17
+ $telematic = new Telematic();
18
+
19
+ expect((new SyncTelematicDevicesJob($telematic))->queue)->toBe($expected)
20
+ ->and((new TestTelematicConnectionJob($telematic))->queue)->toBe($expected)
21
+ ->and(Configuration::pollQueue())->toBe($expected);
22
+ })->with([
23
+ 'unset' => [null, 'default'],
24
+ 'default' => ['default', 'default'],
25
+ 'dedicated' => ['telematics', 'telematics'],
26
+ ]);
27
+
28
+ test('broadcast queue is applied only when configured', function () {
29
+ $event = (new ReflectionClass(TrailerLocationChanged::class))->newInstanceWithoutConstructor();
30
+
31
+ expect(Configuration::withBroadcastQueue($event)->broadcastQueue)->toBeNull();
32
+
33
+ config(['telematics.telemetry.broadcast_queue' => 'telematics-broadcasts']);
34
+ expect(Configuration::withBroadcastQueue($event))->toBe($event)
35
+ ->and($event->broadcastQueue)->toBe('telematics-broadcasts');
36
+ });