feat: endpoint FetchMangaChapters et tests

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-02-11 18:00:49 +01:00
parent 3dc0a0b406
commit 879b8fa2dc
20 changed files with 424 additions and 45 deletions

View File

@@ -25,7 +25,7 @@ framework:
routing:
# Commands
'App\Domain\Scraping\Application\Command\ScrapeChapter': commands
'App\Domain\Manga\Application\Command\FetchMangaChapters': commands
# Events
'App\Domain\Scraping\Domain\Event\ChapterScrapingStarted': events
'App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted': events

View File

@@ -93,7 +93,11 @@ services:
arguments:
$scraperFactory: '@App\Service\Scraper\ScraperFactory'
App\Domain\Scraping\Infrastructure\Handler\SymfonyScrapeChapterHandler:
App\Domain\Scraping\Infrastructure\CommandHandler\SymfonyScrapeChapterHandler:
tags:
- { name: messenger.message_handler, bus: command.bus }
App\Domain\Manga\Infrastructure\CommandHandler\SymfonyFetchMangaChaptersHandler:
tags:
- { name: messenger.message_handler, bus: command.bus }

View File

@@ -5,17 +5,13 @@ services:
public: true
Symfony\Component\Messenger\MessageBusInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryMessageBus'
class: 'App\Tests\Shared\Adapter\InMemoryMessageBus'
public: true
App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
public: true
App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageDownloader'
public: true
App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryCbzGenerator'
arguments:

