00bd02858c
Auto Tag Develop / tag (push) Successful in 8s
Refonte de la taxonomie Catégories (décision produit 01/06) : le modèle est inversé. ## Modèle - **UN SEUL `category_type` : CLIENT**. `Distributeur` / `Courtier` / `Secteur` / `Autre` (+ catégories métier) deviennent des `Category` rattachées à CLIENT. - Filtrage métier sur un **`code` stable porté par `Category`** (NOT NULL, unique partiel `uq_category_code`), slug MAJUSCULE auto-généré du nom (`CategoryCodeGenerator`), figé à la création, exposé en **lecture seule**. ## Contenu - **M0** : `Category.code` (entité + migration corrective `Version20260602100000` au namespace racine + `COMMENT ON COLUMN` + catalogue + ligne `test-db-setup`). Retrofit `Version20260528120000` rendu conscient des colonnes. - **Seed** : type unique CLIENT, catégories codées (`Distributeur→DISTRIBUTEUR`, etc.), anciens types supprimés. Fixtures `CategoryType`/`Category`/`Client` alignées. - **RG-1.03** : `ClientProcessor::hasCategoryCode` — un distributor/broker doit porter la `Category` de code `DISTRIBUTEUR`/`COURTIER`. Filtre liste/export `categoryType` → `categoryCode`. - **RG-1.29** : `ClientAddress::validateCategoryCodes` — denylist des codes `DISTRIBUTEUR`/`COURTIER` sur une adresse (toute autre catégorie autorisée). - **Specs** M0/M1 (back + front) amendées. ## Tests `make php-cs-fixer-allow-risky` OK ; `make db-reset` OK (type unique CLIENT + 11 catégories codées, idempotent) ; `make test` **443 vert**. Ajouts : RG-1.03 courtier, génération/unicité/lecture-seule du code (`CategoryCodeTest`). ## Coordination - #76 (#500) : RG-1.29 réécrite ici sur le nouveau modèle ; #76 ne garde que le gap 2 (mapping CHECK adresse → 422), indépendant de la taxonomie. - ERP-68 (#486) : fixtures démo (déjà mergées via #41) adaptées ici au type unique CLIENT + codes. - Front #480–483 : selects Catégorie / distributeur / courtier basés sur le `code` (`?categoryCode=`), plus le type. Décisions actées avec le PO : `code` NOT NULL auto-généré (slug) ; périmètre complet (réécriture RG + fixtures déjà mergées). --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #42 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
329 lines
13 KiB
PHP
329 lines
13 KiB
PHP
<?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 testPostBrokerReferencingNonBrokerReturns422(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$cat = $this->createCategory('SECTEUR');
|
|
$notBroker = $this->seedClient('Pas Un Courtier', false, 'SECTEUR');
|
|
|
|
$client->request('POST', '/api/clients', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => [
|
|
'companyName' => 'Bad Broker Ref',
|
|
'firstName' => 'A',
|
|
'phonePrimary' => '0102030405',
|
|
'email' => 'badbroker@test.fr',
|
|
'categories' => ['/api/categories/'.$cat->getId()],
|
|
'broker' => '/api/clients/'.$notBroker->getId(),
|
|
],
|
|
]);
|
|
|
|
// RG-1.03 (le broker doit porter la categorie de code COURTIER)
|
|
self::assertResponseStatusCodeSame(422);
|
|
}
|
|
|
|
public function testPostValidBrokerReturns201(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$cat = $this->createCategory('SECTEUR');
|
|
$broker = $this->seedClient('Vrai Courtier', false, 'COURTIER');
|
|
|
|
$client->request('POST', '/api/clients', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => [
|
|
'companyName' => 'Client Avec Courtier',
|
|
'firstName' => 'A',
|
|
'phonePrimary' => '0102030405',
|
|
'email' => 'okbroker@test.fr',
|
|
'categories' => ['/api/categories/'.$cat->getId()],
|
|
'broker' => '/api/clients/'.$broker->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);
|
|
}
|
|
}
|