diff --git a/src/Domain/Manga/Application/Query/GetMangaChapters.php b/src/Domain/Manga/Application/Query/GetMangaChapters.php new file mode 100644 index 0000000..9820d14 --- /dev/null +++ b/src/Domain/Manga/Application/Query/GetMangaChapters.php @@ -0,0 +1,13 @@ +mangaRepository->findById($query->mangaId); + if (!$manga) { + throw new MangaNotFoundException(); + } + + $chapters = $this->mangaRepository->findChapters( + mangaId: $query->mangaId, + page: $query->page, + limit: $query->limit, + sortOrder: $query->sortOrder + ); + + $total = $this->mangaRepository->countChapters($query->mangaId); + + return new ChapterListResponse( + chapters: array_map( + fn ($chapter) => new ChapterResponse( + id: $chapter->getId(), + number: $chapter->getNumber(), + title: $chapter->getTitle(), + volume: $chapter->getVolume(), + isVisible: $chapter->isVisible(), + createdAt: $chapter->getCreatedAt() + ), + $chapters + ), + total: $total, + page: $query->page, + limit: $query->limit + ); + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Application/Response/ChapterListResponse.php b/src/Domain/Manga/Application/Response/ChapterListResponse.php new file mode 100644 index 0000000..c5cb73a --- /dev/null +++ b/src/Domain/Manga/Application/Response/ChapterListResponse.php @@ -0,0 +1,28 @@ +total / $this->limit); + } + + public function hasNextPage(): bool + { + return $this->page < $this->getTotalPages(); + } + + public function hasPreviousPage(): bool + { + return $this->page > 1; + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Application/Response/ChapterResponse.php b/src/Domain/Manga/Application/Response/ChapterResponse.php new file mode 100644 index 0000000..7ced7e9 --- /dev/null +++ b/src/Domain/Manga/Application/Response/ChapterResponse.php @@ -0,0 +1,15 @@ +id->getValue(); + } + + public function getNumber(): float + { + return $this->number; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function getVolume(): ?int + { + return $this->volume; + } + + 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/Manga/Domain/Model/ValueObject/ChapterId.php b/src/Domain/Manga/Domain/Model/ValueObject/ChapterId.php new file mode 100644 index 0000000..3b159c1 --- /dev/null +++ b/src/Domain/Manga/Domain/Model/ValueObject/ChapterId.php @@ -0,0 +1,20 @@ +value; + } + + public function equals(self $other): bool + { + return $this->value === $other->value; + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Dto/ChapterCollection.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Dto/ChapterCollection.php new file mode 100644 index 0000000..4c83b5f --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Dto/ChapterCollection.php @@ -0,0 +1,16 @@ +handler->handle($query); + + return new ChapterCollection( + items: array_map( + fn (ChapterResponse $chapter) => $this->createChapterListItem($chapter), + $response->chapters + ), + total: $response->total, + page: $response->page, + limit: $response->limit, + hasNextPage: $response->hasNextPage(), + hasPreviousPage: $response->hasPreviousPage() + ); + } + + private function createChapterListItem(ChapterResponse $chapter): ChapterListItem + { + return new ChapterListItem( + id: $chapter->id, + number: $chapter->number, + title: $chapter->title, + volume: $chapter->volume, + isVisible: $chapter->isVisible, + createdAt: $chapter->createdAt->format(\DateTimeInterface::RFC3339) + ); + } +} \ 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 efc192b..5c50d82 100644 --- a/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php +++ b/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php @@ -10,6 +10,9 @@ use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug; use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle; use App\Entity\Manga as EntityManga; use Doctrine\ORM\EntityManagerInterface; +use App\Domain\Manga\Domain\Model\Chapter; +use App\Domain\Manga\Domain\Model\ValueObject\ChapterId; +use App\Entity\Chapter as EntityChapter; readonly class LegacyMangaRepository implements MangaRepositoryInterface { @@ -71,6 +74,36 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface } } + public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array + { + $offset = ($page - 1) * $limit; + + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('c') + ->from(EntityChapter::class, 'c') + ->where('c.manga = :mangaId') + ->orderBy('c.number', $sortOrder) + ->setParameter('mangaId', $mangaId) + ->setFirstResult($offset) + ->setMaxResults($limit); + + return array_map( + fn (EntityChapter $entity) => $this->toChapterDomain($entity), + $queryBuilder->getQuery()->getResult() + ); + } + + public function countChapters(string $mangaId): int + { + return $this->entityManager->createQueryBuilder() + ->select('COUNT(c.id)') + ->from(EntityChapter::class, 'c') + ->where('c.manga = :mangaId') + ->setParameter('mangaId', $mangaId) + ->getQuery() + ->getSingleScalarResult(); + } + private function toDomain(EntityManga $entity): DomainManga { return new DomainManga( @@ -110,4 +143,17 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface $entity->setRating($manga->getRating()); } } + + private function toChapterDomain(EntityChapter $entity): Chapter + { + return new Chapter( + id: new ChapterId((string)$entity->getId()), + mangaId: $entity->getManga()->getId(), + number: $entity->getNumber(), + title: $entity->getTitle(), + volume: $entity->getVolume(), + isVisible: $entity->isVisible(), + createdAt: new \DateTimeImmutable() + ); + } } \ No newline at end of file diff --git a/tests/Domain/Manga/Adapter/InMemoryMangaRepository.php b/tests/Domain/Manga/Adapter/InMemoryMangaRepository.php index 8ff2f47..4112e18 100644 --- a/tests/Domain/Manga/Adapter/InMemoryMangaRepository.php +++ b/tests/Domain/Manga/Adapter/InMemoryMangaRepository.php @@ -3,12 +3,17 @@ namespace App\Tests\Domain\Manga\Adapter; use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface; +use App\Domain\Manga\Domain\Model\Chapter; use App\Domain\Manga\Domain\Model\Manga; +use App\Domain\Manga\Domain\Model\ValueObject\ChapterId; class InMemoryMangaRepository implements MangaRepositoryInterface { /** @var Manga[] */ private array $mangas = []; + + /** @var array */ + private array $chapters = []; public function findAll(int $page = 1, int $limit = 20, string $sortBy = 'title', string $sortOrder = 'asc'): array { @@ -66,8 +71,49 @@ class InMemoryMangaRepository implements MangaRepositoryInterface }; } + public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array + { + if (!isset($this->chapters[$mangaId])) { + return []; + } + + $chapters = $this->chapters[$mangaId]; + + usort($chapters, function (Chapter $a, Chapter $b) use ($sortOrder) { + return $sortOrder === 'desc' + ? $b->getNumber() <=> $a->getNumber() + : $a->getNumber() <=> $b->getNumber(); + }); + + $offset = ($page - 1) * $limit; + return array_slice($chapters, $offset, $limit); + } + + public function countChapters(string $mangaId): int + { + return isset($this->chapters[$mangaId]) ? count($this->chapters[$mangaId]) : 0; + } + + public function addChaptersToManga(string $mangaId, int $count): void + { + $this->chapters[$mangaId] = []; + + for ($i = 1; $i <= $count; $i++) { + $this->chapters[$mangaId][] = new Chapter( + id: new ChapterId((string)$i), + mangaId: $mangaId, + number: (float)$i, + title: "Chapter $i", + volume: (int)ceil($i / 10), + isVisible: true, + createdAt: new \DateTimeImmutable() + ); + } + } + public function clear(): void { $this->mangas = []; + $this->chapters = []; } } \ No newline at end of file diff --git a/tests/Domain/Manga/Application/QueryHandler/GetMangaChaptersHandlerTest.php b/tests/Domain/Manga/Application/QueryHandler/GetMangaChaptersHandlerTest.php new file mode 100644 index 0000000..de4b1a9 --- /dev/null +++ b/tests/Domain/Manga/Application/QueryHandler/GetMangaChaptersHandlerTest.php @@ -0,0 +1,104 @@ +repository = new InMemoryMangaRepository(); + $this->handler = new GetMangaChaptersHandler($this->repository); + } + + public function testHandleThrowsExceptionWhenMangaNotFound(): void + { + $this->expectException(MangaNotFoundException::class); + + $query = new GetMangaChapters('non-existent-id'); + $this->handler->handle($query); + } + + public function testHandleReturnsEmptyListWhenNoChapters(): void + { + // Arrange + $this->givenMangaExists('123'); + + // Act + $query = new GetMangaChapters('123'); + $response = $this->handler->handle($query); + + // Assert + $this->assertEmpty($response->chapters); + $this->assertEquals(0, $response->total); + $this->assertEquals(1, $response->page); + $this->assertEquals(20, $response->limit); + $this->assertFalse($response->hasNextPage()); + $this->assertFalse($response->hasPreviousPage()); + } + + public function testHandleReturnsPaginatedChapters(): void + { + // Arrange + $this->givenMangaExistsWithChapters('123', 25); + + // Act + $query = new GetMangaChapters('123', page: 2, limit: 10); + $response = $this->handler->handle($query); + + // Assert + $this->assertCount(10, $response->chapters); + $this->assertEquals(25, $response->total); + $this->assertEquals(2, $response->page); + $this->assertEquals(10, $response->limit); + $this->assertTrue($response->hasNextPage()); + $this->assertTrue($response->hasPreviousPage()); + } + + protected function tearDown(): void + { + $this->repository->clear(); + } + + private function givenMangaExists(string $id): void + { + $manga = $this->createManga($id); + $this->repository->save($manga); + } + + private function givenMangaExistsWithChapters(string $id, int $chapterCount): void + { + $this->givenMangaExists($id); + // Note: We'll need to implement this in InMemoryMangaRepository + $this->repository->addChaptersToManga($id, $chapterCount); + } + + private function createManga(string $id): Manga + { + return new Manga( + id: new MangaId($id), + title: new MangaTitle('Test Manga'), + slug: new MangaSlug('test-manga'), + description: 'This is a test manga', + author: 'Test Author', + publicationYear: 2024, + genres: ['Action', 'Adventure'], + status: 'Ongoing', + externalId: null, + imageUrl: null, + rating: null + ); + } +} \ No newline at end of file diff --git a/tests/Feature/Manga/GetMangaChaptersTest.php b/tests/Feature/Manga/GetMangaChaptersTest.php new file mode 100644 index 0000000..5d99ebe --- /dev/null +++ b/tests/Feature/Manga/GetMangaChaptersTest.php @@ -0,0 +1,115 @@ +request('GET', '/api/mangas/999/chapters'); + + // Then + $this->assertResponseStatusCodeSame(404); + } + + public function testGetEmptyChapterList(): void + { + // Given + $manga = $this->createManga(); + + // When + $client = static::createClient(); + $response = $client->request('GET', '/api/mangas/' . $manga->getId() . '/chapters'); + + // Then + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'total' => 0, + 'page' => 1, + 'limit' => 20, + 'hasNextPage' => false, + 'hasPreviousPage' => false, + 'items' => [] + ]); + } + + public function testGetChaptersWithPagination(): void + { + // Given + $manga = $this->createManga(); + $this->createChapters($manga, 25); + + // When + $client = static::createClient(); + $response = $client->request('GET', '/api/mangas/' . $manga->getId() . '/chapters', [ + 'query' => [ + 'page' => 2, + 'limit' => 10, + 'sortOrder' => 'desc' + ] + ]); + + // Then + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + + $this->assertCount(10, $data['items']); + $this->assertEquals(25, $data['total']); + $this->assertEquals(2, $data['page']); + $this->assertEquals(10, $data['limit']); + $this->assertTrue($data['hasNextPage']); + $this->assertTrue($data['hasPreviousPage']); + + $numbers = array_map(fn($item) => $item['number'], $data['items']); + + $expectedNumbers = $numbers; + rsort($expectedNumbers); + $this->assertEquals($expectedNumbers, $numbers); + } + + private function createManga(): Manga + { + $manga = new Manga(); + $manga->setTitle('One Piece') + ->setSlug('one-piece') + ->setDescription('Test description') + ->setAuthor('Eiichiro Oda') + ->setPublicationYear(1997) + ->setGenres(['action', 'adventure']) + ->setStatus('ongoing') + ->setMonitored(true); + + $entityManager = static::getContainer()->get('doctrine')->getManager(); + $entityManager->persist($manga); + $entityManager->flush(); + + return $manga; + } + + private function createChapters(Manga $manga, int $count): void + { + $entityManager = static::getContainer()->get('doctrine')->getManager(); + + for ($i = 1; $i <= $count; $i++) { + $chapter = new Chapter(); + $chapter->setManga($manga) + ->setNumber($i) + ->setTitle("Chapter $i") + ->setVolume((int)ceil($i / 10)) + ->setVisible(true); + + $entityManager->persist($chapter); + } + + $entityManager->flush(); + } +} \ No newline at end of file