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 $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 $sitePostalCodes * * @return array */ 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 $permissionCodes * @param list $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 $body corps decode (toArray(false)) * * @return array propertyPath => message */ protected function violationsByPath(array $body): array { $byPath = []; foreach ($body['violations'] ?? [] as $v) { $byPath[$v['propertyPath']] = $v['message']; } return $byPath; } }