[ERP-55] ClientProvider + ClientProcessor + RG métier (M1) — stackée sur ERP-54 (#31)
Auto Tag Develop / tag (push) Successful in 9s
Auto Tag Develop / tag (push) Successful in 9s
**MR stackée sur ERP-54** — cible = `feature/ERP-54-creer-entites-client-m1` (PAS `develop`). Tristan validera le stack en fin de chaîne. Branche l'API REST du répertoire clients (M1) sur l'entité `Client` d'ERP-54. ## Périmètre - **ClientProvider** : liste paginée (Paginator ORM aligné ERP-72, `?pagination=false`), exclusion archives+soft-delete par défaut (RG-1.24), `?includeArchived=true` (RG-1.25), tri `companyName ASC` (RG-1.26), filtres `?search` (fuzzy) + `?categoryType`, détail 404 si soft-deleted + embarque contacts/adresses/ribs. - **ClientProcessor** : normalisation (RG-1.18→1.21), 409 doublon nom (RG-1.16) + 409 restauration (RG-1.23), gating par onglet `accounting.manage`/`archive` + mode strict 403 (RG-1.28), archivage exclusif + `archivedAt` (RG-1.22), RG-1.01 / RG-1.03 (mutex + type catégorie) / RG-1.12 / RG-1.13 / RG-1.04. - **ClientReadGroupContextBuilder** : ajout conditionnel du groupe `client:read:accounting` selon `commercial.clients.accounting.view`. - **CategoryReferenceDenormalizer** : résout les IRI catégorie vers `Category` (dénormalisation impossible sur l'interface sinon). - **Contrats Shared** : `CategoryInterface::getCategoryTypeCode()`, `BusinessRoleAwareInterface` + `BusinessRoles::COMMERCIALE`. ## Coordination stack - Permissions `commercial.clients.*` **référencées** ici, déclarées en **ERP-59** (tests RBAC en **ERP-60**). - Rôle métier `commerciale` seedé par **ERP-74** (RG-1.04 dormante d'ici là). - Config globale pagination (itemsPerPage client / max 50) portée par **ERP-72**. - Référentiels comptables (PaymentType/Bank/...) exposés en **ERP-56** → RG-1.12/1.13 testées en unitaire ici (pas d'IRI référentiel disponible avant ERP-56). ## Tests 31 tests Commercial (intégration admin sur les RG métier + unitaires sur le gating / RG-1.04 / RG-1.12 / RG-1.13 / context builder). Suite complète verte (343 tests). Règle n°1 respectée (aucun import inter-modules dans Commercial). --------- Co-authored-by: tristan <tristan@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Co-authored-by: Matthieu <mtholot19@gmail.com> Reviewed-on: #31 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
This commit was merged in pull request #31.
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,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Unit;
|
||||
|
||||
use ApiPlatform\Metadata\IriConverterInterface;
|
||||
use App\Module\Commercial\Infrastructure\ApiPlatform\Serializer\CategoryReferenceDenormalizer;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use stdClass;
|
||||
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
||||
|
||||
/**
|
||||
* Tests unitaires du CategoryReferenceDenormalizer : resolution d'un IRI vers
|
||||
* une Category concrete, et rejet explicite d'un IRI pointant sur une autre
|
||||
* ressource (au lieu d'un null silencieux qui perdrait la reference).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class CategoryReferenceDenormalizerTest extends TestCase
|
||||
{
|
||||
public function testResolvesCategoryIri(): void
|
||||
{
|
||||
$category = $this->createStub(CategoryInterface::class);
|
||||
$iriConverter = $this->createMock(IriConverterInterface::class);
|
||||
$iriConverter->method('getResourceFromIri')->willReturn($category);
|
||||
|
||||
$denormalizer = new CategoryReferenceDenormalizer($iriConverter);
|
||||
|
||||
self::assertSame(
|
||||
$category,
|
||||
$denormalizer->denormalize('/api/categories/1', CategoryInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function testRejectsIriOfWrongType(): void
|
||||
{
|
||||
// Bug review ERP-55 : un IRI syntaxiquement valide mais pointant sur une
|
||||
// autre ressource (ex: /api/clients/5) doit lever une exception au lieu
|
||||
// d'etre silencieusement ignore.
|
||||
$iriConverter = $this->createMock(IriConverterInterface::class);
|
||||
$iriConverter->method('getResourceFromIri')->willReturn(new stdClass());
|
||||
|
||||
$denormalizer = new CategoryReferenceDenormalizer($iriConverter);
|
||||
|
||||
$this->expectException(UnexpectedValueException::class);
|
||||
$denormalizer->denormalize('/api/clients/5', CategoryInterface::class);
|
||||
}
|
||||
|
||||
public function testReturnsNullForEmptyData(): void
|
||||
{
|
||||
// Valeur vide deleguee par l'ArrayDenormalizer : aucun appel a
|
||||
// l'IriConverter, retour null.
|
||||
$iriConverter = $this->createMock(IriConverterInterface::class);
|
||||
$iriConverter->expects(self::never())->method('getResourceFromIri');
|
||||
|
||||
$denormalizer = new CategoryReferenceDenormalizer($iriConverter);
|
||||
|
||||
self::assertNull($denormalizer->denormalize('', CategoryInterface::class));
|
||||
}
|
||||
}
|
||||
@@ -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,354 @@
|
||||
<?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 Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\UnitOfWork;
|
||||
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 : la modification effective d'un champ comptable sans
|
||||
// accounting.manage -> 403. En creation (POST), positionner siren est un
|
||||
// changement vs l'etat persiste vide.
|
||||
$client = $this->minimalClient();
|
||||
$client->setSiren('123456789');
|
||||
|
||||
$processor = $this->makeProcessor(granted: [], payload: ['siren' => '123456789']);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testStrictMixWithAccountingFieldIsForbidden(): void
|
||||
{
|
||||
// RG-1.28 : payload mixant main + accounting sans la permission -> 403
|
||||
// sur l'ensemble (pas de filtrage silencieux).
|
||||
$client = $this->minimalClient();
|
||||
$client->setCompanyName('X');
|
||||
$client->setSiren('123456789');
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: ['companyName' => 'X', 'siren' => '123456789'],
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testArchiveWithoutPermissionIsForbidden(): void
|
||||
{
|
||||
// RG-1.22 : basculer isArchived sans la permission archive -> 403.
|
||||
$client = $this->minimalClient();
|
||||
$client->setIsArchived(true);
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: ['isArchived' => true],
|
||||
managed: true,
|
||||
originalData: ['isArchived' => false],
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testArchiveWithOtherFieldIsUnprocessable(): void
|
||||
{
|
||||
// RG-1.22 : une requete d'archivage ne modifie aucun autre champ.
|
||||
$client = $this->minimalClient();
|
||||
$client->setIsArchived(true);
|
||||
$client->setCompanyName('X');
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: ['commercial.clients.archive'],
|
||||
payload: ['isArchived' => true, 'companyName' => 'X'],
|
||||
managed: true,
|
||||
originalData: ['isArchived' => false],
|
||||
);
|
||||
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$processor->process($client, $this->operation());
|
||||
}
|
||||
|
||||
public function testPostWithIsArchivedFalseIsNotGated(): void
|
||||
{
|
||||
// Bug review ERP-55 : un POST renvoyant isArchived:false (valeur par
|
||||
// defaut) ne doit declencher ni 403 (archive) ni 422, meme sans
|
||||
// permission. L'entite n'est pas encore geree par l'ORM.
|
||||
$client = $this->minimalClient(); // isArchived = false par defaut
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: ['companyName' => 'Test Co', 'isArchived' => false],
|
||||
managed: false,
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
}
|
||||
|
||||
public function testFullRepresentationPatchWithUnchangedArchiveIsNotGated(): void
|
||||
{
|
||||
// Bug review ERP-55 : un PATCH « representation complete » renvoyant
|
||||
// isArchived inchange + des cles JSON-LD (@id, @context) ne doit pas etre
|
||||
// gate (ni 403 archive ni 422), meme sans permission.
|
||||
$client = $this->minimalClient(); // isArchived = false (inchange)
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: [
|
||||
'@id' => '/api/clients/1',
|
||||
'@context' => '/api/contexts/Client',
|
||||
'companyName' => 'Test Co',
|
||||
'isArchived' => false,
|
||||
],
|
||||
managed: true,
|
||||
originalData: ['isArchived' => false],
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
||||
}
|
||||
|
||||
public function testUnchangedAccountingFieldOnPatchIsNotGated(): void
|
||||
{
|
||||
// Bug review ERP-55 : renvoyer un champ comptable a sa valeur persistee
|
||||
// (PATCH representation complete) ne change rien -> pas d'exigence
|
||||
// accounting.manage.
|
||||
$client = $this->minimalClient();
|
||||
$client->setSiren('123456789'); // identique a l'etat persiste
|
||||
|
||||
$processor = $this->makeProcessor(
|
||||
granted: [],
|
||||
payload: ['companyName' => 'Test Co', 'siren' => '123456789'],
|
||||
managed: true,
|
||||
// getOriginalEntityData renvoie tous les champs mappes d'une entite
|
||||
// geree : isArchived (non-null) y figure toujours.
|
||||
originalData: ['siren' => '123456789', 'isArchived' => false],
|
||||
);
|
||||
|
||||
self::assertInstanceOf(Client::class, $processor->process($client, $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
|
||||
* @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST)
|
||||
* @param array<string, mixed> $originalData Etat persiste simule (getOriginalEntityData) pour la detection de changement
|
||||
*/
|
||||
private function makeProcessor(
|
||||
array $granted,
|
||||
array $payload,
|
||||
?UserInterface $user = null,
|
||||
bool $managed = false,
|
||||
array $originalData = [],
|
||||
): 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)));
|
||||
|
||||
// EntityManager stub : contains() distingue creation (POST) et mise a
|
||||
// jour (PATCH) ; getOriginalEntityData() fournit l'etat persiste compare
|
||||
// par le gating (RG-1.22 / RG-1.28).
|
||||
$uow = $this->createMock(UnitOfWork::class);
|
||||
$uow->method('getOriginalEntityData')->willReturn($originalData);
|
||||
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->method('contains')->willReturn($managed);
|
||||
$em->method('getUnitOfWork')->willReturn($uow);
|
||||
|
||||
return new ClientProcessor(
|
||||
$persist,
|
||||
new ClientFieldNormalizer(),
|
||||
new ClientInformationCompletenessValidator(),
|
||||
$security,
|
||||
$requestStack,
|
||||
$em,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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