Files
Starseed/tests/Module/Technique/Api/ProviderSubResourceApiTest.php
T
Matthieu 9a0da4de63 feat(technique) : sous-ressources Contacts / Adresses / RIBs (ERP-135)
Expose les sous-collections du prestataire en #[ApiResource] (POST sur le
parent + PATCH/DELETE/GET unitaires), edition complete par onglet (pas de
POST-only, RETEX M1/M2) :

- ProviderContact : POST /providers/{id}/contacts, PATCH/DELETE
  /provider_contacts/{id} (security technique.providers.manage).
  ProviderContactProcessor : normalisation RG-3.11 (nom/prenom Title Case,
  telephones chiffres, email lowercase) + RG-3.04 (au moins un champ parmi
  prenom/nom/telephone/email, miroir du CHECK chk_provider_contact_name -> 422).
- ProviderAddress : POST /providers/{id}/addresses, PATCH/DELETE
  /provider_addresses/{id} (security technique.providers.manage).
  ProviderAddressProcessor : rattachement parent + cloisonnement d'ecriture des
  sites de l'adresse (RG-3.05 / § 2.13 : site hors user_site -> 422 sur sites).
- ProviderRib : POST /providers/{id}/ribs, PATCH/DELETE /provider_ribs/{id}
  (security technique.providers.accounting.manage). ProviderRibProcessor :
  RG-3.08 (DELETE du dernier RIB sous LCR -> 409).

Tests : ProviderSubResourceApiTest (19 cas) — CRUD chaque sous-ressource, 403
selon permission (Contacts/Adresses=manage, RIB=accounting.manage), 409 dernier
RIB LCR, 422 cloisonnement site adresse. Helpers addContact/addRib/paymentType
ajoutes a AbstractProviderApiTestCase.
2026-06-12 11:32:08 +02:00

