feat: endpoint FetchMangaChapters et tests

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-02-11 18:00:49 +01:00
parent 3dc0a0b406
commit 879b8fa2dc
20 changed files with 424 additions and 45 deletions

View File

@@ -25,7 +25,7 @@ framework:
routing: routing:
# Commands # Commands
'App\Domain\Scraping\Application\Command\ScrapeChapter': commands 'App\Domain\Scraping\Application\Command\ScrapeChapter': commands
'App\Domain\Manga\Application\Command\FetchMangaChapters': commands
# Events # Events
'App\Domain\Scraping\Domain\Event\ChapterScrapingStarted': events 'App\Domain\Scraping\Domain\Event\ChapterScrapingStarted': events
'App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted': events 'App\Domain\Scraping\Domain\Event\ChapterScrapingCompleted': events

View File

@@ -93,7 +93,11 @@ services:
arguments: arguments:
$scraperFactory: '@App\Service\Scraper\ScraperFactory' $scraperFactory: '@App\Service\Scraper\ScraperFactory'
App\Domain\Scraping\Infrastructure\Handler\SymfonyScrapeChapterHandler: App\Domain\Scraping\Infrastructure\CommandHandler\SymfonyScrapeChapterHandler:
tags:
- { name: messenger.message_handler, bus: command.bus }
App\Domain\Manga\Infrastructure\CommandHandler\SymfonyFetchMangaChaptersHandler:
tags: tags:
- { name: messenger.message_handler, bus: command.bus } - { name: messenger.message_handler, bus: command.bus }

View File

