diff --git a/src/Concerns/RespectsRateLimits.php b/src/Concerns/RespectsRateLimits.php index 6b3b000..2088c75 100644 --- a/src/Concerns/RespectsRateLimits.php +++ b/src/Concerns/RespectsRateLimits.php @@ -4,6 +4,8 @@ namespace Cerbero\LazyJsonPages\Concerns; +use Cerbero\LazyJsonPages\Services\ClientFactory; + /** * The trait to respect rate limits of APIs. */ @@ -14,8 +16,8 @@ trait RespectsRateLimits */ protected function respectRateLimits(): void { - if (microtime(true) < $timestamp = $this->config->rateLimits?->resetAt() ?? 0) { - time_sleep_until($timestamp); + if (microtime(true) < $timestamp = $this->config->rateLimits?->resetAt() ?? 0.0) { + ClientFactory::isFake() ? ClientFactory::$fakedRateLimits[] = $timestamp : time_sleep_until($timestamp); } } } diff --git a/src/Paginations/Pagination.php b/src/Paginations/Pagination.php index 81be2eb..44b23a8 100644 --- a/src/Paginations/Pagination.php +++ b/src/Paginations/Pagination.php @@ -48,6 +48,8 @@ final public function __construct( /** * Determine whether this pagination matches the configuration. + * + * @codeCoverageIgnore */ public function matches(): bool { diff --git a/src/Services/ClientFactory.php b/src/Services/ClientFactory.php index 142a9ff..4af9845 100644 --- a/src/Services/ClientFactory.php +++ b/src/Services/ClientFactory.php @@ -42,6 +42,18 @@ final class ClientFactory */ private static array $globalMiddleware = []; + /** + * Whether HTTP requests are faked. + */ + private static bool $isFake = false; + + /** + * The faked rate limits timestamps. + * + * @var float[] + */ + public static array $fakedRateLimits = []; + /** * The tap middleware callbacks. */ @@ -84,6 +96,8 @@ public static function fake(array $responses, Closure $callback): array { $transactions = []; + self::$isFake = true; + $handler = HandlerStack::create(new MockHandler($responses)); $handler->push(Middleware::history($transactions)); @@ -94,9 +108,21 @@ public static function fake(array $responses, Closure $callback): array unset(self::$defaultConfig['handler']); + self::$fakedRateLimits = []; + + self::$isFake = false; + return $transactions; } + /** + * Determine whether the HTTP requests are faked. + */ + public static function isFake(): bool + { + return self::$isFake; + } + /** * Instantiate the class. */ diff --git a/src/Sources/Source.php b/src/Sources/Source.php index 21021ff..fc04ff7 100644 --- a/src/Sources/Source.php +++ b/src/Sources/Source.php @@ -39,6 +39,8 @@ final public function __construct( /** * Determine whether this class can handle the source. + * + * @codeCoverageIgnore */ public function matches(): bool { diff --git a/tests/Feature/PaginationTest.php b/tests/Feature/PaginationTest.php index 0d0a67b..43444dc 100644 --- a/tests/Feature/PaginationTest.php +++ b/tests/Feature/PaginationTest.php @@ -1,7 +1,11 @@ linkHeader() + ->collect('data.*'); + + $responses = [ + new Response(body: file_get_contents(fixture('pagination/page1.json'))), + ]; + + $transactions = ClientFactory::fake($responses, fn() => expect($lazyCollection)->sequence( + ['name' => 'item1'], + ['name' => 'item2'], + ['name' => 'item3'], + ['name' => 'item4'], + ['name' => 'item5'], + )); + + expect($transactions)->toHaveCount(1); + expect((string) $transactions[0]['request']->getUri())->toBe('https://example.com/api/v1/users'); +}); + it('fails if an invalid custom pagination is provided', function () { $lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users') ->pagination('Invalid') @@ -73,3 +98,19 @@ 'https://example.com/api/v1/users' => 'pagination/page1.json', ]); })->throws(InvalidPaginationException::class, 'The class [Invalid] should extend [Cerbero\LazyJsonPages\Paginations\Pagination].'); + +it('fails if an invalid JSON key is provided', function () { + $lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users') + ->totalPages('invalid') + ->collect('data.*'); + + expect($lazyCollection)->toLoadItemsViaRequests([ + 'https://example.com/api/v1/users' => 'pagination/page1.json', + ]); +})->throws(InvalidKeyException::class, 'The key [invalid] does not contain a valid value.'); + +it('fails if a pagination is not supported', function () { + LazyJsonPages::from('https://example.com/api/v1/users') + ->collect('data.*') + ->each(fn() => true); +})->throws(UnsupportedPaginationException::class, 'The provided configuration does not match with any supported pagination.'); diff --git a/tests/Feature/RequestsOptimizationTest.php b/tests/Feature/RequestsOptimizationTest.php index b7d0832..5134158 100644 --- a/tests/Feature/RequestsOptimizationTest.php +++ b/tests/Feature/RequestsOptimizationTest.php @@ -1,7 +1,13 @@ sequence('sending', 'sent', 'sending', 'sending', 'sent', 'sent'); }); + +it('handles failures when sending HTTP requests asynchronously', function () { + $lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users') + ->async(3) + ->attempts(3) + ->backoff(fn() => 0) + ->totalPages('meta.total_pages') + ->collect('data.*'); + + $responses = [ + new Response(body: file_get_contents(fixture('paginationWith5Pages/page1.json'))), + new RequestException('connection failed', new Request('GET', 'https://example.com/api/v1/users?page=2')), + new RequestException('connection failed', new Request('GET', 'https://example.com/api/v1/users?page=2')), + new RequestException('connection failed', new Request('GET', 'https://example.com/api/v1/users?page=2')), + new RequestException('connection failed', new Request('GET', 'https://example.com/api/v1/users?page=2')), + new RequestException('connection failed', new Request('GET', 'https://example.com/api/v1/users?page=2')), + new RequestException('connection failed', new Request('GET', 'https://example.com/api/v1/users?page=2')), + $e = new RequestException('connection failed', new Request('GET', 'https://example.com/api/v1/users?page=2')), + new Response(body: file_get_contents(fixture('paginationWith5Pages/page3.json'))), + ]; + + ClientFactory::fake($responses, function() use ($lazyCollection, $e) { + try { + $lazyCollection->toArray(); + } catch (Throwable $exception) { + expect($exception) + ->toBeInstanceOf(OutOfAttemptsException::class) + ->getPrevious()->toBe($e) + ->failedPages->toContain(2) + ->items->pluck('name')->all()->toContain('item11', 'item12', 'item13', 'item14', 'item15'); + } + }); +}); + +it('sets the timeouts', function () { + LazyJsonPages::from('https://nonexisting.test') + ->totalPages('meta.total_pages') + ->connectionTimeout(0.01) + ->requestTimeout(0.01) + ->collect('data.*') + ->each(fn() => true); +})->throws(ConnectException::class, 'Connection refused for URI https://nonexisting.test'); + +it('respects rate limits', function () { + $lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users') + ->totalPages('meta.total_pages') + ->throttle(requests: 1, perSeconds: 1) + ->collect('data.*'); + + $responses = [ + new Response(body: file_get_contents(fixture('pagination/page1.json'))), + new Response(body: file_get_contents(fixture('pagination/page2.json'))), + new Response(body: file_get_contents(fixture('pagination/page3.json'))), + ]; + + $transactions = ClientFactory::fake($responses, function () use ($lazyCollection) { + expect($lazyCollection)->sequence(...require fixture('items.php')); + expect(ClientFactory::$fakedRateLimits)->toHaveCount(3); + }); + + $actualUris = array_map(fn(array $transaction) => (string) $transaction['request']->getUri(), $transactions); + + expect($actualUris)->toBe([ + 'https://example.com/api/v1/users', + 'https://example.com/api/v1/users?page=2', + 'https://example.com/api/v1/users?page=3', + ]); +}); diff --git a/tests/Feature/SourceTest.php b/tests/Feature/SourceTest.php index 09a7390..6dcc551 100644 --- a/tests/Feature/SourceTest.php +++ b/tests/Feature/SourceTest.php @@ -1,6 +1,10 @@ 'pagination/page3.json', ]); })->with('sources'); + +it('fails if a source is not supported', function () { + LazyJsonPages::from(123) + ->totalPages('total_pages') + ->collect('data.*') + ->each(fn() => true); +})->throws(UnsupportedSourceException::class, 'The provided source is not supported.'); + +it('fails if a Laravel client response did not send the request', function () { + $response = new Response(new Psr7Response(body: '{"cursor":"abc"}')); + + LazyJsonPages::from($response) + ->cursor('cursor') + ->collect('data.*') + ->each(fn() => true); +})->throws(RequestNotSentException::class, 'The source did not send any HTTP request.'); diff --git a/tests/Feature/StructureTest.php b/tests/Feature/StructureTest.php index 07452c7..db56c5f 100644 --- a/tests/Feature/StructureTest.php +++ b/tests/Feature/StructureTest.php @@ -16,6 +16,19 @@ ]); }); +it('supports paginations with the current page in the URI path and last page as a URI', function () { + $lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users/page/1') + ->pageInPath() + ->lastPage('meta.last_page_uri') + ->collect('data.*'); + + expect($lazyCollection)->toLoadItemsViaRequests([ + 'https://example.com/api/v1/users/page/1' => 'pagination/page1.json', + 'https://example.com/api/v1/users/page/2' => 'pagination/page2.json', + 'https://example.com/api/v1/users/page/3' => 'pagination/page3.json', + ]); +}); + it('supports a custom pattern for paginations with the current page in the URI path', function () { $lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users/page1') ->pageInPath('~/page(\d+)$~') @@ -92,3 +105,16 @@ 'https://example.com/api/v1/users?skip=10' => 'pagination/page3.json', ]); }); + +it('supports paginations with custom page name', function () { + $lazyCollection = LazyJsonPages::from('https://example.com/api/v1/users') + ->pageName('current_page') + ->totalPages('meta.total_pages') + ->collect('data.*'); + + expect($lazyCollection)->toLoadItemsViaRequests([ + 'https://example.com/api/v1/users' => 'pagination/page1.json', + 'https://example.com/api/v1/users?current_page=2' => 'pagination/page2.json', + 'https://example.com/api/v1/users?current_page=3' => 'pagination/page3.json', + ]); +}); diff --git a/tests/fixtures/pagination/page1.json b/tests/fixtures/pagination/page1.json index d483994..c6af2f7 100644 --- a/tests/fixtures/pagination/page1.json +++ b/tests/fixtures/pagination/page1.json @@ -20,6 +20,8 @@ "total_pages": 3, "total_items": 14, "last_page": 3, - "cursor": "cursor1" + "last_page_uri": "https://example.com/api/v1/users/page/3", + "cursor": "cursor1", + "cursor_uri": "https://example.com/api/v1/users/page/cursor1" } } diff --git a/tests/fixtures/pagination/page2.json b/tests/fixtures/pagination/page2.json index f592a12..f4b41e9 100644 --- a/tests/fixtures/pagination/page2.json +++ b/tests/fixtures/pagination/page2.json @@ -20,6 +20,8 @@ "total_pages": 3, "total_items": 14, "last_page": 3, - "cursor": "cursor2" + "last_page_uri": "https://example.com/api/v1/users/page/3", + "cursor": "cursor2", + "cursor_uri": "https://example.com/api/v1/users/page/cursor2" } } diff --git a/tests/fixtures/pagination/page3.json b/tests/fixtures/pagination/page3.json index ef375ac..b1e6712 100644 --- a/tests/fixtures/pagination/page3.json +++ b/tests/fixtures/pagination/page3.json @@ -17,6 +17,8 @@ "total_pages": 3, "total_items": 14, "last_page": 3, - "cursor": null + "last_page_uri": "https://example.com/api/v1/users/page/3", + "cursor": null, + "cursor_uri": null } } diff --git a/tests/fixtures/paginationWith5Pages/page1.json b/tests/fixtures/paginationWith5Pages/page1.json new file mode 100644 index 0000000..1b82365 --- /dev/null +++ b/tests/fixtures/paginationWith5Pages/page1.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "name": "item1" + }, + { + "name": "item2" + }, + { + "name": "item3" + }, + { + "name": "item4" + }, + { + "name": "item5" + } + ], + "meta": { + "total_pages": 5, + "total_items": 24, + "last_page": 5, + "last_page_uri": "https://example.com/api/v1/users/page/5", + "cursor": "cursor1", + "cursor_uri": "https://example.com/api/v1/users/page/cursor1" + } +} diff --git a/tests/fixtures/paginationWith5Pages/page2.json b/tests/fixtures/paginationWith5Pages/page2.json new file mode 100644 index 0000000..f4539f3 --- /dev/null +++ b/tests/fixtures/paginationWith5Pages/page2.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "name": "item6" + }, + { + "name": "item7" + }, + { + "name": "item8" + }, + { + "name": "item9" + }, + { + "name": "item10" + } + ], + "meta": { + "total_pages": 5, + "total_items": 24, + "last_page": 5, + "last_page_uri": "https://example.com/api/v1/users/page/5", + "cursor": "cursor2", + "cursor_uri": "https://example.com/api/v1/users/page/cursor2" + } +} diff --git a/tests/fixtures/paginationWith5Pages/page3.json b/tests/fixtures/paginationWith5Pages/page3.json new file mode 100644 index 0000000..88a3c4b --- /dev/null +++ b/tests/fixtures/paginationWith5Pages/page3.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "name": "item11" + }, + { + "name": "item12" + }, + { + "name": "item13" + }, + { + "name": "item14" + }, + { + "name": "item15" + } + ], + "meta": { + "total_pages": 5, + "total_items": 24, + "last_page": 5, + "last_page_uri": "https://example.com/api/v1/users/page/5", + "cursor": "cursor3", + "cursor_uri": "https://example.com/api/v1/users/page/cursor3" + } +} diff --git a/tests/fixtures/paginationWith5Pages/page4.json b/tests/fixtures/paginationWith5Pages/page4.json new file mode 100644 index 0000000..2370ce2 --- /dev/null +++ b/tests/fixtures/paginationWith5Pages/page4.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "name": "item16" + }, + { + "name": "item17" + }, + { + "name": "item18" + }, + { + "name": "item19" + }, + { + "name": "item20" + } + ], + "meta": { + "total_pages": 5, + "total_items": 24, + "last_page": 5, + "last_page_uri": "https://example.com/api/v1/users/page/5", + "cursor": "cursor4", + "cursor_uri": "https://example.com/api/v1/users/page/cursor4" + } +} diff --git a/tests/fixtures/paginationWith5Pages/page5.json b/tests/fixtures/paginationWith5Pages/page5.json new file mode 100644 index 0000000..2d28813 --- /dev/null +++ b/tests/fixtures/paginationWith5Pages/page5.json @@ -0,0 +1,24 @@ +{ + "data": [ + { + "name": "item21" + }, + { + "name": "item22" + }, + { + "name": "item23" + }, + { + "name": "item24" + } + ], + "meta": { + "total_pages": 5, + "total_items": 24, + "last_page": 5, + "last_page_uri": "https://example.com/api/v1/users/page/5", + "cursor": null, + "cursor_uri": null + } +}