feat: Reader beginning

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-02-16 16:15:42 +01:00
parent e90c0a140e
commit 55945adc53
37 changed files with 1057 additions and 47 deletions

View File

@@ -44,9 +44,10 @@ logs: ## Show live logs
sh: ## Connect to the FrankenPHP container sh: ## Connect to the FrankenPHP container
@$(PHP_CONT) sh @$(PHP_CONT) sh
test: ## Start tests with phpunit, pass the parameter "c=" to add options to phpunit, example: make test c="--group e2e --stop-on-failure" test: ## Start tests with phpunit, pass the parameter "c=" to add options to phpunit, example: make test c="--group e2e --stop-on-failure", or "f=" to specify a test file, example: make test f="ScrapeChapterHandlerTest"
@$(eval c ?=) @$(eval c ?=)
@$(DOCKER_COMP) exec -e APP_ENV=test php bin/phpunit $(c) @$(eval f ?=)
@$(DOCKER_COMP) exec -e APP_ENV=test php bin/phpunit $(c) $(if $(f),--filter=$(f),)
phpmd: ## Start PHP Mess Detector phpmd: ## Start PHP Mess Detector
@if ! $(DOCKER_COMP) exec php vendor/bin/phpmd src/ text phpmd.xml -v; then \ @if ! $(DOCKER_COMP) exec php vendor/bin/phpmd src/ text phpmd.xml -v; then \

View File

@@ -27,5 +27,6 @@ api_platform:
paths: paths:
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto' - '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto'
- '%kernel.project_dir%/src/Domain/Manga/Infrastructure/ApiPlatform/Resource' - '%kernel.project_dir%/src/Domain/Manga/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource'
patch_formats: patch_formats:
json: ['application/merge-patch+json'] json: ['application/merge-patch+json']

View File

