feat: commit before changing gitea

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2026-02-08 17:58:01 +01:00
parent b05bd98f63
commit ffceda606f
22 changed files with 1653 additions and 22 deletions

25
.vscode/settings.json vendored
View File

@@ -1,4 +1,25 @@
{
"symfony-vscode.shellExecutable": "/bin/bash",
"symfony-vscode.shellCommand": "docker exec mangarr-php-1 /bin/sh -c 'cd / && php \"$@\"' -- "
}
"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"
}

View File

@@ -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

View File

@@ -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<Object>} - 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<Object>} - 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<Object>} - 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();

View File

@@ -110,3 +110,7 @@ const props = defineProps({
const emit = defineEmits(['select-match']);
</script>

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domain\Manga\Application\Command;
readonly class ImportChapter
{
public function __construct(
public string $mangaId,
public float $chapterNumber,
public string $fileBinary
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domain\Manga\Application\Command;
readonly class ImportVolume
{
public function __construct(
public string $mangaId,
public int $volumeNumber,
public string $fileBinary
) {}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ImportChapter;
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
use Ramsey\Uuid\Uuid;
readonly class ImportChapterHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private ChapterRepositoryInterface $chapterRepository,
private MangaPathManagerInterface $pathManager
) {}
public function handle(ImportChapter $command): void
{
// 1. Validate that the manga exists
$manga = $this->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;
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ImportVolume;
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
readonly class ImportVolumeHandler
{
public function __construct(
private MangaRepositoryInterface $mangaRepository,
private ChapterRepositoryInterface $chapterRepository,
private MangaPathManagerInterface $pathManager
) {}
public function handle(ImportVolume $command): void
{
// 1. Validate that the manga exists
$manga = $this->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;
}
}

View File

@@ -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
);

View File

@@ -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;

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Controller;
use App\Domain\Manga\Application\Command\ImportChapter;
use App\Domain\Manga\Application\CommandHandler\ImportChapterHandler;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\ImportChapterResource;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
#[AsController]
final class ImportChapterController extends AbstractController
{
public function __construct(
private readonly ImportChapterHandler $commandHandler
) {}
public function __invoke(Request $request): Response
{
// Get form parameters
$mangaId = $request->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;
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Controller;
use App\Domain\Manga\Application\Command\ImportVolume;
use App\Domain\Manga\Application\CommandHandler\ImportVolumeHandler;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\ImportVolumeResource;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
#[AsController]
final class ImportVolumeController extends AbstractController
{
public function __construct(
private readonly ImportVolumeHandler $commandHandler
) {}
public function __invoke(Request $request): Response
{
// Get form parameters
$mangaId = $request->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;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Manga\Infrastructure\ApiPlatform\Controller\ImportChapterController;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'ImportChapter',
operations: [
new Post(
uriTemplate: '/chapters/import',
controller: ImportChapterController::class,
deserialize: false,
openapiContext: [
'summary' => '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;
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Manga\Infrastructure\ApiPlatform\Controller\ImportVolumeController;
use Symfony\Component\HttpFoundation\File\File;
#[ApiResource(
shortName: 'ImportVolume',
operations: [
new Post(
uriTemplate: '/volumes/import',
controller: ImportVolumeController::class,
deserialize: false,
openapiContext: [
'summary' => '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;
}

View File

@@ -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());

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Tests\Domain\Manga\Adapter;
use App\Domain\Manga\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Manga\Domain\Model\Chapter;
class InMemoryChapterRepository implements ChapterRepositoryInterface
{
/** @var array<string, Chapter> */
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 = [];
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Tests\Domain\Manga\Adapter;
use App\Domain\Shared\Domain\Contract\MangaPathManagerInterface;
class InMemoryPathManager implements MangaPathManagerInterface
{
/** @var array<string, string> */
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);
}
}
}

View File

@@ -0,0 +1,197 @@
<?php
namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ImportChapter;
use App\Domain\Manga\Application\CommandHandler\ImportChapterHandler;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Exception\ChapterNotFoundException;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryChapterRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager;
use PHPUnit\Framework\TestCase;
class ImportChapterHandlerTest extends TestCase
{
private InMemoryMangaRepository $mangaRepository;
private InMemoryChapterRepository $chapterRepository;
private InMemoryPathManager $pathManager;
private ImportChapterHandler $handler;
protected function setUp(): void
{
$this->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;
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\ImportVolume;
use App\Domain\Manga\Application\CommandHandler\ImportVolumeHandler;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaId;
use App\Domain\Manga\Domain\Model\ValueObject\MangaSlug;
use App\Domain\Manga\Domain\Model\ValueObject\MangaTitle;
use App\Tests\Domain\Manga\Adapter\InMemoryChapterRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryPathManager;
use PHPUnit\Framework\TestCase;
class ImportVolumeHandlerTest extends TestCase
{
private InMemoryMangaRepository $mangaRepository;
private InMemoryChapterRepository $chapterRepository;
private InMemoryPathManager $pathManager;
private ImportVolumeHandler $handler;
protected function setUp(): void
{
$this->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;
}
}

View File

@@ -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

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Tests\Feature\Manga;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImportChapterTest extends WebTestCase
{
private const API_ENDPOINT = '/api/chapters/import';
public function test_it_returns_404_when_manga_not_found(): void
{
$client = static::createClient();
$file = $this->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');
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Tests\Feature\Manga;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImportVolumeTest extends WebTestCase
{
private const API_ENDPOINT = '/api/volumes/import';
public function test_it_returns_404_when_manga_not_found(): void
{
$client = static::createClient();
$file = $this->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');
}
}