feat: SearchManga endpoint + tests
This commit is contained in:
parent
6667cc224b
commit
ae0eac3197
@@ -100,3 +100,10 @@ services:
|
|||||||
App\Domain\Scraping\Infrastructure\Service\CbzGenerator:
|
App\Domain\Scraping\Infrastructure\Service\CbzGenerator:
|
||||||
arguments:
|
arguments:
|
||||||
$projectDir: '%kernel.project_dir%'
|
$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)%'
|
||||||
|
|||||||
10
src/Domain/Manga/Application/Query/SearchManga.php
Normal file
10
src/Domain/Manga/Application/Query/SearchManga.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\Query;
|
||||||
|
|
||||||
|
readonly class SearchManga
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $title
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\QueryHandler;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Query\SearchManga;
|
||||||
|
use App\Domain\Manga\Application\Response\MangaSearchItem;
|
||||||
|
use App\Domain\Manga\Application\Response\MangaSearchResponse;
|
||||||
|
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
|
||||||
|
|
||||||
|
readonly class SearchMangaHandler
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MangaProviderInterface $mangaProvider
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(SearchManga $query): MangaSearchResponse
|
||||||
|
{
|
||||||
|
$mangaCollection = $this->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()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/Domain/Manga/Application/Response/MangaSearchItem.php
Normal file
19
src/Domain/Manga/Application/Response/MangaSearchItem.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\Response;
|
||||||
|
|
||||||
|
readonly class MangaSearchItem
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $externalId,
|
||||||
|
public string $title,
|
||||||
|
public string $slug,
|
||||||
|
public string $description,
|
||||||
|
public string $author,
|
||||||
|
public int $publicationYear,
|
||||||
|
public array $genres,
|
||||||
|
public string $status,
|
||||||
|
public ?string $imageUrl,
|
||||||
|
public ?float $rating
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Application\Response;
|
||||||
|
|
||||||
|
readonly class MangaSearchResponse
|
||||||
|
{
|
||||||
|
/** @var MangaSearchItem[] */
|
||||||
|
public array $items;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param MangaSearchItem[] $items
|
||||||
|
*/
|
||||||
|
public function __construct(array $items)
|
||||||
|
{
|
||||||
|
$this->items = $items;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Domain\Contract\Client;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Exception\MangadexAuthenticationException;
|
||||||
|
|
||||||
|
interface MangadexClientInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws \App\Domain\Manga\Domain\Exception\MangadexAuthenticationException
|
||||||
|
*/
|
||||||
|
public function authenticate(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \App\Domain\Manga\Domain\Exception\MangadexAuthenticationException
|
||||||
|
*/
|
||||||
|
public function refreshToken(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* result: string,
|
||||||
|
* data: array<array{
|
||||||
|
* id: string,
|
||||||
|
* attributes: array{
|
||||||
|
* title: array<string, string>,
|
||||||
|
* description: array<string, string>,
|
||||||
|
* year: ?int,
|
||||||
|
* status: string,
|
||||||
|
* tags: array<array{attributes: array{name: array<string, string>}}>
|
||||||
|
* },
|
||||||
|
* relationships: array<array{
|
||||||
|
* type: string,
|
||||||
|
* attributes: array{name: string|null, fileName: string|null}
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function searchManga(string $title): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $mangaIds
|
||||||
|
* @return array{
|
||||||
|
* statistics: array<string, array{
|
||||||
|
* rating: array{average: float}
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function getMangaRatings(array $mangaIds): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* data: array<array{
|
||||||
|
* id: string,
|
||||||
|
* attributes: array{
|
||||||
|
* chapter: string,
|
||||||
|
* title: ?string,
|
||||||
|
* volume: ?string
|
||||||
|
* }
|
||||||
|
* }>,
|
||||||
|
* total: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function getMangaFeed(string $mangaId, int $offset = 0, int $limit = 500, string $order = 'asc'): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* result: string,
|
||||||
|
* volumes: array<string, array{
|
||||||
|
* volume: string,
|
||||||
|
* chapters: array<string, array{chapter: string}>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function getMangaAggregate(string $mangaId): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Domain\Contract\Provider;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Model\MangaCollection;
|
||||||
|
|
||||||
|
interface MangaProviderInterface
|
||||||
|
{
|
||||||
|
public function search(string $title): MangaCollection;
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domain\Manga\Domain\Exception;
|
namespace App\Domain\Manga\Domain\Exception;
|
||||||
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
class MangaDomainException extends \DomainException
|
||||||
|
|
||||||
class MangaDomainException extends NotFoundHttpException
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Domain\Manga\Domain\Exception;
|
namespace App\Domain\Manga\Domain\Exception;
|
||||||
|
|
||||||
class MangaNotFoundException extends MangaDomainException
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class MangaNotFoundException extends NotFoundHttpException
|
||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
parent::__construct('Manga not found');
|
parent::__construct('Manga not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/Domain/Manga/Domain/Exception/MangadexApiException.php
Normal file
11
src/Domain/Manga/Domain/Exception/MangadexApiException.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Domain\Exception;
|
||||||
|
|
||||||
|
class MangadexApiException extends MangaDomainException
|
||||||
|
{
|
||||||
|
public function __construct(string $message, ?\Throwable $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct($message, 0, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Domain\Exception;
|
||||||
|
|
||||||
|
class MangadexAuthenticationException extends MangaDomainException
|
||||||
|
{
|
||||||
|
public function __construct(string $message, ?\Throwable $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct($message, 0, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
|||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||||
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||||
|
|
||||||
readonly class Manga
|
final class Manga
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private MangaId $id,
|
private MangaId $id,
|
||||||
@@ -77,4 +77,9 @@ readonly class Manga
|
|||||||
{
|
{
|
||||||
return $this->rating;
|
return $this->rating;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public function setRating(float $rating): void
|
||||||
|
{
|
||||||
|
$this->rating = $rating;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Domain/Manga/Domain/Model/MangaCollection.php
Normal file
27
src/Domain/Manga/Domain/Model/MangaCollection.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Domain\Model;
|
||||||
|
|
||||||
|
readonly class MangaCollection
|
||||||
|
{
|
||||||
|
/** @var Manga[] */
|
||||||
|
private array $items;
|
||||||
|
|
||||||
|
public function __construct(array $items)
|
||||||
|
{
|
||||||
|
$this->items = $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Manga[]
|
||||||
|
*/
|
||||||
|
public function getItems(): array
|
||||||
|
{
|
||||||
|
return $this->items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return count($this->items);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Dto;
|
||||||
|
|
||||||
|
readonly class MangaSearchCollection
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
/** @var MangaSearchItem[] */
|
||||||
|
public array $items
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Dto;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
|
|
||||||
|
readonly class MangaSearchItem
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[ApiProperty(identifier: true)]
|
||||||
|
public string $externalId,
|
||||||
|
public string $title,
|
||||||
|
public string $slug,
|
||||||
|
public string $description,
|
||||||
|
public string $author,
|
||||||
|
public int $publicationYear,
|
||||||
|
public array $genres,
|
||||||
|
public string $status,
|
||||||
|
public ?string $imageUrl,
|
||||||
|
public ?float $rating
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -13,7 +13,51 @@ use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\GetMangaChaptersS
|
|||||||
new Get(
|
new Get(
|
||||||
uriTemplate: '/mangas/{id}/chapters',
|
uriTemplate: '/mangas/{id}/chapters',
|
||||||
provider: GetMangaChaptersStateProvider::class,
|
provider: GetMangaChaptersStateProvider::class,
|
||||||
output: ChapterCollection::class
|
output: ChapterCollection::class,
|
||||||
|
openapiContext: [
|
||||||
|
'parameters' => [
|
||||||
|
[
|
||||||
|
'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'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -13,7 +13,20 @@ use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\GetMangaStateProv
|
|||||||
new Get(
|
new Get(
|
||||||
uriTemplate: '/mangas/{id}',
|
uriTemplate: '/mangas/{id}',
|
||||||
provider: GetMangaStateProvider::class,
|
provider: GetMangaStateProvider::class,
|
||||||
output: MangaDetail::class
|
output: MangaDetail::class,
|
||||||
|
openapiContext: [
|
||||||
|
'parameters' => [
|
||||||
|
[
|
||||||
|
'name' => 'id',
|
||||||
|
'in' => 'path',
|
||||||
|
'required' => true,
|
||||||
|
'schema' => [
|
||||||
|
'type' => 'string'
|
||||||
|
],
|
||||||
|
'description' => 'The manga identifier'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchCollection;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\SearchMangaStateProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'MangaSearch',
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/mangas-search',
|
||||||
|
openapiContext: [
|
||||||
|
'parameters' => [
|
||||||
|
[
|
||||||
|
'name' => 'title',
|
||||||
|
'in' => 'query',
|
||||||
|
'required' => true,
|
||||||
|
'schema' => [
|
||||||
|
'type' => 'string'
|
||||||
|
],
|
||||||
|
'description' => 'The title to search for'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
output: MangaSearchCollection::class,
|
||||||
|
provider: SearchMangaStateProvider::class
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
class MangaSearchResource
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Domain\Manga\Application\Query\SearchManga;
|
||||||
|
use App\Domain\Manga\Application\QueryHandler\SearchMangaHandler;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchCollection;
|
||||||
|
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaSearchItem;
|
||||||
|
|
||||||
|
readonly class SearchMangaStateProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SearchMangaHandler $handler
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): MangaSearchCollection
|
||||||
|
{
|
||||||
|
$title = $context['filters']['title'] ?? '';
|
||||||
|
|
||||||
|
$query = new SearchManga($title);
|
||||||
|
$response = $this->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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/Domain/Manga/Infrastructure/Client/MangadexClient.php
Normal file
156
src/Domain/Manga/Infrastructure/Client/MangadexClient.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Client;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Contract\Client\MangadexClientInterface;
|
||||||
|
use App\Domain\Manga\Domain\Exception\MangadexApiException;
|
||||||
|
use App\Domain\Manga\Domain\Exception\MangadexAuthenticationException;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
class MangadexClient implements MangadexClientInterface
|
||||||
|
{
|
||||||
|
private const API_URL = 'https://api.mangadex.org';
|
||||||
|
private const AUTH_URL = 'https://auth.mangadex.org/realms/mangadex/protocol/openid-connect/token';
|
||||||
|
private const EXCLUDED_TAGS = ['0234a31e-a729-4e28-9d6a-3f87c4966b9e' , 'b13b2a48-c720-44a9-9c77-39c9979373fb', '891cf039-b895-47f0-9229-bef4c96eccd4'];
|
||||||
|
|
||||||
|
private ?string $accessToken = null;
|
||||||
|
private ?string $refreshToken = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private HttpClientInterface $client,
|
||||||
|
private string $clientId,
|
||||||
|
private string $clientSecret,
|
||||||
|
private string $username,
|
||||||
|
private string $password
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function authenticate(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = $this->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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php
Normal file
126
src/Domain/Manga/Infrastructure/Provider/MangadexProvider.php
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domain\Manga\Infrastructure\Provider;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Contract\Client\MangadexClientInterface;
|
||||||
|
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
|
||||||
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
|
use App\Domain\Manga\Domain\Model\MangaCollection;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||||
|
|
||||||
|
|
||||||
|
readonly class MangadexProvider implements MangaProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MangadexClientInterface $client,
|
||||||
|
private SluggerInterface $slugger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function search(string $title): MangaCollection
|
||||||
|
{
|
||||||
|
$results = $this->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<mixed> $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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
tests/Domain/Manga/Adapter/InMemoryMangaProvider.php
Normal file
34
tests/Domain/Manga/Adapter/InMemoryMangaProvider.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Domain\Manga\Adapter;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Contract\Provider\MangaProviderInterface;
|
||||||
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
|
use App\Domain\Manga\Domain\Model\MangaCollection;
|
||||||
|
|
||||||
|
class InMemoryMangaProvider implements MangaProviderInterface
|
||||||
|
{
|
||||||
|
/** @var Manga[] */
|
||||||
|
private array $mangas;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Manga[] $mangas
|
||||||
|
*/
|
||||||
|
public function __construct(array $mangas = [])
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Domain\Manga\Application\QueryHandler;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Application\Query\SearchManga;
|
||||||
|
use App\Domain\Manga\Application\QueryHandler\SearchMangaHandler;
|
||||||
|
use App\Domain\Manga\Domain\Model\Manga;
|
||||||
|
use App\Domain\Manga\Domain\Model\MangaCollection;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
|
||||||
|
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
|
||||||
|
use App\Tests\Domain\Manga\Adapter\InMemoryMangaProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class SearchMangaHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testHandleReturnsEmptyResultWhenNoMatches(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$provider = new InMemoryMangaProvider([]);
|
||||||
|
$handler = new SearchMangaHandler($provider);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $handler->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
tests/Domain/Manga/Infrastructure/Client/MangadexClientTest.php
Normal file
130
tests/Domain/Manga/Infrastructure/Client/MangadexClientTest.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Domain\Manga\Infrastructure\Client;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Exception\MangadexAuthenticationException;
|
||||||
|
use App\Domain\Manga\Infrastructure\Client\MangadexClient;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||||
|
|
||||||
|
class MangadexClientTest extends TestCase
|
||||||
|
{
|
||||||
|
private HttpClientInterface $httpClient;
|
||||||
|
private MangadexClient $client;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Domain\Manga\Infrastructure\Provider;
|
||||||
|
|
||||||
|
use App\Domain\Manga\Domain\Contract\Client\MangadexClientInterface;
|
||||||
|
use App\Domain\Manga\Infrastructure\Provider\MangadexProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\String\Slugger\AsciiSlugger;
|
||||||
|
|
||||||
|
class MangadexProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
private MangadexClientInterface $client;
|
||||||
|
private MangadexProvider $provider;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user