feat: ajout de claude + correction des tests
All checks were successful
Build and Deploy / deploy (push) Successful in 9m36s

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2026-03-09 17:09:31 +01:00
parent b5a832fbbc
commit dae215dd3d
8 changed files with 1094 additions and 24 deletions

View File

@@ -0,0 +1,208 @@
---
name: api-platform
description: Conventions API Platform du projet Mangarr — brancher un State Processor sur une Command, un State Provider sur une Query, nommage des Resources, gestion des DTOs. Utiliser quand on crée ou modifie une Resource, un State Processor/Provider, ou un DTO API Platform.
allowed-tools: Read, Grep, Glob
---
# API Platform — Mangarr
Tout le code API Platform vit dans `Infrastructure/ApiPlatform/` du domaine concerné.
```
Infrastructure/ApiPlatform/
Resource/
{FeatureName}Resource.php ← classe vide avec attribut #[ApiResource]
State/
Processor/
{DoSomething}Processor.php ← implémente ProcessorInterface → Command
Provider/
{GetSomething}StateProvider.php ← implémente ProviderInterface → Query
Dto/
{Name}.php ← données entrantes ou sortantes
Controller/
{Action}Controller.php ← uniquement pour cas non-standards
```
---
## Resource
Classe **vide** — elle ne contient que l'attribut `#[ApiResource]`. Aucune logique.
```php
// Infrastructure/ApiPlatform/Resource/MangaResource.php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Delete;
use App\Domain\Manga\Infrastructure\ApiPlatform\Dto\MangaDetail;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Provider\GetMangaStateProvider;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\CreateMangaProcessor;
#[ApiResource(
shortName: 'Manga',
operations: [
new Get(
uriTemplate: '/mangas/by-id/{id}',
provider: GetMangaStateProvider::class,
output: MangaDetail::class,
),
new Post(
uriTemplate: '/mangas',
input: CreateMangaDto::class,
processor: CreateMangaProcessor::class,
),
new Delete(
uriTemplate: '/mangas/{id}',
provider: DeleteMangaProvider::class, // ← provider requis pour Delete
processor: DeleteMangaProcessor::class,
),
]
)]
class MangaResource {}
```
**Règles Resource :**
- `shortName` = nom du concept métier (ex: `'Manga'`, `'Chapter'`).
- `uriTemplate` explicite (pas de génération automatique depuis le nom de classe).
- `output` = DTO de sortie, `input` = DTO d'entrée.
- `provider` et `processor` référencés par `::class`.
---
## State Processor → Command (écriture)
```php
// Infrastructure/ApiPlatform/State/Processor/{DoSomething}Processor.php
namespace App\Domain\{Domain}\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\{Domain}\Application\Command\{DoSomething};
use App\Domain\{Domain}\Application\CommandHandler\{DoSomething}Handler;
use App\Domain\{Domain}\Domain\Exception\{Something}NotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class {DoSomething}Processor implements ProcessorInterface
{
public function __construct(
private {DoSomething}Handler $handler,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
try {
$this->handler->handle(new {DoSomething}(
$uriVariables['id'] ?? $data->someField,
));
} catch ({Something}NotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}
}
}
```
**Règles Processor :**
- Injecte le **Handler concret** (pas une interface, car l'Infrastructure peut dépendre de l'Application).
- Traduit les Domain Exceptions en HTTP Exceptions Symfony (`NotFoundHttpException`, `UnprocessableEntityHttpException`…).
- Retourne `void` pour les opérations sans corps de réponse, ou le DTO de sortie si nécessaire.
---
## State Provider → Query (lecture)
```php
// Infrastructure/ApiPlatform/State/Provider/{GetSomething}StateProvider.php
namespace App\Domain\{Domain}\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\{Domain}\Application\Query\{GetSomething};
use App\Domain\{Domain}\Application\QueryHandler\{GetSomething}Handler;
use App\Domain\{Domain}\Infrastructure\ApiPlatform\Dto\{Something}Detail;
readonly class {GetSomething}StateProvider implements ProviderInterface
{
public function __construct(
private {GetSomething}Handler $handler,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): {Something}Detail
{
$query = new {GetSomething}($uriVariables['id']);
$response = $this->handler->handle($query);
return new {Something}Detail(
id: $response->id,
title: $response->title,
// mapper Response → DTO ici
);
}
}
```
**Règles Provider :**
- Construit la Query depuis `$uriVariables` et/ou `$context['filters']`.
- Mappe le `Response` (Application) vers un **DTO** (Infrastructure) — ne jamais retourner un Response directement.
- Pour les collections, retourner un tableau ou un objet `Paginator`.
---
## DTOs
Les DTOs sont des classes de données spécifiques à la couche HTTP. Ils ne doivent pas contenir de logique.
```php
// Infrastructure/ApiPlatform/Dto/MangaDetail.php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Dto;
readonly class MangaDetail
{
public function __construct(
public string $id,
public string $title,
public string $slug,
public ?string $imageUrl,
// ...
) {}
}
```
**Règles DTO :**
- `readonly class`.
- Uniquement des types PHP natifs.
- **DTO d'entrée** : contient les champs que le client envoie (`input:`).
- **DTO de sortie** : contient les champs que l'API retourne (`output:`).
- Ne jamais réutiliser un `Response` Application comme DTO API Platform (couches séparées).
---
## Conventions de nommage
| Fichier | Pattern de nom | Exemple |
|------------------------------------|----------------------------------------|------------------------------------|
| Resource (opération GET) | `{Concept}Resource.php` | `MangaResource.php` |
| Resource (opération spécifique) | `{Action}{Concept}Resource.php` | `CreateMangaResource.php` |
| Processor | `{DoSomething}Processor.php` | `CreateMangaProcessor.php` |
| Provider (GET item) | `Get{Concept}StateProvider.php` | `GetMangaStateProvider.php` |
| Provider (GET collection) | `Get{Concept}ListStateProvider.php` | `GetMangaListStateProvider.php` |
| Provider (Delete, nécessite item) | `Delete{Concept}Provider.php` | `DeleteMangaProvider.php` |
| DTO de sortie (item) | `{Concept}Detail.php` | `MangaDetail.php` |
| DTO de sortie (liste) | `{Concept}ListItem.php` | `MangaListItem.php` |
| DTO de sortie (collection) | `{Concept}Collection.php` | `MangaCollection.php` |
---
## Flux complet : une opération POST
```
HTTP POST /mangas
→ CreateMangaResource (#[ApiResource] avec processor:)
→ CreateMangaProcessor::process($data, ...)
→ CreateMangaFromMangadexHandler::handle(new CreateMangaFromMangadex(...))
→ Domain : Manga::__construct(...) + invariants
→ MangaRepositoryInterface::save($manga)
→ MessageBus::dispatch(new MangaCreated(...))
```

