96ddd15c86
Auto Tag Develop / tag (push) Successful in 9s
## Contexte M1 · Ticket 1/3 (Backend) de la refonte contact. Le contact principal inline du `Client` (firstName, lastName, phonePrimary, phoneSecondary, email) faisait doublon avec la sous-entité `ClientContact` (onglet Contact). Il est supprimé : les contacts vivent désormais **uniquement** dans `client_contact`. RG-1.01 (firstName OU lastName sur Client) et RG-1.02 (max 2 téléphones sur Client) sont **supprimées** du Client — leur équivalent vit déjà sur `ClientContact` (RG-1.05 / RG-1.14). ## Changements - **Migration** `Version20260603120000` (namespace racine `DoctrineMigrations` — tri par timestamp, cf. AlphabeticalComparator) : **backfill** des clients sans contact vers `client_contact` (position 0) **avant** le `DROP` des 5 colonnes. `down()` best-effort documenté. - **Entité `Client`** : retrait des 5 propriétés + getters/setters + groupes de sérialisation. - **`ClientProcessor`** : `MAIN_FIELDS` / `changedBusinessFields()` / `normalize()` allégés ; `validateMainContact()` (RG-1.01) supprimée. - **Recherche répertoire (D1)** : sur `companyName` seul (les anciens critères lastName/email vivaient sur les colonnes supprimées). - **Export XLSX (D2)** : colonnes de contact retirées (Nom entreprise / Catégories / Sites / [SIREN] / Date). - **Fixtures** + **catalogue de commentaires SQL** (`ColumnCommentsCatalog`) alignés. - **Tests** fonctionnels et unitaires mis à jour. ## Décisions actées - **Migration** au namespace racine (et non modulaire Commercial) : une migration `App\Module\Commercial\…` trierait avant le `CREATE TABLE client` sur base fraîche → casse. Conforme à la règle ABSOLUE n°11. - **D1** = recherche `companyName` seul. **D2** = retrait des colonnes contact de l'export. ## Vérifications - ✅ `make db-reset && make migration-migrate` : migration rejouable sur base fraîche (backfill no-op si contacts déjà présents). - ✅ `make test` : 466 tests verts. - ✅ `make php-cs-fixer-allow-risky` : clean. - ✅ Contrat réel `GET /api/clients/{id}` : les 5 champs ont disparu de la racine, `contacts[]` porte l'info. --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #55 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
430 lines
16 KiB
PHP
430 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Module\Commercial\Api;
|
|
|
|
use ApiPlatform\Symfony\Bundle\Test\Client;
|
|
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
|
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
|
use App\Module\Sites\Domain\Entity\Site;
|
|
|
|
/**
|
|
* 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 testPostNormalizesCompanyName(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$cat = $this->createCategory('SECTEUR');
|
|
|
|
$response = $client->request('POST', '/api/clients', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => [
|
|
'companyName' => 'acme sas',
|
|
'categories' => ['/api/categories/'.$cat->getId()],
|
|
],
|
|
]);
|
|
|
|
self::assertResponseStatusCodeSame(201);
|
|
$data = $response->toArray();
|
|
// RG-1.18 : companyName normalise en MAJUSCULES. Les champs de contact
|
|
// inline ont disparu (refonte contact) -> plus de normalisation ici.
|
|
self::assertSame('ACME SAS', $data['companyName']);
|
|
self::assertArrayNotHasKey('firstName', $data);
|
|
self::assertArrayNotHasKey('email', $data);
|
|
self::assertFalse($data['isArchived']);
|
|
}
|
|
|
|
public function testPostDuplicateCompanyNameReturns409(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$cat = $this->createCategory('SECTEUR');
|
|
$iri = '/api/categories/'.$cat->getId();
|
|
|
|
$payload = [
|
|
'companyName' => 'Doublon SARL',
|
|
'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).
|
|
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]);
|
|
self::assertResponseStatusCodeSame(409);
|
|
}
|
|
|
|
public function testPostWithoutCategoryReturns422(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
|
|
$client->request('POST', '/api/clients', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => [
|
|
'companyName' => 'No Category',
|
|
'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',
|
|
'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',
|
|
'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',
|
|
'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',
|
|
'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',
|
|
'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);
|
|
}
|
|
|
|
/**
|
|
* ERP-62 : la LISTE doit alimenter les colonnes « Catégories » (codes) et
|
|
* « Site(s) » (badges name + color) du Repertoire. On verifie donc que la
|
|
* collection embarque le `code` de chaque categorie et les sites agreges des
|
|
* adresses (accessoire Client::getSites()).
|
|
*/
|
|
public function testListEmbedsCategoryCodesAndAggregatedSites(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
|
|
// Client seede + une adresse rattachee a un site (fixtures Sites).
|
|
$seed = $this->seedClient('Embed List Co', false, 'DISTRIBUTEUR');
|
|
$em = $this->getEm();
|
|
$site = $em->getRepository(Site::class)->findOneBy([]);
|
|
self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Site(s).');
|
|
|
|
$address = new ClientAddress();
|
|
$address->setClient($seed);
|
|
$address->setPostalCode('86100');
|
|
$address->setCity('Châtellerault');
|
|
$address->setStreet('1 rue du Test');
|
|
$address->addSite($site);
|
|
$em->persist($address);
|
|
$em->flush();
|
|
|
|
$member = $client->request('GET', '/api/clients?pagination=false', [
|
|
'headers' => ['Accept' => self::LD],
|
|
])->toArray()['member'];
|
|
|
|
$row = null;
|
|
foreach ($member as $candidate) {
|
|
if ('EMBED LIST CO' === $candidate['companyName']) {
|
|
$row = $candidate;
|
|
|
|
break;
|
|
}
|
|
}
|
|
self::assertNotNull($row, 'Le client seede doit figurer dans la liste.');
|
|
|
|
// Colonne « Catégories » : chaque categorie embarquee porte son code.
|
|
self::assertNotEmpty($row['categories']);
|
|
self::assertArrayHasKey('code', $row['categories'][0]);
|
|
self::assertSame('DISTRIBUTEUR', $row['categories'][0]['code']);
|
|
|
|
// Colonne « Site(s) » : sites agreges des adresses, avec name + color.
|
|
self::assertArrayHasKey('sites', $row);
|
|
self::assertNotEmpty($row['sites']);
|
|
self::assertArrayHasKey('name', $row['sites'][0]);
|
|
self::assertArrayHasKey('color', $row['sites'][0]);
|
|
self::assertSame($site->getName(), $row['sites'][0]['name']);
|
|
}
|
|
|
|
/**
|
|
* ERP-62 (drawer) : filtre Catégories multi (?categoryCode[]=A&categoryCode[]=B)
|
|
* — union des clients possedant l'un OU l'autre code.
|
|
*/
|
|
public function testListFilterByMultipleCategoryCodes(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$this->seedClient('Filtre Distrib Co', false, 'DISTRIBUTEUR');
|
|
$this->seedClient('Filtre Courtier Co', false, 'COURTIER');
|
|
$this->seedClient('Filtre Secteur Co', false, 'SECTEUR');
|
|
|
|
$names = $this->companyNames($client, '/api/clients?pagination=false&categoryCode[]=DISTRIBUTEUR&categoryCode[]=COURTIER');
|
|
|
|
self::assertContains('FILTRE DISTRIB CO', $names);
|
|
self::assertContains('FILTRE COURTIER CO', $names);
|
|
self::assertNotContains('FILTRE SECTEUR CO', $names);
|
|
}
|
|
|
|
/**
|
|
* ERP-62 (drawer) : filtre Sites (?siteId[]=X) — clients ayant >= 1 adresse
|
|
* rattachee au site donne.
|
|
*/
|
|
public function testListFilterBySite(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$em = $this->getEm();
|
|
|
|
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
|
|
self::assertCount(2, $sites, 'Deux sites seedes requis pour ce test.');
|
|
[$siteA, $siteB] = $sites;
|
|
|
|
$onSiteA = $this->seedClient('Client Sur Site A');
|
|
$this->attachAddressWithSite($onSiteA, $siteA);
|
|
|
|
$onSiteB = $this->seedClient('Client Sur Site B');
|
|
$this->attachAddressWithSite($onSiteB, $siteB);
|
|
|
|
$names = $this->companyNames($client, '/api/clients?pagination=false&siteId[]='.$siteA->getId());
|
|
|
|
self::assertContains('CLIENT SUR SITE A', $names);
|
|
self::assertNotContains('CLIENT SUR SITE B', $names);
|
|
}
|
|
|
|
/**
|
|
* ERP-62 (drawer) : statut « Archivés » (?archivedOnly=true) — uniquement les
|
|
* archives, contrairement a includeArchived qui ajoute les archives aux actifs.
|
|
*/
|
|
public function testListArchivedOnlyReturnsOnlyArchived(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$this->seedClient('Actif Visible Co');
|
|
$this->seedClient('Archive Visible Co', true);
|
|
|
|
$names = $this->companyNames($client, '/api/clients?pagination=false&archivedOnly=true');
|
|
|
|
self::assertContains('ARCHIVE VISIBLE CO', $names);
|
|
self::assertNotContains('ACTIF VISIBLE CO', $names);
|
|
}
|
|
|
|
/**
|
|
* Rattache une adresse minimale portant un site au client (les sites vivent
|
|
* sur les adresses, RG-1.10).
|
|
*/
|
|
private function attachAddressWithSite(ClientEntity $client, Site $site): void
|
|
{
|
|
$em = $this->getEm();
|
|
$address = new ClientAddress();
|
|
$address->setClient($client);
|
|
$address->setPostalCode('86100');
|
|
$address->setCity('Châtellerault');
|
|
$address->setStreet('1 rue du Test');
|
|
$address->addSite($site);
|
|
$em->persist($address);
|
|
$em->flush();
|
|
}
|
|
|
|
/**
|
|
* Helper : recupere les companyName d'une collection /api/clients.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
private function companyNames(Client $client, string $url): array
|
|
{
|
|
$members = $client->request('GET', $url, [
|
|
'headers' => ['Accept' => self::LD],
|
|
])->toArray()['member'];
|
|
|
|
return array_map(static fn (array $c): string => $c['companyName'], $members);
|
|
}
|
|
}
|