@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.
- package/RELEASE.md +9 -8
- package/addon/components/fuel-integration/form.hbs +2 -2
- package/addon/components/fuel-integration/form.js +8 -0
- package/addon/components/fuel-integration/hub.js +1 -1
- package/addon/components/telematic/details.hbs +1 -1
- package/addon/components/telematic/telemetry-status.hbs +93 -34
- package/addon/components/widget/radar.hbs +1 -1
- package/addon/components/widget/radar.js +7 -0
- package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
- package/addon/utils/fuel-transaction.js +2 -0
- package/composer.json +2 -1
- package/docs/AFAQY.md +71 -17
- package/docs/SAFEE.md +130 -0
- package/docs/TELEMATICS_QUEUES.md +230 -0
- package/extension.json +1 -1
- package/package.json +1 -1
- package/server/config/fuel-providers.php +53 -0
- package/server/config/safee.php +10 -0
- package/server/config/telematics.php +8 -1
- package/server/config/telemetry.php +4 -1
- package/server/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php +90 -0
- package/server/src/Console/Commands/SyncTelematics.php +11 -5
- package/server/src/Events/DeviceTelemetryUpdated.php +2 -1
- package/server/src/Events/TrailerLocationChanged.php +2 -0
- package/server/src/Events/VehicleLocationChanged.php +5 -0
- package/server/src/Jobs/PollTelematicTelemetry.php +163 -12
- package/server/src/Jobs/ProcessTelematicDelivery.php +64 -10
- package/server/src/Jobs/SyncTelematicDevicesJob.php +77 -1
- package/server/src/Jobs/TestTelematicConnectionJob.php +1 -0
- package/server/src/Providers/FleetOpsServiceProvider.php +5 -2
- package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
- package/server/src/Support/Telematics/Providers/AfaqyProvider.php +65 -10
- package/server/src/Support/Telematics/Providers/SafeeProvider.php +213 -139
- package/server/src/Support/Telematics/Safee/Transport.php +210 -0
- package/server/src/Support/Telematics/TelematicService.php +45 -1
- package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
- package/server/src/Support/Telematics/Telemetry/Inbox.php +49 -3
- package/server/src/Support/Telematics/Telemetry/Ingestor.php +1 -1
- package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +147 -58
- package/server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php +106 -0
- package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +545 -0
- package/server/tests/Feature/Http/SafeeRealtimeIngestionTest.php +208 -0
- package/server/tests/Feature/Http/TelematicDeliveryCheckpointTest.php +198 -0
- package/server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php +166 -0
- package/server/tests/ProviderContractsTest.php +50 -1
- package/server/tests/Support/AfaqyTestCrypto.php +6 -23
- package/server/tests/SupportJobAndAiCoverageTest.php +102 -5
- package/server/tests/TelematicsHardeningTest.php +18 -13
- package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +140 -0
- package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
- package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +132 -0
- package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +1 -1
- package/server/tests/Unit/Support/Telematics/Providers/SafeeLiveContractTest.php +335 -0
- package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +17 -27
- package/server/tests/Unit/Support/Telematics/TelematicsQueueRoutingTest.php +36 -0
|
@@ -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(
|
|
132
|
+
->toThrow(Fleetbase\FleetOps\Exceptions\TelematicProviderException::class, 'Safee authentication did not return an access token.');
|
|
133
133
|
});
|