From ae0eac31975c8e724308ffa52636399a82bc79f6 Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Mon, 10 Feb 2025 21:33:34 +0100 Subject: [PATCH] feat: SearchManga endpoint + tests --- config/services.yaml | 7 + .../Manga/Application/Query/SearchManga.php | 10 ++ .../QueryHandler/SearchMangaHandler.php | 38 +++++ .../Application/Response/MangaSearchItem.php | 19 +++ .../Response/MangaSearchResponse.php | 17 ++ .../Client/MangadexClientInterface.php | 75 +++++++++ .../Provider/MangaProviderInterface.php | 10 ++ .../Domain/Exception/MangaDomainException.php | 6 +- .../Exception/MangaNotFoundException.php | 6 +- .../Domain/Exception/MangadexApiException.php | 11 ++ .../MangadexAuthenticationException.php | 11 ++ src/Domain/Manga/Domain/Model/Manga.php | 9 +- .../Manga/Domain/Model/MangaCollection.php | 27 +++ .../ApiPlatform/Dto/MangaSearchCollection.php | 11 ++ .../ApiPlatform/Dto/MangaSearchItem.php | 22 +++ .../Resource/MangaChaptersResource.php | 46 +++++- .../ApiPlatform/Resource/MangaResource.php | 15 +- .../Resource/MangaSearchResource.php | 35 ++++ .../Provider/SearchMangaStateProvider.php | 43 +++++ .../Infrastructure/Client/MangadexClient.php | 156 ++++++++++++++++++ .../Provider/MangadexProvider.php | 126 ++++++++++++++ .../Manga/Adapter/InMemoryMangaProvider.php | 34 ++++ .../QueryHandler/SearchMangaHandlerTest.php | 60 +++++++ .../Client/MangadexClientTest.php | 130 +++++++++++++++ .../Provider/MangadexProviderTest.php | 108 ++++++++++++ 25 files changed, 1022 insertions(+), 10 deletions(-) create mode 100644 src/Domain/Manga/Application/Query/SearchManga.php create mode 100644 src/Domain/Manga/Application/QueryHandler/SearchMangaHandler.php create mode 100644 src/Domain/Manga/Application/Response/MangaSearchItem.php create mode 100644 src/Domain/Manga/Application/Response/MangaSearchResponse.php create mode 100644 src/Domain/Manga/Domain/Contract/Client/MangadexClientInterface.php create mode 100644 src/Domain/Manga/Domain/Contract/Provider/MangaProviderInterface.php create mode 100644 src/Domain/Manga/Domain/Exception/MangadexApiException.php create mode 100644 src/Domain/Manga/Domain/Exception/MangadexAuthenticationException.php create mode 100644 src/Domain/Manga/Domain/Model/MangaCollection.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/Dto/MangaSearchCollection.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/Dto/MangaSearchItem.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/Resource/MangaSearchResource.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/SearchMangaStateProvider.php create mode 100644 src/Domain/Manga/Infrastructure/Client/MangadexClient.php create mode 100644 src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php create mode 100644 tests/Domain/Manga/Adapter/InMemoryMangaProvider.php create mode 100644 tests/Domain/Manga/Application/QueryHandler/SearchMangaHandlerTest.php create mode 100644 tests/Domain/Manga/Infrastructure/Client/MangadexClientTest.php create mode 100644 tests/Domain/Manga/Infrastructure/Provider/MangadexProviderTest.php diff --git a/config/services.yaml b/config/services.yaml index efc4f62..74c1048 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -100,3 +100,10 @@ services: App\Domain\Scraping\Infrastructure\Service\CbzGenerator: arguments: $projectDir: '%kernel.project_dir%' + + App\Domain\Manga\Infrastructure\Client\MangadexClient: + arguments: + $clientId: '%env(MANGADEX_CLIENT_ID)%' + $clientSecret: '%env(MANGADEX_CLIENT_SECRET)%' + $username: '%env(MANGADEX_USERNAME)%' + $password: '%env(MANGADEX_PASSWORD)%' diff --git a/src/Domain/Manga/Application/Query/SearchManga.php b/src/Domain/Manga/Application/Query/SearchManga.php new file mode 100644 index 0000000..12b6388 --- /dev/null +++ b/src/Domain/Manga/Application/Query/SearchManga.php @@ -0,0 +1,10 @@ +mangaProvider->search($query->title); + + return new MangaSearchResponse( + array_map( + fn ($manga) => new MangaSearchItem( + externalId: $manga->getExternalId()->getValue(), + title: $manga->getTitle()->getValue(), + slug: $manga->getSlug()->getValue(), + description: $manga->getDescription(), + author: $manga->getAuthor(), + publicationYear: $manga->getPublicationYear(), + genres: $manga->getGenres(), + status: $manga->getStatus(), + imageUrl: $manga->getImageUrl(), + rating: $manga->getRating() + ), + $mangaCollection->getItems() + ) + ); + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Application/Response/MangaSearchItem.php b/src/Domain/Manga/Application/Response/MangaSearchItem.php new file mode 100644 index 0000000..eae1e1b --- /dev/null +++ b/src/Domain/Manga/Application/Response/MangaSearchItem.php @@ -0,0 +1,19 @@ +items = $items; + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Domain/Contract/Client/MangadexClientInterface.php b/src/Domain/Manga/Domain/Contract/Client/MangadexClientInterface.php new file mode 100644 index 0000000..e5db0be --- /dev/null +++ b/src/Domain/Manga/Domain/Contract/Client/MangadexClientInterface.php @@ -0,0 +1,75 @@ +, + * description: array, + * year: ?int, + * status: string, + * tags: array}}> + * }, + * relationships: array + * }> + * } + */ + public function searchManga(string $title): array; + + /** + * @param string[] $mangaIds + * @return array{ + * statistics: array + * } + */ + public function getMangaRatings(array $mangaIds): array; + + /** + * @return array{ + * data: array, + * total: int + * } + */ + public function getMangaFeed(string $mangaId, int $offset = 0, int $limit = 500, string $order = 'asc'): array; + + /** + * @return array{ + * result: string, + * volumes: array + * }> + * } + */ + public function getMangaAggregate(string $mangaId): array; +} \ No newline at end of file diff --git a/src/Domain/Manga/Domain/Contract/Provider/MangaProviderInterface.php b/src/Domain/Manga/Domain/Contract/Provider/MangaProviderInterface.php new file mode 100644 index 0000000..655e414 --- /dev/null +++ b/src/Domain/Manga/Domain/Contract/Provider/MangaProviderInterface.php @@ -0,0 +1,10 @@ +rating; } -} + + public function setRating(float $rating): void + { + $this->rating = $rating; + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Domain/Model/MangaCollection.php b/src/Domain/Manga/Domain/Model/MangaCollection.php new file mode 100644 index 0000000..28763f6 --- /dev/null +++ b/src/Domain/Manga/Domain/Model/MangaCollection.php @@ -0,0 +1,27 @@ +items = $items; + } + + /** + * @return Manga[] + */ + public function getItems(): array + { + return $this->items; + } + + public function count(): int + { + return count($this->items); + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Dto/MangaSearchCollection.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Dto/MangaSearchCollection.php new file mode 100644 index 0000000..a1fcf73 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Dto/MangaSearchCollection.php @@ -0,0 +1,11 @@ + [ + [ + 'name' => 'id', + 'in' => 'path', + 'required' => true, + 'schema' => [ + 'type' => 'string' + ], + 'description' => 'The manga identifier' + ], + [ + 'name' => 'page', + 'in' => 'query', + 'required' => false, + 'schema' => [ + 'type' => 'integer', + 'default' => 1 + ], + 'description' => 'The page number' + ], + [ + 'name' => 'limit', + 'in' => 'query', + 'required' => false, + 'schema' => [ + 'type' => 'integer', + 'default' => 20 + ], + 'description' => 'Number of items per page' + ], + [ + 'name' => 'sortOrder', + 'in' => 'query', + 'required' => false, + 'schema' => [ + 'type' => 'string', + 'enum' => ['asc', 'desc'], + 'default' => 'desc' + ], + 'description' => 'Sort order for chapters' + ] + ] + ] ) ] )] diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/MangaResource.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/MangaResource.php index 4672a50..45a2f58 100644 --- a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/MangaResource.php +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/MangaResource.php @@ -13,7 +13,20 @@ use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\GetMangaStateProv new Get( uriTemplate: '/mangas/{id}', provider: GetMangaStateProvider::class, - output: MangaDetail::class + output: MangaDetail::class, + openapiContext: [ + 'parameters' => [ + [ + 'name' => 'id', + 'in' => 'path', + 'required' => true, + 'schema' => [ + 'type' => 'string' + ], + 'description' => 'The manga identifier' + ] + ] + ] ) ] )] diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/MangaSearchResource.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/MangaSearchResource.php new file mode 100644 index 0000000..9625a2c --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/MangaSearchResource.php @@ -0,0 +1,35 @@ + [ + [ + 'name' => 'title', + 'in' => 'query', + 'required' => true, + 'schema' => [ + 'type' => 'string' + ], + 'description' => 'The title to search for' + ] + ] + ], + output: MangaSearchCollection::class, + provider: SearchMangaStateProvider::class + ) + ] +)] +class MangaSearchResource +{ +} diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/SearchMangaStateProvider.php b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/SearchMangaStateProvider.php new file mode 100644 index 0000000..b4dc4ad --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/SearchMangaStateProvider.php @@ -0,0 +1,43 @@ +handler->handle($query); + + return new MangaSearchCollection( + items: array_map( + fn ($item) => new MangaSearchItem( + externalId: $item->externalId, + title: $item->title, + slug: $item->slug, + description: $item->description, + author: $item->author, + publicationYear: $item->publicationYear, + genres: $item->genres, + status: $item->status, + imageUrl: $item->imageUrl, + rating: $item->rating + ), + $response->items + ) + ); + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Infrastructure/Client/MangadexClient.php b/src/Domain/Manga/Infrastructure/Client/MangadexClient.php new file mode 100644 index 0000000..0fab522 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/Client/MangadexClient.php @@ -0,0 +1,156 @@ +client->request('POST', self::AUTH_URL, [ + 'body' => [ + 'grant_type' => 'password', + 'username' => $this->username, + 'password' => $this->password, + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + ] + ]); + + $data = $response->toArray(); + + if (!isset($data['access_token'], $data['refresh_token'])) { + throw new MangadexAuthenticationException('Invalid authentication response from Mangadex'); + } + + $this->accessToken = $data['access_token']; + $this->refreshToken = $data['refresh_token']; + } catch (\Exception $e) { + throw new MangadexAuthenticationException( + 'Failed to authenticate with Mangadex: ' . $e->getMessage(), + $e + ); + } + } + + public function refreshToken(): void + { + if (!$this->refreshToken) { + throw new MangadexAuthenticationException('No refresh token available'); + } + + try { + $response = $this->client->request('POST', self::AUTH_URL, [ + 'body' => [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $this->refreshToken, + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + ] + ]); + + $data = $response->toArray(); + + if (!isset($data['access_token'])) { + throw new MangadexAuthenticationException('Invalid refresh token response from Mangadex'); + } + + $this->accessToken = $data['access_token']; + $this->refreshToken = $data['refresh_token'] ?? $this->refreshToken; + } catch (\Exception $e) { + throw new MangadexAuthenticationException( + 'Failed to refresh token: ' . $e->getMessage(), + $e + ); + } + } + + public function searchManga(string $title): array + { + return $this->get('/manga', [ + 'title' => $title, + 'contentRating' => ['safe', 'suggestive', 'erotica'], + 'excludedTags' => self::EXCLUDED_TAGS, + 'includes' => ['cover_art', 'author'], + 'limit' => 50, + ]); + } + + public function getMangaRatings(array $mangaIds): array + { + return $this->get('/statistics/manga', [ + 'manga' => $mangaIds, + ]); + } + + public function getMangaFeed(string $mangaId, int $offset = 0, int $limit = 500, string $order = 'asc'): array + { + return $this->get('/manga/' . $mangaId . '/feed', [ + 'limit' => $limit, + 'translatedLanguage' => ['en', 'fr'], + 'order' => ['chapter' => $order], + 'offset' => $offset, + ]); + } + + public function getMangaAggregate(string $mangaId): array + { + return $this->get('/manga/' . $mangaId . '/aggregate'); + } + + private function get(string $endpoint, array $params = []): array + { + try { + if (!$this->accessToken) { + $this->authenticate(); + } + + $response = $this->client->request('GET', self::API_URL . $endpoint, [ + 'query' => $params, + 'headers' => [ + 'Authorization' => 'Bearer ' . $this->accessToken + ] + ]); + + // Handle 401 (Unauthorized) by refreshing the token and retrying + if ($response->getStatusCode() === 401) { + $this->refreshToken(); + $response = $this->client->request('GET', self::API_URL . $endpoint, [ + 'query' => $params, + 'headers' => [ + 'Authorization' => 'Bearer ' . $this->accessToken + ] + ]); + } + + return $response->toArray(); + } catch (MangadexAuthenticationException $e) { + throw $e; + } catch (\Exception $e) { + throw new MangadexApiException( + sprintf('Failed to fetch data from Mangadex: %s', $e->getMessage()), + $e + ); + } + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php b/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php new file mode 100644 index 0000000..a48bc6c --- /dev/null +++ b/src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php @@ -0,0 +1,126 @@ +client->searchManga($title); + + if (empty($results['data'])) { + return new MangaCollection([]); + } + + $mangas = $this->createMangasFromResults($results['data']); + $this->enrichWithRatings($mangas); + + usort($mangas, fn ($a, $b) => ($b->getRating() ?? 0) <=> ($a->getRating() ?? 0)); + + return new MangaCollection($mangas); + } + + /** + * @param array $results + * @return Manga[] + */ + private function createMangasFromResults(array $results): array + { + $mangas = []; + foreach ($results as $result) { + $manga = $this->createMangaFromResult($result); + if ($manga !== null) { + $mangas[] = $manga; + } + } + + return $mangas; + } + + private function createMangaFromResult(array $result): ?Manga + { + try { + $attributes = $result['attributes']; + $title = $attributes['title']['en'] ?? null; + + if (!$title) { + return null; + } + + $genres = array_map( + fn ($tag) => $tag['attributes']['name']['en'], + $attributes['tags'] + ); + + $author = ''; + $imageUrl = null; + foreach ($result['relationships'] as $relationship) { + if ($relationship['type'] === 'author') { + $author = $relationship['attributes']['name']; + } + if ($relationship['type'] === 'cover_art') { + $imageUrl = sprintf( + 'https://mangadex.org/covers/%s/%s', + $result['id'], + $relationship['attributes']['fileName'] + ); + } + } + + return new Manga( + new MangaId((string) Uuid::uuid4()), + new MangaTitle($title), + new MangaSlug($this->slugger->slug($title)->lower()), + $attributes['description']['fr'] ?? $attributes['description']['en'] ?? '', + $author, + $attributes['year'] ?? 0, + $genres, + $attributes['status'], + new ExternalId($result['id']), + $imageUrl, + null + ); + } catch (\Exception $e) { + return null; + } + } + + /** + * @param Manga[] $mangas + */ + private function enrichWithRatings(array $mangas): void + { + $externalIds = array_map( + fn (Manga $manga) => $manga->getExternalId()->getValue(), + $mangas + ); + + $ratings = $this->client->getMangaRatings($externalIds); + + if (isset($ratings['statistics'])) { + foreach ($mangas as $manga) { + $externalId = $manga->getExternalId()->getValue(); + if (isset($ratings['statistics'][$externalId]['rating']['average'])) { + $manga->setRating($ratings['statistics'][$externalId]['rating']['average']); + } + } + } + } +} \ No newline at end of file diff --git a/tests/Domain/Manga/Adapter/InMemoryMangaProvider.php b/tests/Domain/Manga/Adapter/InMemoryMangaProvider.php new file mode 100644 index 0000000..d06ad84 --- /dev/null +++ b/tests/Domain/Manga/Adapter/InMemoryMangaProvider.php @@ -0,0 +1,34 @@ +mangas = $mangas; + } + + public function search(string $title): MangaCollection + { + $results = array_filter( + $this->mangas, + fn (Manga $manga) => str_contains( + strtolower($manga->getTitle()->getValue()), + strtolower($title) + ) + ); + + return new MangaCollection($results); + } +} \ No newline at end of file diff --git a/tests/Domain/Manga/Application/QueryHandler/SearchMangaHandlerTest.php b/tests/Domain/Manga/Application/QueryHandler/SearchMangaHandlerTest.php new file mode 100644 index 0000000..9e710a8 --- /dev/null +++ b/tests/Domain/Manga/Application/QueryHandler/SearchMangaHandlerTest.php @@ -0,0 +1,60 @@ +handle(new SearchManga('One Piece')); + + // Assert + $this->assertEmpty($response->items); + } + + public function testHandleReturnsMangaSearchResults(): void + { + // Arrange + $manga = new Manga( + new MangaId('123'), + new MangaTitle('One Piece'), + new MangaSlug('one-piece'), + 'Description test', + 'Eiichiro Oda', + 1997, + ['action', 'adventure'], + 'ongoing', + new ExternalId('external-123'), + 'http://example.com/image.jpg', + 4.5 + ); + + $provider = new InMemoryMangaProvider([$manga]); + $handler = new SearchMangaHandler($provider); + + // Act + $response = $handler->handle(new SearchManga('One Piece')); + + // Assert + $this->assertCount(1, $response->items); + $this->assertEquals('external-123', $response->items[0]->externalId); + $this->assertEquals('One Piece', $response->items[0]->title); + $this->assertEquals('one-piece', $response->items[0]->slug); + } +} \ No newline at end of file diff --git a/tests/Domain/Manga/Infrastructure/Client/MangadexClientTest.php b/tests/Domain/Manga/Infrastructure/Client/MangadexClientTest.php new file mode 100644 index 0000000..47ed10b --- /dev/null +++ b/tests/Domain/Manga/Infrastructure/Client/MangadexClientTest.php @@ -0,0 +1,130 @@ +httpClient = $this->createMock(HttpClientInterface::class); + $this->client = new MangadexClient( + $this->httpClient, + 'client_id', + 'client_secret', + 'username', + 'password' + ); + } + + private function mockAuthenticationResponse(): MockObject&ResponseInterface + { + $authResponse = $this->createMock(ResponseInterface::class); + $authResponse->method('toArray')->willReturn([ + 'access_token' => 'access_token', + 'refresh_token' => 'refresh_token' + ]); + return $authResponse; + } + + public function testAuthenticateSuccess(): void + { + $response = $this->mockAuthenticationResponse(); + + $this->httpClient->expects($this->once()) + ->method('request') + ->with( + 'POST', + 'https://auth.mangadex.org/realms/mangadex/protocol/openid-connect/token', + $this->callback(function ($options) { + return $options['body']['grant_type'] === 'password' + && $options['body']['username'] === 'username' + && $options['body']['password'] === 'password' + && $options['body']['client_id'] === 'client_id' + && $options['body']['client_secret'] === 'client_secret'; + }) + ) + ->willReturn($response); + + $this->client->authenticate(); + } + + public function testAuthenticateFailure(): void + { + $this->httpClient->method('request') + ->willThrowException(new \Exception('Authentication failed')); + + $this->expectException(MangadexAuthenticationException::class); + $this->expectExceptionMessage('Failed to authenticate with Mangadex: Authentication failed'); + + $this->client->authenticate(); + } + + public function testSearchManga(): void + { + $expectedResponse = [ + 'data' => [ + [ + 'id' => '123', + 'attributes' => [ + 'title' => ['en' => 'Test Manga'] + ] + ] + ] + ]; + + $authResponse = $this->mockAuthenticationResponse(); + $searchResponse = $this->createMock(ResponseInterface::class); + $searchResponse->method('toArray')->willReturn($expectedResponse); + $searchResponse->method('getStatusCode')->willReturn(200); + + $this->httpClient->expects($this->exactly(2)) + ->method('request') + ->willReturnCallback(function ($method, $url) use ($authResponse, $searchResponse) { + if (str_contains($url, 'auth.mangadex.org')) { + return $authResponse; + } + return $searchResponse; + }); + + $result = $this->client->searchManga('test'); + $this->assertEquals($expectedResponse, $result); + } + + public function testGetMangaRatings(): void + { + $expectedResponse = [ + 'statistics' => [ + '123' => [ + 'rating' => ['average' => 4.5] + ] + ] + ]; + + $authResponse = $this->mockAuthenticationResponse(); + $ratingsResponse = $this->createMock(ResponseInterface::class); + $ratingsResponse->method('toArray')->willReturn($expectedResponse); + $ratingsResponse->method('getStatusCode')->willReturn(200); + + $this->httpClient->expects($this->exactly(2)) + ->method('request') + ->willReturnCallback(function ($method, $url) use ($authResponse, $ratingsResponse) { + if (str_contains($url, 'auth.mangadex.org')) { + return $authResponse; + } + return $ratingsResponse; + }); + + $result = $this->client->getMangaRatings(['123']); + $this->assertEquals($expectedResponse, $result); + } +} \ No newline at end of file diff --git a/tests/Domain/Manga/Infrastructure/Provider/MangadexProviderTest.php b/tests/Domain/Manga/Infrastructure/Provider/MangadexProviderTest.php new file mode 100644 index 0000000..7d5a1bc --- /dev/null +++ b/tests/Domain/Manga/Infrastructure/Provider/MangadexProviderTest.php @@ -0,0 +1,108 @@ +client = $this->createMock(MangadexClientInterface::class); + $this->provider = new MangadexProvider( + $this->client, + new AsciiSlugger() + ); + } + + public function testSearchWithNoResults(): void + { + $this->client->method('searchManga') + ->willReturn(['data' => []]); + + $result = $this->provider->search('test'); + $this->assertCount(0, $result->getItems()); + } + + public function testSearchWithResults(): void + { + $this->client->method('searchManga') + ->willReturn([ + 'data' => [ + [ + 'id' => '123', + 'attributes' => [ + 'title' => ['en' => 'Test Manga'], + 'description' => ['en' => 'Test description'], + 'year' => 2020, + 'status' => 'ongoing', + 'tags' => [ + ['attributes' => ['name' => ['en' => 'Action']]] + ] + ], + 'relationships' => [ + [ + 'type' => 'author', + 'attributes' => ['name' => 'Test Author'] + ], + [ + 'type' => 'cover_art', + 'attributes' => ['fileName' => 'cover.jpg'] + ] + ] + ] + ] + ]); + + $this->client->method('getMangaRatings') + ->willReturn([ + 'statistics' => [ + '123' => [ + 'rating' => ['average' => 4.5] + ] + ] + ]); + + $result = $this->provider->search('test'); + $mangas = $result->getItems(); + + $this->assertCount(1, $mangas); + $manga = $mangas[0]; + + $this->assertEquals('Test Manga', $manga->getTitle()->getValue()); + $this->assertEquals('test-manga', $manga->getSlug()->getValue()); + $this->assertEquals('Test description', $manga->getDescription()); + $this->assertEquals('Test Author', $manga->getAuthor()); + $this->assertEquals(2020, $manga->getPublicationYear()); + $this->assertEquals(['Action'], $manga->getGenres()); + $this->assertEquals('ongoing', $manga->getStatus()); + $this->assertEquals('123', $manga->getExternalId()->getValue()); + $this->assertEquals(4.5, $manga->getRating()); + } + + public function testSearchWithInvalidData(): void + { + $this->client->method('searchManga') + ->willReturn([ + 'data' => [ + [ + 'id' => '123', + 'attributes' => [ + // Missing required 'title' field + 'description' => ['en' => 'Test description'] + ], + 'relationships' => [] + ] + ] + ]); + + $result = $this->provider->search('test'); + $this->assertCount(0, $result->getItems()); + } +} \ No newline at end of file