feat(commercial) : add Client API Platform provider + processor + business rules
Branche l'API REST du repertoire clients (M1) sur l'entite Client preparee en ERP-54. Operations GetCollection / Get / Post / Patch (pas de Delete au M1 : l'archivage passe par PATCH isArchived). ClientProvider : - liste paginee (Paginator ORM, aligne sur la convention ERP-72) + echappatoire ?pagination=false - exclut archives + soft-deletes par defaut (RG-1.24), ?includeArchived=true reintegre les archives (RG-1.25) - tri companyName ASC (RG-1.26), filtres ?search (fuzzy companyName/lastName/ email) et ?categoryType=<code> - detail : 404 sur soft-delete, embarque contacts/adresses/ribs ClientProcessor : - normalisation serveur via ClientFieldNormalizer (RG-1.18 a 1.21) - 409 sur doublon de nom de societe (RG-1.16) ; 409 dedie sur conflit de restauration (RG-1.23) - gating par onglet : champ comptable -> accounting.manage, isArchived -> archive, mode strict 403 sur tout le payload (RG-1.28) ; archivage exclusif (RG-1.22) + pose/retrait archivedAt - regles metier RG-1.01 (prenom/nom), RG-1.03 (distributor/broker exclusifs + controle du type de categorie), RG-1.12 (Virement -> banque), RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information pour le role Commerciale) Lecture comptable conditionnelle : ClientReadGroupContextBuilder ajoute le groupe client:read:accounting selon commercial.clients.accounting.view. Resolution des references categorie : CategoryReferenceDenormalizer resout les IRI vers Category quand la propriete est type-hintee par le contrat CategoryInterface (denormalisation impossible sur une interface sinon). Contrats Shared : - CategoryInterface::getCategoryTypeCode() (implemente par Category) pour la verification de type sans import inter-modules - BusinessRoleAwareInterface (implemente par User) + BusinessRoles::COMMERCIALE pour detecter le role metier ; le code de role sera seede par ERP-74 et reutilise par ERP-59/60. RG-1.04 reste dormante tant qu'aucun user ne porte ce role. Coordination stack : - chaines de permission commercial.clients.* referencees ici, declarees en ERP-59 (tests RBAC complets en ERP-60) - config globale de pagination (itemsPerPage client, max 50) portee par ERP-72 - referentiels comptables (PaymentType/Bank/...) exposes en ERP-56 Tests : 31 tests Commercial (integration admin sur les regles metier + unitaires sur le gating, RG-1.04/1.12/1.13 et le context builder). Suite complete verte (339 tests).
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Unit;
|
||||
|
||||
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests unitaires de la normalisation serveur (RG-1.18 a RG-1.21).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientFieldNormalizerTest extends TestCase
|
||||
{
|
||||
private ClientFieldNormalizer $normalizer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->normalizer = new ClientFieldNormalizer();
|
||||
}
|
||||
|
||||
public function testCompanyNameIsUppercased(): void
|
||||
{
|
||||
// RG-1.18
|
||||
self::assertSame('ACME SAS', $this->normalizer->normalizeCompanyName(' acme sas '));
|
||||
self::assertNull($this->normalizer->normalizeCompanyName(null));
|
||||
}
|
||||
|
||||
public function testPersonNameIsTitleCased(): void
|
||||
{
|
||||
// RG-1.19
|
||||
self::assertSame('Jean', $this->normalizer->normalizePersonName('JEAN'));
|
||||
self::assertSame('Dupont', $this->normalizer->normalizePersonName('dupont'));
|
||||
self::assertNull($this->normalizer->normalizePersonName(' '));
|
||||
self::assertNull($this->normalizer->normalizePersonName(null));
|
||||
}
|
||||
|
||||
public function testEmailIsLowercased(): void
|
||||
{
|
||||
// RG-1.21
|
||||
self::assertSame('jean.dupont@acme.fr', $this->normalizer->normalizeEmail(' Jean.DUPONT@ACME.FR '));
|
||||
self::assertNull($this->normalizer->normalizeEmail(null));
|
||||
self::assertNull($this->normalizer->normalizeEmail(' '));
|
||||
}
|
||||
|
||||
public function testPhoneKeepsOnlyDigits(): void
|
||||
{
|
||||
// RG-1.20
|
||||
self::assertSame('0612345678', $this->normalizer->normalizePhone('06.12.34.56.78'));
|
||||
self::assertSame('0612345678', $this->normalizer->normalizePhone('06 12 34 56 78'));
|
||||
self::assertNull($this->normalizer->normalizePhone('----'));
|
||||
self::assertNull($this->normalizer->normalizePhone(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?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\ClientFieldNormalizer;
|
||||
use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator;
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
|
||||
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
|
||||
use App\Shared\Domain\Security\BusinessRoles;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Tests unitaires du ClientProcessor : gating par permission (accounting.manage
|
||||
* / archive / RG-1.28 strict) et regles metier non testables en HTTP admin
|
||||
* (RG-1.04 Commerciale, RG-1.12 Virement, RG-1.13 LCR), grace a un Security et
|
||||
* un RequestStack stubbes.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientProcessorTest extends TestCase
|
||||
{
|
||||
public function testAccountingFieldWithoutPermissionIsForbidden(): void
|
||||
{
|
||||
// RG-1.28 : un champ comptable sans accounting.manage -> 403.
|
||||
$processor = $this->makeProcessor(granted: [], payload: ['siren' => '123456789']);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$processor->process($this->minimalClient(), $this->operation());
|
||||
}
|
||||
|
||||
public function testStrictMixWithAccountingFieldIsForbidden(): void
|
||||
{
|
||||
// RG-1.28 : payload mixant main + accounting sans la permission -> 403
|
||||
// sur l'ensemble (pas de filtrage silencieux).
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: ['companyName' => 'X', 'siren' => '123456789'],
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$processor->process($this->minimalClient(), $this->operation());
|
||||
}
|
||||
|
||||
public function testArchiveWithoutPermissionIsForbidden(): void
|
||||
{
|
||||
// RG-1.22 : isArchived sans la permission archive -> 403.
|
||||
$processor = $this->makeProcessor(granted: [], payload: ['isArchived' => true]);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$processor->process($this->minimalClient(), $this->operation());
|
||||
}
|
||||
|
||||
public function testArchiveWithOtherFieldIsUnprocessable(): void
|
||||
{
|
||||
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ.
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.archive'],
|
||||
payload: ['isArchived' => true, 'companyName' => 'X'],
|
||||
);
|
||||
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$processor->process($this->minimalClient(), $this->operation());
|
||||
}
|
||||
|
||||
public function testVirementWithoutBankIsUnprocessable(): void
|
||||
{
|
||||
// RG-1.12
|
||||
$client = $this->minimalClient();
|
||||
$client->setPaymentType($this->paymentType('VIREMENT'));
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.accounting.manage'],
|
||||
payload: ['paymentType' => '/api/payment_types/1'],
|
||||
);
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testVirementWithBankPasses(): void
|
||||
{
|
||||
// RG-1.12 satisfait : Virement + banque.
|
||||
$client = $this->minimalClient();
|
||||
$client->setPaymentType($this->paymentType('VIREMENT'));
|
||||
$client->setBank(new Bank());
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.accounting.manage'],
|
||||
payload: ['paymentType' => '/api/payment_types/1', 'bank' => '/api/banks/1'],
|
||||
);
|
||||
|
||||
$result = $processor->process($client, $this->operation());
|
||||
self::assertInstanceOf(Client::class, $result);
|
||||
}
|
||||
|
||||
public function testLcrWithoutRibIsUnprocessable(): void
|
||||
{
|
||||
// RG-1.13
|
||||
$client = $this->minimalClient();
|
||||
$client->setPaymentType($this->paymentType('LCR'));
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.accounting.manage'],
|
||||
payload: ['paymentType' => '/api/payment_types/2'],
|
||||
);
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testLcrWithRibPasses(): void
|
||||
{
|
||||
// RG-1.13 satisfait : LCR + au moins un RIB.
|
||||
$client = $this->minimalClient();
|
||||
$client->setPaymentType($this->paymentType('LCR'));
|
||||
$client->addRib(new ClientRib());
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.accounting.manage'],
|
||||
payload: ['paymentType' => '/api/payment_types/2'],
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
}
|
||||
|
||||
public function testCommercialeIncompleteInformationIsUnprocessable(): void
|
||||
{
|
||||
// RG-1.04 : role Commerciale + onglet Information incomplet -> 422.
|
||||
$client = $this->minimalClient();
|
||||
$client->setDescription('Une description'); // les autres champs Information restent null
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: ['description' => 'Une description'],
|
||||
user: $this->commercialeUser(),
|
||||
);
|
||||
|
||||
$this->expectException(ValidationException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testNonCommercialeSkipsInformationCompleteness(): void
|
||||
{
|
||||
// Meme payload incomplet, mais user non-Commerciale -> aucun blocage.
|
||||
$client = $this->minimalClient();
|
||||
$client->setDescription('Une description');
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: ['description' => 'Une description'],
|
||||
user: null,
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $granted Permissions accordees a l'utilisateur courant
|
||||
* @param array<string, mixed> $payload Corps JSON simule de la requete
|
||||
*/
|
||||
private function makeProcessor(array $granted, array $payload, ?UserInterface $user = null): ClientProcessor
|
||||
{
|
||||
$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)));
|
||||
|
||||
return new ClientProcessor(
|
||||
$persist,
|
||||
new ClientFieldNormalizer(),
|
||||
new ClientInformationCompletenessValidator(),
|
||||
$security,
|
||||
$requestStack,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client minimal valide vis-a-vis de RG-1.01 (un nom de contact) — suffisant
|
||||
* pour atteindre les validations testees.
|
||||
*/
|
||||
private function minimalClient(): Client
|
||||
{
|
||||
$client = new Client();
|
||||
$client->setCompanyName('Test Co');
|
||||
$client->setLastName('Dupont');
|
||||
$client->setPhonePrimary('0102030405');
|
||||
$client->setEmail('t@test.fr');
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function paymentType(string $code): PaymentType
|
||||
{
|
||||
$type = new PaymentType();
|
||||
$type->setCode($code);
|
||||
$type->setLabel($code);
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Unit;
|
||||
|
||||
use ApiPlatform\State\SerializerContextBuilderInterface;
|
||||
use App\Module\Commercial\Domain\Entity\Client;
|
||||
use App\Module\Commercial\Infrastructure\ApiPlatform\Serializer\ClientReadGroupContextBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Tests unitaires du context builder qui ajoute conditionnellement le groupe
|
||||
* de lecture `client:read:accounting` selon la permission accounting.view
|
||||
* (§ 2.7 / § 4.1 / § 4.2).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientReadGroupContextBuilderTest extends TestCase
|
||||
{
|
||||
public function testAddsAccountingGroupForClientReadWhenGranted(): void
|
||||
{
|
||||
$builder = $this->builder(
|
||||
baseContext: ['resource_class' => Client::class, 'groups' => ['client:read', 'default:read']],
|
||||
granted: true,
|
||||
);
|
||||
|
||||
$context = $builder->createFromRequest(new Request(), true);
|
||||
|
||||
self::assertContains('client:read:accounting', $context['groups']);
|
||||
}
|
||||
|
||||
public function testDoesNotAddAccountingGroupWhenNotGranted(): void
|
||||
{
|
||||
$builder = $this->builder(
|
||||
baseContext: ['resource_class' => Client::class, 'groups' => ['client:read', 'default:read']],
|
||||
granted: false,
|
||||
);
|
||||
|
||||
$context = $builder->createFromRequest(new Request(), true);
|
||||
|
||||
self::assertNotContains('client:read:accounting', $context['groups']);
|
||||
}
|
||||
|
||||
public function testDoesNotAddAccountingGroupOnWrite(): void
|
||||
{
|
||||
$builder = $this->builder(
|
||||
baseContext: ['resource_class' => Client::class, 'groups' => ['client:write:main']],
|
||||
granted: true,
|
||||
);
|
||||
|
||||
// normalization = false -> ecriture : pas de groupe de lecture ajoute.
|
||||
$context = $builder->createFromRequest(new Request(), false);
|
||||
|
||||
self::assertNotContains('client:read:accounting', $context['groups']);
|
||||
}
|
||||
|
||||
public function testIgnoresOtherResources(): void
|
||||
{
|
||||
$builder = $this->builder(
|
||||
baseContext: ['resource_class' => 'App\Other\Resource', 'groups' => ['other:read']],
|
||||
granted: true,
|
||||
);
|
||||
|
||||
$context = $builder->createFromRequest(new Request(), true);
|
||||
|
||||
self::assertSame(['other:read'], $context['groups']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $baseContext
|
||||
*/
|
||||
private function builder(array $baseContext, bool $granted): ClientReadGroupContextBuilder
|
||||
{
|
||||
$decorated = $this->createStub(SerializerContextBuilderInterface::class);
|
||||
$decorated->method('createFromRequest')->willReturn($baseContext);
|
||||
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('isGranted')->willReturn($granted);
|
||||
|
||||
return new ClientReadGroupContextBuilder($decorated, $security);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user