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.
355 lines
13 KiB
PHP
355 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Module\Technique\Api;
|
|
|
|
use ApiPlatform\Symfony\Bundle\Test\Client;
|
|
use App\Module\Catalog\Domain\Entity\Category;
|
|
use App\Module\Catalog\Domain\Entity\CategoryType;
|
|
use App\Module\Commercial\Domain\Entity\PaymentType;
|
|
use App\Module\Core\Domain\Entity\Permission;
|
|
use App\Module\Core\Domain\Entity\Role;
|
|
use App\Module\Core\Domain\Entity\User;
|
|
use App\Module\Sites\Domain\Entity\Site;
|
|
use App\Module\Technique\Domain\Entity\Provider;
|
|
use App\Module\Technique\Domain\Entity\ProviderContact;
|
|
use App\Module\Technique\Domain\Entity\ProviderRib;
|
|
use App\Tests\Module\Core\Api\AbstractApiTestCase;
|
|
use DateTimeImmutable;
|
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|
|
|
/**
|
|
* Base des tests fonctionnels du repertoire prestataires (M3 — module Technique).
|
|
* Jumelle de la base fournisseurs (M2), recentree sur le perimetre ERP-134
|
|
* (Provider + Processor + cloisonnement site).
|
|
*
|
|
* Donnees (RETEX M1/M2 — pas de fixtures globales pour les tests) : chaque test
|
|
* seede ses prestataires en base via les helpers ci-dessous, puis le tearDown les
|
|
* purge. Les 3 sites (Chatellerault 86 / Saint-Jean 17 / Pommevic 82) sont seedes
|
|
* par SitesFixtures (make test-db-setup) ; on les recupere par code postal.
|
|
*
|
|
* Categories : `providerCategory('NETTOYAGE')` fetch-or-create une categorie de
|
|
* type PRESTATAIRE (requis par RG-3.09). Pour fabriquer une categorie d'un AUTRE
|
|
* type (test de rejet RG-3.09), utiliser `foreignCategory()`.
|
|
*
|
|
* Cleanup : tearDown purge prestataires AVANT categories/users (provider_category
|
|
* et provider_site sont ON DELETE CASCADE cote provider — le DELETE DQL sur
|
|
* Provider libere categories et sites pour les purges suivantes).
|
|
*
|
|
* @internal
|
|
*/
|
|
abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
|
|
{
|
|
protected const string LD = 'application/ld+json';
|
|
protected const string MERGE = 'application/merge-patch+json';
|
|
|
|
protected const string TEST_CATEGORY_PREFIX = 'test_prov_cat_';
|
|
|
|
/** Codes postaux des 3 sites fixtures (cf. SitesFixtures). */
|
|
protected const string SITE_86 = '86100'; // Chatellerault
|
|
protected const string SITE_17 = '17400'; // Saint-Jean
|
|
protected const string SITE_82 = '82400'; // Pommevic
|
|
|
|
/** IBAN / BIC valides (memes valeurs que les tests M2) pour les RIB. */
|
|
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
|
|
protected const string VALID_BIC = 'BNPAFRPPXXX';
|
|
/** BIC d'un autre pays (DE) : controle croise pays BIC/IBAN. */
|
|
protected const string FOREIGN_BIC = 'DEUTDEFFXXX';
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$em = $this->getEm();
|
|
|
|
$em->createQuery('DELETE FROM '.Provider::class)->execute();
|
|
$em->createQuery('DELETE FROM '.Category::class.' c WHERE c.name LIKE :prefix')
|
|
->setParameter('prefix', self::TEST_CATEGORY_PREFIX.'%')->execute()
|
|
;
|
|
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix')
|
|
->setParameter('prefix', 'test_%')->execute()
|
|
;
|
|
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix')
|
|
->setParameter('prefix', 'test_%')->execute()
|
|
;
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
protected function createAdminClient(): Client
|
|
{
|
|
return $this->authenticatedClient('admin', 'admin');
|
|
}
|
|
|
|
/**
|
|
* Recupere (ou cree) le type PRESTATAIRE. Idempotent (unicite category_type.code).
|
|
*/
|
|
protected function providerCategoryType(): CategoryType
|
|
{
|
|
$em = $this->getEm();
|
|
$existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'PRESTATAIRE']);
|
|
if (null !== $existing) {
|
|
return $existing;
|
|
}
|
|
|
|
$type = new CategoryType();
|
|
$type->setCode('PRESTATAIRE');
|
|
$type->setLabel('Prestataire');
|
|
$em->persist($type);
|
|
$em->flush();
|
|
|
|
return $type;
|
|
}
|
|
|
|
/**
|
|
* Fetch-or-create une categorie de type PRESTATAIRE par code (defaut NETTOYAGE).
|
|
* Idempotent (lookup par code, aligne sur l'index unique partiel uq_category_code)
|
|
* et auto-suffisant. Nom prefixe -> purge par tearDown.
|
|
*/
|
|
protected function providerCategory(string $code = 'NETTOYAGE'): Category
|
|
{
|
|
$em = $this->getEm();
|
|
$existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]);
|
|
if (null !== $existing) {
|
|
return $existing;
|
|
}
|
|
|
|
$category = new Category();
|
|
$category->setName(self::TEST_CATEGORY_PREFIX.strtolower($code));
|
|
$category->setCode($code);
|
|
$category->addCategoryType($this->providerCategoryType());
|
|
$em->persist($category);
|
|
$em->flush();
|
|
|
|
return $category;
|
|
}
|
|
|
|
/**
|
|
* Cree une categorie d'un type DIFFERENT de PRESTATAIRE (pour tester le rejet
|
|
* RG-3.09). Code unique pour ne pas collisionner avec une categorie existante.
|
|
*/
|
|
protected function foreignCategory(): Category
|
|
{
|
|
$em = $this->getEm();
|
|
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
|
|
|
$type = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'CLIENT']);
|
|
if (null === $type) {
|
|
$type = new CategoryType();
|
|
$type->setCode('CLIENT');
|
|
$type->setLabel('Client');
|
|
$em->persist($type);
|
|
}
|
|
|
|
$category = new Category();
|
|
$category->setName(self::TEST_CATEGORY_PREFIX.'foreign_'.$suffix);
|
|
$category->setCode('FOREIGN_'.strtoupper($suffix));
|
|
$category->addCategoryType($type);
|
|
$em->persist($category);
|
|
$em->flush();
|
|
|
|
return $category;
|
|
}
|
|
|
|
/**
|
|
* Recupere un site fixture par code postal (cf. SitesFixtures). Echoue
|
|
* explicitement si absent (fixtures non chargees / module Sites off).
|
|
*/
|
|
protected function site(string $postalCode): Site
|
|
{
|
|
$site = $this->getEm()->getRepository(Site::class)->findOneBy(['postalCode' => $postalCode]);
|
|
|
|
self::assertNotNull(
|
|
$site,
|
|
sprintf('Site fixture "%s" introuvable : SitesFixtures charge (make test-db-setup) ?', $postalCode),
|
|
);
|
|
|
|
return $site;
|
|
}
|
|
|
|
/**
|
|
* Seede directement un Provider minimal (sans passer par l'API), pour les tests
|
|
* de liste / archivage / cloisonnement. Nom stocke en MAJUSCULES pour refleter
|
|
* l'etat normalise (RG-3.11) qu'aurait produit le ProviderProcessor. Porte une
|
|
* categorie PRESTATAIRE + les sites donnes (par code postal).
|
|
*
|
|
* @param list<string> $sitePostalCodes codes postaux des sites a rattacher
|
|
*/
|
|
protected function seedProvider(
|
|
string $companyName,
|
|
array $sitePostalCodes = [self::SITE_86],
|
|
bool $isArchived = false,
|
|
string $categoryCode = 'NETTOYAGE',
|
|
?string $siren = null,
|
|
): Provider {
|
|
$em = $this->getEm();
|
|
$provider = new Provider();
|
|
$provider->setCompanyName(mb_strtoupper($companyName, 'UTF-8'));
|
|
$provider->addCategory($this->providerCategory($categoryCode));
|
|
foreach ($sitePostalCodes as $postalCode) {
|
|
$provider->addSite($this->site($postalCode));
|
|
}
|
|
if (null !== $siren) {
|
|
$provider->setSiren($siren);
|
|
}
|
|
$provider->setIsArchived($isArchived);
|
|
if ($isArchived) {
|
|
$provider->setArchivedAt(new DateTimeImmutable());
|
|
}
|
|
$em->persist($provider);
|
|
$em->flush();
|
|
|
|
return $provider;
|
|
}
|
|
|
|
/**
|
|
* Payload minimal valide du formulaire principal (companyName + 1 categorie
|
|
* PRESTATAIRE + sites donnes). Categorie NETTOYAGE par defaut.
|
|
*
|
|
* @param list<string> $sitePostalCodes
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
protected function validMainPayload(string $companyName, array $sitePostalCodes = [self::SITE_86]): array
|
|
{
|
|
$siteIris = array_map(fn (string $pc): string => '/api/sites/'.$this->site($pc)->getId(), $sitePostalCodes);
|
|
|
|
return [
|
|
'companyName' => $companyName,
|
|
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
|
|
'sites' => $siteIris,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Cree un utilisateur non-admin CLOISONNE : porte les permissions donnees via
|
|
* un role jetable, rattache aux seuls sites donnes (par code postal), avec un
|
|
* currentSite positionne. N'a PAS `sites.bypass_scope` (sauf si fourni dans
|
|
* $permissionCodes) -> sujet ideal des tests de cloisonnement (RG-3.17).
|
|
*
|
|
* Contrairement a createUserWithPermissions() (parent, qui attache TOUS les
|
|
* sites et ne pose pas de currentSite), ce helper controle finement le
|
|
* perimetre site de l'user.
|
|
*
|
|
* @param list<string> $permissionCodes
|
|
* @param list<string> $sitePostalCodes sites a rattacher (user_site)
|
|
*
|
|
* @return array{username: string, password: string}
|
|
*/
|
|
protected function createScopedUser(
|
|
array $permissionCodes,
|
|
array $sitePostalCodes,
|
|
?string $currentSitePostalCode = null,
|
|
): array {
|
|
$em = $this->getEm();
|
|
|
|
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
|
|
$username = 'test_scoped_'.$suffix;
|
|
$password = 'testpass';
|
|
|
|
/** @var UserPasswordHasherInterface $hasher */
|
|
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
|
|
|
$role = new Role('test_'.$suffix, 'Test Role '.$suffix, false);
|
|
foreach ($permissionCodes as $code) {
|
|
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => $code]);
|
|
self::assertNotNull($permission, sprintf('Permission "%s" introuvable (app:sync-permissions ?).', $code));
|
|
$role->addPermission($permission);
|
|
}
|
|
$em->persist($role);
|
|
|
|
$user = new User();
|
|
$user->setUsername($username);
|
|
$user->setIsAdmin(false);
|
|
$user->setPassword($hasher->hashPassword($user, $password));
|
|
$user->addRbacRole($role);
|
|
|
|
foreach ($sitePostalCodes as $postalCode) {
|
|
$user->addSite($this->site($postalCode));
|
|
}
|
|
if (null !== $currentSitePostalCode) {
|
|
$user->setCurrentSite($this->site($currentSitePostalCode));
|
|
}
|
|
|
|
$em->persist($user);
|
|
$em->flush();
|
|
$em->clear();
|
|
|
|
return ['username' => $username, 'password' => $password];
|
|
}
|
|
|
|
/**
|
|
* Ajoute un contact a un prestataire deja persiste (seed direct).
|
|
*/
|
|
protected function addContact(
|
|
Provider $provider,
|
|
?string $firstName = 'Marie',
|
|
?string $lastName = 'Martin',
|
|
?string $phonePrimary = null,
|
|
?string $email = null,
|
|
int $position = 0,
|
|
): ProviderContact {
|
|
$contact = new ProviderContact();
|
|
$contact->setProvider($provider);
|
|
$contact->setFirstName($firstName);
|
|
$contact->setLastName($lastName);
|
|
$contact->setPhonePrimary($phonePrimary);
|
|
$contact->setEmail($email);
|
|
$contact->setPosition($position);
|
|
$provider->addContact($contact);
|
|
$this->getEm()->persist($contact);
|
|
$this->getEm()->flush();
|
|
|
|
return $contact;
|
|
}
|
|
|
|
/**
|
|
* Ajoute un RIB a un prestataire deja persiste (seed direct).
|
|
*/
|
|
protected function addRib(Provider $provider, string $label = 'Compte principal'): ProviderRib
|
|
{
|
|
$rib = new ProviderRib();
|
|
$rib->setProvider($provider);
|
|
$rib->setLabel($label);
|
|
$rib->setBic(self::VALID_BIC);
|
|
$rib->setIban(self::VALID_IBAN);
|
|
$provider->addRib($rib);
|
|
$this->getEm()->persist($rib);
|
|
$this->getEm()->flush();
|
|
|
|
return $rib;
|
|
}
|
|
|
|
/**
|
|
* Recupere un type de reglement seede (CommercialReferentialFixtures) par code
|
|
* (ex. LCR, VIREMENT). Echoue explicitement si absent (fixtures non chargees).
|
|
*/
|
|
protected function paymentType(string $code): PaymentType
|
|
{
|
|
$paymentType = $this->getEm()->getRepository(PaymentType::class)->findOneBy(['code' => $code]);
|
|
|
|
self::assertNotNull(
|
|
$paymentType,
|
|
sprintf('Type de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
|
|
);
|
|
|
|
return $paymentType;
|
|
}
|
|
|
|
/**
|
|
* Indexe les violations d'un corps 422 par propertyPath (assert ciblee).
|
|
*
|
|
* @param array<string, mixed> $body corps decode (toArray(false))
|
|
*
|
|
* @return array<string, string> propertyPath => message
|
|
*/
|
|
protected function violationsByPath(array $body): array
|
|
{
|
|
$byPath = [];
|
|
foreach ($body['violations'] ?? [] as $v) {
|
|
$byPath[$v['propertyPath']] = $v['message'];
|
|
}
|
|
|
|
return $byPath;
|
|
}
|
|
}
|