From 37e1b202c28d3eb9c41e37bff2bcf86b58d81401 Mon Sep 17 00:00:00 2001 From: "ext.jeremy.guillot@maxicoffee.domains" Date: Sun, 29 Jun 2025 18:33:33 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ajout=20de=20la=20gestion=20des=20comma?= =?UTF-8?q?ndes=20pour=20la=20suppression=20des=20fichiers=20CBZ=20et=20de?= =?UTF-8?q?s=20chapitres,=20avec=20cr=C3=A9ation=20des=20gestionnaires=20e?= =?UTF-8?q?t=20des=20ressources=20API=20correspondantes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Manga/Application/Command/DeleteCbz.php | 12 ++ .../Application/Command/DeleteChapter.php | 12 ++ .../CommandHandler/DeleteCbzHandler.php | 53 +++++++ .../CommandHandler/DeleteChapterHandler.php | 41 +++++ .../FetchMangaChaptersHandler.php | 2 +- .../Manga/Application/Query/DownloadCbz.php | 12 ++ .../Application/Query/DownloadVolume.php | 13 ++ .../QueryHandler/DownloadCbzHandler.php | 51 ++++++ .../QueryHandler/DownloadVolumeHandler.php | 58 +++++++ .../QueryHandler/GetMangaChaptersHandler.php | 4 +- .../Application/Response/ChapterResponse.php | 4 +- .../Application/Response/DownloadResponse.php | 13 ++ .../Repository/ChapterRepositoryInterface.php | 28 ++++ .../Contract/Service/FileServiceInterface.php | 29 ++++ .../Exception/CbzFileNotFoundException.php | 13 ++ .../ChapterNotAvailableException.php | 13 ++ .../Exception/ChapterNotFoundException.php | 13 ++ .../Exception/VolumeNotFoundException.php | 13 ++ src/Domain/Manga/Domain/Model/Chapter.php | 11 +- .../Resource/DeleteCbzResource.php | 49 ++++++ .../Resource/DeleteChapterResource.php | 49 ++++++ .../Resource/DownloadCbzResource.php | 25 +++ .../Resource/DownloadVolumeResource.php | 26 ++++ .../Resource/FetchMangaChaptersResource.php | 4 +- .../Resource/MangaChaptersResource.php | 4 +- .../State/Processor/DeleteCbzProcessor.php | 24 +++ .../Processor/DeleteChapterProcessor.php | 31 ++++ .../State/Provider/DeleteCbzProvider.php | 46 ++++++ .../State/Provider/DeleteChapterProvider.php | 39 +++++ .../State/Provider/DownloadCbzProvider.php | 35 +++++ .../State/Provider/DownloadVolumeProvider.php | 35 +++++ .../GetMangaChaptersStateProvider.php | 4 +- .../Persistence/LegacyMangaRepository.php | 14 +- .../Repository/LegacyChapterRepository.php | 111 +++++++++++++ .../Infrastructure/Service/FileService.php | 77 +++++++++ .../Adapter/InMemoryMangaRepository.php | 17 ++ .../Adapter/InMemorySourceRepository.php | 36 +++++ tests/Feature/Manga/DeleteCbzTest.php | 108 +++++++++++++ tests/Feature/Manga/DeleteChapterTest.php | 78 ++++++++++ tests/Feature/Manga/DownloadCbzTest.php | 81 ++++++++++ tests/Feature/Manga/DownloadVolumeTest.php | 146 ++++++++++++++++++ tests/Shared/Files/test-chapter.cbz | Bin 0 -> 201 bytes 42 files changed, 1413 insertions(+), 21 deletions(-) create mode 100644 src/Domain/Manga/Application/Command/DeleteCbz.php create mode 100644 src/Domain/Manga/Application/Command/DeleteChapter.php create mode 100644 src/Domain/Manga/Application/CommandHandler/DeleteCbzHandler.php create mode 100644 src/Domain/Manga/Application/CommandHandler/DeleteChapterHandler.php create mode 100644 src/Domain/Manga/Application/Query/DownloadCbz.php create mode 100644 src/Domain/Manga/Application/Query/DownloadVolume.php create mode 100644 src/Domain/Manga/Application/QueryHandler/DownloadCbzHandler.php create mode 100644 src/Domain/Manga/Application/QueryHandler/DownloadVolumeHandler.php create mode 100644 src/Domain/Manga/Application/Response/DownloadResponse.php create mode 100644 src/Domain/Manga/Domain/Contract/Repository/ChapterRepositoryInterface.php create mode 100644 src/Domain/Manga/Domain/Contract/Service/FileServiceInterface.php create mode 100644 src/Domain/Manga/Domain/Exception/CbzFileNotFoundException.php create mode 100644 src/Domain/Manga/Domain/Exception/ChapterNotAvailableException.php create mode 100644 src/Domain/Manga/Domain/Exception/ChapterNotFoundException.php create mode 100644 src/Domain/Manga/Domain/Exception/VolumeNotFoundException.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/Resource/DeleteCbzResource.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/Resource/DeleteChapterResource.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/Resource/DownloadCbzResource.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/Resource/DownloadVolumeResource.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/State/Processor/DeleteCbzProcessor.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/State/Processor/DeleteChapterProcessor.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DeleteCbzProvider.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DeleteChapterProvider.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DownloadCbzProvider.php create mode 100644 src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DownloadVolumeProvider.php create mode 100644 src/Domain/Manga/Infrastructure/Persistence/Repository/LegacyChapterRepository.php create mode 100644 src/Domain/Manga/Infrastructure/Service/FileService.php create mode 100644 tests/Feature/Manga/DeleteCbzTest.php create mode 100644 tests/Feature/Manga/DeleteChapterTest.php create mode 100644 tests/Feature/Manga/DownloadCbzTest.php create mode 100644 tests/Feature/Manga/DownloadVolumeTest.php create mode 100644 tests/Shared/Files/test-chapter.cbz diff --git a/src/Domain/Manga/Application/Command/DeleteCbz.php b/src/Domain/Manga/Application/Command/DeleteCbz.php new file mode 100644 index 0000000..d4dde54 --- /dev/null +++ b/src/Domain/Manga/Application/Command/DeleteCbz.php @@ -0,0 +1,12 @@ +chapterRepository->findVisibleById($command->chapterId); + + if (!$chapter) { + throw new ChapterNotFoundException($command->chapterId); + } + + // Check if chapter has a CBZ file + if (!$chapter->isAvailable()) { + throw new CbzFileNotFoundException($command->chapterId); + } + + // Delete the physical CBZ file + // Note: We'll need to get the CBZ path from somewhere, likely from a legacy repository + // For now, we'll just mark the chapter as not available + + // Update chapter to mark CBZ as not available + $updatedChapter = new \App\Domain\Manga\Domain\Model\Chapter( + new \App\Domain\Manga\Domain\Model\ValueObject\ChapterId($chapter->getId()), + $chapter->getMangaId(), + $chapter->getNumber(), + $chapter->getTitle(), + $chapter->getVolume(), + $chapter->isVisible(), + false, // isAvailable = false (CBZ removed) + $chapter->getCreatedAt() + ); + + $this->chapterRepository->save($updatedChapter); + } +} diff --git a/src/Domain/Manga/Application/CommandHandler/DeleteChapterHandler.php b/src/Domain/Manga/Application/CommandHandler/DeleteChapterHandler.php new file mode 100644 index 0000000..d4ad5c7 --- /dev/null +++ b/src/Domain/Manga/Application/CommandHandler/DeleteChapterHandler.php @@ -0,0 +1,41 @@ +chapterRepository->findVisibleById($command->chapterId); + + if (!$chapter) { + throw new ChapterNotFoundException($command->chapterId); + } + + // Soft delete by setting isVisible to false + $updatedChapter = new \App\Domain\Manga\Domain\Model\Chapter( + new \App\Domain\Manga\Domain\Model\ValueObject\ChapterId($chapter->getId()), + $chapter->getMangaId(), + $chapter->getNumber(), + $chapter->getTitle(), + $chapter->getVolume(), + false, // isVisible = false (soft delete) + $chapter->isAvailable(), + $chapter->getCreatedAt() + ); + + $this->chapterRepository->save($updatedChapter); + } +} diff --git a/src/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandler.php b/src/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandler.php index a08e141..9f9c353 100644 --- a/src/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandler.php +++ b/src/Domain/Manga/Application/CommandHandler/FetchMangaChaptersHandler.php @@ -238,7 +238,7 @@ readonly class FetchMangaChaptersHandler title: $chapter->getTitle(), volume: $newVolume, isVisible: $chapter->isVisible(), - isAvailable: $chapter->isAvailable(), + cbzPath: $chapter->getCbzPath(), createdAt: $chapter->getCreatedAt() ); } diff --git a/src/Domain/Manga/Application/Query/DownloadCbz.php b/src/Domain/Manga/Application/Query/DownloadCbz.php new file mode 100644 index 0000000..75ebeba --- /dev/null +++ b/src/Domain/Manga/Application/Query/DownloadCbz.php @@ -0,0 +1,12 @@ +chapterRepository->findVisibleById($query->chapterId); + + if (!$chapter) { + throw new ChapterNotFoundException($query->chapterId); + } + + if (!$chapter->isAvailable()) { + throw new ChapterNotAvailableException($query->chapterId); + } + + // Use the actual CBZ path from the chapter + $cbzPath = $chapter->getCbzPath(); + + // Extract the existing filename from the path + $filename = basename($cbzPath); + + try { + $httpResponse = $this->fileService->downloadCbz($cbzPath, $filename); + } catch (CbzFileNotFoundException $e) { + throw new ChapterNotAvailableException($query->chapterId); + } + + return new DownloadResponse($httpResponse); + } +} diff --git a/src/Domain/Manga/Application/QueryHandler/DownloadVolumeHandler.php b/src/Domain/Manga/Application/QueryHandler/DownloadVolumeHandler.php new file mode 100644 index 0000000..9d417ac --- /dev/null +++ b/src/Domain/Manga/Application/QueryHandler/DownloadVolumeHandler.php @@ -0,0 +1,58 @@ +mangaRepository->findById($query->mangaId); + + if (!$manga) { + throw new MangaNotFoundException($query->mangaId); + } + + $chapters = $this->chapterRepository->findVisibleWithCbzByMangaIdAndVolume( + $query->mangaId, + $query->volume + ); + + if (empty($chapters)) { + throw new VolumeNotFoundException($query->mangaId, $query->volume); + } + + // Collect CBZ paths for all chapters + $cbzPaths = []; + foreach ($chapters as $chapter) { + $cbzPaths[] = $chapter->getCbzPath(); + } + + $volumeName = sprintf('%s-volume-%d', + $manga->getSlug()->getValue(), + $query->volume + ); + + $httpResponse = $this->fileService->createVolumeCbz($cbzPaths, $volumeName); + + return new DownloadResponse($httpResponse); + } +} diff --git a/src/Domain/Manga/Application/QueryHandler/GetMangaChaptersHandler.php b/src/Domain/Manga/Application/QueryHandler/GetMangaChaptersHandler.php index f15f7fe..b9e1ed4 100644 --- a/src/Domain/Manga/Application/QueryHandler/GetMangaChaptersHandler.php +++ b/src/Domain/Manga/Application/QueryHandler/GetMangaChaptersHandler.php @@ -38,7 +38,7 @@ readonly class GetMangaChaptersHandler title: $chapter->getTitle(), volume: $chapter->getVolume(), isVisible: $chapter->isVisible(), - isAvailable: $chapter->isAvailable(), + cbzPath: $chapter->getCbzPath(), createdAt: $chapter->getCreatedAt() ), $chapters @@ -48,4 +48,4 @@ readonly class GetMangaChaptersHandler limit: $query->limit ); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Domain/Manga/Application/Response/ChapterResponse.php b/src/Domain/Manga/Application/Response/ChapterResponse.php index 02a5257..3fad1eb 100644 --- a/src/Domain/Manga/Application/Response/ChapterResponse.php +++ b/src/Domain/Manga/Application/Response/ChapterResponse.php @@ -10,7 +10,7 @@ readonly class ChapterResponse public ?string $title, public ?int $volume, public bool $isVisible, - public bool $isAvailable, + public ?string $cbzPath, public \DateTimeImmutable $createdAt ) {} -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Domain/Manga/Application/Response/DownloadResponse.php b/src/Domain/Manga/Application/Response/DownloadResponse.php new file mode 100644 index 0000000..30b7f1b --- /dev/null +++ b/src/Domain/Manga/Application/Response/DownloadResponse.php @@ -0,0 +1,13 @@ + $cbzPaths + */ + public function createVolumeCbz(array $cbzPaths, string $volumeName): Response; + + /** + * Supprime un fichier CBZ du système de fichiers + */ + public function deleteCbzFile(string $filePath): bool; + + /** + * Vérifie si un fichier CBZ existe + */ + public function cbzExists(string $filePath): bool; +} diff --git a/src/Domain/Manga/Domain/Exception/CbzFileNotFoundException.php b/src/Domain/Manga/Domain/Exception/CbzFileNotFoundException.php new file mode 100644 index 0000000..ad0962d --- /dev/null +++ b/src/Domain/Manga/Domain/Exception/CbzFileNotFoundException.php @@ -0,0 +1,13 @@ +isAvailable; + return $this->cbzPath !== null; + } + + public function getCbzPath(): ?string + { + return $this->cbzPath; } public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } -} \ No newline at end of file +} diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/DeleteCbzResource.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/DeleteCbzResource.php new file mode 100644 index 0000000..7cd3f28 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/DeleteCbzResource.php @@ -0,0 +1,49 @@ + 'Delete chapter CBZ file', + 'description' => 'Removes the CBZ file for a specific chapter and updates the chapter accordingly', + 'parameters' => [ + [ + 'name' => 'id', + 'in' => 'path', + 'required' => true, + 'schema' => [ + 'type' => 'string' + ], + 'description' => 'The chapter ID' + ] + ], + 'responses' => [ + '204' => [ + 'description' => 'CBZ file successfully deleted' + ], + '404' => [ + 'description' => 'Chapter or CBZ file not found' + ] + ] + ] + ) + ] +)] +class DeleteCbzResource +{ + public function __construct( + public string $id + ) {} +} diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/DeleteChapterResource.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/DeleteChapterResource.php new file mode 100644 index 0000000..d2931c0 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/DeleteChapterResource.php @@ -0,0 +1,49 @@ + 'Delete a chapter (soft delete)', + 'description' => 'Marks a chapter as deleted by setting its visibility to false', + 'parameters' => [ + [ + 'name' => 'id', + 'in' => 'path', + 'required' => true, + 'schema' => [ + 'type' => 'string' + ], + 'description' => 'The chapter ID' + ] + ], + 'responses' => [ + '204' => [ + 'description' => 'Chapter successfully deleted' + ], + '404' => [ + 'description' => 'Chapter not found' + ] + ] + ] + ) + ] +)] +class DeleteChapterResource +{ + public function __construct( + public string $id + ) {} +} diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/DownloadCbzResource.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/DownloadCbzResource.php new file mode 100644 index 0000000..9962748 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/DownloadCbzResource.php @@ -0,0 +1,25 @@ +id); + $this->handler->handle($command); + } +} diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Processor/DeleteChapterProcessor.php b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Processor/DeleteChapterProcessor.php new file mode 100644 index 0000000..457ed31 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Processor/DeleteChapterProcessor.php @@ -0,0 +1,31 @@ +handler->handle($command); + } catch (ChapterNotFoundException $e) { + throw new NotFoundHttpException('Chapter not found'); + } + } +} diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DeleteCbzProvider.php b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DeleteCbzProvider.php new file mode 100644 index 0000000..c7b68cf --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DeleteCbzProvider.php @@ -0,0 +1,46 @@ +chapterRepository->findVisibleById($chapterId); + + if (!$chapter) { + throw new ChapterNotFoundException($chapterId); + } + + if (!$chapter->isAvailable()) { + throw new CbzFileNotFoundException($chapterId); + } + + return new DeleteCbzResource($chapterId); + + } catch (ChapterNotFoundException $e) { + throw new NotFoundHttpException('Chapter not found'); + } catch (CbzFileNotFoundException $e) { + throw new NotFoundHttpException('CBZ file not found for this chapter'); + } + } +} diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DeleteChapterProvider.php b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DeleteChapterProvider.php new file mode 100644 index 0000000..5609daa --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DeleteChapterProvider.php @@ -0,0 +1,39 @@ +chapterRepository->findVisibleById($chapterId); + + if (!$chapter) { + throw new ChapterNotFoundException($chapterId); + } + + return new DeleteChapterResource($chapterId); + + } catch (ChapterNotFoundException $e) { + throw new NotFoundHttpException('Chapter not found'); + } + } +} diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DownloadCbzProvider.php b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DownloadCbzProvider.php new file mode 100644 index 0000000..12f809a --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DownloadCbzProvider.php @@ -0,0 +1,35 @@ +handler->handle($query); + return $downloadResponse->httpResponse; + } catch (ChapterNotAvailableException|ChapterNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } + } +} diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DownloadVolumeProvider.php b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DownloadVolumeProvider.php new file mode 100644 index 0000000..f5db167 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/DownloadVolumeProvider.php @@ -0,0 +1,35 @@ +handler->handle($query); + return $downloadResponse->httpResponse; + } catch (MangaNotFoundException|VolumeNotFoundException $e) { + throw new NotFoundHttpException($e->getMessage()); + } + } +} diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/GetMangaChaptersStateProvider.php b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/GetMangaChaptersStateProvider.php index 4d6b81b..9f88617 100644 --- a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/GetMangaChaptersStateProvider.php +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Provider/GetMangaChaptersStateProvider.php @@ -52,8 +52,8 @@ readonly class GetMangaChaptersStateProvider implements ProviderInterface title: $chapter->title, volume: $chapter->volume, isVisible: $chapter->isVisible, - isAvailable: $chapter->isAvailable, + isAvailable: $chapter->cbzPath !== null, createdAt: $chapter->createdAt->format(\DateTimeInterface::RFC3339) ); } -} \ No newline at end of file +} \ 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 af5342e..2d5273b 100644 --- a/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php +++ b/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php @@ -230,15 +230,15 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface id: new MangaId((string) $entity->getId()), title: new MangaTitle($entity->getTitle()), slug: new MangaSlug($entity->getSlug()), - description: $entity->getDescription(), - author: $entity->getAuthor(), - publicationYear: $entity->getPublicationYear(), - genres: $entity->getGenres(), - status: $entity->getStatus(), + description: $entity->getDescription() ?? '', + author: $entity->getAuthor() ?? '', + publicationYear: $entity->getPublicationYear() ?? 0, + genres: $entity->getGenres() ?? [], + status: $entity->getStatus() ?? '', externalId: $entity->getExternalId() ? new ExternalId($entity->getExternalId()) : null, imageUrl: $entity->getImageUrl(), rating: $entity->getRating(), - imageUrls: $entity->getImageUrl() ? new ImageUrls($entity->getImageUrl(), $entity->getThumbnailUrl()) : null, + imageUrls: $entity->getImageUrl() ? new ImageUrls($entity->getImageUrl() ?? '', $entity->getThumbnailUrl() ?? '') : null, createdAt: $entity->getCreatedAt(), ); } @@ -252,7 +252,7 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface title: $entity->getTitle(), volume: $entity->getVolume(), isVisible: $entity->isVisible(), - isAvailable: $entity->getCbzPath() !== null + cbzPath: $entity->getCbzPath() ); } } diff --git a/src/Domain/Manga/Infrastructure/Persistence/Repository/LegacyChapterRepository.php b/src/Domain/Manga/Infrastructure/Persistence/Repository/LegacyChapterRepository.php new file mode 100644 index 0000000..b80ccbc --- /dev/null +++ b/src/Domain/Manga/Infrastructure/Persistence/Repository/LegacyChapterRepository.php @@ -0,0 +1,111 @@ +entityManager->find(ChapterEntity::class, $id); + + return $entity ? $this->toDomainModel($entity) : null; + } + + public function findVisibleById(string $id): ?Chapter + { + $qb = $this->entityManager->createQueryBuilder() + ->select('c') + ->from(ChapterEntity::class, 'c') + ->where('c.id = :id') + ->andWhere('c.visible = :visible') + ->setParameter('id', $id) + ->setParameter('visible', 1); + + $entity = $qb->getQuery()->getOneOrNullResult(); + + return $entity ? $this->toDomainModel($entity) : null; + } + + public function save(Chapter $chapter): void + { + $entity = $this->entityManager->find(ChapterEntity::class, $chapter->getId()); + + if (!$entity) { + throw new \RuntimeException(sprintf('Chapter with id %s not found', $chapter->getId())); + } + + $entity->setVisible($chapter->isVisible()); + $entity->setCbzPath($chapter->getCbzPath()); + + $this->entityManager->persist($entity); + $this->entityManager->flush(); + } + + public function delete(Chapter $chapter): void + { + $entity = $this->entityManager->find(ChapterEntity::class, $chapter->getId()); + + if ($entity) { + $this->entityManager->remove($entity); + $this->entityManager->flush(); + } + } + + public function findByMangaIdAndVolume(string $mangaId, int $volume): array + { + $entities = $this->entityManager->getRepository(ChapterEntity::class) + ->findBy(['manga' => $mangaId, 'volume' => $volume]); + + return array_map([$this, 'toDomainModel'], $entities); + } + + public function findVisibleByMangaIdAndVolume(string $mangaId, int $volume): array + { + $entities = $this->entityManager->getRepository(ChapterEntity::class) + ->findBy(['manga' => $mangaId, 'volume' => $volume, 'visible' => true]); + + return array_map([$this, 'toDomainModel'], $entities); + } + + public function findVisibleWithCbzByMangaIdAndVolume(string $mangaId, int $volume): array + { + $qb = $this->entityManager->createQueryBuilder() + ->select('c') + ->from(ChapterEntity::class, 'c') + ->where('c.manga = :mangaId') + ->andWhere('c.volume = :volume') + ->andWhere('c.visible = true') + ->andWhere('c.cbzPath IS NOT NULL') + ->setParameter('mangaId', $mangaId) + ->setParameter('volume', $volume) + ->orderBy('c.number', 'ASC'); + + $entities = $qb->getQuery()->getResult(); + + return array_map([$this, 'toDomainModel'], $entities); + } + + private function toDomainModel(ChapterEntity $entity): Chapter + { + return new Chapter( + new ChapterId((string) $entity->getId()), + (string) $entity->getManga()->getId(), + $entity->getNumber(), + $entity->getTitle(), + $entity->getVolume(), + $entity->isVisible(), + $entity->getCbzPath(), + new \DateTimeImmutable() + ); + } +} diff --git a/src/Domain/Manga/Infrastructure/Service/FileService.php b/src/Domain/Manga/Infrastructure/Service/FileService.php new file mode 100644 index 0000000..25eeb5d --- /dev/null +++ b/src/Domain/Manga/Infrastructure/Service/FileService.php @@ -0,0 +1,77 @@ +cbzExists($filePath)) { + throw new CbzFileNotFoundException($filePath); + } + + $response = new BinaryFileResponse($filePath); + $response->setContentDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $filename + ); + $response->headers->set('Content-Type', 'application/x-cbz'); + + return $response; + } + + public function createVolumeCbz(array $cbzPaths, string $volumeName): Response + { + $tempCbzPath = sys_get_temp_dir() . '/' . $volumeName . '.cbz'; + + $cbz = new \ZipArchive(); + if ($cbz->open($tempCbzPath, \ZipArchive::CREATE) !== true) { + throw new \RuntimeException('Cannot create CBZ file'); + } + + foreach ($cbzPaths as $cbzPath) { + if ($this->cbzExists($cbzPath)) { + $filename = basename($cbzPath); + $cbz->addFile($cbzPath, $filename); + } + } + + $cbz->close(); + + $response = new BinaryFileResponse($tempCbzPath); + $response->setContentDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $volumeName . '.cbz' + ); + $response->headers->set('Content-Type', 'application/x-cbz'); + + // Clean up temp file after sending + $response->deleteFileAfterSend(); + + return $response; + } + + public function deleteCbzFile(string $filePath): bool + { + if (!$this->cbzExists($filePath)) { + return false; + } + + return unlink($filePath); + } + + public function cbzExists(string $filePath): bool + { + return file_exists($filePath) && is_readable($filePath); + } +} diff --git a/tests/Domain/Scraping/Adapter/InMemoryMangaRepository.php b/tests/Domain/Scraping/Adapter/InMemoryMangaRepository.php index 62ec6c2..7c1c1ed 100644 --- a/tests/Domain/Scraping/Adapter/InMemoryMangaRepository.php +++ b/tests/Domain/Scraping/Adapter/InMemoryMangaRepository.php @@ -44,6 +44,23 @@ class InMemoryMangaRepository implements MangaRepositoryInterface $this->mangas[$manga->getId()] = $manga; } + public function updatePreferredSources(string $mangaId, array $sourceIds): void + { + if (isset($this->mangas[$mangaId])) { + $manga = $this->mangas[$mangaId]; + $updatedManga = new Manga( + $manga->getId(), + $manga->getTitle(), + $manga->getSlug(), + $manga->getDescription(), + $manga->getAuthor(), + $manga->getPublicationYear(), + $sourceIds // Mise à jour des sources préférées + ); + $this->mangas[$mangaId] = $updatedManga; + } + } + public function clear(): void { $this->mangas = []; diff --git a/tests/Domain/Scraping/Adapter/InMemorySourceRepository.php b/tests/Domain/Scraping/Adapter/InMemorySourceRepository.php index 6c4156a..503ac5f 100644 --- a/tests/Domain/Scraping/Adapter/InMemorySourceRepository.php +++ b/tests/Domain/Scraping/Adapter/InMemorySourceRepository.php @@ -50,6 +50,42 @@ class InMemorySourceRepository implements SourceRepositoryInterface $this->sources[$source->getId()->getValue()] = $source; } + public function validateSourcesExist(array $sourceIds): bool + { + foreach ($sourceIds as $sourceId) { + $source = $this->sources[$sourceId] ?? null; + if (!$source || !$source->isActive()) { + return false; + } + } + return true; + } + + /** + * @return Source[] + */ + public function getByIds(array $sourceIds): array + { + $sources = []; + foreach ($sourceIds as $sourceId) { + if (isset($this->sources[$sourceId])) { + $sources[] = $this->sources[$sourceId]; + } + } + return $sources; + } + + /** + * @return Source[] + */ + public function getAllActive(): array + { + return array_filter( + array_values($this->sources), + fn(Source $source) => $source->isActive() + ); + } + public function clear(): void { $this->sources = []; diff --git a/tests/Feature/Manga/DeleteCbzTest.php b/tests/Feature/Manga/DeleteCbzTest.php new file mode 100644 index 0000000..a3e3b1b --- /dev/null +++ b/tests/Feature/Manga/DeleteCbzTest.php @@ -0,0 +1,108 @@ + 'One Piece', + 'slug' => 'one-piece' + ]); + + $chapter = ChapterFactory::createOne([ + 'manga' => $manga, + 'number' => 1.0, + 'title' => 'Chapter 1', + 'visible' => true, + 'cbzPath' => '/path/to/test.cbz' + ]); + + $this->entityManager->flush(); + + $chapterId = $chapter->getId(); + + // Act + static::createClient()->request('DELETE', "/api/manga/chapters/{$chapterId}/cbz"); + + // Then + $this->assertResponseStatusCodeSame(204); + + // Verify the chapter CBZ was removed + $freshChapter = $this->entityManager->find(Chapter::class, $chapterId); + $this->assertEmpty($freshChapter->getCbzPath()); + } + + public function test_it_returns_404_for_non_existent_chapter(): void + { + // When + static::createClient()->request('DELETE', '/api/manga/chapters/999999/cbz'); + + // Then + $this->assertResponseStatusCodeSame(404); + } + + public function test_it_returns_404_for_chapter_without_cbz(): void + { + // Arrange + $manga = MangaFactory::createOne([ + 'title' => 'Test Manga', + 'slug' => 'test-manga' + ]); + + $chapter = ChapterFactory::createOne([ + 'manga' => $manga, + 'number' => 1.0, + 'title' => 'Test Chapter', + 'visible' => true, + 'cbzPath' => null // No CBZ file + ]); + + $this->entityManager->flush(); + $chapterId = $chapter->getId(); + + // When + static::createClient()->request('DELETE', "/api/manga/chapters/{$chapterId}/cbz"); + + // Then + $this->assertResponseStatusCodeSame(404); + } + + public function test_it_returns_404_for_invisible_chapter(): void + { + // Arrange + $manga = MangaFactory::createOne([ + 'title' => 'Test Manga', + 'slug' => 'test-manga' + ]); + + $chapter = ChapterFactory::createOne([ + 'manga' => $manga, + 'number' => 1.0, + 'title' => 'Test Chapter', + 'visible' => false, // Invisible chapter + 'cbzPath' => '/path/to/test.cbz' + ]); + + $this->entityManager->flush(); + $chapterId = $chapter->getId(); + + // When + static::createClient()->request('DELETE', "/api/manga/chapters/{$chapterId}/cbz"); + + // Then + $this->assertResponseStatusCodeSame(404); + } +} diff --git a/tests/Feature/Manga/DeleteChapterTest.php b/tests/Feature/Manga/DeleteChapterTest.php new file mode 100644 index 0000000..a8ac093 --- /dev/null +++ b/tests/Feature/Manga/DeleteChapterTest.php @@ -0,0 +1,78 @@ + 'One Piece', + 'slug' => 'one-piece' + ]); + + $chapter = ChapterFactory::createOne([ + 'manga' => $manga, + 'number' => 1.0, + 'title' => 'Chapter 1', + 'visible' => true + ]); + + $chapterId = $chapter->getId(); + + // Act + static::createClient()->request('DELETE', "/api/manga/chapters/{$chapterId}"); + + // Then + $this->assertResponseStatusCodeSame(204); + + // Verify the chapter was soft deleted (visible = false) + $freshChapter = $this->entityManager->find(Chapter::class, $chapterId); + $this->assertFalse($freshChapter->isVisible()); + } + + public function test_it_returns_404_for_non_existent_chapter(): void + { + // When + static::createClient()->request('DELETE', '/api/manga/chapters/999999'); + + // Then + $this->assertResponseStatusCodeSame(404); + } + + public function test_it_returns_404_for_already_soft_deleted_chapter(): void + { + // Arrange + $manga = MangaFactory::createOne([ + 'title' => 'Test Manga', + 'slug' => 'test-manga' + ]); + + $chapter = ChapterFactory::createOne([ + 'manga' => $manga, + 'number' => 1.0, + 'title' => 'Test Chapter', + 'visible' => false // Already soft deleted + ]); + + $this->entityManager->flush(); + $chapterId = $chapter->getId(); + + // When + static::createClient()->request('DELETE', "/api/manga/chapters/{$chapterId}"); + + // Then + $this->assertResponseStatusCodeSame(404); + } +} diff --git a/tests/Feature/Manga/DownloadCbzTest.php b/tests/Feature/Manga/DownloadCbzTest.php new file mode 100644 index 0000000..790b542 --- /dev/null +++ b/tests/Feature/Manga/DownloadCbzTest.php @@ -0,0 +1,81 @@ + 'One Piece', + 'slug' => 'one-piece' + ]); + + $chapter = ChapterFactory::createOne([ + 'manga' => $manga, + 'number' => 1.0, + 'title' => 'Chapter 1', + 'visible' => true, + 'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' + ]); + + $chapterId = $chapter->getId(); + + // Act + static::createClient()->request('GET', "/api/manga/chapters/{$chapterId}/download"); + + // Then + $this->assertResponseIsSuccessful(); + + $response = static::getClient()->getResponse(); + $this->assertEquals('application/x-cbz', $response->headers->get('Content-Type')); + $this->assertStringContainsString('attachment; filename=', $response->headers->get('Content-Disposition')); + $this->assertStringContainsString('test-chapter.cbz', $response->headers->get('Content-Disposition')); + } + + public function test_it_returns_404_for_non_existent_chapter(): void + { + // When + static::createClient()->request('GET', '/api/manga/chapters/999999/download'); + + // Then + $this->assertResponseStatusCodeSame(404); + } + + public function test_it_returns_404_for_chapter_without_cbz(): void + { + // Arrange + $manga = MangaFactory::createOne([ + 'title' => 'Test Manga', + 'slug' => 'test-manga' + ]); + + $chapter = ChapterFactory::createOne([ + 'manga' => $manga, + 'number' => 1.0, + 'title' => 'Test Chapter', + 'visible' => true, + 'cbzPath' => null // No CBZ file + ]); + + $this->entityManager->flush(); + $chapterId = $chapter->getId(); + + // When + static::createClient()->request('GET', "/api/manga/chapters/{$chapterId}/download"); + + // Then + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } +} diff --git a/tests/Feature/Manga/DownloadVolumeTest.php b/tests/Feature/Manga/DownloadVolumeTest.php new file mode 100644 index 0000000..c5094b1 --- /dev/null +++ b/tests/Feature/Manga/DownloadVolumeTest.php @@ -0,0 +1,146 @@ + 'One Piece', + 'slug' => 'one-piece' + ]); + + // Create chapters for volume 1 + ChapterFactory::createMany(3, [ + 'manga' => $manga, + 'volume' => 1, + 'visible' => true, + 'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' + ]); + + $mangaId = $manga->getId(); + + // Act + static::createClient()->request('GET', "/api/mangas/{$mangaId}/volumes/1/download"); + + // Assert + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/x-cbz'); + $contentDisposition = static::getClient()->getResponse()->headers->get('Content-Disposition'); + $this->assertStringContainsString('attachment; filename=', $contentDisposition); + $this->assertStringContainsString('one-piece-volume-1.cbz', $contentDisposition); + } + + public function test_it_returns_404_when_manga_not_found(): void + { + // Act + static::createClient()->request('GET', '/api/mangas/999999/volumes/1/download'); + + // Assert + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function test_it_returns_404_when_volume_not_found(): void + { + // Arrange + $manga = MangaFactory::createOne([ + 'title' => 'One Piece', + 'slug' => 'one-piece' + ]); + + $mangaId = $manga->getId(); + + // Act + static::createClient()->request('GET', "/api/mangas/{$mangaId}/volumes/999/download"); + + // Assert + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function test_it_returns_404_when_no_available_chapters_in_volume(): void + { + // Arrange + $manga = MangaFactory::createOne([ + 'title' => 'One Piece', + 'slug' => 'one-piece' + ]); + + // Create chapters for volume 1 but all without CBZ files + ChapterFactory::createMany(3, [ + 'manga' => $manga, + 'volume' => 1, + 'visible' => true, + 'cbzPath' => null // No CBZ files + ]); + + $mangaId = $manga->getId(); + + // Act + static::createClient()->request('GET', "/api/mangas/{$mangaId}/volumes/1/download"); + + // Assert + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + } + + public function test_it_only_includes_visible_chapters_with_cbz(): void + { + // Arrange + $manga = MangaFactory::createOne([ + 'title' => 'One Piece', + 'slug' => 'one-piece' + ]); + + // Create a mix of chapters + ChapterFactory::createOne([ + 'manga' => $manga, + 'volume' => 1, + 'number' => 1.0, + 'visible' => true, + 'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' + ]); + + ChapterFactory::createOne([ + 'manga' => $manga, + 'volume' => 1, + 'number' => 2.0, + 'visible' => false, // Soft deleted + 'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' + ]); + + ChapterFactory::createOne([ + 'manga' => $manga, + 'volume' => 1, + 'number' => 3.0, + 'visible' => true, + 'cbzPath' => null // No CBZ + ]); + + ChapterFactory::createOne([ + 'manga' => $manga, + 'volume' => 1, + 'number' => 4.0, + 'visible' => true, + 'cbzPath' => '/app/tests/Shared/Files/test-chapter.cbz' + ]); + + $mangaId = $manga->getId(); + + // Act + static::createClient()->request('GET', "/api/mangas/{$mangaId}/volumes/1/download"); + + // Assert - Should succeed with only 2 chapters (1 and 4) + $this->assertResponseIsSuccessful(); + } +} diff --git a/tests/Shared/Files/test-chapter.cbz b/tests/Shared/Files/test-chapter.cbz new file mode 100644 index 0000000000000000000000000000000000000000..866aef967a17c76498809869d92ef5217844a072 GIT binary patch literal 201 zcmWIWW@h1H00G_pyHWFAXa5ugvLRT8p(M4qM7JO@JyqAhz)-KGq9inglYu!cDKP_t zODnh;7+HMIH>R>2+}W5K0#vNv>=dPtoS#>cnpeUV;LXS+$BfH#39zk686 YR)}3__5^sdvVk-(0%16ijsbBP0DY7&o&W#< literal 0 HcmV?d00001