View File

@@ -0,0 +1,208 @@
---
name: cqrs
description: Patterns CQRS du projet Mangarr — templates Command/CommandHandler et Query/QueryHandler, enregistrement dans Symfony Messenger, séparation read/write model. Utiliser quand on crée ou modifie un use case (Command ou Query) et son handler.
allowed-tools: Read, Grep, Glob
---
# CQRS — Mangarr
## Principe
- **Command** : intention de modifier l'état. Retourne `void`.
- **Query** : lecture seule. Retourne un `Response` objet (jamais une entité Doctrine).
- Les handlers vivent dans `Application/`, jamais dans `Infrastructure/` directement.
- Les handlers sont `readonly class` et reçoivent leurs dépendances via le constructeur (autowiring).
---
## Template Command
```php
// src/Domain/{Domain}/Application/Command/{DoSomething}.php
namespace App\Domain\{Domain}\Application\Command;
readonly class {DoSomething}
{
public function __construct(
public string $someId,
public string $someValue,
// scalaires ou tableaux uniquement — pas d'objets du Domain
) {}
}
```
## Template CommandHandler
```php
// src/Domain/{Domain}/Application/CommandHandler/{DoSomething}Handler.php
namespace App\Domain\{Domain}\Application\CommandHandler;
use App\Domain\{Domain}\Application\Command\{DoSomething};
use App\Domain\{Domain}\Domain\Contract\Repository\{Name}RepositoryInterface;
use App\Domain\{Domain}\Domain\Event\{SomethingHappened};
use App\Domain\{Domain}\Domain\Model\{Aggregate};
use Ramsey\Uuid\Uuid;
use Symfony\Component\Messenger\MessageBusInterface;
readonly class {DoSomething}Handler
{
public function __construct(
private {Name}RepositoryInterface $repository,
// autres interfaces Domain uniquement
private MessageBusInterface $messageBus, // si event à dispatcher
) {}
public function handle({DoSomething} $command): void
{
// 1. Reconstruire/créer l'Aggregate via Value Objects
$aggregate = new {Aggregate}(
new {AggregateId}(Uuid::uuid4()->toString()),
// ...
);
// 2. Appeler la méthode métier (invariants dans le Domain)
$aggregate->doSomething();
// 3. Persister via l'interface repository
$this->repository->save($aggregate);
// 4. Dispatcher un Domain Event si nécessaire
$this->messageBus->dispatch(new {SomethingHappened}($aggregate->getId()->getValue()));
}
}
```
**Règles CommandHandler :**
- N'injecte que des interfaces définies dans `Domain/Contract/` (jamais une classe concrète Infrastructure).
- Exception autorisée : `MessageBusInterface` de Symfony Messenger.
- Ne retourne jamais de données (`void`).
---
## Template Query
```php
// src/Domain/{Domain}/Application/Query/{GetSomething}.php
namespace App\Domain\{Domain}\Application\Query;
readonly class {GetSomething}
{
public function __construct(
public string $id,
// critères de filtrage en scalaires
) {}
}
```
## Template QueryHandler
```php
// src/Domain/{Domain}/Application/QueryHandler/{GetSomething}Handler.php
namespace App\Domain\{Domain}\Application\QueryHandler;
use App\Domain\{Domain}\Application\Query\{GetSomething};
use App\Domain\{Domain}\Application\Response\{Something}Response;
use App\Domain\{Domain}\Domain\Contract\Repository\{Name}RepositoryInterface;
use App\Domain\{Domain}\Domain\Exception\{Something}NotFoundException;
readonly class {GetSomething}Handler
{
public function __construct(
private {Name}RepositoryInterface $repository,
) {}
public function handle({GetSomething} $query): {Something}Response
{
$aggregate = $this->repository->findById($query->id);
if (!$aggregate) {
throw new {Something}NotFoundException();
}
return new {Something}Response(
id: $aggregate->getId()->getValue(),
// mapper les Value Objects vers scalaires ici
);
}
}
```
## Template Response
```php
// src/Domain/{Domain}/Application/Response/{Something}Response.php
namespace App\Domain\{Domain}\Application\Response;
readonly class {Something}Response
{
public function __construct(
public string $id,
public string $title,
// scalaires et tableaux uniquement — jamais d'objets Domain
) {}
}
```
**Règles Response :**
- `readonly class`.
- Uniquement des types PHP natifs (`string`, `int`, `float`, `bool`, `array`, `?string`…).
- C'est le **read model** — il ne sert qu'à transporter des données vers l'Infrastructure.
---
## Enregistrement dans Symfony Messenger
### Command via bus synchrone
Les handlers sont auto-découverts par autowiring. Aucune configuration supplémentaire pour les CommandHandlers Application purs.
Pour les handlers Symfony Messenger (traitement asynchrone ou via `command.bus`), créer un wrapper dans `Infrastructure/CommandHandler/` :
```php
// src/Domain/{Domain}/Infrastructure/CommandHandler/Symfony{DoSomething}Handler.php
namespace App\Domain\{Domain}\Infrastructure\CommandHandler;
use App\Domain\{Domain}\Application\Command\{DoSomething};
use App\Domain\{Domain}\Application\CommandHandler\{DoSomething}Handler;
readonly class Symfony{DoSomething}Handler
{
public function __construct(
private {DoSomething}Handler $handler,
) {}
public function __invoke({DoSomething} $command): void
{
$this->handler->handle($command);
}
}
```
Déclarer dans `config/services.yaml` :
```yaml
App\Domain\{Domain}\Infrastructure\CommandHandler\Symfony{DoSomething}Handler:
tags:
- { name: messenger.message_handler, bus: command.bus }
```
### Buses disponibles
| Bus / Transport | Usage |
|------------------|------------------------------------|
| `command.bus` | Commands synchrones ou async |
| `events` | Domain Events (async) |
| `commands` | Messages async (ex: scraping) |
| `async` | Scheduler (tâches planifiées) |
---
## Séparation Read Model / Write Model
| Write Model | Read Model |
|--------------------------------------|-------------------------------------------|
| `Domain/Model/{Aggregate}.php` | `Application/Response/{Name}Response.php` |
| Contient les invariants métier | Contient uniquement des données aplaties |
| Manipulé par les CommandHandlers | Retourné par les QueryHandlers |
| Persisté via `RepositoryInterface` | Jamais persisté directement |
Ne jamais retourner un Aggregate depuis un QueryHandler — toujours mapper vers une Response.

