diff --git a/src/Module/Transport/Domain/Entity/CarrierAddress.php b/src/Module/Transport/Domain/Entity/CarrierAddress.php index fab9fea..80bf418 100644 --- a/src/Module/Transport/Domain/Entity/CarrierAddress.php +++ b/src/Module/Transport/Domain/Entity/CarrierAddress.php @@ -4,22 +4,86 @@ declare(strict_types=1); namespace App\Module\Transport\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Transport\Infrastructure\ApiPlatform\State\Processor\CarrierAddressProcessor; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Trait\TimestampableBlamableTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Validator\Constraints as Assert; /** * Adresse d'un transporteur (1:n) — onglet Adresse (M4). Jumelle de * SupplierAddress (M2), version simplifiee (pas de type d'adresse, pas de M2M * sites/categories sur l'adresse : les sites du M4 vivent dans l'onglet Prix). * - * WT3 (ERP-155/157) = LECTURE seule : proprietes en `carrier:item:read` - * (embarquees au detail du transporteur). Les sous-ressources d'ecriture - * (POST/PATCH/DELETE) + RG-4.05→4.07 arrivent au worktree dedie (WT6). + * Lecture : proprietes en `carrier:item:read` (embarquees au detail du + * transporteur). Ecriture : groupe `carrier:write:addresses`. + * + * Sous-ressource API (ERP-159, spec § 4.5) — jumelle de SupplierAddress (M2) / + * ProviderAddress (M3), sans address_type ni M2M (les sites du M4 vivent dans + * l'onglet Prix) : + * - POST /api/carriers/{carrierId}/addresses : creation rattachee au + * transporteur parent (Link toProperty 'carrier'), security + * transport.carriers.manage. + * - PATCH / DELETE /api/carrier_addresses/{id} : security + * transport.carriers.manage. + * - GET /api/carrier_addresses/{id} : lecture unitaire (security view) — la + * lecture courante reste via le parent. Pas de GET collection autonome. + * Tout passe par le CarrierAddressProcessor (rattachement parent + RG-4.05). + * + * Regles de l'onglet Adresse : + * - RG-4.06 : code postal a 4 ou 5 chiffres (Assert\Regex ; pas de controle + * CP/ville serveur, l'autocomplete BAN est front). + * - RG-4.05 : si le transporteur est affrete (isChartered), l'adresse devient + * obligatoire (Pays / CP / Ville / Adresse) — validation conditionnelle portee + * par le CarrierAddressProcessor (le parent n'est pas disponible a la + * validation Symfony sur un POST sous-ressource en read:false). + * - RG-4.07 : masquage du bouton « Valider » si QUALIMAT = front ; le back + * accepte le PATCH normalement (aucune garde back specifique). + * + * Audite (#[Auditable]) + Timestampable / Blamable. */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('transport.carriers.view')", + normalizationContext: ['groups' => ['carrier:item:read', 'default:read']], + ), + new Post( + uriTemplate: '/carriers/{carrierId}/addresses', + uriVariables: [ + 'carrierId' => new Link(fromClass: Carrier::class, toProperty: 'carrier'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT CarrierAddress ... WHERE carrier = :id) + // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par CarrierAddressProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('transport.carriers.manage')", + normalizationContext: ['groups' => ['carrier:item:read', 'default:read']], + denormalizationContext: ['groups' => ['carrier:write:addresses']], + processor: CarrierAddressProcessor::class, + ), + new Patch( + security: "is_granted('transport.carriers.manage')", + normalizationContext: ['groups' => ['carrier:item:read', 'default:read']], + denormalizationContext: ['groups' => ['carrier:write:addresses']], + processor: CarrierAddressProcessor::class, + ), + new Delete( + security: "is_granted('transport.carriers.manage')", + processor: CarrierAddressProcessor::class, + ), + ], +)] #[ORM\Entity] #[ORM\Table(name: 'carrier_address')] #[ORM\Index(name: 'idx_carrier_address_carrier', columns: ['carrier_id'])] @@ -41,23 +105,32 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface private ?Carrier $carrier = null; #[ORM\Column(length: 80, options: ['default' => 'France'])] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:addresses'])] private string $country = 'France'; + // RG-4.06 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur, + // l'autocomplete BAN est front). Le Regex borne deja la longueur (<= 5) : pas + // de Length redondant (whitelist EXCLUDED_LENGTH_MIRROR). Nullable : obligatoire + // seulement si affrete (RG-4.05, garde CarrierAddressProcessor). #[ORM\Column(name: 'postal_code', length: 20, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')] + #[Groups(['carrier:item:read', 'carrier:write:addresses'])] private ?string $postalCode = null; #[ORM\Column(length: 120, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:addresses'])] private ?string $city = null; #[ORM\Column(length: 255, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 255, maxMessage: 'L\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:addresses'])] private ?string $street = null; #[ORM\Column(name: 'street_complement', length: 255, nullable: true)] - #[Groups(['carrier:item:read'])] + #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['carrier:item:read', 'carrier:write:addresses'])] private ?string $streetComplement = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Transport/Domain/Entity/QualimatCarrier.php b/src/Module/Transport/Domain/Entity/QualimatCarrier.php index b7c471c..35d951d 100644 --- a/src/Module/Transport/Domain/Entity/QualimatCarrier.php +++ b/src/Module/Transport/Domain/Entity/QualimatCarrier.php @@ -4,13 +4,10 @@ declare(strict_types=1); namespace App\Module\Transport\Domain\Entity; -use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; -use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use App\Module\Transport\Infrastructure\ApiPlatform\State\Provider\QualimatCarrierSearchProvider; use DateTimeImmutable; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; @@ -26,8 +23,9 @@ use Symfony\Component\Serializer\Attribute\SerializedName; * - cible de la FK editable `carrier.qualimat_carrier_id` (§ 2.5) ; * - embarquee (groupe `qualimat:read`) dans la liste et le detail Carrier pour * afficher statut + date de validite QUALIMAT (RG-4.04) ; - * - endpoint de recherche `GET /api/qualimat_carriers?...` pour la saisie - * assistee du nom (§ 4.7) — filtres built-in name/siret (partiel), isActive. + * - endpoint de recherche `GET /api/qualimat_carriers?search=` pour la saisie + * assistee du nom (§ 4.7) — fuzzy name (+ siret), SEULEMENT les lignes actives, + * tri name ASC, paginee ; logique portee par QualimatCarrierSearchProvider. * * La table reste hors `schema_filter` Doctrine (doctrine.yaml) : c'est la * migration modulaire Version20260612150000 qui possede son DDL et ses COMMENT @@ -36,8 +34,14 @@ use Symfony\Component\Serializer\Attribute\SerializedName; */ #[ApiResource( operations: [ + // Saisie assistee (§ 4.7 / RG-4.01) : ?search= fuzzy name (+ siret), + // SEULEMENT les lignes actives, tri name ASC, paginee. La logique vit + // dans le provider (forcage is_active + recherche multi-champs) car un + // SearchFilter natif ne sait ni unifier name/siret sous un seul ?search=, + // ni imposer cote serveur le filtre actif. new GetCollection( security: "is_granted('transport.carriers.view')", + provider: QualimatCarrierSearchProvider::class, normalizationContext: ['groups' => ['qualimat:read', 'default:read']], ), new Get( @@ -46,9 +50,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName; ), ], )] -#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'siret' => 'partial'])] -#[ApiFilter(BooleanFilter::class, properties: ['isActive'])] -#[ApiFilter(OrderFilter::class, properties: ['name'], arguments: ['orderParameterName' => 'order'])] #[ORM\Entity] // Mapping reproduisant a l'identique le DDL de la migration ERP-39 // (Version20260612150000) pour que `schema:update --force` reste un no-op : diff --git a/src/Module/Transport/Domain/Repository/QualimatCarrierRepositoryInterface.php b/src/Module/Transport/Domain/Repository/QualimatCarrierRepositoryInterface.php new file mode 100644 index 0000000..26a1577 --- /dev/null +++ b/src/Module/Transport/Domain/Repository/QualimatCarrierRepositoryInterface.php @@ -0,0 +1,25 @@ + + */ +final class CarrierAddressProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof CarrierAddress) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->guardCharteredAddress($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache l'adresse au transporteur parent de la sous-ressource POST + * (/carriers/{carrierId}/addresses) : la relation n'est pas peuplee + * automatiquement par le Link sur une ecriture. Sur PATCH, no-op. + */ + private function linkParent(CarrierAddress $address, array $uriVariables): void + { + if (null !== $address->getCarrier()) { + return; + } + + $carrierId = $uriVariables['carrierId'] ?? null; + if (null === $carrierId) { + return; + } + + $carrier = $carrierId instanceof Carrier + ? $carrierId + : $this->em->getRepository(Carrier::class)->find($carrierId); + + // read:false sur le POST : sans stade lecture, un parent introuvable n'est + // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la + // contrainte carrier_id NOT NULL). + if (!$carrier instanceof Carrier) { + throw new NotFoundHttpException('Transporteur introuvable.'); + } + + $address->setCarrier($carrier); + } + + /** + * RG-4.05 : si le transporteur parent est affrete (isChartered), l'adresse doit + * porter Pays / Code postal / Ville / Adresse. Chaque champ manquant -> une + * violation 422 sur son propre propertyPath (mapping inline ERP-101). La + * validation porte sur l'ETAT RESULTANT de l'adresse (apres application du + * payload), donc identique sur POST et sur PATCH partiel. Sans affretement, + * l'adresse reste partielle (champs nullable, RG-4.06 inchangee). + */ + private function guardCharteredAddress(CarrierAddress $address): void + { + $carrier = $address->getCarrier(); + if (!$carrier instanceof Carrier || !$carrier->isChartered()) { + return; + } + + $required = [ + 'country' => [$address->getCountry(), 'Le pays est obligatoire pour un transporteur affrété.'], + 'postalCode' => [$address->getPostalCode(), 'Le code postal est obligatoire pour un transporteur affrété.'], + 'city' => [$address->getCity(), 'La ville est obligatoire pour un transporteur affrété.'], + 'street' => [$address->getStreet(), 'L\'adresse est obligatoire pour un transporteur affrété.'], + ]; + + $violations = new ConstraintViolationList(); + foreach ($required as $path => [$value, $message]) { + if (null === $value || '' === trim($value)) { + $violations->add(new ConstraintViolation($message, null, [], $address, $path, $value)); + } + } + + if (0 < $violations->count()) { + throw new ValidationException($violations); + } + } +} diff --git a/src/Module/Transport/Infrastructure/ApiPlatform/State/Provider/QualimatCarrierSearchProvider.php b/src/Module/Transport/Infrastructure/ApiPlatform/State/Provider/QualimatCarrierSearchProvider.php new file mode 100644 index 0000000..ed8697e --- /dev/null +++ b/src/Module/Transport/Infrastructure/ApiPlatform/State/Provider/QualimatCarrierSearchProvider.php @@ -0,0 +1,64 @@ + : + * - restreint aux lignes actives (is_active = true) — regle serveur, pas un + * filtre client desactivable ; + * - recherche fuzzy insensible a la casse sur name (+ siret) ; + * - tri par name ASC ; + * - pagination Hydra (regle n°13) + echappatoire ?pagination=false (selects). + * + * Branche uniquement sur la GetCollection ; le Get unitaire reste servi par le + * provider ORM par defaut (lecture seule, aucune ecriture exposee). + * + * @implements ProviderInterface + */ +final class QualimatCarrierSearchProvider implements ProviderInterface +{ + public function __construct( + #[Autowire(service: 'App\Module\Transport\Infrastructure\Doctrine\DoctrineQualimatCarrierRepository')] + private readonly QualimatCarrierRepositoryInterface $repository, + private readonly Pagination $pagination, + ) {} + + /** + * @return list|Paginator + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|Paginator + { + $filters = $context['filters'] ?? []; + $search = $filters['search'] ?? null; + + $qb = $this->repository->createSearchQueryBuilder(is_string($search) ? $search : null); + + // Echappatoire ?pagination=false : collection complete (selects front). + if (!$this->pagination->isEnabled($operation, $context)) { + /** @var list $carriers */ + 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: false — aucune jointure to-many (referentiel plat). + return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false)); + } +} diff --git a/src/Module/Transport/Infrastructure/Doctrine/DoctrineQualimatCarrierRepository.php b/src/Module/Transport/Infrastructure/Doctrine/DoctrineQualimatCarrierRepository.php new file mode 100644 index 0000000..33b351c --- /dev/null +++ b/src/Module/Transport/Infrastructure/Doctrine/DoctrineQualimatCarrierRepository.php @@ -0,0 +1,57 @@ + + */ +class DoctrineQualimatCarrierRepository extends ServiceEntityRepository implements QualimatCarrierRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, QualimatCarrier::class); + } + + public function createSearchQueryBuilder(?string $search = null): QueryBuilder + { + // Saisie assistee (§ 4.7) : on ne propose QUE des transporteurs QUALIMAT + // actifs (is_active = true), tries par nom. Le forcage de l'actif est une + // regle serveur (pas un filtre client) — les lignes soft-deletees par la + // synchro restent invisibles. + $qb = $this->createQueryBuilder('q') + ->andWhere('q.isActive = true') + ->orderBy('q.name', 'ASC') + ; + + $this->applySearch($qb, $search); + + return $qb; + } + + /** + * Recherche fuzzy insensible a la casse sur le nom (+ siret) du transporteur + * QUALIMAT (§ 4.7 / RG-4.01). Metacaracteres LIKE (%, _, \) echappes pour + * rester litteraux. + */ + private function applySearch(QueryBuilder $qb, ?string $search): void + { + if (null === $search || '' === trim($search)) { + return; + } + + $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search)); + $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%'; + + $qb->andWhere('LOWER(q.name) LIKE :search OR LOWER(q.siret) LIKE :search') + ->setParameter('search', $pattern) + ; + } +} diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php index a8978ea..799ff09 100644 --- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -56,6 +56,8 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase 'SupplierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', // Idem cote prestataire (meme Regex CP — M3 Technique). 'ProviderAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', + // Idem cote transporteur (meme Regex CP — M4 Transport). + 'CarrierAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', // Le Choice {PROSPECT,DEPART,RENDU} borne les valeurs (<= 8 < 20). 'SupplierAddress::addressType' => 'Choice {PROSPECT,DEPART,RENDU} borne deja les valeurs.', // Le Choice {QUALIMAT,GMP_PLUS,OVOCOM,COMPTE_PROPRE,AUTRE} borne les valeurs (<= 13 < 20). diff --git a/tests/Module/Transport/Api/CarrierAddressApiTest.php b/tests/Module/Transport/Api/CarrierAddressApiTest.php new file mode 100644 index 0000000..53ea6f3 --- /dev/null +++ b/tests/Module/Transport/Api/CarrierAddressApiTest.php @@ -0,0 +1,172 @@ + 422 ; + * - RG-4.05 : transporteur affrete + adresse incomplete -> 422 (par champ) ; + * - RG-4.05 : transporteur affrete + adresse complete -> 201 ; + * - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul). + * + * @internal + */ +final class CarrierAddressApiTest extends AbstractCarrierApiTestCase +{ + private const string PWD = RbacDemoFixtures::DEMO_PASSWORD; + + protected function setUp(): void + { + parent::setUp(); + + // Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin + // qu'en recette), requis pour les tests de permission (bureau/commerciale). + self::bootKernel(); + $application = new Application(self::$kernel); + $application->setAutoExit(false); + $exit = $application->run( + new ArrayInput([ + 'command' => 'app:seed-rbac', + '--with-demo-users' => true, + '--password' => self::PWD, + ]), + new NullOutput(), + ); + self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).'); + + self::ensureKernelShutdown(); + } + + public function testInvalidPostalCodeReturns422(): void + { + // Transporteur NON affrete : RG-4.05 ne s'applique pas, seule RG-4.06 joue. + $carrier = $this->seedCarrierWithChartered('Cp Invalide', false); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['postalCode' => '123'], // 3 chiffres -> Regex KO + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testCharteredCarrierIncompleteAddressReturns422(): void + { + // Transporteur affrete : RG-4.05 exige Pays/CP/Ville/Adresse. CP valide mais + // ville + rue manquantes -> 422 conditionnelle (CarrierAddressProcessor). + $carrier = $this->seedCarrierWithChartered('Affrete Incomplet', true); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['postalCode' => '86000'], + ]); + self::assertResponseStatusCodeSame(422); + } + + public function testCharteredCarrierCompleteAddressIsCreated(): void + { + $carrier = $this->seedCarrierWithChartered('Affrete Complet', true); + $client = $this->createAdminClient(); + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'country' => 'France', + 'postalCode' => '86000', + 'city' => 'Poitiers', + 'street' => '12 rue des Acacias', + ], + ]); + self::assertResponseStatusCodeSame(201); + } + + public function testPatchAndDeleteSucceedWithManage(): void + { + $address = $this->seedAddress('Patch Delete', false); + $client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2) + + // PATCH (manage) -> 200 + $client->request('PATCH', '/api/carrier_addresses/'.$address->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['city' => 'Lyon'], + ]); + self::assertResponseStatusCodeSame(200); + + // DELETE (manage) -> 204 + $client->request('DELETE', '/api/carrier_addresses/'.$address->getId()); + self::assertResponseStatusCodeSame(204); + } + + public function testWriteForbiddenWithoutManage(): void + { + $address = $this->seedAddress('Forbidden', false); + $carrier = $address->getCarrier(); + self::assertNotNull($carrier); + $client = $this->authenticatedClient('commerciale', self::PWD); // view seul + + $client->request('POST', '/api/carriers/'.$carrier->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => ['postalCode' => '86000', 'city' => 'Poitiers', 'street' => '1 rue X'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('PATCH', '/api/carrier_addresses/'.$address->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['city' => 'Lyon'], + ]); + self::assertResponseStatusCodeSame(403); + + $client->request('DELETE', '/api/carrier_addresses/'.$address->getId()); + self::assertResponseStatusCodeSame(403); + } + + /** + * Seede un transporteur minimal en controlant le flag affrete (RG-4.05). + */ + private function seedCarrierWithChartered(string $name, bool $isChartered): Carrier + { + $em = $this->getEm(); + $carrier = new Carrier(); + $carrier->setName(mb_strtoupper($name, 'UTF-8')); + $carrier->setCertificationType('GMP_PLUS'); + $carrier->setIsChartered($isChartered); + $em->persist($carrier); + $em->flush(); + + return $carrier; + } + + /** + * Seede un transporteur + une adresse rattachee (pour les tests PATCH/DELETE). + */ + private function seedAddress(string $name, bool $isChartered): CarrierAddress + { + $em = $this->getEm(); + $carrier = $this->seedCarrierWithChartered($name, $isChartered); + + $address = new CarrierAddress(); + $address->setCarrier($carrier); + $address->setPostalCode('86000'); + $address->setCity('Poitiers'); + $address->setStreet('12 rue des Acacias'); + $carrier->addAddress($address); + $em->persist($address); + $em->flush(); + + return $address; + } +} diff --git a/tests/Module/Transport/Api/QualimatCarrierSearchTest.php b/tests/Module/Transport/Api/QualimatCarrierSearchTest.php new file mode 100644 index 0000000..5bb56c0 --- /dev/null +++ b/tests/Module/Transport/Api/QualimatCarrierSearchTest.php @@ -0,0 +1,129 @@ +setAutoExit(false); + $exit = $application->run( + new ArrayInput([ + 'command' => 'app:seed-rbac', + '--with-demo-users' => true, + '--password' => self::PWD, + ]), + new NullOutput(), + ); + self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).'); + + self::ensureKernelShutdown(); + } + + public function testSearchReturnsOnlyActiveOrderedByName(): void + { + // Marqueur unique partage par les 3 lignes : isole la recherche d'eventuelles + // autres lignes du referentiel. + $this->insertQualimat('QSEARCH GAMMA', true, 'A1'); + $this->insertQualimat('QSEARCH ALPHA', true, 'A2'); + $this->insertQualimat('QSEARCH BETA', false, 'A3'); // inactive -> exclue + + $client = $this->createAdminClient(); + $client->request('GET', '/api/qualimat_carriers?search=qsearch', ['headers' => ['Accept' => self::LD]]); + self::assertResponseIsSuccessful(); + + $data = $client->getResponse()->toArray(); + $names = array_column($data['member'], 'name'); + + self::assertSame(2, $data['totalItems'], 'Seules les 2 lignes actives doivent remonter (BETA inactive exclue).'); + self::assertSame(['QSEARCH ALPHA', 'QSEARCH GAMMA'], $names, 'Tri name ASC, sans la ligne inactive.'); + } + + public function testSearchMatchesSiret(): void + { + // Le nom ne porte pas le marqueur : la correspondance se fait via le siret. + $this->insertQualimat('TRANSPORTEUR SANS MARQUEUR', true, 'SIRETHIT1'); + + $client = $this->createAdminClient(); + $client->request('GET', '/api/qualimat_carriers?search=testqsirethit1', ['headers' => ['Accept' => self::LD]]); + self::assertResponseIsSuccessful(); + + $data = $client->getResponse()->toArray(); + self::assertSame(1, $data['totalItems'], 'La recherche fuzzy doit aussi cibler le siret.'); + self::assertSame('TRANSPORTEUR SANS MARQUEUR', $data['member'][0]['name']); + } + + public function testCollectionExposesHydraPagination(): void + { + $this->insertQualimat('QPAGE UN', true, 'P1'); + $this->insertQualimat('QPAGE DEUX', true, 'P2'); + $this->insertQualimat('QPAGE TROIS', true, 'P3'); + + $client = $this->createAdminClient(); + $client->request('GET', '/api/qualimat_carriers?search=qpage&itemsPerPage=2', ['headers' => ['Accept' => self::LD]]); + self::assertResponseIsSuccessful(); + + $data = $client->getResponse()->toArray(); + self::assertArrayHasKey('totalItems', $data, 'La collection doit exposer totalItems.'); + self::assertArrayHasKey('view', $data, 'La collection doit exposer view quand totalItems > itemsPerPage.'); + self::assertIsArray($data['member']); + self::assertSame(3, $data['totalItems']); + self::assertCount(2, $data['member'], 'La page doit etre bornee a itemsPerPage=2.'); + } + + public function testForbiddenWithoutPermission(): void + { + // Usine : aucun acces transporteurs (matrice § 5.2) -> 403 sur la recherche. + $client = $this->authenticatedClient('usine', self::PWD); + $client->request('GET', '/api/qualimat_carriers', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403); + } + + /** + * Insere une ligne qualimat_carrier de test en DBAL brut (l'entite mappee est + * en lecture seule). SIRET prefixe TESTQ pour la purge ciblee du tearDown. + */ + private function insertQualimat(string $name, bool $isActive, string $siretSuffix): void + { + $this->getEm()->getConnection()->insert('qualimat_carrier', [ + 'siret' => self::SIRET_PREFIX.$siretSuffix, + 'name' => $name, + 'status' => 'Valide', + 'validity_date' => '2027-12-31', + 'is_active' => $isActive ? 'true' : 'false', + 'last_synced_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'), + ]); + } +}