diff --git a/config/services_test.yaml b/config/services_test.yaml index ec680a7..94822ee 100644 --- a/config/services_test.yaml +++ b/config/services_test.yaml @@ -22,4 +22,8 @@ services: $projectDir: '%kernel.project_dir%' public: true + App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface: + class: App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor + public: true + diff --git a/src/Domain/Manga/Application/Command/CreateManga.php b/src/Domain/Manga/Application/Command/CreateManga.php new file mode 100644 index 0000000..b24dd0b --- /dev/null +++ b/src/Domain/Manga/Application/Command/CreateManga.php @@ -0,0 +1,19 @@ +toString()), + new MangaTitle($command->title), + new MangaSlug($command->slug), + $command->description, + $command->author, + $command->publicationYear, + $command->genres, + $command->status, + $command->externalId ? new ExternalId($command->externalId) : null, + $command->imageUrl, + $command->rating + ); + + if (!is_null($command->imageUrl)) { + try { + $fullImagePath = $this->imageProcessor->downloadImage($command->imageUrl); + $thumbnailPath = $this->imageProcessor->createThumbnail($fullImagePath); + $manga->updateImageUrls(new ImageUrls($fullImagePath, $thumbnailPath)); + } catch (\Exception $e) { + throw new \RuntimeException('Erreur lors du traitement de l\'image : ' . $e->getMessage()); + } + } + + $this->mangaRepository->save($manga); + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/CreateMangaDirectlyResource.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/CreateMangaDirectlyResource.php new file mode 100644 index 0000000..e9a841b --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/CreateMangaDirectlyResource.php @@ -0,0 +1,61 @@ + 'Create a new manga directly', + 'description' => 'Creates a new manga with provided data' + ] + ) + ] +)] +class CreateMangaDirectlyResource +{ + #[Assert\NotBlank(message: 'Le titre ne peut pas être vide')] + #[Assert\Length(min: 1, max: 255, minMessage: 'Le titre doit contenir au moins {{ limit }} caractère', maxMessage: 'Le titre ne peut pas dépasser {{ limit }} caractères')] + public string $title; + + #[Assert\NotBlank(message: 'Le slug ne peut pas être vide')] + #[Assert\Regex(pattern: '/^[a-z0-9-]+$/', message: 'Le slug ne peut contenir que des lettres minuscules, des chiffres et des tirets')] + public string $slug; + + #[Assert\NotBlank(message: 'La description ne peut pas être vide')] + public string $description; + + #[Assert\NotBlank(message: 'L\'auteur ne peut pas être vide')] + public string $author; + + #[Assert\NotBlank(message: 'L\'année de publication ne peut pas être vide')] + #[Assert\Type(type: 'integer', message: 'L\'année de publication doit être un nombre entier')] + #[Assert\Range(min: 1900, max: 2100, notInRangeMessage: 'L\'année de publication doit être comprise entre {{ min }} et {{ max }}')] + public int $publicationYear; + + #[Assert\NotBlank(message: 'Les genres ne peuvent pas être vides')] + #[Assert\Type(type: 'array', message: 'Les genres doivent être une liste')] + #[Assert\Count(min: 1, minMessage: 'Vous devez spécifier au moins un genre')] + public array $genres; + + #[Assert\NotBlank(message: 'Le statut ne peut pas être vide')] + #[Assert\Choice(choices: ['ongoing', 'completed', 'hiatus'], message: 'Le statut doit être l\'un des suivants : ongoing, completed, hiatus')] + public string $status; + + public ?string $externalId = null; + + #[Assert\Url(message: 'L\'URL de l\'image n\'est pas valide')] + public ?string $imageUrl = null; + + #[Assert\Type(type: 'float', message: 'La note doit être un nombre décimal')] + #[Assert\Range(min: 0, max: 5, notInRangeMessage: 'La note doit être comprise entre {{ min }} et {{ max }}')] + public ?float $rating = null; +} \ No newline at end of file diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/State/Processor/CreateMangaDirectlyProcessor.php b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Processor/CreateMangaDirectlyProcessor.php new file mode 100644 index 0000000..05e5050 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/State/Processor/CreateMangaDirectlyProcessor.php @@ -0,0 +1,38 @@ +title, + slug: $data->slug, + description: $data->description, + author: $data->author, + publicationYear: $data->publicationYear, + genres: $data->genres, + status: $data->status, + externalId: $data->externalId, + imageUrl: $data->imageUrl, + rating: $data->rating + ); + + $this->handler->handle($command); + } +} \ No newline at end of file diff --git a/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php b/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php index c771664..935b60d 100644 --- a/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php +++ b/src/Domain/Manga/Infrastructure/Persistence/LegacyMangaRepository.php @@ -56,6 +56,10 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface public function save(DomainManga $manga): void { $entity = new EntityManga(); + + $imageUrls = $manga->getImageUrls(); + $fullImageUrl = $imageUrls?->getFull(); + $thumbnailUrl = $imageUrls?->getThumbnail(); $entity->setTitle($manga->getTitle()->getValue()) ->setSlug($manga->getSlug()->getValue()) @@ -65,8 +69,8 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface ->setGenres($manga->getGenres()) ->setStatus($manga->getStatus()) ->setExternalId($manga->getExternalId()->getValue()) - ->setImageUrl($manga->getImageUrls()->getFull()) - ->setThumbnailUrl($manga->getImageUrls()->getThumbnail()) + ->setImageUrl($fullImageUrl ?? null) + ->setThumbnailUrl($thumbnailUrl ?? null) ->setMonitored(false); if ($manga->getRating() !== null) { diff --git a/tests/Domain/Manga/Adapter/InMemoryImageProcessor.php b/tests/Domain/Manga/Adapter/InMemoryImageProcessor.php index 41e3eea..5ee6b6c 100644 --- a/tests/Domain/Manga/Adapter/InMemoryImageProcessor.php +++ b/tests/Domain/Manga/Adapter/InMemoryImageProcessor.php @@ -6,18 +6,34 @@ use App\Domain\Manga\Domain\Contract\Service\ImageProcessorInterface; class InMemoryImageProcessor implements ImageProcessorInterface { - private const string FULL_IMAGE_PATH = '/images/full'; - private const string THUMBNAIL_PATH = '/images/thumbnails'; + private const string FAKE_FULL_IMAGE_PATH = '/images/full/test-image.jpg'; + private const string FAKE_THUMBNAIL_PATH = '/images/thumbnails/test-image.jpg'; + + /** @var array */ + private array $downloadedImages = []; + + /** @var array */ + private array $thumbnails = []; public function downloadImage(string $imageUrl): string { - $filename = sprintf('%s/%s.jpg', self::FULL_IMAGE_PATH, uniqid()); - return $filename; + $this->downloadedImages[$imageUrl] = self::FAKE_FULL_IMAGE_PATH; + return self::FAKE_FULL_IMAGE_PATH; } public function createThumbnail(string $originalImagePath): string { - $filename = basename($originalImagePath); - return sprintf('%s/%s', self::THUMBNAIL_PATH, $filename); + $this->thumbnails[$originalImagePath] = self::FAKE_THUMBNAIL_PATH; + return self::FAKE_THUMBNAIL_PATH; + } + + public function getDownloadedImages(): array + { + return $this->downloadedImages; + } + + public function getThumbnails(): array + { + return $this->thumbnails; } } \ No newline at end of file diff --git a/tests/Domain/Manga/Application/CommandHandler/CreateMangaHandlerTest.php b/tests/Domain/Manga/Application/CommandHandler/CreateMangaHandlerTest.php new file mode 100644 index 0000000..6d1e20d --- /dev/null +++ b/tests/Domain/Manga/Application/CommandHandler/CreateMangaHandlerTest.php @@ -0,0 +1,80 @@ +repository = new InMemoryMangaRepository(); + $this->imageProcessor = new InMemoryImageProcessor(); + $this->handler = new CreateMangaHandler( + $this->repository, + $this->imageProcessor + ); + } + + public function testHandleSuccess(): void + { + // Arrange + $command = new CreateManga( + title: 'One Piece', + slug: 'one-piece', + description: 'Description test', + author: 'Eiichiro Oda', + publicationYear: 1997, + genres: ['action', 'adventure'], + status: 'ongoing', + externalId: 'external-123', + imageUrl: 'http://example.com/image.jpg', + rating: 4.5 + ); + + // Act + $this->handler->handle($command); + + // Assert + $savedManga = $this->repository->findAll()[0]; + $this->assertEquals('One Piece', $savedManga->getTitle()->getValue()); + $this->assertEquals('one-piece', $savedManga->getSlug()->getValue()); + $this->assertEquals('external-123', $savedManga->getExternalId()?->getValue()); + $this->assertNotNull($savedManga->getImageUrls()); + $this->assertStringStartsWith('/images/full/', $savedManga->getImageUrls()->getFull()); + $this->assertStringStartsWith('/images/thumbnails/', $savedManga->getImageUrls()->getThumbnail()); + } + + public function testHandleWithoutImage(): void + { + // Arrange + $command = new CreateManga( + title: 'One Piece', + slug: 'one-piece', + description: 'Description test', + author: 'Eiichiro Oda', + publicationYear: 1997, + genres: ['action', 'adventure'], + status: 'ongoing', + externalId: 'external-123', + imageUrl: null, + rating: 4.5 + ); + + // Act + $this->handler->handle($command); + + // Assert + $savedManga = $this->repository->findAll()[0]; + $this->assertEquals('One Piece', $savedManga->getTitle()->getValue()); + $this->assertNull($savedManga->getImageUrls()); + } +} \ No newline at end of file diff --git a/tests/Feature/Manga/CreateMangaDirectlyTest.php b/tests/Feature/Manga/CreateMangaDirectlyTest.php new file mode 100644 index 0000000..7078202 --- /dev/null +++ b/tests/Feature/Manga/CreateMangaDirectlyTest.php @@ -0,0 +1,128 @@ +request('POST', '/api/mangas/create', [ + 'json' => [ + 'title' => 'One Piece', + 'slug' => 'one-piece', + 'description' => 'Test description', + 'author' => 'Eiichiro Oda', + 'publicationYear' => 1997, + 'genres' => ['action', 'adventure'], + 'status' => 'ongoing', + 'externalId' => 'external-123', + 'imageUrl' => 'http://example.com/image.jpg', + 'rating' => 4.5 + ] + ]); + + // Then + $this->assertResponseIsSuccessful(); + + // Verify the manga was created in database + $entityManager = static::getContainer()->get('doctrine')->getManager(); + $manga = $entityManager->getRepository(\App\Entity\Manga::class)->findOneBy(['slug' => 'one-piece']); + + $this->assertNotNull($manga); + $this->assertEquals('One Piece', $manga->getTitle()); + $this->assertEquals('Test description', $manga->getDescription()); + $this->assertEquals('Eiichiro Oda', $manga->getAuthor()); + $this->assertEquals(1997, $manga->getPublicationYear()); + $this->assertEquals(['action', 'adventure'], $manga->getGenres()); + $this->assertEquals('ongoing', $manga->getStatus()); + $this->assertEquals('external-123', $manga->getExternalId()); + $this->assertEquals('/images/full/test-image.jpg', $manga->getImageUrl()); + $this->assertEquals('/images/thumbnails/test-image.jpg', $manga->getThumbnailUrl()); + $this->assertEquals(4.5, $manga->getRating()); + } + + public function testCreateMangaWithoutImage(): void + { + // When + $client = static::createClient(); + $response = $client->request('POST', '/api/mangas/create', [ + 'json' => [ + 'title' => 'One Piece', + 'slug' => 'one-piece', + 'description' => 'Test description', + 'author' => 'Eiichiro Oda', + 'publicationYear' => 1997, + 'genres' => ['action', 'adventure'], + 'status' => 'ongoing', + 'externalId' => 'external-123', + 'imageUrl' => null, + 'rating' => 4.5 + ] + ]); + + // Then + $this->assertResponseIsSuccessful(); + + $entityManager = static::getContainer()->get('doctrine')->getManager(); + $manga = $entityManager->getRepository(\App\Entity\Manga::class)->findOneBy(['slug' => 'one-piece']); + + $this->assertNotNull($manga); + $this->assertNull($manga->getImageUrl()); + $this->assertNull($manga->getThumbnailUrl()); + } + + public function testCreateMangaWithInvalidData(): void + { + // When + $client = static::createClient(); + $response = $client->request('POST', '/api/mangas/create', [ + 'json' => [ + 'title' => '', // Invalid: empty title + 'publicationYear' => 'invalid', // Invalid: not a number + ] + ]); + + // Then + $this->assertResponseStatusCodeSame(400); + $this->assertJsonContains([ + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'The type of the "publicationYear" attribute must be "int", "string" given.' + ]); + } + + public function testCreateMangaWithInvalidYear(): void + { + // When + $client = static::createClient(); + $response = $client->request('POST', '/api/mangas/create', [ + 'json' => [ + 'title' => 'One Piece', + 'slug' => 'one-piece', + 'description' => 'Test description', + 'author' => 'Eiichiro Oda', + 'publicationYear' => 2200, // Année invalide > 2100 + 'genres' => ['action', 'adventure'], + 'status' => 'ongoing', + 'externalId' => 'external-123', + 'imageUrl' => null, + 'rating' => 4.5 + ] + ]); + + // Then + $this->assertResponseStatusCodeSame(422); + $this->assertJsonContains([ + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'publicationYear: L\'année de publication doit être comprise entre 1900 et 2100', + 'title' => 'An error occurred' + ]); + } +} \ No newline at end of file