Files
Starseed/tests/Module/Commercial/Api/SupplierApiTest.php
T
matthieu 6f9bb68170
Auto Tag Develop / tag (push) Successful in 7s
test(commercial) : tests PHPUnit M2 fournisseurs (matrice RG + contrat sérialisation + DoD JSON réel) (ERP-92) (#71)
## 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>
2026-06-08 07:49:17 +00:00

181 lines
6.9 KiB
PHP

<?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.');
}
}