96ddd15c86
Auto Tag Develop / tag (push) Successful in 9s
## Contexte M1 · Ticket 1/3 (Backend) de la refonte contact. Le contact principal inline du `Client` (firstName, lastName, phonePrimary, phoneSecondary, email) faisait doublon avec la sous-entité `ClientContact` (onglet Contact). Il est supprimé : les contacts vivent désormais **uniquement** dans `client_contact`. RG-1.01 (firstName OU lastName sur Client) et RG-1.02 (max 2 téléphones sur Client) sont **supprimées** du Client — leur équivalent vit déjà sur `ClientContact` (RG-1.05 / RG-1.14). ## Changements - **Migration** `Version20260603120000` (namespace racine `DoctrineMigrations` — tri par timestamp, cf. AlphabeticalComparator) : **backfill** des clients sans contact vers `client_contact` (position 0) **avant** le `DROP` des 5 colonnes. `down()` best-effort documenté. - **Entité `Client`** : retrait des 5 propriétés + getters/setters + groupes de sérialisation. - **`ClientProcessor`** : `MAIN_FIELDS` / `changedBusinessFields()` / `normalize()` allégés ; `validateMainContact()` (RG-1.01) supprimée. - **Recherche répertoire (D1)** : sur `companyName` seul (les anciens critères lastName/email vivaient sur les colonnes supprimées). - **Export XLSX (D2)** : colonnes de contact retirées (Nom entreprise / Catégories / Sites / [SIREN] / Date). - **Fixtures** + **catalogue de commentaires SQL** (`ColumnCommentsCatalog`) alignés. - **Tests** fonctionnels et unitaires mis à jour. ## Décisions actées - **Migration** au namespace racine (et non modulaire Commercial) : une migration `App\Module\Commercial\…` trierait avant le `CREATE TABLE client` sur base fraîche → casse. Conforme à la règle ABSOLUE n°11. - **D1** = recherche `companyName` seul. **D2** = retrait des colonnes contact de l'export. ## Vérifications - ✅ `make db-reset && make migration-migrate` : migration rejouable sur base fraîche (backfill no-op si contacts déjà présents). - ✅ `make test` : 466 tests verts. - ✅ `make php-cs-fixer-allow-risky` : clean. - ✅ Contrat réel `GET /api/clients/{id}` : les 5 champs ont disparu de la racine, `contacts[]` porte l'info. --------- Co-authored-by: admin malio <malio@yuno.malio.fr> Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #55 Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr> Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
437 lines
16 KiB
PHP
437 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Module\Commercial\Unit;
|
|
|
|
use ApiPlatform\Metadata\Operation;
|
|
use ApiPlatform\State\ProcessorInterface;
|
|
use ApiPlatform\Validator\Exception\ValidationException;
|
|
use App\Module\Commercial\Application\Service\ClientFieldNormalizer;
|
|
use App\Module\Commercial\Application\Validator\ClientInformationCompletenessValidator;
|
|
use App\Module\Commercial\Domain\Entity\Bank;
|
|
use App\Module\Commercial\Domain\Entity\Client;
|
|
use App\Module\Commercial\Domain\Entity\ClientRib;
|
|
use App\Module\Commercial\Domain\Entity\PaymentType;
|
|
use App\Module\Commercial\Infrastructure\ApiPlatform\State\Processor\ClientProcessor;
|
|
use App\Shared\Domain\Contract\BusinessRoleAwareInterface;
|
|
use App\Shared\Domain\Security\BusinessRoles;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Doctrine\ORM\UnitOfWork;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Bundle\SecurityBundle\Security;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\RequestStack;
|
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
|
use Symfony\Component\Security\Core\User\UserInterface;
|
|
|
|
/**
|
|
* Tests unitaires du ClientProcessor : gating par permission (accounting.manage
|
|
* / archive / RG-1.28 strict) et regles metier non testables en HTTP admin
|
|
* (RG-1.04 Commerciale, RG-1.12 Virement, RG-1.13 LCR), grace a un Security et
|
|
* un RequestStack stubbes.
|
|
*
|
|
* @internal
|
|
*/
|
|
final class ClientProcessorTest extends TestCase
|
|
{
|
|
public function testAccountingFieldWithoutPermissionIsForbidden(): void
|
|
{
|
|
// RG-1.28 : la modification effective d'un champ comptable sans
|
|
// accounting.manage -> 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,
|
|
// Etat persiste (valeurs normalisees) : sans companyName, guardManage
|
|
// (ERP-74) le croirait modifie (compare a null) et leverait un 403
|
|
// parasite.
|
|
originalData: [
|
|
'companyName' => 'TEST CO',
|
|
'triageService' => false,
|
|
'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, ainsi que les
|
|
// champs metier (companyName) sinon guardManage les croirait modifies.
|
|
originalData: [
|
|
'siren' => '123456789',
|
|
'companyName' => 'TEST CO',
|
|
'triageService' => false,
|
|
'isArchived' => false,
|
|
],
|
|
);
|
|
|
|
self::assertInstanceOf(Client::class, $processor->process($client, $this->operation()));
|
|
}
|
|
|
|
public function testBusinessFieldWithoutManagePermissionIsForbidden(): void
|
|
{
|
|
// ERP-74 (guardManage) : modifier un champ metier (companyName) sur un
|
|
// client existant sans `manage` -> 403, meme avec accounting.manage
|
|
// (cas Compta qui sort de son onglet).
|
|
$client = $this->minimalClient();
|
|
$client->setCompanyName('Renamed Co');
|
|
|
|
$processor = $this->makeProcessor(
|
|
granted: ['commercial.clients.accounting.manage'],
|
|
payload: ['companyName' => 'Renamed Co'],
|
|
managed: true,
|
|
originalData: [
|
|
'companyName' => 'TEST CO',
|
|
'triageService' => false,
|
|
'isArchived' => false,
|
|
],
|
|
);
|
|
|
|
$this->expectException(AccessDeniedHttpException::class);
|
|
$processor->process($client, $this->operation());
|
|
}
|
|
|
|
public function testAccountingOnlyPatchWithAccountingManageOnlyPasses(): void
|
|
{
|
|
// ERP-74 : Compta (accounting.manage, PAS manage) qui ne touche QUE
|
|
// l'onglet Comptabilite d'un client existant -> 200. guardManage ne
|
|
// declenche pas (aucun champ metier modifie), guardAccounting passe.
|
|
$client = $this->minimalClient();
|
|
$client->setSiren('999999999');
|
|
|
|
$processor = $this->makeProcessor(
|
|
granted: ['commercial.clients.accounting.manage'],
|
|
payload: ['siren' => '999999999'],
|
|
managed: true,
|
|
originalData: [
|
|
'siren' => '111111111',
|
|
'companyName' => 'TEST CO',
|
|
'triageService' => false,
|
|
'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 testCommercialeIncompleteInformationOnNonInformationPatchIsUnprocessable(): void
|
|
{
|
|
// RG-1.04 durcie (ERP-74) : pour une Commerciale, la completude de
|
|
// l'onglet Information est exigee meme quand le payload ne touche PAS
|
|
// l'onglet Information (ici seulement companyName). L'ancienne condition
|
|
// d'intersection avec INFORMATION_FIELDS a ete retiree.
|
|
$client = $this->minimalClient();
|
|
$client->setCompanyName('Renamed Co'); // onglet principal uniquement, Information vide
|
|
|
|
$processor = $this->makeProcessor(
|
|
granted: ['commercial.clients.manage'],
|
|
payload: ['companyName' => 'Renamed Co'],
|
|
user: $this->commercialeUser(),
|
|
managed: true,
|
|
originalData: [
|
|
'companyName' => 'TEST CO',
|
|
'triageService' => false,
|
|
'isArchived' => false,
|
|
],
|
|
);
|
|
|
|
$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<string> $granted Permissions accordees a l'utilisateur courant
|
|
* @param array<string, mixed> $payload Corps JSON simule de la requete
|
|
* @param bool $managed true = entite geree par l'ORM (PATCH), false = creation (POST)
|
|
* @param array<string, mixed> $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 — companyName seul depuis la suppression du contact inline.
|
|
* Suffisant pour atteindre les validations testees (le contact vit desormais
|
|
* dans ClientContact, hors scope du ClientProcessor).
|
|
*/
|
|
private function minimalClient(): Client
|
|
{
|
|
$client = new Client();
|
|
$client->setCompanyName('Test Co');
|
|
|
|
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';
|
|
}
|
|
};
|
|
}
|
|
}
|