feat: SearchManga endpoint + tests

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-02-10 21:33:34 +01:00
parent 6667cc224b
commit ae0eac3197
25 changed files with 1022 additions and 10 deletions

View File

@@ -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)%'

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Domain\Manga\Application\Query;
readonly class SearchManga
{
public function __construct(
public string $title
) {}
}

View File

@@ -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()
)
);
}
}

View 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
) {}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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
{ {
} }

View File

@@ -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');
} }
} }

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View 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);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Dto;
readonly class MangaSearchCollection
{
public function __construct(
/** @var MangaSearchItem[] */
public array $items
) {}
}

View File

@@ -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
) {}
}

View File

@@ -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'
]
]
]
) )
] ]
)] )]

View File

@@ -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'
]
]
]
) )
] ]
)] )]

View File

@@ -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
{
}

View File

@@ -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
)
);
}
}

View 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
);
}
}
}

View 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']);
}
}
}
}
}

View 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);
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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());
}
}