feat: ajout de claude + correction des tests
All checks were successful
Build and Deploy / deploy (push) Successful in 9m36s
All checks were successful
Build and Deploy / deploy (push) Successful in 9m36s
This commit is contained in:
parent
b5a832fbbc
commit
dae215dd3d
208
.claude/skills/api-platform/SKILL.md
Normal file
208
.claude/skills/api-platform/SKILL.md
Normal 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(...))
|
||||||
|
```
|
||||||
208
.claude/skills/cqrs/SKILL.md
Normal file
208
.claude/skills/cqrs/SKILL.md
Normal 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.
|
||||||
144
.claude/skills/ddd-core/SKILL.md
Normal file
144
.claude/skills/ddd-core/SKILL.md
Normal 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`
|
||||||
139
.claude/skills/hexagonal-arch/SKILL.md
Normal file
139
.claude/skills/hexagonal-arch/SKILL.md
Normal 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`
|
||||||
259
.claude/skills/testing-strategy/SKILL.md
Normal file
259
.claude/skills/testing-strategy/SKILL.md
Normal 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.
|
||||||
110
CLAUDE.md
Normal file
110
CLAUDE.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Mangarr is a Symfony 7.0 manga management/reader application. It scrapes manga chapters from various sources, stores them as CBZ files, and provides a reader interface. It runs on FrankenPHP inside Docker.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
All commands run via Docker through the Makefile. Use `make help` to see all available targets.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make start # Start Docker containers
|
||||||
|
make stop # Stop containers
|
||||||
|
make install # Build images, start containers, install deps (first-time setup)
|
||||||
|
make logs # Follow container logs
|
||||||
|
make sh # Shell into the PHP container
|
||||||
|
```
|
||||||
|
|
||||||
|
**PHP / Symfony:**
|
||||||
|
```bash
|
||||||
|
make sf c="about" # Run any Symfony console command
|
||||||
|
make cc # Clear cache
|
||||||
|
make vendor # Install Composer dependencies
|
||||||
|
make migration-migrate # Run pending migrations
|
||||||
|
make fixtures-load # Load fixtures (drops and recreates data)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
```bash
|
||||||
|
make test # Run all tests
|
||||||
|
make test f="ScrapeChapterHandlerTest" # Run a specific test by class name
|
||||||
|
make test c="--group e2e" # Pass phpunit options
|
||||||
|
```
|
||||||
|
|
||||||
|
**Code Quality:**
|
||||||
|
```bash
|
||||||
|
make phpcs # Fix code style (PSR-12 via php-cs-fixer)
|
||||||
|
make phpmd # Run PHP Mess Detector
|
||||||
|
make quality # Run both phpmd and phpcs
|
||||||
|
make phparkitect # Check architectural rules
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
make npm-run # Build assets once (dev)
|
||||||
|
make npm-watch # Watch and rebuild assets
|
||||||
|
make npm-add p=pkg # Add an npm dependency
|
||||||
|
```
|
||||||
|
|
||||||
|
**Messenger workers** (run in separate terminals):
|
||||||
|
```bash
|
||||||
|
make consume-commands # Process command.bus messages
|
||||||
|
make consume-events # Process domain events
|
||||||
|
make consume-schedule # Process scheduled tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The project uses **Domain-Driven Design** with strict layer separation enforced by PHPArkitect (`phparkitect.php`).
|
||||||
|
|
||||||
|
### Domain Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/Domain/
|
||||||
|
{DomainName}/
|
||||||
|
Domain/ # Pure domain: Models, Contracts (interfaces), Events, Exceptions
|
||||||
|
Application/ # Use cases: Commands, Queries, CommandHandlers, QueryHandlers, Responses
|
||||||
|
Infrastructure/ # Framework: Persistence, API Platform State, Clients, Services
|
||||||
|
Shared/ # Cross-domain contracts and infrastructure (MangaPathManagerInterface, EventDispatcherInterface, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Business domains:** `Manga`, `Reader`, `Scraping`, `Conversion`, `Setting` (+ `Shared`)
|
||||||
|
|
||||||
|
**Architectural rules enforced:**
|
||||||
|
- `Domain` layer has no outside dependencies (only std exceptions)
|
||||||
|
- `Application` layer may depend on its own Domain + `App\Domain\Shared\Domain\Contract` + `Symfony\Messenger` + `Ramsey\Uuid`; never on Infrastructure
|
||||||
|
- `Shared` depends on nothing outside itself
|
||||||
|
|
||||||
|
### Outside Domain (`src/`)
|
||||||
|
|
||||||
|
- `src/Entity/` — Doctrine ORM entities (legacy, used by repositories)
|
||||||
|
- `src/Controller/` — Symfony HTTP controllers
|
||||||
|
- `src/ApiResource/` — API Platform resource definitions + `OpenApiFactoryDecorator`
|
||||||
|
- `src/Service/` — Legacy services (being migrated into Domain)
|
||||||
|
- `src/Message/` + `src/MessageHandler/` — Legacy Messenger messages (outside DDD)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- `assets/controllers/` — Stimulus controllers (one per UI interaction)
|
||||||
|
- `assets/vue/app/` — Vue.js SPA mounted at `/vue/*`
|
||||||
|
- Tailwind CSS via PostCSS, bundled with Webpack Encore
|
||||||
|
- Mercure for real-time updates (queue status, download progress)
|
||||||
|
|
||||||
|
### Key Infrastructure
|
||||||
|
|
||||||
|
- **Database:** PostgreSQL 16 via Doctrine ORM; Adminer on port 8080
|
||||||
|
- **Scraping:** `scrapers.json` defines per-site CSS selectors; `HtmlScraper` and `JavascriptScraper` (Panther) strategies
|
||||||
|
- **File storage:** CBZ files stored at `MANGA_DATA_PATH` (default `~/Mangas`); images at `IMAGE_DATA_PATH`
|
||||||
|
- **External API:** MangaDex client for metadata (`MANGADEX_CLIENT_ID/SECRET/USERNAME/PASSWORD` env vars)
|
||||||
|
- **Messenger buses:** `command.bus` (sync commands), `events` transport, `commands` transport, `async` transport (scheduler)
|
||||||
|
|
||||||
|
### Adding a New Domain Feature
|
||||||
|
|
||||||
|
1. Define contracts (interfaces) in `Domain/{Name}/Domain/Contract/`
|
||||||
|
2. Write Command/Query + Handler in `Domain/{Name}/Application/`
|
||||||
|
3. Implement interfaces in `Domain/{Name}/Infrastructure/`
|
||||||
|
4. Register infrastructure aliases in `config/services.yaml`
|
||||||
|
5. Run `make phparkitect` to validate layer boundaries
|
||||||
@@ -21,7 +21,8 @@ class ImportChapterTest extends WebTestCase
|
|||||||
'mangaId' => 'non-existent-manga-id',
|
'mangaId' => 'non-existent-manga-id',
|
||||||
'chapterNumber' => '1.5'
|
'chapterNumber' => '1.5'
|
||||||
],
|
],
|
||||||
['file' => $file]
|
['file' => $file],
|
||||||
|
['CONTENT_TYPE' => 'multipart/form-data']
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(404);
|
$this->assertResponseStatusCodeSame(404);
|
||||||
@@ -40,7 +41,8 @@ class ImportChapterTest extends WebTestCase
|
|||||||
[
|
[
|
||||||
'chapterNumber' => '1.5'
|
'chapterNumber' => '1.5'
|
||||||
],
|
],
|
||||||
['file' => $file]
|
['file' => $file],
|
||||||
|
['CONTENT_TYPE' => 'multipart/form-data']
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(422);
|
$this->assertResponseStatusCodeSame(422);
|
||||||
@@ -59,7 +61,8 @@ class ImportChapterTest extends WebTestCase
|
|||||||
[
|
[
|
||||||
'mangaId' => 'some-manga-id'
|
'mangaId' => 'some-manga-id'
|
||||||
],
|
],
|
||||||
['file' => $file]
|
['file' => $file],
|
||||||
|
['CONTENT_TYPE' => 'multipart/form-data']
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(422);
|
$this->assertResponseStatusCodeSame(422);
|
||||||
@@ -77,7 +80,9 @@ class ImportChapterTest extends WebTestCase
|
|||||||
[
|
[
|
||||||
'mangaId' => 'some-manga-id',
|
'mangaId' => 'some-manga-id',
|
||||||
'chapterNumber' => '1.5'
|
'chapterNumber' => '1.5'
|
||||||
]
|
],
|
||||||
|
[],
|
||||||
|
['CONTENT_TYPE' => 'multipart/form-data']
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(422);
|
$this->assertResponseStatusCodeSame(422);
|
||||||
@@ -89,10 +94,9 @@ class ImportChapterTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
// Create a non-CBZ file
|
|
||||||
$tempFile = tempnam(sys_get_temp_dir(), 'test_');
|
$tempFile = tempnam(sys_get_temp_dir(), 'test_');
|
||||||
file_put_contents($tempFile, 'This is not a CBZ file');
|
file_put_contents($tempFile, 'This is not a CBZ file');
|
||||||
$file = new UploadedFile($tempFile, 'test.txt', 'text/plain');
|
$file = new UploadedFile($tempFile, 'test.txt', 'text/plain', null, true);
|
||||||
|
|
||||||
$client->request(
|
$client->request(
|
||||||
'POST',
|
'POST',
|
||||||
@@ -101,7 +105,8 @@ class ImportChapterTest extends WebTestCase
|
|||||||
'mangaId' => 'some-manga-id',
|
'mangaId' => 'some-manga-id',
|
||||||
'chapterNumber' => '1.5'
|
'chapterNumber' => '1.5'
|
||||||
],
|
],
|
||||||
['file' => $file]
|
['file' => $file],
|
||||||
|
['CONTENT_TYPE' => 'multipart/form-data']
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(422);
|
$this->assertResponseStatusCodeSame(422);
|
||||||
@@ -122,10 +127,6 @@ class ImportChapterTest extends WebTestCase
|
|||||||
$zip->addFromString('image1.jpg', 'fake-image-data');
|
$zip->addFromString('image1.jpg', 'fake-image-data');
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
return new UploadedFile($tmpFile, 'test-chapter.cbz', 'application/x-cbz');
|
return new UploadedFile($tmpFile, 'test-chapter.cbz', 'application/x-cbz', null, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ class ImportVolumeTest extends WebTestCase
|
|||||||
'mangaId' => 'non-existent-manga-id',
|
'mangaId' => 'non-existent-manga-id',
|
||||||
'volumeNumber' => '1'
|
'volumeNumber' => '1'
|
||||||
],
|
],
|
||||||
['file' => $file]
|
['file' => $file],
|
||||||
|
['CONTENT_TYPE' => 'multipart/form-data']
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(404);
|
$this->assertResponseStatusCodeSame(404);
|
||||||
@@ -40,7 +41,8 @@ class ImportVolumeTest extends WebTestCase
|
|||||||
[
|
[
|
||||||
'volumeNumber' => '1'
|
'volumeNumber' => '1'
|
||||||
],
|
],
|
||||||
['file' => $file]
|
['file' => $file],
|
||||||
|
['CONTENT_TYPE' => 'multipart/form-data']
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(422);
|
$this->assertResponseStatusCodeSame(422);
|
||||||
@@ -59,7 +61,8 @@ class ImportVolumeTest extends WebTestCase
|
|||||||
[
|
[
|
||||||
'mangaId' => 'some-manga-id'
|
'mangaId' => 'some-manga-id'
|
||||||
],
|
],
|
||||||
['file' => $file]
|
['file' => $file],
|
||||||
|
['CONTENT_TYPE' => 'multipart/form-data']
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(422);
|
$this->assertResponseStatusCodeSame(422);
|
||||||
@@ -77,7 +80,9 @@ class ImportVolumeTest extends WebTestCase
|
|||||||
[
|
[
|
||||||
'mangaId' => 'some-manga-id',
|
'mangaId' => 'some-manga-id',
|
||||||
'volumeNumber' => '1'
|
'volumeNumber' => '1'
|
||||||
]
|
],
|
||||||
|
[],
|
||||||
|
['CONTENT_TYPE' => 'multipart/form-data']
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(422);
|
$this->assertResponseStatusCodeSame(422);
|
||||||
@@ -89,10 +94,9 @@ class ImportVolumeTest extends WebTestCase
|
|||||||
{
|
{
|
||||||
$client = static::createClient();
|
$client = static::createClient();
|
||||||
|
|
||||||
// Create a non-CBZ file
|
|
||||||
$tempFile = tempnam(sys_get_temp_dir(), 'test_');
|
$tempFile = tempnam(sys_get_temp_dir(), 'test_');
|
||||||
file_put_contents($tempFile, 'This is not a CBZ file');
|
file_put_contents($tempFile, 'This is not a CBZ file');
|
||||||
$file = new UploadedFile($tempFile, 'test.txt', 'text/plain');
|
$file = new UploadedFile($tempFile, 'test.txt', 'text/plain', null, true);
|
||||||
|
|
||||||
$client->request(
|
$client->request(
|
||||||
'POST',
|
'POST',
|
||||||
@@ -101,7 +105,8 @@ class ImportVolumeTest extends WebTestCase
|
|||||||
'mangaId' => 'some-manga-id',
|
'mangaId' => 'some-manga-id',
|
||||||
'volumeNumber' => '1'
|
'volumeNumber' => '1'
|
||||||
],
|
],
|
||||||
['file' => $file]
|
['file' => $file],
|
||||||
|
['CONTENT_TYPE' => 'multipart/form-data']
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertResponseStatusCodeSame(422);
|
$this->assertResponseStatusCodeSame(422);
|
||||||
@@ -122,10 +127,6 @@ class ImportVolumeTest extends WebTestCase
|
|||||||
$zip->addFromString('image1.jpg', 'fake-image-data');
|
$zip->addFromString('image1.jpg', 'fake-image-data');
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
return new UploadedFile($tmpFile, 'test-volume.cbz', 'application/x-cbz');
|
return new UploadedFile($tmpFile, 'test-volume.cbz', 'application/x-cbz', null, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user