0ca1fb159a
Coeur API du repertoire prestataires (M3), jumeau du M2 fournisseurs : - ProviderProvider : liste paginee (Paginator ORM), filtres search/categoryCode/siteId/includeArchived, tri companyName ASC, exclusion archives + soft-deletes (RG-3.16). Cloisonnement par site pilote par l'utilisateur (RG-3.17 / § 2.13) : liste restreinte au currentSite avant pagination (totalItems = perimetre), detail hors perimetre -> 404, bypass via sites.bypass_scope. - ProviderProcessor : normalisation companyName (RG-3.11), POST formulaire principal (companyName + categories + sites), PATCH partiels par groupe en mode strict (RG-3.15, 403 sur tout le payload), archivage (RG-3.13/3.14), 409 doublon de nom (RG-3.10), garde d'ecriture cloisonnee des sites (RG-3.03/3.17, 422 sur sites pour les users sites.read_ref). - ProviderReadGroupContextBuilder : gating comptabilite par AJOUT du groupe provider:read:accounting si accounting.view (jamais par retrait). - ProviderFieldNormalizer : miroir SupplierFieldNormalizer. - ApiResource cable (provider + processor) sur l'entite Provider. Tests : ProviderApiTest, ProviderListTest, ProviderRbacGatingTest, ProviderSiteScopeTest (26 tests). Suite complete verte (612 tests).
288 lines
10 KiB
PHP
288 lines
10 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\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\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
|
|
|
|
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];
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|