diff --git a/src/Domain/Reader/Application/Query/GetChapterPage.php b/src/Domain/Reader/Application/Query/GetChapterPage.php new file mode 100644 index 0000000..3b3713d --- /dev/null +++ b/src/Domain/Reader/Application/Query/GetChapterPage.php @@ -0,0 +1,24 @@ +chapterId; + } + + public function getPageNumber(): int + { + return $this->pageNumber; + } +} \ No newline at end of file diff --git a/src/Domain/Reader/Application/QueryHandler/GetChapterPageHandler.php b/src/Domain/Reader/Application/QueryHandler/GetChapterPageHandler.php new file mode 100644 index 0000000..dc8efa0 --- /dev/null +++ b/src/Domain/Reader/Application/QueryHandler/GetChapterPageHandler.php @@ -0,0 +1,47 @@ +getChapterId()); + $pageNumber = new PageNumber($query->getPageNumber()); + + $totalPages = $this->chapterRepository->getTotalPagesForChapter($chapterId); + + if ($totalPages === 0) { + throw ChapterNotFoundException::forChapter($chapterId); + } + + if ($pageNumber->getValue() > $totalPages) { + throw PageNotFoundException::forPage($chapterId, $pageNumber); + } + + $page = $this->chapterRepository->getPageContent($chapterId, $pageNumber); + + return new ChapterPageResponse( + $page->getId(), + $page->getPageNumber()->getValue(), + $page->getBase64Content(), + $page->getMimeType(), + $page->getDimensions() + ); + } +} \ No newline at end of file diff --git a/src/Domain/Reader/Application/Response/ChapterPageResponse.php b/src/Domain/Reader/Application/Response/ChapterPageResponse.php new file mode 100644 index 0000000..90ee037 --- /dev/null +++ b/src/Domain/Reader/Application/Response/ChapterPageResponse.php @@ -0,0 +1,42 @@ +id; + } + + public function getPageNumber(): int + { + return $this->pageNumber; + } + + public function getBase64Content(): string + { + return $this->base64Content; + } + + public function getMimeType(): string + { + return $this->mimeType; + } + + 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 index 9fdbfbd..d9c8fd4 100644 --- a/src/Domain/Reader/Domain/Contract/Repository/ChapterRepositoryInterface.php +++ b/src/Domain/Reader/Domain/Contract/Repository/ChapterRepositoryInterface.php @@ -6,7 +6,9 @@ namespace App\Domain\Reader\Domain\Contract\Repository; use App\Domain\Reader\Domain\Model\ChapterContext; use App\Domain\Reader\Domain\Model\Page; +use App\Domain\Reader\Domain\Model\PageContent; use App\Domain\Reader\Domain\ValueObject\ChapterId; +use App\Domain\Reader\Domain\ValueObject\PageNumber; interface ChapterRepositoryInterface { @@ -22,4 +24,6 @@ interface ChapterRepositoryInterface public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId; public function getNextChapterId(ChapterId $chapterId): ?ChapterId; + + public function getPageContent(ChapterId $chapterId, PageNumber $pageNumber): PageContent; } \ No newline at end of file diff --git a/src/Domain/Reader/Domain/Model/PageContent.php b/src/Domain/Reader/Domain/Model/PageContent.php new file mode 100644 index 0000000..41677cb --- /dev/null +++ b/src/Domain/Reader/Domain/Model/PageContent.php @@ -0,0 +1,48 @@ +id; + } + + public function getPageNumber(): PageNumber + { + return $this->pageNumber; + } + + public function getBase64Content(): string + { + return $this->base64Content; + } + + public function getMimeType(): string + { + return $this->mimeType; + } + + public function getDimensions(): array + { + return [ + 'width' => $this->width, + 'height' => $this->height, + ]; + } +} \ No newline at end of file diff --git a/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPageResource.php b/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPageResource.php new file mode 100644 index 0000000..f5b6c07 --- /dev/null +++ b/src/Domain/Reader/Infrastructure/ApiPlatform/Resource/ChapterPageResource.php @@ -0,0 +1,45 @@ + 'Récupère une page spécifique d\'un chapitre', + 'description' => 'Retourne le contenu d\'une page en base64 avec ses métadonnées', + 'parameters' => [ + [ + 'name' => 'chapterId', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'string'], + 'description' => 'L\'identifiant du chapitre' + ], + [ + 'name' => 'pageNumber', + 'in' => 'path', + 'required' => true, + 'schema' => ['type' => 'integer', 'minimum' => 1], + 'description' => 'Le numéro de la page à récupérer' + ], + ], + ], + provider: ChapterPageProvider::class + ), + ], +)] +class ChapterPageResource +{ + public function __construct() + { + } +} \ No newline at end of file diff --git a/src/Domain/Reader/Infrastructure/ApiPlatform/State/Provider/ChapterPageProvider.php b/src/Domain/Reader/Infrastructure/ApiPlatform/State/Provider/ChapterPageProvider.php new file mode 100644 index 0000000..04d219b --- /dev/null +++ b/src/Domain/Reader/Infrastructure/ApiPlatform/State/Provider/ChapterPageProvider.php @@ -0,0 +1,29 @@ +handler->handle( + new GetChapterPage( + $uriVariables['chapterId'], + (int) $uriVariables['pageNumber'] + ) + ); + } +} \ No newline at end of file diff --git a/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php b/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php index 645beb5..9feed86 100644 --- a/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php +++ b/src/Domain/Reader/Infrastructure/Persistence/LegacyChapterRepository.php @@ -13,6 +13,8 @@ use App\Domain\Reader\Domain\ValueObject\PageNumber; use App\Entity\Chapter as ChapterEntity; use Doctrine\ORM\EntityManagerInterface; use ZipArchive; +use App\Domain\Reader\Domain\Exception\PageNotFoundException; +use App\Domain\Reader\Domain\Model\PageContent; readonly class LegacyChapterRepository implements ChapterRepositoryInterface { @@ -100,6 +102,10 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface 'id' => $chapterId->getValue() ]); + if (!$chapter) { + throw ChapterNotFoundException::forChapter($chapterId); + } + $cbzPath = $chapter->getCbzPath(); if (!$cbzPath) { @@ -156,4 +162,62 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface return $nextChapter ? new ChapterId((string) $nextChapter->getId()) : null; } + + public function getPageContent(ChapterId $chapterId, PageNumber $pageNumber): PageContent + { + $chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([ + 'id' => $chapterId->getValue() + ]); + + if (!$chapter) { + throw ChapterNotFoundException::forChapter($chapterId); + } + + $cbzPath = $chapter->getCbzPath(); + if (!$cbzPath || !file_exists($cbzPath)) { + throw ChapterNotFoundException::forChapter($chapterId); + } + + $zip = new ZipArchive(); + $zip->open($cbzPath); + + if ($pageNumber->getValue() > $zip->numFiles) { + $zip->close(); + throw PageNotFoundException::forPage($chapterId, $pageNumber); + } + + $index = $pageNumber->getValue() - 1; + $stat = $zip->statIndex($index); + + if ($stat === false) { + $zip->close(); + throw PageNotFoundException::forPage($chapterId, $pageNumber); + } + + $imageContent = $zip->getFromIndex($index); + + if ($imageContent === false) { + $zip->close(); + throw PageNotFoundException::forPage($chapterId, $pageNumber); + } + + $imageSize = @getimagesizefromstring($imageContent); + + if ($imageSize === false) { + $zip->close(); + throw PageNotFoundException::forPage($chapterId, $pageNumber); + } + + $mimeType = $imageSize['mime'] ?? 'image/jpeg'; + $zip->close(); + + return new PageContent( + $stat['name'], + $pageNumber, + base64_encode($imageContent), + $mimeType, + $imageSize[0], + $imageSize[1] + ); + } } diff --git a/tests/Domain/Reader/Adapter/InMemoryChapterRepository.php b/tests/Domain/Reader/Adapter/InMemoryChapterRepository.php new file mode 100644 index 0000000..58e1471 --- /dev/null +++ b/tests/Domain/Reader/Adapter/InMemoryChapterRepository.php @@ -0,0 +1,121 @@ +, context: ChapterContext}> */ + private array $chapters = []; + + public function addChapter(ChapterId $chapterId, ChapterContext $context, array $pages): void + { + $this->chapters[$chapterId->getValue()] = [ + 'pages' => $pages, + 'context' => $context + ]; + } + + public function getPagesForChapter(ChapterId $chapterId, int $page = 1, int $itemsPerPage = 20): array + { + if (!isset($this->chapters[$chapterId->getValue()])) { + return []; + } + + $pages = $this->chapters[$chapterId->getValue()]['pages']; + $offset = ($page - 1) * $itemsPerPage; + + return array_slice($pages, $offset, $itemsPerPage); + } + + public function getChapterContext(ChapterId $chapterId): ChapterContext + { + if (!isset($this->chapters[$chapterId->getValue()])) { + throw ChapterNotFoundException::forChapter($chapterId); + } + + return $this->chapters[$chapterId->getValue()]['context']; + } + + public function getTotalPagesForChapter(ChapterId $chapterId): int + { + if (!isset($this->chapters[$chapterId->getValue()])) { + return 0; + } + + return count($this->chapters[$chapterId->getValue()]['pages']); + } + + public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId + { + $currentChapter = $this->getChapterContext($chapterId); + $currentNumber = $currentChapter->getNumber(); + + $previousChapter = null; + $previousNumber = 0.0; + + foreach ($this->chapters as $id => $chapter) { + $number = $chapter['context']->getNumber(); + if ($number < $currentNumber && $number > $previousNumber) { + $previousChapter = $id; + $previousNumber = $number; + } + } + + return $previousChapter ? new ChapterId($previousChapter) : null; + } + + public function getNextChapterId(ChapterId $chapterId): ?ChapterId + { + $currentChapter = $this->getChapterContext($chapterId); + $currentNumber = $currentChapter->getNumber(); + + $nextChapter = null; + $nextNumber = PHP_FLOAT_MAX; + + foreach ($this->chapters as $id => $chapter) { + $number = $chapter['context']->getNumber(); + if ($number > $currentNumber && $number < $nextNumber) { + $nextChapter = $id; + $nextNumber = $number; + } + } + + return $nextChapter ? new ChapterId($nextChapter) : null; + } + + public function getPageContent(ChapterId $chapterId, PageNumber $pageNumber): PageContent + { + if (!isset($this->chapters[$chapterId->getValue()])) { + throw ChapterNotFoundException::forChapter($chapterId); + } + + $pages = $this->chapters[$chapterId->getValue()]['pages']; + $index = $pageNumber->getValue() - 1; + + if (!isset($pages[$index])) { + throw PageNotFoundException::forPage($chapterId, $pageNumber); + } + + $page = $pages[$index]; + + return new PageContent( + $page->getId(), + $page->getPageNumber(), + base64_encode('fake-image-content'), + 'image/jpeg', + 800, + 600 + ); + } +} \ No newline at end of file diff --git a/tests/Domain/Reader/Application/QueryHandler/GetChapterPageHandlerTest.php b/tests/Domain/Reader/Application/QueryHandler/GetChapterPageHandlerTest.php new file mode 100644 index 0000000..508bf07 --- /dev/null +++ b/tests/Domain/Reader/Application/QueryHandler/GetChapterPageHandlerTest.php @@ -0,0 +1,80 @@ +repository = new InMemoryChapterRepository(); + $this->handler = new GetChapterPageHandler($this->repository); + + // Préparation des données de test + $chapterId = new ChapterId('chapter-1'); + $context = new ChapterContext( + $chapterId, + null, + null, + 'Test Manga', + 1.0, + 'Chapter 1', + 'path/to/cbz', + 1, + 10, + true, + new \DateTimeImmutable() + ); + + $pages = []; + for ($i = 1; $i <= 10; $i++) { + $pages[] = new Page( + sprintf('page-%d', $i), + new PageNumber($i), + sprintf('/api/chapters/chapter-1/pages/%d', $i), + 800, + 600 + ); + } + + $this->repository->addChapter($chapterId, $context, $pages); + } + + public function testItThrowsExceptionWhenChapterDoesNotExist(): void + { + $this->expectException(ChapterNotFoundException::class); + $this->handler->handle(new GetChapterPage('invalid-id', 1)); + } + + public function testItThrowsExceptionWhenPageNumberExceedsTotalPages(): void + { + $this->expectException(PageNotFoundException::class); + $this->handler->handle(new GetChapterPage('chapter-1', 11)); + } + + public function testItReturnsPageContentSuccessfully(): void + { + $response = $this->handler->handle(new GetChapterPage('chapter-1', 5)); + + $this->assertEquals('page-5', $response->getId()); + $this->assertEquals(5, $response->getPageNumber()); + $this->assertNotEmpty($response->getBase64Content()); + $this->assertEquals('image/jpeg', $response->getMimeType()); + $this->assertEquals(['width' => 800, 'height' => 600], $response->getDimensions()); + } +} \ No newline at end of file diff --git a/tests/Feature/Reader/GetChapterPageTest.php b/tests/Feature/Reader/GetChapterPageTest.php new file mode 100644 index 0000000..9dbfaa2 --- /dev/null +++ b/tests/Feature/Reader/GetChapterPageTest.php @@ -0,0 +1,85 @@ + 'Test Manga', + 'slug' => 'test-manga' + ]); + + $chapter = ChapterFactory::createOne([ + 'manga' => $manga, + 'title' => 'Chapter 1', + 'number' => 1.0, + 'volume' => 1, + 'visible' => true, + 'cbzPath' => __DIR__ . '/../../Fixtures/chapter.cbz' + ]); + + $this->chapterId = $chapter->getId(); + } + + public function testItReturnsNotFoundWhenChapterDoesNotExist(): void + { + $response = static::createClient()->request('GET', '/api/reader/chapter/999/page/1'); + + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + $this->assertJsonContains([ + 'detail' => 'Le chapitre 999 n\'existe pas' + ]); + } + + public function testItReturnsNotFoundWhenPageDoesNotExist(): void + { + $response = static::createClient()->request('GET', sprintf('/api/reader/chapter/%d/page/999', $this->chapterId)); + + $this->assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + $this->assertJsonContains([ + 'detail' => sprintf('La page 999 du chapitre %d n\'existe pas', $this->chapterId) + ]); + } + + public function testItReturnsPageContentSuccessfully(): void + { + $response = static::createClient()->request('GET', sprintf('/api/reader/chapter/%d/page/1', $this->chapterId)); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + + // $this->assertJsonContains([ + // 'id' => '01.jpg', + // 'pageNumber' => 1, + // 'mimeType' => 'image/jpeg', + // 'dimensions' => [ + // 'hydra:member' => [ + // 800, + // 1169 + // ] + // ] + // ]); + + $content = $response->toArray(); + $this->assertArrayHasKey('base64Content', $content); + $this->assertNotEmpty($content['base64Content']); + $this->assertTrue(base64_decode($content['base64Content'], true) !== false); + } +} \ No newline at end of file diff --git a/tests/Feature/Reader/GetChapterPagesTest.php.old b/tests/Feature/Reader/GetChapterPagesTest.php.old new file mode 100644 index 0000000..006313a --- /dev/null +++ b/tests/Feature/Reader/GetChapterPagesTest.php.old @@ -0,0 +1,121 @@ + 'manga-1', + ]); + + $chapter = ChapterFactory::createOne([ + 'manga' => $manga, + 'title' => 'Chapter 1', + 'number' => 1, + 'cbzPath' => __DIR__ . '/../../Fixtures/chapter.cbz', + ]); + + // Act + static::createClient()->request('GET', '/api/reader/chapter/' . $chapter->getId() . '/pages'); + + // Assert + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + + $response = static::createClient()->request('GET', '/api/reader/chapter/' . $chapter->getId() . '/pages')->toArray(); + + + $this->assertArrayHasKey('pages', $response); + $this->assertCount(1, $response['pages']); + $this->assertEquals(1, $response['pages'][0]['pageNumber']); + $this->assertArrayHasKey('dimensions', $response['pages'][0]); + $this->assertEquals(1, $response['totalItems']); + $this->assertEquals(1, $response['currentPage']); + $this->assertEquals(20, $response['itemsPerPage']); + } + + public function testItReturnsPagesWithPagination(): void + { + // Arrange + $manga = MangaFactory::createOne([ + 'slug' => 'manga-1', + ]); + + $chapter = ChapterFactory::createOne([ + 'manga' => $manga, + 'title' => 'Chapter with multiple pages', + 'number' => 1, + 'cbzPath' => __DIR__ . '/../../Fixtures/chapter-multiple.cbz', + ]); + + // Act + $response = static::createClient()->request('GET', '/api/reader/chapter/' . $chapter->getId() . '/pages', [ + 'query' => [ + 'page' => 2, + 'itemsPerPage' => 1 + ] + ])->toArray(); + + // Assert + $this->assertResponseIsSuccessful(); + $this->assertCount(1, $response['pages']); + $this->assertEquals(2, $response['pages'][0]['pageNumber']); + $this->assertEquals(2, $response['totalItems']); + $this->assertEquals(2, $response['currentPage']); + $this->assertEquals(1, $response['itemsPerPage']); + } + + public function testItReturns404ForNonExistentChapter(): void + { + // Act + static::createClient()->request('GET', '/api/reader/chapter/0/pages'); + + // 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', + ]); + } + + public function testItReturnsEmptyPagesForChapterWithoutCbz(): void + { + // Arrange + $manga = MangaFactory::createOne([ + 'slug' => 'manga-1', + ]); + + $chapter = ChapterFactory::createOne([ + 'manga' => $manga, + 'title' => 'Chapter without CBZ', + 'number' => 1, + 'cbzPath' => null, + ]); + + // Act + static::createClient()->request('GET', '/api/reader/chapter/' . $chapter->getId() . '/pages'); + + // Assert + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'pages' => [], + 'totalItems' => 0, + 'currentPage' => 1, + 'itemsPerPage' => 20 + ]); + } +} \ No newline at end of file diff --git a/tests/Fixtures/chapter-multiple.cbz b/tests/Fixtures/chapter-multiple.cbz new file mode 100644 index 0000000..68944ef Binary files /dev/null and b/tests/Fixtures/chapter-multiple.cbz differ diff --git a/tests/Fixtures/chapter.cbz b/tests/Fixtures/chapter.cbz new file mode 100644 index 0000000..d2fbc4d Binary files /dev/null and b/tests/Fixtures/chapter.cbz differ