393 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use App\Module\Technique\Domain\Entity\Provider;
/**
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
* (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
* (au moins un champ parmi prenom/nom/telephone/email), RG-3.05 (>= 1 site sur
* l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse),
* le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`),
* RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de
* garde « dernier contact ») et le gating selon permission (Contacts/Adresses =
* manage, RIB = accounting.manage). Jumeau de SupplierSubResourceApiTest.
*
* @internal
*/
final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
{
protected function setUp(): void
{
parent::setUp();
// seedProvider exige >= 1 site (RG-3.03) : le module Sites doit etre actif.
$this->skipIfSitesModuleDisabled();
}
// === Contacts (security: technique.providers.manage) ===
public function testPostContactNormalizesFields(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact Host');
$data = $client->request('POST', '/api/providers/'.$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-3.11 : 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']);
}
/**
* RG-3.04 : un bloc sans aucun champ du CHECK (prenom/nom/telephone/email) est
* rejete avant la base (chk_provider_contact_name) -> 422 rattachee a firstName.
* Ici seul jobTitle est fourni (hors CHECK).
*/
public function testPostContactWithoutNamedFieldReturns422OnFirstNamePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact No Name');
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => 'Directeur'],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
}
public function testPostContactOnMissingProviderReturns404(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/providers/999999/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Orphan'],
]);
self::assertResponseStatusCodeSame(404);
}
public function testPatchContactNormalizesFields(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact Patch');
$contact = $this->addContact($seed, 'Marie', 'Martin');
$data = $client->request('PATCH', '/api/provider_contacts/'.$contact->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['lastName' => 'durand'],
])->toArray();
self::assertResponseStatusCodeSame(200);
// Normalisation aussi sur PATCH : "durand" -> "Durand".
self::assertSame('Durand', $data['lastName']);
}
public function testDeleteLastContactReturns204(): void
{
// M3 : pas de garde « dernier contact » (RG-3.12 front-driven) — la
// suppression du dernier contact est libre (204).
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact Solo');
$contact = $this->addContact($seed, 'Unique', 'Contact');
$client->request('DELETE', '/api/provider_contacts/'.$contact->getId());
self::assertResponseStatusCodeSame(204);
}
public function testContactWriteWithoutManageReturns403(): void
{
// Un user sans permission technique.providers.manage -> 403 sur la sous-ressource.
$seed = $this->seedProvider('Contact Forbidden');
$creds = $this->createUserWithPermission('core.users.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => ['firstName' => 'Nope'],
]);
self::assertResponseStatusCodeSame(403);
}
// === Adresses (security: technique.providers.manage) ===
public function testPostAddressWithValidPayloadReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Host');
$category = $this->providerCategory('NETTOYAGE');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Châtellerault', $data['city']);
}
public function testPostAddressWithoutSiteReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address No Site');
$client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [],
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
],
]);
// RG-3.05 (Assert\Count min 1 sur sites).
self::assertResponseStatusCodeSame(422);
}
public function testPostAddressWithInvalidPostalCodeReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Bad CP');
$client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '123',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
],
]);
// RG-3.06 (Assert\Regex ^[0-9]{4,5}$).
self::assertResponseStatusCodeSame(422);
}
public function testPostAddressWithNonPrestataireCategoryReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Bad Cat');
$foreign = $this->foreignCategory(); // type CLIENT -> interdite (RG-3.09).
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$foreign->getId()],
],
]);
// RG-3.09 -> 422 rattachee a categories.
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
}
public function testDeleteAddressReturns204(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Delete');
$category = $this->providerCategory('NETTOYAGE');
$created = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray();
$client->request('DELETE', $created['@id']);
self::assertResponseStatusCodeSame(204);
}
public function testAddressWriteWithoutManageReturns403(): void
{
$seed = $this->seedProvider('Address Forbidden');
$creds = $this->createUserWithPermission('core.users.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
],
]);
self::assertResponseStatusCodeSame(403);
}
/**
* § 2.13 / RG-3.05 (cloisonnement d'ECRITURE sur l'adresse) : un user non-bypass
* `sites.read_ref` (qui peut resoudre n'importe quel IRI de site, sinon 400 en
* amont) ne peut attacher a l'adresse que ses propres user_site. Site hors
* perimetre -> 422 sur `sites` (garde ProviderAddressProcessor).
*/
public function testPostAddressWithOutOfScopeSiteReturns422OnSitesPath(): void
{
$seed = $this->seedProvider('Address Scope', [self::SITE_86]);
$category = $this->providerCategory('NETTOYAGE');
$creds = $this->createScopedUser(
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '17400',
'city' => 'Saint-Jean-d\'Angély',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_17)->getId()], // hors user_site
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
}
// === RIBs (security: technique.providers.accounting.manage) ===
public function testPostRibByAdminReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Host');
$data = $client->request('POST', '/api/providers/'.$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->seedProvider('Rib Bad Iban');
$client->request('POST', '/api/providers/'.$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 sur `bic`.
*/
public function testPostRibWithBicIbanCountryMismatchReturns422OnBic(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Pays Mismatch');
$response = $client->request('POST', '/api/providers/'.$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);
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
}
public function testDeleteRibNonLcrReturns204(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Non LCR');
$rib = $this->addRib($seed);
$client->request('DELETE', '/api/provider_ribs/'.$rib->getId());
self::assertResponseStatusCodeSame(204);
}
public function testDeleteLastRibUnderLcrReturns409(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib LCR Solo');
$rib = $this->addRib($seed);
// Passe le prestataire en LCR (seed direct).
$em = $this->getEm();
$managed = $em->getRepository(Provider::class)->find($seed->getId());
$managed->setPaymentType($this->paymentType('LCR'));
$em->flush();
$client->request('DELETE', '/api/provider_ribs/'.$rib->getId());
// RG-3.08 : LCR exige >= 1 RIB -> suppression du dernier refusee.
self::assertResponseStatusCodeSame(409);
}
public function testRibWriteWithoutAccountingManageReturns403(): void
{
// Un user portant seulement technique.providers.manage (sans accounting.manage)
// ne peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5).
$seed = $this->seedProvider('Rib Forbidden');
$rib = $this->addRib($seed);
$creds = $this->createUserWithPermission('technique.providers.manage');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/providers/'.$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/provider_ribs/'.$rib->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['label' => 'Y'],
]);
self::assertResponseStatusCodeSame(403);
$http->request('DELETE', '/api/provider_ribs/'.$rib->getId());
self::assertResponseStatusCodeSame(403);
}
}