@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,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' => '
|
|
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
|
|
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
|
-
'
|
|
201
|
-
'
|
|
202
|
-
'page_index' => 2,
|
|
196
|
+
'limit' => 50,
|
|
197
|
+
'refresh_inventory' => true,
|
|
203
198
|
]);
|
|
204
199
|
|
|
205
|
-
expect($provider->postCalls[0])->toBe(
|
|
206
|
-
|
|
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(
|
|
211
|
+
->and($result['devices'])->toHaveCount(2)
|
|
221
212
|
->and($result['devices'][0]['_safee']['current_state']['speed'])->toBe(44)
|
|
222
|
-
->and($result['devices'][
|
|
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' =>
|
|
215
|
+
'vehicles_listed' => 2,
|
|
225
216
|
'unique_vehicle_ids' => 2,
|
|
226
|
-
'missing_vehicle_ids' =>
|
|
227
|
-
'duplicate_vehicle_ids' => [
|
|
228
|
-
'
|
|
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(
|
|
486
|
-
->and(fn () => $rawInvoke('safeePost', '/api/v2/broken', ['x' => 1]))->toThrow(
|
|
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
|
+
});
|