@fleetbase/fleetops-engine 0.6.66 → 0.6.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/RELEASE.md +9 -8
  2. package/addon/components/fuel-integration/form.hbs +2 -2
  3. package/addon/components/fuel-integration/form.js +8 -0
  4. package/addon/components/fuel-integration/hub.js +1 -1
  5. package/addon/components/telematic/details.hbs +1 -1
  6. package/addon/components/telematic/telemetry-status.hbs +93 -34
  7. package/addon/components/widget/radar.hbs +1 -1
  8. package/addon/components/widget/radar.js +7 -0
  9. package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
  10. package/addon/utils/fuel-transaction.js +2 -0
  11. package/composer.json +2 -1
  12. package/docs/AFAQY.md +71 -17
  13. package/docs/SAFEE.md +130 -0
  14. package/docs/TELEMATICS_QUEUES.md +230 -0
  15. package/extension.json +1 -1
  16. package/package.json +1 -1
  17. package/server/config/fuel-providers.php +53 -0
  18. package/server/config/safee.php +10 -0
  19. package/server/config/telematics.php +8 -1
  20. package/server/config/telemetry.php +4 -1
  21. package/server/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php +90 -0
  22. package/server/src/Console/Commands/SyncTelematics.php +11 -5
  23. package/server/src/Events/DeviceTelemetryUpdated.php +2 -1
  24. package/server/src/Events/TrailerLocationChanged.php +2 -0
  25. package/server/src/Events/VehicleLocationChanged.php +5 -0
  26. package/server/src/Jobs/PollTelematicTelemetry.php +163 -12
  27. package/server/src/Jobs/ProcessTelematicDelivery.php +64 -10
  28. package/server/src/Jobs/SyncTelematicDevicesJob.php +77 -1
  29. package/server/src/Jobs/TestTelematicConnectionJob.php +1 -0
  30. package/server/src/Providers/FleetOpsServiceProvider.php +5 -2
  31. package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
  32. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +65 -10
  33. package/server/src/Support/Telematics/Providers/SafeeProvider.php +213 -139
  34. package/server/src/Support/Telematics/Safee/Transport.php +210 -0
  35. package/server/src/Support/Telematics/TelematicService.php +45 -1
  36. package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
  37. package/server/src/Support/Telematics/Telemetry/Inbox.php +49 -3
  38. package/server/src/Support/Telematics/Telemetry/Ingestor.php +1 -1
  39. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +147 -58
  40. package/server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php +106 -0
  41. package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +545 -0
  42. package/server/tests/Feature/Http/SafeeRealtimeIngestionTest.php +208 -0
  43. package/server/tests/Feature/Http/TelematicDeliveryCheckpointTest.php +198 -0
  44. package/server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php +166 -0
  45. package/server/tests/ProviderContractsTest.php +50 -1
  46. package/server/tests/Support/AfaqyTestCrypto.php +6 -23
  47. package/server/tests/SupportJobAndAiCoverageTest.php +102 -5
  48. package/server/tests/TelematicsHardeningTest.php +18 -13
  49. package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +140 -0
  50. package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
  51. package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +132 -0
  52. package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +1 -1
  53. package/server/tests/Unit/Support/Telematics/Providers/SafeeLiveContractTest.php +335 -0
  54. package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +17 -27
  55. package/server/tests/Unit/Support/Telematics/TelematicsQueueRoutingTest.php +36 -0
