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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user