From 879b8fa2dc17ecbf31ee9928105cf2928714400a Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Tue, 11 Feb 2025 18:00:49 +0100 Subject: [PATCH] feat: endpoint FetchMangaChapters et tests --- config/packages/messenger.yaml | 2 +- config/services.yaml | 6 +- config/services_test.yaml | 6 +- .../Command/FetchMangaChapters.php | 10 ++ .../FetchMangaChaptersHandler.php | 57 ++++++++++ .../Repository/MangaRepositoryInterface.php | 4 + src/Domain/Manga/Domain/Model/Chapter.php | 5 + .../Resource/FetchMangaChaptersResource.php | 26 +++++ .../Processor/FetchMangaChaptersProcessor.php | 27 +++++ .../SymfonyFetchMangaChaptersHandler.php | 20 ++++ .../Persistence/LegacyMangaRepository.php | 28 +++++ .../SymfonyScrapeChapterHandler.php | 2 +- .../Manga/Adapter/InMemoryMangaRepository.php | 53 ++++++--- .../Manga/Adapter/InMemoryMangadexClient.php | 23 ++-- .../FetchMangaChaptersHandlerTest.php | 78 +++++++++++++ .../GetMangaChaptersHandlerTest.php | 2 +- tests/Feature/ApiTestCase.php | 1 + .../Feature/Manga/FetchMangaChaptersTest.php | 103 ++++++++++++++++++ tests/Feature/Scraping/ScrapeChapterTest.php | 12 +- .../Adapter/InMemoryMessageBus.php | 4 +- 20 files changed, 424 insertions(+), 45 deletions(-) create mode 100644 src/Domain/Manga/Application/Command/FetchMangaChapters.php create mode 100644 src/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandler.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/Resource/FetchMangaChaptersResource.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/State/Processor/FetchMangaChaptersProcessor.php create mode 100644 src/Domain/Manga/Infrastructure/CommandHandler/SymfonyFetchMangaChaptersHandler.php rename src/Domain/Scraping/Infrastructure/{Handler => CommandHandler}/SymfonyScrapeChapterHandler.php (88%) create mode 100644 tests/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandlerTest.php create mode 100644 tests/Feature/ApiTestCase.php create mode 100644 tests/Feature/Manga/FetchMangaChaptersTest.php rename tests/{Domain/Scraping => Shared}/Adapter/InMemoryMessageBus.php (94%) diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index d8acfe8..ce4a541 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -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 diff --git a/config/services.yaml b/config/services.yaml index 5fdc806..0c35be7 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 } diff --git a/config/services_test.yaml b/config/services_test.yaml index 94822ee..351b642 100644 --- a/config/services_test.yaml +++ b/config/services_test.yaml @@ -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: diff --git a/src/Domain/Manga/Application/Command/FetchMangaChapters.php b/src/Domain/Manga/Application/Command/FetchMangaChapters.php new file mode 100644 index 0000000..d76a952 --- /dev/null +++ b/src/Domain/Manga/Application/Command/FetchMangaChapters.php @@ -0,0 +1,10 @@ +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; + } + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Domain/Contract/Repository/MangaRepositoryInterface.php b/src/Domain/Manga/Domain/Contract/Repository/MangaRepositoryInterface.php index c93192c..303299d 100644 --- a/src/Domain/Manga/Domain/Contract/Repository/MangaRepositoryInterface.php +++ b/src/Domain/Manga/Domain/Contract/Repository/MangaRepositoryInterface.php @@ -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; } \ No newline at end of file diff --git a/src/Domain/Manga/Domain/Model/Chapter.php b/src/Domain/Manga/Domain/Model/Chapter.php index ed97f20..2c66820 100644 --- a/src/Domain/Manga/Domain/Model/Chapter.php +++ b/src/Domain/Manga/Domain/Model/Chapter.php @@ -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; diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/FetchMangaChaptersResource.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/FetchMangaChaptersResource.php new file mode 100644 index 0000000..d4f894d --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/FetchMangaChaptersResource.php @@ -0,0 +1,26 @@ +messageBus->dispatch( + new FetchMangaChapters($data->externalId) + ); + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Infrastructure/CommandHandler/SymfonyFetchMangaChaptersHandler.php b/src/Domain/Manga/Infrastructure/CommandHandler/SymfonyFetchMangaChaptersHandler.php new file mode 100644 index 0000000..986cf4a --- /dev/null +++ b/src/Domain/Manga/Infrastructure/CommandHandler/SymfonyFetchMangaChaptersHandler.php @@ -0,0 +1,20 @@ +handler->handle($command); + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php b/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php index 935b60d..fdbcbd8 100644 --- a/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php +++ b/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php @@ -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( diff --git a/src/Domain/Scraping/Infrastructure/Handler/SymfonyScrapeChapterHandler.php b/src/Domain/Scraping/Infrastructure/CommandHandler/SymfonyScrapeChapterHandler.php similarity index 88% rename from src/Domain/Scraping/Infrastructure/Handler/SymfonyScrapeChapterHandler.php rename to src/Domain/Scraping/Infrastructure/CommandHandler/SymfonyScrapeChapterHandler.php index be2a803..ef260e6 100644 --- a/src/Domain/Scraping/Infrastructure/Handler/SymfonyScrapeChapterHandler.php +++ b/src/Domain/Scraping/Infrastructure/CommandHandler/SymfonyScrapeChapterHandler.php @@ -1,6 +1,6 @@ */ private array $mangas = []; - /** @var array */ + /** @var array> */ private array $chapters = []; + /** @var array */ + 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 */ + public function getSavedChapters(): array + { + return $this->savedChapters; + } + public function clear(): void { $this->mangas = []; $this->chapters = []; + $this->savedChapters = []; } } \ No newline at end of file diff --git a/tests/Domain/Manga/Adapter/InMemoryMangadexClient.php b/tests/Domain/Manga/Adapter/InMemoryMangadexClient.php index 1996aea..a7f27bb 100644 --- a/tests/Domain/Manga/Adapter/InMemoryMangadexClient.php +++ b/tests/Domain/Manga/Adapter/InMemoryMangadexClient.php @@ -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; + } } \ No newline at end of file diff --git a/tests/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandlerTest.php b/tests/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandlerTest.php new file mode 100644 index 0000000..fa05a6f --- /dev/null +++ b/tests/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandlerTest.php @@ -0,0 +1,78 @@ +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); + } +} \ No newline at end of file diff --git a/tests/Domain/Manga/Application/QueryHandler/GetMangaChaptersHandlerTest.php b/tests/Domain/Manga/Application/QueryHandler/GetMangaChaptersHandlerTest.php index de4b1a9..56768a5 100644 --- a/tests/Domain/Manga/Application/QueryHandler/GetMangaChaptersHandlerTest.php +++ b/tests/Domain/Manga/Application/QueryHandler/GetMangaChaptersHandlerTest.php @@ -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); diff --git a/tests/Feature/ApiTestCase.php b/tests/Feature/ApiTestCase.php new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/tests/Feature/ApiTestCase.php @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Feature/Manga/FetchMangaChaptersTest.php b/tests/Feature/Manga/FetchMangaChaptersTest.php new file mode 100644 index 0000000..9758ee1 --- /dev/null +++ b/tests/Feature/Manga/FetchMangaChaptersTest.php @@ -0,0 +1,103 @@ +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(); + } +} diff --git a/tests/Feature/Scraping/ScrapeChapterTest.php b/tests/Feature/Scraping/ScrapeChapterTest.php index 6ba0a47..4ddb207 100644 --- a/tests/Feature/Scraping/ScrapeChapterTest.php +++ b/tests/Feature/Scraping/ScrapeChapterTest.php @@ -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(); + } } diff --git a/tests/Domain/Scraping/Adapter/InMemoryMessageBus.php b/tests/Shared/Adapter/InMemoryMessageBus.php similarity index 94% rename from tests/Domain/Scraping/Adapter/InMemoryMessageBus.php rename to tests/Shared/Adapter/InMemoryMessageBus.php index bd13dd6..66c89ef 100644 --- a/tests/Domain/Scraping/Adapter/InMemoryMessageBus.php +++ b/tests/Shared/Adapter/InMemoryMessageBus.php @@ -1,6 +1,6 @@