fix(commercial) : corrections ajout fournisseur — addressType en select, 422 inline (addressType/catégorie/compta complète/LCR sur paymentType), Information facultative (RG-2.03 retirée, miroir client) (ERP-94)

This commit is contained in:
2026-06-09 23:36:39 +02:00
parent 556b7026da
commit e2ad17820b
16 changed files with 239 additions and 598 deletions
@@ -1,129 +0,0 @@
<?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();
}
}
@@ -1,244 +0,0 @@
<?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()));
}
public function testAdminIncompleteInformationPasses(): void
{
// Distinct du cas user=null : un utilisateur AUTHENTIFIE mais non-Commerciale
// (ici un admin, BusinessRoleAwareInterface renvoyant false pour tout role
// metier) n'est pas soumis a la completude Information -> 200 malgre un
// onglet Information incomplet. Prouve que le gate porte bien sur le ROLE
// metier Commerciale, et pas sur « il y a un utilisateur connecte ».
$supplier = $this->minimalSupplier();
$supplier->setDescription('Une description');
$processor = $this->makeProcessor(
payload: ['description' => 'Une description'],
user: $this->adminUser(),
);
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);
}
/**
* Utilisateur authentifie non-Commerciale (profil admin) : porte
* BusinessRoleAwareInterface mais ne reconnait aucun role metier. Sert a
* distinguer « pas de role Commerciale » de « pas d'utilisateur » (null).
*/
private function adminUser(): UserInterface
{
return new class implements UserInterface, BusinessRoleAwareInterface {
public function hasBusinessRole(string $roleCode): bool
{
return false;
}
public function getRoles(): array
{
return ['ROLE_ADMIN'];
}
public function eraseCredentials(): void {}
public function getUserIdentifier(): string
{
return 'admin-test';
}
};
}
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';
}
};
}
}