diff --git a/.vscode/settings.json b/.vscode/settings.json index f07931e..5d88261 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,25 @@ { "symfony-vscode.shellExecutable": "/bin/bash", - "symfony-vscode.shellCommand": "docker exec mangarr-php-1 /bin/sh -c 'cd / && php \"$@\"' -- " -} \ No newline at end of file + "symfony-vscode.shellCommand": "docker exec mangarr-php-1 /bin/sh -c 'cd / && php \"$@\"' -- ", + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#2f7c47", + "activityBar.background": "#2f7c47", + "activityBar.foreground": "#e7e7e7", + "activityBar.inactiveForeground": "#e7e7e799", + "activityBarBadge.background": "#422c74", + "activityBarBadge.foreground": "#e7e7e7", + "commandCenter.border": "#e7e7e799", + "sash.hoverBorder": "#2f7c47", + "statusBar.background": "#215732", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#2f7c47", + "statusBarItem.remoteBackground": "#215732", + "statusBarItem.remoteForeground": "#e7e7e7", + "titleBar.activeBackground": "#215732", + "titleBar.activeForeground": "#e7e7e7", + "titleBar.inactiveBackground": "#21573299", + "titleBar.inactiveForeground": "#e7e7e799", + "activityBar.activeBorder": "#422c74" + }, + "peacock.color": "#215732" +} diff --git a/assets/vue/app/domain/import/README.md b/assets/vue/app/domain/import/README.md index 856183c..87c62ee 100644 --- a/assets/vue/app/domain/import/README.md +++ b/assets/vue/app/domain/import/README.md @@ -95,13 +95,35 @@ Retourne : ### Import de fichier ``` -POST /api/import/upload-file +POST /api/chapters/import ``` FormData : -- `file`: Le fichier CBZ/CBR -- `mangaId`: ID du manga sélectionné -- `chapterNumber`: Numéro de chapitre (optionnel, float) -- `volumeNumber`: Numéro de volume (optionnel, float) +- `file`: Le fichier CBZ à importer +- `mangaId`: ID du manga +- `chapterNumber`: Numéro de chapitre (float, optionnel) + +Réponse (200) : +```json +{ + "message": "Chapter imported successfully", + "mangaId": "uuid", + "chapterNumber": 1.5 +} +``` + +Erreurs : +- `404`: Manga ou Chapitre non trouvé +- `422`: Paramètres invalides ou fichier absent +- `400`: Fichier CBZ invalide + +### Import de volume (À venir) +``` +POST /api/volumes/import +``` +FormData : +- `file`: Le fichier CBZ à importer +- `mangaId`: ID du manga +- `volumeNumber`: Numéro de volume (int) ## Store Pinia diff --git a/assets/vue/app/domain/import/infrastructure/api/apiImportRepository.js b/assets/vue/app/domain/import/infrastructure/api/apiImportRepository.js index 13f4f62..9018edc 100644 --- a/assets/vue/app/domain/import/infrastructure/api/apiImportRepository.js +++ b/assets/vue/app/domain/import/infrastructure/api/apiImportRepository.js @@ -63,26 +63,42 @@ export class ApiImportRepository { * Upload et import d'un fichier avec les informations du manga * @param {File} file - Fichier à uploader * @param {string} mangaId - ID du manga - * @param {string|null} chapterId - ID du chapitre (optionnel) + * @param {number|null} chapterNumber - Numéro du chapitre (optionnel) * @param {number|null} volumeNumber - Numéro du volume (optionnel) * @returns {Promise} - Résultat de l'import */ - async importFile(file, mangaId, chapterId = null, volumeNumber = null) { + async importFile(file, mangaId, chapterNumber = null, volumeNumber = null) { + try { + // Déterminer s'il s'agit d'un import de chapitre ou volume + if (chapterNumber !== null && chapterNumber !== undefined) { + return await this.importChapter(file, mangaId, chapterNumber); + } else if (volumeNumber !== null && volumeNumber !== undefined) { + return await this.importVolume(file, mangaId, volumeNumber); + } else { + throw new Error('Either chapterNumber or volumeNumber must be provided'); + } + } catch (error) { + console.error('API Error:', error); + throw error; + } + } + + /** + * Import d'un chapitre + * @param {File} file - Fichier CBZ à uploader + * @param {string} mangaId - ID du manga + * @param {number} chapterNumber - Numéro du chapitre + * @returns {Promise} - Résultat de l'import + */ + async importChapter(file, mangaId, chapterNumber) { try { const formData = new FormData(); formData.append('file', file); formData.append('mangaId', mangaId); + formData.append('chapterNumber', chapterNumber.toString()); - if (chapterId) { - formData.append('chapterId', chapterId); - } - - if (volumeNumber) { - formData.append('volumeNumber', volumeNumber.toString()); - } - - console.log('Importing file:', file.name, 'for manga:', mangaId); - const response = await fetch('/api/import/upload-file', { + console.log('Importing chapter:', chapterNumber, 'for manga:', mangaId); + const response = await fetch('/api/chapters/import', { method: 'POST', body: formData }); @@ -90,7 +106,60 @@ export class ApiImportRepository { if (!response.ok) { const errorText = await response.text(); console.error('Import failed:', response.status, errorText); - throw new Error(`Failed to import file: ${response.status}`); + + // Parse the error response if it's JSON + let errorMessage = `Failed to import chapter: ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.error || errorJson.details || errorMessage; + } catch (e) { + // Not JSON, use the status message + } + throw new Error(errorMessage); + } + + const result = await response.json(); + console.log('Import result:', result); + return result; + } catch (error) { + console.error('API Error:', error); + throw error; + } + } + + /** + * Import d'un volume (TODO: À implémenter) + * @param {File} file - Fichier CBZ à uploader + * @param {string} mangaId - ID du manga + * @param {number} volumeNumber - Numéro du volume + * @returns {Promise} - Résultat de l'import + */ + async importVolume(file, mangaId, volumeNumber) { + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('mangaId', mangaId); + formData.append('volumeNumber', volumeNumber.toString()); + + console.log('Importing volume:', volumeNumber, 'for manga:', mangaId); + const response = await fetch('/api/volumes/import', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Import failed:', response.status, errorText); + + // Parse the error response if it's JSON + let errorMessage = `Failed to import volume: ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.error || errorJson.details || errorMessage; + } catch (e) { + // Not JSON, use the status message + } + throw new Error(errorMessage); } const result = await response.json(); diff --git a/assets/vue/app/domain/import/presentation/components/MangaMatchCard.vue b/assets/vue/app/domain/import/presentation/components/MangaMatchCard.vue index 6a72b0f..bd730f6 100644 --- a/assets/vue/app/domain/import/presentation/components/MangaMatchCard.vue +++ b/assets/vue/app/domain/import/presentation/components/MangaMatchCard.vue @@ -110,3 +110,7 @@ const props = defineProps({ const emit = defineEmits(['select-match']); + + + + diff --git a/src/Domain/Manga/Application/Command/ImportChapter.php b/src/Domain/Manga/Application/Command/ImportChapter.php new file mode 100644 index 0000000..5798df6 --- /dev/null +++ b/src/Domain/Manga/Application/Command/ImportChapter.php @@ -0,0 +1,12 @@ +mangaRepository->findById($command->mangaId); + if (!$manga) { + throw new MangaNotFoundException($command->mangaId); + } + + // 2. Validate that the file is a valid CBZ + if (!$this->isValidCbzFile($command->fileBinary)) { + throw new \InvalidArgumentException('The provided file is not a valid CBZ file'); + } + + // 3. Check if chapter exists + $existingChapter = $this->chapterRepository->findByMangaIdAndChapterNumber( + $command->mangaId, + $command->chapterNumber + ); + + if (!$existingChapter) { + throw new ChapterNotFoundException("Chapter {$command->chapterNumber} not found for manga {$command->mangaId}"); + } + + // 4. Save the CBZ file to storage using the path manager + $cbzPath = $this->saveCbzFile($command, $manga, $existingChapter); + + // 5. Update existing chapter with new CBZ path + $updatedChapter = new Chapter( + id: new ChapterId($existingChapter->getId()), + mangaId: $existingChapter->getMangaId(), + number: $existingChapter->getNumber(), + title: $existingChapter->getTitle(), + volume: $existingChapter->getVolume(), + isVisible: $existingChapter->isVisible(), + cbzPath: $cbzPath, + createdAt: $existingChapter->getCreatedAt() + ); + $this->chapterRepository->save($updatedChapter); + } + + /** + * Validate that the binary data is a valid CBZ (ZIP) file + */ + private function isValidCbzFile(string $fileBinary): bool + { + // CBZ files are ZIP archives, check for ZIP magic number + $zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04 + + return strpos($fileBinary, $zipMagicNumber) === 0; + } + + /** + * Save the CBZ file to storage and return the path + */ + private function saveCbzFile(ImportChapter $command, \App\Domain\Manga\Domain\Model\Manga $manga, Chapter $chapter): string + { + // Build the final CBZ path using the path manager (creates directories) + $volumeNumber = $chapter->getVolume() ?? 0; + $cbzPath = $this->pathManager->buildChapterCbzPath( + $manga->getTitle()->getValue(), + (string)$manga->getPublicationYear(), + $volumeNumber, + (string)$command->chapterNumber + ); + + // Write the binary content directly to the CBZ path + if (!file_put_contents($cbzPath, $command->fileBinary)) { + throw new \RuntimeException('Failed to save CBZ file'); + } + + return $cbzPath; + } +} diff --git a/src/Domain/Manga/Application/CommandHandler/ImportVolumeHandler.php b/src/Domain/Manga/Application/CommandHandler/ImportVolumeHandler.php new file mode 100644 index 0000000..404755d --- /dev/null +++ b/src/Domain/Manga/Application/CommandHandler/ImportVolumeHandler.php @@ -0,0 +1,99 @@ +mangaRepository->findById($command->mangaId); + if (!$manga) { + throw new MangaNotFoundException($command->mangaId); + } + + // 2. Validate that the file is a valid CBZ + if (!$this->isValidCbzFile($command->fileBinary)) { + throw new \InvalidArgumentException('The provided file is not a valid CBZ file'); + } + + // 3. Get all chapters for this volume + $chapters = $this->chapterRepository->findByMangaIdAndVolume( + $command->mangaId, + $command->volumeNumber + ); + + if (empty($chapters)) { + throw new \InvalidArgumentException( + "No chapters found for manga {$command->mangaId} in volume {$command->volumeNumber}" + ); + } + + // 4. Save the CBZ file to storage using the path manager + $cbzPath = $this->saveCbzFile($command, $manga); + + // 5. Update all chapters with the volume CBZ path + foreach ($chapters as $chapter) { + $updatedChapter = new Chapter( + id: new ChapterId($chapter->getId()), + mangaId: $chapter->getMangaId(), + number: $chapter->getNumber(), + title: $chapter->getTitle(), + volume: $chapter->getVolume(), + isVisible: $chapter->isVisible(), + cbzPath: $cbzPath, + createdAt: $chapter->getCreatedAt() + ); + $this->chapterRepository->save($updatedChapter); + } + } + + /** + * Validate that the binary data is a valid CBZ (ZIP) file + */ + private function isValidCbzFile(string $fileBinary): bool + { + // CBZ files are ZIP archives, check for ZIP magic number + $zipMagicNumber = "\x50\x4b\x03\x04"; // PK\x03\x04 + + return strpos($fileBinary, $zipMagicNumber) === 0; + } + + /** + * Save the CBZ file to storage and return the path + */ + private function saveCbzFile(ImportVolume $command, \App\Domain\Manga\Domain\Model\Manga $manga): string + { + // Build the final CBZ path using the path manager (creates directories) + $cbzPath = $this->pathManager->buildVolumeCbzPath( + $manga->getTitle()->getValue(), + (string)$manga->getPublicationYear(), + $command->volumeNumber + ); + + // Write the binary content directly to the CBZ path + if (!file_put_contents($cbzPath, $command->fileBinary)) { + throw new \RuntimeException('Failed to save CBZ file'); + } + + return $cbzPath; + } +} + + + + diff --git a/src/Domain/Manga/Application/QueryHandler/DownloadVolumeHandler.php b/src/Domain/Manga/Application/QueryHandler/DownloadVolumeHandler.php index 9d417ac..47f236e 100644 --- a/src/Domain/Manga/Application/QueryHandler/DownloadVolumeHandler.php +++ b/src/Domain/Manga/Application/QueryHandler/DownloadVolumeHandler.php @@ -46,7 +46,7 @@ readonly class DownloadVolumeHandler implements QueryHandlerInterface $cbzPaths[] = $chapter->getCbzPath(); } - $volumeName = sprintf('%s-volume-%d', + $volumeName = sprintf('%s_vol%d', $manga->getSlug()->getValue(), $query->volume ); diff --git a/src/Domain/Manga/Domain/Contract/Repository/ChapterRepositoryInterface.php b/src/Domain/Manga/Domain/Contract/Repository/ChapterRepositoryInterface.php index acebc37..e2accf8 100644 --- a/src/Domain/Manga/Domain/Contract/Repository/ChapterRepositoryInterface.php +++ b/src/Domain/Manga/Domain/Contract/Repository/ChapterRepositoryInterface.php @@ -8,6 +8,7 @@ interface ChapterRepositoryInterface { public function findById(string $id): ?Chapter; public function findVisibleById(string $id): ?Chapter; + public function findByMangaIdAndChapterNumber(string $mangaId, float $chapterNumber): ?Chapter; public function save(Chapter $chapter): void; public function delete(Chapter $chapter): void; diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Controller/ImportChapterController.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Controller/ImportChapterController.php new file mode 100644 index 0000000..eedba05 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Controller/ImportChapterController.php @@ -0,0 +1,145 @@ +request->get('mangaId'); + $chapterNumber = $request->request->get('chapterNumber'); + $uploadedFile = $request->files->get('file'); + + // Validate required fields + if (!$mangaId) { + return $this->json([ + ['propertyPath' => 'mangaId', 'message' => 'mangaId is required'] + ], 422); + } + + if (!$chapterNumber) { + return $this->json([ + ['propertyPath' => 'chapterNumber', 'message' => 'chapterNumber is required'] + ], 422); + } + + if (!$uploadedFile) { + return $this->json([ + ['propertyPath' => 'file', 'message' => 'Please upload a file'] + ], 422); + } + + // Validate file + $errors = $this->validateFile($uploadedFile); + if (!empty($errors)) { + return $this->json($errors, 422); + } + + try { + // Read file binary content + $fileBinary = file_get_contents($uploadedFile->getPathname()); + if ($fileBinary === false) { + return $this->json([ + ['propertyPath' => 'file', 'message' => 'Failed to read the uploaded file'] + ], 400); + } + + // Create the command + $command = new ImportChapter( + mangaId: $mangaId, + chapterNumber: (float) $chapterNumber, + fileBinary: $fileBinary + ); + + // Execute the import + $this->commandHandler->handle($command); + + return $this->json([ + 'message' => 'Chapter imported successfully', + 'mangaId' => $mangaId, + 'chapterNumber' => $chapterNumber + ], 200); + + } catch (MangaNotFoundException $e) { + return $this->json([ + 'error' => 'Manga not found', + 'details' => $e->getMessage() + ], 404); + + } catch (ChapterNotFoundException $e) { + return $this->json([ + 'error' => 'Chapter not found', + 'details' => $e->getMessage() + ], 404); + + } catch (\InvalidArgumentException $e) { + return $this->json([ + 'error' => 'Invalid file', + 'details' => $e->getMessage() + ], 400); + + } catch (\Exception $e) { + return $this->json([ + 'error' => 'Import failed', + 'details' => $e->getMessage() + ], 500); + } + } + + private function validateFile($uploadedFile): array + { + $errors = []; + + // Check if file is valid + if (!$uploadedFile->isValid()) { + $errors[] = [ + 'propertyPath' => 'file', + 'message' => 'The uploaded file is not valid: ' . $uploadedFile->getErrorMessage() + ]; + return $errors; + } + + // Check file size (500MB max) + $maxSize = 500 * 1024 * 1024; // 500MB in bytes + if ($uploadedFile->getSize() > $maxSize) { + $errors[] = [ + 'propertyPath' => 'file', + 'message' => 'The uploaded file is too large. Allowed size is 500MB.' + ]; + } + + // Check file extension + $allowedExtensions = ['cbz']; + $extension = strtolower($uploadedFile->getClientOriginalExtension()); + + if (!in_array($extension, $allowedExtensions)) { + $errors[] = [ + 'propertyPath' => 'file', + 'message' => 'Please upload a valid CBZ file' + ]; + } + + return $errors; + } +} + + + + diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Controller/ImportVolumeController.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Controller/ImportVolumeController.php new file mode 100644 index 0000000..d946e75 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Controller/ImportVolumeController.php @@ -0,0 +1,138 @@ +request->get('mangaId'); + $volumeNumber = $request->request->get('volumeNumber'); + $uploadedFile = $request->files->get('file'); + + // Validate required fields + if (!$mangaId) { + return $this->json([ + ['propertyPath' => 'mangaId', 'message' => 'mangaId is required'] + ], 422); + } + + if (!$volumeNumber) { + return $this->json([ + ['propertyPath' => 'volumeNumber', 'message' => 'volumeNumber is required'] + ], 422); + } + + if (!$uploadedFile) { + return $this->json([ + ['propertyPath' => 'file', 'message' => 'Please upload a file'] + ], 422); + } + + // Validate file + $errors = $this->validateFile($uploadedFile); + if (!empty($errors)) { + return $this->json($errors, 422); + } + + try { + // Read file binary content + $fileBinary = file_get_contents($uploadedFile->getPathname()); + if ($fileBinary === false) { + return $this->json([ + ['propertyPath' => 'file', 'message' => 'Failed to read the uploaded file'] + ], 400); + } + + // Create the command + $command = new ImportVolume( + mangaId: $mangaId, + volumeNumber: (int) $volumeNumber, + fileBinary: $fileBinary + ); + + // Execute the import + $this->commandHandler->handle($command); + + return $this->json([ + 'message' => 'Volume imported successfully', + 'mangaId' => $mangaId, + 'volumeNumber' => (int) $volumeNumber + ], 200); + + } catch (MangaNotFoundException $e) { + return $this->json([ + 'error' => 'Manga not found', + 'details' => $e->getMessage() + ], 404); + + } catch (\InvalidArgumentException $e) { + $statusCode = str_contains($e->getMessage(), 'not found') ? 404 : 400; + return $this->json([ + 'error' => 'Invalid request', + 'details' => $e->getMessage() + ], $statusCode); + + } catch (\Exception $e) { + return $this->json([ + 'error' => 'Import failed', + 'details' => $e->getMessage() + ], 500); + } + } + + private function validateFile($uploadedFile): array + { + $errors = []; + + // Check if file is valid + if (!$uploadedFile->isValid()) { + $errors[] = [ + 'propertyPath' => 'file', + 'message' => 'The uploaded file is not valid: ' . $uploadedFile->getErrorMessage() + ]; + return $errors; + } + + // Check file size (500MB max) + $maxSize = 500 * 1024 * 1024; // 500MB in bytes + if ($uploadedFile->getSize() > $maxSize) { + $errors[] = [ + 'propertyPath' => 'file', + 'message' => 'The uploaded file is too large. Allowed size is 500MB.' + ]; + } + + // Check file extension + $allowedExtensions = ['cbz']; + $extension = strtolower($uploadedFile->getClientOriginalExtension()); + + if (!in_array($extension, $allowedExtensions)) { + $errors[] = [ + 'propertyPath' => 'file', + 'message' => 'Please upload a valid CBZ file' + ]; + } + + return $errors; + } +} + + + + diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/ImportChapterResource.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/ImportChapterResource.php new file mode 100644 index 0000000..df23d23 --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/ImportChapterResource.php @@ -0,0 +1,78 @@ + 'Import a chapter from CBZ file', + 'description' => 'Imports a CBZ file for an existing chapter and stores it', + 'requestBody' => [ + 'content' => [ + 'multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['mangaId', 'chapterNumber', 'file'], + 'properties' => [ + 'mangaId' => [ + 'type' => 'string', + 'format' => 'uuid', + 'description' => 'The manga UUID' + ], + 'chapterNumber' => [ + 'type' => 'number', + 'description' => 'The chapter number (e.g., 1.5)' + ], + 'file' => [ + 'type' => 'string', + 'format' => 'binary', + 'description' => 'CBZ file to import (max 500MB)' + ] + ] + ] + ] + ] + ], + 'responses' => [ + '200' => [ + 'description' => 'Chapter imported successfully', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'message' => ['type' => 'string'], + 'chapterId' => ['type' => 'string'] + ] + ] + ] + ] + ] + ] + ] + ) + ] +)] +class ImportChapterResource +{ + public ?string $mangaId = null; + + public ?float $chapterNumber = null; + + public ?File $file = null; +} + + + + diff --git a/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/ImportVolumeResource.php b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/ImportVolumeResource.php new file mode 100644 index 0000000..92f4aaa --- /dev/null +++ b/src/Domain/Manga/Infrastructure/ApiPlatform/Resource/ImportVolumeResource.php @@ -0,0 +1,78 @@ + 'Import a volume from CBZ file', + 'description' => 'Imports a CBZ file for an existing volume and updates all chapters', + 'requestBody' => [ + 'content' => [ + 'multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['mangaId', 'volumeNumber', 'file'], + 'properties' => [ + 'mangaId' => [ + 'type' => 'string', + 'format' => 'uuid', + 'description' => 'The manga UUID' + ], + 'volumeNumber' => [ + 'type' => 'integer', + 'description' => 'The volume number' + ], + 'file' => [ + 'type' => 'string', + 'format' => 'binary', + 'description' => 'CBZ file to import (max 500MB)' + ] + ] + ] + ] + ] + ], + 'responses' => [ + '200' => [ + 'description' => 'Volume imported successfully', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'message' => ['type' => 'string'], + 'mangaId' => ['type' => 'string'], + 'volumeNumber' => ['type' => 'integer'] + ] + ] + ] + ] + ] + ] + ] + ) + ] +)] +class ImportVolumeResource +{ + public ?string $mangaId = null; + + public ?int $volumeNumber = null; + + public ?File $file = null; +} + + + + diff --git a/src/Domain/Manga/Infrastructure/Persistence/Repository/LegacyChapterRepository.php b/src/Domain/Manga/Infrastructure/Persistence/Repository/LegacyChapterRepository.php index fe9ce3d..211eb26 100644 --- a/src/Domain/Manga/Infrastructure/Persistence/Repository/LegacyChapterRepository.php +++ b/src/Domain/Manga/Infrastructure/Persistence/Repository/LegacyChapterRepository.php @@ -36,6 +36,21 @@ readonly class LegacyChapterRepository implements ChapterRepositoryInterface return $entity ? $this->toDomainModel($entity) : null; } + public function findByMangaIdAndChapterNumber(string $mangaId, float $chapterNumber): ?Chapter + { + $qb = $this->entityManager->createQueryBuilder() + ->select('c') + ->from(ChapterEntity::class, 'c') + ->where('c.manga = :mangaId') + ->andWhere('c.number = :chapterNumber') + ->setParameter('mangaId', $mangaId) + ->setParameter('chapterNumber', $chapterNumber); + + $entity = $qb->getQuery()->getOneOrNullResult(); + + return $entity ? $this->toDomainModel($entity) : null; + } + public function save(Chapter $chapter): void { $entity = $this->entityManager->find(ChapterEntity::class, $chapter->getId()); diff --git a/tests/Domain/Manga/Adapter/InMemoryChapterRepository.php b/tests/Domain/Manga/Adapter/InMemoryChapterRepository.php new file mode 100644 index 0000000..0674415 --- /dev/null +++ b/tests/Domain/Manga/Adapter/InMemoryChapterRepository.php @@ -0,0 +1,95 @@ + */ + private array $chapters = []; + + public function findById(string $id): ?Chapter + { + return $this->chapters[$id] ?? null; + } + + public function findVisibleById(string $id): ?Chapter + { + $chapter = $this->chapters[$id] ?? null; + if ($chapter && $chapter->isVisible()) { + return $chapter; + } + + return null; + } + + public function findByMangaIdAndChapterNumber(string $mangaId, float $chapterNumber): ?Chapter + { + foreach ($this->chapters as $chapter) { + if ($chapter->getMangaId() === $mangaId && $chapter->getNumber() === $chapterNumber) { + return $chapter; + } + } + + return null; + } + + public function save(Chapter $chapter): void + { + $this->chapters[$chapter->getId()] = $chapter; + } + + public function delete(Chapter $chapter): void + { + unset($this->chapters[$chapter->getId()]); + } + + public function findByMangaIdAndVolume(string $mangaId, int $volume): array + { + return array_filter( + $this->chapters, + fn (Chapter $chapter) => $chapter->getMangaId() === $mangaId && $chapter->getVolume() === $volume + ); + } + + public function findVisibleByMangaIdAndVolume(string $mangaId, int $volume): array + { + return array_filter( + $this->chapters, + fn (Chapter $chapter) => + $chapter->getMangaId() === $mangaId && + $chapter->getVolume() === $volume && + $chapter->isVisible() + ); + } + + public function findVisibleWithCbzByMangaIdAndVolume(string $mangaId, int $volume): array + { + return array_filter( + $this->chapters, + fn (Chapter $chapter) => + $chapter->getMangaId() === $mangaId && + $chapter->getVolume() === $volume && + $chapter->isVisible() && + $chapter->isAvailable() + ); + } + + /** + * Get all chapters + */ + public function getAll(): array + { + return array_values($this->chapters); + } + + /** + * Clear all chapters + */ + public function clear(): void + { + $this->chapters = []; + } +} diff --git a/tests/Domain/Manga/Adapter/InMemoryPathManager.php b/tests/Domain/Manga/Adapter/InMemoryPathManager.php new file mode 100644 index 0000000..1605292 --- /dev/null +++ b/tests/Domain/Manga/Adapter/InMemoryPathManager.php @@ -0,0 +1,91 @@ + */ + private array $files = []; + + public function getMangaDirectory(string $mangaTitle, string $publicationYear): string + { + $dir = '/tmp/manga/' . $this->slugify($mangaTitle) . '_' . $publicationYear; + $this->ensureDirectory($dir); + return $dir; + } + + public function getVolumeDirectory(string $mangaTitle, string $publicationYear, int $volumeNumber): string + { + $dir = $this->getMangaDirectory($mangaTitle, $publicationYear) . '/volume_' . $volumeNumber; + $this->ensureDirectory($dir); + return $dir; + } + + public function buildChapterCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber, string $chapterNumber): string + { + $dir = $this->getVolumeDirectory($mangaTitle, $publicationYear, $volumeNumber); + return $dir . '/' . $this->slugify($mangaTitle) . '_vol' . $volumeNumber . '_ch' . $chapterNumber . '.cbz'; + } + + public function buildVolumeCbzPath(string $mangaTitle, string $publicationYear, int $volumeNumber): string + { + return $this->getVolumeDirectory($mangaTitle, $publicationYear, $volumeNumber) + . '/' . $this->slugify($mangaTitle) . '_vol' . $volumeNumber . '.cbz'; + } + + public function createCbzArchive(array $files, string $cbzPath): void + { + // For testing, just store the CBZ path + $this->files[$cbzPath] = json_encode($files); + } + + public function moveFileTo(string $sourcePath, string $destinationPath): void + { + // In-memory: just copy content if source exists + if (file_exists($sourcePath)) { + $content = file_get_contents($sourcePath); + $this->files[$destinationPath] = $content; + } + } + + public function fileExists(string $path): bool + { + return isset($this->files[$path]) || file_exists($path); + } + + /** + * Get all stored files + */ + public function getFiles(): array + { + return $this->files; + } + + /** + * Clear all stored files + */ + public function clear(): void + { + $this->files = []; + } + + private function slugify(string $text): string + { + $text = preg_replace('~[^\pL\d]+~u', '-', $text); + $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text); + $text = preg_replace('~[^-\w]+~', '', $text); + $text = trim($text, '-'); + $text = preg_replace('~-+~', '-', $text); + $text = strtolower($text); + return $text ?: 'n-a'; + } + + private function ensureDirectory(string $path): void + { + if (!is_dir($path)) { + mkdir($path, 0777, true); + } + } +} diff --git a/tests/Domain/Manga/Application/CommandHandler/ImportChapterHandlerTest.php b/tests/Domain/Manga/Application/CommandHandler/ImportChapterHandlerTest.php new file mode 100644 index 0000000..1e80133 --- /dev/null +++ b/tests/Domain/Manga/Application/CommandHandler/ImportChapterHandlerTest.php @@ -0,0 +1,197 @@ +mangaRepository = new InMemoryMangaRepository(); + $this->chapterRepository = new InMemoryChapterRepository(); + $this->pathManager = new InMemoryPathManager(); + $this->handler = new ImportChapterHandler( + $this->mangaRepository, + $this->chapterRepository, + $this->pathManager + ); + } + + public function test_it_throws_exception_when_chapter_not_found(): void + { + // Arrange + $mangaId = 'manga-123'; + $manga = new Manga( + new MangaId($mangaId), + new MangaTitle('One Piece'), + new MangaSlug('one-piece'), + 'Description', + 'Eiichiro Oda', + 1997, + ['action', 'adventure'], + 'ongoing' + ); + $this->mangaRepository->save($manga); + + $cbzBinary = $this->createValidCbzBinary(); + $command = new ImportChapter( + mangaId: $mangaId, + chapterNumber: 1.5, + fileBinary: $cbzBinary + ); + + // Assert + $this->expectException(ChapterNotFoundException::class); + + // Act + $this->handler->handle($command); + } + + public function test_it_updates_existing_chapter_with_new_cbz(): void + { + // Arrange + $mangaId = 'manga-123'; + $manga = new Manga( + new MangaId($mangaId), + new MangaTitle('One Piece'), + new MangaSlug('one-piece'), + 'Description', + 'Eiichiro Oda', + 1997, + ['action', 'adventure'], + 'ongoing' + ); + $this->mangaRepository->save($manga); + + // Create an existing chapter without CBZ + $existingChapter = new Chapter( + new ChapterId('chapter-123'), + $mangaId, + 1.5, + 'Chapter 1.5', + 1, + true, + null + ); + $this->chapterRepository->save($existingChapter); + + // Import the same chapter with CBZ + $cbzBinary = $this->createValidCbzBinary(); + $command = new ImportChapter( + mangaId: $mangaId, + chapterNumber: 1.5, + fileBinary: $cbzBinary + ); + + // Act + $this->handler->handle($command); + + // Assert + $chapters = $this->chapterRepository->getAll(); + $this->assertCount(1, $chapters); // Still only one chapter + + $updatedChapter = $chapters[0]; + $this->assertEquals('chapter-123', $updatedChapter->getId()); + $this->assertEquals($mangaId, $updatedChapter->getMangaId()); + $this->assertEquals(1.5, $updatedChapter->getNumber()); + $this->assertEquals('Chapter 1.5', $updatedChapter->getTitle()); // Title preserved + $this->assertEquals(1, $updatedChapter->getVolume()); // Volume preserved + $this->assertTrue($updatedChapter->isVisible()); + $this->assertTrue($updatedChapter->isAvailable()); // Now has CBZ + $this->assertStringContainsString('_vol1_ch1.5.cbz', $updatedChapter->getCbzPath()); + } + + public function test_it_throws_exception_when_manga_not_found(): void + { + // Arrange + $cbzBinary = $this->createValidCbzBinary(); + $command = new ImportChapter( + mangaId: 'non-existent-manga', + chapterNumber: 1.0, + fileBinary: $cbzBinary + ); + + // Assert + $this->expectException(MangaNotFoundException::class); + + // Act + $this->handler->handle($command); + } + + public function test_it_throws_exception_when_file_is_not_valid_cbz(): void + { + // Arrange + $mangaId = 'manga-123'; + $manga = new Manga( + new MangaId($mangaId), + new MangaTitle('One Piece'), + new MangaSlug('one-piece'), + 'Description', + 'Eiichiro Oda', + 1997, + ['action', 'adventure'], + 'ongoing' + ); + $this->mangaRepository->save($manga); + + $invalidBinary = 'This is not a CBZ file'; + $command = new ImportChapter( + mangaId: $mangaId, + chapterNumber: 1.0, + fileBinary: $invalidBinary + ); + + // Assert + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The provided file is not a valid CBZ file'); + + // Act + $this->handler->handle($command); + } + + /** + * Create a minimal valid CBZ (ZIP) binary for testing + */ + private function createValidCbzBinary(): string + { + $tmpFile = tempnam(sys_get_temp_dir(), 'cbz'); + + // Delete the empty file created by tempnam + unlink($tmpFile); + + $zip = new \ZipArchive(); + + // Create a new ZIP archive (avoid opening empty file) + if ($zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException('Cannot create test CBZ file'); + } + + // Add a dummy image file to the ZIP + $zip->addFromString('image1.jpg', 'fake-image-data'); + $zip->close(); + + $binaryContent = file_get_contents($tmpFile); + unlink($tmpFile); + + return $binaryContent; + } +} diff --git a/tests/Domain/Manga/Application/CommandHandler/ImportVolumeHandlerTest.php b/tests/Domain/Manga/Application/CommandHandler/ImportVolumeHandlerTest.php new file mode 100644 index 0000000..4cf32f3 --- /dev/null +++ b/tests/Domain/Manga/Application/CommandHandler/ImportVolumeHandlerTest.php @@ -0,0 +1,193 @@ +mangaRepository = new InMemoryMangaRepository(); + $this->chapterRepository = new InMemoryChapterRepository(); + $this->pathManager = new InMemoryPathManager(); + $this->handler = new ImportVolumeHandler( + $this->mangaRepository, + $this->chapterRepository, + $this->pathManager + ); + } + + public function test_it_updates_all_chapters_in_volume(): void + { + // Arrange + $mangaId = 'manga-123'; + $volumeNumber = 1; + $manga = new Manga( + new MangaId($mangaId), + new MangaTitle('One Piece'), + new MangaSlug('one-piece'), + 'Description', + 'Eiichiro Oda', + 1997, + ['action', 'adventure'], + 'ongoing' + ); + $this->mangaRepository->save($manga); + + // Create chapters in volume 1 + for ($i = 1; $i <= 3; $i++) { + $chapter = new Chapter( + new ChapterId("chapter-$i"), + $mangaId, + (float)$i, + "Chapter $i", + $volumeNumber, + true, + null + ); + $this->chapterRepository->save($chapter); + } + + $cbzBinary = $this->createValidCbzBinary(); + $command = new ImportVolume( + mangaId: $mangaId, + volumeNumber: $volumeNumber, + fileBinary: $cbzBinary + ); + + // Act + $this->handler->handle($command); + + // Assert + $chapters = $this->chapterRepository->findByMangaIdAndVolume($mangaId, $volumeNumber); + $this->assertCount(3, $chapters); + + foreach ($chapters as $chapter) { + $this->assertTrue($chapter->isAvailable()); + $this->assertStringContainsString('_vol' . $volumeNumber . '.cbz', $chapter->getCbzPath()); + } + } + + public function test_it_throws_exception_when_manga_not_found(): void + { + // Arrange + $cbzBinary = $this->createValidCbzBinary(); + $command = new ImportVolume( + mangaId: 'non-existent-manga', + volumeNumber: 1, + fileBinary: $cbzBinary + ); + + // Assert + $this->expectException(MangaNotFoundException::class); + + // Act + $this->handler->handle($command); + } + + public function test_it_throws_exception_when_file_is_not_valid_cbz(): void + { + // Arrange + $mangaId = 'manga-123'; + $manga = new Manga( + new MangaId($mangaId), + new MangaTitle('One Piece'), + new MangaSlug('one-piece'), + 'Description', + 'Eiichiro Oda', + 1997, + ['action', 'adventure'], + 'ongoing' + ); + $this->mangaRepository->save($manga); + + $invalidBinary = 'This is not a CBZ file'; + $command = new ImportVolume( + mangaId: $mangaId, + volumeNumber: 1, + fileBinary: $invalidBinary + ); + + // Assert + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The provided file is not a valid CBZ file'); + + // Act + $this->handler->handle($command); + } + + public function test_it_throws_exception_when_no_chapters_in_volume(): void + { + // Arrange + $mangaId = 'manga-123'; + $manga = new Manga( + new MangaId($mangaId), + new MangaTitle('One Piece'), + new MangaSlug('one-piece'), + 'Description', + 'Eiichiro Oda', + 1997, + ['action', 'adventure'], + 'ongoing' + ); + $this->mangaRepository->save($manga); + + $cbzBinary = $this->createValidCbzBinary(); + $command = new ImportVolume( + mangaId: $mangaId, + volumeNumber: 999, // Non-existent volume + fileBinary: $cbzBinary + ); + + // Assert + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No chapters found'); + + // Act + $this->handler->handle($command); + } + + /** + * Create a minimal valid CBZ (ZIP) binary for testing + */ + private function createValidCbzBinary(): string + { + $tmpFile = tempnam(sys_get_temp_dir(), 'cbz_'); + unlink($tmpFile); + + $zip = new \ZipArchive(); + if ($zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException('Cannot create test CBZ file'); + } + + $zip->addFromString('image1.jpg', 'fake-image-data'); + $zip->close(); + + $binaryContent = file_get_contents($tmpFile); + unlink($tmpFile); + + return $binaryContent; + } +} + + + + diff --git a/tests/Feature/Manga/DownloadVolumeTest.php b/tests/Feature/Manga/DownloadVolumeTest.php index c001394..60e6529 100644 --- a/tests/Feature/Manga/DownloadVolumeTest.php +++ b/tests/Feature/Manga/DownloadVolumeTest.php @@ -40,7 +40,7 @@ class DownloadVolumeTest extends AbstractApiTestCase $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); + $this->assertStringContainsString('one-piece_vol1.cbz', $contentDisposition); } public function test_it_returns_404_when_manga_not_found(): void diff --git a/tests/Feature/Manga/ImportChapterTest.php b/tests/Feature/Manga/ImportChapterTest.php new file mode 100644 index 0000000..0f357ca --- /dev/null +++ b/tests/Feature/Manga/ImportChapterTest.php @@ -0,0 +1,131 @@ +createValidCbzFile(); + + $client->request( + 'POST', + self::API_ENDPOINT, + [ + 'mangaId' => 'non-existent-manga-id', + 'chapterNumber' => '1.5' + ], + ['file' => $file] + ); + + $this->assertResponseStatusCodeSame(404); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals('Manga not found', $response['error']); + } + + public function test_it_returns_422_when_manga_id_is_missing(): void + { + $client = static::createClient(); + $file = $this->createValidCbzFile(); + + $client->request( + 'POST', + self::API_ENDPOINT, + [ + 'chapterNumber' => '1.5' + ], + ['file' => $file] + ); + + $this->assertResponseStatusCodeSame(422); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertStringContainsString('mangaId is required', $response[0]['message']); + } + + public function test_it_returns_422_when_chapter_number_is_missing(): void + { + $client = static::createClient(); + $file = $this->createValidCbzFile(); + + $client->request( + 'POST', + self::API_ENDPOINT, + [ + 'mangaId' => 'some-manga-id' + ], + ['file' => $file] + ); + + $this->assertResponseStatusCodeSame(422); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertStringContainsString('chapterNumber is required', $response[0]['message']); + } + + public function test_it_returns_422_when_file_is_missing(): void + { + $client = static::createClient(); + + $client->request( + 'POST', + self::API_ENDPOINT, + [ + 'mangaId' => 'some-manga-id', + 'chapterNumber' => '1.5' + ] + ); + + $this->assertResponseStatusCodeSame(422); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertStringContainsString('Please upload a file', $response[0]['message']); + } + + public function test_it_returns_422_when_file_is_not_cbz(): void + { + $client = static::createClient(); + + // Create a non-CBZ file + $tempFile = tempnam(sys_get_temp_dir(), 'test_'); + file_put_contents($tempFile, 'This is not a CBZ file'); + $file = new UploadedFile($tempFile, 'test.txt', 'text/plain'); + + $client->request( + 'POST', + self::API_ENDPOINT, + [ + 'mangaId' => 'some-manga-id', + 'chapterNumber' => '1.5' + ], + ['file' => $file] + ); + + $this->assertResponseStatusCodeSame(422); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertStringContainsString('valid CBZ file', $response[0]['message']); + } + + private function createValidCbzFile(): UploadedFile + { + $tmpFile = tempnam(sys_get_temp_dir(), 'cbz_'); + unlink($tmpFile); + + $zip = new \ZipArchive(); + if ($zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException('Cannot create test CBZ file'); + } + + $zip->addFromString('image1.jpg', 'fake-image-data'); + $zip->close(); + + return new UploadedFile($tmpFile, 'test-chapter.cbz', 'application/x-cbz'); + } +} + + + + diff --git a/tests/Feature/Manga/ImportVolumeTest.php b/tests/Feature/Manga/ImportVolumeTest.php new file mode 100644 index 0000000..dd34499 --- /dev/null +++ b/tests/Feature/Manga/ImportVolumeTest.php @@ -0,0 +1,131 @@ +createValidCbzFile(); + + $client->request( + 'POST', + self::API_ENDPOINT, + [ + 'mangaId' => 'non-existent-manga-id', + 'volumeNumber' => '1' + ], + ['file' => $file] + ); + + $this->assertResponseStatusCodeSame(404); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertEquals('Manga not found', $response['error']); + } + + public function test_it_returns_422_when_manga_id_is_missing(): void + { + $client = static::createClient(); + $file = $this->createValidCbzFile(); + + $client->request( + 'POST', + self::API_ENDPOINT, + [ + 'volumeNumber' => '1' + ], + ['file' => $file] + ); + + $this->assertResponseStatusCodeSame(422); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertStringContainsString('mangaId is required', $response[0]['message']); + } + + public function test_it_returns_422_when_volume_number_is_missing(): void + { + $client = static::createClient(); + $file = $this->createValidCbzFile(); + + $client->request( + 'POST', + self::API_ENDPOINT, + [ + 'mangaId' => 'some-manga-id' + ], + ['file' => $file] + ); + + $this->assertResponseStatusCodeSame(422); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertStringContainsString('volumeNumber is required', $response[0]['message']); + } + + public function test_it_returns_422_when_file_is_missing(): void + { + $client = static::createClient(); + + $client->request( + 'POST', + self::API_ENDPOINT, + [ + 'mangaId' => 'some-manga-id', + 'volumeNumber' => '1' + ] + ); + + $this->assertResponseStatusCodeSame(422); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertStringContainsString('Please upload a file', $response[0]['message']); + } + + public function test_it_returns_422_when_file_is_not_cbz(): void + { + $client = static::createClient(); + + // Create a non-CBZ file + $tempFile = tempnam(sys_get_temp_dir(), 'test_'); + file_put_contents($tempFile, 'This is not a CBZ file'); + $file = new UploadedFile($tempFile, 'test.txt', 'text/plain'); + + $client->request( + 'POST', + self::API_ENDPOINT, + [ + 'mangaId' => 'some-manga-id', + 'volumeNumber' => '1' + ], + ['file' => $file] + ); + + $this->assertResponseStatusCodeSame(422); + $response = json_decode($client->getResponse()->getContent(), true); + $this->assertStringContainsString('valid CBZ file', $response[0]['message']); + } + + private function createValidCbzFile(): UploadedFile + { + $tmpFile = tempnam(sys_get_temp_dir(), 'cbz_'); + unlink($tmpFile); + + $zip = new \ZipArchive(); + if ($zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { + throw new \RuntimeException('Cannot create test CBZ file'); + } + + $zip->addFromString('image1.jpg', 'fake-image-data'); + $zip->close(); + + return new UploadedFile($tmpFile, 'test-volume.cbz', 'application/x-cbz'); + } +} + + + +