diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index 9ae95fd..5040e02 100644 --- a/src/Module/Catalog/Domain/Entity/Category.php +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -153,6 +153,16 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt return $this; } + /** + * Implemente CategoryInterface : code du type rattache (ou null). Permet + * aux modules tiers de filtrer/valider par type metier sans dependre de + * Catalog. + */ + public function getCategoryTypeCode(): ?string + { + return $this->categoryType?->getCode(); + } + public function getDeletedAt(): ?DateTimeImmutable { return $this->deletedAt; diff --git a/src/Module/Commercial/Application/Service/ClientFieldNormalizer.php b/src/Module/Commercial/Application/Service/ClientFieldNormalizer.php new file mode 100644 index 0000000..606fd4d --- /dev/null +++ b/src/Module/Commercial/Application/Service/ClientFieldNormalizer.php @@ -0,0 +1,80 @@ + "0612345678" (RG-1.20). + * Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front. + * - email : lowercase integral (RG-1.21) + * + * Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide + * apres trim devient null (evite de persister "" dans des colonnes nullable). + */ +final class ClientFieldNormalizer +{ + /** + * Nom de societe en majuscules (RG-1.18). Conserve null tel quel ; une + * chaine non vide est trim + upper. Une chaine vide reste "" (champ + * obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer). + */ + public function normalizeCompanyName(?string $value): ?string + { + if (null === $value) { + return null; + } + + return mb_strtoupper(trim($value), 'UTF-8'); + } + + /** + * Nom/prenom de personne en Title Case (RG-1.19) : "JEAN dupont" -> + * "Jean Dupont". Une chaine vide apres trim devient null. + */ + public function normalizePersonName(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8'); + } + + /** + * Email en minuscules (RG-1.21). Une chaine vide apres trim devient null. + */ + public function normalizeEmail(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : mb_strtolower($value, 'UTF-8'); + } + + /** + * Telephone reduit aux chiffres (RG-1.20) : "06.12.34.56.78" -> + * "0612345678". Une valeur sans aucun chiffre devient null. + */ + public function normalizePhone(?string $value): ?string + { + if (null === $value) { + return null; + } + + $digits = preg_replace('/\D+/', '', $value) ?? ''; + + return '' === $digits ? null : $digits; + } +} diff --git a/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php b/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php new file mode 100644 index 0000000..154184d --- /dev/null +++ b/src/Module/Commercial/Application/Validator/ClientInformationCompletenessValidator.php @@ -0,0 +1,76 @@ + valeur courante de l'onglet Information. + $fields = [ + 'description' => $client->getDescription(), + 'competitors' => $client->getCompetitors(), + 'foundedAt' => $client->getFoundedAt(), + 'employeesCount' => $client->getEmployeesCount(), + 'revenueAmount' => $client->getRevenueAmount(), + 'directorName' => $client->getDirectorName(), + 'profitAmount' => $client->getProfitAmount(), + ]; + + $violations = new ConstraintViolationList(); + + foreach ($fields as $property => $value) { + if ($this->isMissing($value)) { + $violations->add(new ConstraintViolation( + sprintf('Ce champ est obligatoire pour le role Commerciale (champ "%s").', $property), + null, + [], + $client, + $property, + $value, + )); + } + } + + if (count($violations) > 0) { + throw new ValidationException($violations); + } + } + + /** + * Une valeur est manquante si null ou, pour une chaine, vide apres trim. + * Les zeros numeriques (employeesCount = 0, profitAmount = "0.00") sont des + * valeurs valides : on ne les considere pas manquants. + */ + private function isMissing(mixed $value): bool + { + if (null === $value) { + return true; + } + + return is_string($value) && '' === trim($value); + } +} diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index 8eee053..6240975 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Commercial\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor; +use App\Module\Commercial\Infrastructure\ApiPlatform\State\Provider\ClientProvider; use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; @@ -15,6 +22,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Validator\Constraints as Assert; /** @@ -36,9 +44,62 @@ use Symfony\Component\Validator\Constraints as Assert; * - categories : M2M vers Category (module Catalog) via le contrat * CategoryInterface + resolve_target_entities (regle n°1, pas d'import direct). * - * Aucun ApiResource au M1.1 (ERP-54) : les operations API (Provider + Processor, - * normalisation, archivage, accounting conditionnel) sont branchees en ERP-55. + * Operations API (Provider + Processor) branchees en ERP-55 : + * - GetCollection / Get : security commercial.clients.view. La liste expose le + * groupe client:read ; le detail embarque en plus contacts/adresses/ribs + * (groupe client:item:read). Les champs comptables (client:read:accounting) + * sont ajoutes DYNAMIQUEMENT par ClientReadGroupContextBuilder si l'user a + * la permission accounting.view (§ 2.7 / § 4.1 / § 4.2). + * - Post / Patch : security commercial.clients.manage ; le ClientProcessor + * applique normalisation, gating accounting/archive et regles metier. + * - Pas de Delete au M1 (HP-M2-1) : l'archivage passe par PATCH isArchived. */ +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('commercial.clients.view')", + normalizationContext: ['groups' => ['client:read', 'default:read']], + provider: ClientProvider::class, + ), + new Get( + security: "is_granted('commercial.clients.view')", + // Detail : client + sous-collections embarquees. Le groupe + // client:read:accounting est ajoute par le context builder selon la + // permission, donc absent ici volontairement. + normalizationContext: ['groups' => [ + 'client:read', + 'client:item:read', + 'client_contact:read', + 'client_address:read', + 'client_rib:read', + 'default:read', + ]], + provider: ClientProvider::class, + ), + new Post( + security: "is_granted('commercial.clients.manage')", + normalizationContext: ['groups' => ['client:read', 'default:read']], + denormalizationContext: ['groups' => ['client:write:main']], + processor: ClientProcessor::class, + ), + new Patch( + security: "is_granted('commercial.clients.manage')", + // Le ClientProcessor inspecte les champs reellement envoyes pour + // autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les + // champs accounting exigent accounting.manage, isArchived exige + // archive. + normalizationContext: ['groups' => ['client:read', 'default:read']], + denormalizationContext: ['groups' => [ + 'client:write:main', + 'client:write:information', + 'client:write:accounting', + 'client:write:archive', + ]], + provider: ClientProvider::class, + processor: ClientProcessor::class, + ), + ], +)] #[ORM\Entity(repositoryClass: DoctrineClientRepository::class)] #[ORM\Table(name: 'client')] // Index nommes pour matcher la migration (Version20260601000000). L'index @@ -202,8 +263,13 @@ class Client implements TimestampableInterface, BlamableInterface private Collection $ribs; // === Archive / Soft delete === + // Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH + // archive). Le groupe de LECTURE est declare sur le getter isArchived() + // avec SerializedName('isArchived') : sans cela, Symfony strip le prefixe + // "is" et exposerait la cle JSON "archived" (meme pattern que User::isAdmin + // et Role::isSystem). #[ORM\Column(name: 'is_archived', options: ['default' => false])] - #[Groups(['client:read', 'client:write:archive'])] + #[Groups(['client:write:archive'])] private bool $isArchived = false; #[ORM\Column(type: 'datetime_immutable', nullable: true)] @@ -526,6 +592,7 @@ class Client implements TimestampableInterface, BlamableInterface } /** @return Collection */ + #[Groups(['client:item:read'])] public function getContacts(): Collection { return $this->contacts; @@ -551,6 +618,7 @@ class Client implements TimestampableInterface, BlamableInterface } /** @return Collection */ + #[Groups(['client:item:read'])] public function getAddresses(): Collection { return $this->addresses; @@ -576,6 +644,7 @@ class Client implements TimestampableInterface, BlamableInterface } /** @return Collection */ + #[Groups(['client:item:read'])] public function getRibs(): Collection { return $this->ribs; @@ -600,6 +669,10 @@ class Client implements TimestampableInterface, BlamableInterface return $this; } + // Groupe de lecture + nom serialise explicite : sans SerializedName, Symfony + // exposerait la cle "archived" (strip du prefixe "is" sur les getters). + #[Groups(['client:read'])] + #[SerializedName('isArchived')] public function isArchived(): bool { return $this->isArchived; diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php new file mode 100644 index 0000000..a1f1e72 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/CategoryReferenceDenormalizer.php @@ -0,0 +1,62 @@ +`, donc + * l'INTERFACE. Or le serializer ne sait pas denormaliser un IRI vers une + * interface (« Could not denormalize object of type CategoryInterface[] ») : il + * lui faut une classe-ressource concrete. On resout donc l'IRI via l'IriConverter + * (qui retourne la Category mappee a la route) sans importer Category — la regle + * ABSOLUE n°1 reste respectee (dependance au seul contrat Shared + API Platform). + * + * En lecture (normalisation), aucun probleme : l'objet reel EST une Category, + * resource a part entiere, serialisee en IRI par le normalizer standard. + */ +final class CategoryReferenceDenormalizer implements DenormalizerInterface +{ + public function __construct( + private readonly IriConverterInterface $iriConverter, + ) {} + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?CategoryInterface + { + if (!is_string($data) || '' === $data) { + return null; + } + + // getResourceFromIri leve une exception sur IRI invalide -> 400, ce qui + // est le comportement attendu pour une reference cassee. + $resource = $this->iriConverter->getResourceFromIri($data); + + return $resource instanceof CategoryInterface ? $resource : null; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + // Support base sur le seul type cible : l'ArrayDenormalizer (collection + // `CategoryInterface[]`) interroge le support en passant le TABLEAU + // complet comme $data avant de deleguer element par element. Tester + // is_string($data) ici casserait donc la chaine pour les collections. + return CategoryInterface::class === $type; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [CategoryInterface::class => true]; + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/ClientReadGroupContextBuilder.php b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/ClientReadGroupContextBuilder.php new file mode 100644 index 0000000..9d5220b --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/Serializer/ClientReadGroupContextBuilder.php @@ -0,0 +1,65 @@ +decorated->createFromRequest($request, $normalization, $extractedAttributes); + + // Uniquement en lecture, sur la ressource Client, avec la permission. + if (!$normalization) { + return $context; + } + + if (Client::class !== ($context['resource_class'] ?? null)) { + return $context; + } + + if (!$this->security->isGranted('commercial.clients.accounting.view')) { + return $context; + } + + $groups = $context['groups'] ?? []; + if (!in_array('client:read:accounting', $groups, true)) { + $groups[] = 'client:read:accounting'; + } + $context['groups'] = $groups; + + return $context; + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php new file mode 100644 index 0000000..e083744 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientProcessor.php @@ -0,0 +1,362 @@ + exige accounting.manage (RG-1.28, 403) ; + * - champ isArchived dans le payload -> exige archive (RG-1.22, 403) et + * interdit toute autre modification dans la meme requete (RG-1.22, 422). + * 2. Normalisation serveur (RG-1.18 a 1.21) via ClientFieldNormalizer. + * 3. Regles metier : RG-1.01 (prenom/nom), RG-1.03 (distributor/broker + * exclusifs + type de categorie), RG-1.12 (Virement -> banque), + * RG-1.13 (LCR -> >= 1 RIB), RG-1.04 (completude Information pour le role + * Commerciale). + * 4. Pose / retrait de archivedAt (RG-1.22 true=now, RG-1.23 false=null). + * 5. Persistance via le persist_processor Doctrine, avec traduction des + * collisions d'unicite en 409 (RG-1.16 doublon de nom ; RG-1.23 conflit de + * restauration). + * + * Note : la validation Symfony (Assert\NotBlank, Assert\Email, Assert\Count sur + * categories...) est jouee par API Platform AVANT ce processor ; on n'y traite + * donc que les regles non exprimables en simples contraintes d'attribut. + * + * @implements ProcessorInterface + */ +final class ClientProcessor implements ProcessorInterface +{ + /** Champs de l'onglet principal (groupe client:write:main). */ + private const array MAIN_FIELDS = [ + 'companyName', 'firstName', 'lastName', 'phonePrimary', 'phoneSecondary', + 'email', 'distributor', 'broker', 'triageService', 'categories', + ]; + + /** Champs de l'onglet Information (groupe client:write:information). */ + private const array INFORMATION_FIELDS = [ + 'description', 'competitors', 'foundedAt', 'employeesCount', + 'revenueAmount', 'directorName', 'profitAmount', + ]; + + /** Champs de l'onglet Comptabilite (groupe client:write:accounting). */ + private const array ACCOUNTING_FIELDS = [ + 'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', + 'paymentType', 'bank', + ]; + + /** Champ d'archivage (groupe client:write:archive). */ + private const string ARCHIVE_FIELD = 'isArchived'; + + private const string PERM_ACCOUNTING_MANAGE = 'commercial.clients.accounting.manage'; + private const string PERM_ARCHIVE = 'commercial.clients.archive'; + + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + private readonly ClientFieldNormalizer $normalizer, + private readonly ClientInformationCompletenessValidator $informationValidator, + private readonly Security $security, + private readonly RequestStack $requestStack, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof Client) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + $payloadKeys = $this->payloadKeys(); + + $isArchiveRequest = $this->guardArchive($data, $payloadKeys); + $this->guardAccounting($payloadKeys); + + $this->normalize($data); + + $this->validateMainContact($data); + $this->validateDistributorBroker($data); + $this->validateAccountingConsistency($data); + $this->validateInformationCompleteness($data, $payloadKeys); + + try { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } catch (UniqueConstraintViolationException $e) { + // Le seul index unique partiel est uq_client_company_name_active + // (LOWER(company_name) parmi non-archives/non-deletes — decision Q4). + if ($isArchiveRequest && false === $data->isArchived()) { + // RG-1.23 : restauration en conflit avec un homonyme actif. + throw new ConflictHttpException( + 'Restauration impossible : un autre client a pris le nom entre-temps.', + $e, + ); + } + + // RG-1.16 : doublon de nom de societe. + throw new ConflictHttpException( + sprintf('Un client nommé "%s" existe déjà.', (string) $data->getCompanyName()), + $e, + ); + } + } + + /** + * RG-1.22 / RG-1.23 : si le payload porte isArchived, exige la permission + * archive (403), interdit toute autre modification (422) et pose/retire + * archivedAt. Retourne true si la requete est une requete d'archivage. + * + * @param list $payloadKeys + */ + private function guardArchive(Client $data, array $payloadKeys): bool + { + if (!in_array(self::ARCHIVE_FIELD, $payloadKeys, true)) { + return false; + } + + if (!$this->security->isGranted(self::PERM_ARCHIVE)) { + throw new AccessDeniedHttpException(sprintf( + 'Le champ "%s" requiert la permission "%s".', + self::ARCHIVE_FIELD, + self::PERM_ARCHIVE, + )); + } + + // RG-1.22 : une requete d'archivage ne modifie aucun autre champ. + if ([] !== array_diff($payloadKeys, [self::ARCHIVE_FIELD])) { + throw new UnprocessableEntityHttpException( + 'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".', + ); + } + + // RG-1.22 (true -> now) / RG-1.23 (false -> null). + $data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null); + + return true; + } + + /** + * RG-1.28 : un champ comptable dans le payload exige accounting.manage, + * sinon 403 sur l'ensemble du payload (mode strict, pas de filtrage + * silencieux). Le message precise le premier champ fautif. + * + * @param list $payloadKeys + */ + private function guardAccounting(array $payloadKeys): void + { + $touched = array_values(array_intersect($payloadKeys, self::ACCOUNTING_FIELDS)); + + if ([] === $touched) { + return; + } + + if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) { + throw new AccessDeniedHttpException(sprintf( + 'Le champ "%s" requiert la permission "%s".', + $touched[0], + self::PERM_ACCOUNTING_MANAGE, + )); + } + } + + /** + * Normalisation serveur (RG-1.18 a 1.21). Les setters non-nullables + * (companyName, email, phonePrimary) ne sont touches que si une valeur est + * presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel. + */ + private function normalize(Client $data): void + { + if (null !== $data->getCompanyName()) { + $data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName())); + } + if (null !== $data->getEmail()) { + $data->setEmail((string) $this->normalizer->normalizeEmail($data->getEmail())); + } + if (null !== $data->getPhonePrimary()) { + $data->setPhonePrimary((string) $this->normalizer->normalizePhone($data->getPhonePrimary())); + } + + $data->setFirstName($this->normalizer->normalizePersonName($data->getFirstName())); + $data->setLastName($this->normalizer->normalizePersonName($data->getLastName())); + $data->setPhoneSecondary($this->normalizer->normalizePhone($data->getPhoneSecondary())); + } + + /** + * RG-1.01 : au moins le prenom OU le nom du contact principal. + */ + private function validateMainContact(Client $data): void + { + if (null === $data->getFirstName() && null === $data->getLastName()) { + $this->throwViolation( + 'firstName', + 'Le prénom ou le nom du contact principal est obligatoire.', + $data, + ); + } + } + + /** + * RG-1.03 : distributor et broker mutuellement exclusifs ; un distributor + * doit referencer un client de categorie DISTRIBUTEUR (idem broker -> + * COURTIER). + */ + private function validateDistributorBroker(Client $data): void + { + $distributor = $data->getDistributor(); + $broker = $data->getBroker(); + + if (null !== $distributor && null !== $broker) { + $this->throwViolation( + 'distributor', + 'Un client ne peut pas être rattaché à la fois à un distributeur et à un courtier.', + $data, + ); + } + + if (null !== $distributor && !$this->hasCategoryType($distributor, 'DISTRIBUTEUR')) { + $this->throwViolation( + 'distributor', + 'Le distributeur référencé doit être un client de catégorie DISTRIBUTEUR.', + $data, + ); + } + + if (null !== $broker && !$this->hasCategoryType($broker, 'COURTIER')) { + $this->throwViolation( + 'broker', + 'Le courtier référencé doit être un client de catégorie COURTIER.', + $data, + ); + } + } + + /** + * RG-1.12 : Virement -> banque obligatoire. RG-1.13 : LCR -> au moins un RIB. + */ + private function validateAccountingConsistency(Client $data): void + { + $paymentCode = $data->getPaymentType()?->getCode(); + + if ('VIREMENT' === $paymentCode && null === $data->getBank()) { + $this->throwViolation( + 'bank', + 'La banque est obligatoire pour le type de règlement Virement.', + $data, + ); + } + + if ('LCR' === $paymentCode && $data->getRibs()->isEmpty()) { + $this->throwViolation( + 'paymentType', + 'Au moins un RIB est obligatoire pour le type de règlement LCR.', + $data, + ); + } + } + + /** + * RG-1.04 : si l'utilisateur porte le role metier Commerciale ET que le + * payload touche l'onglet Information, tous les champs Information sont + * obligatoires. Dormant tant qu'aucun user ne porte le role `commerciale`. + * + * @param list $payloadKeys + */ + private function validateInformationCompleteness(Client $data, array $payloadKeys): void + { + $touchesInformation = [] !== array_intersect($payloadKeys, self::INFORMATION_FIELDS); + + if ($touchesInformation && $this->currentUserIsCommerciale()) { + $this->informationValidator->validate($data); + } + } + + /** + * Vrai si au moins une categorie du client porte le type donne. S'appuie + * sur CategoryInterface::getCategoryTypeCode() (pas d'import de Category). + */ + private function hasCategoryType(Client $client, string $typeCode): bool + { + foreach ($client->getCategories() as $category) { + if ($category instanceof CategoryInterface && $category->getCategoryTypeCode() === $typeCode) { + return true; + } + } + + return false; + } + + private function currentUserIsCommerciale(): bool + { + $user = $this->security->getUser(); + + return $user instanceof BusinessRoleAwareInterface + && $user->hasBusinessRole(BusinessRoles::COMMERCIALE); + } + + /** + * Cles de premier niveau effectivement envoyees par le client (payload JSON + * brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies ; + * c'est ce qui permet le gating par onglet (RG-1.22 / RG-1.28) et le + * declenchement conditionnel de RG-1.04. + * + * @return list + */ + private function payloadKeys(): array + { + $request = $this->requestStack->getCurrentRequest(); + if (null === $request) { + return []; + } + + $content = $request->getContent(); + if ('' === $content) { + return []; + } + + try { + $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return []; + } + + return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : []; + } + + /** + * Leve une ValidationException (HTTP 422) portant une violation unique sur + * la propriete visee — meme rendu Hydra que les contraintes Symfony. + * + * @return never + */ + private function throwViolation(string $property, string $message, Client $root): void + { + $violations = new ConstraintViolationList(); + $violations->add(new ConstraintViolation($message, null, [], $root, $property, null)); + + throw new ValidationException($violations); + } +} diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php new file mode 100644 index 0000000..bbd6e52 --- /dev/null +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Provider/ClientProvider.php @@ -0,0 +1,170 @@ + (clients ayant >= 1 categorie de ce type) ; + * - pagination obligatoire (convention Starseed ERP-72) : Paginator ORM ; + * echappatoire ?pagination=false pour alimenter un cote front). + if (!$this->pagination->isEnabled($operation, $context)) { + /** @var list $result */ + return $qb->getQuery()->getResult(); + } + + $limit = $this->pagination->getLimit($operation, $context); + $page = max(1, $this->pagination->getPage($context)); + $offset = ($page - 1) * $limit; + + $qb->setFirstResult($offset)->setMaxResults($limit); + + // fetchJoinCollection: true pour un COUNT correct des que des JOINs + // to-many seront ajoutes (sous-collections embarquees en detail). + return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true)); + } + + /** + * @param array $uriVariables + */ + private function provideItem(array $uriVariables): ?Client + { + $id = $uriVariables['id'] ?? null; + if (!is_int($id) && !(is_string($id) && ctype_digit($id))) { + return null; + } + + $client = $this->repository->findById((int) $id); + if (null === $client) { + return null; + } + + // Soft-delete : jamais expose au M1 (HP-M2-1) — 404 via retour null. + // Les archives restent visibles en detail (consultation + restauration). + if (null !== $client->getDeletedAt()) { + return null; + } + + return $client; + } + + /** + * Recherche fuzzy insensible a la casse sur companyName + lastName + email. + * Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester + * litteraux. + */ + private function applySearch(QueryBuilder $qb, mixed $search): void + { + if (!is_string($search) || '' === trim($search)) { + return; + } + + $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search)); + $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%'; + + $qb->andWhere( + 'LOWER(c.companyName) LIKE :search ' + .'OR LOWER(c.lastName) LIKE :search ' + .'OR LOWER(c.email) LIKE :search', + )->setParameter('search', $pattern); + } + + /** + * Restreint aux clients possedant au moins une categorie du type donne. + * Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne pas + * perturber le DISTINCT / ORDER BY de la requete paginee principale. + */ + private function applyCategoryType(QueryBuilder $qb, mixed $categoryType): void + { + if (!is_string($categoryType) || '' === trim($categoryType)) { + return; + } + + $sub = $this->repository->createQueryBuilder('c2') + ->select('c2.id') + ->join('c2.categories', 'cat2') + ->join('cat2.categoryType', 'ct2') + ->where('ct2.code = :categoryType') + ; + + $qb->andWhere($qb->expr()->in('c.id', $sub->getDQL())) + ->setParameter('categoryType', trim($categoryType)) + ; + } + + /** + * Lit un flag booleen issu des query params. Accepte true / "true" / "1". + */ + private function readBool(mixed $raw): bool + { + if (is_bool($raw)) { + return $raw; + } + + return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); + } +} diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index 00e6d50..8016543 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -22,6 +22,7 @@ use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; // C'est le pattern officiel Doctrine pour les bounded contexts DDD. use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\AuditIgnore; +use App\Shared\Domain\Contract\BusinessRoleAwareInterface; use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Exception\SiteNotAuthorizedException; use DateTimeImmutable; @@ -75,7 +76,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName; #[ORM\Entity(repositoryClass: DoctrineUserRepository::class)] #[ORM\Table(name: '`user`')] #[Auditable] -class User implements UserInterface, PasswordAuthenticatedUserInterface +class User implements UserInterface, PasswordAuthenticatedUserInterface, BusinessRoleAwareInterface { #[ORM\Id] #[ORM\GeneratedValue] @@ -337,6 +338,23 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $keys; } + /** + * Implemente BusinessRoleAwareInterface : vrai si l'un des roles RBAC + * rattaches porte le code donne. Permet aux modules tiers de detecter un + * role metier (ex: `commerciale` pour RG-1.04 du M1 Clients) sans importer + * cette classe. Comparaison stricte sur Role::code. + */ + public function hasBusinessRole(string $roleCode): bool + { + foreach ($this->rbacRoles as $role) { + if ($role->getCode() === $roleCode) { + return true; + } + } + + return false; + } + public function getPassword(): ?string { return $this->password; diff --git a/src/Shared/Domain/Contract/BusinessRoleAwareInterface.php b/src/Shared/Domain/Contract/BusinessRoleAwareInterface.php new file mode 100644 index 0000000..92646a4 --- /dev/null +++ b/src/Shared/Domain/Contract/BusinessRoleAwareInterface.php @@ -0,0 +1,27 @@ + purge manuelle obligatoire. + * + * @internal + */ +abstract class AbstractCommercialApiTestCase extends AbstractApiTestCase +{ + protected const string TEST_CATEGORY_PREFIX = 'test_cli_cat_'; + + protected function tearDown(): void + { + $this->cleanupCommercialTestData(); + parent::tearDown(); + } + + protected function createAdminClient(): Client + { + return $this->authenticatedClient('admin', 'admin'); + } + + /** + * Recupere (ou cree) un CategoryType par son code metier. Idempotent : la + * contrainte d'unicite sur category_type.code interdit les doublons. + */ + protected function createCategoryType(string $code): CategoryType + { + $em = $this->getEm(); + $existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => $code]); + if (null !== $existing) { + return $existing; + } + + $type = new CategoryType(); + $type->setCode($code); + $type->setLabel(ucfirst(strtolower($code))); + $em->persist($type); + $em->flush(); + + return $type; + } + + /** + * Cree une Category de test rattachee a un type metier donne (code). + */ + protected function createCategory(string $typeCode = 'SECTEUR'): Category + { + $em = $this->getEm(); + $suffix = substr(bin2hex(random_bytes(4)), 0, 8); + $category = new Category(); + $category->setName(self::TEST_CATEGORY_PREFIX.$suffix); + $category->setCategoryType($this->createCategoryType($typeCode)); + $em->persist($category); + $em->flush(); + + return $category; + } + + /** + * Seede directement un Client en base (sans passer par l'API), pour les + * tests de liste / archivage. Le client porte une categorie SECTEUR. + */ + protected function seedClient(string $companyName, bool $isArchived = false, string $categoryTypeCode = 'SECTEUR'): ClientEntity + { + $em = $this->getEm(); + $client = new ClientEntity(); + // Stocke en MAJUSCULES pour refleter l'etat normalise (RG-1.18) qu'aurait + // produit le ClientProcessor via l'API. + $client->setCompanyName(mb_strtoupper($companyName, 'UTF-8')); + $client->setLastName('Seed'); + $client->setPhonePrimary('0102030405'); + $client->setEmail(strtolower(str_replace(' ', '', $companyName)).'@seed.test'); + $client->addCategory($this->createCategory($categoryTypeCode)); + $client->setIsArchived($isArchived); + if ($isArchived) { + $client->setArchivedAt(new DateTimeImmutable()); + } + $em->persist($client); + $em->flush(); + + return $client; + } + + private function cleanupCommercialTestData(): void + { + $em = $this->getEm(); + + // Clients d'abord (la jointure client_category est purgee par + // ON DELETE CASCADE ; les auto-references distributor/broker sont + // ON DELETE SET NULL). + $em->createQuery('DELETE FROM '.ClientEntity::class)->execute(); + + // Categories de test ensuite (FK client_category deja purgee). + $em->createQuery( + 'DELETE FROM '.Category::class.' c WHERE c.name LIKE :prefix', + )->setParameter('prefix', self::TEST_CATEGORY_PREFIX.'%')->execute(); + + // Users / roles jetables. + $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(); + } +} diff --git a/tests/Module/Commercial/Api/ClientApiTest.php b/tests/Module/Commercial/Api/ClientApiTest.php new file mode 100644 index 0000000..605a07e --- /dev/null +++ b/tests/Module/Commercial/Api/ClientApiTest.php @@ -0,0 +1,285 @@ +createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + + $response = $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'acme sas', + 'firstName' => 'JEAN', + 'lastName' => 'dupont', + 'phonePrimary' => '06.12.34.56.78', + 'email' => 'Jean.DUPONT@ACME.FR', + 'categories' => ['/api/categories/'.$cat->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(201); + $data = $response->toArray(); + // RG-1.18 / 1.19 / 1.20 / 1.21 + self::assertSame('ACME SAS', $data['companyName']); + self::assertSame('Jean', $data['firstName']); + self::assertSame('Dupont', $data['lastName']); + self::assertSame('0612345678', $data['phonePrimary']); + self::assertSame('jean.dupont@acme.fr', $data['email']); + self::assertFalse($data['isArchived']); + } + + public function testPostDuplicateCompanyNameReturns409(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + $iri = '/api/categories/'.$cat->getId(); + + $payload = [ + 'companyName' => 'Doublon SARL', + 'firstName' => 'A', + 'phonePrimary' => '0102030405', + 'email' => 'dup@test.fr', + 'categories' => [$iri], + ]; + + $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]); + self::assertResponseStatusCodeSame(201); + + // Meme nom (insensible a la casse via l'index LOWER) -> 409 (RG-1.16). + $payload['email'] = 'dup2@test.fr'; + $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload]); + self::assertResponseStatusCodeSame(409); + } + + public function testPostWithoutFirstOrLastNameReturns422(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + + $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'No Contact Name', + 'phonePrimary' => '0102030405', + 'email' => 'nc@test.fr', + 'categories' => ['/api/categories/'.$cat->getId()], + ], + ]); + + // RG-1.01 + self::assertResponseStatusCodeSame(422); + } + + public function testPostWithoutCategoryReturns422(): void + { + $client = $this->createAdminClient(); + + $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'No Category', + 'firstName' => 'A', + 'phonePrimary' => '0102030405', + 'email' => 'nocat@test.fr', + 'categories' => [], + ], + ]); + + // Assert\Count(min: 1) + self::assertResponseStatusCodeSame(422); + } + + public function testPostWithDistributorAndBrokerReturns422(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + $distributor = $this->seedClient('Distrib Mutex', false, 'DISTRIBUTEUR'); + + $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'Mutex Client', + 'firstName' => 'A', + 'phonePrimary' => '0102030405', + 'email' => 'mutex@test.fr', + 'categories' => ['/api/categories/'.$cat->getId()], + 'distributor' => '/api/clients/'.$distributor->getId(), + 'broker' => '/api/clients/'.$distributor->getId(), + ], + ]); + + // RG-1.03 (exclusivite) + self::assertResponseStatusCodeSame(422); + } + + public function testPostDistributorReferencingNonDistributorReturns422(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + $notDistro = $this->seedClient('Pas Un Distrib', false, 'SECTEUR'); + + $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'Bad Distrib Ref', + 'firstName' => 'A', + 'phonePrimary' => '0102030405', + 'email' => 'baddistrib@test.fr', + 'categories' => ['/api/categories/'.$cat->getId()], + 'distributor' => '/api/clients/'.$notDistro->getId(), + ], + ]); + + // RG-1.03 (le distributor doit etre categorise DISTRIBUTEUR) + self::assertResponseStatusCodeSame(422); + } + + public function testPostValidDistributorReturns201(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + $distributor = $this->seedClient('Vrai Distrib', false, 'DISTRIBUTEUR'); + + $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'Client Avec Distrib', + 'firstName' => 'A', + 'phonePrimary' => '0102030405', + 'email' => 'okdistrib@test.fr', + 'categories' => ['/api/categories/'.$cat->getId()], + 'distributor' => '/api/clients/'.$distributor->getId(), + ], + ]); + + self::assertResponseStatusCodeSame(201); + } + + public function testListSortedByCompanyNameAscAndExcludesArchived(): void + { + $client = $this->createAdminClient(); + $this->seedClient('Zebra Co'); + $this->seedClient('Alpha Co'); + $this->seedClient('Archivé Co', true); + + $names = $client->request('GET', '/api/clients?pagination=false', [ + 'headers' => ['Accept' => self::LD], + ])->toArray()['member']; + $companyNames = array_map(static fn (array $c): string => $c['companyName'], $names); + + // RG-1.24 : l'archive est exclue par defaut. + self::assertNotContains('ARCHIVÉ CO', $companyNames); + // RG-1.26 : tri companyName ASC (Alpha avant Zebra). + $alpha = array_search('ALPHA CO', $companyNames, true); + $zebra = array_search('ZEBRA CO', $companyNames, true); + self::assertNotFalse($alpha); + self::assertNotFalse($zebra); + self::assertLessThan($zebra, $alpha); + } + + public function testListIncludeArchivedReturnsArchived(): void + { + $client = $this->createAdminClient(); + $this->seedClient('Hidden Archived', true); + + $members = $client->request('GET', '/api/clients?includeArchived=true&pagination=false', [ + 'headers' => ['Accept' => self::LD], + ])->toArray()['member']; + $names = array_map(static fn (array $c): string => $c['companyName'], $members); + + // RG-1.25 + self::assertContains('HIDDEN ARCHIVED', $names); + } + + public function testCollectionIsPaginated(): void + { + $client = $this->createAdminClient(); + $this->seedClient('Paginated One'); + + // Collection Hydra avec total (la cle `view` n'apparait qu'a partir de + // 2 pages cote API Platform 4, donc non assertable sur page unique). + $page1 = $client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertArrayHasKey('totalItems', $page1); + self::assertNotEmpty($page1['member']); + + // Preuve que la pagination serveur est bien engagee : la page 2 d'un jeu + // tenant sur une page est vide (un provider non pagine ignorerait `page` + // et renverrait quand meme les items). + $page2 = $client->request('GET', '/api/clients?page=2', ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertSame([], $page2['member']); + } + + public function testPatchArchiveSetsArchivedAtThenRestore(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('To Archive'); + $iri = '/api/clients/'.$seed->getId(); + + // Archive (RG-1.22) : admin a la permission archive via bypass isAdmin. + $archived = $client->request('PATCH', $iri, [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isArchived' => true], + ])->toArray(); + self::assertResponseStatusCodeSame(200); + self::assertTrue($archived['isArchived']); + self::assertNotNull($archived['archivedAt']); + + // Restauration (RG-1.23) : archivedAt repasse a null. + $restored = $client->request('PATCH', $iri, [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isArchived' => false], + ])->toArray(); + self::assertResponseStatusCodeSame(200); + self::assertFalse($restored['isArchived']); + self::assertNull($restored['archivedAt']); + } + + public function testPatchArchiveWithOtherFieldReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Archive Plus Field'); + + $client->request('PATCH', '/api/clients/'.$seed->getId(), [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['isArchived' => true, 'companyName' => 'Renamed'], + ]); + + // RG-1.22 : une requete d'archivage ne modifie aucun autre champ. + self::assertResponseStatusCodeSame(422); + } + + public function testGetDetailEmbedsSubCollections(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedClient('Detail Embed'); + + $data = $client->request('GET', '/api/clients/'.$seed->getId(), [ + 'headers' => ['Accept' => self::LD], + ])->toArray(); + + // § 4.2 : le detail embarque contacts / adresses / ribs. + self::assertArrayHasKey('contacts', $data); + self::assertArrayHasKey('addresses', $data); + self::assertArrayHasKey('ribs', $data); + } +} diff --git a/tests/Module/Commercial/Unit/ClientFieldNormalizerTest.php b/tests/Module/Commercial/Unit/ClientFieldNormalizerTest.php new file mode 100644 index 0000000..f31823a --- /dev/null +++ b/tests/Module/Commercial/Unit/ClientFieldNormalizerTest.php @@ -0,0 +1,56 @@ +normalizer = new ClientFieldNormalizer(); + } + + public function testCompanyNameIsUppercased(): void + { + // RG-1.18 + self::assertSame('ACME SAS', $this->normalizer->normalizeCompanyName(' acme sas ')); + self::assertNull($this->normalizer->normalizeCompanyName(null)); + } + + public function testPersonNameIsTitleCased(): void + { + // RG-1.19 + self::assertSame('Jean', $this->normalizer->normalizePersonName('JEAN')); + self::assertSame('Dupont', $this->normalizer->normalizePersonName('dupont')); + self::assertNull($this->normalizer->normalizePersonName(' ')); + self::assertNull($this->normalizer->normalizePersonName(null)); + } + + public function testEmailIsLowercased(): void + { + // RG-1.21 + self::assertSame('jean.dupont@acme.fr', $this->normalizer->normalizeEmail(' Jean.DUPONT@ACME.FR ')); + self::assertNull($this->normalizer->normalizeEmail(null)); + self::assertNull($this->normalizer->normalizeEmail(' ')); + } + + public function testPhoneKeepsOnlyDigits(): void + { + // RG-1.20 + self::assertSame('0612345678', $this->normalizer->normalizePhone('06.12.34.56.78')); + self::assertSame('0612345678', $this->normalizer->normalizePhone('06 12 34 56 78')); + self::assertNull($this->normalizer->normalizePhone('----')); + self::assertNull($this->normalizer->normalizePhone(null)); + } +} diff --git a/tests/Module/Commercial/Unit/ClientProcessorTest.php b/tests/Module/Commercial/Unit/ClientProcessorTest.php new file mode 100644 index 0000000..7552d1d --- /dev/null +++ b/tests/Module/Commercial/Unit/ClientProcessorTest.php @@ -0,0 +1,253 @@ + 403. + $processor = $this->makeProcessor(granted: [], payload: ['siren' => '123456789']); + + $this->expectException(AccessDeniedHttpException::class); + $processor->process($this->minimalClient(), $this->operation()); + } + + public function testStrictMixWithAccountingFieldIsForbidden(): void + { + // RG-1.28 : payload mixant main + accounting sans la permission -> 403 + // sur l'ensemble (pas de filtrage silencieux). + $processor = $this->makeProcessor( + granted: [], + payload: ['companyName' => 'X', 'siren' => '123456789'], + ); + + $this->expectException(AccessDeniedHttpException::class); + $processor->process($this->minimalClient(), $this->operation()); + } + + public function testArchiveWithoutPermissionIsForbidden(): void + { + // RG-1.22 : isArchived sans la permission archive -> 403. + $processor = $this->makeProcessor(granted: [], payload: ['isArchived' => true]); + + $this->expectException(AccessDeniedHttpException::class); + $processor->process($this->minimalClient(), $this->operation()); + } + + public function testArchiveWithOtherFieldIsUnprocessable(): void + { + // RG-1.22 : une requete d'archivage ne modifie aucun autre champ. + $processor = $this->makeProcessor( + granted: ['commercial.clients.archive'], + payload: ['isArchived' => true, 'companyName' => 'X'], + ); + + $this->expectException(UnprocessableEntityHttpException::class); + $processor->process($this->minimalClient(), $this->operation()); + } + + public function testVirementWithoutBankIsUnprocessable(): void + { + // RG-1.12 + $client = $this->minimalClient(); + $client->setPaymentType($this->paymentType('VIREMENT')); + + $processor = $this->makeProcessor( + granted: ['commercial.clients.accounting.manage'], + payload: ['paymentType' => '/api/payment_types/1'], + ); + + $this->expectException(ValidationException::class); + $processor->process($client, $this->operation()); + } + + public function testVirementWithBankPasses(): void + { + // RG-1.12 satisfait : Virement + banque. + $client = $this->minimalClient(); + $client->setPaymentType($this->paymentType('VIREMENT')); + $client->setBank(new Bank()); + + $processor = $this->makeProcessor( + granted: ['commercial.clients.accounting.manage'], + payload: ['paymentType' => '/api/payment_types/1', 'bank' => '/api/banks/1'], + ); + + $result = $processor->process($client, $this->operation()); + self::assertInstanceOf(Client::class, $result); + } + + public function testLcrWithoutRibIsUnprocessable(): void + { + // RG-1.13 + $client = $this->minimalClient(); + $client->setPaymentType($this->paymentType('LCR')); + + $processor = $this->makeProcessor( + granted: ['commercial.clients.accounting.manage'], + payload: ['paymentType' => '/api/payment_types/2'], + ); + + $this->expectException(ValidationException::class); + $processor->process($client, $this->operation()); + } + + public function testLcrWithRibPasses(): void + { + // RG-1.13 satisfait : LCR + au moins un RIB. + $client = $this->minimalClient(); + $client->setPaymentType($this->paymentType('LCR')); + $client->addRib(new ClientRib()); + + $processor = $this->makeProcessor( + granted: ['commercial.clients.accounting.manage'], + payload: ['paymentType' => '/api/payment_types/2'], + ); + + self::assertInstanceOf(Client::class, $processor->process($client, $this->operation())); + } + + public function testCommercialeIncompleteInformationIsUnprocessable(): void + { + // RG-1.04 : role Commerciale + onglet Information incomplet -> 422. + $client = $this->minimalClient(); + $client->setDescription('Une description'); // les autres champs Information restent null + + $processor = $this->makeProcessor( + granted: [], + payload: ['description' => 'Une description'], + user: $this->commercialeUser(), + ); + + $this->expectException(ValidationException::class); + $processor->process($client, $this->operation()); + } + + public function testNonCommercialeSkipsInformationCompleteness(): void + { + // Meme payload incomplet, mais user non-Commerciale -> aucun blocage. + $client = $this->minimalClient(); + $client->setDescription('Une description'); + + $processor = $this->makeProcessor( + granted: [], + payload: ['description' => 'Une description'], + user: null, + ); + + self::assertInstanceOf(Client::class, $processor->process($client, $this->operation())); + } + + /** + * @param list $granted Permissions accordees a l'utilisateur courant + * @param array $payload Corps JSON simule de la requete + */ + private function makeProcessor(array $granted, array $payload, ?UserInterface $user = null): ClientProcessor + { + $persist = new class implements ProcessorInterface { + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + return $data; + } + }; + + $security = $this->createStub(Security::class); + $security->method('isGranted')->willReturnCallback( + static fn (mixed $attribute): bool => is_string($attribute) && in_array($attribute, $granted, true), + ); + $security->method('getUser')->willReturn($user); + + $requestStack = new RequestStack(); + $requestStack->push(new Request([], [], [], [], [], [], json_encode($payload, JSON_THROW_ON_ERROR))); + + return new ClientProcessor( + $persist, + new ClientFieldNormalizer(), + new ClientInformationCompletenessValidator(), + $security, + $requestStack, + ); + } + + /** + * Client minimal valide vis-a-vis de RG-1.01 (un nom de contact) — suffisant + * pour atteindre les validations testees. + */ + private function minimalClient(): Client + { + $client = new Client(); + $client->setCompanyName('Test Co'); + $client->setLastName('Dupont'); + $client->setPhonePrimary('0102030405'); + $client->setEmail('t@test.fr'); + + return $client; + } + + private function paymentType(string $code): PaymentType + { + $type = new PaymentType(); + $type->setCode($code); + $type->setLabel($code); + + return $type; + } + + private function operation(): Operation + { + return $this->createStub(Operation::class); + } + + private function commercialeUser(): UserInterface + { + return new class implements UserInterface, BusinessRoleAwareInterface { + public function hasBusinessRole(string $roleCode): bool + { + return BusinessRoles::COMMERCIALE === $roleCode; + } + + public function getRoles(): array + { + return ['ROLE_USER']; + } + + public function eraseCredentials(): void {} + + public function getUserIdentifier(): string + { + return 'commerciale-test'; + } + }; + } +} diff --git a/tests/Module/Commercial/Unit/ClientReadGroupContextBuilderTest.php b/tests/Module/Commercial/Unit/ClientReadGroupContextBuilderTest.php new file mode 100644 index 0000000..60f6eea --- /dev/null +++ b/tests/Module/Commercial/Unit/ClientReadGroupContextBuilderTest.php @@ -0,0 +1,85 @@ +builder( + baseContext: ['resource_class' => Client::class, 'groups' => ['client:read', 'default:read']], + granted: true, + ); + + $context = $builder->createFromRequest(new Request(), true); + + self::assertContains('client:read:accounting', $context['groups']); + } + + public function testDoesNotAddAccountingGroupWhenNotGranted(): void + { + $builder = $this->builder( + baseContext: ['resource_class' => Client::class, 'groups' => ['client:read', 'default:read']], + granted: false, + ); + + $context = $builder->createFromRequest(new Request(), true); + + self::assertNotContains('client:read:accounting', $context['groups']); + } + + public function testDoesNotAddAccountingGroupOnWrite(): void + { + $builder = $this->builder( + baseContext: ['resource_class' => Client::class, 'groups' => ['client:write:main']], + granted: true, + ); + + // normalization = false -> ecriture : pas de groupe de lecture ajoute. + $context = $builder->createFromRequest(new Request(), false); + + self::assertNotContains('client:read:accounting', $context['groups']); + } + + public function testIgnoresOtherResources(): void + { + $builder = $this->builder( + baseContext: ['resource_class' => 'App\Other\Resource', 'groups' => ['other:read']], + granted: true, + ); + + $context = $builder->createFromRequest(new Request(), true); + + self::assertSame(['other:read'], $context['groups']); + } + + /** + * @param array $baseContext + */ + private function builder(array $baseContext, bool $granted): ClientReadGroupContextBuilder + { + $decorated = $this->createStub(SerializerContextBuilderInterface::class); + $decorated->method('createFromRequest')->willReturn($baseContext); + + $security = $this->createStub(Security::class); + $security->method('isGranted')->willReturn($granted); + + return new ClientReadGroupContextBuilder($decorated, $security); + } +}