feat(transport) : endpoint recherche QualimatCarrier (ERP-156)

GET /api/qualimat_carriers?search= pour la saisie assistee du nom (RG-4.01,
spec-back § 4.7) : recherche fuzzy sur name (+ siret), restreinte aux lignes
actives (is_active = true), triee name ASC, paginee (regle n°13).

- QualimatCarrierRepositoryInterface + DoctrineQualimatCarrierRepository :
  QueryBuilder de recherche (forcage is_active cote serveur, fuzzy multi-champs).
- QualimatCarrierSearchProvider : provider de la GetCollection (pagination Hydra
  + echappatoire ?pagination=false), branche uniquement sur la collection.
- ApiResource : provider custom sur GetCollection, retrait des ApiFilter natifs
  (incapables d'unifier name/siret sous ?search= ni d'imposer l'actif). Mapping
  ORM inchange (schema:update reste no-op). Aucune ecriture exposee.
- Tests : actifs seuls, tri name, match siret, pagination Hydra, 403 sans perm.
This commit is contained in:
Matthieu
2026-06-16 08:34:28 +02:00
parent 97d7cacd2c
commit 456c6682b0
5 changed files with 285 additions and 9 deletions
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Endpoint de recherche du referentiel QUALIMAT (spec-back M4 § 4.7 / RG-4.01,
* ERP-156). Saisie assistee du nom : GET /api/qualimat_carriers?search= .
*
* Contrat verifie :
* - recherche fuzzy sur name (+ siret), SEULEMENT les lignes actives ;
* - tri name ASC ;
* - enveloppe Hydra paginee (member / totalItems / view — regle n°13) ;
* - 403 sans la permission transport.carriers.view (compta/usine, matrice § 5.2).
*
* @internal
*/
final class QualimatCarrierSearchTest extends AbstractCarrierApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
/** Prefixe SIRET dedie (purge par AbstractCarrierApiTestCase::tearDown). */
private const string SIRET_PREFIX = 'TESTQ';
protected function setUp(): void
{
parent::setUp();
// Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin
// qu'en recette), requis pour le test de permission (usine sans acces).
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 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'),
]);
}
}