test(commercial) : cover RG-1.01..1.29 except role-gated (M1)
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de l'onglet Adresse — combler les trous (ERP-60).
|
||||
*
|
||||
* RG-1.09 (code postal) et RG-1.10 (>= 1 site) sont DEJA couverts par
|
||||
* ClientSubResourceApiTest (ERP-57) et ne sont pas reduplique ici. Ce fichier
|
||||
* cible les contraintes CHECK BDD non encore testees :
|
||||
* - RG-1.06 / RG-1.07 / RG-1.08 : `chk_client_address_prospect_exclusive`
|
||||
* (is_prospect exclusif de is_delivery / is_billing) ;
|
||||
* - RG-1.11 : `chk_client_address_billing_email` (billing_email obligatoire
|
||||
* ssi is_billing).
|
||||
*
|
||||
* Note : ces regles sont portees par des CHECK Postgres (pas d'Assert ni de
|
||||
* regle Processor au M1). On verifie donc que la combinaison invalide est
|
||||
* REJETEE par le serveur (statut >= 400), sans coupler le test au code exact :
|
||||
* une violation CHECK non mappee remonte aujourd'hui en erreur serveur ; un
|
||||
* mapping fin vers 422 serait une amelioration ulterieure (hors perimetre
|
||||
* ERP-60, test-only).
|
||||
*
|
||||
* RG-1.29 (filtrage du type de categorie SECTEUR/AUTRE sur une adresse) n'est
|
||||
* PAS testee : la validation d'ecriture correspondante n'est pas implementee
|
||||
* cote back au M1 (et ne figure pas dans la liste § 8.1). Documentee comme gap
|
||||
* dans le cahier de test #478.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
/**
|
||||
* RG-1.06 / RG-1.07 : une adresse de prospection ne peut pas etre une
|
||||
* adresse de livraison (CHECK chk_client_address_prospect_exclusive).
|
||||
*/
|
||||
public function testProspectAddressCannotBeDelivery(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Prospect Delivery');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isProspect' => true,
|
||||
'isDelivery' => true,
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.06 / RG-1.08 : une adresse de prospection ne peut pas etre une
|
||||
* adresse de facturation (meme CHECK). On fournit billingEmail pour que la
|
||||
* seule violation possible soit l'exclusivite prospect/billing.
|
||||
*/
|
||||
public function testProspectAddressCannotBeBilling(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Prospect Billing');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isProspect' => true,
|
||||
'isBilling' => true,
|
||||
'billingEmail' => 'facturation@test.fr',
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.11 : une adresse de facturation exige un billingEmail
|
||||
* (CHECK chk_client_address_billing_email).
|
||||
*/
|
||||
public function testBillingAddressRequiresBillingEmail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Billing No Email');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isBilling' => true,
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.11 (sens inverse) : une adresse NON facturable ne peut pas porter un
|
||||
* billingEmail (meme CHECK).
|
||||
*/
|
||||
public function testNonBillingAddressRejectsBillingEmail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Non Billing With Email');
|
||||
|
||||
$response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isBilling' => false,
|
||||
'billingEmail' => 'parasite@test.fr',
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertGreaterThanOrEqual(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'IRI du premier site seede (fixtures Sites).
|
||||
*/
|
||||
private function firstSiteIri(): string
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.');
|
||||
|
||||
return '/api/sites/'.$site->getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Tests d'archivage / restauration — combler les trous (ERP-60).
|
||||
*
|
||||
* Le cas nominal RG-1.22 (archive pose archivedAt) + RG-1.23 (restauration
|
||||
* repasse archivedAt a null) ainsi que le 422 « archive + autre champ » sont
|
||||
* DEJA couverts par ClientApiTest (ERP-55). Ce fichier cible le trou identifie
|
||||
* en revue (P1 review ERP-55) : le 409 de RESTAURATION en conflit d'unicite.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientArchiveTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string MERGE = 'application/merge-patch+json';
|
||||
|
||||
/**
|
||||
* RG-1.23 : restaurer un client archive dont le nom a ete repris par un
|
||||
* client actif entre-temps doit echouer en 409 (l'index partiel
|
||||
* uq_client_company_name_active n'admet qu'un seul actif portant ce nom).
|
||||
*
|
||||
* Scenario :
|
||||
* 1. un client « ACME CONFLICT » est archive (donc hors index partiel) ;
|
||||
* 2. un autre client actif « ACME CONFLICT » est cree (autorise tant que le
|
||||
* premier reste archive) ;
|
||||
* 3. la restauration du premier le rendrait actif -> collision d'unicite
|
||||
* -> ClientProcessor traduit la UniqueConstraintViolationException en 409.
|
||||
*/
|
||||
public function testRestoreConflictReturns409(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$archived = $this->seedClient('Acme Conflict', true);
|
||||
$this->seedClient('Acme Conflict', false);
|
||||
|
||||
$client->request('PATCH', '/api/clients/'.$archived->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => false],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Tests Audit + Timestampable/Blamable du repertoire clients (ERP-60).
|
||||
*
|
||||
* Couvre :
|
||||
* - RG-1.27 : createdAt / createdBy figes au POST, updatedBy reflete bien
|
||||
* l'auteur de la modification (POST admin puis PATCH par un autre user) ;
|
||||
* - Audit (§ 6.1) : le RIB est `#[Auditable]` SANS `#[AuditIgnore]` sur iban /
|
||||
* bic — ces champs sensibles DOIVENT donc apparaitre dans le diff audite
|
||||
* (decision Matthieu, revue MR 29/05/2026).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientAuditTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
private const string MERGE = 'application/merge-patch+json';
|
||||
private const string RIB_TYPE = 'commercial.ClientRib';
|
||||
private const string VALID_IBAN = 'FR1420041010050500013M02606';
|
||||
private const string VALID_BIC = 'BNPAFRPPXXX';
|
||||
|
||||
private ?Connection $auditConnection = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
self::bootKernel();
|
||||
|
||||
/** @var Connection $conn */
|
||||
$conn = self::getContainer()->get('doctrine.dbal.audit_connection');
|
||||
$this->auditConnection = $conn;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (null !== $this->auditConnection) {
|
||||
$this->auditConnection->close();
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.27 : createdAt / createdBy sont poses au POST puis figes ; updatedBy
|
||||
* suit l'auteur de la derniere modification. On cree en admin puis on
|
||||
* modifie avec un user `commercial.clients.manage` distinct : createdBy reste
|
||||
* l'admin, updatedBy devient le manager, createdAt ne bouge pas.
|
||||
*/
|
||||
public function testCreatedFrozenAndUpdatedByReflectsModifier(): void
|
||||
{
|
||||
// 1. User modificateur (non-admin) cree AVANT le reboot de kernel induit
|
||||
// par les clients authentifies suivants ; il est persiste en base.
|
||||
$manageCreds = $this->createUserWithPermission('commercial.clients.manage');
|
||||
|
||||
// 2. Creation en admin (createdBy = admin).
|
||||
$admin = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
|
||||
$created = $admin->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Blamable Co',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'blamable@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$id = (int) $created['id'];
|
||||
$createdAtTs = new DateTimeImmutable((string) $created['createdAt'])->getTimestamp();
|
||||
|
||||
// 3. Modification par le manager (updatedBy = manager).
|
||||
$manage = $this->authenticatedClient($manageCreds['username'], $manageCreds['password']);
|
||||
$manage->request('PATCH', '/api/clients/'.$id, [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Blamable Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// 4. Verification cote base (etat re-charge depuis la BDD).
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(ClientEntity::class)->find($id);
|
||||
self::assertNotNull($reloaded);
|
||||
|
||||
self::assertSame('admin', $reloaded->getCreatedBy()?->getUserIdentifier(), 'createdBy doit rester l\'admin createur.');
|
||||
self::assertSame(
|
||||
$manageCreds['username'],
|
||||
$reloaded->getUpdatedBy()?->getUserIdentifier(),
|
||||
'updatedBy doit refleter le dernier modificateur.',
|
||||
);
|
||||
self::assertSame($createdAtTs, $reloaded->getCreatedAt()?->getTimestamp(), 'createdAt doit etre fige au POST.');
|
||||
self::assertNotNull($reloaded->getUpdatedAt());
|
||||
self::assertGreaterThanOrEqual($createdAtTs, $reloaded->getUpdatedAt()->getTimestamp());
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit § 6.1 : la creation d'un RIB produit une ligne audit_log
|
||||
* `commercial.ClientRib` / `create` dont le snapshot contient iban et bic
|
||||
* (champs volontairement NON ignores).
|
||||
*/
|
||||
public function testRibCreateAuditIncludesIbanAndBic(): void
|
||||
{
|
||||
$admin = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Rib Audit Host');
|
||||
|
||||
$rib = $admin->request('POST', '/api/clients/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'label' => 'Compte audite',
|
||||
'bic' => self::VALID_BIC,
|
||||
'iban' => self::VALID_IBAN,
|
||||
],
|
||||
])->toArray();
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
$rows = $this->auditConnection->fetchAllAssociative(
|
||||
'SELECT changes FROM audit_log '
|
||||
.'WHERE entity_type = :type AND entity_id = :id AND action = :action '
|
||||
.'ORDER BY performed_at DESC',
|
||||
['type' => self::RIB_TYPE, 'id' => (string) $rib['id'], 'action' => 'create'],
|
||||
);
|
||||
|
||||
self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "create" doit etre genere pour le RIB.');
|
||||
|
||||
/** @var array<string, mixed> $changes */
|
||||
$changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR);
|
||||
self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).');
|
||||
self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).');
|
||||
self::assertSame(self::VALID_IBAN, $changes['iban']);
|
||||
self::assertSame(self::VALID_BIC, $changes['bic']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels du formulaire principal — combler les trous (ERP-60).
|
||||
*
|
||||
* RG-1.01 (prenom OU nom obligatoire) et RG-1.03 (distributor/broker exclusifs
|
||||
* + type de categorie) sont DEJA couverts par ClientApiTest (ERP-55) : on ne les
|
||||
* reduplique pas ici. Ce fichier ne couvre que RG-1.02 (telephone secondaire),
|
||||
* non encore testee.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientFormulaireMainTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
/**
|
||||
* RG-1.02 : le telephone secondaire est optionnel mais persiste (2 colonnes
|
||||
* distinctes). Verifie aussi la normalisation chiffres-seuls (RG-1.20) sur
|
||||
* la colonne secondaire.
|
||||
*/
|
||||
public function testPostPersistsSecondaryPhoneNormalized(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
|
||||
$data = $client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Two Phones SARL',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '06.12.34.56.78',
|
||||
'phoneSecondary' => '05 49 00 11 22',
|
||||
'email' => 'twophones@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('0612345678', $data['phonePrimary']);
|
||||
self::assertSame('0549001122', $data['phoneSecondary']);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.02 : maximum 2 telephones — le modele n'expose que phonePrimary et
|
||||
* phoneSecondary. Un eventuel 3e champ envoye par un appel API direct est
|
||||
* ignore (aucune 3e colonne), il ne peut donc pas creer un troisieme numero.
|
||||
*/
|
||||
public function testThirdPhoneFieldIsIgnored(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
|
||||
$data = $client->request('POST', '/api/clients', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'companyName' => 'Third Phone SARL',
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0612345678',
|
||||
'phoneSecondary' => '0549001122',
|
||||
'phoneTertiary' => '0700000000',
|
||||
'email' => 'thirdphone@test.fr',
|
||||
'categories' => ['/api/categories/'.$cat->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
// Le champ inconnu est ignore par le denormaliseur : il n'apparait pas
|
||||
// dans la representation et n'a pas ete persiste.
|
||||
self::assertArrayNotHasKey('phoneTertiary', $data);
|
||||
|
||||
// Confirmation cote base : seules les 2 colonnes telephone existent.
|
||||
$persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']);
|
||||
self::assertNotNull($persisted);
|
||||
self::assertSame('0612345678', $persisted->getPhonePrimary());
|
||||
self::assertSame('0549001122', $persisted->getPhoneSecondary());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Tests de structure / migration M1 (ERP-60).
|
||||
*
|
||||
* Verifie la decision Q4 (29/05/2026) au niveau du schema Postgres :
|
||||
* - l'unique index partiel fonctionnel uq_client_company_name_active existe
|
||||
* (un seul, sur LOWER(company_name), partiel sur les actifs non archives /
|
||||
* non supprimes) — seule unicite metier conservee (RG-1.16) ;
|
||||
* - les anciens index uq_client_siren_active (RG-1.15) et uq_client_email_active
|
||||
* (RG-1.17) ont ete supprimes / ne sont jamais crees.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientMigrationTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void
|
||||
{
|
||||
$rows = $this->clientIndexes();
|
||||
|
||||
$companyNameIndexes = array_filter(
|
||||
$rows,
|
||||
static fn (array $r): bool => 'uq_client_company_name_active' === $r['indexname'],
|
||||
);
|
||||
|
||||
self::assertCount(
|
||||
1,
|
||||
$companyNameIndexes,
|
||||
'Il doit exister exactement UN index uq_client_company_name_active.',
|
||||
);
|
||||
|
||||
// Confirme la nature fonctionnelle (LOWER) + partielle (WHERE) de l'index.
|
||||
// Postgres serialise l'expression sous la forme `lower((company_name)::text)`,
|
||||
// d'ou des verifications de sous-chaines distinctes.
|
||||
$def = strtolower((string) array_values($companyNameIndexes)[0]['indexdef']);
|
||||
self::assertStringContainsString('unique', $def);
|
||||
self::assertStringContainsString('lower', $def);
|
||||
self::assertStringContainsString('company_name', $def);
|
||||
self::assertStringContainsString('where', $def, 'L\'index doit etre partiel (clause WHERE sur les actifs).');
|
||||
}
|
||||
|
||||
public function testNoSirenOrEmailUniqueIndex(): void
|
||||
{
|
||||
$names = array_map(static fn (array $r): string => $r['indexname'], $this->clientIndexes());
|
||||
|
||||
// RG-1.15 / RG-1.17 supprimees (Q4) : aucun index unique siren / email.
|
||||
self::assertNotContains('uq_client_siren_active', $names);
|
||||
self::assertNotContains('uq_client_email_active', $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{indexname: string, indexdef: string}>
|
||||
*/
|
||||
private function clientIndexes(): array
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
/** @var list<array{indexname: string, indexdef: string}> $rows */
|
||||
return $this->getEm()->getConnection()->fetchAllAssociative(
|
||||
"SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'client'",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
|
||||
/**
|
||||
* Test fonctionnel du mode strict PATCH multi-groupes (RG-1.28) — ERP-60.
|
||||
*
|
||||
* Le cas est deja couvert en unitaire (ClientProcessorTest) ; on en ajoute la
|
||||
* preuve fonctionnelle HTTP, SANS dependre d'un role metier : un utilisateur
|
||||
* portant `commercial.clients.manage` mais PAS `commercial.clients.accounting.manage`
|
||||
* qui envoie un PATCH melant un champ principal (companyName) et un champ
|
||||
* comptable (siren) recoit un 403 sur l'ENSEMBLE du payload — aucun champ n'est
|
||||
* applique (pas de filtrage silencieux).
|
||||
*
|
||||
* ⚠ La matrice differenciee par role metier (Bureau / Compta / Commerciale) est
|
||||
* DELEGUEE a ERP-74 (#493). Ici on n'utilise qu'un user mono-permission.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientPatchStrictTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string MERGE = 'application/merge-patch+json';
|
||||
|
||||
public function testMixedGroupsPatchWithoutAccountingPermissionIsForbidden(): void
|
||||
{
|
||||
$seed = $this->seedClient('Strict Mix');
|
||||
$credentials = $this->createUserWithPermission('commercial.clients.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
|
||||
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => [
|
||||
'companyName' => 'Renamed Strict',
|
||||
'siren' => '123456789',
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-1.28 : 403 strict (le champ comptable siren exige accounting.manage).
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// Aucun champ applique : le companyName d'origine est intact.
|
||||
$em = $this->getEm();
|
||||
$em->clear();
|
||||
$reloaded = $em->getRepository(ClientEntity::class)->find($seed->getId());
|
||||
self::assertNotNull($reloaded);
|
||||
self::assertSame('STRICT MIX', $reloaded->getCompanyName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Tests de securite GENERIQUE de /api/clients (ERP-60).
|
||||
*
|
||||
* Couvre les garde-fous non dependants des roles metier :
|
||||
* - 401 si requete anonyme (firewall JWT) ;
|
||||
* - 403 si l'utilisateur authentifie ne porte pas `commercial.clients.view`.
|
||||
*
|
||||
* ⚠ La matrice RBAC differenciee par role metier (bureau / compta / commerciale
|
||||
* / usine) et le test fonctionnel RG-1.04 sont DELEGUES a ERP-74 (#493) : ils
|
||||
* exigent les roles seedes apres le merge de la stack. NE PAS les ajouter ici.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientSecurityTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
public function testAnonymousGetCollectionReturns401(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testAnonymousGetItemReturns401(): void
|
||||
{
|
||||
$seed = $this->seedClient('Anon Item');
|
||||
$client = self::createClient();
|
||||
|
||||
$client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutClientsViewPermission(): void
|
||||
{
|
||||
// User authentifie portant une permission SANS rapport avec les clients.
|
||||
$seed = $this->seedClient('Forbidden Target');
|
||||
$credentials = $this->createUserWithPermission('core.users.view');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
|
||||
// Collection.
|
||||
$client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// Detail.
|
||||
$client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||
|
||||
/**
|
||||
* Tests d'unicite — combler les trous (ERP-60).
|
||||
*
|
||||
* RG-1.16 (doublon de companyName parmi les actifs -> 409) est DEJA couvert par
|
||||
* ClientApiTest::testPostDuplicateCompanyNameReturns409 (ERP-55). Ce fichier
|
||||
* verifie l'envers de la decision Q4 (29/05/2026) : le SIREN (RG-1.15 supprimee)
|
||||
* et l'email (RG-1.17 supprimee) NE SONT PLUS contraints uniques.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ClientUniquenessTest extends AbstractCommercialApiTestCase
|
||||
{
|
||||
private const string LD = 'application/ld+json';
|
||||
|
||||
/**
|
||||
* RG-1.16 / RG-1.17 (Q4) : deux clients actifs peuvent partager le meme
|
||||
* email principal — aucune contrainte d'unicite (un email peut servir
|
||||
* plusieurs clients).
|
||||
*/
|
||||
public function testDuplicateEmailIsAllowed(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$cat = $this->createCategory('SECTEUR');
|
||||
$iri = '/api/categories/'.$cat->getId();
|
||||
|
||||
$payload = static fn (string $name): array => [
|
||||
'companyName' => $name,
|
||||
'firstName' => 'A',
|
||||
'phonePrimary' => '0102030405',
|
||||
'email' => 'partage@test.fr',
|
||||
'categories' => [$iri],
|
||||
];
|
||||
|
||||
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share One')]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// Meme email, nom different -> doit passer (pas d'index unique email).
|
||||
$client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share Two')]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.15 (Q4) : deux clients peuvent partager le meme SIREN (etablissements
|
||||
* multiples). Le SIREN n'est pas ecrivable au POST (groupe accounting), on
|
||||
* seede donc directement via l'ORM et on prouve que le flush ne leve aucune
|
||||
* violation d'unicite.
|
||||
*/
|
||||
public function testDuplicateSirenIsAllowed(): void
|
||||
{
|
||||
// Boot kernel pour disposer de l'EM (pas d'appel HTTP necessaire ici).
|
||||
self::bootKernel();
|
||||
$em = $this->getEm();
|
||||
|
||||
$one = $this->seedClient('Siren Share One');
|
||||
$two = $this->seedClient('Siren Share Two');
|
||||
|
||||
$one->setSiren('123456789');
|
||||
$two->setSiren('123456789');
|
||||
$em->flush();
|
||||
|
||||
// Aucune exception : preuve qu'il n'existe pas d'index unique sur siren.
|
||||
self::assertSame('123456789', $em->getRepository(ClientEntity::class)->find($one->getId())->getSiren());
|
||||
self::assertSame('123456789', $em->getRepository(ClientEntity::class)->find($two->getId())->getSiren());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user