View File

@@ -0,0 +1,144 @@
---
name: ddd-core
description: Règles DDD du projet Mangarr — Aggregates, Value Objects immutables, Domain Events, invariants. Utiliser quand on crée ou modifie un Model, Value Object, Event ou Exception dans src/Domain/*/Domain/.
allowed-tools: Read, Grep, Glob
---
# Règles DDD — Couche Domain
## Emplacement
```
src/Domain/{DomainName}/Domain/
Model/
{AggregateName}.php
ValueObject/
{VoName}.php
Event/
{SomethingHappened}.php
Exception/
{Something}Exception.php
Contract/
Repository/
{Name}RepositoryInterface.php
Service/
{Name}Interface.php
Client/
{Name}ClientInterface.php
```
## Aggregates
- Classe normale (pas `readonly`), propriétés `private`.
- Le constructeur prend des **Value Objects**, jamais des scalaires bruts pour les identifiants et concepts métier.
- **Aucune annotation Doctrine** dans le Model — c'est la responsabilité du Repository (Infrastructure).
- Les méthodes métier protègent les invariants et lèvent des **Domain Exceptions** (jamais des exceptions génériques).
- Les setters publics sont interdits. Exposer des méthodes métier explicites (`updateImageUrls()`, `enableMonitoring()`, etc.).
```php
// ✅ Correct
class Manga
{
public function __construct(
private MangaId $id,
private MangaTitle $title,
private MangaSlug $slug,
// ...
) {}
public function updateImageUrls(ImageUrls $imageUrls): void
{
$this->imageUrls = $imageUrls;
}
}
// ❌ Interdit
class Manga
{
public string $title; // propriété publique
#[ORM\Column] // annotation Doctrine dans le Domain
private string $title;
public function setTitle(string $title): void {} // setter générique
}
```
## Value Objects
- Toujours `readonly class`.
- Valider dans le constructeur, lever une **Domain Exception** si invalide.
- Exposer `getValue()` pour récupérer la valeur primitive.
- Jamais de dépendance externe (pas de Symfony, pas de Doctrine).
```php
readonly class MangaTitle
{
public function __construct(public readonly string $value)
{
if (empty(trim($value))) {
throw new InvalidMangaTitleException('Title cannot be empty');
}
}
public function getValue(): string
{
return $this->value;
}
}
```
Valeurs composées (ex: chemins d'images) → Value Object avec plusieurs propriétés :
```php
readonly class ImageUrls
{
public function __construct(
private string $full,
private string $thumbnail,
) {}
public function getFull(): string { return $this->full; }
public function getThumbnail(): string { return $this->thumbnail; }
}
```
## Domain Events
- Nommés au **passé** : `MangaCreated`, `ChapterImported`, `MonitoringEnabled`.
- `readonly class`, transportent uniquement des scalaires (pas d'objets du Domain).
- Placés dans `Domain/Event/`.
- Dispatchés depuis le **CommandHandler** (Application), jamais depuis le Domain lui-même.
- Le bus utilisé est `MessageBusInterface` de Symfony Messenger (autorisé dans Application, pas dans Domain).
```php
readonly class MangaCreated
{
public function __construct(
public string $mangaId,
public string $externalId,
) {}
}
```
## Domain Exceptions
- Étendent `DomainException` ou `\RuntimeException` selon le cas.
- Nommées avec le suffixe `Exception` ou `NotFoundException`.
- Localisées dans `Domain/Exception/`.
```php
class MangaNotFoundException extends \DomainException
{
public function __construct()
{
parent::__construct('Manga not found');
}
}
```
## Règles d'invariants PHPArkitect (enforced automatiquement)
- `App\Domain\{X}\Domain`**aucune dépendance** en dehors de son propre namespace.
- Exceptions autorisées : `DateTimeImmutable`, `RuntimeException`, `Exception`, `DomainException`, `InvalidArgumentException`, `Throwable`, `Symfony\Component\HttpKernel\Exception`.
- `Ramsey\Uuid` et `Symfony\Component\Messenger` : autorisés uniquement en **Application**, pas en Domain.
Vérification : `make phparkitect`

View File

@@ -0,0 +1,139 @@
---
name: hexagonal-arch
description: Architecture hexagonale du projet Mangarr — structure exacte des dossiers, règles d'import strictes par couche, nommage ports (interfaces) vs adapters (implémentations). Utiliser quand on crée un nouveau domaine, un nouveau fichier, ou qu'on vérifie les dépendances entre couches.
allowed-tools: Read, Grep, Glob, Bash
---
# Architecture Hexagonale — Mangarr
## Structure canonique d'un domaine
```
src/Domain/{DomainName}/
Domain/ ← NOYAU pur, 0 dépendance framework
Model/
{Aggregate}.php
ValueObject/
{VoName}.php
Event/
{SomethingHappened}.php
Exception/
{Something}Exception.php
Contract/ ← PORTS (interfaces only)
Repository/
{Name}RepositoryInterface.php
Service/
{Name}Interface.php
Client/
{Name}ClientInterface.php
Application/ ← Use cases, orchestre le Domain
Command/
{DoSomething}.php
CommandHandler/
{DoSomething}Handler.php
Query/
{GetSomething}.php
QueryHandler/
{GetSomething}Handler.php
Response/
{Something}Response.php
EventListener/
{SomethingHappened}EventListener.php
Infrastructure/ ← ADAPTERS (implémentations concrètes)
Persistence/
Repository/
{Name}Repository.php ← implémente Domain/Contract/Repository/
ApiPlatform/
Resource/
{FeatureName}Resource.php
State/
Processor/
{DoSomething}Processor.php
Provider/
{GetSomething}StateProvider.php
Dto/
{Name}.php
Service/
{ServiceName}.php ← implémente Domain/Contract/Service/
Client/
{ClientName}.php ← implémente Domain/Contract/Client/
CommandHandler/ ← handlers Symfony Messenger (wrappent l'Application)
Symfony{DoSomething}Handler.php
```
## Domaines du projet
| Domaine | Responsabilité |
|-------------|-----------------------------------------------------|
| `Manga` | Catalogue mangas, chapitres, métadonnées |
| `Scraping` | Téléchargement de chapitres depuis les sources |
| `Conversion`| Conversion de formats (CBR→CBZ, génération CBZ) |
| `Reader` | Lecture de chapitres |
| `Setting` | Configuration applicative |
| `Shared` | Contrats transverses (`EventDispatcherInterface`, `MangaPathManagerInterface`, etc.) |
## Règles d'import strictes
### Domain (noyau)
```
✅ Peut importer : son propre namespace uniquement
+ exceptions PHP standard
❌ Interdit : Symfony\*, Doctrine\*, Ramsey\Uuid, tout autre domaine
```
### Application
```
✅ Peut importer : son propre Domain (App\Domain\{X}\Domain\*)
App\Domain\Shared\Domain\Contract\*
Symfony\Component\Messenger\*
Ramsey\Uuid\*
❌ Interdit : son propre Infrastructure (App\Domain\{X}\Infrastructure\*)
Doctrine\*, tout autre domaine
```
### Infrastructure
```
✅ Peut importer : tout (Symfony, Doctrine, API Platform, etc.)
son Application et son Domain
❌ Convention : ne pas contenir de logique métier (déléguer à Application)
```
## Ports vs Adapters — nommage
| Concept | Localisation | Suffixe | Exemple |
|-----------|--------------------------------------|-----------------|---------------------------------|
| Port | `Domain/Contract/Repository/` | `Interface` | `MangaRepositoryInterface` |
| Port | `Domain/Contract/Service/` | `Interface` | `ImageProcessorInterface` |
| Port | `Domain/Contract/Client/` | `Interface` | `MangadexClientInterface` |
| Adapter | `Infrastructure/Persistence/` | `Repository` | `LegacyChapterRepository` |
| Adapter | `Infrastructure/Service/` | *(nom libre)* | `ImageProcessor` |
| Adapter | `Infrastructure/Client/` | `Client` | `MangadexClient` |
Le binding port → adapter se déclare dans `config/services.yaml` :
```yaml
App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface:
alias: App\Domain\Manga\Infrastructure\Persistence\Repository\LegacyMangaRepository
```
## Shared Domain
Les contrats transverses vivent dans `src/Domain/Shared/Domain/Contract/` :
- `CommandInterface`, `QueryInterface`, `ResponseInterface` — marqueurs
- `CommandHandlerInterface`, `QueryHandlerInterface` — handlers génériques
- `EventDispatcherInterface` — dispatch d'événements domain
- `MangaPathManagerInterface` — gestion des chemins de fichiers
- `FileUploadInterface`, `NotificationInterface` — services transverses
`App\Domain\Shared` **ne dépend de personne** (règle PHPArkitect).
## Checklist avant de créer un fichier
1. Dans quelle couche va ce fichier ? (Domain / Application / Infrastructure)
2. Ce fichier va-t-il importer quelque chose d'interdit pour cette couche ?
3. Si c'est une implémentation concrète → existe-t-il déjà une interface (port) dans `Domain/Contract/` ?
4. Si c'est une nouvelle interface → est-elle dans `Domain/Contract/` et non dans Infrastructure ?
5. Le binding alias est-il déclaré dans `config/services.yaml` ?
Vérification automatique : `make phparkitect`

View File

@@ -0,0 +1,259 @@
---
name: testing-strategy
description: Stratégie de tests du projet Mangarr — pyramide adaptée à l'archi DDD/Hexa. Tests unitaires purs sur le Domain/Application (sans framework), adapters InMemory, tests fonctionnels API. Utiliser quand on crée ou modifie des tests, ou qu'on discute de la couverture à implémenter.
allowed-tools: Read, Grep, Glob
---
# Stratégie de tests — Mangarr
## Pyramide
```
┌─────────────────────────────┐
│ Tests Fonctionnels (API) │ ← peu nombreux, coûteux
│ tests/Functional/ │ zenstruck/browser + BrowserKit
├─────────────────────────────┤
│ Tests d'Intégration │ ← adapters Doctrine, clients HTTP
│ tests/Domain/*/Adapter/ │ zenstruck/foundry + DAMA
├─────────────────────────────┤
│ Tests Unitaires (Domain) │ ← majorité, rapides, sans framework
│ tests/Domain/*/Application/ │ PHPUnit pur, InMemory adapters
└─────────────────────────────┘
```
---
## 1. Tests Unitaires — Application Layer (CommandHandlers, QueryHandlers)
**Localisation :** `tests/Domain/{Domain}/Application/CommandHandler/` et `QueryHandler/`
**Principe :** Aucune dépendance au framework. On injecte des **adapters InMemory** à la place des vraies implémentations Infrastructure.
### Structure d'un test CommandHandler
```php
// tests/Domain/Manga/Application/CommandHandler/CreateMangaHandlerTest.php
namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\CreateManga;
use App\Domain\Manga\Application\CommandHandler\CreateMangaHandler;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor;
use App\Tests\Shared\Adapter\InMemoryMessageBus;
use PHPUnit\Framework\TestCase;
class CreateMangaHandlerTest extends TestCase
{
private InMemoryMangaRepository $repository;
private CreateMangaHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryMangaRepository();
$this->handler = new CreateMangaHandler(
$this->repository,
new InMemoryImageProcessor(),
new InMemoryMessageBus(),
);
}
public function testHandleSuccess(): void
{
// Arrange
$command = new CreateManga(
title: 'One Piece',
slug: 'one-piece',
// ...
);
// Act
$this->handler->handle($command);
// Assert
$saved = $this->repository->findAll()[0];
$this->assertEquals('One Piece', $saved->getTitle()->getValue());
}
public function testThrowsWhenInvalid(): void
{
$this->expectException(\RuntimeException::class);
$this->handler->handle(new CreateManga(title: '', /* ... */));
}
}
```
### Structure d'un test QueryHandler
```php
// tests/Domain/Manga/Application/QueryHandler/GetMangaByIdHandlerTest.php
class GetMangaByIdHandlerTest extends TestCase
{
private InMemoryMangaRepository $repository;
private GetMangaByIdHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryMangaRepository();
$this->handler = new GetMangaByIdHandler($this->repository);
}
public function testThrowsWhenNotFound(): void
{
$this->expectException(MangaNotFoundException::class);
$this->handler->handle(new GetMangaById('non-existent'));
}
public function testReturnsMappedResponse(): void
{
// Arrange — construire l'Aggregate directement avec Value Objects
$manga = new Manga(
id: new MangaId('123'),
title: new MangaTitle('One Piece'),
// ...
);
$this->repository->save($manga);
// Act
$response = $this->handler->handle(new GetMangaById('123'));
// Assert — vérifier les scalaires du Response
$this->assertEquals('123', $response->id);
$this->assertEquals('One Piece', $response->title);
}
protected function tearDown(): void
{
$this->repository->clear();
}
}
```
---
## 2. Adapters InMemory
**Localisation :** `tests/Domain/{Domain}/Adapter/`
Chaque interface de `Domain/Contract/` a son adapter InMemory dans les tests. Ces adapters stockent les données en mémoire (`array`).
### Structure d'un InMemory Repository
```php
// tests/Domain/Manga/Adapter/InMemoryMangaRepository.php
namespace App\Tests\Domain\Manga\Adapter;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Manga;
class InMemoryMangaRepository implements MangaRepositoryInterface
{
/** @var array<string, Manga> */
private array $mangas = [];
public function save(Manga $manga): void
{
$this->mangas[$manga->getId()->getValue()] = $manga;
}
public function findById(string $id): ?Manga
{
return $this->mangas[$id] ?? null;
}
public function findAll(): array
{
return array_values($this->mangas);
}
public function clear(): void
{
$this->mangas = [];
}
// ... implémenter toutes les méthodes de l'interface
}
```
### Adapters InMemory disponibles (existants)
| Adapter | Interface implémentée |
|----------------------------------|------------------------------------------|
| `InMemoryMangaRepository` | `MangaRepositoryInterface` |
| `InMemoryChapterRepository` | `ChapterRepositoryInterface` |
| `InMemoryImageProcessor` | `ImageProcessorInterface` |
| `InMemoryMangadexClient` | `MangadexClientInterface` |
| `InMemoryMangaProvider` | `MangaProviderInterface` |
| `InMemoryPathManager` | `MangaPathManagerInterface` |
| `InMemoryMessageBus` | `MessageBusInterface` |
Quand on crée une nouvelle interface dans `Domain/Contract/`, **créer l'adapter InMemory correspondant** avant d'écrire les tests.
---
## 3. Tests Fonctionnels API
**Localisation :** `tests/Functional/`
Utilisent `zenstruck/browser` + `BrowserKitBrowser` avec le conteneur Symfony complet. Les données sont gérées par `zenstruck/foundry` (Factories) et `DAMA\DoctrineTestBundle` (rollback automatique après chaque test).
```php
// tests/Functional/SomeEndpointTest.php
namespace App\Tests\Functional;
use Zenstruck\Browser\Test\HasBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class SomeEndpointTest extends WebTestCase
{
use HasBrowser;
public function testGetManga(): void
{
$this->browser()
->get('/api/mangas/by-id/some-uuid')
->assertStatus(200)
->assertJson()
->assertJsonMatches('title', 'One Piece');
}
public function testCreateManga(): void
{
$this->browser()
->post('/api/mangas', [
'json' => ['externalId' => 'abc-123'],
])
->assertStatus(204);
}
}
```
---
## Commandes
```bash
make test # tous les tests
make test f="CreateMangaHandlerTest" # un test par nom de classe
make test c="--group unit" # par groupe
make test c="--stop-on-failure" # s'arrêter au premier échec
```
---
## Checklist par feature
Quand on implémente une nouvelle feature, les tests à écrire dans l'ordre :
1. **Test du CommandHandler/QueryHandler** (unitaire, `TestCase` pur)
- Cas nominal (happy path)
- Cas d'erreur (not found, invalide…)
- Vérification que le repository est bien appelé
2. **Test de la Value Object** si une nouvelle VO est créée
- Validation des invariants (cas invalides)
- `getValue()` retourne la bonne valeur
3. **Test fonctionnel de l'endpoint** (si API Platform)
- Codes HTTP corrects (200, 201, 204, 404…)
- Structure JSON de la réponse
Ne pas tester les Processors/Providers API Platform en unitaire (trop de couplage framework) — les couvrir via les tests fonctionnels.