9cda225bdf
Auto Tag Develop / tag (push) Successful in 8s
Correctifs issus de la review lead du stack M2 fournisseurs (ERP-84→113), répartis en priorités. Base : `develop`. Suite verte : `make test` 577 tests / 2475 assertions, `php-cs-fixer` 0 correction. ## P1 — défauts bloquants - **ERP-89** — Le message de complétude Information ne fuit plus le nom de champ technique (`(champ "%s")` retiré). Correction miroir appliquée aux deux validators (Supplier + Client), accent uniformisé. Le `propertyPath` est conservé pour le mapping inline front. - **ERP-112** — La fixture fournisseurs résout désormais la catégorie en filtrant sur le type `FOURNISSEUR` (via `CategoryInterface::getCategoryTypeCode()`), évitant de rattacher une catégorie homonyme d'un autre type (RG-2.10). - **ERP-113** — Tests d'export complétés : dédup F3 (fournisseur multi-catégories rendu sur une seule ligne) ; gating SIREN prouvé via un utilisateur minimal non-admin portant `suppliers.view` + `suppliers.accounting.view` (nouveau helper `createUserWithPermissions`). ## P2 / P3 - **ERP-86** — `maxMessage` explicite sur `competitors` (Supplier). - **ERP-92** — Garde `skipIfSitesModuleDisabled()` sur le test POST adresse sans site (évite un faux positif si le module Sites est désactivé). - **ERP-89 bis** — Nouveau test : Admin authentifié non-Commerciale + Information incomplète → 200 (distinct du cas `user=null`). - **ERP-85** — `down()` de la migration fournisseurs en `DROP TABLE IF EXISTS`. - **ERP-87** — Reset de la mémoïsation payload en début de `process()` du SupplierProcessor + documentation du filtre `?archivedOnly` de l'export (parité avec le provider liste). - **spec-back.md (M2)** — Alignée sur le code (le code fait foi) : security PATCH `manage or accounting.manage`, gating accounting par ajout de groupe (`SupplierReadGroupContextBuilder`), anti-N+1 via `hydrateListCollections` (pas de fetch-join), types de colonnes réels (`IDENTITY` / `TIMESTAMP(0)`). ## Alignement M1 ↔ M2 - **ERP-86/87 (Client)** — Mêmes corrections appliquées aux jumeaux M1 : message `competitors` explicite + reset mémoïsation `ClientProcessor`. ## Décision actée - **RG-2.10 (catégorie)** : court-circuit conservé (une seule violation sur `categories`). Les violations partageant path + message sont fusionnées côté front ; ERP-101 (toutes les erreurs en un aller-retour) est déjà respecté car le Callback n'interrompt pas la validation des autres champs. --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #74 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
245 lines
8.8 KiB
PHP
245 lines
8.8 KiB
PHP
<?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';
|
|
}
|
|
};
|
|
}
|
|
}
|