Files
Starseed/tests/Module/Commercial/Api/SupplierSubResourceApiTest.php
T
tristan 222338e5a4
Auto Tag Develop / tag (push) Successful in 7s
fix(commercial) : validation onglet compta LCR + controle croise BIC/IBAN (ERP-118) (#78)
## ERP-118 — Validation onglet Comptabilité (LCR / RIB)

### 1. Fix — 422 « Au moins un RIB est obligatoire pour le type de règlement LCR »

L'onglet Comptabilité envoyait le `PATCH /clients/{id}` des scalaires (`paymentType=LCR`) **avant** le `POST /clients/{id}/ribs`. Or le back valide RG-1.13 (LCR ⟹ ≥1 RIB persisté) sur ce PATCH, en lisant les RIB en base — vides à ce stade. Résultat : 422, et le `return` empêchait la création des RIB. Premier passage en LCR impossible (deadlock).

**Correctif :** inverser l'ordre — RIB d'abord, puis PATCH des scalaires.
- `new.vue` : `POST/PATCH RIB` → `PATCH scalaires`.
- `[id]/edit.vue` : ordre universel `CREATE/UPDATE RIB` → `PATCH scalaires` → `DELETE RIB retirés` (suppressions après le PATCH : le guard back n'autorise la suppression du dernier RIB qu'une fois quitté LCR). Corrige au passage un 409 latent sur le swap du dernier RIB en LCR.

### 2. Feat — contrôle croisé pays BIC/IBAN

`Assert\Bic(ibanPropertyPath: 'iban')` sur `ClientRib` et `SupplierRib` : le pays du BIC (positions 5-6) doit correspondre au pays de l'IBAN (positions 1-2). Un BIC et un IBAN valides isolément mais de pays différents → 422, violation portée par le champ `bic` avec message FR (`ibanMessage`), mappée inline côté front. Aucune modif front nécessaire.

### Tests

- Tests fonctionnels du mismatch (BIC DE + IBAN FR → 422 sur `propertyPath=bic`, message FR) côté client et fournisseur.
- Suite back complète au vert (garde-fou `EntityConstraintsHaveFrenchMessageTest` inclus), suite front Vitest au vert.

### Points d'attention

- **Durcissement de RG** (cross-check BIC/IBAN) hors spec initiale : des RIB existants avec BIC/IBAN de pays différents deviendraient non modifiables sans correction.
- L'orchestration de submit n'est pas couverte par un test unitaire (pas d'infra de test composant sur ces écrans) — vérification golden path recommandée.

Reviewed-on: #78
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-09 08:44:12 +00:00

383 lines
15 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);
}
/**
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
* un IBAN (FR) valides isolement mais de pays differents -> 422. La violation
* porte propertyPath=bic et le message FR `ibanMessage` (mapping inline front).
*/
public function testPostRibWithBicIbanCountryMismatchReturns422WithFrenchMessageOnBic(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Rib Pays Mismatch');
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['label' => 'Compte incoherent', 'bic' => self::FOREIGN_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('bic', $byPath, 'Le mismatch pays BIC/IBAN doit porter propertyPath=bic (mapping front).');
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
}
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();
}
}