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,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
use App\Module\Core\Domain\Entity\Role;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Base des tests fonctionnels du module Commercial (M1 — repertoire clients).
|
||||
*
|
||||
* Etend la base Core : ajoute des factories pour seeder vite des categories
|
||||
* typees (DISTRIBUTEUR / COURTIER / SECTEUR) et des clients, plus un helper
|
||||
* d'authentification admin.
|
||||
*
|
||||
* Cleanup : tearDown purge clients, categories `test_cli_cat_*` et users/roles
|
||||
* `test_*`. Les category_types business sont fetch-or-create (idempotents) et
|
||||
* laisses en place (pas de DELETE pour ne pas entrer en conflit avec d'autres
|
||||
* suites). Pas de DAMA en local -> purge manuelle obligatoire.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase
|
||||
{
|
||||
protected const string TEST_CATEGORY_PREFIX = 'test_cli_cat_';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->cleanupCommercialTestData();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
protected function createAdminClient(): Client
|
||||
{
|
||||
return $this->authenticatedClient('admin', 'admin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere (ou cree) un CategoryType par son code metier. Idempotent : la
|
||||
* contrainte d'unicite sur category_type.code interdit les doublons.
|
||||
*/
|
||||
protected function createCategoryType(string $code): CategoryType
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => $code]);
|
||||
if (null !== $existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$type = new CategoryType();
|
||||
$type->setCode($code);
|
||||
$type->setLabel(ucfirst(strtolower($code)));
|
||||
$em->persist($type);
|
||||
$em->flush();
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cree une Category de test rattachee a un type metier donne (code).
|
||||
*/
|
||||
protected function createCategory(string $typeCode = 'SECTEUR'): Category
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
$category = new Category();
|
||||
$category->setName(self::TEST_CATEGORY_PREFIX.$suffix);
|
||||
$category->setCategoryType($this->createCategoryType($typeCode));
|
||||
$em->persist($category);
|
||||
$em->flush();
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seede directement un Client en base (sans passer par l'API), pour les
|
||||
* tests de liste / archivage. Le client porte une categorie SECTEUR.
|
||||
*/
|
||||
protected function seedClient(string $companyName, bool $isArchived = false, string $categoryTypeCode = 'SECTEUR'): ClientEntity
|
||||
{
|
||||
$em = $this->getEm();
|
||||
$client = new ClientEntity();
|
||||
// Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait
|
||||
// produit le ClientProcessor via l'API.
|
||||
$client->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
|
||||
$client->setLastName('Seed');
|
||||
$client->setPhonePrimary('0102030405');
|
||||
$client->setEmail(strtolower(str_replace(' ', '', $companyName)).'@seed.test');
|
||||
$client->addCategory($this->createCategory($categoryTypeCode));
|
||||
$client->setIsArchived($isArchived);
|
||||
if ($isArchived) {
|
||||
$client->setArchivedAt(new DateTimeImmutable());
|
||||
}
|
||||
$em->persist($client);
|
||||
$em->flush();
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function cleanupCommercialTestData(): void
|
||||
{
|
||||
$em = $this->getEm();
|
||||
|
||||
// Clients d'abord (la jointure client_category est purgee par
|
||||
// ON DELETE CASCADE ; les auto-references distributor/broker sont
|
||||
// ON DELETE SET NULL).
|
||||
$em->createQuery('DELETE FROM '.ClientEntity::class)->execute();
|
||||
|
||||
// Categories de test ensuite (FK client_category deja purgee).
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Category::class.' c WHERE c.name LIKE :prefix',
|
||||
)->setParameter('prefix', self::TEST_CATEGORY_PREFIX.'%')->execute();
|
||||
|
||||
// Users / roles jetables.
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix',
|
||||
)->setParameter('prefix', 'test_%')->execute();
|
||||
|
||||
$em->createQuery(
|
||||
'DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix',
|
||||
)->setParameter('prefix', 'test_%')->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'API /api/clients (M1) — branche ERP-55.
|
||||
*
|
||||
* Authentifies en ADMIN (bypass RBAC via isAdmin) : on valide ici les regles
|
||||
* METIER (normalisation, unicite, distributor/broker, archivage, liste). Le
|
||||
* gating par permission (accounting.manage / archive / RG-1.28 strict, RG-1.04
|
||||
* Commerciale) est couvert par les tests unitaires du ClientProcessor : il
|
||||
* exige des users non-admin portant des permissions `commercial.clients.*` qui
|
||||
* ne sont declarees qu'en ERP-59 (tests RBAC complets en ERP-60).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientApiTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
public function testPostNormalizesTextFields(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
|
||||
$response = $client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'acme sas',
|
||||
'firstName' => 'JEAN',
|
||||
'lastName' => 'dupont',
|
||||
'phonePrimary' => '06.12.34.56.78',
|
||||
'email' => 'Jean.DUPONT@ACME.FR',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$data = $response->toArray();
|
||||
// RG-1.18 / 1.19 / 1.20 / 1.21
|
||||
self::assertSame('ACME SAS', $data['companyName']);
|
||||
self::assertSame('Jean', $data['firstName']);
|
||||
self::assertSame('Dupont', $data['lastName']);
|
||||
self::assertSame('0612345678', $data['phonePrimary']);
|
||||
self::assertSame('jean.dupont@acme.fr', $data['email']);
|
||||
self::assertFalse($data['isArchived']);
|
||||
}
|
||||
|
||||
public function testPostDuplicateCompanyNameReturns409(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
$iri = '/api/categories/'.$cat->getId();
|
||||
|
||||
$payload = [
|
||||
'companyName' => 'Doublon SARL',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'dup@test.fr',
|
||||
'categories' => [$iri],
|
||||
];
|
||||
|
||||
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16).
|
||||
$payload['email'] = 'dup2@test.fr';
|
||||
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
|
||||
public function testPostWithoutFirstOrLastNameReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'No Contact Name',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'nc@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-1.01
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostWithoutCategoryReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'No Category',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'nocat@test.fr',
|
||||
'categories' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
// Assert\Count(min: 1)
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostWithDistributorAndBrokerReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
$distributor = $this->seedClient('Distrib Mutex', false, 'DISTRIBUTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Mutex Client',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'mutex@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
'distributor' => '/api/clients/'.$distributor->getId(),
|
||||
'broker' => '/api/clients/'.$distributor->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-1.03 (exclusivite)
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostDistributorReferencingNonDistributorReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
$notDistro = $this->seedClient('Pas Un Distrib', false, 'SECTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Bad Distrib Ref',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'baddistrib@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
'distributor' => '/api/clients/'.$notDistro->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-1.03 (le distributor doit etre categorise DISTRIBUTEUR)
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostValidDistributorReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
$distributor = $this->seedClient('Vrai Distrib', false, 'DISTRIBUTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Client Avec Distrib',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'okdistrib@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
'distributor' => '/api/clients/'.$distributor->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testListSortedByCompanyNameAscAndExcludesArchived(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedClient('Zebra Co');
|
||||
$this->seedClient('Alpha Co');
|
||||
$this->seedClient('Archivé Co', true);
|
||||
|
||||
$names = $client->request('GET', '/api/clients?pagination=false', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray()['member'];
|
||||
$companyNames = array_map(static fn (array $c): string => $c['companyName'], $names);
|
||||
|
||||
// RG-1.24 : l'archive est exclue par defaut.
|
||||
self::assertNotContains('ARCHIVÉ CO', $companyNames);
|
||||
// RG-1.26 : tri companyName ASC (Alpha avant Zebra).
|
||||
$alpha = array_search('ALPHA CO', $companyNames, true);
|
||||
$zebra = array_search('ZEBRA CO', $companyNames, true);
|
||||
self::assertNotFalse($alpha);
|
||||
self::assertNotFalse($zebra);
|
||||
self::assertLessThan($zebra, $alpha);
|
||||
}
|
||||
|
||||
public function testListIncludeArchivedReturnsArchived(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedClient('Hidden Archived', true);
|
||||
|
||||
$members = $client->request('GET', '/api/clients?includeArchived=true&pagination=false', [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray()['member'];
|
||||
$names = array_map(static fn (array $c): string => $c['companyName'], $members);
|
||||
|
||||
// RG-1.25
|
||||
self::assertContains('HIDDEN ARCHIVED', $names);
|
||||
}
|
||||
|
||||
public function testCollectionIsPaginated(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$this->seedClient('Paginated One');
|
||||
|
||||
// Collection Hydra avec total (la cle `view` n'apparait qu'a partir de
|
||||
// 2 pages cote API Platform 4, donc non assertable sur page unique).
|
||||
$page1 = $client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('totalItems', $page1);
|
||||
self::assertNotEmpty($page1['member']);
|
||||
|
||||
// Preuve que la pagination serveur est bien engagee : la page 2 d'un jeu
|
||||
// tenant sur une page est vide (un provider non pagine ignorerait `page`
|
||||
// et renverrait quand meme les items).
|
||||
$page2 = $client->request('GET', '/api/clients?page=2', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertSame([], $page2['member']);
|
||||
}
|
||||
|
||||
public function testPatchArchiveSetsArchivedAtThenRestore(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('To Archive');
|
||||
$iri = '/api/clients/'.$seed->getId();
|
||||
|
||||
// Archive (RG-1.22) : admin a la permission archive via bypass isAdmin.
|
||||
$archived = $client->request('PATCH', $iri, [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['isArchived' => true],
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertTrue($archived['isArchived']);
|
||||
self::assertNotNull($archived['archivedAt']);
|
||||
|
||||
// Restauration (RG-1.23) : archivedAt repasse a null.
|
||||
$restored = $client->request('PATCH', $iri, [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['isArchived' => false],
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
self::assertFalse($restored['isArchived']);
|
||||
self::assertNull($restored['archivedAt']);
|
||||
}
|
||||
|
||||
public function testPatchArchiveWithOtherFieldReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Archive Plus Field');
|
||||
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['isArchived' => true, 'companyName' => 'Renamed'],
|
||||
]);
|
||||
|
||||
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ.
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testGetDetailEmbedsSubCollections(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Detail Embed');
|
||||
|
||||
$data = $client->request('GET', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Accept' => self::LD],
|
||||
])->toArray();
|
||||
|
||||
// § 4.2 : le detail embarque contacts / adresses / ribs.
|
||||
self::assertArrayHasKey('contacts', $data);
|
||||
self::assertArrayHasKey('addresses', $data);
|
||||
self::assertArrayHasKey('ribs', $data);
|
||||
}
|
||||
}
|
||||
@@ -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