@@ -0,0 +1,338 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Models\FuelProviderConnection;
4
+ use Fleetbase\FleetOps\Support\FuelProviders\Providers\SascoFuelProvider;
5
+ use Illuminate\Http\Client\Request;
6
+ use Illuminate\Support\Carbon;
7
+ use Illuminate\Support\Facades\Cache;
8
+ use Illuminate\Support\Facades\Http;
9
+
10
+ /**
11
+ * Covers the SASCO B2B fuel provider against faked HTTP responses: username and
12
+ * password login with cached JWTs, one-shot re-authentication on 401, connection
13
+ * testing, current_page pagination bounded by totalItems, and driver transaction
14
+ * normalization.
15
+ */
16
+ const SASCO_TEST_BASE = 'https://sasco.test';
17
+
18
+ function fleetopsSascoFresh(): void
19
+ {
20
+ Http::clearResolvedInstances();
21
+ app()->forgetInstance(Illuminate\Http\Client\Factory::class);
22
+ Cache::clear();
23
+ }
24
+
25
+ function fleetopsSascoConnection(array $credentials = [], ?string $environment = null): FuelProviderConnection
26
+ {
27
+ $connection = new FuelProviderConnection();
28
+ $connection->setRawAttributes([
29
+ 'uuid' => 'conn-sasco',
30
+ 'company_uuid' => 'company-1',
31
+ 'provider' => 'sasco',
32
+ 'environment' => $environment,
33
+ 'credentials' => json_encode(array_merge([
34
+ 'base_url' => SASCO_TEST_BASE,
35
+ 'username' => 'fleet@example.test',
36
+ 'password' => 'secret',
37
+ ], $credentials)),
38
+ ], true);
39
+
40
+ return $connection;
41
+ }
42
+
43
+ function fleetopsSascoJwt(?int $exp): string
44
+ {
45
+ $segment = fn (array $data) => rtrim(strtr(base64_encode(json_encode($data)), '+/', '-_'), '=');
46
+
47
+ return $segment(['alg' => 'RS256']) . '.' . $segment($exp ? ['exp' => $exp] : ['sub' => 'user']) . '.signature';
48
+ }
49
+
50
+ function fleetopsSascoInvoke(SascoFuelProvider $provider, string $method, ...$arguments)
51
+ {
52
+ $reflection = new ReflectionMethod($provider, $method);
53
+ $reflection->setAccessible(true);
54
+
55
+ return $reflection->invoke($provider, ...$arguments);
56
+ }
57
+
58
+ function fleetopsSascoTransaction(array $overrides = []): array
59
+ {
60
+ return array_merge([
61
+ 'transactionNumber' => '27307485',
62
+ 'stationName' => ' Station North ',
63
+ 'noOfLitters' => 42.735,
64
+ 'fuelType' => '95',
65
+ 'driverName' => 'Annabella Francesconi',
66
+ 'driverPhone' => '518596945',
67
+ 'createdAt' => '2025-11-03T17:19:49.595558+03:00',
68
+ 'amount' => 100.5,
69
+ 'fuelImageUrl' => 'https://storage.example.test/receipt.jpg',
70
+ 'vehiclePlate' => [
71
+ 'plateNumber' => '1240',
72
+ 'firstPlateLetter' => 'N',
73
+ 'secondPlateLetter' => 'X',
74
+ 'thirdPlateLetter' => 'D',
75
+ ],
76
+ 'type' => 'Trip',
77
+ 'tripId' => null,
78
+ 'period' => 4,
79
+ ], $overrides);
80
+ }
81
+
82
+ test('sasco provider identifies itself and resolves environment base urls', function () {
83
+ $provider = new SascoFuelProvider();
84
+
85
+ expect($provider->key())->toBe('sasco')
86
+ ->and($provider->name())->toBe('SASCO')
87
+ ->and(fleetopsSascoInvoke($provider, 'baseUrl', fleetopsSascoConnection(['base_url' => ''], 'sandbox')))->toBe('https://loyalty-api-qc.sasco.sa')
88
+ ->and(fleetopsSascoInvoke($provider, 'baseUrl', fleetopsSascoConnection(['base_url' => ''])))->toBe('https://loyalty-api.sasco.com.sa')
89
+ ->and(fleetopsSascoInvoke($provider, 'baseUrl', fleetopsSascoConnection(['base_url' => ' https://custom.test/root/ '])))->toBe('https://custom.test/root');
90
+ });
91
+
92
+ test('sasco authenticate caches the access token until shortly before it expires', function () {
93
+ fleetopsSascoFresh();
94
+ Carbon::setTestNow(Carbon::createFromTimestamp(1_000_000));
95
+ $token = fleetopsSascoJwt(1_000_000 + 7200);
96
+ Http::fake([SASCO_TEST_BASE . '/apigateway/api/identity/user/b2badmin-login' => Http::response(['accessToken' => $token, 'loginSucces' => true], 200)]);
97
+
98
+ $provider = new SascoFuelProvider();
99
+ $connection = fleetopsSascoConnection(['username' => ' fleet@example.test ']);
100
+ $result = $provider->authenticate($connection);
101
+
102
+ expect($result)->toBe(['success' => true, 'message' => 'SASCO login successful.', 'token' => $token])
103
+ ->and(Cache::get(fleetopsSascoInvoke($provider, 'tokenCacheKey', $connection)))->toBe($token)
104
+ ->and(fleetopsSascoInvoke($provider, 'tokenTtl', $token))->toBe(7140)
105
+ ->and(fleetopsSascoInvoke($provider, 'tokenTtl', fleetopsSascoJwt(null)))->toBe(3600)
106
+ ->and(fleetopsSascoInvoke($provider, 'tokenTtl', 'not-a-jwt'))->toBe(3600)
107
+ ->and(fleetopsSascoInvoke($provider, 'tokenTtl', fleetopsSascoJwt(1_000_000 + 10)))->toBe(1);
108
+
109
+ Http::assertSent(fn (Request $request) => $request->method() === 'POST'
110
+ && $request->data() === ['username' => 'fleet@example.test', 'password' => 'secret']);
111
+
112
+ Carbon::setTestNow();
113
+ });
114
+
115
+ test('sasco authenticate reports rejected logins with the provider message or a fallback', function () {
116
+ fleetopsSascoFresh();
117
+ Http::fake([SASCO_TEST_BASE . '/*' => Http::sequence()
118
+ ->push(['message' => 'Invalid user credentials'], 400)
119
+ ->push(['loginSucces' => false], 200)
120
+ ->push(['message' => ['not', 'a', 'string']], 500),
121
+ ]);
122
+
123
+ $provider = new SascoFuelProvider();
124
+ $invalid = $provider->authenticate(fleetopsSascoConnection());
125
+ $noToken = $provider->authenticate(fleetopsSascoConnection());
126
+ $badBody = $provider->authenticate(fleetopsSascoConnection());
127
+
128
+ expect($invalid)->toBe(['success' => false, 'message' => 'Invalid user credentials', 'status' => 400])
129
+ ->and($noToken['success'])->toBeFalse()
130
+ ->and($noToken['message'])->toBe('SASCO login failed. Check the B2B admin username and password.')
131
+ ->and($badBody['message'])->toBe('SASCO login failed. Check the B2B admin username and password.');
132
+ });
133
+
134
+ test('sasco test connection reports success failure and login errors', function () {
135
+ fleetopsSascoFresh();
136
+ Http::fake([
137
+ SASCO_TEST_BASE . '/apigateway/api/identity/user/b2badmin-login' => Http::response(['accessToken' => fleetopsSascoJwt(null)], 200),
138
+ SASCO_TEST_BASE . '/apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles*' => Http::sequence()
139
+ ->push(['vehicles' => [['id' => 'vehicle-1']], 'totalItems' => 12], 200)
140
+ ->push(['title' => 'Forbidden'], 403)
141
+ ->push(['vehicles' => null], 200),
142
+ ]);
143
+
144
+ $provider = new SascoFuelProvider();
145
+ $success = $provider->testConnection(fleetopsSascoConnection([], 'sandbox'));
146
+ $failure = $provider->testConnection(fleetopsSascoConnection());
147
+ $invalid = $provider->testConnection(fleetopsSascoConnection());
148
+
149
+ expect($success)->toBe([
150
+ 'success' => true,
151
+ 'message' => 'SASCO connection successful.',
152
+ 'metadata' => ['environment' => 'sandbox', 'host' => 'sasco.test', 'status' => 200, 'total_vehicles' => 12],
153
+ ])
154
+ ->and($failure['success'])->toBeFalse()
155
+ ->and($failure['message'])->toBe('Forbidden')
156
+ ->and($failure['metadata'])->toBe(['environment' => 'production', 'host' => 'sasco.test', 'status' => 403])
157
+ ->and($invalid['message'])->toBe('Unable to connect to SASCO.');
158
+
159
+ Http::assertSentCount(4);
160
+
161
+ fleetopsSascoFresh();
162
+ Http::fake([SASCO_TEST_BASE . '/*' => Http::response(['error' => 'Account locked'], 401)]);
163
+ $locked = $provider->testConnection(fleetopsSascoConnection());
164
+
165
+ expect($locked['success'])->toBeFalse()
166
+ ->and($locked['message'])->toBe('Account locked')
167
+ ->and($locked['metadata'])->toBe(['environment' => 'production', 'host' => 'sasco.test']);
168
+ });
169
+
170
+ test('sasco requests reuse the cached token and log in again once after a 401', function () {
171
+ fleetopsSascoFresh();
172
+ Http::fake([
173
+ SASCO_TEST_BASE . '/apigateway/api/identity/user/b2badmin-login' => Http::sequence()
174
+ ->push(['accessToken' => 'token-1'], 200)
175
+ ->push(['accessToken' => 'token-2'], 200),
176
+ SASCO_TEST_BASE . '/apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles*' => Http::sequence()
177
+ ->push(['vehicles' => [['id' => 'a']], 'totalItems' => 1], 200)
178
+ ->push(['message' => 'expired'], 401)
179
+ ->push(['vehicles' => [['id' => 'b']], 'totalItems' => 1], 200),
180
+ ]);
181
+
182
+ $provider = new SascoFuelProvider();
183
+ $first = $provider->listVehicles(fleetopsSascoConnection());
184
+ $second = $provider->listVehicles(fleetopsSascoConnection());
185
+
186
+ $authorizations = collect(Http::recorded())
187
+ ->map(fn ($pair) => $pair[0])
188
+ ->filter(fn (Request $request) => $request->method() === 'GET')
189
+ ->map(fn (Request $request) => $request->header('Authorization')[0])
190
+ ->values()
191
+ ->all();
192
+
193
+ expect($first->pluck('id')->all())->toBe(['a'])
194
+ ->and($second->pluck('id')->all())->toBe(['b'])
195
+ ->and($authorizations)->toBe(['Bearer token-1', 'Bearer token-1', 'Bearer token-2']);
196
+ });
197
+
198
+ test('sasco requests raise the login error when no token can be obtained', function () {
199
+ fleetopsSascoFresh();
200
+ Http::fake([SASCO_TEST_BASE . '/*' => Http::response(['message' => 'Invalid user credentials'], 400)]);
201
+
202
+ expect(fn () => (new SascoFuelProvider())->listVehicles(fleetopsSascoConnection()))
203
+ ->toThrow(RuntimeException::class, 'Invalid user credentials');
204
+ });
205
+
206
+ test('sasco pagination walks current pages until totalItems and guards the page limit', function () {
207
+ fleetopsSascoFresh();
208
+ Http::fake([
209
+ SASCO_TEST_BASE . '/apigateway/api/identity/user/b2badmin-login' => Http::response(['accessToken' => 'token'], 200),
210
+ SASCO_TEST_BASE . '/apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles*' => Http::sequence()
211
+ ->push(['vehicles' => [['id' => 1], ['id' => 2]], 'totalItems' => 3], 200)
212
+ ->push(['vehicles' => [['id' => 3]], 'totalItems' => 3], 200)
213
+ ->push(['vehicles' => [], 'totalItems' => 9], 200)
214
+ ->push(['vehicles' => [['id' => 4]], 'totalItems' => 5], 200),
215
+ ]);
216
+
217
+ $provider = new SascoFuelProvider();
218
+ $vehicles = $provider->listVehicles(fleetopsSascoConnection(), ['page_size' => 2, 'SearchKey' => 'NXD']);
219
+ $empty = $provider->listVehicles(fleetopsSascoConnection());
220
+
221
+ expect($vehicles->pluck('id')->all())->toBe([1, 2, 3])
222
+ ->and($empty->all())->toBe([]);
223
+
224
+ $queries = collect(Http::recorded())
225
+ ->map(fn ($pair) => $pair[0])
226
+ ->filter(fn (Request $request) => $request->method() === 'GET')
227
+ ->map(fn (Request $request) => parse_url($request->url(), PHP_URL_QUERY))
228
+ ->values()
229
+ ->all();
230
+
231
+ expect($queries[0])->toBe('SearchKey=NXD&Page_size=2&Current_page=1')
232
+ ->and($queries[1])->toBe('SearchKey=NXD&Page_size=2&Current_page=2')
233
+ ->and($queries[2])->toBe('Page_size=100&Current_page=1');
234
+
235
+ expect(fn () => $provider->listVehicles(fleetopsSascoConnection(), ['page' => 3, 'max_pages' => 3]))
236
+ ->toThrow(RuntimeException::class, 'SASCO returned more pages than the sync limit.');
237
+ });
238
+
239
+ test('sasco pagination raises runtime errors for failed and malformed responses', function () {
240
+ fleetopsSascoFresh();
241
+ Http::fake([
242
+ SASCO_TEST_BASE . '/apigateway/api/identity/user/b2badmin-login' => Http::response(['accessToken' => 'token'], 200),
243
+ SASCO_TEST_BASE . '/apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles*' => Http::sequence()
244
+ ->push('gateway timeout', 504)
245
+ ->push(['data' => []], 200),
246
+ ]);
247
+
248
+ $provider = new SascoFuelProvider();
249
+
250
+ expect(fn () => $provider->listVehicles(fleetopsSascoConnection()))
251
+ ->toThrow(RuntimeException::class, 'SASCO apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles request failed.')
252
+ ->and(fn () => $provider->listVehicles(fleetopsSascoConnection()))
253
+ ->toThrow(RuntimeException::class, 'SASCO apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles returned an invalid response.');
254
+ });
255
+
256
+ test('sasco list transactions filters by date and normalizes driver transactions', function () {
257
+ fleetopsSascoFresh();
258
+ Http::fake([
259
+ SASCO_TEST_BASE . '/apigateway/api/identity/user/b2badmin-login' => Http::response(['accessToken' => 'token'], 200),
260
+ SASCO_TEST_BASE . '/apigateway/api/analytics/v1/b2bexternal/drivers-transactions*' => Http::sequence()
261
+ ->push([
262
+ 'driverTransactions' => [
263
+ fleetopsSascoTransaction(),
264
+ fleetopsSascoTransaction(['transactionNumber' => ' ', 'tripId' => ' TRIP-7 ', 'vehiclePlate' => null, 'amount' => null]),
265
+ ],
266
+ 'totalItems' => 2,
267
+ ], 200)
268
+ ->push(['driverTransactions' => [], 'totalItems' => 0], 200)
269
+ ->push(['driverTransactions' => [], 'totalItems' => 0], 200),
270
+ ]);
271
+
272
+ $provider = new SascoFuelProvider();
273
+ $from = Carbon::parse('2025-10-30 08:00:00');
274
+ $to = Carbon::parse('2025-11-03 20:00:00');
275
+
276
+ $transactions = $provider->listTransactions(fleetopsSascoConnection(), $from, $to);
277
+ $provider->listTransactions(fleetopsSascoConnection(), $from, $to, ['is_trip' => true]);
278
+ $provider->listTransactions(fleetopsSascoConnection(), $from, $to, ['is_trip' => false]);
279
+
280
+ [$first, $second] = $transactions->all();
281
+
282
+ expect($first)->toMatchArray([
283
+ 'provider' => 'sasco',
284
+ 'provider_transaction_id' => '27307485',
285
+ 'plate_number' => 'NXD 1240',
286
+ 'trip_number' => null,
287
+ 'station_name' => 'Station North',
288
+ 'volume' => 42.735,
289
+ 'metric_unit' => 'l',
290
+ 'amount' => 10050,
291
+ 'currency' => 'SAR',
292
+ 'normalized_payload' => [
293
+ 'fuel_type' => '95',
294
+ 'driver_name' => 'Annabella Francesconi',
295
+ 'driver_phone' => '518596945',
296
+ 'type' => 'Trip',
297
+ 'period' => 4,
298
+ 'fuel_image_url' => 'https://storage.example.test/receipt.jpg',
299
+ ],
300
+ 'raw_payload' => fleetopsSascoTransaction(),
301
+ ])
302
+ ->and($first['transaction_at']->toIso8601String())->toBe('2025-11-03T17:19:49+03:00')
303
+ ->and($second['provider_transaction_id'])->toHaveLength(64)
304
+ ->and($second['plate_number'])->toBeNull()
305
+ ->and($second['trip_number'])->toBe('TRIP-7')
306
+ ->and($second['amount'])->toBeNull();
307
+
308
+ $queries = collect(Http::recorded())
309
+ ->map(fn ($pair) => $pair[0])
310
+ ->filter(fn (Request $request) => $request->method() === 'GET')
311
+ ->map(fn (Request $request) => parse_url($request->url(), PHP_URL_QUERY))
312
+ ->values()
313
+ ->all();
314
+
315
+ expect($queries)->toBe([
316
+ 'FromDate=2025-10-30&ToDate=2025-11-03&Page_size=100&Current_page=1',
317
+ 'FromDate=2025-10-30&ToDate=2025-11-03&IsTrip=true&Page_size=100&Current_page=1',
318
+ 'FromDate=2025-10-30&ToDate=2025-11-03&IsTrip=false&Page_size=100&Current_page=1',
319
+ ]);
320
+ });
321
+
322
+ test('sasco plate numbers tolerate partial plates', function () {
323
+ $provider = new SascoFuelProvider();
324
+
325
+ expect(fleetopsSascoInvoke($provider, 'plateNumber', ['plateNumber' => '435']))->toBe('435')
326
+ ->and(fleetopsSascoInvoke($provider, 'plateNumber', ['firstPlateLetter' => 'R', 'secondPlateLetter' => ' ', 'thirdPlateLetter' => 'R']))->toBe('RR')
327
+ ->and(fleetopsSascoInvoke($provider, 'plateNumber', []))->toBeNull();
328
+ });
329
+
330
+ test('sasco is registered as a native fuel provider', function () {
331
+ $providers = require __DIR__ . '/../../../config/fuel-providers.php';
332
+ $sasco = collect($providers['providers'])->firstWhere('key', 'sasco');
333
+
334
+ expect($sasco['driver_class'])->toBe(SascoFuelProvider::class)
335
+ ->and(collect($sasco['required_fields'])->pluck('name')->all())->toBe(['username', 'password', 'base_url'])
336
+ ->and($sasco['metadata']['base_urls'])->toBe(SascoFuelProvider::BASE_URLS)
337
+ ->and($sasco['capabilities'])->toBe(['vehicles', 'transactions']);
338
+ });
@@ -22,6 +22,13 @@ use Illuminate\Support\Facades\Http;
22
22
  */