@@ -22,4 +22,8 @@ services:
class: App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor class: App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor
public: true public: true
# App\Domain\Reader\Domain\Contract\Repository\ChapterRepositoryInterface:
# class: App\Tests\Domain\Reader\Adapter\InMemoryChapterRepository
# public: true

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Application\Query;
final class GetChapterContext
{
public function __construct(
private readonly string $chapterId
) {
}
public function getChapterId(): string
{
return $this->chapterId;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Application\Query;
final readonly class GetChapterPages
{
public function __construct(
private string $chapterId,
private int $page = 1,
private int $itemsPerPage = 20
) {
}
public function getChapterId(): string
{
return $this->chapterId;
}
public function getPage(): int
{
return $this->page;
}
public function getItemsPerPage(): int
{
return $this->itemsPerPage;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Application\QueryHandler;
use App\Domain\Reader\Application\Query\GetChapterContext;
use App\Domain\Reader\Application\Response\ChapterContextResponse;
use App\Domain\Reader\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Reader\Domain\ValueObject\ChapterId;
final readonly class GetChapterContextHandler
{
public function __construct(
private ChapterRepositoryInterface $chapterRepository
) {
}
public function handle(GetChapterContext $query): ChapterContextResponse
{
$chapterId = new ChapterId($query->getChapterId());
$context = $this->chapterRepository->getChapterContext($chapterId);
return new ChapterContextResponse(
$query->getChapterId(),
$context->getChapterTitle(),
$context->getNumber(),
$context->getTotalPages(),
$context->getPreviousChapterId()?->getValue(),
$context->getNextChapterId()?->getValue(),
);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Application\QueryHandler;
use App\Domain\Reader\Application\Query\GetChapterPages;
use App\Domain\Reader\Application\Response\ChapterPagesResponse;
use App\Domain\Reader\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Reader\Domain\Exception\ChapterNotFoundException;
use App\Domain\Reader\Domain\ValueObject\ChapterId;
final readonly class GetChapterPagesHandler
{
public function __construct(
private ChapterRepositoryInterface $chapterRepository
) {
}
public function handle(GetChapterPages $query): ChapterPagesResponse
{
$chapterId = new ChapterId($query->getChapterId());
$totalItems = $this->chapterRepository->getTotalPagesForChapter($chapterId);
if ($totalItems === 0) {
throw ChapterNotFoundException::forChapter($chapterId);
}
$pages = $this->chapterRepository->getPagesForChapter(
$chapterId,
$query->getPage(),
$query->getItemsPerPage()
);
return new ChapterPagesResponse(
$pages,
$totalItems,
$query->getPage(),
$query->getItemsPerPage()
);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Application\Response;
final readonly class ChapterContextResponse
{
public function __construct(
private string $id,
private string $title,
private float $number,
private int $totalPages,
private ?string $previousChapterId,
private ?string $nextChapterId
)
{
}
public function getId(): string
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
public function getNumber(): float
{
return $this->number;
}
public function getTotalPages(): int
{
return $this->totalPages;
}
public function getPreviousChapterId(): ?string
{
return $this->previousChapterId;
}
public function getNextChapterId(): ?string
{
return $this->nextChapterId;
}
public function getNavigation(): array
{
$navigation['previousChapter'] = $this->previousChapterId;
$navigation['nextChapter'] = $this->nextChapterId;
return $navigation;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Application\Response;
use App\Domain\Reader\Domain\Model\Page;
final class ChapterPagesResponse
{
/** @var array<PageResponse> */
private array $pages;
private int $totalItems;
private int $currentPage;
private int $itemsPerPage;
/**
* @param array<Page> $pages
*/
public function __construct(array $pages, int $totalItems, int $currentPage, int $itemsPerPage)
{
$this->pages = array_map(
static fn (Page $page) => new PageResponse($page),
$pages
);
$this->totalItems = $totalItems;
$this->currentPage = $currentPage;
$this->itemsPerPage = $itemsPerPage;
}
/**
* @return array<PageResponse>
*/
public function getPages(): array
{
return $this->pages;
}
public function getTotalItems(): int
{
return $this->totalItems;
}
public function getCurrentPage(): int
{
return $this->currentPage;
}
public function getItemsPerPage(): int
{
return $this->itemsPerPage;
}
public function getTotalPages(): int
{
return (int) ceil($this->totalItems / $this->itemsPerPage);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Application\Response;
use App\Domain\Reader\Domain\Model\Page;
final class PageResponse
{
private string $id;
private int $pageNumber;
private string $url;
private array $dimensions;
public function __construct(Page $page)
{
$this->id = $page->getId();
$this->pageNumber = $page->getPageNumber()->getValue();
$this->url = $page->getUrl();
$this->dimensions = $page->getDimensions();
}
public function getId(): string
{
return $this->id;
}
public function getPageNumber(): int
{
return $this->pageNumber;
}
public function getUrl(): string
{
return $this->url;
}
public function getDimensions(): array
{
return $this->dimensions;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Domain\Contract\Repository;
use App\Domain\Reader\Domain\Model\ChapterContext;
use App\Domain\Reader\Domain\Model\Page;
use App\Domain\Reader\Domain\ValueObject\ChapterId;
interface ChapterRepositoryInterface
{
/**
* @return array<Page>
*/
public function getPagesForChapter(ChapterId $chapterId, int $page = 1, int $itemsPerPage = 20): array;
public function getChapterContext(ChapterId $chapterId): ChapterContext;
public function getTotalPagesForChapter(ChapterId $chapterId): int;
public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId;
public function getNextChapterId(ChapterId $chapterId): ?ChapterId;
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Domain\Exception;
use App\Domain\Reader\Domain\ValueObject\ChapterId;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class ChapterNotFoundException extends NotFoundHttpException
{
public static function forChapter(ChapterId $chapterId): self
{
return new self(sprintf('Le chapitre %s n\'existe pas', $chapterId->getValue()));
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Domain\Model;
use App\Domain\Reader\Domain\ValueObject\ChapterId;
readonly class ChapterContext
{
public function __construct(
private ChapterId $id,
private ?ChapterId $previousChapterId,
private ?ChapterId $nextChapterId,
private string $mangaTitle,
private float $number,
private ?string $chapterTitle,
private ?string $cbzPath,
private ?int $volume,
private int $totalPages,
private bool $isVisible,
private \DateTimeImmutable $createdAt
) {}
public function getId(): ChapterId
{
return $this->id;
}
public function getPreviousChapterId(): ?ChapterId
{
return $this->previousChapterId;
}
public function getNextChapterId(): ?ChapterId
{
return $this->nextChapterId;
}
public function getMangaTitle(): string
{
return $this->mangaTitle;
}
public function getNumber(): float
{
return $this->number;
}
public function getChapterTitle(): ?string
{
return $this->chapterTitle;
}
public function getCbzPath(): ?string
{
return $this->cbzPath;
}
public function getVolume(): ?int
{
return $this->volume;
}
public function getTotalPages(): int
{
return $this->totalPages;
}
public function isVisible(): bool
{
return $this->isVisible;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Domain\Model;
use App\Domain\Reader\Domain\ValueObject\PageNumber;
class Page
{
public function __construct(
private readonly string $id,
private readonly PageNumber $pageNumber,
private readonly string $url,
private readonly int $width,
private readonly int $height
) {
}
public function getId(): string
{
return $this->id;
}
public function getPageNumber(): PageNumber
{
return $this->pageNumber;
}
public function getUrl(): string
{
return $this->url;
}
public function getWidth(): int
{
return $this->width;
}
public function getHeight(): int
{
return $this->height;
}
public function getDimensions(): array
{
return [
'width' => $this->width,
'height' => $this->height,
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Domain\ValueObject;
readonly class ChapterId
{
public function __construct(
private string $value
) {}
public function getValue(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Domain\ValueObject;
use InvalidArgumentException;
final class PageNumber
{
private int $value;
public function __construct(int $value)
{
if ($value < 1) {
throw new InvalidArgumentException('Le numéro de page doit être supérieur à 0');
}
$this->value = $value;
}
public function getValue(): int
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return (string) $this->value;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Domain\Reader\Infrastructure\ApiPlatform\State\Provider\ChapterContextProvider;
use Symfony\Component\Serializer\Annotation\Groups;
#[ApiResource(
shortName: 'Reader',
operations: [
new Get(
uriTemplate: '/reader/chapter/{chapterId}',
openapiContext: [
'summary' => 'Récupère le contexte d\'un chapitre',
'description' => 'Retourne les métadonnées du chapitre et sa navigation',
'parameters' => [
[
'name' => 'chapterId',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
],
],
],
provider: ChapterContextProvider::class
),
],
)]
class ChapterContextResource
{
public function __construct()
{
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Domain\Reader\Infrastructure\ApiPlatform\State\Provider\ChapterPagesProvider;
use Symfony\Component\Serializer\Annotation\Groups;
#[ApiResource(
shortName: 'Reader',
operations: [
new Get(
uriTemplate: '/reader/chapter/{chapterId}/pages',
openapiContext: [
'summary' => 'Récupère les pages d\'un chapitre',
'description' => 'Retourne une collection paginée des pages du chapitre',
'parameters' => [
[
'name' => 'chapterId',
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
],
[
'name' => 'page',
'in' => 'query',
'required' => false,
'schema' => ['type' => 'integer', 'default' => 1],
],
[
'name' => 'itemsPerPage',
'in' => 'query',
'required' => false,
'schema' => ['type' => 'integer', 'default' => 20],
],
],
],
provider: ChapterPagesProvider::class
),
],
)]
class ChapterPagesResource
{
public function __construct()
{
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Reader\Application\Query\GetChapterContext;
use App\Domain\Reader\Application\QueryHandler\GetChapterContextHandler;
use App\Domain\Reader\Application\Response\ChapterContextResponse;
final readonly class ChapterContextProvider implements ProviderInterface
{
public function __construct(
private GetChapterContextHandler $handler
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ChapterContextResponse
{
$response = $this->handler->handle(
new GetChapterContext(
$uriVariables['chapterId']
)
);
return new ChapterContextResponse(
id: $response->getId(),
title: $response->getTitle(),
number: $response->getNumber(),
totalPages: $response->getTotalPages(),
previousChapterId: $response->getPreviousChapterId(),
nextChapterId: $response->getNextChapterId()
);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Reader\Application\Query\GetChapterPages;
use App\Domain\Reader\Application\QueryHandler\GetChapterPagesHandler;
use App\Domain\Reader\Application\Response\ChapterPagesResponse;
final readonly class ChapterPagesProvider implements ProviderInterface
{
public function __construct(
private GetChapterPagesHandler $handler
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ChapterPagesResponse
{
$page = $context['filters']['page'] ?? 1;
$itemsPerPage = $context['filters']['itemsPerPage'] ?? 20;
return $this->handler->handle(
new GetChapterPages(
$uriVariables['chapterId'],
(int) $page,
(int) $itemsPerPage
)
);
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Domain\Reader\Infrastructure\Persistence;
use App\Domain\Reader\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Reader\Domain\Exception\ChapterNotFoundException;
use App\Domain\Reader\Domain\Model\ChapterContext;
use App\Domain\Reader\Domain\Model\Page;
use App\Domain\Reader\Domain\ValueObject\ChapterId;
use App\Domain\Reader\Domain\ValueObject\PageNumber;
use App\Entity\Chapter as ChapterEntity;
use Doctrine\ORM\EntityManagerInterface;
use ZipArchive;
readonly class LegacyChapterRepository implements ChapterRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager
) {}
public function getPagesForChapter(ChapterId $chapterId, int $page = 1, int $itemsPerPage = 20): array
{
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
'id' => $chapterId->getValue()
]);
$cbzPath = $chapter->getCbzPath();
if (!$cbzPath) {
return [];
}
$zip = new ZipArchive();
$zip->open($cbzPath);
$pages = [];
$start = ($page - 1) * $itemsPerPage;
$end = min($start + $itemsPerPage, $zip->numFiles);
for ($i = $start; $i < $end; $i++) {
$stat = $zip->statIndex($i);
if ($stat === false) {
continue;
}
$imageContent = $zip->getFromIndex($i);
if ($imageContent === false) {
continue;
}
$imageSize = @getimagesizefromstring($imageContent);
if ($imageSize === false) {
continue;
}
$pages[] = new Page(
$stat['name'],
new PageNumber($i + 1),
sprintf('/api/chapters/%s/pages/%d', $chapterId->getValue(), $i + 1),
$imageSize[0],
$imageSize[1]
);
}
$zip->close();
return $pages;
}
public function getChapterContext(ChapterId $chapterId): ChapterContext
{
/** @var ChapterEntity $chapter */
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
'id' => $chapterId->getValue()
]);
if (!$chapter) {
throw ChapterNotFoundException::forChapter($chapterId);
}
return new ChapterContext(
id: $chapterId,
previousChapterId: $this->getPreviousChapterId($chapterId),
nextChapterId: $this->getNextChapterId($chapterId),
mangaTitle: $chapter->getManga()->getTitle(),
number: $chapter->getNumber(),
chapterTitle: $chapter->getTitle(),
cbzPath: $chapter->getCbzPath(),
volume: $chapter->getVolume(),
totalPages: 0,
isVisible: $chapter->isVisible(),
createdAt: new \DateTimeImmutable()
);
}
public function getTotalPagesForChapter(ChapterId $chapterId): int
{
$chapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
'id' => $chapterId->getValue()
]);
$cbzPath = $chapter->getCbzPath();
if (!$cbzPath) {
return 0;
}
$zip = new ZipArchive();
$zip->open($cbzPath);
return $zip->numFiles;
}
public function getPreviousChapterId(ChapterId $chapterId): ?ChapterId
{
$currentChapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
'id' => $chapterId->getValue()
]);
$qb = $this->entityManager->createQueryBuilder();
$qb->select('c')
->from(ChapterEntity::class, 'c')
->where('c.manga = :manga')
->andWhere('c.number < :number')
->orderBy('c.number', 'DESC')
->setMaxResults(1)
->setParameters([
'manga' => $currentChapter->getManga(),
'number' => $currentChapter->getNumber()
]);
$previousChapter = $qb->getQuery()->getOneOrNullResult();
return $previousChapter ? new ChapterId((string) $previousChapter->getId()) : null;
}
public function getNextChapterId(ChapterId $chapterId): ?ChapterId
{
$currentChapter = $this->entityManager->getRepository(ChapterEntity::class)->findOneBy([
'id' => $chapterId->getValue()
]);
$qb = $this->entityManager->createQueryBuilder();
$qb->select('c')
->from(ChapterEntity::class, 'c')
->where('c.manga = :manga')
->andWhere('c.number > :number')
->orderBy('c.number', 'ASC')
->setMaxResults(1)
->setParameters([
'manga' => $currentChapter->getManga(),
'number' => $currentChapter->getNumber()
]);
$nextChapter = $qb->getQuery()->getOneOrNullResult();
return $nextChapter ? new ChapterId((string) $nextChapter->getId()) : null;
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domain\Scraping\Application\CommandHandler; namespace App\Domain\Scraping\Application\CommandHandler;
use App\Domain\Scraping\Application\Command\ScrapeChapter; use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface; use App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Service\ScraperInterface; use App\Domain\Scraping\Domain\Contract\Service\ScraperInterface;
use App\Domain\Scraping\Domain\Event\ChapterScraped; use App\Domain\Scraping\Domain\Event\ChapterScraped;
@@ -18,6 +19,7 @@ readonly class ScrapeChapterHandler
public function __construct( public function __construct(
private ScraperInterface $scraper, private ScraperInterface $scraper,
private ScrapingJobRepositoryInterface $scrapingJobRepository, private ScrapingJobRepositoryInterface $scrapingJobRepository,
private ChapterRepositoryInterface $chapterRepository,
private MessageBusInterface $eventBus private MessageBusInterface $eventBus
) { ) {
} }
@@ -42,6 +44,9 @@ readonly class ScrapeChapterHandler
$this->eventBus->dispatch(new ChapterScrapingFailed($command->mangaId, $command->chapterNumber, $job->failureReason)); $this->eventBus->dispatch(new ChapterScrapingFailed($command->mangaId, $command->chapterNumber, $job->failureReason));
}elseif ($job->status === ScrapingStatus::COMPLETED) { }elseif ($job->status === ScrapingStatus::COMPLETED) {
$this->eventBus->dispatch(new ChapterScraped($job->getId())); $this->eventBus->dispatch(new ChapterScraped($job->getId()));
$chapter = $this->chapterRepository->getByMangaIdAndChapterNumber($command->mangaId, $command->chapterNumber);
$chapter->cbzPath = $job->cbzPath->getPath();
$this->chapterRepository->save($chapter);
} }
$this->scrapingJobRepository->save($job); $this->scrapingJobRepository->save($job);

View File

@@ -7,4 +7,5 @@ use App\Domain\Scraping\Domain\Model\Chapter;
interface ChapterRepositoryInterface interface ChapterRepositoryInterface
{ {
public function getByMangaIdAndChapterNumber(string $mangaId, int $chapterNumber): Chapter; public function getByMangaIdAndChapterNumber(string $mangaId, int $chapterNumber): Chapter;
} public function save(Chapter $chapter): void;
}

View File

@@ -9,5 +9,6 @@ class Chapter
public readonly string $mangaId, public readonly string $mangaId,
public readonly int $chapterNumber, public readonly int $chapterNumber,
public readonly int $volumeNumber, public readonly int $volumeNumber,
public ?string $cbzPath = null,
) {} ) {}
} }

View File

@@ -2,17 +2,16 @@
namespace App\Domain\Scraping\Domain\Model\ValueObject; namespace App\Domain\Scraping\Domain\Model\ValueObject;
class CbzPath readonly class CbzPath
{ {
public function __construct(private readonly string $path) public function __construct(private string $path)
{ {
if (empty($path)) { if (empty($path)) {
throw new \InvalidArgumentException('Le chemin du fichier CBZ ne peut pas être vide'); throw new \InvalidArgumentException('Le chemin du fichier CBZ ne peut pas être vide');
} }
} }
public function getPath(): string public function getPath(): string
{ {
return $this->path; return $this->path;
} }
} }

View File

@@ -21,15 +21,15 @@ use Symfony\Component\Validator\Constraints as Assert;
readonly class ScrapeChapterRequest readonly class ScrapeChapterRequest
{ {
public function __construct( public function __construct(
#[ApiProperty(description: 'ID du chapitre à scraper')]
#[Assert\NotBlank]
public string $chapterId,
#[ApiProperty(description: 'ID de la source à utiliser')]
#[Assert\NotBlank]
public string $sourceId,
#[ApiProperty(description: 'ID du manga')] #[ApiProperty(description: 'ID du manga')]
#[Assert\NotBlank] #[Assert\NotBlank]
public string $mangaId, public string $mangaId,
#[ApiProperty(description: 'Numéro du chapitre')]
#[Assert\NotBlank]
public string $chapterNumber,
#[ApiProperty(description: 'ID de la source')]
#[Assert\NotBlank]
public string $sourceId,
) { ) {
} }
} }

View File

@@ -22,9 +22,9 @@ final class ScrapeChapterStateProcessor implements ProcessorInterface
{ {
$this->commandBus->dispatch( $this->commandBus->dispatch(
new ScrapeChapter( new ScrapeChapter(
$data->chapterId, $data->mangaId,
$data->sourceId, $data->chapterNumber,
$data->mangaId $data->sourceId
) )
); );
} }

View File

@@ -7,12 +7,15 @@ use App\Domain\Scraping\Domain\Exception\ChapterNotFoundException;
use App\Domain\Scraping\Domain\Model\Chapter; use App\Domain\Scraping\Domain\Model\Chapter;
use App\Entity\Chapter as EntityChapter; use App\Entity\Chapter as EntityChapter;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
class LegacyChapterRepository implements ChapterRepositoryInterface readonly class LegacyChapterRepository implements ChapterRepositoryInterface
{ {
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
) {} ) {}
/**
* @throws ChapterNotFoundException
*/
public function getByMangaIdAndChapterNumber(string $mangaId, int $chapterNumber): Chapter public function getByMangaIdAndChapterNumber(string $mangaId, int $chapterNumber): Chapter
{ {
$chapterEntity = $this->entityManager->getRepository(EntityChapter::class)->findOneBy([ $chapterEntity = $this->entityManager->getRepository(EntityChapter::class)->findOneBy([
@@ -31,4 +34,23 @@ class LegacyChapterRepository implements ChapterRepositoryInterface
volumeNumber: $chapterEntity->getVolume(), volumeNumber: $chapterEntity->getVolume(),
); );
} }
/**
* @throws ChapterNotFoundException
*/
public function save(Chapter $chapter): void
{
$chapterEntity = $this->entityManager->getRepository(EntityChapter::class)->findOneBy([
'id' => $chapter->id,
]);
if (!$chapterEntity) {
throw new ChapterNotFoundException();
}
$chapterEntity->setCbzPath($chapter->cbzPath);
$this->entityManager->persist($chapterEntity);
$this->entityManager->flush();
}
} }

View File

@@ -48,8 +48,13 @@ final class ChapterFactory extends ModelFactory
{ {
return [ return [
'manga' => MangaFactory::new(), 'manga' => MangaFactory::new(),
'number' => self::faker()->randomNumber(2), 'number' => self::faker()->randomFloat(2, 0, 999),
'pages' => [], 'volume' => self::faker()->optional()->numberBetween(1, 100),
'title' => self::faker()->optional()->sentence(3),
'localPath' => self::faker()->optional()->filePath(),
'externalId' => self::faker()->optional()->uuid(),
'cbzPath' => self::faker()->optional()->filePath(),
'visible' => true,
]; ];
} }

View File

@@ -50,12 +50,20 @@ final class MangaFactory extends ModelFactory
$title = self::faker()->words(rand(1, 3), true); $title = self::faker()->words(rand(1, 3), true);
return [ return [
'slug' => $this->slugger->slug($title)->lower(),
'title' => $title, 'title' => $title,
'description' => self::faker()->text(), 'slug' => $this->slugger->slug($title)->lower(),
'genres' => self::faker()->words(rand(1, 5)), 'imageUrl' => self::faker()->optional()->imageUrl(),
'publicationYear' => self::faker()->year(), 'publicationYear' => self::faker()->optional()->year(),
'rating' => self::faker()->randomFloat(1, 0, 10), 'description' => self::faker()->optional()->text(),
'genres' => self::faker()->optional()->words(rand(1, 5)),
'createdAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
'rating' => self::faker()->optional()->randomFloat(1, 0, 10),
'author' => self::faker()->optional()->name(),
'externalId' => self::faker()->optional()->uuid(),
'status' => self::faker()->optional()->randomElement(['ongoing', 'completed', 'hiatus']),
'thumbnailUrl' => self::faker()->optional()->imageUrl(150, 150),
'monitored' => self::faker()->boolean(),
'AlternativeSlugs' => self::faker()->optional()->words(3),
]; ];
} }

View File

@@ -47,9 +47,12 @@ final class SourceFactory extends ModelFactory
protected function getDefaults(): array protected function getDefaults(): array
{ {
return [ return [
'baseUrl' => self::faker()->text(255), 'name' => self::faker()->optional()->company(),
'createdAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), 'description' => self::faker()->optional()->text(),
'baseUrl' => self::faker()->url(),
'scrappingParameters' => [],
'isActive' => self::faker()->boolean(), 'isActive' => self::faker()->boolean(),
'createdAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
'updatedAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()), 'updatedAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
]; ];
} }

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Tests\Domain\Scraping\Adapter;
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Scraping\Domain\Model\Chapter;
class InMemoryChapterRepository implements ChapterRepositoryInterface
{
private array $chapters = [];
public function getByMangaIdAndChapterNumber(string $mangaId, int $chapterNumber): Chapter
{
foreach ($this->chapters as $chapter) {
if ($chapter->mangaId === $mangaId && $chapter->chapterNumber === $chapterNumber) {
return $chapter;
}
}
throw new \RuntimeException('Chapter not found');
}
public function save(Chapter $chapter): void
{
$this->chapters[$chapter->id] = $chapter;
}
public function clear(): void
{
$this->chapters = [];
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Tests\Domain\Scraping\Adapter;
use App\Domain\Scraping\Domain\Contract\Service\ScraperInterface; use App\Domain\Scraping\Domain\Contract\Service\ScraperInterface;
use App\Domain\Scraping\Domain\Model\ScrapingJob; use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Domain\Model\ValueObject\CbzPath;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
class InMemoryScraperAdapter implements ScraperInterface class InMemoryScraperAdapter implements ScraperInterface
@@ -18,6 +19,7 @@ class InMemoryScraperAdapter implements ScraperInterface
} }
$job->complete(); $job->complete();
$job->cbzPath = new CbzPath('/path/to/test.cbz');
return $job; return $job;
} }

View File

@@ -28,7 +28,7 @@ class InMemoryScrapingJobRepository implements ScrapingJobRepositoryInterface
public function findByChapterId(string $chapterId): ?ScrapingJob public function findByChapterId(string $chapterId): ?ScrapingJob
{ {
foreach (self::$jobs as $job) { foreach (self::$jobs as $job) {
if ($job->getChapterId() === $chapterId) { if ($job->getId() === $chapterId) {
return $job; return $job;
} }
} }

View File

@@ -7,7 +7,9 @@ use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
use App\Domain\Scraping\Domain\Event\ChapterScraped; use App\Domain\Scraping\Domain\Event\ChapterScraped;
use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed; use App\Domain\Scraping\Domain\Event\ChapterScrapingFailed;
use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted; use App\Domain\Scraping\Domain\Event\ChapterScrapingStarted;
use App\Domain\Scraping\Domain\Model\Chapter;
use App\Domain\Scraping\Domain\Model\ScrapingStatus; use App\Domain\Scraping\Domain\Model\ScrapingStatus;
use App\Tests\Domain\Scraping\Adapter\InMemoryChapterRepository;
use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus; use App\Tests\Domain\Scraping\Adapter\InMemoryEventBus;
use App\Tests\Domain\Scraping\Adapter\InMemoryScraperAdapter; use App\Tests\Domain\Scraping\Adapter\InMemoryScraperAdapter;
use App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository; use App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository;
@@ -16,18 +18,30 @@ use PHPUnit\Framework\TestCase;
class ScrapeChapterHandlerTest extends TestCase class ScrapeChapterHandlerTest extends TestCase
{ {
private InMemoryScraperAdapter $scraper; private InMemoryScraperAdapter $scraper;
private InMemoryScrapingJobRepository $repository; private InMemoryScrapingJobRepository $scrapingJobRepository;
private InMemoryChapterRepository $chapterRepository;
private InMemoryEventBus $eventBus; private InMemoryEventBus $eventBus;
private ScrapeChapterHandler $handler; private ScrapeChapterHandler $handler;
protected function setUp(): void protected function setUp(): void
{ {
$this->scraper = new InMemoryScraperAdapter(); $this->scraper = new InMemoryScraperAdapter();
$this->repository = new InMemoryScrapingJobRepository(); $this->scrapingJobRepository = new InMemoryScrapingJobRepository();
$this->chapterRepository = new InMemoryChapterRepository();
$this->chapterRepository->save(new Chapter(
id: '1',
mangaId: '1',
chapterNumber: '2',
volumeNumber: 1,
cbzPath: null,
));
$this->eventBus = new InMemoryEventBus(); $this->eventBus = new InMemoryEventBus();
$this->handler = new ScrapeChapterHandler( $this->handler = new ScrapeChapterHandler(
$this->scraper, $this->scraper,
$this->repository, $this->scrapingJobRepository,
$this->chapterRepository,
$this->eventBus $this->eventBus
); );
} }
@@ -35,16 +49,14 @@ class ScrapeChapterHandlerTest extends TestCase
public function testHandleSuccessfully(): void public function testHandleSuccessfully(): void
{ {
$command = new ScrapeChapter( $command = new ScrapeChapter(
mangaId: 1, mangaId: '1',
chapterNumber: 2, chapterNumber: '2',
sourceId: 3, sourceId: '3',
); );
$this->handler->handle($command); $this->handler->handle($command);
$scrapingJobs = $this->repository->getJobs(); $scrapingJobs = $this->scrapingJobRepository->getJobs();
$this->assertCount(1, $scrapingJobs); $this->assertCount(1, $scrapingJobs);
$job = $scrapingJobs[0]; $job = $scrapingJobs[0];
@@ -54,15 +66,16 @@ class ScrapeChapterHandlerTest extends TestCase
$this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[1]); $this->assertInstanceOf(ChapterScraped::class, $dispatchedMessages[1]);
$this->assertEquals($job->getId(), $dispatchedMessages[0]->getJobId()); $this->assertEquals($job->getId(), $dispatchedMessages[0]->getJobId());
$this->repository->clear(); $chapter = $this->chapterRepository->getByMangaIdAndChapterNumber('1', '2');
$this->assertNotNull($chapter->cbzPath);
} }
public function testHandleThrowsException(): void public function testHandleThrowsException(): void
{ {
$command = new ScrapeChapter( $command = new ScrapeChapter(
mangaId: 1, mangaId: '1',
chapterNumber: 2, chapterNumber: '2',
sourceId: 3, sourceId: '3',
); );
$exception = new \Exception('Scraping failed'); $exception = new \Exception('Scraping failed');
@@ -71,15 +84,24 @@ class ScrapeChapterHandlerTest extends TestCase
$this->handler->handle($command); $this->handler->handle($command);
$dispatchedMessages = $this->eventBus->getDispatchedMessages(); $dispatchedMessages = $this->eventBus->getDispatchedMessages();
$this->assertCount(2, $dispatchedMessages); $this->assertCount(2, $dispatchedMessages);
$this->assertInstanceOf(ChapterScrapingStarted::class, $dispatchedMessages[0]); $this->assertInstanceOf(ChapterScrapingStarted::class, $dispatchedMessages[0]);
$this->assertInstanceOf(ChapterScrapingFailed::class, $dispatchedMessages[1]); $this->assertInstanceOf(ChapterScrapingFailed::class, $dispatchedMessages[1]);
$this->assertEquals(2, $dispatchedMessages[1]->getChapterNumber()); $this->assertEquals('1', $dispatchedMessages[1]->getMangaId());
$this->assertEquals('2', $dispatchedMessages[1]->getChapterNumber());
$this->assertEquals('Scraping failed', $dispatchedMessages[1]->getReason()); $this->assertEquals('Scraping failed', $dispatchedMessages[1]->getReason());
$jobs = $this->repository->getJobs(); $jobs = $this->scrapingJobRepository->getJobs();
$this->assertCount(1, $jobs); $this->assertCount(1, $jobs);
$this->assertEquals(ScrapingStatus::FAILED, $jobs[0]->status); $this->assertEquals(ScrapingStatus::FAILED, $jobs[0]->status);
$this->assertEquals('Scraping failed', $jobs[0]->failureReason); $this->assertEquals('Scraping failed', $jobs[0]->failureReason);
} }
public function tearDown(): void
{
$this->scrapingJobRepository->clear();
$this->chapterRepository->clear();
}
} }

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Tests\Feature\Reader;
use App\Factory\ChapterFactory;
use App\Factory\MangaFactory;
use App\Tests\Feature\AbstractApiTestCase;
use Zenstruck\Foundry\Test\ResetDatabase;
final class GetChapterContextTest extends AbstractApiTestCase
{
use ResetDatabase;
public function testItReturnsChapterContext(): void
{
// Arrange
$manga = MangaFactory::createOne([
'slug' => 'manga-1',
]);
$chapter1 = ChapterFactory::createOne([
'manga' => $manga,
'title' => 'Chapter 1',
'number' => 1,
]);
$chapter2 = ChapterFactory::createOne([
'manga' => $manga,
'title' => 'Chapter 2',
'number' => 2,
]);
$chapter3 = ChapterFactory::createOne([
'manga' => $manga,
'title' => 'Chapter 3',
'number' => 3,
]);
// Act
static::createClient()->request('GET', '/api/reader/chapter/' . $chapter1->getId());
// Assert
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains([
'id' => (string)$chapter1->getId(),
'title' => 'Chapter 1',
'number' => 1,
'totalPages' => 0,
'navigation' => [
'hydra:member' => [
null, (string)$chapter2->getId(),
],
]
]);
}
public function testItReturns404ForNonExistentChapter(): void
{
// Act
static::createClient()->request('GET', '/api/reader/chapter/0');
// Assert
$this->assertResponseStatusCodeSame(404);
$this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8');
$this->assertJsonContains([
'hydra:title' => 'An error occurred',
'hydra:description' => 'Le chapitre 0 n\'existe pas',
]);
}
}

View File

@@ -23,7 +23,7 @@ class ScrapeChapterTest extends AbstractApiTestCase
{ {
// Given // Given
$payload = [ $payload = [
'chapterId' => 'chapter-123', 'chapterNumber' => 'chapter-123',
'sourceId' => 'source-456', 'sourceId' => 'source-456',
'mangaId' => 'manga-789', 'mangaId' => 'manga-789',
]; ];
@@ -49,7 +49,7 @@ class ScrapeChapterTest extends AbstractApiTestCase
{ {
// Given // Given
$payload = [ $payload = [
'chapterId' => '', 'chapterNumber' => '',
'sourceId' => 'source-456', 'sourceId' => 'source-456',
'mangaId' => 'manga-789', 'mangaId' => 'manga-789',
]; ];
@@ -65,7 +65,7 @@ class ScrapeChapterTest extends AbstractApiTestCase
$this->assertJsonContains([ $this->assertJsonContains([
'violations' => [ 'violations' => [
[ [
'propertyPath' => 'chapterId', 'propertyPath' => 'chapterNumber',
'message' => 'This value should not be blank.', 'message' => 'This value should not be blank.',
], ],
], ],