Passed
Push — master ( 0e4442...f6f84b )
by Eric
15:09 queued 13:04
created

LibrariesIOTest::testProcessClientOptions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 8
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 11
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * This file is part of Esi\LibrariesIO.
7
 *
8
 * (c) 2023-2024 Eric Sizemore <https://github.com/ericsizemore>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13
14
namespace Esi\LibrariesIO\Tests;
15
16
use Esi\LibrariesIO\AbstractClient;
17
use Esi\LibrariesIO\Exception\InvalidApiKeyException;
18
use Esi\LibrariesIO\Exception\InvalidEndpointException;
19
use Esi\LibrariesIO\Exception\InvalidEndpointOptionsException;
20
use Esi\LibrariesIO\Exception\RateLimitExceededException;
21
use Esi\LibrariesIO\LibrariesIO;
22
use Esi\LibrariesIO\Utils;
23
use GuzzleHttp\Exception\ClientException;
24
use GuzzleHttp\Handler\MockHandler;
25
use GuzzleHttp\Psr7\Request;
26
use GuzzleHttp\Psr7\Response;
27
use Iterator;
28
use PHPUnit\Framework\Attributes\CoversClass;
29
use PHPUnit\Framework\Attributes\DataProvider;
30
use PHPUnit\Framework\Attributes\TestDox;
31
use PHPUnit\Framework\MockObject\MockObject;
32
use PHPUnit\Framework\TestCase;
33
use stdClass;
34
35
use function md5;
36
use function sys_get_temp_dir;
37
38
/**
39
 * LibrariesIO Tests.
40
 *
41
 * @internal
42
 *
43
 * @psalm-internal Esi\LibrariesIO\Tests
44
 *
45
 * @todo Split tests into *Class*Test
46
 */
