Files
Starseed/tests/Module/Technique/Api/AbstractProviderApiTestCase.php
T
matthieu 3fe0f676f6
Auto Tag Develop / tag (push) Successful in 11s
test(technique) : couvrir RG-3.x PHPUnit + capturer le contrat JSON (ERP-139) (#100)
Ticket Lesstime #139 (M3 — Répertoire prestataires, position 1.9). DoD back avant le front : suite PHPUnit consolidée sur la matrice § 8.1 + captures JSON réelles dans la spec § 4.0.bis.

## Contenu
- **Fix réfs comptables** : `provider:read:accounting` ajouté sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` — sans ça elles sortaient en IRI nu dans le détail prestataire (réplique du fix ERP-92 du M2, piège #1 § 4.0.bis).
- **`ProviderSerializationContractTest`** (13 tests) : gating RIB/scalaires par omission, réfs compta en objet `{id,code,label}`, `isArchived`, embed categories/sites liste+détail, sous-collections, enveloppe AP4 ; `testDodReferenceJsonShape` dumpe le JSON réel (`PROVIDER_DOD_DUMP=1`).
- **`ProviderAuditTest`** (5 tests) : create/update/archive (`technique.Provider`), iban/bic dans le diff (`technique.ProviderRib`, pas dAuditIgnore), trace M2M `sites`.
- **`ProviderListTest`** étendu : `?pagination=false`, anti-N+1, filtre `?typeCode=PRESTATAIRE`.
- **`ProviderRbacGatingTest`** étendu : restauration en conflit de nom → 409 (RG-3.14).
- **`ProviderFixtures`** (§ 8.4) : démo idempotente (complet VIREMENT+banque+RIB, LCR+RIB, CHEQUE multi-cat, minimal, archivé) répartie sur sites 86/17/82 ; skip en env `test`.
- Helper `seedCompleteProvider` ; spec § 4.0.bis : gabarits remplacés par les captures réelles (liste + détail avec/sans accounting.view).

## Vérifications
- `make php-cs-fixer-allow-risky` → 0 fichier
- `make test` → OK, 677 tests, 3328 assertions (garde-fous globaux verts)

## Notes
- MR stackée sur ERP-138 (base = sa branche).
- Fixtures démo exercées en dev via `make fixtures` (autowiring vérifié).

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #100
2026-06-12 14:44:43 +00:00

490 lines
18 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\Bank;
use App\Module\Commercial\Domain\Entity\PaymentDelay;
use App\Module\Commercial\Domain\Entity\PaymentType;
use App\Module\Commercial\Domain\Entity\TvaMode;
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\ProviderAddress;
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;
}
/**
* Seede un prestataire COMPLET (sans passer par l'API — validations applicatives
* non rejouees mais CHECK BDD respectes) : bloc comptable non nul (SIREN + refs),
* >= 1 RIB, >= 2 sites en relation DIRECTE (formulaire principal, RG-3.03), >= 1
* adresse multi-sites (>= 2 sites) avec >= 1 categorie PRESTATAIRE et >= 1 contact,
* >= 1 contact et >= 1 categorie sur le prestataire. Socle du contrat de
* serialisation et de la DoD (§ 4.0.bis), jumeau de seedCompleteSupplier (M2)
* mais SANS onglet Information (absent au M3) et AVEC sites directs sur le
* prestataire (NOUVEAU M3 — la liste affiche provider.sites, pas un agregat
* d'adresses).
*
* @param string $paymentTypeCode code du type de reglement a poser (defaut LCR,
* coherent avec le RIB seede ; RG-3.08)
*/
protected function seedCompleteProvider(string $companyName, string $paymentTypeCode = 'LCR'): Provider
{
$em = $this->getEm();
// Nom unique parmi les actifs (index partiel uq_provider_company_name_active).
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
$provider = new Provider();
$provider->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
$provider->addCategory($this->providerCategory('NETTOYAGE'));
// Bloc comptable non nul (gating par omission cote sans accounting.view).
$provider->setSiren('987654321');
$provider->setAccountNumber('P0001');
$provider->setNTva('FR00987654321');
$provider->setTvaMode($this->tvaMode('FRANCE_VENTES'));
$provider->setPaymentDelay($this->paymentDelay('J30'));
$provider->setPaymentType($this->paymentType($paymentTypeCode));
if ('VIREMENT' === $paymentTypeCode) {
$provider->setBank($this->bank('SG'));
}
// >= 2 sites fixtures : relation DIRECTE provider.sites (RG-3.03) pour la
// LISTE + reutilises sur l'adresse multi-sites pour le DETAIL.
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');
foreach ($sites as $site) {
$provider->addSite($site);
}
$em->persist($provider);
$contact = new ProviderContact();
$contact->setProvider($provider);
$contact->setFirstName('Marie');
$contact->setLastName('Martin');
$contact->setJobTitle('Responsable');
$contact->setPhonePrimary('0612345678');
$contact->setEmail('marie.martin@seed.test');
$provider->addContact($contact);
$em->persist($contact);
// Adresse simplifiee M3 (PAS de addressType / bennes / triageProvider).
$address = new ProviderAddress();
$address->setProvider($provider);
$address->setCountry('France');
$address->setPostalCode('86000');
$address->setCity('Poitiers');
$address->setStreet('12 rue des Acacias');
foreach ($sites as $site) {
$address->addSite($site);
}
$address->addCategory($this->providerCategory('NETTOYAGE'));
$address->addContact($contact);
$provider->addAddress($address);
$em->persist($address);
$rib = new ProviderRib();
$rib->setProvider($provider);
$rib->setLabel('Compte principal');
$rib->setBic(self::VALID_BIC);
$rib->setIban(self::VALID_IBAN);
$provider->addRib($rib);
$em->persist($rib);
$em->flush();
return $provider;
}
/**
* Recupere un mode de TVA seede (CommercialReferentialFixtures) par code (ex.
* FRANCE_VENTES). Echoue explicitement si absent (fixtures non chargees).
*/
protected function tvaMode(string $code): TvaMode
{
$tvaMode = $this->getEm()->getRepository(TvaMode::class)->findOneBy(['code' => $code]);
self::assertNotNull(
$tvaMode,
sprintf('Mode de TVA "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
);
return $tvaMode;
}
/**
* Recupere un delai de reglement seede (CommercialReferentialFixtures) par code
* (ex. J30). Echoue explicitement si absent (fixtures non chargees).
*/
protected function paymentDelay(string $code): PaymentDelay
{
$paymentDelay = $this->getEm()->getRepository(PaymentDelay::class)->findOneBy(['code' => $code]);
self::assertNotNull(
$paymentDelay,
sprintf('Delai de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
);
return $paymentDelay;
}
/**
* 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;
}
/**
* Recupere une banque seedee (CommercialReferentialFixtures) par code (ex. SG).
* Echoue explicitement si absente (fixtures non chargees).
*/
protected function bank(string $code): Bank
{
$bank = $this->getEm()->getRepository(Bank::class)->findOneBy(['code' => $code]);
self::assertNotNull(
$bank,
sprintf('Banque "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code),
);
return $bank;
}
/**
* 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;
}
}