@@ -5,17 +5,13 @@ services:
public: true public: true
Symfony\Component\Messenger\MessageBusInterface: Symfony\Component\Messenger\MessageBusInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryMessageBus' class: 'App\Tests\Shared\Adapter\InMemoryMessageBus'
public: true public: true
App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface: App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository' class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
public: true public: true
App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryImageDownloader'
public: true
App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface: App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryCbzGenerator' class: 'App\Tests\Domain\Scraping\Adapter\InMemoryCbzGenerator'
arguments: arguments:

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Domain\Manga\Application\Command;
readonly class FetchMangaChapters
{
public function __construct(
public string $externalId
) {}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Domain\Contract\Client\MangadexClientInterface;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
use Ramsey\Uuid\Uuid;
readonly class FetchMangaChaptersHandler
{
public function __construct(
private MangadexClientInterface $mangadexClient,
private MangaRepositoryInterface $mangaRepository
) {}
public function handle(FetchMangaChapters $command): void
{
$manga = $this->mangaRepository->findByExternalId(new ExternalId($command->externalId));
if ($manga === null) {
throw new \RuntimeException('Manga not found');
}
$offset = 0;
$limit = 500;
$hasMore = true;
while ($hasMore) {
$feed = $this->mangadexClient->getMangaFeed(
$command->externalId,
$offset,
$limit
);
foreach ($feed['data'] as $chapterData) {
$chapter = new Chapter(
new ChapterId((string) Uuid::uuid4()),
$manga->getId()->getValue(),
(float) $chapterData['attributes']['chapter'],
$chapterData['attributes']['title'],
isset($chapterData['attributes']['volume']) ? (int) $chapterData['attributes']['volume'] : null,
true,
new \DateTimeImmutable()
);
$this->mangaRepository->saveChapter($chapter);
}
$offset += $limit;
$hasMore = count($feed['data']) === $limit;
}
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Domain\Manga\Domain\Contract\Repository; namespace App\Domain\Manga\Domain\Contract\Repository;
use App\Domain\Manga\Domain\Model\Manga; use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
interface MangaRepositoryInterface interface MangaRepositoryInterface
{ {
@@ -13,4 +15,6 @@ interface MangaRepositoryInterface
public function delete(Manga $manga): void; public function delete(Manga $manga): void;
public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array; public function findChapters(string $mangaId, int $page = 1, int $limit = 20, string $sortOrder = 'desc'): array;
public function countChapters(string $mangaId): int; public function countChapters(string $mangaId): int;
public function findByExternalId(ExternalId $externalId): ?Manga;
public function saveChapter(Chapter $chapter): void;
} }

View File

@@ -21,6 +21,11 @@ readonly class Chapter
return $this->id->getValue(); return $this->id->getValue();
} }
public function getMangaId(): string
{
return $this->mangaId;
}
public function getNumber(): float public function getNumber(): float
{ {
return $this->number; return $this->number;

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\FetchMangaChaptersProcessor;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'FetchMangaChapters',
operations: [
new Post(
uriTemplate: '/manga/chapters/fetch',
processor: FetchMangaChaptersProcessor::class,
status: 202
)
]
)]
class FetchMangaChaptersResource
{
public function __construct(
#[Assert\NotBlank]
public string $externalId
) {}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Infrastructure\ApiPlatform\Resource\FetchMangaChaptersResource;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class FetchMangaChaptersProcessor implements ProcessorInterface
{
public function __construct(
private MessageBusInterface $messageBus
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
if (!$data instanceof FetchMangaChaptersResource) {
throw new \InvalidArgumentException('Invalid resource type');
}
$this->messageBus->dispatch(
new FetchMangaChapters($data->externalId)
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domain\Manga\Infrastructure\CommandHandler;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Application\CommandHandler\FetchMangaChaptersHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class SymfonyFetchMangaChaptersHandler
{
public function __construct(
private FetchMangaChaptersHandler $handler
) {}
public function __invoke(FetchMangaChapters $command): void
{
$this->handler->handle($command);
}
}

View File

@@ -126,6 +126,34 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
->getSingleScalarResult(); ->getSingleScalarResult();
} }
public function findByExternalId(ExternalId $externalId): ?DomainManga
{
$entity = $this->entityManager->getRepository(EntityManga::class)->findOneBy([
'externalId' => $externalId->getValue()
]);
return $entity ? $this->toDomain($entity) : null;
}
public function saveChapter(Chapter $chapter): void
{
$manga = $this->entityManager->find(EntityManga::class, $chapter->getMangaId());
if (!$manga) {
throw new \RuntimeException('Manga not found');
}
$entity = new EntityChapter();
$entity->setManga($manga)
->setNumber($chapter->getNumber())
->setTitle($chapter->getTitle())
->setVolume($chapter->getVolume())
->setVisible($chapter->isVisible());
$this->entityManager->persist($entity);
$this->entityManager->flush();
}
private function toDomain(EntityManga $entity): DomainManga private function toDomain(EntityManga $entity): DomainManga
{ {
return new DomainManga( return new DomainManga(

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Domain\Scraping\Infrastructure\Handler; namespace App\Domain\Scraping\Infrastructure\CommandHandler;
use App\Domain\Scraping\Application\Command\ScrapeChapter; use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler; use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;

View File

@@ -6,18 +6,22 @@ use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Chapter; use App\Domain\Manga\Domain\Model\Chapter;
use App\Domain\Manga\Domain\Model\Manga; use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ChapterId; use App\Domain\Manga\Domain\Model\ValueObject\ChapterId;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
class InMemoryMangaRepository implements MangaRepositoryInterface class InMemoryMangaRepository implements MangaRepositoryInterface
{ {
/** @var Manga[] */ /** @var array<string, Manga> */
private array $mangas = []; private array $mangas = [];
/** @var array<string, Chapter[]> */ /** @var array<string, array<Chapter>> */
private array $chapters = []; private array $chapters = [];
/** @var array<Chapter> */
private array $savedChapters = [];
public function findAll(int $page = 1, int $limit = 20, string $sortBy = 'title', string $sortOrder = 'asc'): array public function findAll(int $page = 1, int $limit = 20, string $sortBy = 'title', string $sortOrder = 'asc'): array
{ {
$sortedMangas = $this->mangas; $sortedMangas = array_values($this->mangas);
usort($sortedMangas, function (Manga $a, Manga $b) use ($sortBy, $sortOrder) { usort($sortedMangas, function (Manga $a, Manga $b) use ($sortBy, $sortOrder) {
$valueA = $this->getPropertyValue($a, $sortBy); $valueA = $this->getPropertyValue($a, $sortBy);
@@ -40,26 +44,17 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
public function findById(string $id): ?Manga public function findById(string $id): ?Manga
{ {
foreach ($this->mangas as $manga) { return $this->mangas[$id] ?? null;
if ($manga->getId()->getValue() === $id) {
return $manga;
}
}
return null;
} }
public function save(Manga $manga): void public function save(Manga $manga): void
{ {
$this->mangas[] = $manga; $this->mangas[$manga->getId()->getValue()] = $manga;
} }
public function delete(Manga $manga): void public function delete(Manga $manga): void
{ {
$this->mangas = array_filter( unset($this->mangas[$manga->getId()->getValue()]);
$this->mangas,
fn(Manga $existingManga) => !$existingManga->getId()->equals($manga->getId())
);
} }
private function getPropertyValue(Manga $manga, string $property): mixed private function getPropertyValue(Manga $manga, string $property): mixed
@@ -91,7 +86,7 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
public function countChapters(string $mangaId): int public function countChapters(string $mangaId): int
{ {
return isset($this->chapters[$mangaId]) ? count($this->chapters[$mangaId]) : 0; return count($this->chapters[$mangaId] ?? []);
} }
public function addChaptersToManga(string $mangaId, int $count): void public function addChaptersToManga(string $mangaId, int $count): void
@@ -111,9 +106,35 @@ class InMemoryMangaRepository implements MangaRepositoryInterface
} }
} }
public function findByExternalId(ExternalId $externalId): ?Manga
{
foreach ($this->mangas as $manga) {
if ($manga->getExternalId() && $manga->getExternalId()->getValue() === $externalId->getValue()) {
return $manga;
}
}
return null;
}
public function saveChapter(Chapter $chapter): void
{
$this->savedChapters[] = $chapter;
if (!isset($this->chapters[$chapter->getMangaId()])) {
$this->chapters[$chapter->getMangaId()] = [];
}
$this->chapters[$chapter->getMangaId()][] = $chapter;
}
/** @return array<Chapter> */
public function getSavedChapters(): array
{
return $this->savedChapters;
}
public function clear(): void public function clear(): void
{ {
$this->mangas = []; $this->mangas = [];
$this->chapters = []; $this->chapters = [];
$this->savedChapters = [];
} }
} }

View File

@@ -9,6 +9,7 @@ class InMemoryMangadexClient implements MangadexClientInterface
private array $mangas = []; private array $mangas = [];
private array $feeds = []; private array $feeds = [];
private array $aggregates = []; private array $aggregates = [];
private array $mangaFeeds = [];
public function __construct( public function __construct(
array $mangas = [], array $mangas = [],
@@ -61,22 +62,7 @@ class InMemoryMangadexClient implements MangadexClientInterface
public function getMangaFeed(string $mangaId, int $offset = 0, int $limit = 500, string $order = 'asc'): array public function getMangaFeed(string $mangaId, int $offset = 0, int $limit = 500, string $order = 'asc'): array
{ {
if (!isset($this->feeds[$mangaId])) { return $this->mangaFeeds[$mangaId] ?? ['data' => []];
return [
'data' => [],
'total' => 0
];
}
$feed = $this->feeds[$mangaId];
if ($order === 'desc') {
$feed = array_reverse($feed);
}
return [
'data' => array_slice($feed, $offset, $limit),
'total' => count($feed)
];
} }
public function getMangaAggregate(string $mangaId): array public function getMangaAggregate(string $mangaId): array
@@ -120,4 +106,9 @@ class InMemoryMangadexClient implements MangadexClientInterface
{ {
$this->aggregates[$mangaId] = $aggregate; $this->aggregates[$mangaId] = $aggregate;
} }
public function setMangaFeed(string $mangaId, array $feed): void
{
$this->mangaFeeds[$mangaId] = $feed;
}
} }

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Application\CommandHandler\FetchMangaChaptersHandler;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
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\InMemoryMangadexClient;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use PHPUnit\Framework\TestCase;
class FetchMangaChaptersHandlerTest extends TestCase
{
private InMemoryMangadexClient $mangadexClient;
private InMemoryMangaRepository $mangaRepository;
private FetchMangaChaptersHandler $handler;
protected function setUp(): void
{
$this->mangadexClient = new InMemoryMangadexClient();
$this->mangaRepository = new InMemoryMangaRepository();
$this->handler = new FetchMangaChaptersHandler(
$this->mangadexClient,
$this->mangaRepository
);
}
public function testHandleWithExistingManga(): void
{
$externalId = 'manga-123';
$manga = new Manga(
new MangaId('manga-id'),
new MangaTitle('Test Manga'),
new MangaSlug('test-manga'),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId($externalId)
);
$this->mangaRepository->save($manga);
$this->mangadexClient->setMangaFeed($externalId, [
'data' => [
[
'id' => 'chapter-1',
'attributes' => [
'chapter' => '1',
'title' => 'Chapter 1',
'volume' => '1'
]
]
]
]);
$command = new FetchMangaChapters($externalId);
$this->handler->handle($command);
$this->assertCount(1, $this->mangaRepository->getSavedChapters());
}
public function testHandleWithNonExistingManga(): void
{
$externalId = 'non-existing-manga';
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Manga not found');
$command = new FetchMangaChapters($externalId);
$this->handler->handle($command);
}
}

View File

@@ -57,7 +57,7 @@ class GetMangaChaptersHandlerTest extends TestCase
// Act // Act
$query = new GetMangaChapters('123', page: 2, limit: 10); $query = new GetMangaChapters('123', page: 2, limit: 10);
$response = $this->handler->handle($query); $response = $this->handler->handle($query);
// Assert // Assert
$this->assertCount(10, $response->chapters); $this->assertCount(10, $response->chapters);
$this->assertEquals(25, $response->total); $this->assertEquals(25, $response->total);

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Tests\Feature\Manga;
use App\Domain\Manga\Application\Command\FetchMangaChapters;
use App\Domain\Manga\Domain\Model\Manga;
use App\Domain\Manga\Domain\Model\ValueObject\ExternalId;
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\Feature\AbstractApiTestCase;
use App\Tests\Shared\Adapter\InMemoryMessageBus;
use Symfony\Component\Messenger\MessageBusInterface;
use Zenstruck\Foundry\Test\ResetDatabase;
class FetchMangaChaptersTest extends AbstractApiTestCase
{
use ResetDatabase;
private InMemoryMessageBus $messageBus;
protected function setUp(): void
{
parent::setUp();
$this->messageBus = new InMemoryMessageBus();
$this->container->set(MessageBusInterface::class, $this->messageBus);
$this->messageBus->clear();
}
public function testFetchChaptersForExistingManga(): void
{
$externalId = 'manga-123';
$manga = new Manga(
new MangaId('manga-id'),
new MangaTitle('Test Manga'),
new MangaSlug('test-manga'),
'Description',
'Author',
2024,
[],
'ongoing',
new ExternalId($externalId)
);
$this->entityManager->persist($this->toEntity($manga));
$this->entityManager->flush();
static::createClient()->request('POST', '/api/manga/chapters/fetch', [
'json' => [
'externalId' => $externalId
]
]);
$this->assertResponseStatusCodeSame(202);
$messages = $this->messageBus->getDispatchedMessages();
$this->assertCount(1, $messages);
$this->assertInstanceOf(FetchMangaChapters::class, $messages[0]);
$this->assertEquals($externalId, $messages[0]->externalId);
}
public function testFetchChaptersWithInvalidExternalId(): void
{
$response = static::createClient()->request('POST', '/api/manga/chapters/fetch', [
'json' => [
'externalId' => ''
]
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
[
'propertyPath' => 'externalId',
'message' => 'This value should not be blank.'
]
]
]);
}
private function toEntity(Manga $manga): \App\Entity\Manga
{
$entity = new \App\Entity\Manga();
$entity->setTitle($manga->getTitle()->getValue())
->setSlug($manga->getSlug()->getValue())
->setDescription($manga->getDescription())
->setAuthor($manga->getAuthor())
->setPublicationYear($manga->getPublicationYear())
->setGenres($manga->getGenres())
->setStatus($manga->getStatus())
->setExternalId($manga->getExternalId()->getValue())
->setMonitored(false);
return $entity;
}
protected function tearDown(): void
{
parent::tearDown();
$this->messageBus->clear();
}
}

View File

@@ -3,8 +3,8 @@
namespace App\Tests\Feature\Scraping; namespace App\Tests\Feature\Scraping;
use App\Domain\Scraping\Application\Command\ScrapeChapter; use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Tests\Domain\Scraping\Adapter\InMemoryMessageBus;
use App\Tests\Feature\AbstractApiTestCase; use App\Tests\Feature\AbstractApiTestCase;
use App\Tests\Shared\Adapter\InMemoryMessageBus;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
class ScrapeChapterTest extends AbstractApiTestCase class ScrapeChapterTest extends AbstractApiTestCase
@@ -14,7 +14,9 @@ class ScrapeChapterTest extends AbstractApiTestCase
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
$this->messageBus = self::getContainer()->get(MessageBusInterface::class); $this->messageBus = new InMemoryMessageBus();
$this->container->set(MessageBusInterface::class, $this->messageBus);
$this->messageBus->clear();
} }
public function testInitiateChapterScraping(): void public function testInitiateChapterScraping(): void
@@ -69,4 +71,10 @@ class ScrapeChapterTest extends AbstractApiTestCase
], ],
]); ]);
} }
protected function tearDown(): void
{
parent::tearDown();
$this->messageBus->clear();
}
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Tests\Domain\Scraping\Adapter; namespace App\Tests\Shared\Adapter;
use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
@@ -35,4 +35,4 @@ class InMemoryMessageBus implements MessageBusInterface
} }
return false; return false;
} }
} }