9cda225bdf
Auto Tag Develop / tag (push) Successful in 8s
Correctifs issus de la review lead du stack M2 fournisseurs (ERP-84→113), répartis en priorités. Base : `develop`. Suite verte : `make test` 577 tests / 2475 assertions, `php-cs-fixer` 0 correction. ## P1 — défauts bloquants - **ERP-89** — Le message de complétude Information ne fuit plus le nom de champ technique (`(champ "%s")` retiré). Correction miroir appliquée aux deux validators (Supplier + Client), accent uniformisé. Le `propertyPath` est conservé pour le mapping inline front. - **ERP-112** — La fixture fournisseurs résout désormais la catégorie en filtrant sur le type `FOURNISSEUR` (via `CategoryInterface::getCategoryTypeCode()`), évitant de rattacher une catégorie homonyme d'un autre type (RG-2.10). - **ERP-113** — Tests d'export complétés : dédup F3 (fournisseur multi-catégories rendu sur une seule ligne) ; gating SIREN prouvé via un utilisateur minimal non-admin portant `suppliers.view` + `suppliers.accounting.view` (nouveau helper `createUserWithPermissions`). ## P2 / P3 - **ERP-86** — `maxMessage` explicite sur `competitors` (Supplier). - **ERP-92** — Garde `skipIfSitesModuleDisabled()` sur le test POST adresse sans site (évite un faux positif si le module Sites est désactivé). - **ERP-89 bis** — Nouveau test : Admin authentifié non-Commerciale + Information incomplète → 200 (distinct du cas `user=null`). - **ERP-85** — `down()` de la migration fournisseurs en `DROP TABLE IF EXISTS`. - **ERP-87** — Reset de la mémoïsation payload en début de `process()` du SupplierProcessor + documentation du filtre `?archivedOnly` de l'export (parité avec le provider liste). - **spec-back.md (M2)** — Alignée sur le code (le code fait foi) : security PATCH `manage or accounting.manage`, gating accounting par ajout de groupe (`SupplierReadGroupContextBuilder`), anti-N+1 via `hydrateListCollections` (pas de fetch-join), types de colonnes réels (`IDENTITY` / `TIMESTAMP(0)`). ## Alignement M1 ↔ M2 - **ERP-86/87 (Client)** — Mêmes corrections appliquées aux jumeaux M1 : message `competitors` explicite + reset mémoïsation `ClientProcessor`. ## Décision actée - **RG-2.10 (catégorie)** : court-circuit conservé (une seule violation sur `categories`). Les violations partageant path + message sont fusionnées côté front ; ERP-101 (toutes les erreurs en un aller-retour) est déjà respecté car le Callback n'interrompt pas la validation des autres champs. --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #74 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
362 lines
14 KiB
PHP
362 lines
14 KiB
PHP
<?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
|
|
{
|
|
// Sans cette garde, un module Sites desactive renverrait 404 (route
|
|
// /addresses indisponible) et le test passerait pour la MAUVAISE raison
|
|
// au lieu de prouver RG-2.06 (Assert\Count min 1 sur sites).
|
|
$this->skipIfSitesModuleDisabled();
|
|
$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();
|
|
}
|
|
}
|