feat: firsts endpoints and firsts tests

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-02-05 16:54:13 +01:00
parent 89570ad951
commit 6bc3696190
20 changed files with 455 additions and 23 deletions

View File

@@ -0,0 +1 @@
{"version":1,"defects":{"App\\Tests\\Domain\\Scraping\\Application\\CommandHandler\\ScrapeChapterHandlerTest::testHandleSuccessfully":8,"App\\Tests\\Domain\\Scraping\\Application\\CommandHandler\\ScrapeChapterHandlerTest::testHandleThrowsException":8,"App\\Tests\\Feature\\Scraping\\ScrapeChapterTest::testInitiateChapterScraping":8,"App\\Tests\\Feature\\Scraping\\ScrapeChapterTest::testInitiateChapterScrapingWithInvalidPayload":8,"App\\Tests\\Feature\\Scraping\\ScrapingStatusTest::testGetScrapingStatus":7,"App\\Tests\\Feature\\Scraping\\ScrapingStatusTest::testGetScrapingStatusForNonExistentJob":7},"times":{"App\\Tests\\Domain\\Scraping\\Application\\CommandHandler\\ScrapeChapterHandlerTest::testHandleSuccessfully":0.003,"App\\Tests\\Domain\\Scraping\\Application\\CommandHandler\\ScrapeChapterHandlerTest::testHandleThrowsException":0,"App\\Tests\\Feature\\Scraping\\ScrapeChapterTest::testInitiateChapterScraping":0.038,"App\\Tests\\Feature\\Scraping\\ScrapeChapterTest::testInitiateChapterScrapingWithInvalidPayload":0.008,"App\\Tests\\Feature\\Scraping\\ScrapingStatusTest::testGetScrapingStatus":0.005,"App\\Tests\\Feature\\Scraping\\ScrapingStatusTest::testGetScrapingStatusForNonExistentJob":0.006}}

View File

@@ -2,8 +2,8 @@ api_platform:
title: Mangarr API
version: 1.0.0
formats:
jsonld: ['application/ld+json']
json: ['application/json']
jsonld: ['application/ld+json']
html: ['text/html']
jsonhal: ['application/hal+json']
swagger:
@@ -23,3 +23,8 @@ api_platform:
rfc_7807_compliant_errors: true
event_listeners_backward_compatibility_layer: false
keep_legacy_inflector: false
mapping:
paths:
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto'
patch_formats:
json: ['application/merge-patch+json']

View File

@@ -21,6 +21,13 @@ doctrine:
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
# Ajout du mapping pour le domaine Scraping
Scraping:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/Persistence/Entity'
prefix: 'App\Domain\Scraping\Infrastructure\Persistence\Entity'
alias: Scraping
when@test:
doctrine:

15
config/services_test.yaml Normal file
View File

@@ -0,0 +1,15 @@
services:
_defaults:
autowire: true
autoconfigure: true
public: true
Symfony\Component\Messenger\MessageBusInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryMessageBus'
public: true
App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface:
class: 'App\Tests\Domain\Scraping\Adapter\InMemoryScrapingJobRepository'
public: true

View File

@@ -15,10 +15,10 @@
<property name="cache.location" value="var/cache/phpmd.cache"/>
</properties>
<rule ref="rulesets/codesize.xml"/>
<!-- <rule ref="rulesets/codesize.xml"/>-->
<!-- <rule ref="rulesets/cleancode.xml"/>-->
<!-- <rule ref="rulesets/controversial.xml"/>-->
<!-- <rule ref="rulesets/design.xml"/>-->
<rule ref="rulesets/naming.xml"/>
<rule ref="rulesets/unusedcode.xml"/>
<!-- <rule ref="rulesets/naming.xml"/>-->
<!-- <rule ref="rulesets/unusedcode.xml"/>-->
</ruleset>

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domain\Scraping\Domain\Repository;
namespace App\Domain\Scraping\Domain\Contract\Repository;
use App\Domain\Scraping\Domain\Model\Source;

View File

