feat: SearchManga endpoint + tests
This commit is contained in:
parent
6667cc224b
commit
ae0eac3197
@@ -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)%'
|
||||
|
||||
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;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class MangaDomainException extends NotFoundHttpException
|
||||
class MangaDomainException extends \DomainException
|
||||
{
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Domain\Manga\Domain\Exception;
|
||||
|
||||
class MangaNotFoundException extends MangaDomainException
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class MangaNotFoundException extends NotFoundHttpException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
|
||||
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\MangaTitle;
|
||||
|
||||
readonly class Manga
|
||||
final class Manga
|
||||
{
|
||||
public function __construct(
|
||||
private MangaId $id,
|
||||
@@ -77,4 +77,9 @@ readonly class Manga
|
||||
{
|
||||
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(
|
||||
uriTemplate: '/mangas/{id}/chapters',
|
||||
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(
|
||||
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'
|
||||
]
|
||||
]
|
||||
]
|
||||
)
|
||||
]
|
||||
)]
|
||||
|
||||
@@ -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