403. En creation (POST), positionner siren est un // changement vs l'etat persiste vide. $client = $this->minimalClient(); $client->setSiren('123456789'); $processor = $this->makeProcessor(granted: [], payload: ['siren' => '123456789']); $this->expectException(AccessDeniedHttpException::class); $processor->process($client, $this->operation()); } public function testStrictMixWithAccountingFieldIsForbidden(): void { // RG-1.28 : payload mixant main + accounting sans la permission -> 403 // sur l'ensemble (pas de filtrage silencieux). $client = $this->minimalClient(); $client->setCompanyName('X'); $client->setSiren('123456789'); $processor = $this->makeProcessor( granted: [], payload: ['companyName' => 'X', 'siren' => '123456789'], ); $this->expectException(AccessDeniedHttpException::class); $processor->process($client, $this->operation()); } public function testArchiveWithoutPermissionIsForbidden(): void { // RG-1.22 : basculer isArchived sans la permission archive -> 403. $client = $this->minimalClient(); $client->setIsArchived(true); $processor = $this->makeProcessor( granted: [], payload: ['isArchived' => true], managed: true, originalData: ['isArchived' => false], ); $this->expectException(AccessDeniedHttpException::class); $processor->process($client, $this->operation()); } public function testArchiveWithOtherFieldIsUnprocessable(): void { // RG-1.22 : une requete d'archivage ne modifie aucun autre champ. $client = $this->minimalClient(); $client->setIsArchived(true); $client->setCompanyName('X'); $processor = $this->makeProcessor( granted: ['commercial.clients.archive'], payload: ['isArchived' => true, 'companyName' => 'X'], managed: true, originalData: ['isArchived' => false], ); $this->expectException(UnprocessableEntityHttpException::class); $processor->process($client, $this->operation()); } public function testPostWithIsArchivedFalseIsNotGated(): void { // Bug review ERP-55 : un POST renvoyant isArchived:false (valeur par // defaut) ne doit declencher ni 403 (archive) ni 422, meme sans // permission. L'entite n'est pas encore geree par l'ORM. $client = $this->minimalClient(); // isArchived = false par defaut $processor = $this->makeProcessor( granted: [], payload: ['companyName' => 'Test Co', 'isArchived' => false], managed: false, ); self::assertInstanceOf(Client::class, $processor->process($client, $this->operation())); } public function testFullRepresentationPatchWithUnchangedArchiveIsNotGated(): void { // Bug review ERP-55 : un PATCH « representation complete » renvoyant // isArchived inchange + des cles JSON-LD (@id, @context) ne doit pas etre // gate (ni 403 archive ni 422), meme sans permission. $client = $this->minimalClient(); // isArchived = false (inchange) $processor = $this->makeProcessor( granted: [], payload: [ '@id' => '/api/clients/1', '@context' => '/api/contexts/Client', 'companyName' => 'Test Co', 'isArchived' => false, ], managed: true, originalData: ['isArchived' => false], ); self::assertInstanceOf(Client::class, $processor->process($client, $this->operation())); } public function testUnchangedAccountingFieldOnPatchIsNotGated(): void { // Bug review ERP-55 : renvoyer un champ comptable a sa valeur persistee // (PATCH representation complete) ne change rien -> pas d'exigence // accounting.manage. $client = $this->minimalClient(); $client->setSiren('123456789'); // identique a l'etat persiste $processor = $this->makeProcessor( granted: [], payload: ['companyName' => 'Test Co', 'siren' => '123456789'], managed: true, // getOriginalEntityData renvoie tous les champs mappes d'une entite // geree : isArchived (non-null) y figure toujours. originalData: ['siren' => '123456789', 'isArchived' => false], ); self::assertInstanceOf(Client::class, $processor->process($client, $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 * @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST) * @param array $originalData Etat persiste simule (getOriginalEntityData) pour la detection de changement */ private function makeProcessor( array $granted, array $payload, ?UserInterface $user = null, bool $managed = false, array $originalData = [], ): 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))); // EntityManager stub : contains() distingue creation (POST) et mise a // jour (PATCH) ; getOriginalEntityData() fournit l'etat persiste compare // par le gating (RG-1.22 / RG-1.28). $uow = $this->createMock(UnitOfWork::class); $uow->method('getOriginalEntityData')->willReturn($originalData); $em = $this->createMock(EntityManagerInterface::class); $em->method('contains')->willReturn($managed); $em->method('getUnitOfWork')->willReturn($uow); return new ClientProcessor( $persist, new ClientFieldNormalizer(), new ClientInformationCompletenessValidator(), $security, $requestStack, $em, ); } /** * 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'; } }; } }