47fdbc43ff
- WeighingTicketSerializationContractTest : couvre la branche FOURNISSEUR (supplier embarque, client null) en plus de CLIENT (piege #1 symetrique, spec § 4.0.bis). - AbstractWeighingTicketApiTestCase : helpers seedTestSupplier + payload FOURNISSEUR + purge supplier au tearDown. - WeighbridgeReadingApiTest : les 422 (mode invalide / poids manquant) verifient desormais le propertyPath (garde-fou ERP-101), pas seulement le code HTTP. - NetWeightTest : docbloc isNew/contains() clarifie.
251 lines
8.9 KiB
PHP
251 lines
8.9 KiB
PHP
<?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;
|
|
}
|
|
}
|