feat(commercial) : validators M2 fournisseurs (RG-2.03/2.07/2.08/2.10) (ERP-89)
RG inter-champs via Assert\Callback->atPath() sur l'entite Supplier (decision
figee ERP-89), pour un 422 a propertyPath consommable par extractApiViolations :
- RG-2.10 : categories de type FOURNISSEUR (supplier.categories) -> atPath('categories')
- RG-2.07 : VIREMENT impose une banque -> atPath('bank')
- RG-2.08 : LCR impose au moins un RIB -> atPath('ribs')
RG-2.03 (completude Information pour le role Commerciale, detection back via
BusinessRoleAwareInterface) portee par SupplierInformationCompletenessValidator,
invoque par le SupplierProcessor.
Tests : SupplierValidationTest (Callbacks 2.07/2.08/2.10),
SupplierInformationCompletenessValidatorTest, SupplierProcessorTest (RG-2.03).
This commit is contained in:
+79
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Commercial\Application\Validator;
|
||||
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Validator metier RG-2.03 (jumeau du ClientInformationCompletenessValidator M1) :
|
||||
* pour un utilisateur portant le role metier Commerciale, TOUS les champs de
|
||||
* l'onglet Information sont obligatoires sur POST comme sur tout PATCH,
|
||||
* independamment des champs reellement envoyes.
|
||||
*
|
||||
* Invoque par le SupplierProcessor des que l'utilisateur courant porte le role
|
||||
* Commerciale (detection du role cote back). Pour les autres roles, ces champs
|
||||
* restent optionnels — le validator n'est pas appele.
|
||||
*
|
||||
* NEW vs Client : ajoute le champ `volumeForecast` (volume previsionnel),
|
||||
* specifique fournisseur.
|
||||
*
|
||||
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, chaque
|
||||
* violation portant son propertyPath (consommable par extractApiViolations,
|
||||
* ERP-101), par coherence avec les violations Symfony rendues par API Platform.
|
||||
*/
|
||||
final class SupplierInformationCompletenessValidator
|
||||
{
|
||||
public function validate(Supplier $supplier): void
|
||||
{
|
||||
// Map champ -> valeur courante de l'onglet Information.
|
||||
$fields = [
|
||||
'description' => $supplier->getDescription(),
|
||||
'competitors' => $supplier->getCompetitors(),
|
||||
'foundedAt' => $supplier->getFoundedAt(),
|
||||
'employeesCount' => $supplier->getEmployeesCount(),
|
||||
'revenueAmount' => $supplier->getRevenueAmount(),
|
||||
'directorName' => $supplier->getDirectorName(),
|
||||
'profitAmount' => $supplier->getProfitAmount(),
|
||||
'volumeForecast' => $supplier->getVolumeForecast(),
|
||||
];
|
||||
|
||||
$violations = new ConstraintViolationList();
|
||||
|
||||
foreach ($fields as $property => $value) {
|
||||
if ($this->isMissing($value)) {
|
||||
$violations->add(new ConstraintViolation(
|
||||
sprintf('Ce champ est obligatoire pour le rôle Commerciale (champ "%s").', $property),
|
||||
null,
|
||||
[],
|
||||
$supplier,
|
||||
$property,
|
||||
$value,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (count($violations) > 0) {
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les
|
||||
* zeros numeriques (employeesCount = 0, profitAmount = "0.00",
|
||||
* volumeForecast = 0) sont des valeurs valides : on ne les considere pas
|
||||
* manquants.
|
||||
*/
|
||||
private function isMissing(mixed $value): bool
|
||||
{
|
||||
if (null === $value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_string($value) && '' === trim($value);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Fournisseur (M2 Commercial) — entite racine du repertoire fournisseurs,
|
||||
@@ -133,6 +134,20 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/**
|
||||
* RG-2.10 : seules les categories de ce type sont autorisees sur le
|
||||
* fournisseur (entite principale). Miroir de SupplierAddress (ERP-88).
|
||||
* S'appuie sur CategoryInterface::getCategoryTypeCode() (pas d'import du
|
||||
* module Catalog — regle ABSOLUE n°1).
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'FOURNISSEUR';
|
||||
|
||||
/** RG-2.07 : code du type de reglement imposant une banque. */
|
||||
private const string PAYMENT_TYPE_VIREMENT = 'VIREMENT';
|
||||
|
||||
/** RG-2.08 : code du type de reglement imposant au moins un RIB. */
|
||||
private const string PAYMENT_TYPE_LCR = 'LCR';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -280,6 +295,65 @@ class Supplier implements TimestampableInterface, BlamableInterface
|
||||
$this->ribs = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-2.10 : toute categorie posee sur le fournisseur doit etre de type
|
||||
* FOURNISSEUR -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
|
||||
* SupplierAddress::validateCategoryType (ERP-88). S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCode() (pas d'import du module Catalog —
|
||||
* regle ABSOLUE n°1). Joue avant la base via la validation API Platform, sur
|
||||
* POST (categories ∈ supplier:write:main) comme sur PATCH.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||
{
|
||||
foreach ($this->categories as $category) {
|
||||
if ($category instanceof CategoryInterface
|
||||
&& self::REQUIRED_CATEGORY_TYPE_CODE !== $category->getCategoryTypeCode()) {
|
||||
$context->buildViolation('Type de catégorie non autorisé (FOURNISSEUR attendu).')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-2.07 / RG-2.08 : coherence du type de reglement comptable. Decision
|
||||
* figee ERP-89 : ces RG inter-champs passent par une contrainte d'entite
|
||||
* (Assert\Callback + ->atPath()) et NON par le SupplierProcessor, afin que
|
||||
* chaque 422 porte un propertyPath exploitable par extractApiViolations
|
||||
* (mapping inline sous le champ, pas un toast — convention ERP-101).
|
||||
* - RG-2.07 : paymentType = VIREMENT impose une banque -> violation sur `bank`.
|
||||
* - RG-2.08 : paymentType = LCR impose au moins un RIB -> violation sur `ribs`
|
||||
* (le 409 sur DELETE du dernier RIB en LCR est porte par ERP-88).
|
||||
*
|
||||
* Ces champs vivant dans le groupe d'ecriture comptable (absent du POST, qui
|
||||
* n'expose que supplier:write:main), la contrainte ne mord en pratique que
|
||||
* sur le PATCH de l'onglet Comptabilite.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validatePaymentTypeConsistency(ExecutionContextInterface $context): void
|
||||
{
|
||||
$paymentCode = $this->paymentType?->getCode();
|
||||
|
||||
if (self::PAYMENT_TYPE_VIREMENT === $paymentCode && null === $this->bank) {
|
||||
$context->buildViolation('La banque est obligatoire pour le type de règlement Virement.')
|
||||
->atPath('bank')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if (self::PAYMENT_TYPE_LCR === $paymentCode && $this->ribs->isEmpty()) {
|
||||
$context->buildViolation('Au moins un RIB est obligatoire pour le type de règlement LCR.')
|
||||
->atPath('ribs')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
|
||||
+50
-7
@@ -7,7 +7,10 @@ namespace App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Commercial\Application\Service\SupplierFieldNormalizer;
|
||||
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
|
||||
use App\Shared\Domain\Security\BusinessRoles;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -40,14 +43,19 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
* collisions d'unicite en 409 (RG-2.11 doublon de nom ; RG-2.15 conflit de
|
||||
* restauration).
|
||||
*
|
||||
* Hors perimetre ERP-87 (ticket #5 « Validators ») : RG-2.03 (completude
|
||||
* Information pour la Commerciale), RG-2.07 (Virement -> banque), RG-2.08 (LCR ->
|
||||
* RIB), RG-2.10 (categorie de type FOURNISSEUR). Ces regles metier seront
|
||||
* branchees ici via des validators dedies au ticket suivant.
|
||||
* Validators metier (ERP-89). Decision figee : ce processor ne porte QUE
|
||||
* RG-2.03 (completude Information exigee pour le role Commerciale — detection du
|
||||
* role cote back, non exprimable en contrainte d'entite). Les RG inter-champs
|
||||
* RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB) et RG-2.10 (categorie
|
||||
* de type FOURNISSEUR) sont portees par des Assert\Callback + ->atPath() sur
|
||||
* l'entite Supplier (jouees par API Platform AVANT ce processor), pour que
|
||||
* chaque 422 porte un propertyPath consommable par extractApiViolations
|
||||
* (mapping inline, pas un toast — convention ERP-101).
|
||||
*
|
||||
* Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories...)
|
||||
* est jouee par API Platform AVANT ce processor ; on n'y traite donc que les
|
||||
* regles non exprimables en simples contraintes d'attribut.
|
||||
* Note : la validation Symfony (Assert\NotBlank, Assert\Count sur categories,
|
||||
* les Callback RG-2.07/2.08/2.10...) est jouee par API Platform AVANT ce
|
||||
* processor ; on n'y traite donc que les regles non exprimables en simples
|
||||
* contraintes d'entite (RG-2.03, qui depend du role de l'utilisateur courant).
|
||||
*
|
||||
* @implements ProcessorInterface<Supplier, Supplier>
|
||||
*/
|
||||
@@ -94,6 +102,7 @@ final class SupplierProcessor implements ProcessorInterface
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly SupplierFieldNormalizer $normalizer,
|
||||
private readonly SupplierInformationCompletenessValidator $informationValidator,
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly EntityManagerInterface $em,
|
||||
@@ -117,6 +126,8 @@ final class SupplierProcessor implements ProcessorInterface
|
||||
// normalisees des deux cotes (l'etat persiste l'a deja ete).
|
||||
$this->guardManage($data);
|
||||
|
||||
$this->validateInformationCompleteness($data);
|
||||
|
||||
try {
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
@@ -244,6 +255,38 @@ final class SupplierProcessor implements ProcessorInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-2.03 : si l'utilisateur porte le role metier Commerciale, TOUS les
|
||||
* champs de l'onglet Information sont obligatoires sur POST comme sur TOUT
|
||||
* PATCH — independamment des champs reellement envoyes. Garantit qu'un
|
||||
* fournisseur cree/edite par une Commerciale ne reste jamais avec un onglet
|
||||
* Information incomplet. Pour les autres roles, ces champs restent optionnels.
|
||||
*
|
||||
* Consequence (cf. spec § 7, miroir RG-1.04) : le POST n'exposant que
|
||||
* supplier:write:main, une Commerciale obtient 422 sur tout POST tant que
|
||||
* l'Information n'est pas complete -> la completude se fait via les PATCH
|
||||
* supplier:write:information.
|
||||
*/
|
||||
private function validateInformationCompleteness(Supplier $data): void
|
||||
{
|
||||
if ($this->currentUserIsCommerciale()) {
|
||||
$this->informationValidator->validate($data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection du role metier Commerciale cote back (jamais front), via le
|
||||
* contrat BusinessRoleAwareInterface (pas d'import de User — regle ABSOLUE
|
||||
* n°1). Identique au ClientProcessor (M1).
|
||||
*/
|
||||
private function currentUserIsCommerciale(): bool
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
return $user instanceof BusinessRoleAwareInterface
|
||||
&& $user->hasBusinessRole(BusinessRoles::COMMERCIALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Champs « metier » (onglets principal + Information, hors comptabilite et
|
||||
* archivage) dont la valeur courante differe de l'etat persiste. Memes
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Domain\Entity;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierRib;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Validator\Validation;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
/**
|
||||
* Tests des contraintes inter-champs de l'entite Supplier portees par
|
||||
* Assert\Callback (decision figee ERP-89) : RG-2.10 (categorie de type
|
||||
* FOURNISSEUR), RG-2.07 (Virement -> banque), RG-2.08 (LCR -> >= 1 RIB).
|
||||
*
|
||||
* On valide l'entite avec le validator Symfony (mapping par attributs) et on
|
||||
* assert le propertyPath exact de chaque violation (contrat ERP-101 :
|
||||
* exploitable par extractApiViolations). Pas de base : les Callback ne touchent
|
||||
* que des champs en memoire (categories via un double CategoryInterface).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierValidationTest extends TestCase
|
||||
{
|
||||
private ValidatorInterface $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->validator = Validation::createValidatorBuilder()
|
||||
->enableAttributeMapping()
|
||||
->getValidator()
|
||||
;
|
||||
}
|
||||
|
||||
// === RG-2.10 : categories de type FOURNISSEUR ===
|
||||
|
||||
public function testFournisseurCategoryIsAccepted(): void
|
||||
{
|
||||
$supplier = $this->validSupplier();
|
||||
|
||||
self::assertSame([], $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
public function testNonFournisseurCategoryIsRejectedOnCategoriesPath(): void
|
||||
{
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName('Recycla SAS');
|
||||
$supplier->addCategory($this->category('CLIENT'));
|
||||
|
||||
self::assertContains('categories', $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
// === RG-2.07 : Virement impose une banque ===
|
||||
|
||||
public function testVirementWithoutBankIsRejectedOnBankPath(): void
|
||||
{
|
||||
$supplier = $this->validSupplier();
|
||||
$supplier->setPaymentType($this->paymentType('VIREMENT'));
|
||||
|
||||
self::assertContains('bank', $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
public function testVirementWithBankPasses(): void
|
||||
{
|
||||
$supplier = $this->validSupplier();
|
||||
$supplier->setPaymentType($this->paymentType('VIREMENT'));
|
||||
$supplier->setBank(new Bank());
|
||||
|
||||
self::assertNotContains('bank', $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
// === RG-2.08 : LCR impose au moins un RIB ===
|
||||
|
||||
public function testLcrWithoutRibIsRejectedOnRibsPath(): void
|
||||
{
|
||||
$supplier = $this->validSupplier();
|
||||
$supplier->setPaymentType($this->paymentType('LCR'));
|
||||
|
||||
self::assertContains('ribs', $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
public function testLcrWithRibPasses(): void
|
||||
{
|
||||
$supplier = $this->validSupplier();
|
||||
$supplier->setPaymentType($this->paymentType('LCR'));
|
||||
$supplier->addRib(new SupplierRib());
|
||||
|
||||
self::assertNotContains('ribs', $this->violationPaths($supplier));
|
||||
}
|
||||
|
||||
public function testNeutralPaymentTypeRequiresNeitherBankNorRib(): void
|
||||
{
|
||||
// Un type de reglement neutre (ni VIREMENT ni LCR) n'exige ni banque ni RIB.
|
||||
$supplier = $this->validSupplier();
|
||||
$supplier->setPaymentType($this->paymentType('CHEQUE'));
|
||||
|
||||
$paths = $this->violationPaths($supplier);
|
||||
self::assertNotContains('bank', $paths);
|
||||
self::assertNotContains('ribs', $paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fournisseur valide (nom + 1 categorie FOURNISSEUR), sans onglet
|
||||
* Comptabilite renseigne : sert de base aux tests RG-2.07/2.08.
|
||||
*/
|
||||
private function validSupplier(): Supplier
|
||||
{
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName('Recycla SAS');
|
||||
$supplier->addCategory($this->category('FOURNISSEUR'));
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> propertyPaths des violations levees par le validator
|
||||
*/
|
||||
private function violationPaths(Supplier $supplier): array
|
||||
{
|
||||
$paths = [];
|
||||
foreach ($this->validator->validate($supplier) as $violation) {
|
||||
$paths[] = $violation->getPropertyPath();
|
||||
}
|
||||
|
||||
return $paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Double minimal de CategoryInterface (pas d'acces base) renvoyant le code de
|
||||
* type de categorie voulu — seul element regarde par validateCategoryType.
|
||||
*/
|
||||
private function category(string $typeCode): CategoryInterface
|
||||
{
|
||||
return new class($typeCode) implements CategoryInterface {
|
||||
public function __construct(private readonly string $typeCode) {}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
return 'Categorie test';
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return 'TEST';
|
||||
}
|
||||
|
||||
public function getCategoryTypeCode(): ?string
|
||||
{
|
||||
return $this->typeCode;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function paymentType(string $code): PaymentType
|
||||
{
|
||||
$type = new PaymentType();
|
||||
$type->setCode($code);
|
||||
$type->setLabel($code);
|
||||
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Unit;
|
||||
|
||||
use ApiPlatform\Validator\Exception\ValidationException;
|
||||
use App\Module\Commercial\Application\Validator\SupplierInformationCompletenessValidator;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires du SupplierInformationCompletenessValidator (RG-2.03) : pour le
|
||||
* role Commerciale, TOUS les champs de l'onglet Information sont obligatoires.
|
||||
* Chaque champ manquant produit une violation portant son propertyPath (ERP-101).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierInformationCompletenessValidatorTest extends TestCase
|
||||
{
|
||||
public function testCompleteInformationPasses(): void
|
||||
{
|
||||
$supplier = $this->completeSupplier();
|
||||
|
||||
$this->validator()->validate($supplier);
|
||||
|
||||
// Aucune exception levee : la completude est satisfaite.
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testEmptyInformationListsEveryMissingField(): void
|
||||
{
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName('Recycla SAS'); // onglet principal, hors Information
|
||||
|
||||
try {
|
||||
$this->validator()->validate($supplier);
|
||||
self::fail('Une ValidationException etait attendue (onglet Information vide).');
|
||||
} catch (ValidationException $e) {
|
||||
$paths = [];
|
||||
foreach ($e->getConstraintViolationList() as $violation) {
|
||||
$paths[] = $violation->getPropertyPath();
|
||||
}
|
||||
|
||||
// Les 8 champs Information (dont volumeForecast, NEW vs Client) sont
|
||||
// tous signales d'un coup, chacun sous son propre propertyPath.
|
||||
sort($paths);
|
||||
self::assertSame([
|
||||
'competitors',
|
||||
'description',
|
||||
'directorName',
|
||||
'employeesCount',
|
||||
'foundedAt',
|
||||
'profitAmount',
|
||||
'revenueAmount',
|
||||
'volumeForecast',
|
||||
], $paths);
|
||||
}
|
||||
}
|
||||
|
||||
public function testPartialInformationReportsOnlyMissingFields(): void
|
||||
{
|
||||
$supplier = $this->completeSupplier();
|
||||
$supplier->setDirectorName(null);
|
||||
$supplier->setVolumeForecast(null);
|
||||
|
||||
try {
|
||||
$this->validator()->validate($supplier);
|
||||
self::fail('Une ValidationException etait attendue (2 champs manquants).');
|
||||
} catch (ValidationException $e) {
|
||||
$paths = [];
|
||||
foreach ($e->getConstraintViolationList() as $violation) {
|
||||
$paths[] = $violation->getPropertyPath();
|
||||
}
|
||||
|
||||
sort($paths);
|
||||
self::assertSame(['directorName', 'volumeForecast'], $paths);
|
||||
}
|
||||
}
|
||||
|
||||
public function testZeroNumericValuesAreNotMissing(): void
|
||||
{
|
||||
// employeesCount = 0, profitAmount = "0.00", volumeForecast = 0 sont des
|
||||
// valeurs valides (un zero n'est pas une absence) -> pas de violation.
|
||||
$supplier = $this->completeSupplier();
|
||||
$supplier->setEmployeesCount(0);
|
||||
$supplier->setProfitAmount('0.00');
|
||||
$supplier->setVolumeForecast(0);
|
||||
|
||||
$this->validator()->validate($supplier);
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function testBlankStringIsMissing(): void
|
||||
{
|
||||
// Une chaine vide apres trim compte comme manquante.
|
||||
$supplier = $this->completeSupplier();
|
||||
$supplier->setDescription(' ');
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
$this->validator()->validate($supplier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fournisseur dont l'onglet Information est entierement renseigne.
|
||||
*/
|
||||
private function completeSupplier(): Supplier
|
||||
{
|
||||
$supplier = new Supplier();
|
||||
$supplier->setCompanyName('Recycla SAS');
|
||||
$supplier->setDescription('Specialiste du recyclage');
|
||||
$supplier->setCompetitors('Concurrent A, Concurrent B');
|
||||
$supplier->setFoundedAt(new DateTimeImmutable('2010-01-01'));
|
||||
$supplier->setEmployeesCount(42);
|
||||
$supplier->setRevenueAmount('1000000.00');
|
||||
$supplier->setDirectorName('Marie Durand');
|
||||
$supplier->setProfitAmount('150000.00');
|
||||
$supplier->setVolumeForecast(5000);
|
||||
|
||||
return $supplier;
|
||||
}
|
||||
|
||||
private function validator(): SupplierInformationCompletenessValidator
|
||||
{
|
||||
return new SupplierInformationCompletenessValidator();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user