9a0da4de63
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.
393 lines
16 KiB
PHP
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);
|
|
}
|
|
}
|