View File

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

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Domain\Contract\Client\MangadexClientInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use Ramsey\Uuid\Uuid;
readonly class FetchMangaChaptersHandler
{
public function __construct(
private MangadexClientInterface $mangadexClient,
private MangaRepositoryInterface $mangaRepository
) {}
public function handle(FetchMangaChapters $command): void
{
$manga = $this->mangaRepository->findByExternalId(new ExternalId($command->externalId));
if ($manga === null) {
throw new \RuntimeException('Manga not found');
}
$offset = 0;
$limit = 500;
$hasMore = true;
while ($hasMore) {
$feed = $this->mangadexClient->getMangaFeed(
$command->externalId,
$offset,
$limit
);
foreach ($feed['data'] as $chapterData) {
$chapter = new Chapter(
new ChapterId((string) Uuid::uuid4()),
$manga->getId()->getValue(),
(float) $chapterData['attributes']['chapter'],
$chapterData['attributes']['title'],
isset($chapterData['attributes']['volume']) ? (int) $chapterData['attributes']['volume'] : null,
true,
new \DateTimeImmutable()
);
$this->mangaRepository->saveChapter($chapter);
}
$offset += $limit;
$hasMore = count($feed['data']) === $limit;
}
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Domain\Manga\Domain\Contract\Repository;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
interface MangaRepositoryInterface
{
@@ -13,4 +15,6 @@ interface MangaRepositoryInterface
public function delete(Manga $manga): void;
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array;
public function countChapters(string $mangaId): int;
public function findByExternalId(ExternalId $externalId): ?Manga;
public function saveChapter(Chapter $chapter): void;
}

View File

@@ -21,6 +21,11 @@ readonly class Chapter
return $this->id->getValue();
}
public function getMangaId(): string
{
return $this->mangaId;
}
public function getNumber(): float
{
return $this->number;

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\FetchMangaChaptersProcessor;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'FetchMangaChapters',
operations: [
new Post(
uriTemplate: '/manga/chapters/fetch',
processor: FetchMangaChaptersProcessor::class,
status: 202
)
]
)]
class FetchMangaChaptersResource
{
public function __construct(
#[Assert\NotBlank]
public string $externalId
) {}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\FetchMangaChaptersResource;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class FetchMangaChaptersProcessor implements ProcessorInterface
{
public function __construct(
private MessageBusInterface $messageBus
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
if (!$data instanceof FetchMangaChaptersResource) {
throw new \InvalidArgumentException('Invalid resource type');
}
$this->messageBus->dispatch(
new FetchMangaChapters($data->externalId)
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domain\Manga\Infrastructure\CommandHandler;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Application\CommandHandler\FetchMangaChaptersHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class SymfonyFetchMangaChaptersHandler
{
public function __construct(
private FetchMangaChaptersHandler $handler
) {}
public function __invoke(FetchMangaChapters $command): void
{
$this->handler->handle($command);
}
}

View File

@@ -126,6 +126,34 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
->getSingleScalarResult();
}
public function findByExternalId(ExternalId $externalId): ?DomainManga
{
$entity = $this->entityManager->getRepository(EntityManga::class)->findOneBy([
'externalId' => $externalId->getValue()
]);
return $entity ? $this->toDomain($entity) : null;
}
public function saveChapter(Chapter $chapter): void
{
$manga = $this->entityManager->find(EntityManga::class, $chapter->getMangaId());
if (!$manga) {
throw new \RuntimeException('Manga not found');
}
$entity = new EntityChapter();
$entity->setManga($manga)
->setNumber($chapter->getNumber())
->setTitle($chapter->getTitle())
->setVolume($chapter->getVolume())
->setVisible($chapter->isVisible());
$this->entityManager->persist($entity);
$this->entityManager->flush();
}
private function toDomain(EntityManga $entity): DomainManga
{
return new DomainManga(

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domain\Scraping\Infrastructure\Handler;
namespace App\Domain\Scraping\Infrastructure\CommandHandler;
use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;

View File

@@ -6,18 +6,22 @@ use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
class InMemoryMangaRepository implements MangaRepositoryInterface
{
/** @var Manga[] */
/** @var array<string, Manga> */
private array $mangas = [];
/** @var array<string, Chapter[]> */
/** @var array<string, array<Chapter>> */
private array $chapters = [];
/** @var array<Chapter> */
private array $savedChapters = [];
public function findAll(int $page = 1, int $limit = 20, string $sortBy = 'title', string $sortOrder = 'asc'): array
{
$sortedMangas = $this->mangas;
$sortedMangas = array_values($this->mangas);
usort($sortedMangas, function (Manga $a, Manga $b) use ($sortBy, $sortOrder) {
$valueA = $this->getPropertyValue($a, $sortBy);
@@ -40,26 +44,17 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
public function findById(string $id): ?Manga
{
foreach ($this->mangas as $manga) {
if ($manga->getId()->getValue() === $id) {
return $manga;
}
}
return null;
return $this->mangas[$id] ?? null;
}
public function save(Manga $manga): void
{
$this->mangas[] = $manga;
$this->mangas[$manga->getId()->getValue()] = $manga;
}
public function delete(Manga $manga): void
{
$this->mangas = array_filter(
$this->mangas,
fn(Manga $existingManga) => !$existingManga->getId()->equals($manga->getId())
);
unset($this->mangas[$manga->getId()->getValue()]);
}
private function getPropertyValue(Manga $manga, string $property): mixed
@@ -91,7 +86,7 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
public function countChapters(string $mangaId): int
{
return isset($this->chapters[$mangaId]) ? count($this->chapters[$mangaId]) : 0;
return count($this->chapters[$mangaId] ?? []);
}
public function addChaptersToManga(string $mangaId, int $count): void
@@ -111,9 +106,35 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
}
}
public function findByExternalId(ExternalId $externalId): ?Manga
{
foreach ($this->mangas as $manga) {
if ($manga->getExternalId() && $manga->getExternalId()->getValue() === $externalId->getValue()) {
return $manga;
}
}
return null;
}
public function saveChapter(Chapter $chapter): void
{
$this->savedChapters[] = $chapter;
if (!isset($this->chapters[$chapter->getMangaId()])) {
$this->chapters[$chapter->getMangaId()] = [];
}
$this->chapters[$chapter->getMangaId()][] = $chapter;
}
/** @return array<Chapter> */
public function getSavedChapters(): array
{
return $this->savedChapters;
}
public function clear(): void
{
$this->mangas = [];
$this->chapters = [];
$this->savedChapters = [];
}
}

View File

@@ -9,6 +9,7 @@ class InMemoryMangadexClient implements MangadexClientInterface
private array $mangas = [];
private array $feeds = [];
private array $aggregates = [];
private array $mangaFeeds = [];
public function __construct(
array $mangas = [],
@@ -61,22 +62,7 @@ class InMemoryMangadexClient implements MangadexClientInterface
public function getMangaFeed(string $mangaId, int $offset = 0, int $limit = 500, string $order = 'asc'): array
{
if (!isset($this->feeds[$mangaId])) {
return [
'data' => [],
'total' => 0
];
}
$feed = $this->feeds[$mangaId];
if ($order === 'desc') {
$feed = array_reverse($feed);
}
return [
'data' => array_slice($feed, $offset, $limit),
'total' => count($feed)
];
return $this->mangaFeeds[$mangaId] ?? ['data' => []];
}
public function getMangaAggregate(string $mangaId): array
@@ -120,4 +106,9 @@ class InMemoryMangadexClient implements MangadexClientInterface
{
$this->aggregates[$mangaId] = $aggregate;
}
public function setMangaFeed(string $mangaId, array $feed): void
{
$this->mangaFeeds[$mangaId] = $feed;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Application\CommandHandler\FetchMangaChaptersHandler;
use App\Domain\Manga\Domain\Model\Manga;
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\InMemoryMangadexClient;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use PHPUnit\Framework\TestCase;
class FetchMangaChaptersHandlerTest extends TestCase
{
private InMemoryMangadexClient $mangadexClient;
private InMemoryMangaRepository $mangaRepository;
private FetchMangaChaptersHandler $handler;
protected function setUp(): void
{
$this->mangadexClient = new InMemoryMangadexClient();
$this->mangaRepository = new InMemoryMangaRepository();
$this->handler = new FetchMangaChaptersHandler(
$this->mangadexClient,
$this->mangaRepository
);
}
public function testHandleWithExistingManga(): void
{
$externalId = 'manga-123';
$manga = new Manga(
new MangaId('manga-id'),
new MangaTitle('Test Manga'),
new MangaSlug('test-manga'),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId($externalId)
);
$this->mangaRepository->save($manga);
$this->mangadexClient->setMangaFeed($externalId, [
'data' => [
[
'id' => 'chapter-1',
'attributes' => [
'chapter' => '1',
'title' => 'Chapter 1',
'volume' => '1'
]
]
]
]);
$command = new FetchMangaChapters($externalId);
$this->handler->handle($command);
$this->assertCount(1, $this->mangaRepository->getSavedChapters());
}
public function testHandleWithNonExistingManga(): void
{
$externalId = 'non-existing-manga';
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Manga not found');
$command = new FetchMangaChapters($externalId);
$this->handler->handle($command);
}
}

View File

@@ -57,7 +57,7 @@ class GetMangaChaptersHandlerTest extends TestCase
// Act
$query = new GetMangaChapters('123', page: 2, limit: 10);
$response = $this->handler->handle($query);
// Assert
$this->assertCount(10, $response->chapters);
$this->assertEquals(25, $response->total);

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Tests\Feature\Manga;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Domain\Model\Manga;
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\Feature\AbstractApiTestCase;
use App\Tests\Shared\Adapter\InMemoryMessageBus;
use Symfony\Component\Messenger\MessageBusInterface;
use Zenstruck\Foundry\Test\ResetDatabase;
class FetchMangaChaptersTest extends AbstractApiTestCase
{
use ResetDatabase;
private InMemoryMessageBus $messageBus;
protected function setUp(): void
{
parent::setUp();
$this->messageBus = new InMemoryMessageBus();
$this->container->set(MessageBusInterface::class, $this->messageBus);
$this->messageBus->clear();
}
public function testFetchChaptersForExistingManga(): void
{
$externalId = 'manga-123';
$manga = new Manga(
new MangaId('manga-id'),
new MangaTitle('Test Manga'),
new MangaSlug('test-manga'),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId($externalId)
);
$this->entityManager->persist($this->toEntity($manga));
$this->entityManager->flush();
static::createClient()->request('POST', '/api/manga/chapters/fetch', [
'json' => [
'externalId' => $externalId
]
]);
$this->assertResponseStatusCodeSame(202);
$messages = $this->messageBus->getDispatchedMessages();
$this->assertCount(1, $messages);
$this->assertInstanceOf(FetchMangaChapters::class, $messages[0]);
$this->assertEquals($externalId, $messages[0]->externalId);
}
public function testFetchChaptersWithInvalidExternalId(): void
{
$response = static::createClient()->request('POST', '/api/manga/chapters/fetch', [
'json' => [
'externalId' => ''
]
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
[
'propertyPath' => 'externalId',
'message' => 'This value should not be blank.'
]
]
]);
}
private function toEntity(Manga $manga): \App\Entity\Manga
{
$entity = new \App\Entity\Manga();
$entity->setTitle($manga->getTitle()->getValue())
->setSlug($manga->getSlug()->getValue())
->setDescription($manga->getDescription())
->setAuthor($manga->getAuthor())
->setPublicationYear($manga->getPublicationYear())
->setGenres($manga->getGenres())
->setStatus($manga->getStatus())
->setExternalId($manga->getExternalId()->getValue())
->setMonitored(false);
return $entity;
}
protected function tearDown(): void
{
parent::tearDown();
$this->messageBus->clear();
}
}

View File

@@ -3,8 +3,8 @@
namespace App\Tests\Feature\Scraping;
use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Tests\Domain\Scraping\Adapter\InMemoryMessageBus;
use App\Tests\Feature\AbstractApiTestCase;
use App\Tests\Shared\Adapter\InMemoryMessageBus;
use Symfony\Component\Messenger\MessageBusInterface;
class ScrapeChapterTest extends AbstractApiTestCase
@@ -14,7 +14,9 @@ class ScrapeChapterTest extends AbstractApiTestCase
protected function setUp(): void
{
parent::setUp();
$this->messageBus = self::getContainer()->get(MessageBusInterface::class);
$this->messageBus = new InMemoryMessageBus();
$this->container->set(MessageBusInterface::class, $this->messageBus);
$this->messageBus->clear();
}
public function testInitiateChapterScraping(): void
@@ -69,4 +71,10 @@ class ScrapeChapterTest extends AbstractApiTestCase
],
]);
}
protected function tearDown(): void
{
parent::tearDown();
$this->messageBus->clear();
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Tests\Domain\Scraping\Adapter;
namespace App\Tests\Shared\Adapter;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -35,4 +35,4 @@ class InMemoryMessageBus implements MessageBusInterface
}
return false;
}
}
}