test(logistique) : tests PHPUnit RG-5.01→5.10 + capture contrat JSON (ERP-187) (#137)
Auto Tag Develop / tag (push) Successful in 8s

## ERP-187 (1.7) — Tests PHPUnit RG-5.01→5.10 + capture contrat JSON

Couvre les règles de gestion du M5 (tickets de pesée) par des tests PHPUnit et capture la **réponse JSON réelle** (DoD § 4.0.bis) collée dans `spec-back.md` avant les écrans front.

### Tests unitaires (Processor / Normalizer / Callback — sans BDD ni HTTP)
- **NetWeightTest** (RG-5.05) : net = plein − vide, `null` si une pesée manque, recalcul au PATCH.
- **CounterpartyValidationTest** (RG-5.03) : présence du champ requis par branche (propertyPath `client`/`supplier`/`otherLabel`) + exclusivité (null-ification hors-branche).
- **ImmatriculationNormalizationTest** (RG-5.01/5.10) : masque `XX-000-XX`, « Tout format », mapping 422 sur `immatriculation`.

### Tests fonctionnels (API réelle)
- **WeighingTicketNumberingTest** (RG-5.02/5.09) : format `{siteCode}-TP-{NNNN}`, séquence par site, isolation inter-sites, immuabilité numéro/site au PATCH.
- **WeighingTicketSerializationContractTest** (DoD § 4.0.bis) : 4 pièges verts (client embarqué, `plateFreeFormat` présent, `number` formaté, `netWeight` = full − empty) + dump JSON via `WEIGHING_TICKET_DOD_DUMP`.
- **WeighingTicketRBACMatrixTest** (§ 5.2) : admin/bureau/usine OK, compta/commerciale 403, anonyme 401.

> DSD / stub pont bascule / endpoint pesée déjà couverts (ERP-184/185).

### DoD
- `spec-back.md § 4.0.bis` : **JSON réel** (liste + détail) collé, 4 pièges marqués  — feu vert front.

### Vérifications
- `make test` complet **vert** : 848 tests, 6302 assertions (0 échec ; deprecations/notices PHPUnit seuls).
- `make php-cs-fixer-allow-risky` : 0 correction.

Empilée sur ERP-186 (stack M5).

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #137
This commit was merged in pull request #137.
This commit is contained in:
2026-06-18 13:33:39 +00:00
parent b4e550b5de
commit 36e947fd8e
10 changed files with 1211 additions and 41 deletions
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
use App\Module\Commercial\Domain\Entity\Supplier as SupplierEntity;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Base des tests fonctionnels du ticket de pesee (M5). Mutualise le seeding des
* dependances (Client cross-module, user manage/view rattache a un site courant),
* le payload POST de reference et la purge ciblee (pas de DAMA en local).
*
* Cloisonnement (§ 2.3) : le POST resout le site depuis le site courant de l'user
* (CurrentSiteProvider) ; on positionne donc toujours un site courant avant
* d'ecrire. Les Client de test sont prefixes pour une purge sans collision.
*
* @internal
*/
abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
{
protected const string LD = 'application/ld+json';
protected const string MERGE = 'application/merge-patch+json';
/** Prefixe companyName des Client seedes par ces tests (purge ciblee). */
protected const string TEST_CLIENT_PREFIX = 'ZTESTWTAPI';
/** Prefixe companyName des Supplier seedes par ces tests (purge ciblee). */
protected const string TEST_SUPPLIER_PREFIX = 'ZTESTWTAPISUP';
protected function tearDown(): void
{
$em = $this->getEm();
// Tickets referencant un Client OU un Supplier de test d'abord (FK
// client_id / supplier_id RESTRICT) : purge DBAL brute pour liberer la
// contrepartie avant de la supprimer. Un ticket FOURNISSEUR a client_id
// NULL -> il faut bien purger aussi par supplier_id (sinon ticket orphelin).
$em->getConnection()->executeStatement(
'DELETE FROM weighing_ticket WHERE client_id IN (SELECT id FROM client WHERE company_name LIKE :p)',
['p' => self::TEST_CLIENT_PREFIX.'%'],
);
$em->getConnection()->executeStatement(
'DELETE FROM weighing_ticket WHERE supplier_id IN (SELECT id FROM supplier WHERE company_name LIKE :p)',
['p' => self::TEST_SUPPLIER_PREFIX.'%'],
);
$em->createQuery('DELETE FROM '.SupplierEntity::class.' s WHERE s.companyName LIKE :p')
->setParameter('p', self::TEST_SUPPLIER_PREFIX.'%')->execute()
;
$em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p')
->setParameter('p', self::TEST_CLIENT_PREFIX.'%')->execute()
;
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p')
->setParameter('p', 'testuser_%')->execute()
;
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p')
->setParameter('p', 'test_%')->execute()
;
parent::tearDown();
}
/**
* Garde-fou ERP-101 (miroir M4) : une 422 doit porter une violation sur le
* `propertyPath` attendu, et pas seulement le bon code HTTP.
*/
protected static function assertViolationOnPath(object $response, string $path): void
{
/** @var ResponseInterface $response */
$paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath');
self::assertContains(
$path,
$paths,
sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)),
);
}
protected function firstSite(): Site
{
$site = $this->getEm()->getRepository(Site::class)->findAll()[0] ?? null;
self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis (SitesFixtures).');
return $site;
}
protected function siteByCode(string $code): Site
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy(['code' => $code]);
self::assertInstanceOf(Site::class, $site, sprintf('Le site de code "%s" doit etre seede.', $code));
return $site;
}
/**
* Cree un user non-admin portant view + manage, lui positionne $site comme site
* courant (cloisonnement + numerotation) et renvoie un client authentifie.
*/
protected function authManageOnSite(Site $site): Client
{
$creds = $this->createUserWithPermissions([
'logistique.weighing_tickets.view',
'logistique.weighing_tickets.manage',
]);
$this->setCurrentSite($creds['username'], $site);
return $this->authenticatedClient($creds['username'], $creds['password']);
}
/**
* Positionne le site courant d'un user (par username) — persiste en base, donc
* survit au reboot du kernel a l'authentification.
*/
protected function setCurrentSite(string $username, Site $site): void
{
$em = $this->getEm();
$user = $em->getRepository(User::class)->findOneBy(['username' => $username]);
self::assertInstanceOf(User::class, $user);
$user->setCurrentSite($em->getReference(Site::class, $site->getId()));
$em->flush();
}
/**
* Seede un Client minimal (companyName prefixe pour la purge). Sert de
* contrepartie aux tickets de test.
*/
protected function seedTestClient(string $label): ClientEntity
{
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
$client = new ClientEntity();
$client->setCompanyName(mb_strtoupper(self::TEST_CLIENT_PREFIX.' '.$label.' '.$suffix, 'UTF-8'));
$em->persist($client);
$em->flush();
return $client;
}
protected function clientIri(ClientEntity $client): string
{
return '/api/clients/'.$client->getId();
}
/**
* Seede un Supplier minimal (companyName prefixe pour la purge). Sert de
* contrepartie aux tickets de test en branche FOURNISSEUR (RG-5.03).
*/
protected function seedTestSupplier(string $label): SupplierEntity
{
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = new SupplierEntity();
$supplier->setCompanyName(mb_strtoupper(self::TEST_SUPPLIER_PREFIX.' '.$label.' '.$suffix, 'UTF-8'));
$em->persist($supplier);
$em->flush();
return $supplier;
}
protected function supplierIri(SupplierEntity $supplier): string
{
return '/api/suppliers/'.$supplier->getId();
}
/**
* Payload POST de reference : contrepartie Client, pesee a vide + a plein en
* mode AUTO (le Processor (re)alloue les DSD et calcule le net = 14300 - 7150).
*
* @return array<string, mixed>
*/
protected function validClientTicketPayload(ClientEntity $client): array
{
return [
'counterpartyType' => 'CLIENT',
'client' => $this->clientIri($client),
'immatriculation' => 'AB-123-CD',
'plateFreeFormat' => false,
'emptyDate' => '2026-06-17T09:00:00+02:00',
'emptyWeight' => 7150,
'emptyMode' => 'AUTO',
'fullDate' => '2026-06-17T09:12:00+02:00',
'fullWeight' => 14300,
'fullMode' => 'AUTO',
];
}
/**
* Payload POST de reference en branche FOURNISSEUR (RG-5.03) — miroir de
* validClientTicketPayload, contrepartie Supplier. Sert a prouver l'embed
* symetrique de `supplier` (spec § 4.0.bis piege #1).
*
* @return array<string, mixed>
*/
protected function validSupplierTicketPayload(SupplierEntity $supplier): array
{
return [
'counterpartyType' => 'FOURNISSEUR',
'supplier' => $this->supplierIri($supplier),
'immatriculation' => 'AB-123-CD',
'plateFreeFormat' => false,
'emptyDate' => '2026-06-17T09:00:00+02:00',
'emptyWeight' => 7150,
'emptyMode' => 'AUTO',
'fullDate' => '2026-06-17T09:12:00+02:00',
'fullWeight' => 14300,
'fullMode' => 'AUTO',
];
}
/**
* POST un ticket et renvoie la reponse (assertions de statut a la charge de
* l'appelant).
*/
protected function postTicket(Client $http, array $payload): ResponseInterface
{
return $http->request('POST', '/api/weighing_tickets', [
'headers' => ['Content-Type' => self::LD],
'json' => $payload,
]);
}
/**
* Retrouve un membre d'une collection Hydra par son id.
*
* @param array<string, mixed> $collection
*
* @return null|array<string, mixed>
*/
protected function memberById(array $collection, int $id): ?array
{
foreach ($collection['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}