23
23
  class FleetOpsAfaqyTransportProbe extends AfaqyProvider
24
24
  {
25
+ public ?float $clock = null;
26
+
27
+ protected function requestTime(): float
28
+ {
29
+ return $this->clock ?? parent::requestTime();
30
+ }
31
+
25
32
  public function setCredentialsForTest(array $credentials): void
26
33
  {
27
34
  $this->credentials = $credentials;
@@ -144,3 +151,128 @@ test('transport errors and helper extractors resolve metadata', function () {
144
151
  expect($probe->callHelper('resolveSensorName', ['param' => 'fuel'], 'fallback'))->toBe('fuel')
145
152
  ->and($probe->callHelper('resolveSensorName', [], 'fallback'))->toBe('fallback');
146
153
  });
154
+
155
+ test('unreadable cached tokens are evicted even when reauthentication fails', function () {
156
+ fleetopsAfaqyTransportBoot();
157
+ $key = 'afaqy:token:' . hash('sha256', 'https://api.afaqy.sa|user') . ':' . hash('sha256', 'secret');
158
+ Illuminate\Support\Facades\Cache::put($key, 'not-an-encrypted-payload', 3600);
159
+ Http::preventStrayRequests();
160
+ Http::fake(['*/auth/login' => Http::response(['message' => 'denied'], 401)]);
161
+ $connection = new Fleetbase\FleetOps\Models\Telematic();
162
+ $connection->credentials = ['username' => 'user', 'password' => 'secret'];
163
+ expect(fn () => (new AfaqyProvider())->connect($connection))->toThrow(TelematicProviderException::class, 'authentication failed with status 401');
164
+ expect(Illuminate\Support\Facades\Cache::has($key))->toBeFalse();
165
+ expect(Illuminate\Support\Facades\Cache::get('afaqy:rate:' . hash('sha256', 'https://api.afaqy.sa|user')))->toHaveCount(1);
166
+ Http::assertSentCount(1);
167
+ });
168
+
169
+ test('rejected token refresh and retry share the original HTTP time budget', function () {
170
+ fleetopsAfaqyTransportBoot();
171
+ $probe = fleetopsAfaqyTransportProbe(['token' => 'stale-token', 'username' => 'user', 'password' => 'secret']);
172
+ $probe->clock = 100;
173
+ $timeouts = [];
174
+ Http::fake(function ($request, $options) use ($probe, &$timeouts) {
175
+ $timeouts[] = [$options['timeout'], $options['connect_timeout']];
176
+ if (count($timeouts) === 1) {
177
+ $probe->clock += 20;
178
+
179
+ return Http::response(['message' => 'expired'], 401);
180
+ }
181
+ if (str_ends_with($request->url(), '/auth/login')) {
182
+ $probe->clock += 10;
183
+
184
+ return Http::response(['data' => ['token' => 'refreshed-token']], 200);
185
+ }
186
+
187
+ return Http::response(['data' => ['ok' => true]], 200);
188
+ });
189
+
190
+ expect($probe->callHelper('afaqyPost', '/units/list', [], false, 45, 5))->toBe(['data' => ['ok' => true]])
191
+ ->and($timeouts)->toBe([[45, 5], [25, 15], [15, 5]]);
192
+ Http::assertSentCount(3);
193
+ });
194
+
195
+ test('exhausted refresh budget prevents a retry and is reset for the next request', function () {
196
+ fleetopsAfaqyTransportBoot();
197
+ $probe = fleetopsAfaqyTransportProbe(['token' => 'stale-token', 'username' => 'user', 'password' => 'secret']);
198
+ $probe->clock = 100;
199
+ $timeouts = [];
200
+ Http::fake(function ($request, $options) use ($probe, &$timeouts) {
201
+ $timeouts[] = $options['timeout'];
202
+ if (count($timeouts) === 1) {
203
+ $probe->clock += 20;
204
+
205
+ return Http::response(['message' => 'expired'], 401);
206
+ }
207
+ if (str_ends_with($request->url(), '/auth/login')) {
208
+ $probe->clock += 24.5;
209
+
210
+ return Http::response(['data' => ['token' => 'refreshed-token']], 200);
211
+ }
212
+
213
+ return Http::response(['data' => []], 200);
214
+ });
215
+
216
+ expect(fn () => $probe->callHelper('afaqyPost', '/units/list', [], false, 45, 5))
217
+ ->toThrow(TelematicProviderException::class, 'time budget exhausted');
218
+ Http::assertSentCount(2);
219
+ expect($timeouts)->toBe([45, 25]);
220
+
221
+ $probe->clock += 60;
222
+ expect($probe->callHelper('afaqyPost', '/units/list', [], false, 45, 5))->toBe(['data' => []]);
223
+ expect($timeouts)->toBe([45, 25, 45]);
224
+ Http::assertSentCount(3);
225
+ });
226
+
227
+ test('token lock wait consumes the refresh budget before authentication starts', function () {
228
+ fleetopsAfaqyTransportBoot();
229
+ $probe = fleetopsAfaqyTransportProbe(['token' => 'stale-token', 'username' => 'user', 'password' => 'secret']);
230
+ $probe->clock = 100;
231
+ $originalCache = Illuminate\Support\Facades\Cache::getFacadeRoot();
232
+ $cache = new class($originalCache, $probe) {
233
+ public array $waits = [];
234
+
235
+ public function __construct(public $repository, public FleetOpsAfaqyTransportProbe $probe)
236
+ {
237
+ }
238
+
239
+ public function lock(string $key, int $seconds)
240
+ {
241
+ return new class($this, $key) {
242
+ public function __construct(public $cache, public string $key)
243
+ {
244
+ }
245
+
246
+ public function block($seconds, $callback)
247
+ {
248
+ $this->cache->waits[] = $seconds;
249
+ if (str_starts_with($this->key, 'afaqy:token:')) {
250
+ $this->cache->probe->clock += 1.2;
251
+ }
252
+
253
+ return $callback();
254
+ }
255
+ };
256
+ }
257
+
258
+ public function __call($method, $arguments)
259
+ {
260
+ return $this->repository->{$method}(...$arguments);
261
+ }
262
+ };
263
+ Illuminate\Support\Facades\Cache::swap($cache);
264
+ try {
265
+ Http::fake(function () use ($probe) {
266
+ $probe->clock += 43;
267
+
268
+ return Http::response(['message' => 'expired'], 401);
269
+ });
270
+
271
+ expect(fn () => $probe->callHelper('afaqyPost', '/units/list', [], false, 45, 5))
272
+ ->toThrow(TelematicProviderException::class, 'time budget exhausted');
273
+ expect($cache->waits)->toBe([2, 2]);
274
+ Http::assertSentCount(1);
275
+ } finally {
276
+ Illuminate\Support\Facades\Cache::swap($originalCache);
277
+ }
278
+ });
@@ -129,5 +129,5 @@ test('safee authentication rejects a successful response that carries no token',
129
129
  ]);
130
130
 
131
131
  expect(fn () => $probe->callHelper('authenticate'))
132
- ->toThrow(RuntimeException::class, 'Safee authentication did not return an access token.');
132
+ ->toThrow(Fleetbase\FleetOps\Exceptions\TelematicProviderException::class, 'Safee authentication did not return an access token.');
133
133
  });