test(commercial) : tests PHPUnit M2 fournisseurs (matrice RG + contrat sérialisation + DoD JSON réel) (ERP-92) (#71)
Auto Tag Develop / tag (push) Successful in 7s

## ERP-92 — Tests PHPUnit M2 fournisseurs (#521)

Suite fonctionnelle M2 assertant sur le **corps JSON** (jamais les annotations), jumelle de la suite clients M1.

### Couverture
- **Contrat de sérialisation** (`SupplierSerializationContractTest`) : 4 régressions M1 re-testées — RIB gaté **absent** pour la Commerciale, booléens `triageProvider`/`isArchived` présents, embed `categories[].code/name`, embed `sites[].name/postalCode` (objet, pas IRI) — + enveloppe AP4 (`member`/`totalItems`/`view`, archivés exclus) + suppression du contact inline.
- **Matrice RBAC réelle** (`app:seed-rbac`, pas de mock) : bureau/compta/commerciale/usine 200/403, gating `accounting` par **omission de clé**, mode strict PATCH (RG-2.16).
- **Matrice RG-2.03 → RG-2.17** (création, normalisation RG-2.12, catégorie FOURNISSEUR RG-2.10, unicité RG-2.11, archivage RG-2.14/2.15, RG-2.07/2.08 compta, sous-ressources RG-2.04/2.05/2.06/2.09).
- **Anti N+1 liste** : nombre de requêtes constant entre 2 et 4 fournisseurs. **Audit** Supplier + RIB (`iban`/`bic` dans le diff).

### Fix de contrat (découvert par la DoD)
Les référentiels comptables (`TvaMode`/`PaymentType`/`PaymentDelay`/`Bank`) ne portaient que `client:read:accounting` (M1) → sur un fournisseur ils sortaient en **IRI nu**. Ajout de `supplier:read:accounting` → objet `{id, code, label}` embarqué (additif, zéro impact M1). Sans ce fix, #95/#96 auraient été développés contre un contrat faux.

### Infra
`makefile` : `test-db-setup` recrée l'index partiel `uq_supplier_company_name_active` (droppé par `schema:update` comme celui du client — oubli M2).

### DoD 
§ 4.0.bis : réponses JSON **réelles** (liste + détail admin/commerciale) collées. Front #93→#96 peuvent démarrer.

### Vérifs
- `make test` : **574 tests OK** (suite complète verte)
- `make php-cs-fixer-allow-risky` : 0 correction

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #71
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 #71.
This commit is contained in:
2026-06-08 07:49:17 +00:00
committed by admin malio
parent 97459e798f
commit 6f9bb68170
19 changed files with 2172 additions and 64 deletions
@@ -0,0 +1,339 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Catalog\Domain\Entity\Category;
use App\Module\Catalog\Domain\Entity\CategoryType;
use App\Module\Commercial\Domain\Entity\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Commercial\Domain\Entity\SupplierAddress;
use App\Module\Commercial\Domain\Entity\SupplierContact;
use App\Module\Commercial\Domain\Entity\SupplierRib;
use App\Module\Commercial\Domain\Entity\TvaMode;
use App\Module\Sites\Domain\Entity\Site;
use DateTimeImmutable;
/**
* Base des tests fonctionnels du repertoire fournisseurs (M2). Jumelle de la base
* clients (M1), elle ajoute les factories specifiques fournisseur au-dessus de
* {@see AbstractCommercialApiTestCase} (qui apporte deja createCategory sous le
* type CLIENT, createUserWithPermission, authenticatedClient...).
*
* Donnees (RETEX M1 — pas de fixtures globales pour les tests) : chaque test seede
* ses fournisseurs en base via les helpers ci-dessous, puis le tearDown les purge.
* Les referentiels comptables (tva_mode / payment_delay / payment_type / bank) et
* les categories FOURNISSEUR (Negociant, Cooperative...) sont seedes par les
* fixtures applicatives (make test-db-setup) ; on les recupere par code.
*
* Categories : `supplierCategory('NEGOCIANT')` fetch-or-create une categorie de
* type FOURNISSEUR (requis par RG-2.10) — fetch-or-create par code pour rester
* idempotent et auto-suffisant (ne depend pas du seed, que d'autres tests de la
* suite peuvent purger). Pour fabriquer une categorie d'un AUTRE type (test de
* rejet RG-2.10), utiliser `createCategory()` du parent, qui cree sous CLIENT.
*
* Cleanup : le tearDown purge les fournisseurs AVANT le parent (qui supprime les
* categories `test_cli_cat_*`) : la jointure supplier_category est ON DELETE
* CASCADE cote supplier mais RESTRICT cote category — le DELETE DQL sur Supplier
* declenche le cascade BDD sur supplier_category / _contact / _address, liberant
* les categories pour la purge du parent.
*
* @internal
*/
abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
{
protected const string LD = 'application/ld+json';
protected const string MERGE = 'application/merge-patch+json';
/** IBAN/BIC valides (Assert\Iban / Assert\Bic) reutilises par les seeds. */
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
protected const string VALID_BIC = 'BNPAFRPPXXX';
protected function tearDown(): void
{
$this->getEm()->createQuery('DELETE FROM '.Supplier::class)->execute();
parent::tearDown();
}
/**
* Fetch-or-create une categorie de type FOURNISSEUR par code (defaut
* Negociant). Type FOURNISSEUR exige par RG-2.10 : un POST fournisseur portant
* cette categorie passe la validation. Idempotent (lookup par code, aligne sur
* l'index unique partiel uq_category_code) et auto-suffisant : ne depend pas du
* seed CategoryFixtures (que d'autres tests de la suite peuvent purger). Une
* categorie creee ici porte le prefixe de nom de test -> purgee par le parent.
*/
protected function supplierCategory(string $code = 'NEGOCIANT'): Category
{
$em = $this->getEm();
$existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]);
if (null !== $existing) {
return $existing;
}
$category = new Category();
$category->setName(self::TEST_CATEGORY_PREFIX.'fr_'.strtolower($code));
$category->setCode($code);
$category->setCategoryType($this->supplierCategoryType());
$em->persist($category);
$em->flush();
return $category;
}
/**
* Recupere (ou cree) le type FOURNISSEUR. Idempotent : la contrainte d'unicite
* sur category_type.code interdit les doublons.
*/
protected function supplierCategoryType(): CategoryType
{
$em = $this->getEm();
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'FOURNISSEUR']);
if (null !== $existing) {
return $existing;
}
$type = new CategoryType();
$type->setCode('FOURNISSEUR');
$type->setLabel('Fournisseur');
$em->persist($type);
$em->flush();
return $type;
}
/**
* Seede directement un Supplier minimal (sans passer par l'API), pour les
* tests de liste / archivage / serialisation. Nom stocke en MAJUSCULES pour
* refleter l'etat normalise (RG-2.12) qu'aurait produit le SupplierProcessor.
* Porte une categorie FOURNISSEUR (defaut Negociant).
*/
protected function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'NEGOCIANT'): Supplier
{
$em = $this->getEm();
$supplier = new Supplier();
$supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
$supplier->addCategory($this->supplierCategory($categoryCode));
$supplier->setIsArchived($isArchived);
if ($isArchived) {
$supplier->setArchivedAt(new DateTimeImmutable());
}
$em->persist($supplier);
$em->flush();
return $supplier;
}
/**
* Seede un fournisseur COMPLET (sans passer par l'API — validations
* applicatives non rejouees mais CHECK BDD respectes) : onglet Information
* rempli, bloc comptable non nul (SIREN + refs), >= 1 RIB, >= 1 adresse
* multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie
* FOURNISSEUR, >= 1 contact, >= 1 categorie sur le fournisseur. Sert de socle
* au contrat de serialisation et a la DoD (§ 4.0.bis).
*
* @param string $paymentTypeCode code du type de reglement a poser (defaut LCR,
* coherent avec le RIB seede ; RG-2.08)
*/
protected function seedCompleteSupplier(string $companyName, string $paymentTypeCode = 'LCR'): Supplier
{
$em = $this->getEm();
// Nom unique parmi les actifs (index partiel uq_supplier_company_name_active).
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = new Supplier();
$supplier->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
$supplier->addCategory($this->supplierCategory('NEGOCIANT'));
// Onglet Information complet (RG-2.03 : exige pour la Commerciale).
$supplier->setDescription('Fournisseur de test complet.');
$supplier->setCompetitors('Concurrent A, Concurrent B');
$supplier->setFoundedAt(new DateTimeImmutable('2008-04-01'));
$supplier->setEmployeesCount(42);
$supplier->setRevenueAmount('1500000.00');
$supplier->setDirectorName('Jean Dupont');
$supplier->setProfitAmount('120000.00');
$supplier->setVolumeForecast(8000);
// Bloc comptable non nul (gating par omission cote Commerciale).
$supplier->setSiren('123456789');
$supplier->setAccountNumber('F0001');
$supplier->setNTva('FR00123456789');
$supplier->setTvaMode($this->tvaMode('FRANCE_VENTES'));
$supplier->setPaymentDelay($this->paymentDelay('J30'));
$supplier->setPaymentType($this->paymentType($paymentTypeCode));
if ('VIREMENT' === $paymentTypeCode) {
$supplier->setBank($this->bank('SG'));
}
$em->persist($supplier);
// >= 2 sites fixtures pour une adresse multi-sites (RG-2.06).
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');
$contact = new SupplierContact();
$contact->setSupplier($supplier);
$contact->setFirstName('Marie');
$contact->setLastName('Martin');
$contact->setJobTitle('Responsable achats');
$contact->setPhonePrimary('0612345678');
$contact->setEmail('marie.martin@seed.test');
$supplier->addContact($contact);
$em->persist($contact);
$address = new SupplierAddress();
$address->setSupplier($supplier);
$address->setAddressType('DEPART');
$address->setPostalCode('86000');
$address->setCity('Poitiers');
$address->setStreet('12 rue des Acacias');
$address->setBennes(3);
// triageProvider=true : prouve qu'un booleen `true` est bien serialise
// (piege n°3 du M1 — la cle etait droppee).
$address->setTriageProvider(true);
foreach ($sites as $site) {
$address->addSite($site);
}
$address->addCategory($this->supplierCategory('NEGOCIANT'));
$address->addContact($contact);
$supplier->addAddress($address);
$em->persist($address);
$rib = new SupplierRib();
$rib->setSupplier($supplier);
$rib->setLabel('Compte principal');
$rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN);
$supplier->addRib($rib);
$em->persist($rib);
$em->flush();
return $supplier;
}
/**
* Ajoute un contact a un fournisseur deja persiste (seed direct).
*/
protected function addContact(
Supplier $supplier,
?string $firstName = 'Marie',
?string $lastName = 'Martin',
?string $phonePrimary = null,
?string $email = null,
int $position = 0,
): SupplierContact {
$contact = new SupplierContact();
$contact->setSupplier($supplier);
$contact->setFirstName($firstName);
$contact->setLastName($lastName);
$contact->setPhonePrimary($phonePrimary);
$contact->setEmail($email);
$contact->setPosition($position);
$supplier->addContact($contact);
$this->getEm()->persist($contact);
$this->getEm()->flush();
return $contact;
}
/**
* Ajoute un RIB a un fournisseur deja persiste (seed direct).
*/
protected function addRib(Supplier $supplier, string $label = 'Compte principal'): SupplierRib
{
$rib = new SupplierRib();
$rib->setSupplier($supplier);
$rib->setLabel($label);
$rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN);
$supplier->addRib($rib);
$this->getEm()->persist($rib);
$this->getEm()->flush();
return $rib;
}
/**
* Payload minimal valide de l'onglet principal (companyName + 1 categorie
* FOURNISSEUR). Si $categoryId est null, la categorie Negociant seedee est
* utilisee.
*
* @return array<string, mixed>
*/
protected function validMainPayload(string $companyName, ?int $categoryId = null): array
{
$categoryId ??= $this->supplierCategory('NEGOCIANT')->getId();
return [
'companyName' => $companyName,
'categories' => ['/api/categories/'.$categoryId],
];
}
protected function paymentType(string $code): PaymentType
{
return $this->referential(PaymentType::class, $code);
}
protected function paymentDelay(string $code): PaymentDelay
{
return $this->referential(PaymentDelay::class, $code);
}
protected function tvaMode(string $code): TvaMode
{
return $this->referential(TvaMode::class, $code);
}
protected function bank(string $code): Bank
{
return $this->referential(Bank::class, $code);
}
/**
* Recupere un referentiel comptable seede (CommercialReferentialFixtures) par
* code. Echoue explicitement si absent (fixtures non chargees).
*
* @template T of object
*
* @param class-string<T> $entityClass
*
* @return T
*/
private function referential(string $entityClass, string $code): object
{
$entity = $this->getEm()->getRepository($entityClass)->findOneBy(['code' => $code]);
self::assertNotNull(
$entity,
sprintf('Referentiel %s "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $entityClass, $code),
);
return $entity;
}
/**
* Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
* d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
* orthogonal) : un test qui se contente du code 422 passerait meme si la RG
* visee etait cassee pour une autre raison.
*
* @param array<string, mixed> $body corps decode de la reponse (toArray(false))
*
* @return array<string, string> propertyPath => message
*/
protected function violationsByPath(array $body): array
{
$byPath = [];
foreach ($body['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
return $byPath;
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests fonctionnels des RG comptables inter-champs portees par les Assert\Callback
* de l'entite Supplier (M2, RG-2.07 / RG-2.08), via le PATCH de l'onglet
* Comptabilite (groupe supplier:write:accounting). On asserte le code HTTP et le
* propertyPath de la violation (consommable par extractApiViolations cote front,
* ERP-101). Complete les tests unitaires SupplierValidationTest par la preuve HTTP.
*
* @internal
*/
final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase
{
// === RG-2.07 : Virement impose une banque ===
public function testVirementWithoutBankReturns422OnBankPath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Virement No Bank');
$response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId()],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('bank', $this->violationsByPath($response->toArray(false)));
}
public function testVirementWithBankReturns200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Virement With Bank');
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => [
'paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId(),
'bank' => '/api/banks/'.$this->bank('SG')->getId(),
],
]);
self::assertResponseStatusCodeSame(200);
}
// === RG-2.08 : LCR impose au moins un RIB ===
public function testLcrWithoutRibReturns422OnRibsPath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Lcr No Rib');
$response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('ribs', $this->violationsByPath($response->toArray(false)));
}
public function testLcrWithRibReturns200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Lcr With Rib');
$this->addRib($seed);
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()],
]);
self::assertResponseStatusCodeSame(200);
}
// violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase.
}
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
/**
* Tests fonctionnels du formulaire principal fournisseur (M2, spec § 4.3 / § 4.4)
* sur le CORPS JSON : creation (companyName + categories), normalisation serveur
* (RG-2.12 UPPERCASE), categorie de type FOURNISSEUR (RG-2.10), unicite du nom
* (RG-2.11) et archivage nominal (RG-2.14). Jumeau de ClientApiTest (M1).
*
* @internal
*/
final class SupplierApiTest extends AbstractSupplierApiTestCase
{
// === POST formulaire principal ===
public function testPostMainFormUppercasesCompanyName(): void
{
$client = $this->createAdminClient();
$cat = $this->supplierCategory('NEGOCIANT');
$data = $client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'recycla sas',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
// RG-2.12 : companyName normalise en MAJUSCULES sur la valeur RENVOYEE.
self::assertSame('RECYCLA SAS', $data['companyName']);
// Embed categorie : code/name presents (category:read dans le contexte).
self::assertSame('NEGOCIANT', $data['categories'][0]['code']);
}
public function testPostMainFormHasNoInlineContactFields(): void
{
// refonte-contact V0.2 : plus aucun champ de contact inline au POST.
$client = $this->createAdminClient();
$cat = $this->supplierCategory('NEGOCIANT');
$data = $client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'No Inline Co',
// Champs historiques : ignores par le denormaliseur.
'firstName' => 'Ignored',
'lastName' => 'Ignored',
'phonePrimary' => '0612345678',
'email' => 'ignored@test.fr',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
self::assertArrayNotHasKey($key, $data);
}
}
// === RG-2.10 : categorie de type FOURNISSEUR ===
public function testPostWithNonFournisseurCategoryReturns422OnCategoriesPath(): void
{
$client = $this->createAdminClient();
// createCategory() (parent) cree une categorie de type CLIENT -> interdite.
$clientTypedCategory = $this->createCategory('SECTEUR');
$response = $client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'companyName' => 'Wrong Cat Type',
'categories' => ['/api/categories/'.$clientTypedCategory->getId()],
],
]);
self::assertResponseStatusCodeSame(422);
$byPath = [];
foreach ($response->toArray(false)['violations'] ?? [] as $v) {
$byPath[$v['propertyPath']] = $v['message'];
}
// ERP-101 : la violation porte propertyPath=categories (mapping inline front).
self::assertArrayHasKey('categories', $byPath);
self::assertSame('Type de catégorie non autorisé (FOURNISSEUR attendu).', $byPath['categories']);
}
// === RG-2.11 : unicite du nom de societe ===
public function testPostDuplicateCompanyNameReturns409(): void
{
$client = $this->createAdminClient();
$this->seedSupplier('Dup Name Co');
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Dup Name Co'),
]);
// RG-2.11 : doublon parmi les actifs -> 409 (index uq_supplier_company_name_active).
self::assertResponseStatusCodeSame(409);
}
public function testPostSameNameAfterArchivingPreviousReturns201(): void
{
$client = $this->createAdminClient();
// L'homonyme est archive -> hors index partiel : le nom redevient disponible.
$this->seedSupplier('Reuse After Archive', true);
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Reuse After Archive'),
]);
self::assertResponseStatusCodeSame(201);
}
// === RG-2.14 : archivage (admin) ===
public function testAdminArchiveSetsArchivedAt(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Archive Me');
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(200);
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(Supplier::class)->find($seed->getId());
self::assertNotNull($reloaded);
self::assertTrue($reloaded->isArchived());
self::assertNotNull($reloaded->getArchivedAt(), 'RG-2.14 : archivedAt doit etre rempli a l\'archivage.');
}
public function testArchiveWithOtherFieldReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Archive Plus Field');
// RG-2.14 : une requete d'archivage ne modifie aucun autre champ.
$response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true, 'companyName' => 'Renamed While Archiving'],
]);
self::assertResponseStatusCodeSame(422);
// Le 422 doit etre celui de RG-2.14 (archivage exclusif) et non un 422
// orthogonal : on verifie le message porte par l'exception.
self::assertStringContainsString('archivage', $response->getContent(false));
}
public function testRestoreSetsArchivedAtNull(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Restore Me', true);
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => false],
]);
self::assertResponseStatusCodeSame(200);
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(Supplier::class)->find($seed->getId());
self::assertNotNull($reloaded);
self::assertFalse($reloaded->isArchived());
self::assertNull($reloaded->getArchivedAt(), 'RG-2.15 : archivedAt repasse a null a la restauration.');
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests d'archivage / restauration fournisseur — trou 409 de restauration en
* conflit d'unicite (M2, RG-2.15). Le nominal RG-2.14 (archive pose archivedAt)
* et le 422 « archive + autre champ » sont couverts par SupplierApiTest. Jumeau
* de ClientArchiveTest (M1).
*
* @internal
*/
final class SupplierArchiveTest extends AbstractSupplierApiTestCase
{
/**
* RG-2.15 : restaurer un fournisseur archive dont le nom a ete repris par un
* fournisseur actif entre-temps doit echouer en 409 (index partiel
* uq_supplier_company_name_active : un seul actif portant ce nom).
*/
public function testRestoreConflictReturns409(): void
{
$client = $this->createAdminClient();
$archived = $this->seedSupplier('Acme Conflict', true);
$this->seedSupplier('Acme Conflict', false);
$client->request('PATCH', '/api/suppliers/'.$archived->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => false],
]);
self::assertResponseStatusCodeSame(409);
}
}
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use Doctrine\DBAL\Connection;
/**
* Tests Audit du repertoire fournisseurs (M2, spec § 6). Couvre :
* - POST / PATCH / archivage -> ligne audit_log entity_type='commercial.Supplier'
* avec l'action et le diff attendus ;
* - RIB : `#[Auditable]` SANS `#[AuditIgnore]` sur iban/bic -> ces champs sensibles
* DOIVENT apparaitre dans le diff audite (decision § 2.7, miroir M1).
*
* @internal
*/
final class SupplierAuditTest extends AbstractSupplierApiTestCase
{
private const string SUPPLIER_TYPE = 'commercial.Supplier';
private const string RIB_TYPE = 'commercial.SupplierRib';
private ?Connection $auditConnection = null;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
/** @var Connection $conn */
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
$this->auditConnection = $conn;
}
protected function tearDown(): void
{
if (null !== $this->auditConnection) {
$this->auditConnection->close();
}
parent::tearDown();
}
public function testPostSupplierIsAudited(): void
{
$admin = $this->createAdminClient();
$cat = $this->supplierCategory('NEGOCIANT');
$created = $admin->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'companyName' => 'Audit Created Co',
'categories' => ['/api/categories/'.$cat->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertGreaterThanOrEqual(
1,
$this->countAudit(self::SUPPLIER_TYPE, (string) $created['id'], 'create'),
'Un audit_log "create" doit etre genere pour le fournisseur.',
);
}
public function testPatchSupplierIsAudited(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedSupplier('Audit Patch Co');
$admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Audit Patch Renamed'],
]);
self::assertResponseStatusCodeSame(200);
self::assertGreaterThanOrEqual(
1,
$this->countAudit(self::SUPPLIER_TYPE, (string) $seed->getId(), 'update'),
'Un audit_log "update" doit etre genere pour le PATCH.',
);
}
public function testArchiveSupplierIsAudited(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedSupplier('Audit Archive Co');
$admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(200);
$rows = $this->auditConnection->fetchAllAssociative(
'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC',
['type' => self::SUPPLIER_TYPE, 'id' => (string) $seed->getId(), 'action' => 'update'],
);
self::assertGreaterThanOrEqual(1, count($rows));
/** @var array<string, mixed> $changes */
$changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived.');
}
public function testRibCreateAuditIncludesIbanAndBic(): void
{
$admin = $this->createAdminClient();
$seed = $this->seedSupplier('Rib Audit Host');
$rib = $admin->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'label' => 'Compte audite',
'bic' => self::VALID_BIC,
'iban' => self::VALID_IBAN,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
$rows = $this->auditConnection->fetchAllAssociative(
'SELECT changes FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action ORDER BY performed_at DESC',
['type' => self::RIB_TYPE, 'id' => (string) $rib['id'], 'action' => 'create'],
);
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "create" doit etre genere pour le RIB.');
/** @var array<string, mixed> $changes */
$changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).');
self::assertSame(self::VALID_IBAN, $changes['iban']);
self::assertSame(self::VALID_BIC, $changes['bic']);
}
private function countAudit(string $type, string $id, string $action): int
{
return (int) $this->auditConnection->fetchOne(
'SELECT COUNT(*) FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action',
['type' => $type, 'id' => $id, 'action' => $action],
);
}
}
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests fonctionnels de la liste fournisseurs (M2, spec § 4.1 + RG-2.17 + règle
* ABSOLUE n°13) : exclusion des archives par défaut, ?includeArchived, tri
* companyName ASC, enveloppe Hydra (member/totalItems/view), échappatoire
* ?pagination=false, et ANTI N+1 (le nombre de requêtes SQL de la liste ne croît
* pas avec le nombre de lignes — fetch-joins/hydratation batchée § 2.12).
*
* @internal
*/
final class SupplierListTest extends AbstractSupplierApiTestCase
{
public function testListExcludesArchivedByDefaultAndIncludesWithFlag(): void
{
$http = $this->createAdminClient();
$token = $this->token();
$this->seedSupplier($token.' Active');
$this->seedSupplier($token.' Archived', true);
$default = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(1, $default['totalItems'], 'RG-2.17 : archives exclus par defaut.');
$all = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(2, $all['totalItems'], 'RG-2.17 : ?includeArchived reintegre les archives.');
}
public function testListIsSortedByCompanyNameAsc(): void
{
$http = $this->createAdminClient();
$token = $this->token();
// Inseres dans le desordre ; le tri par defaut doit remonter ALPHA avant ZETA.
$this->seedSupplier($token.' Zeta');
$this->seedSupplier($token.' Alpha');
$names = array_map(
static fn (array $m): string => (string) $m['companyName'],
$http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray()['member'],
);
self::assertCount(2, $names);
self::assertStringContainsString('ALPHA', $names[0], 'RG-2.17 : tri companyName ASC.');
self::assertStringContainsString('ZETA', $names[1]);
}
public function testPaginationDisabledReturnsFullCollection(): void
{
$http = $this->createAdminClient();
$token = $this->token();
for ($i = 0; $i < 3; ++$i) {
$this->seedSupplier($token.' Item'.$i);
}
// ?pagination=false : echappatoire pour alimenter un <select> (regle n°13).
$data = $http->request('GET', '/api/suppliers?search='.$token.'&pagination=false', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('member', $data);
self::assertCount(3, $data['member']);
}
/**
* Anti N+1 (§ 2.12) : le nombre de requetes SQL de la liste ne doit PAS croitre
* avec le nombre de fournisseurs. On mesure pour N=2 puis N=4 (memes relations
* embarquees : categories + addresses.sites) et on exige un compte IDENTIQUE —
* preuve que l'hydratation est batchee (WHERE IN) et non par ligne.
*/
public function testListQueryCountDoesNotGrowWithRowCount(): void
{
$this->skipIfSitesModuleDisabled();
$token = $this->token();
// Premiere mesure : 2 fournisseurs complets (avec adresses/sites/categories).
$this->seedCompleteSupplier($token.' A');
$this->seedCompleteSupplier($token.' B');
$countFor2 = $this->countListQueries($token);
// Seconde mesure : 2 de plus (4 au total, tous sur la meme page).
$this->seedCompleteSupplier($token.' C');
$this->seedCompleteSupplier($token.' D');
$countFor4 = $this->countListQueries($token);
self::assertSame(
$countFor2,
$countFor4,
sprintf('Anti N+1 : le nombre de requetes liste doit etre constant (%d pour 2, %d pour 4).', $countFor2, $countFor4),
);
}
/**
* Compte les requetes SQL emises par UN GET liste filtre, via le data holder de
* debug Doctrine (actif car kernel.debug=true en test). Le holder est remis a
* zero juste avant la requete pour isoler ses requetes (hors login).
*/
private function countListQueries(string $token): int
{
$http = $this->createAdminClient();
$holder = self::getContainer()->get('doctrine.debug_data_holder');
$holder->reset();
$http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]]);
$data = $holder->getData();
return count($data['default'] ?? []);
}
private function token(): string
{
return 'List'.substr(bin2hex(random_bytes(4)), 0, 8);
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests de structure / migration M2 (§ 8.1). Vérifie au niveau du schéma Postgres :
* - l'unique index partiel fonctionnel uq_supplier_company_name_active existe
* (LOWER(company_name), partiel sur actifs non archivés / non supprimés —
* RG-2.11), seule unicité de nom conservée ; pas d'index unique siren/email ;
* - le type de catégorie FOURNISSEUR est présent (seedé migration + fixture).
*
* @internal
*/
final class SupplierMigrationTest extends AbstractSupplierApiTestCase
{
public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void
{
$rows = $this->supplierIndexes();
$companyNameIndexes = array_filter(
$rows,
static fn (array $r): bool => 'uq_supplier_company_name_active' === $r['indexname'],
);
self::assertCount(1, $companyNameIndexes, 'Il doit exister exactement UN index uq_supplier_company_name_active.');
$def = strtolower((string) array_values($companyNameIndexes)[0]['indexdef']);
self::assertStringContainsString('unique', $def);
self::assertStringContainsString('lower', $def);
self::assertStringContainsString('company_name', $def);
self::assertStringContainsString('where', $def, 'L\'index doit etre partiel (clause WHERE sur les actifs).');
}
public function testNoSirenOrEmailUniqueIndexOnSupplier(): void
{
$names = array_map(static fn (array $r): string => $r['indexname'], $this->supplierIndexes());
// § 2.6 : SIREN et email NON uniques sur le fournisseur.
self::assertNotContains('uq_supplier_siren_active', $names);
self::assertNotContains('uq_supplier_email_active', $names);
}
public function testFournisseurCategoryTypeExists(): void
{
self::bootKernel();
$count = (int) $this->getEm()->getConnection()->fetchOne(
"SELECT COUNT(*) FROM category_type WHERE code = 'FOURNISSEUR'",
);
self::assertSame(1, $count, 'Le type de categorie FOURNISSEUR doit etre present (migration + fixture).');
}
/**
* @return list<array{indexname: string, indexdef: string}>
*/
private function supplierIndexes(): array
{
self::bootKernel();
/** @var list<array{indexname: string, indexdef: string}> $rows */
return $this->getEm()->getConnection()->fetchAllAssociative(
"SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'supplier'",
);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
/**
* Mode strict PATCH multi-groupes fournisseur (M2, RG-2.16) — preuve fonctionnelle
* HTTP, SANS dependre d'un role metier : un user portant
* `commercial.suppliers.manage` mais PAS `commercial.suppliers.accounting.manage`
* qui envoie un PATCH melant un champ principal (companyName) et un champ
* comptable (siren) recoit 403 sur TOUT le payload — aucun champ applique (pas de
* filtrage silencieux). Jumeau de ClientPatchStrictTest (M1).
*
* @internal
*/
final class SupplierPatchStrictTest extends AbstractSupplierApiTestCase
{
public function testMixedGroupsPatchWithoutAccountingPermissionIsForbidden(): void
{
$seed = $this->seedSupplier('Strict Mix');
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => [
'companyName' => 'Renamed Strict',
'siren' => '123456789',
],
]);
// RG-2.16 : 403 strict (le champ comptable siren exige accounting.manage).
self::assertResponseStatusCodeSame(403);
// Aucun champ applique : le companyName d'origine est intact.
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(Supplier::class)->find($seed->getId());
self::assertNotNull($reloaded);
self::assertSame('STRICT MIX', $reloaded->getCompanyName());
}
}
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Matrice RBAC complete du repertoire fournisseurs par role metier (spec-back M2
* § 2.9 + ERP-90). Valide 200/403 par verbe et par onglet pour
* bureau / compta / commerciale / usine, le gating des champs comptables en
* lecture (omission de cle) et le durcissement RG-2.03 (Commerciale) au POST/PATCH.
*
* Les comptes demo et la matrice sont seedes via la commande reelle
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente —
* pas de mock de role. Jumeau de ClientRBACMatrixTest (M1).
*
* Matrice § 2.9 (ERP-90) — rappel :
* - bureau : suppliers.view + manage (ni accounting, ni archive)
* - compta : suppliers.view + accounting.view + accounting.manage (PAS manage)
* - commerciale : suppliers.view + manage (PAS accounting), durcie RG-2.03
* - usine : aucune permission (403 partout)
* - archive : admin seul (aucun role metier)
*
* @internal
*/
final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent via la commande applicative (roles + matrice § 2.9 +
// comptes demo). Exerce aussi le chemin de code prod.
self::bootKernel();
$application = new Application(self::$kernel);
$application->setAutoExit(false);
$exit = $application->run(
new ArrayInput([
'command' => 'app:seed-rbac',
'--with-demo-users' => true,
'--password' => self::PWD,
]),
new NullOutput(),
);
self::assertSame(
0,
$exit,
'app:seed-rbac a echoue : les permissions commercial.suppliers.* sont-elles synchronisees (app:sync-permissions) ?',
);
self::ensureKernelShutdown();
}
public function testUsineIsForbiddenEverywhere(): void
{
$seed = $this->seedSupplier('Usine Target');
$client = $this->authAs('usine');
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
$client->request('GET', '/api/suppliers/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Usine Post'),
]);
self::assertResponseStatusCodeSame(403);
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Renamed By Usine'],
]);
self::assertResponseStatusCodeSame(403);
}
public function testBureauHasViewAndManageButNoAccountingNoArchive(): void
{
$seed = $this->seedSupplier('Bureau Target');
$cat = $this->supplierCategory('NEGOCIANT');
$client = $this->authAs('bureau');
// view
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : creation OK (bureau n'est pas gate par RG-2.03)
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Bureau Created', $cat->getId()),
]);
self::assertResponseStatusCodeSame(201);
// manage : edition onglet principal OK
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Bureau Renamed'],
]);
self::assertResponseStatusCodeSame(200);
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testBureauDetailHasNoAccountingFields(): void
{
// Bureau a view mais PAS accounting.view : les champs comptables sont
// ABSENTS du JSON (gating par omission, pas null).
$supplier = $this->seedCompleteSupplier('Bureau Gating Co');
$client = $this->authAs('bureau');
$data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Gating par omission sur l'ensemble des champs comptables (pas seulement
// siren/ribs) : une regression reintroduisant accountNumber/nTva/tvaMode/
// paymentType dans le groupe bureau serait sinon invisible.
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
public function testComptaCanEditAccountingOnly(): void
{
$seed = $this->seedSupplier('Compta Target');
$client = $this->authAs('compta');
// view
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// PAS manage : creation refusee
$client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Compta Post'),
]);
self::assertResponseStatusCodeSame(403);
// accounting.manage : edition onglet Comptabilite OK
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(200);
// PAS manage : edition onglet principal refusee (guardManage)
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Compta Renamed'],
]);
self::assertResponseStatusCodeSame(403);
// PAS manage : edition onglet Information refusee (guardManage)
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['description' => 'Une description'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testComptaDetailHasAccountingFields(): void
{
// Compta a accounting.view : siren + ribs presents dans le JSON.
$supplier = $this->seedCompleteSupplier('Compta View Co');
$client = $this->authAs('compta');
$data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('siren', $data);
self::assertSame('123456789', $data['siren']);
self::assertArrayHasKey('ribs', $data);
self::assertNotEmpty($data['ribs']);
}
public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void
{
$seed = $this->seedSupplier('Commerciale Target');
$client = $this->authAs('commerciale');
// view
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
// manage : la creation passe la security d'operation (pas un 403 comme
// Compta) mais bute sur RG-2.03 (onglet Information incomplet) -> 422.
$response = $client->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('Commerciale Post'),
]);
self::assertResponseStatusCodeSame(422);
// Le 422 doit bien etre celui de RG-2.03 (onglet Information) et non un
// 422 orthogonal : on exige une violation sur un champ de completude.
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
// PAS accounting : edition onglet Comptabilite refusee
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['siren' => '123456789'],
]);
self::assertResponseStatusCodeSame(403);
// PAS archive : archivage refuse
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['isArchived' => true],
]);
self::assertResponseStatusCodeSame(403);
}
public function testCommercialeDetailHasNoAccountingFields(): void
{
$supplier = $this->seedCompleteSupplier('Commerciale Gating Co');
$client = $this->authAs('commerciale');
$data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
public function testRG203CommercialePostIncompleteIs422AdminIs201(): void
{
$cat = $this->supplierCategory('NEGOCIANT');
// RG-2.03 : Commerciale POST sans onglet Information complet -> 422.
$commerciale = $this->authAs('commerciale');
$response = $commerciale->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('RG203 Commerciale', $cat->getId()),
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
// Meme payload par un Admin (non gate par RG-2.03) -> 201.
$admin = $this->createAdminClient();
$admin->request('POST', '/api/suppliers', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validMainPayload('RG203 Admin', $cat->getId()),
]);
self::assertResponseStatusCodeSame(201);
}
public function testRG203CommercialePatchIncompleteIs422(): void
{
// RG-2.03 : tout PATCH par une Commerciale exige l'Information complete.
// Le fournisseur seede a une Information vide -> meme un PATCH du nom -> 422.
$seed = $this->seedSupplier('Commerciale Patch Incomplete');
$commerciale = $this->authAs('commerciale');
$response = $commerciale->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Commerciale Renamed'],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
// Le meme PATCH par un Admin passe (non gate par RG-2.03) -> 200.
$admin = $this->createAdminClient();
$admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Admin Renamed'],
]);
self::assertResponseStatusCodeSame(200);
}
private function authAs(string $role): Client
{
return $this->authenticatedClient($role, self::PWD);
}
}
@@ -0,0 +1,371 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire fournisseurs
* (M2, spec-back § 4.0 / § 4.0.bis / § 4.0.ter). Jumeau du
* {@see ClientSerializationContractTest} (M1), il reverifie sur le JSON reel les
* 4 pieges silencieux constates en prod sur le M1 :
* - #4 : fuite RIB (IBAN/BIC) vers un user sans accounting.view -> clé `ribs`
* ABSENTE pour la Commerciale.
* - #3 : booleens droppes (Groups sur la propriete `isX`, getter derivant `x`)
* -> triageProvider (adresse) et isArchived (fournisseur) presents.
* - #1 : categories embarquees sans code/name -> code + name presents en LISTE
* ET DETAIL.
* - #2 : sites embarques en IRI nu -> name + postalCode presents en LISTE
* (via getSites()) ET DETAIL (addresses[].sites[]).
* Plus l'enveloppe AP4 (member/totalItems/view sans prefixe hydra:, archives
* exclus) et la suppression du contact inline (refonte-contact V0.2).
*
* REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les
* annotations. Toute regression de groupe de serialisation casse ici.
*
* @internal
*/
final class SupplierSerializationContractTest extends AbstractSupplierApiTestCase
{
// === #4 — Gating des RIB par accounting.view ===
public function testRibsPresentForAdminWithAccountingView(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Rib Admin Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Admin bypass RBAC -> accounting.view -> RIB embarques (label/bic/iban).
self::assertArrayHasKey('ribs', $data);
self::assertNotEmpty($data['ribs']);
self::assertSame('Compte principal', $data['ribs'][0]['label']);
self::assertSame(self::VALID_IBAN, $data['ribs'][0]['iban']);
self::assertSame(self::VALID_BIC, $data['ribs'][0]['bic']);
}
public function testRibsAbsentForCommercialeWithoutAccountingView(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Rib Commerciale Co');
// Commerciale : commercial.suppliers.view SANS accounting.view.
$creds = $this->createUserWithPermission('commercial.suppliers.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// La clé `ribs` est ABSENTE (pas null) : le groupe supplier:read:accounting
// n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
// fuite IBAN/BIC (piege n°4 du M1).
self::assertArrayNotHasKey('ribs', $data);
}
// === #4.bis — Gating par OMISSION des scalaires comptables ===
public function testAccountingScalarsGatedByOmission(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Compta Gating Co');
$id = $supplier->getId();
// Admin : scalaires comptables presents.
$admin = $this->createAdminClient();
$adminData = $admin->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('siren', $adminData);
self::assertSame('123456789', $adminData['siren']);
self::assertArrayHasKey('accountNumber', $adminData);
self::assertArrayHasKey('paymentType', $adminData);
// Commerciale : scalaires comptables ABSENTS (omission, pas null).
$creds = $this->createUserWithPermission('commercial.suppliers.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$data = $http->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayNotHasKey('siren', $data);
self::assertArrayNotHasKey('accountNumber', $data);
self::assertArrayNotHasKey('nTva', $data);
self::assertArrayNotHasKey('tvaMode', $data);
self::assertArrayNotHasKey('paymentType', $data);
self::assertArrayNotHasKey('ribs', $data);
}
// === Refs comptables embarquees {id,label} et non IRI nu (ERP-92) ===
public function testAccountingReferentialsEmbedIdAndLabel(): void
{
$this->skipIfSitesModuleDisabled();
// Reglement Virement -> banque renseignee : on couvre les 4 referentiels.
$supplier = $this->seedCompleteSupplier('Refs Embed Co', 'VIREMENT');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Avant fix ERP-92 : ces refs sortaient en IRI nu ("/api/tva_modes/30")
// car les entites partagees ne portaient que `client:read:accounting` (M1),
// pas `supplier:read:accounting`. Apres fix : objet {id, label} embarque
// (le front consultation/edition affiche le libelle sans fetch — § 4.0).
foreach (['tvaMode', 'paymentDelay', 'paymentType', 'bank'] as $ref) {
self::assertArrayHasKey($ref, $data, sprintf('Le ref comptable "%s" doit etre present.', $ref));
self::assertIsArray($data[$ref], sprintf('Le ref "%s" doit etre un objet embarque, pas un IRI nu.', $ref));
self::assertArrayHasKey('id', $data[$ref]);
self::assertArrayHasKey('label', $data[$ref]);
self::assertNotSame('', (string) $data[$ref]['label']);
}
// paymentType embarque aussi son code (logique front VIREMENT/LCR).
self::assertArrayHasKey('code', $data['paymentType']);
self::assertSame('VIREMENT', $data['paymentType']['code']);
}
// === #3 — Booleens presents dans le JSON (triageProvider + isArchived) ===
public function testAddressTriageProviderBooleanIsPresentInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Bool Addr Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('addresses', $data);
self::assertNotEmpty($data['addresses']);
$address = $data['addresses'][0];
// Le bug M1 droppait TOTALEMENT la cle (Groups sur la propriete `triageProvider`,
// getter derivant `triage`). Apres parade (Groups + SerializedName sur le
// getter isTriageProvider), la cle est presente ET typee bool `true`.
self::assertArrayHasKey('triageProvider', $address);
self::assertTrue($address['triageProvider']);
}
public function testSupplierIsArchivedBooleanIsPresentInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Bool Archived Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// isArchived expose via Groups + SerializedName('isArchived') sur le getter :
// sans cela Symfony exposerait la cle "archived" et la droppait (piege n°3 M1).
self::assertArrayHasKey('isArchived', $data);
self::assertFalse($data['isArchived']);
}
// === #1 — Embed code/name des Category (liste ET detail) ===
public function testCategoriesEmbedCodeAndNameInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Embed Cat Detail Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNotEmpty($data['categories']);
$category = $data['categories'][0];
// Avant correctif M1 : seuls @id/@type (category:read absent du contexte).
// Apres : code + name embarques.
self::assertArrayHasKey('code', $category);
self::assertArrayHasKey('name', $category);
self::assertSame('NEGOCIANT', $category['code']);
// Categories d'adresse aussi (category:read dans le contexte du detail).
self::assertArrayHasKey('categories', $data['addresses'][0]);
self::assertNotEmpty($data['addresses'][0]['categories']);
self::assertArrayHasKey('code', $data['addresses'][0]['categories'][0]);
}
public function testCategoriesEmbedCodeAndNameInList(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'CatList'.substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = $this->seedCompleteSupplier($token);
$http = $this->createAdminClient();
$list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$row = $this->memberById($list, (int) $supplier->getId());
self::assertNotNull($row, 'Le fournisseur seede doit apparaitre dans la liste filtree.');
self::assertNotEmpty($row['categories']);
self::assertArrayHasKey('code', $row['categories'][0]);
self::assertArrayHasKey('name', $row['categories'][0]);
self::assertSame('NEGOCIANT', $row['categories'][0]['code']);
}
// === #2 — Embed name/postalCode des Site (liste via getSites + detail) ===
public function testSitesEmbedNameAndPostalCodeInList(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'SiteList'.substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = $this->seedCompleteSupplier($token);
$http = $this->createAdminClient();
$list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$row = $this->memberById($list, (int) $supplier->getId());
self::assertNotNull($row);
// sites agreges depuis les adresses via getSites() : objet Site entier
// (name + postalCode), pas un IRI nu (piege n°2 M1). Multi-sites (>= 2).
self::assertArrayHasKey('sites', $row);
self::assertGreaterThanOrEqual(2, count($row['sites']));
self::assertArrayHasKey('name', $row['sites'][0]);
self::assertArrayHasKey('postalCode', $row['sites'][0]);
self::assertNotSame('', (string) $row['sites'][0]['name']);
}
public function testSitesEmbedNameAndPostalCodeInDetail(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Site Detail Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
$address = $data['addresses'][0];
self::assertArrayHasKey('sites', $address);
self::assertGreaterThanOrEqual(2, count($address['sites']), 'L\'adresse seedee est multi-sites.');
self::assertArrayHasKey('name', $address['sites'][0]);
self::assertArrayHasKey('postalCode', $address['sites'][0]);
self::assertNotSame('', (string) $address['sites'][0]['name']);
}
// === Detail : sous-collections embarquees ===
public function testDetailEmbedsContactsAddressesRibs(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('Embed Subres Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNotEmpty($data['contacts']);
self::assertSame('Marie', $data['contacts'][0]['firstName']);
self::assertSame('Martin', $data['contacts'][0]['lastName']);
self::assertArrayHasKey('email', $data['contacts'][0]);
self::assertNotEmpty($data['addresses']);
self::assertSame('DEPART', $data['addresses'][0]['addressType']);
self::assertNotEmpty($data['ribs']);
}
// === refonte-contact V0.2 : plus de contact inline sur le fournisseur ===
public function testSupplierHasNoInlineContactFields(): void
{
$this->skipIfSitesModuleDisabled();
$supplier = $this->seedCompleteSupplier('No Inline Contact Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// Les champs de contact vivent UNIQUEMENT sous contacts[] (refonte-contact).
foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
self::assertArrayNotHasKey($key, $data, sprintf('Le champ inline "%s" ne doit plus exister au niveau du fournisseur.', $key));
}
}
// === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives ===
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
{
$this->skipIfSitesModuleDisabled();
$http = $this->createAdminClient();
$token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
$this->seedSupplier($token.' Active');
$this->seedSupplier($token.' Archived', true);
// Liste par defaut filtree sur le token : enveloppe member/totalItems sans
// prefixe hydra:, archive EXCLU du totalItems (RG-2.17).
$default = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('member', $default);
self::assertArrayHasKey('totalItems', $default);
self::assertArrayNotHasKey('hydra:member', $default);
self::assertArrayNotHasKey('hydra:totalItems', $default);
self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
// includeArchived : l'archive reintegre le total.
$all = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(2, $all['totalItems']);
// `view` (PartialCollectionView) sans prefixe hydra:.
$paged = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('view', $paged);
self::assertArrayNotHasKey('hydra:view', $paged);
}
/**
* DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail admin +
* detail commerciale) pour les coller dans la spec avant de lancer les tickets
* front. Le test asserte la forme ; si la variable d'env SUPPLIER_DOD_DUMP est
* positionnee, il ecrit aussi les 3 corps formates sous /tmp pour copie.
*/
public function testDodReferenceJsonShape(): void
{
$this->skipIfSitesModuleDisabled();
$token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = $this->seedCompleteSupplier($token);
$id = (int) $supplier->getId();
$admin = $this->createAdminClient();
$list = $admin->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$detailAdmin = $admin->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
$creds = $this->createUserWithPermission('commercial.suppliers.view');
$commerciale = $this->authenticatedClient($creds['username'], $creds['password']);
$detailCommerciale = $commerciale->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
// Forme minimale attendue (la DoD valide que tout champ front est present).
self::assertArrayHasKey('member', $list);
self::assertArrayHasKey('siren', $detailAdmin);
self::assertArrayHasKey('ribs', $detailAdmin);
self::assertArrayNotHasKey('siren', $detailCommerciale);
self::assertArrayNotHasKey('ribs', $detailCommerciale);
if (false !== getenv('SUPPLIER_DOD_DUMP')) {
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
file_put_contents('/tmp/supplier-dod-list.json', json_encode($list, $flags));
file_put_contents('/tmp/supplier-dod-detail-admin.json', json_encode($detailAdmin, $flags));
file_put_contents('/tmp/supplier-dod-detail-commerciale.json', json_encode($detailCommerciale, $flags));
}
}
/**
* Retrouve un membre de la collection par son id (liste filtree).
*
* @param array<string, mixed> $collection
*
* @return array<string, mixed>|null
*/
private function memberById(array $collection, int $id): ?array
{
foreach ($collection['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}
@@ -0,0 +1,357 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Sites\Domain\Entity\Site;
/**
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du fournisseur
* (M2, spec § 4.5). Couvrent : normalisation contact (RG-2.12), RG-2.04 (prenom
* OU nom), RG-2.05 (code postal), RG-2.06 (>= 1 site), RG-2.09 (enum addressType),
* RG-2.10 (categorie FOURNISSEUR sur adresse), RG-2.08 (DELETE dernier RIB sous
* LCR -> 409), DELETE contact libre au M2 (pas de garde « dernier contact ») et le
* gating comptable des RIB (manage seul -> 403). Jumeau de ClientSubResourceApiTest.
*
* @internal
*/
final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
{
// === Contacts ===
public function testPostContactNormalizesFields(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Contact Host');
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'firstName' => 'JEAN',
'lastName' => 'dupont',
'phonePrimary' => '06.12.34.56.78',
'email' => 'Jean.DUPONT@ACME.FR',
],
])->toArray();
self::assertResponseStatusCodeSame(201);
// RG-2.12 : prenom/nom Title Case, telephone chiffres seuls, email lowercase.
self::assertSame('Jean', $data['firstName']);
self::assertSame('Dupont', $data['lastName']);
self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('jean.dupont@acme.fr', $data['email']);
}
public function testPostContactWithoutNameReturns422OnFirstNamePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Contact No Name');
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => 'Directeur'],
]);
// RG-2.04 (prenom OU nom obligatoire) -> 422 rattachee a firstName.
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('firstName', $byPath);
}
public function testPostContactOnMissingSupplierReturns404(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/suppliers/999999/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Orphan'],
]);
self::assertResponseStatusCodeSame(404);
}
public function testDeleteLastContactReturns204(): void
{
// M2 : pas de garde « dernier contact » (RG-2.13 front-driven) — la
// suppression du dernier contact est libre (204), contrairement au M1.
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Contact Solo');
$contact = $this->addContact($seed, 'Unique', 'Contact');
$client->request('DELETE', '/api/supplier_contacts/'.$contact->getId());
self::assertResponseStatusCodeSame(204);
}
public function testContactWriteWithoutManageReturns403(): void
{
// Un user sans aucune permission suppliers -> 403 sur la sous-ressource.
$seed = $this->seedSupplier('Contact Forbidden');
$creds = $this->createUserWithPermission('core.users.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => ['firstName' => 'Nope'],
]);
self::assertResponseStatusCodeSame(403);
}
// === Adresses ===
public function testPostAddressWithValidPayloadReturns201(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Host');
$category = $this->supplierCategory('NEGOCIANT');
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'addressType' => 'DEPART',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('DEPART', $data['addressType']);
}
public function testPostAddressWithoutSiteReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address No Site');
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'addressType' => 'DEPART',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [],
],
]);
// RG-2.06 (Assert\Count min 1 sur sites).
self::assertResponseStatusCodeSame(422);
}
public function testPostAddressWithInvalidPostalCodeReturns422(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Bad CP');
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'addressType' => 'DEPART',
'postalCode' => '123',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
],
]);
// RG-2.05 (Assert\Regex ^[0-9]{4,5}$).
self::assertResponseStatusCodeSame(422);
}
public function testPostAddressWithIncoherentCityAndPostalCodeReturns201(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Incoherent');
// RG-2.05 : pas de controle strict de coherence CP/ville cote serveur.
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'addressType' => 'DEPART',
'postalCode' => '86100',
'city' => 'Marseille',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
],
]);
self::assertResponseStatusCodeSame(201);
}
public function testPostAddressWithInvalidTypeReturns422(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Bad Type');
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'addressType' => 'INVALID',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
],
]);
// RG-2.09 (Assert\Choice PROSPECT|DEPART|RENDU).
self::assertResponseStatusCodeSame(422);
}
/**
* RG-2.09 : les 3 valeurs valides de addressType sont acceptees.
*/
public function testPostAddressWithEachValidTypeReturns201(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Types');
$siteIri = $this->firstSiteIri();
foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) {
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'addressType' => $type,
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$siteIri],
],
]);
self::assertResponseStatusCodeSame(201, sprintf('addressType=%s doit etre accepte.', $type));
}
}
public function testPostAddressWithNonFournisseurCategoryReturns422(): void
{
$this->skipIfSitesModuleDisabled();
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Address Bad Cat');
// categorie de type CLIENT -> interdite sur une adresse fournisseur.
$clientTypedCategory = $this->createCategory('SECTEUR');
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'addressType' => 'DEPART',
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [$this->firstSiteIri()],
'categories' => ['/api/categories/'.$clientTypedCategory->getId()],
],
]);
// RG-2.10 -> 422 rattachee a categories.
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
}
// === RIBs ===
public function testPostRibByAdminReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Rib Host');
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'label' => 'Compte principal',
'bic' => self::VALID_BIC,
'iban' => self::VALID_IBAN,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Compte principal', $data['label']);
}
public function testPostRibWithInvalidIbanReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Rib Bad Iban');
$client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'],
]);
self::assertResponseStatusCodeSame(422);
}
public function testDeleteRibNonLcrReturns204(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Rib Non LCR');
$rib = $this->addRib($seed);
$client->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
self::assertResponseStatusCodeSame(204);
}
public function testDeleteLastRibUnderLcrReturns409(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Rib LCR Solo');
$rib = $this->addRib($seed);
// Passe le fournisseur en LCR (seed direct).
$em = $this->getEm();
$managed = $em->getRepository(Supplier::class)->find($seed->getId());
$managed->setPaymentType($this->paymentType('LCR'));
$em->flush();
$client->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
// RG-2.08 : LCR exige >= 1 RIB -> suppression du dernier refusee.
self::assertResponseStatusCodeSame(409);
}
public function testRibWriteWithoutAccountingManageReturns403(): void
{
// Un user portant seulement suppliers.manage (sans accounting.manage) ne
// peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5).
$seed = $this->seedSupplier('Rib Forbidden');
$rib = $this->addRib($seed);
$creds = $this->createUserWithPermission('commercial.suppliers.manage');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(403);
$http->request('PATCH', '/api/supplier_ribs/'.$rib->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['label' => 'Y'],
]);
self::assertResponseStatusCodeSame(403);
$http->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
self::assertResponseStatusCodeSame(403);
}
// === Helpers ===
// violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase.
private function firstSiteIri(): string
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.');
return '/api/sites/'.$site->getId();
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\Supplier;
/**
* Tests d'unicite fournisseur (M2, RG-2.11). Le doublon de companyName (409) est
* couvert par {@see SupplierApiTest::testPostDuplicateCompanyNameReturns409}. Ce
* fichier prouve l'envers de la decision § 2.6 : SIREN NON unique (etablissements
* multiples). Jumeau de ClientUniquenessTest (M1).
*
* @internal
*/
final class SupplierUniquenessTest extends AbstractSupplierApiTestCase
{
public function testDuplicateSirenIsAllowed(): void
{
self::bootKernel();
$em = $this->getEm();
$one = $this->seedSupplier('Siren Share One');
$two = $this->seedSupplier('Siren Share Two');
// Le SIREN n'est pas ecrivable au POST (groupe accounting) : seed direct.
$one->setSiren('123456789');
$two->setSiren('123456789');
$em->flush();
// Aucune exception : pas d'index unique sur siren (§ 2.6).
self::assertSame('123456789', $em->getRepository(Supplier::class)->find($one->getId())?->getSiren());
self::assertSame('123456789', $em->getRepository(Supplier::class)->find($two->getId())?->getSiren());
}
}