47
#[CoversClass(LibrariesIO::class)]
48
#[CoversClass(AbstractClient::class)]
49
#[CoversClass(Utils::class)]
50
#[CoversClass(RateLimitExceededException::class)]
51
final class LibrariesIOTest extends TestCase
52
{
53
    /**
54
     * @var array<string, ClientException|Response>
55
     */
56
    private array $responses;
57
58
    private string $testApiKey;
59
60
    #[\Override]
61
    protected function setUp(): void
62
    {
63
        $rateLimitHeaders = [
64
            'X-RateLimit-Limit'     => '60',
65
            'X-RateLimit-Remaining' => '0',
66
            'X-RateLimit-Reset'     => '',
67
        ];
68
69
        $this->testApiKey = md5('test');
70
71
        $this->responses = [
72
            'valid'       => new Response(200, body: '{"Hello":"World"}'),
73
            'clientError' => new ClientException('Error Communicating with Server', new Request('GET', 'test'), new Response(202, ['X-Foo' => 'Bar'])),
74
            'rateLimit'   => new ClientException('Rate Limit Exceeded', new Request('GET', 'test'), new Response(429, $rateLimitHeaders)),
75
            'rateLimiter' => new Response(429, $rateLimitHeaders, 'Rate Limit Exceeded'),
76
        ];
77
    }
78
79
    #[TestDox('Client error throws a Guzzle ClientException')]
80
    public function testClientError(): void
81
    {
82
        $mockHandler = new MockHandler([$this->responses['clientError']]);
83
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
84
        $this->expectException(ClientException::class);
85
        $mockClient->platform();
86
    }
87
88
    public function testEndpointParametersInvalidEndpoint(): void
89
    {
90
        $this->expectException(InvalidEndpointException::class);
91
        Utils::endpointParameters('notvalid', '', []);
92
    }
93
94
    #[TestDox('Providing an invalid API key results in an InvalidApiKeyException')]
95
    public function testInvalidApiKey(): void
96
    {
97
        $this->expectException(InvalidApiKeyException::class);
98
        $this->mockClient('notvalid');
99
    }
100
101
    #[DataProvider('dataEndpointProvider')]
102
    public function testNormalizeEndpoint(string $expected, string $endpoint, string $apiUrl): void
103
    {
104
        self::assertSame($expected, Utils::normalizeEndpoint($endpoint, $apiUrl));
105
    }
106
107
    #[DataProvider('dataMethodProvider')]
108
    public function testNormalizeMethod(string $expected, string $method): void
109
    {
110
        self::assertSame($expected, Utils::normalizeMethod($method));
111
    }
112
113
    #[TestDox('LibrariesIO::platform() returns expected response')]
114
    public function testPlatform(): void
115
    {
116
        $mockHandler = new MockHandler([$this->responses['valid']]);
117
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
118
        $response    = $mockClient->platform();
119
120
        self::assertInstanceOf(Response::class, $response);
121
        self::assertSame('{"Hello":"World"}', $response->getBody()->getContents());
122
    }
123
124
    /**
125
     * Test the processClientOptions function. It should remove:
126
     *  'base_uri', 'handler', 'http_errors', 'query'
127
     */
128
    public function testProcessClientOptions(): void
129
    {
130
        $reflection = new \ReflectionMethod('\Esi\LibrariesIO\AbstractClient::processClientOptions');
131
        $result     = $reflection->invoke(null, [
132
            'base_uri'    => 'local.host',
133
            'handler'     => null,
134
            'http_errors' => true,
135
            'query'       => [],
136
            'test'        => false,
137
        ]);
138
        self::assertSame(['test' => false], $result);
139
    }
140
141
    /**
142
     * @param array<string, int|string> $options
143
     */
144
    #[DataProvider('dataProjectProvider')]
145
    #[TestDox('LibrariesIO::project() returns expected response')]
146
    public function testProject(string $expected, string $endpoint, array $options): void
147
    {
148
        $mockHandler = new MockHandler([$this->responses['valid']]);
149
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
150
        $response    = $mockClient->project($endpoint, $options);
151
152
        self::assertInstanceOf(Response::class, $response);
153
        self::assertSame($expected, $response->getBody()->getContents());
154
    }
155
156
    #[TestDox('LibrariesIO::project() with an invalid endpoint throws an InvalidEndpointException')]
157
    public function testProjectInvalidEndpoint(): void
158
    {
159
        $mockHandler = new MockHandler([$this->responses['valid']]);
160
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
161
162
        $this->expectException(InvalidEndpointException::class);
163
        $mockClient->project('notvalid', ['platform' => 'npm', 'name' => 'utility']);
164
    }
165
166
    #[TestDox('LibrariesIO::project() with an invalid options throws an InvalidEndpointOptionsException')]
167
    public function testProjectInvalidOptions(): void
168
    {
169
        $mockHandler = new MockHandler([$this->responses['valid']]);
170
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
171
172
        $this->expectException(InvalidEndpointOptionsException::class);
173
        $mockClient->project('search', ['huh' => 'what']);
174
    }
175
176
    #[TestDox('A response with status code 429 throws a RateLimitExceededException')]
177
    public function testRateLimitExceeded(): void
178
    {
179
        $response    = $this->responses['rateLimiter'];
180
        $mockHandler = new MockHandler([$response]);
181
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
182
183
        try {
184
            $mockClient->platform();
185
        } catch (RateLimitExceededException $rateLimitExceededException) {
186
            $response = $rateLimitExceededException->getResponse();
187
188
            self::assertSame('60', $response->getHeaderLine('x-ratelimit-limit'));
189
            self::assertSame('0', $response->getHeaderLine('x-ratelimit-remaining'));
190
            self::assertSame('', $response->getHeaderLine('x-ratelimit-reset'));
191
        }
192
    }
193
194
    #[TestDox('(ClientException) A response with status code 429 throws a RateLimitExceededException')]
195
    public function testRateLimitExceededClientException(): void
196
    {
197
        $response    = $this->responses['rateLimit'];
198
        $mockHandler = new MockHandler([$response]);
199
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
200
201
        try {
202
            $mockClient->platform();
203
        } catch (RateLimitExceededException $rateLimitExceededException) {
204
            $response = $rateLimitExceededException->getResponse();
205
206
            self::assertSame('60', $response->getHeaderLine('x-ratelimit-limit'));
207
            self::assertSame('0', $response->getHeaderLine('x-ratelimit-remaining'));
208
            self::assertSame('', $response->getHeaderLine('x-ratelimit-reset'));
209
        }
210
    }
211
212
    #[TestDox('Utils::toRaw() returns raw JSON and expected response')]
213
    public function testRaw(): void
214
    {
215
        $mockHandler = new MockHandler([$this->responses['valid']]);
216
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
217
        $response    = $mockClient->user('dependencies', ['login' => 'ericsizemore']);
218
219
        self::assertInstanceOf(Response::class, $response);
220
        self::assertSame('{"Hello":"World"}', Utils::raw($response));
221
    }
222
223
    /**
224
     * @param array<string, int|string> $options
225
     */
226
    #[DataProvider('dataRepositoryProvider')]
227
    #[TestDox('LibrariesIO::repository() returns expected response')]
228
    public function testRepository(string $expected, string $endpoint, array $options): void
229
    {
230
        $mockHandler = new MockHandler([$this->responses['valid']]);
231
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
232
        $response    = $mockClient->repository($endpoint, $options);
233
234
        self::assertInstanceOf(Response::class, $response);
235
        self::assertSame($expected, $response->getBody()->getContents());
236
    }
237
238
    /**
239
     * Test the repository endpoint with an invalid $endpoint arg specified.
240
     */
241
    public function testRepositoryInvalidEndpoint(): void
242
    {
243
        $mockHandler = new MockHandler([$this->responses['valid']]);
244
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
245
        $this->expectException(InvalidEndpointException::class);
246
        $mockClient->repository('notvalid', ['owner' => 'ericsizemore', 'name' => 'utility']);
247
    }
248
249
    /**
250
     * Test the repository endpoint with a valid subset $endpoint arg and invalid options specified.
251
     */
252
    public function testRepositoryInvalidOptions(): void
253
    {
254
        $mockHandler = new MockHandler([$this->responses['valid']]);
255
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
256
        $this->expectException(InvalidEndpointOptionsException::class);
257
        $mockClient->repository('repository', ['huh' => 'what']);
258
    }
259
260
    /**
261
     * Test the subscription endpoint.
262
     *
263
     * @param array<string> $options
264
     */
265
    #[DataProvider('dataSubscriptionProvider')]
266
    public function testSubscription(string $expected, string $endpoint, array $options): void
267
    {
268
        $mockHandler = new MockHandler([$this->responses['valid']]);
269
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
270
        $response    = $mockClient->subscription($endpoint, $options);
271
272
        self::assertInstanceOf(Response::class, $response);
273
        self::assertSame($expected, $response->getBody()->getContents());
274
    }
275
276
    /**
277
     * Test the subscription endpoint with an invalid $endpoint arg specified.
278
     */
279
    public function testSubscriptionInvalidEndpoint(): void
280
    {
281
        $mockHandler = new MockHandler([$this->responses['valid']]);
282
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
283
        $this->expectException(InvalidEndpointException::class);
284
        $mockClient->subscription('notvalid', ['platform' => 'npm', 'name' => 'utility']);
285
    }
286
287
    /**
288
     * Test the subscription endpoint with a valid $endpoint arg and invalid $options specified.
289
     */
290
    public function testSubscriptionInvalidOptions(): void
291
    {
292
        $mockHandler = new MockHandler([$this->responses['valid']]);
293
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
294
        $this->expectException(InvalidEndpointOptionsException::class);
295
        $mockClient->subscription('check', ['huh' => 'what']);
296
    }
297
298
    /**
299
     * Test the toArray function. It decodes the raw json data into an associative array.
300
     */
301
    public function testToArray(): void
302
    {
303
        $mockHandler = new MockHandler([$this->responses['valid']]);
304
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
305
        $response    = $mockClient->user('dependencies', ['login' => 'ericsizemore']);
306
307
        self::assertInstanceOf(Response::class, $response);
308
        self::assertSame(['Hello' => 'World'], Utils::toArray($response));
309
    }
310
311
    /**
312
     * Test the toObject function. It decodes the raw json data and creates a \stdClass object.
313
     */
314
    public function testToObject(): void
315
    {
316
        $mockHandler = new MockHandler([$this->responses['valid']]);
317
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
318
        $response    = $mockClient->user('dependencies', ['login' => 'ericsizemore']);
319
320
        self::assertInstanceOf(Response::class, $response);
321
322
        $expected        = new stdClass();
323
        $expected->Hello = 'World';
324
325
        self::assertEquals($expected, Utils::toObject($response));
326
    }
327
328
    /**
329
     * Test the user endpoint.
330
     *
331
     * @param array<string, int|string> $options
332
     */
333
    #[DataProvider('dataUserProvider')]
334
    public function testUser(string $expected, string $endpoint, array $options): void
335
    {
336
        $mockHandler = new MockHandler([$this->responses['valid']]);
337
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
338
        $response    = $mockClient->user($endpoint, $options);
339
340
        self::assertInstanceOf(Response::class, $response);
341
        self::assertSame($expected, $response->getBody()->getContents());
342
    }
343
344
    /**
345
     * Test the user endpoint with an invalid $endpoint arg specified.
346
     */
347
    public function testUserInvalidEndpoint(): void
348
    {
349
        $mockHandler = new MockHandler([$this->responses['valid']]);
350
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
351
        $this->expectException(InvalidEndpointException::class);
352
        $mockClient->user('notvalid', ['login' => 'ericsizemore']);
353
    }
354
355
    /**
356
     * Test the user endpoint with a valid $endpoint arg and invalid $options specified.
357
     */
358
    public function testUserInvalidOptions(): void
359
    {
360
        $mockHandler = new MockHandler([$this->responses['valid']]);
361
        $mockClient  = $this->mockClient($this->testApiKey, $mockHandler);
362
        $this->expectException(InvalidEndpointOptionsException::class);
363
        $mockClient->user('packages', ['huh' => 'what']);
364
    }
365
366
    /**
367
     * Test the Utils::validateCachePath() with valid and invalid $cachePath's.
368
     */
369
    #[DataProvider('dataCachePathProvider')]
370
    public function testValidateCachePathReturnValues(?string $expected, ?string $cachePath): void
371
    {
372
        self::assertSame($expected, Utils::validateCachePath($cachePath));
373
    }
374
375
    /**
376
     * @psalm-suppress PossiblyUnusedMethod
377
     */
378
    public static function dataCachePathProvider(): Iterator
379
    {
380
        yield [null, null];
381
        yield [null, '/path/does/not/exist'];
382
        yield [sys_get_temp_dir(), sys_get_temp_dir()];
383
    }
384
385
    /**
386
     * @psalm-suppress PossiblyUnusedMethod
387
     */
388
    public static function dataEndpointProvider(): Iterator
389
    {
390
        yield ['project', '/project', 'https://libraries.io/api/'];
391
        yield ['project', 'project', 'https://libraries.io/api/'];
392
        yield ['/project', '/project', 'https://libraries.io/api'];
393
        yield ['/project', 'project', 'https://libraries.io/api'];
394
    }
395
396
    /**
397
     * @psalm-suppress PossiblyUnusedMethod
398
     */
399
    public static function dataMethodProvider(): Iterator
400
    {
401
        yield ['GET', 'GET'];
402
        yield ['POST', 'POST'];
403
        yield ['DELETE', 'DELETE'];
404
        yield ['PUT', 'PUT'];
405
        yield ['GET', 'PATCH'];
406
        yield ['GET', 'OPTIONS'];
407
        yield ['GET', 'HEAD'];
408
    }
409
410
    /**
411
     * @psalm-suppress PossiblyUnusedMethod
412
     */
413
    public static function dataProjectProvider(): Iterator
414
    {
415
        yield ['{"Hello":"World"}', 'contributors', ['platform' => 'npm', 'name' => 'utility']];
416
        yield ['{"Hello":"World"}', 'dependencies', ['platform' => 'npm', 'name' => 'utility', 'version' => 'latest']];
417
        yield ['{"Hello":"World"}', 'dependent_repositories', ['platform' => 'npm', 'name' => 'utility']];
418
        yield ['{"Hello":"World"}', 'dependents', ['platform' => 'npm', 'name' => 'utility']];
419
        yield ['{"Hello":"World"}', 'search', ['query' => 'grunt', 'sort' => 'rank', 'keywords' => 'wordpress']];
420
        yield ['{"Hello":"World"}', 'search', ['query' => 'grunt', 'sort' => 'notvalid', 'keywords' => 'wordpress']];
421
        yield ['{"Hello":"World"}', 'sourcerank', ['platform' => 'npm', 'name' => 'utility']];
422
        yield ['{"Hello":"World"}', 'project', ['platform' => 'npm', 'name' => 'utility', 'page' => 1, 'per_page' => 30]];
423
    }
424
425
    /**
426
     * @psalm-suppress PossiblyUnusedMethod
427
     */
428
    public static function dataRepositoryProvider(): Iterator
429
    {
430
        yield ['{"Hello":"World"}', 'dependencies', ['owner' => 'ericsizemore', 'name' => 'utility']];
431
        yield ['{"Hello":"World"}', 'projects', ['owner' => 'ericsizemore', 'name' => 'utility']];
432
        yield ['{"Hello":"World"}', 'repository', ['owner' => 'ericsizemore', 'name' => 'utility']];
433
        yield ['{"Hello":"World"}', 'dependencies', ['owner' => 'ericsizemore', 'name' => 'utility']];
434
        yield ['{"Hello":"World"}', 'projects', ['owner' => 'ericsizemore', 'name' => 'utility', 'page' => 1, 'per_page' => 30]];
435
        yield ['{"Hello":"World"}', 'repository', ['owner' => 'ericsizemore', 'name' => 'utility']];
436
    }
437
438
    /**
439
     * @psalm-suppress PossiblyUnusedMethod
440
     */
441
    public static function dataSubscriptionProvider(): Iterator
442
    {
443
        yield ['{"Hello":"World"}', 'subscribe', ['platform' => 'npm', 'name' => 'utility', 'include_prerelease' => 'true']];
444
        yield ['{"Hello":"World"}', 'check', ['platform' => 'npm', 'name' => 'utility']];
445
        yield ['{"Hello":"World"}', 'update', ['platform' => 'npm', 'name' => 'utility', 'include_prerelease' => 'false']];
446
        yield ['{"Hello":"World"}', 'unsubscribe', ['platform' => 'npm', 'name' => 'utility']];
447
    }
448
449
    /**
450
     * @psalm-suppress PossiblyUnusedMethod
451
     */
452
    public static function dataUserProvider(): Iterator
453
    {
454
        yield ['{"Hello":"World"}', 'dependencies', ['login' => 'ericsizemore']];
455
        yield ['{"Hello":"World"}', 'package_contributions', ['login' => 'ericsizemore']];
456
        yield ['{"Hello":"World"}', 'packages', ['login' => 'ericsizemore']];
457
        yield ['{"Hello":"World"}', 'repositories', ['login' => 'ericsizemore']];
458
        yield ['{"Hello":"World"}', 'repository_contributions', ['login' => 'ericsizemore', 'page' => 1, 'per_page' => 30]];
459
        yield ['{"Hello":"World"}', 'subscriptions', []];
460
    }
461
462
    /**
463
     * Creates a mock for testing.
464
     */
465
    private function mockClient(string $apiKey, ?MockHandler $mockHandler = null): LibrariesIO&MockObject
466
    {
467
        return $this
468
            ->getMockBuilder(LibrariesIO::class)
469
            ->setConstructorArgs([$apiKey, sys_get_temp_dir(), ['_mockHandler' => $mockHandler]])
470
            ->onlyMethods([])
471
            ->getMock();
472
    }
473
}
474