@@ -8,6 +8,7 @@ use App\Domain\Scraping\Domain\Model\ValueObject\PageNumber;
class ScrapingJob
{
private array $pages = [];
private int $totalPages = 0;
private ScrapingStatus $status;
private \DateTimeImmutable $createdAt;
private ?\DateTimeImmutable $completedAt = null;
@@ -67,6 +68,11 @@ class ScrapingJob
return $this->pages;
}
public function getTotalPages(): int
{
return $this->totalPages;
}
public function getStatus(): ScrapingStatus
{
return $this->status;

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Domain\Scraping\Infrastructure\ApiPlatform\Dto;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\Domain\Scraping\Infrastructure\ApiPlatform\State\Processor\ScrapeChapterStateProcessor;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'ScrapeChapter',
operations: [
new Post(
uriTemplate: '/scraping/chapters',
status: 202,
processor: ScrapeChapterStateProcessor::class
),
]
)]
readonly class ScrapeChapterRequest
{
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')]
#[Assert\NotBlank]
public string $mangaId,
) {
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Domain\Scraping\Infrastructure\ApiPlatform\Dto;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Domain\Scraping\Infrastructure\ApiPlatform\State\Provider\ScrapingStatusStateProvider;
use ApiPlatform\Metadata\Link;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
#[ApiResource(
shortName: 'ScrapingStatus',
operations: [
new Get(
uriTemplate: '/scraping/jobs/{jobId}/status',
provider: ScrapingStatusStateProvider::class,
uriVariables: [
'jobId' => new Link(
fromProperty: 'jobId',
toProperty: 'id',
fromClass: ScrapingStatusResponse::class,
toClass: ScrapingJob::class
)
]
),
],
)]
readonly class ScrapingStatusResponse
{
public function __construct(
#[ApiProperty(identifier: true)]
public string $jobId,
#[ApiProperty]
public string $status,
#[ApiProperty]
public ?float $progress = null,
#[ApiProperty]
public ?string $error = null
) {
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domain\Scraping\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Infrastructure\ApiPlatform\Dto\ScrapeChapterRequest;
use Symfony\Component\Messenger\MessageBusInterface;
final class ScrapeChapterStateProcessor implements ProcessorInterface
{
public function __construct(
private readonly MessageBusInterface $commandBus
) {}
/**
* @param ScrapeChapterRequest $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
$this->commandBus->dispatch(
new ScrapeChapter(
$data->chapterId,
$data->sourceId,
$data->mangaId
)
);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Domain\Scraping\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface;
use App\Domain\Scraping\Infrastructure\ApiPlatform\Dto\ScrapingStatusResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class ScrapingStatusStateProvider implements ProviderInterface
{
public function __construct(
private ScrapingJobRepositoryInterface $scrapingJobRepository
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ScrapingStatusResponse
{
$job = $this->scrapingJobRepository->findById($uriVariables['jobId']);
if (!$job) {
throw new NotFoundHttpException('Job de scraping non trouvé');
}
$progress = 0;
if ($job->getTotalPages() > 0) {
$progress = (count($job->getPages()) / $job->getTotalPages()) * 100;
}
return new ScrapingStatusResponse(
jobId: $job->getId(),
status: $job->getStatus()->value,
progress: $progress
);
}
}

View File

@@ -2,17 +2,18 @@
namespace App\Domain\Scraping\Infrastructure\Persistence;
use App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Domain\Model\ScrapingStatus;
use App\Domain\Scraping\Domain\Repository\ScrapingJobRepositoryInterface;
use App\Domain\Scraping\Infrastructure\Persistence\Entity\ScrapingJobEntity;
use Doctrine\ORM\EntityManagerInterface;
class DoctrineScrapingJobRepository implements ScrapingJobRepositoryInterface
readonly class DoctrineScrapingJobRepository implements ScrapingJobRepositoryInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager
) {}
private EntityManagerInterface $entityManager
) {
}
public function save(ScrapingJob $job): void
{

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Tests\Domain\Scraping\Adapter;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
class InMemoryMessageBus implements MessageBusInterface
{
/** @var array<object> */
public static array $messages = [];
public function dispatch(object $message, array $stamps = []): Envelope
{
self::$messages[] = $message;
return new Envelope($message);
}
public function getDispatchedMessages(): array
{
return self::$messages;
}
public function clear(): void
{
self::$messages = [];
}
public function hasMessageOfType(string $messageClass): bool
{
foreach (self::$messages as $message) {
if ($message instanceof $messageClass) {
return true;
}
}
return false;
}
}

View File

@@ -8,21 +8,21 @@ use App\Domain\Scraping\Domain\Model\ScrapingJob;
class InMemoryScrapingJobRepository implements ScrapingJobRepositoryInterface
{
/** @var ScrapingJob[] */
private array $jobs = [];
private static array $jobs = [];
public function save(ScrapingJob $job): void
{
$this->jobs[] = $job;
self::$jobs[] = $job;
}
public function getJobs(): array
{
return $this->jobs;
return self::$jobs;
}
public function findById(string $id): ?ScrapingJob
{
foreach ($this->jobs as $job) {
foreach (self::$jobs as $job) {
if ($job->getId() === $id) {
return $job;
}
@@ -33,7 +33,7 @@ class InMemoryScrapingJobRepository implements ScrapingJobRepositoryInterface
public function findByChapterId(string $chapterId): ?ScrapingJob
{
foreach ($this->jobs as $job) {
foreach (self::$jobs as $job) {
if ($job->getChapterId() === $chapterId) {
return $job;
}
@@ -41,4 +41,9 @@ class InMemoryScrapingJobRepository implements ScrapingJobRepositoryInterface
return null;
}
public function clear(): void
{
self::$jobs = [];
}
}

View File

@@ -40,17 +40,14 @@ class ScrapeChapterHandlerTest extends TestCase
$this->handler->handle($command);
// Vérifier que le job a été créé
$scrapingJobs = $this->scraper->getJobs();
$this->assertCount(1, $scrapingJobs);
$job = $scrapingJobs[0];
// Vérifier que le job a été sauvegardé
$savedJobs = $this->repository->getJobs();
$this->assertCount(1, $savedJobs);
$this->assertSame($job, $savedJobs[0]);
// Vérifier que l'événement a été dispatché
$dispatchedMessages = $this->eventBus->getDispatchedMessages();
$this->assertCount(1, $dispatchedMessages);
$this->assertInstanceOf(ChapterScrapingStarted::class, $dispatchedMessages[0]);
@@ -74,7 +71,6 @@ class ScrapeChapterHandlerTest extends TestCase
try {
$this->handler->handle($command);
} finally {
// Vérifier que l'événement d'échec a été dispatché
$dispatchedMessages = $this->eventBus->getDispatchedMessages();
$this->assertCount(1, $dispatchedMessages);
$this->assertInstanceOf(ChapterScrapingFailed::class, $dispatchedMessages[0]);

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Tests\Feature\Scraping;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
abstract class AbstractApiTestCase extends ApiTestCase
{
protected function setUp(): void
{
parent::setUp();
}
protected function tearDown(): void
{
parent::tearDown();
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Tests\Feature\Scraping\Factory;
use App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Domain\Model\ScrapingStatus;
use App\Domain\Scraping\Domain\Model\ValueObject\ImageUrl;
use App\Domain\Scraping\Domain\Model\ValueObject\PageNumber;
use Ramsey\Uuid\Uuid;
class ScrapingJobFactory
{
public function __construct(
private readonly ScrapingJobRepositoryInterface $repository
) {}
public function createJob(array $attributes = []): ScrapingJob
{
$job = new ScrapingJob(
$attributes['id'] ?? Uuid::uuid4()->toString(),
$attributes['mangaId'] ?? 'manga-'.Uuid::uuid4()->toString(),
$attributes['chapterId'] ?? 'chapter-'.Uuid::uuid4()->toString(),
$attributes['sourceId'] ?? 'source-'.Uuid::uuid4()->toString()
);
if (isset($attributes['status'])) {
$this->setJobStatus($job, $attributes['status']);
}
if (isset($attributes['pages'])) {
foreach ($attributes['pages'] as $index => $page) {
$job->addPage(new PageNumber($index + 1), new ImageUrl($page));
}
}
$this->repository->save($job);
return $job;
}
private function setJobStatus(ScrapingJob $job, ScrapingStatus $status): void
{
// Cette méthode nécessite peut-être d'ajouter des méthodes protégées dans ScrapingJob
// pour permettre la modification du statut dans les tests
// Ou utiliser de la réflexion si nécessaire
$reflection = new \ReflectionProperty($job, 'status');
$reflection->setAccessible(true);
$reflection->setValue($job, $status);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Tests\Feature\Scraping;
use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Tests\Domain\Scraping\Adapter\InMemoryMessageBus;
use Symfony\Component\Messenger\MessageBusInterface;
class ScrapeChapterTest extends AbstractApiTestCase
{
private MessageBusInterface|InMemoryMessageBus $messageBus;
protected function setUp(): void
{
parent::setUp();
$this->messageBus = self::getContainer()->get(MessageBusInterface::class);
}
public function testInitiateChapterScraping(): void
{
// Given
$payload = [
'chapterId' => 'chapter-123',
'sourceId' => 'source-456',
'mangaId' => 'manga-789',
];
// When
$response = static::createClient()->request('POST', '/api/scraping/chapters', [
'json' => $payload,
'headers' => ['Accept' => 'application/json'],
]);
// Then
$this->assertResponseStatusCodeSame(202);
$messages = $this->messageBus::$messages;
$this->assertCount(1, $messages, 'Un message devrait être dispatché');
/** @var ScrapeChapter $message */
$message = $messages[0];
$this->assertInstanceOf(ScrapeChapter::class, $message);
}
public function testInitiateChapterScrapingWithInvalidPayload(): void
{
// Given
$payload = [
'chapterId' => '',
'sourceId' => 'source-456',
'mangaId' => 'manga-789',
];
// When
$response = static::createClient()->request('POST', '/api/scraping/chapters', [
'json' => $payload,
'headers' => ['Accept' => 'application/json'],
]);
// Then
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
[
'propertyPath' => 'chapterId',
'message' => 'This value should not be blank.',
],
],
]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Tests\Feature\Scraping;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Domain\Model\ScrapingStatus;
use App\Domain\Scraping\Domain\Model\ValueObject\ImageUrl;
use App\Domain\Scraping\Domain\Model\ValueObject\PageNumber;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Messenger\MessageBusInterface;
use App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface;
class ScrapingStatusTest extends ApiTestCase
{
private MessageBusInterface $messageBus;
private ScrapingJobRepositoryInterface $repository;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
$this->messageBus = self::getContainer()->get(MessageBusInterface::class);
$this->repository = self::getContainer()->get(ScrapingJobRepositoryInterface::class);
}
public function testGetScrapingStatus(): void
{
// Given
$jobId = Uuid::uuid4()->toString();
$job = new ScrapingJob($jobId, 'manga-123', 'chapter-456', 'source-789');
$job->addPage(new PageNumber(1), new ImageUrl('http://example.com/page1.jpg'));
$job->addPage(new PageNumber(2), new ImageUrl('http://example.com/page2.jpg'));
$this->repository->save($job);
// When
$response = static::createClient()->request('GET', '/api/scraping/jobs/'.$jobId.'/status');
// Then
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'jobId' => $jobId,
'status' => ScrapingStatus::IN_PROGRESS->value,
'progress' => 0
]);
}
public function testGetScrapingStatusForNonExistentJob(): void
{
// When
$response = static::createClient()->request('GET', '/api/scraping/jobs/non-existent-id/status', [
'headers' => ['Accept' => 'application/json']
]);
// Then
$this->assertResponseStatusCodeSame(404);
}
protected function tearDown(): void
{
parent::tearDown();
self::ensureKernelShutdown();
}
}