From 55945adc53c940e8d741286bb2e70a0cb4af2f53 Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Sun, 16 Feb 2025 16:15:42 +0100 Subject: [PATCH] feat: Reader beginning --- Makefile | 5 +- config/packages/api_platform.yaml | 1 + config/services_test.yaml | 4 + .../Application/Query/GetChapterContext.php | 17 ++ .../Application/Query/GetChapterPages.php | 30 ++++ .../QueryHandler/GetChapterContextHandler.php | 34 ++++ .../QueryHandler/GetChapterPagesHandler.php | 43 +++++ .../Response/ChapterContextResponse.php | 57 +++++++ .../Response/ChapterPagesResponse.php | 58 +++++++ .../Application/Response/PageResponse.php | 43 +++++ .../Repository/ChapterRepositoryInterface.php | 25 +++ .../Exception/ChapterNotFoundException.php | 16 ++ .../Reader/Domain/Model/ChapterContext.php | 79 +++++++++ src/Domain/Reader/Domain/Model/Page.php | 52 ++++++ .../Reader/Domain/ValueObject/ChapterId.php | 22 +++ .../Reader/Domain/ValueObject/PageNumber.php | 36 ++++ .../Resource/ChapterContextResource.php | 38 +++++ .../Resource/ChapterPagesResource.php | 50 ++++++ .../State/Provider/ChapterContextProvider.php | 38 +++++ .../State/Provider/ChapterPagesProvider.php | 33 ++++ .../Persistence/LegacyChapterRepository.php | 159 ++++++++++++++++++ .../CommandHandler/ScrapeChapterHandler.php | 5 + .../Repository/ChapterRepositoryInterface.php | 3 +- src/Domain/Scraping/Domain/Model/Chapter.php | 3 +- .../Domain/Model/ValueObject/CbzPath.php | 7 +- .../ApiPlatform/Dto/ScrapeChapterRequest.php | 12 +- .../Processor/ScrapeChapterStateProcessor.php | 6 +- .../Persistence/LegacyChapterRepository.php | 26 ++- src/Factory/ChapterFactory.php | 9 +- src/Factory/MangaFactory.php | 18 +- src/Factory/SourceFactory.php | 7 +- .../Adapter/InMemoryChapterRepository.php | 31 ++++ .../Adapter/InMemoryScraperAdapter.php | 2 + .../Adapter/InMemoryScrapingJobRepository.php | 2 +- .../ScrapeChapterHandlerTest.php | 52 ++++-- .../Feature/Reader/GetChapterContextTest.php | 75 +++++++++ tests/Feature/Scraping/ScrapeChapterTest.php | 6 +- 37 files changed, 1057 insertions(+), 47 deletions(-) create mode 100644 src/Domain/Reader/Application/Query/GetChapterContext.php create mode 100644 src/Domain/Reader/Application/Query/GetChapterPages.php create mode 100644 src/Domain/Reader/Application/QueryHandler/GetChapterContextHandler.php create mode 100644 src/Domain/Reader/Application/QueryHandler/GetChapterPagesHandler.php create mode 100644 src/Domain/Reader/Application/Response/ChapterContextResponse.php create mode 100644 src/Domain/Reader/Application/Response/ChapterPagesResponse.php create mode 100644 src/Domain/Reader/Application/Response/PageResponse.php create mode 100644 src/Domain/Reader/Domain/Contract/Repository/ChapterRepositoryInterface.php create mode 100644 src/Domain/Reader/Domain/Exception/ChapterNotFoundException.php create mode 100644 src/Domain/Reader/Domain/Model/ChapterContext.php create mode 100644 src/Domain/Reader/Domain/Model/Page.php create mode 100644 src/Domain/Reader/Domain/ValueObject/ChapterId.php create mode 100644 src/Domain/Reader/Domain/ValueObject/PageNumber.php create mode 100644 src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterContextResource.php create mode 100644 src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPagesResource.php create mode 100644 src/Domain/Reader/Infrastructure/ApiPlatform/State/Provider/ChapterContextProvider.php create mode 100644 src/Domain/Reader/Infrastructure/ApiPlatform/State/Provider/ChapterPagesProvider.php create mode 100644 src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php create mode 100644 tests/Domain/Scraping/Adapter/InMemoryChapterRepository.php create mode 100644 tests/Feature/Reader/GetChapterContextTest.php diff --git a/Makefile b/Makefile index 4662fb5..d002e57 100644 --- a/Makefile +++ b/Makefile @@ -44,9 +44,10 @@ logs: ## Show live logs sh: ## Connect to the FrankenPHP container @$(PHP_CONT) sh -test: ## Start tests with phpunit, pass the parameter "c=" to add options to phpunit, example: make test c="--group e2e --stop-on-failure" +test: ## Start tests with phpunit, pass the parameter "c=" to add options to phpunit, example: make test c="--group e2e --stop-on-failure", or "f=" to specify a test file, example: make test f="ScrapeChapterHandlerTest" @$(eval c ?=) - @$(DOCKER_COMP) exec -e APP_ENV=test php bin/phpunit $(c) + @$(eval f ?=) + @$(DOCKER_COMP) exec -e APP_ENV=test php bin/phpunit $(c) $(if $(f),--filter=$(f),) phpmd: ## Start PHP Mess Detector @if ! $(DOCKER_COMP) exec php vendor/bin/phpmd src/ text phpmd.xml -v; then \ diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 004f791..08d4767 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -27,5 +27,6 @@ api_platform: paths: - '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto' - '%kernel.project_dir%/src/Domain/Manga/Infrastructure/ApiPlatform/Resource' + - '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource' patch_formats: json: ['application/merge-patch+json'] diff --git a/config/services_test.yaml b/config/services_test.yaml index 351b642..28e6d01 100644 --- a/config/services_test.yaml +++ b/config/services_test.yaml @@ -22,4 +22,8 @@ services: class: App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor public: true +# App\Domain\Reader\Domain\Contract\Repository\ChapterRepositoryInterface: +# class: App\Tests\Domain\Reader\Adapter\InMemoryChapterRepository +# public: true + diff --git a/src/Domain/Reader/Application/Query/GetChapterContext.php b/src/Domain/Reader/Application/Query/GetChapterContext.php new file mode 100644 index 0000000..4c8e673 --- /dev/null +++ b/src/Domain/Reader/Application/Query/GetChapterContext.php @@ -0,0 +1,17 @@ +chapterId; + } +} diff --git a/src/Domain/Reader/Application/Query/GetChapterPages.php b/src/Domain/Reader/Application/Query/GetChapterPages.php new file mode 100644 index 0000000..be5f719 --- /dev/null +++ b/src/Domain/Reader/Application/Query/GetChapterPages.php @@ -0,0 +1,30 @@ +chapterId; + } + + public function getPage(): int + { + return $this->page; + } + + public function getItemsPerPage(): int + { + return $this->itemsPerPage; + } +} diff --git a/src/Domain/Reader/Application/QueryHandler/GetChapterContextHandler.php b/src/Domain/Reader/Application/QueryHandler/GetChapterContextHandler.php new file mode 100644 index 0000000..e33e770 --- /dev/null +++ b/src/Domain/Reader/Application/QueryHandler/GetChapterContextHandler.php @@ -0,0 +1,34 @@ +getChapterId()); + + $context = $this->chapterRepository->getChapterContext($chapterId); + + return new ChapterContextResponse( + $query->getChapterId(), + $context->getChapterTitle(), + $context->getNumber(), + $context->getTotalPages(), + $context->getPreviousChapterId()?->getValue(), + $context->getNextChapterId()?->getValue(), + ); + } +} diff --git a/src/Domain/Reader/Application/QueryHandler/GetChapterPagesHandler.php b/src/Domain/Reader/Application/QueryHandler/GetChapterPagesHandler.php new file mode 100644 index 0000000..a4e7623 --- /dev/null +++ b/src/Domain/Reader/Application/QueryHandler/GetChapterPagesHandler.php @@ -0,0 +1,43 @@ +getChapterId()); + + $totalItems = $this->chapterRepository->getTotalPagesForChapter($chapterId); + + if ($totalItems === 0) { + throw ChapterNotFoundException::forChapter($chapterId); + } + + $pages = $this->chapterRepository->getPagesForChapter( + $chapterId, + $query->getPage(), + $query->getItemsPerPage() + ); + + return new ChapterPagesResponse( + $pages, + $totalItems, + $query->getPage(), + $query->getItemsPerPage() + ); + } +} diff --git a/src/Domain/Reader/Application/Response/ChapterContextResponse.php b/src/Domain/Reader/Application/Response/ChapterContextResponse.php new file mode 100644 index 0000000..7cef999 --- /dev/null +++ b/src/Domain/Reader/Application/Response/ChapterContextResponse.php @@ -0,0 +1,57 @@ +id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getNumber(): float + { + return $this->number; + } + + public function getTotalPages(): int + { + return $this->totalPages; + } + + public function getPreviousChapterId(): ?string + { + return $this->previousChapterId; + } + + public function getNextChapterId(): ?string + { + return $this->nextChapterId; + } + + public function getNavigation(): array + { + $navigation['previousChapter'] = $this->previousChapterId; + $navigation['nextChapter'] = $this->nextChapterId; + + return $navigation; + } +} diff --git a/src/Domain/Reader/Application/Response/ChapterPagesResponse.php b/src/Domain/Reader/Application/Response/ChapterPagesResponse.php new file mode 100644 index 0000000..6302e4a --- /dev/null +++ b/src/Domain/Reader/Application/Response/ChapterPagesResponse.php @@ -0,0 +1,58 @@ + */ + private array $pages; + private int $totalItems; + private int $currentPage; + private int $itemsPerPage; + + /** + * @param array $pages + */ + public function __construct(array $pages, int $totalItems, int $currentPage, int $itemsPerPage) + { + $this->pages = array_map( + static fn (Page $page) => new PageResponse($page), + $pages + ); + $this->totalItems = $totalItems; + $this->currentPage = $currentPage; + $this->itemsPerPage = $itemsPerPage; + } + + /** + * @return array + */ + public function getPages(): array + { + return $this->pages; + } + + public function getTotalItems(): int + { + return $this->totalItems; + } + + public function getCurrentPage(): int + { + return $this->currentPage; + } + + public function getItemsPerPage(): int + { + return $this->itemsPerPage; + } + + public function getTotalPages(): int + { + return (int) ceil($this->totalItems / $this->itemsPerPage); + } +} \ No newline at end of file diff --git a/src/Domain/Reader/Application/Response/PageResponse.php b/src/Domain/Reader/Application/Response/PageResponse.php new file mode 100644 index 0000000..a7bfa8d --- /dev/null +++ b/src/Domain/Reader/Application/Response/PageResponse.php @@ -0,0 +1,43 @@ +id = $page->getId(); + $this->pageNumber = $page->getPageNumber()->getValue(); + $this->url = $page->getUrl(); + $this->dimensions = $page->getDimensions(); + } + + public function getId(): string + { + return $this->id; + } + + public function getPageNumber(): int + { + return $this->pageNumber; + } + + public function getUrl(): string + { + return $this->url; + } + + public function getDimensions(): array + { + return $this->dimensions; + } +} \ No newline at end of file diff --git a/src/Domain/Reader/Domain/Contract/Repository/ChapterRepositoryInterface.php b/src/Domain/Reader/Domain/Contract/Repository/ChapterRepositoryInterface.php new file mode 100644 index 0000000..9fdbfbd --- /dev/null +++ b/src/Domain/Reader/Domain/Contract/Repository/ChapterRepositoryInterface.php @@ -0,0 +1,25 @@ + + */ + public function getPagesForChapter(ChapterId $chapterId, int $page = 1, int $itemsPerPage = 20): array; + + public function getChapterContext(ChapterId $chapterId): ChapterContext; + + public function getTotalPagesForChapter(ChapterId $chapterId): int; + + public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId; + + public function getNextChapterId(ChapterId $chapterId): ?ChapterId; +} \ No newline at end of file diff --git a/src/Domain/Reader/Domain/Exception/ChapterNotFoundException.php b/src/Domain/Reader/Domain/Exception/ChapterNotFoundException.php new file mode 100644 index 0000000..fb31752 --- /dev/null +++ b/src/Domain/Reader/Domain/Exception/ChapterNotFoundException.php @@ -0,0 +1,16 @@ +getValue())); + } +} \ No newline at end of file diff --git a/src/Domain/Reader/Domain/Model/ChapterContext.php b/src/Domain/Reader/Domain/Model/ChapterContext.php new file mode 100644 index 0000000..6dc5951 --- /dev/null +++ b/src/Domain/Reader/Domain/Model/ChapterContext.php @@ -0,0 +1,79 @@ +id; + } + + public function getPreviousChapterId(): ?ChapterId + { + return $this->previousChapterId; + } + + public function getNextChapterId(): ?ChapterId + { + return $this->nextChapterId; + } + + public function getMangaTitle(): string + { + return $this->mangaTitle; + } + + public function getNumber(): float + { + return $this->number; + } + + public function getChapterTitle(): ?string + { + return $this->chapterTitle; + } + + public function getCbzPath(): ?string + { + return $this->cbzPath; + } + + public function getVolume(): ?int + { + return $this->volume; + } + + public function getTotalPages(): int + { + return $this->totalPages; + } + + public function isVisible(): bool + { + return $this->isVisible; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} \ No newline at end of file diff --git a/src/Domain/Reader/Domain/Model/Page.php b/src/Domain/Reader/Domain/Model/Page.php new file mode 100644 index 0000000..5d61468 --- /dev/null +++ b/src/Domain/Reader/Domain/Model/Page.php @@ -0,0 +1,52 @@ +id; + } + + public function getPageNumber(): PageNumber + { + return $this->pageNumber; + } + + public function getUrl(): string + { + return $this->url; + } + + public function getWidth(): int + { + return $this->width; + } + + public function getHeight(): int + { + return $this->height; + } + + public function getDimensions(): array + { + return [ + 'width' => $this->width, + 'height' => $this->height, + ]; + } +} \ No newline at end of file diff --git a/src/Domain/Reader/Domain/ValueObject/ChapterId.php b/src/Domain/Reader/Domain/ValueObject/ChapterId.php new file mode 100644 index 0000000..3e45c70 --- /dev/null +++ b/src/Domain/Reader/Domain/ValueObject/ChapterId.php @@ -0,0 +1,22 @@ +value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } +} \ No newline at end of file diff --git a/src/Domain/Reader/Domain/ValueObject/PageNumber.php b/src/Domain/Reader/Domain/ValueObject/PageNumber.php new file mode 100644 index 0000000..fd73c98 --- /dev/null +++ b/src/Domain/Reader/Domain/ValueObject/PageNumber.php @@ -0,0 +1,36 @@ +value = $value; + } + + public function getValue(): int + { + return $this->value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } + + public function __toString(): string + { + return (string) $this->value; + } +} \ No newline at end of file diff --git a/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterContextResource.php b/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterContextResource.php new file mode 100644 index 0000000..44e51ba --- /dev/null +++ b/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterContextResource.php @@ -0,0 +1,38 @@ + 'Récupère le contexte d\'un chapitre', + 'description' => 'Retourne les métadonnées du chapitre et sa navigation', + 'parameters' => [ + [ + 'name' => 'chapterId', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ], + ], + ], + provider: ChapterContextProvider::class + ), + ], +)] +class ChapterContextResource +{ + public function __construct() + { + } +} diff --git a/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPagesResource.php b/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPagesResource.php new file mode 100644 index 0000000..43a60d2 --- /dev/null +++ b/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPagesResource.php @@ -0,0 +1,50 @@ + 'Récupère les pages d\'un chapitre', + 'description' => 'Retourne une collection paginée des pages du chapitre', + 'parameters' => [ + [ + 'name' => 'chapterId', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + ], + [ + 'name' => 'page', + 'in' => 'query', + 'required' => false, + 'schema' => ['type' => 'integer', 'default' => 1], + ], + [ + 'name' => 'itemsPerPage', + 'in' => 'query', + 'required' => false, + 'schema' => ['type' => 'integer', 'default' => 20], + ], + ], + ], + provider: ChapterPagesProvider::class + ), + ], +)] +class ChapterPagesResource +{ + public function __construct() + { + } +} diff --git a/src/Domain/Reader/Infrastructure/ApiPlatform/State/Provider/ChapterContextProvider.php b/src/Domain/Reader/Infrastructure/ApiPlatform/State/Provider/ChapterContextProvider.php new file mode 100644 index 0000000..55e110c --- /dev/null +++ b/src/Domain/Reader/Infrastructure/ApiPlatform/State/Provider/ChapterContextProvider.php @@ -0,0 +1,38 @@ +handler->handle( + new GetChapterContext( + $uriVariables['chapterId'] + ) + ); + + return new ChapterContextResponse( + id: $response->getId(), + title: $response->getTitle(), + number: $response->getNumber(), + totalPages: $response->getTotalPages(), + previousChapterId: $response->getPreviousChapterId(), + nextChapterId: $response->getNextChapterId() + ); + } +} diff --git a/src/Domain/Reader/Infrastructure/ApiPlatform/State/Provider/ChapterPagesProvider.php b/src/Domain/Reader/Infrastructure/ApiPlatform/State/Provider/ChapterPagesProvider.php new file mode 100644 index 0000000..85b5526 --- /dev/null +++ b/src/Domain/Reader/Infrastructure/ApiPlatform/State/Provider/ChapterPagesProvider.php @@ -0,0 +1,33 @@ +handler->handle( + new GetChapterPages( + $uriVariables['chapterId'], + (int) $page, + (int) $itemsPerPage + ) + ); + } +} diff --git a/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php b/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php new file mode 100644 index 0000000..645beb5 --- /dev/null +++ b/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php @@ -0,0 +1,159 @@ +entityManager->getRepository(ChapterEntity::class)->findOneBy([ + 'id' => $chapterId->getValue() + ]); + + $cbzPath = $chapter->getCbzPath(); + + if (!$cbzPath) { + return []; + } + + $zip = new ZipArchive(); + $zip->open($cbzPath); + + $pages = []; + $start = ($page - 1) * $itemsPerPage; + $end = min($start + $itemsPerPage, $zip->numFiles); + + for ($i = $start; $i < $end; $i++) { + $stat = $zip->statIndex($i); + if ($stat === false) { + continue; + } + + $imageContent = $zip->getFromIndex($i); + if ($imageContent === false) { + continue; + } + + $imageSize = @getimagesizefromstring($imageContent); + if ($imageSize === false) { + continue; + } + + $pages[] = new Page( + $stat['name'], + new PageNumber($i + 1), + sprintf('/api/chapters/%s/pages/%d', $chapterId->getValue(), $i + 1), + $imageSize[0], + $imageSize[1] + ); + } + + $zip->close(); + return $pages; + } + + public function getChapterContext(ChapterId $chapterId): ChapterContext + { + /** @var ChapterEntity $chapter */ + $chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([ + 'id' => $chapterId->getValue() + ]); + + if (!$chapter) { + throw ChapterNotFoundException::forChapter($chapterId); + } + + return new ChapterContext( + id: $chapterId, + previousChapterId: $this->getPreviousChapterId($chapterId), + nextChapterId: $this->getNextChapterId($chapterId), + mangaTitle: $chapter->getManga()->getTitle(), + number: $chapter->getNumber(), + chapterTitle: $chapter->getTitle(), + cbzPath: $chapter->getCbzPath(), + volume: $chapter->getVolume(), + totalPages: 0, + isVisible: $chapter->isVisible(), + createdAt: new \DateTimeImmutable() + ); + } + + public function getTotalPagesForChapter(ChapterId $chapterId): int + { + $chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([ + 'id' => $chapterId->getValue() + ]); + + $cbzPath = $chapter->getCbzPath(); + + if (!$cbzPath) { + return 0; + } + + $zip = new ZipArchive(); + $zip->open($cbzPath); + return $zip->numFiles; + } + + public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId + { + $currentChapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([ + 'id' => $chapterId->getValue() + ]); + + $qb = $this->entityManager->createQueryBuilder(); + $qb->select('c') + ->from(ChapterEntity::class, 'c') + ->where('c.manga = :manga') + ->andWhere('c.number < :number') + ->orderBy('c.number', 'DESC') + ->setMaxResults(1) + ->setParameters([ + 'manga' => $currentChapter->getManga(), + 'number' => $currentChapter->getNumber() + ]); + + $previousChapter = $qb->getQuery()->getOneOrNullResult(); + + return $previousChapter ? new ChapterId((string) $previousChapter->getId()) : null; + } + + public function getNextChapterId(ChapterId $chapterId): ?ChapterId + { + $currentChapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([ + 'id' => $chapterId->getValue() + ]); + + $qb = $this->entityManager->createQueryBuilder(); + $qb->select('c') + ->from(ChapterEntity::class, 'c') + ->where('c.manga = :manga') + ->andWhere('c.number > :number') + ->orderBy('c.number', 'ASC') + ->setMaxResults(1) + ->setParameters([ + 'manga' => $currentChapter->getManga(), + 'number' => $currentChapter->getNumber() + ]); + + $nextChapter = $qb->getQuery()->getOneOrNullResult(); + + return $nextChapter ? new ChapterId((string) $nextChapter->getId()) : null; + } +} diff --git a/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php b/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php index 1861b09..5cc569e 100644 --- a/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php +++ b/src/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandler.php @@ -3,6 +3,7 @@ namespace App\Domain\Scraping\Application\CommandHandler; use App\Domain\Scraping\Application\Command\ScrapeChapter; +use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Service\ScraperInterface; use App\Domain\Scraping\Domain\Event\ChapterScraped; @@ -18,6 +19,7 @@ readonly class ScrapeChapterHandler public function __construct( private ScraperInterface $scraper, private ScrapingJobRepositoryInterface $scrapingJobRepository, + private ChapterRepositoryInterface $chapterRepository, private MessageBusInterface $eventBus ) { } @@ -42,6 +44,9 @@ readonly class ScrapeChapterHandler $this->eventBus->dispatch(new ChapterScrapingFailed($command->mangaId, $command->chapterNumber, $job->failureReason)); }elseif ($job->status === ScrapingStatus::COMPLETED) { $this->eventBus->dispatch(new ChapterScraped($job->getId())); + $chapter = $this->chapterRepository->getByMangaIdAndChapterNumber($command->mangaId, $command->chapterNumber); + $chapter->cbzPath = $job->cbzPath->getPath(); + $this->chapterRepository->save($chapter); } $this->scrapingJobRepository->save($job); diff --git a/src/Domain/Scraping/Domain/Contract/Repository/ChapterRepositoryInterface.php b/src/Domain/Scraping/Domain/Contract/Repository/ChapterRepositoryInterface.php index 6f4bb12..7ef79e5 100644 --- a/src/Domain/Scraping/Domain/Contract/Repository/ChapterRepositoryInterface.php +++ b/src/Domain/Scraping/Domain/Contract/Repository/ChapterRepositoryInterface.php @@ -7,4 +7,5 @@ use App\Domain\Scraping\Domain\Model\Chapter; interface ChapterRepositoryInterface { public function getByMangaIdAndChapterNumber(string $mangaId, int $chapterNumber): Chapter; -} \ No newline at end of file + public function save(Chapter $chapter): void; +} diff --git a/src/Domain/Scraping/Domain/Model/Chapter.php b/src/Domain/Scraping/Domain/Model/Chapter.php index 7483598..14199eb 100644 --- a/src/Domain/Scraping/Domain/Model/Chapter.php +++ b/src/Domain/Scraping/Domain/Model/Chapter.php @@ -9,5 +9,6 @@ class Chapter public readonly string $mangaId, public readonly int $chapterNumber, public readonly int $volumeNumber, + public ?string $cbzPath = null, ) {} -} \ No newline at end of file +} diff --git a/src/Domain/Scraping/Domain/Model/ValueObject/CbzPath.php b/src/Domain/Scraping/Domain/Model/ValueObject/CbzPath.php index 1e31cd8..65b0d14 100644 --- a/src/Domain/Scraping/Domain/Model/ValueObject/CbzPath.php +++ b/src/Domain/Scraping/Domain/Model/ValueObject/CbzPath.php @@ -2,17 +2,16 @@ namespace App\Domain\Scraping\Domain\Model\ValueObject; -class CbzPath +readonly class CbzPath { - public function __construct(private readonly string $path) + public function __construct(private string $path) { if (empty($path)) { throw new \InvalidArgumentException('Le chemin du fichier CBZ ne peut pas être vide'); } } - public function getPath(): string { return $this->path; } -} \ No newline at end of file +} diff --git a/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto/ScrapeChapterRequest.php b/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto/ScrapeChapterRequest.php index 8ef03e9..c9d28dd 100644 --- a/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto/ScrapeChapterRequest.php +++ b/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto/ScrapeChapterRequest.php @@ -21,15 +21,15 @@ use Symfony\Component\Validator\Constraints as Assert; readonly class ScrapeChapterRequest { public function __construct( - #[ApiProperty(description: 'ID du chapitre à scraper')] - #[Assert\NotBlank] - public string $chapterId, - #[ApiProperty(description: 'ID de la source à utiliser')] - #[Assert\NotBlank] - public string $sourceId, #[ApiProperty(description: 'ID du manga')] #[Assert\NotBlank] public string $mangaId, + #[ApiProperty(description: 'Numéro du chapitre')] + #[Assert\NotBlank] + public string $chapterNumber, + #[ApiProperty(description: 'ID de la source')] + #[Assert\NotBlank] + public string $sourceId, ) { } } diff --git a/src/Domain/Scraping/Infrastructure/ApiPlatform/State/Processor/ScrapeChapterStateProcessor.php b/src/Domain/Scraping/Infrastructure/ApiPlatform/State/Processor/ScrapeChapterStateProcessor.php index 79a2902..368588e 100644 --- a/src/Domain/Scraping/Infrastructure/ApiPlatform/State/Processor/ScrapeChapterStateProcessor.php +++ b/src/Domain/Scraping/Infrastructure/ApiPlatform/State/Processor/ScrapeChapterStateProcessor.php @@ -22,9 +22,9 @@ final class ScrapeChapterStateProcessor implements ProcessorInterface { $this->commandBus->dispatch( new ScrapeChapter( - $data->chapterId, - $data->sourceId, - $data->mangaId + $data->mangaId, + $data->chapterNumber, + $data->sourceId ) ); } diff --git a/src/Domain/Scraping/Infrastructure/Persistence/LegacyChapterRepository.php b/src/Domain/Scraping/Infrastructure/Persistence/LegacyChapterRepository.php index 2c7e785..3104e03 100644 --- a/src/Domain/Scraping/Infrastructure/Persistence/LegacyChapterRepository.php +++ b/src/Domain/Scraping/Infrastructure/Persistence/LegacyChapterRepository.php @@ -7,12 +7,15 @@ use App\Domain\Scraping\Domain\Exception\ChapterNotFoundException; use App\Domain\Scraping\Domain\Model\Chapter; use App\Entity\Chapter as EntityChapter; use Doctrine\ORM\EntityManagerInterface; -class LegacyChapterRepository implements ChapterRepositoryInterface +readonly class LegacyChapterRepository implements ChapterRepositoryInterface { public function __construct( - private readonly EntityManagerInterface $entityManager, + private EntityManagerInterface $entityManager, ) {} + /** + * @throws ChapterNotFoundException + */ public function getByMangaIdAndChapterNumber(string $mangaId, int $chapterNumber): Chapter { $chapterEntity = $this->entityManager->getRepository(EntityChapter::class)->findOneBy([ @@ -31,4 +34,23 @@ class LegacyChapterRepository implements ChapterRepositoryInterface volumeNumber: $chapterEntity->getVolume(), ); } + + /** + * @throws ChapterNotFoundException + */ + public function save(Chapter $chapter): void + { + $chapterEntity = $this->entityManager->getRepository(EntityChapter::class)->findOneBy([ + 'id' => $chapter->id, + ]); + + if (!$chapterEntity) { + throw new ChapterNotFoundException(); + } + + $chapterEntity->setCbzPath($chapter->cbzPath); + + $this->entityManager->persist($chapterEntity); + $this->entityManager->flush(); + } } diff --git a/src/Factory/ChapterFactory.php b/src/Factory/ChapterFactory.php index 0e862ff..72ff64a 100644 --- a/src/Factory/ChapterFactory.php +++ b/src/Factory/ChapterFactory.php @@ -48,8 +48,13 @@ final class ChapterFactory extends ModelFactory { return [ 'manga' => MangaFactory::new(), - 'number' => self::faker()->randomNumber(2), - 'pages' => [], + 'number' => self::faker()->randomFloat(2, 0, 999), + 'volume' => self::faker()->optional()->numberBetween(1, 100), + 'title' => self::faker()->optional()->sentence(3), + 'localPath' => self::faker()->optional()->filePath(), + 'externalId' => self::faker()->optional()->uuid(), + 'cbzPath' => self::faker()->optional()->filePath(), + 'visible' => true, ]; } diff --git a/src/Factory/MangaFactory.php b/src/Factory/MangaFactory.php index 3b0b175..36f2210 100644 --- a/src/Factory/MangaFactory.php +++ b/src/Factory/MangaFactory.php @@ -50,12 +50,20 @@ final class MangaFactory extends ModelFactory $title = self::faker()->words(rand(1, 3), true); return [ - 'slug' => $this->slugger->slug($title)->lower(), 'title' => $title, - 'description' => self::faker()->text(), - 'genres' => self::faker()->words(rand(1, 5)), - 'publicationYear' => self::faker()->year(), - 'rating' => self::faker()->randomFloat(1, 0, 10), + 'slug' => $this->slugger->slug($title)->lower(), + 'imageUrl' => self::faker()->optional()->imageUrl(), + 'publicationYear' => self::faker()->optional()->year(), + 'description' => self::faker()->optional()->text(), + 'genres' => self::faker()->optional()->words(rand(1, 5)), + 'createdAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), + 'rating' => self::faker()->optional()->randomFloat(1, 0, 10), + 'author' => self::faker()->optional()->name(), + 'externalId' => self::faker()->optional()->uuid(), + 'status' => self::faker()->optional()->randomElement(['ongoing', 'completed', 'hiatus']), + 'thumbnailUrl' => self::faker()->optional()->imageUrl(150, 150), + 'monitored' => self::faker()->boolean(), + 'AlternativeSlugs' => self::faker()->optional()->words(3), ]; } diff --git a/src/Factory/SourceFactory.php b/src/Factory/SourceFactory.php index a6cefab..4a13bc1 100644 --- a/src/Factory/SourceFactory.php +++ b/src/Factory/SourceFactory.php @@ -47,9 +47,12 @@ final class SourceFactory extends ModelFactory protected function getDefaults(): array { return [ - 'baseUrl' => self::faker()->text(255), - 'createdAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), + 'name' => self::faker()->optional()->company(), + 'description' => self::faker()->optional()->text(), + 'baseUrl' => self::faker()->url(), + 'scrappingParameters' => [], 'isActive' => self::faker()->boolean(), + 'createdAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), 'updatedAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), ]; } diff --git a/tests/Domain/Scraping/Adapter/InMemoryChapterRepository.php b/tests/Domain/Scraping/Adapter/InMemoryChapterRepository.php new file mode 100644 index 0000000..fb7c87c --- /dev/null +++ b/tests/Domain/Scraping/Adapter/InMemoryChapterRepository.php @@ -0,0 +1,31 @@ +chapters as $chapter) { + if ($chapter->mangaId === $mangaId && $chapter->chapterNumber === $chapterNumber) { + return $chapter; + } + } + + throw new \RuntimeException('Chapter not found'); + } + + public function save(Chapter $chapter): void + { + $this->chapters[$chapter->id] = $chapter; + } + + public function clear(): void + { + $this->chapters = []; + } +} diff --git a/tests/Domain/Scraping/Adapter/InMemoryScraperAdapter.php b/tests/Domain/Scraping/Adapter/InMemoryScraperAdapter.php index 205f0f8..bdaf2f7 100644 --- a/tests/Domain/Scraping/Adapter/InMemoryScraperAdapter.php +++ b/tests/Domain/Scraping/Adapter/InMemoryScraperAdapter.php @@ -4,6 +4,7 @@ namespace App\Tests\Domain\Scraping\Adapter; use App\Domain\Scraping\Domain\Contract\Service\ScraperInterface; use App\Domain\Scraping\Domain\Model\ScrapingJob; +use App\Domain\Scraping\Domain\Model\ValueObject\CbzPath; use Ramsey\Uuid\Uuid; class InMemoryScraperAdapter implements ScraperInterface @@ -18,6 +19,7 @@ class InMemoryScraperAdapter implements ScraperInterface } $job->complete(); + $job->cbzPath = new CbzPath('/path/to/test.cbz'); return $job; } diff --git a/tests/Domain/Scraping/Adapter/InMemoryScrapingJobRepository.php b/tests/Domain/Scraping/Adapter/InMemoryScrapingJobRepository.php index fcae1b9..89bb9e9 100644 --- a/tests/Domain/Scraping/Adapter/InMemoryScrapingJobRepository.php +++ b/tests/Domain/Scraping/Adapter/InMemoryScrapingJobRepository.php @@ -28,7 +28,7 @@ class InMemoryScrapingJobRepository implements ScrapingJobRepositoryInterface public function findByChapterId(string $chapterId): ?ScrapingJob { foreach (self::$jobs as $job) { - if ($job->getChapterId() === $chapterId) { + if ($job->getId() === $chapterId) { return $job; } } diff --git a/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php b/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php index 9392d7b..a9e2461 100644 --- a/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php +++ b/tests/Domain/Scraping/Application/CommandHandler/ScrapeChapterHandlerTest.php @@ -7,7 +7,9 @@ use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler; use App\Domain\Scraping\Domain\Event\ChapterScraped; use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed; use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted; +use App\Domain\Scraping\Domain\Model\Chapter; use App\Domain\Scraping\Domain\Model\ScrapingStatus; +use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository; use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus; use App\Tests\Domain\Scraping\Adapter\InMemoryScraperAdapter; use App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository; @@ -16,18 +18,30 @@ use PHPUnit\Framework\TestCase; class ScrapeChapterHandlerTest extends TestCase { private InMemoryScraperAdapter $scraper; - private InMemoryScrapingJobRepository $repository; + private InMemoryScrapingJobRepository $scrapingJobRepository; + private InMemoryChapterRepository $chapterRepository; private InMemoryEventBus $eventBus; private ScrapeChapterHandler $handler; protected function setUp(): void { $this->scraper = new InMemoryScraperAdapter(); - $this->repository = new InMemoryScrapingJobRepository(); + $this->scrapingJobRepository = new InMemoryScrapingJobRepository(); + $this->chapterRepository = new InMemoryChapterRepository(); + + $this->chapterRepository->save(new Chapter( + id: '1', + mangaId: '1', + chapterNumber: '2', + volumeNumber: 1, + cbzPath: null, + )); + $this->eventBus = new InMemoryEventBus(); $this->handler = new ScrapeChapterHandler( $this->scraper, - $this->repository, + $this->scrapingJobRepository, + $this->chapterRepository, $this->eventBus ); } @@ -35,16 +49,14 @@ class ScrapeChapterHandlerTest extends TestCase public function testHandleSuccessfully(): void { $command = new ScrapeChapter( - mangaId: 1, - chapterNumber: 2, - sourceId: 3, + mangaId: '1', + chapterNumber: '2', + sourceId: '3', ); $this->handler->handle($command); - $scrapingJobs = $this->repository->getJobs(); - - + $scrapingJobs = $this->scrapingJobRepository->getJobs(); $this->assertCount(1, $scrapingJobs); $job = $scrapingJobs[0]; @@ -54,15 +66,16 @@ class ScrapeChapterHandlerTest extends TestCase $this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[1]); $this->assertEquals($job->getId(), $dispatchedMessages[0]->getJobId()); - $this->repository->clear(); + $chapter = $this->chapterRepository->getByMangaIdAndChapterNumber('1', '2'); + $this->assertNotNull($chapter->cbzPath); } public function testHandleThrowsException(): void { $command = new ScrapeChapter( - mangaId: 1, - chapterNumber: 2, - sourceId: 3, + mangaId: '1', + chapterNumber: '2', + sourceId: '3', ); $exception = new \Exception('Scraping failed'); @@ -71,15 +84,24 @@ class ScrapeChapterHandlerTest extends TestCase $this->handler->handle($command); $dispatchedMessages = $this->eventBus->getDispatchedMessages(); + $this->assertCount(2, $dispatchedMessages); $this->assertInstanceOf(ChapterScrapingStarted::class, $dispatchedMessages[0]); $this->assertInstanceOf(ChapterScrapingFailed::class, $dispatchedMessages[1]); - $this->assertEquals(2, $dispatchedMessages[1]->getChapterNumber()); + $this->assertEquals('1', $dispatchedMessages[1]->getMangaId()); + $this->assertEquals('2', $dispatchedMessages[1]->getChapterNumber()); $this->assertEquals('Scraping failed', $dispatchedMessages[1]->getReason()); - $jobs = $this->repository->getJobs(); + $jobs = $this->scrapingJobRepository->getJobs(); + $this->assertCount(1, $jobs); $this->assertEquals(ScrapingStatus::FAILED, $jobs[0]->status); $this->assertEquals('Scraping failed', $jobs[0]->failureReason); } + + public function tearDown(): void + { + $this->scrapingJobRepository->clear(); + $this->chapterRepository->clear(); + } } diff --git a/tests/Feature/Reader/GetChapterContextTest.php b/tests/Feature/Reader/GetChapterContextTest.php new file mode 100644 index 0000000..9cc7c0c --- /dev/null +++ b/tests/Feature/Reader/GetChapterContextTest.php @@ -0,0 +1,75 @@ + 'manga-1', + ]); + + $chapter1 = ChapterFactory::createOne([ + 'manga' => $manga, + 'title' => 'Chapter 1', + 'number' => 1, + ]); + + $chapter2 = ChapterFactory::createOne([ + 'manga' => $manga, + 'title' => 'Chapter 2', + 'number' => 2, + ]); + + $chapter3 = ChapterFactory::createOne([ + 'manga' => $manga, + 'title' => 'Chapter 3', + 'number' => 3, + ]); + + // Act + static::createClient()->request('GET', '/api/reader/chapter/' . $chapter1->getId()); + + // Assert + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + + $this->assertJsonContains([ + 'id' => (string)$chapter1->getId(), + 'title' => 'Chapter 1', + 'number' => 1, + 'totalPages' => 0, + 'navigation' => [ + 'hydra:member' => [ + null, (string)$chapter2->getId(), + ], + ] + ]); + } + + public function testItReturns404ForNonExistentChapter(): void + { + // Act + static::createClient()->request('GET', '/api/reader/chapter/0'); + + // Assert + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + + $this->assertJsonContains([ + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'Le chapitre 0 n\'existe pas', + ]); + } +} diff --git a/tests/Feature/Scraping/ScrapeChapterTest.php b/tests/Feature/Scraping/ScrapeChapterTest.php index 4ddb207..07a890c 100644 --- a/tests/Feature/Scraping/ScrapeChapterTest.php +++ b/tests/Feature/Scraping/ScrapeChapterTest.php @@ -23,7 +23,7 @@ class ScrapeChapterTest extends AbstractApiTestCase { // Given $payload = [ - 'chapterId' => 'chapter-123', + 'chapterNumber' => 'chapter-123', 'sourceId' => 'source-456', 'mangaId' => 'manga-789', ]; @@ -49,7 +49,7 @@ class ScrapeChapterTest extends AbstractApiTestCase { // Given $payload = [ - 'chapterId' => '', + 'chapterNumber' => '', 'sourceId' => 'source-456', 'mangaId' => 'manga-789', ]; @@ -65,7 +65,7 @@ class ScrapeChapterTest extends AbstractApiTestCase $this->assertJsonContains([ 'violations' => [ [ - 'propertyPath' => 'chapterId', + 'propertyPath' => 'chapterNumber', 'message' => 'This value should not be blank.', ], ],