feat(commercial) : validators M2 fournisseurs (RG-2.03/2.07/2.08/2.10) (ERP-89) (#68)
Auto Tag Develop / tag (push) Successful in 7s

Étape 4/7 du M2 fournisseurs — stackée sur #67 (ERP-88).

## Périmètre (RG-2.03 / 2.07 / 2.08 / 2.10)

Décision figée ERP-89 : les RG inter-champs passent par `Assert\Callback` + `->atPath()` sur l'entité Supplier (et non dans le Processor), pour que chaque 422 porte un `propertyPath` consommable par `extractApiViolations` (mapping inline, pas un toast — ERP-101).

- **RG-2.10** — `Supplier::validateCategoryType()` → `atPath('categories')` : catégories de type FOURNISSEUR uniquement sur `supplier.categories` (miroir d'ERP-88 côté adresse).
- **RG-2.07** — `Supplier::validatePaymentTypeConsistency()` → `atPath('bank')` : VIREMENT impose une banque.
- **RG-2.08** — même Callback → `atPath('ribs')` : LCR impose ≥ 1 RIB (le 409 sur DELETE du dernier RIB en LCR reste porté par ERP-88).
- **RG-2.03** — `SupplierInformationCompletenessValidator` (8 champs Information dont `volumeForecast`), invoqué par le `SupplierProcessor` après détection back du rôle Commerciale via `BusinessRoleAwareInterface`. Le Processor ne porte que rôle / mode strict / gating.

## Tests (16, verts)

- `SupplierValidationTest` — Callbacks RG-2.07/2.08/2.10, assertion par propertyPath.
- `SupplierInformationCompletenessValidatorTest` — complétude / champs manquants / zéros valides.
- `SupplierProcessorTest` — détection rôle RG-2.03 (POST + PATCH main-only + non-Commerciale).

`make test` : 499 tests OK. `php-cs-fixer` : clean.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #68
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #68.
This commit is contained in:
2026-06-08 07:33:38 +00:00
committed by admin malio
parent 145d4362db
commit e265a008bc
6 changed files with 703 additions and 7 deletions
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Unit;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\SupplierProcessor;
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
use App\Shared\Domain\Security\BusinessRoles;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* Tests unitaires du SupplierProcessor — perimetre ERP-89 : detection du role
* Commerciale cote back (RG-2.03). Les autres responsabilites du processor
* (gating accounting / archive / mode strict) sont heritees d'ERP-87 et testees
* a leur niveau ; les RG inter-champs (RG-2.07/2.08/2.10) sont des contraintes
* d'entite (cf. SupplierValidationTest), non portees ici.
*
* @internal
*/
final class SupplierProcessorTest extends TestCase
{
public function testCommercialeIncompleteInformationIsUnprocessable(): void
{
// RG-2.03 : role Commerciale + onglet Information incomplet -> 422, meme
// sur un POST (les champs Information n'y sont pas renseignables).
$supplier = $this->minimalSupplier();
$supplier->setDescription('Une description'); // les autres champs Information restent null
$processor = $this->makeProcessor(
payload: ['description' => 'Une description'],
user: $this->commercialeUser(),
);
$this->expectException(ValidationException::class);
$processor->process($supplier, $this->operation());
}
public function testCommercialeIncompleteInformationOnMainOnlyPatchIsUnprocessable(): void
{
// RG-2.03 : pour une Commerciale, la completude Information est exigee
// meme quand le payload ne touche PAS l'onglet Information (ici
// companyName seul) -> 422.
$supplier = $this->minimalSupplier();
$supplier->setCompanyName('Renamed Co');
$processor = $this->makeProcessor(
granted: ['commercial.suppliers.manage'],
payload: ['companyName' => 'Renamed Co'],
user: $this->commercialeUser(),
managed: true,
originalData: [
'companyName' => 'TEST CO',
'isArchived' => false,
],
);
$this->expectException(ValidationException::class);
$processor->process($supplier, $this->operation());
}
public function testCommercialeCompleteInformationPasses(): void
{
// RG-2.03 satisfaite : tous les champs Information renseignes -> 200.
$supplier = $this->completeInformationSupplier();
$processor = $this->makeProcessor(
granted: ['commercial.suppliers.manage'],
payload: ['description' => 'desc'],
user: $this->commercialeUser(),
);
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
}
public function testNonCommercialeSkipsInformationCompleteness(): void
{
// Meme onglet Information incomplet, mais user non-Commerciale -> aucun
// blocage (la completude est specifique a la Commerciale).
$supplier = $this->minimalSupplier();
$supplier->setDescription('Une description');
$processor = $this->makeProcessor(
payload: ['description' => 'Une description'],
user: null,
);
self::assertInstanceOf(Supplier::class, $processor->process($supplier, $this->operation()));
}
/**
* @param list<string> $granted Permissions accordees a l'utilisateur courant
* @param array<string, mixed> $payload Corps JSON simule de la requete
* @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST)
* @param array<string, mixed> $originalData Etat persiste simule (getOriginalEntityData)
*/
private function makeProcessor(
array $granted = [],
array $payload = [],
?UserInterface $user = null,
bool $managed = false,
array $originalData = [],
): SupplierProcessor {
$persist = new class implements ProcessorInterface {
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
return $data;
}
};
$security = $this->createStub(Security::class);
$security->method('isGranted')->willReturnCallback(
static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true),
);
$security->method('getUser')->willReturn($user);
$requestStack = new RequestStack();
$requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR)));
$uow = $this->createMock(UnitOfWork::class);
$uow->method('getOriginalEntityData')->willReturn($originalData);
$em = $this->createMock(EntityManagerInterface::class);
$em->method('contains')->willReturn($managed);
$em->method('getUnitOfWork')->willReturn($uow);
return new SupplierProcessor(
$persist,
new SupplierFieldNormalizer(),
new SupplierInformationCompletenessValidator(),
$security,
$requestStack,
$em,
);
}
private function minimalSupplier(): Supplier
{
$supplier = new Supplier();
$supplier->setCompanyName('Test Co');
return $supplier;
}
private function completeInformationSupplier(): Supplier
{
$supplier = $this->minimalSupplier();
$supplier->setDescription('desc');
$supplier->setCompetitors('concurrents');
$supplier->setFoundedAt(new DateTimeImmutable('2010-01-01'));
$supplier->setEmployeesCount(10);
$supplier->setRevenueAmount('1000.00');
$supplier->setDirectorName('Marie Durand');
$supplier->setProfitAmount('100.00');
$supplier->setVolumeForecast(500);
return $supplier;
}
private function operation(): Operation
{
return $this->createStub(Operation::class);
}
private function commercialeUser(): UserInterface
{
return new class implements UserInterface, BusinessRoleAwareInterface {
public function hasBusinessRole(string $roleCode): bool
{
return BusinessRoles::COMMERCIALE === $roleCode;
}
public function getRoles(): array
{
return ['ROLE_USER'];
}
public function eraseCredentials(): void {}
public function getUserIdentifier(): string
{
return 'commerciale-test';
}
};
}
}