Files
Inventory/tests/AbstractApiTestCase.php
r-dev 8f5cd98b82
All checks were successful
Auto Tag Develop / tag (push) Successful in 35s
fix(machine-clone) : preserve context field values when cloning a machine
Context CustomFieldValues attached to component/piece links were
silently dropped from the clone response (and from any subsequent
read in the same request) because the controller persisted the new
CFVs without adding them to the inverse-side collection of the new
link. Doctrine does not auto-sync inverse OneToMany associations,
so getContextFieldValues() returned an empty collection on the
freshly persisted link.

Also synchronise the inverse collection in the test factory so
identity-mapped entities reflect newly-created CFVs when reused
by request handlers within the same test.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:59:03 +02:00

638 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Entity\Composant;
use App\Entity\ComposantConstructeurLink;
use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\Constructeur;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachineConstructeurLink;
use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\PieceConstructeurLink;
use App\Entity\PieceProductSlot;
use App\Entity\Product;
use App\Entity\ProductConstructeurLink;
use App\Entity\Profile;
use App\Entity\Site;
use App\Enum\ModelCategory;
use Doctrine\ORM\EntityManagerInterface;
use stdClass;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
abstract class AbstractApiTestCase extends ApiTestCase
{
private const DEFAULT_PASSWORD = 'test1234';
protected function tearDown(): void
{
$this->getEntityManager()->clear();
parent::tearDown();
}
protected function getEntityManager(): EntityManagerInterface
{
return static::getContainer()->get('doctrine')->getManager();
}
protected function getPasswordHasher(): UserPasswordHasherInterface
{
return static::getContainer()->get(UserPasswordHasherInterface::class);
}
// ── Auth helpers ────────────────────────────────────────────────
protected function createAuthenticatedClient(string $role): Client
{
$profile = $this->createProfile(roles: [$role], password: self::DEFAULT_PASSWORD);
$client = static::createClient();
$client->request('POST', '/api/session/profile', [
'json' => [
'profileId' => $profile->getId(),
'password' => self::DEFAULT_PASSWORD,
],
]);
return $client;
}
protected function createViewerClient(): Client
{
return $this->createAuthenticatedClient('ROLE_VIEWER');
}
protected function createGestionnaireClient(): Client
{
return $this->createAuthenticatedClient('ROLE_GESTIONNAIRE');
}
protected function createAdminClient(): Client
{
return $this->createAuthenticatedClient('ROLE_ADMIN');
}
protected function createUnauthenticatedClient(): Client
{
return static::createClient();
}
// ── MCP helpers ──────────────────────────────────────────────────
/**
* @return array{client: Client, sessionId: string}
*/
protected function createMcpClient(string $role = 'ROLE_VIEWER'): array
{
$profile = $this->createProfile(roles: [$role], password: self::DEFAULT_PASSWORD);
return $this->initMcpSession($profile->getId(), self::DEFAULT_PASSWORD);
}
/**
* @return array{client: Client, sessionId: string}
*/
protected function initMcpSession(string $profileId, string $password): array
{
$client = static::createClient();
$response = $client->request('POST', '/_mcp', [
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'X-Profile-Id' => $profileId,
'X-Profile-Password' => $password,
],
'body' => json_encode([
'jsonrpc' => '2.0',
'method' => 'initialize',
'params' => [
'protocolVersion' => '2025-03-26',
'capabilities' => new stdClass(),
'clientInfo' => ['name' => 'test', 'version' => '1.0'],
],
'id' => 1,
]),
]);
$sessionId = $response->getHeaders()['mcp-session-id'][0] ?? '';
$client->request('POST', '/_mcp', [
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'X-Profile-Id' => $profileId,
'X-Profile-Password' => $password,
'Mcp-Session-Id' => $sessionId,
],
'body' => json_encode([
'jsonrpc' => '2.0',
'method' => 'notifications/initialized',
]),
]);
return ['client' => $client, 'sessionId' => $sessionId, 'profileId' => $profileId, 'password' => $password];
}
/**
* @return array<string, mixed>
*/
protected function callMcpTool(array $session, string $toolName, array $arguments = []): array
{
$response = $session['client']->request('POST', '/_mcp', [
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json, text/event-stream',
'X-Profile-Id' => $session['profileId'],
'X-Profile-Password' => $session['password'],
'Mcp-Session-Id' => $session['sessionId'],
],
'body' => json_encode([
'jsonrpc' => '2.0',
'method' => 'tools/call',
'params' => [
'name' => $toolName,
'arguments' => empty($arguments) ? new stdClass() : $arguments,
],
'id' => random_int(10, 9999),
]),
]);
$raw = $response->getContent(false);
$data = json_decode($raw, true);
if (null === $data) {
// SSE format: parse "data: {...}" lines
foreach (explode("\n", $raw) as $line) {
if (str_starts_with($line, 'data: ')) {
$parsed = json_decode(substr($line, 6), true);
if ($parsed && (isset($parsed['result']) || isset($parsed['error']))) {
$data = $parsed;
break;
}
}
}
}
$data ??= [];
if (isset($data['result']['content'][0]['text'])) {
$data['_parsed'] = json_decode($data['result']['content'][0]['text'], true);
}
return $data;
}
// ── Factory helpers ─────────────────────────────────────────────
protected function createProfile(
string $firstName = 'Test',
string $lastName = 'User',
?string $email = null,
array $roles = ['ROLE_USER'],
?string $password = null,
bool $isActive = true,
): Profile {
$profile = new Profile();
$profile->setFirstName($firstName);
$profile->setLastName($lastName);
$profile->setEmail($email ?? uniqid('test-', true).'@test.local');
$profile->setRoles($roles);
$profile->setIsActive($isActive);
if (null !== $password) {
$hashed = $this->getPasswordHasher()->hashPassword($profile, $password);
$profile->setPassword($hashed);
}
$em = $this->getEntityManager();
$em->persist($profile);
$em->flush();
return $profile;
}
protected function createSite(string $name = 'Site Test', array $extra = []): Site
{
$site = new Site();
$site->setName($name);
if (isset($extra['contactName'])) {
$site->setContactName($extra['contactName']);
}
if (isset($extra['contactCity'])) {
$site->setContactCity($extra['contactCity']);
}
$em = $this->getEntityManager();
$em->persist($site);
$em->flush();
return $site;
}
protected function createConstructeur(string $name = 'Constructeur Test', ?string $email = null, ?string $phone = null): Constructeur
{
$c = new Constructeur();
$c->setName($name);
$c->setEmail($email);
$c->setPhone($phone);
$em = $this->getEntityManager();
$em->persist($c);
$em->flush();
return $c;
}
protected function createMachineConstructeurLink(Machine $machine, Constructeur $constructeur, ?string $supplierReference = null): MachineConstructeurLink
{
$link = new MachineConstructeurLink();
$link->setMachine($machine);
$link->setConstructeur($constructeur);
$link->setSupplierReference($supplierReference);
$this->getEntityManager()->persist($link);
$this->getEntityManager()->flush();
return $link;
}
protected function createPieceConstructeurLink(Piece $piece, Constructeur $constructeur, ?string $supplierReference = null): PieceConstructeurLink
{
$link = new PieceConstructeurLink();
$link->setPiece($piece);
$link->setConstructeur($constructeur);
$link->setSupplierReference($supplierReference);
$this->getEntityManager()->persist($link);
$this->getEntityManager()->flush();
return $link;
}
protected function createComposantConstructeurLink(Composant $composant, Constructeur $constructeur, ?string $supplierReference = null): ComposantConstructeurLink
{
$link = new ComposantConstructeurLink();
$link->setComposant($composant);
$link->setConstructeur($constructeur);
$link->setSupplierReference($supplierReference);
$this->getEntityManager()->persist($link);
$this->getEntityManager()->flush();
return $link;
}
protected function createProductConstructeurLink(Product $product, Constructeur $constructeur, ?string $supplierReference = null): ProductConstructeurLink
{
$link = new ProductConstructeurLink();
$link->setProduct($product);
$link->setConstructeur($constructeur);
$link->setSupplierReference($supplierReference);
$this->getEntityManager()->persist($link);
$this->getEntityManager()->flush();
return $link;
}
protected function createModelType(
string $name = 'ModelType Test',
string $code = 'MT-001',
ModelCategory $category = ModelCategory::COMPONENT,
): ModelType {
$mt = new ModelType();
$mt->setName($name);
$mt->setCode($code);
$mt->setCategory($category);
$em = $this->getEntityManager();
$em->persist($mt);
$em->flush();
return $mt;
}
protected function createMachine(string $name = 'Machine Test', ?Site $site = null, ?string $reference = null): Machine
{
$site ??= $this->createSite();
$machine = new Machine();
$machine->setName($name);
$machine->setSite($site);
if (null !== $reference) {
$machine->setReference($reference);
}
$em = $this->getEntityManager();
$em->persist($machine);
$em->flush();
return $machine;
}
protected function createComposant(string $name = 'Composant Test', ?string $reference = null, ?ModelType $type = null): Composant
{
$c = new Composant();
$c->setName($name);
if (null !== $reference) {
$c->setReference($reference);
}
if (null !== $type) {
$c->setTypeComposant($type);
}
$em = $this->getEntityManager();
$em->persist($c);
$em->flush();
return $c;
}
protected function createPiece(string $name = 'Piece Test', ?string $reference = null, ?ModelType $type = null): Piece
{
$p = new Piece();
$p->setName($name);
if (null !== $reference) {
$p->setReference($reference);
}
if (null !== $type) {
$p->setTypePiece($type);
}
$em = $this->getEntityManager();
$em->persist($p);
$em->flush();
return $p;
}
protected function createProduct(string $name = 'Product Test', ?string $reference = null, ?ModelType $type = null): Product
{
$p = new Product();
$p->setName($name);
if (null !== $reference) {
$p->setReference($reference);
}
if (null !== $type) {
$p->setTypeProduct($type);
}
$em = $this->getEntityManager();
$em->persist($p);
$em->flush();
return $p;
}
protected function createCustomField(
string $name = 'Custom Field',
string $type = 'text',
?Machine $machine = null,
?ModelType $typeComposant = null,
?ModelType $typePiece = null,
?ModelType $typeProduct = null,
int $orderIndex = 0,
bool $machineContextOnly = false,
): CustomField {
$cf = new CustomField();
$cf->setName($name);
$cf->setType($type);
$cf->setOrderIndex($orderIndex);
$cf->setMachineContextOnly($machineContextOnly);
if (null !== $machine) {
$cf->setMachine($machine);
}
if (null !== $typeComposant) {
$cf->setTypeComposant($typeComposant);
}
if (null !== $typePiece) {
$cf->setTypePiece($typePiece);
}
if (null !== $typeProduct) {
$cf->setTypeProduct($typeProduct);
}
$em = $this->getEntityManager();
$em->persist($cf);
$em->flush();
return $cf;
}
protected function createCustomFieldValue(
CustomField $customField,
string $value = 'test value',
?Machine $machine = null,
?Composant $composant = null,
?Piece $piece = null,
?Product $product = null,
?MachineComponentLink $machineComponentLink = null,
?MachinePieceLink $machinePieceLink = null,
): CustomFieldValue {
$cfv = new CustomFieldValue();
$cfv->setValue($value);
$cfv->setCustomField($customField);
if (null !== $machine) {
$cfv->setMachine($machine);
}
if (null !== $composant) {
$cfv->setComposant($composant);
}
if (null !== $piece) {
$cfv->setPiece($piece);
}
if (null !== $product) {
$cfv->setProduct($product);
}
if (null !== $machineComponentLink) {
$cfv->setMachineComponentLink($machineComponentLink);
}
if (null !== $machinePieceLink) {
$cfv->setMachinePieceLink($machinePieceLink);
}
$em = $this->getEntityManager();
$em->persist($cfv);
$em->flush();
// Keep inverse-side collections in sync so identity-mapped entities reflect the new CFV.
if (null !== $machineComponentLink && !$machineComponentLink->getContextFieldValues()->contains($cfv)) {
$machineComponentLink->getContextFieldValues()->add($cfv);
}
if (null !== $machinePieceLink && !$machinePieceLink->getContextFieldValues()->contains($cfv)) {
$machinePieceLink->getContextFieldValues()->add($cfv);
}
return $cfv;
}
protected function createMachineComponentLink(Machine $machine, Composant $composant): MachineComponentLink
{
$link = new MachineComponentLink();
$link->setMachine($machine);
$link->setComposant($composant);
$em = $this->getEntityManager();
$em->persist($link);
$em->flush();
return $link;
}
protected function createMachinePieceLink(Machine $machine, Piece $piece, ?MachineComponentLink $parentLink = null, int $quantity = 1): MachinePieceLink
{
$link = new MachinePieceLink();
$link->setMachine($machine);
$link->setPiece($piece);
$link->setQuantity($quantity);
if (null !== $parentLink) {
$link->setParentLink($parentLink);
}
$em = $this->getEntityManager();
$em->persist($link);
$em->flush();
return $link;
}
protected function createMachineProductLink(Machine $machine, Product $product): MachineProductLink
{
$link = new MachineProductLink();
$link->setMachine($machine);
$link->setProduct($product);
$em = $this->getEntityManager();
$em->persist($link);
$em->flush();
return $link;
}
protected function createComposantPieceSlot(
Composant $composant,
?ModelType $typePiece = null,
?Piece $selectedPiece = null,
int $quantity = 1,
int $position = 0,
): ComposantPieceSlot {
$slot = new ComposantPieceSlot();
$slot->setComposant($composant);
$slot->setQuantity($quantity);
$slot->setPosition($position);
if (null !== $typePiece) {
$slot->setTypePiece($typePiece);
}
if (null !== $selectedPiece) {
$slot->setSelectedPiece($selectedPiece);
}
$em = $this->getEntityManager();
$em->persist($slot);
$em->flush();
return $slot;
}
protected function createComposantSubcomponentSlot(
Composant $composant,
?string $alias = null,
?string $familyCode = null,
?ModelType $typeComposant = null,
?Composant $selectedComposant = null,
int $position = 0,
): ComposantSubcomponentSlot {
$slot = new ComposantSubcomponentSlot();
$slot->setComposant($composant);
$slot->setAlias($alias);
$slot->setFamilyCode($familyCode);
$slot->setPosition($position);
if (null !== $typeComposant) {
$slot->setTypeComposant($typeComposant);
}
if (null !== $selectedComposant) {
$slot->setSelectedComposant($selectedComposant);
}
$em = $this->getEntityManager();
$em->persist($slot);
$em->flush();
return $slot;
}
protected function createComposantProductSlot(
Composant $composant,
?ModelType $typeProduct = null,
?Product $selectedProduct = null,
?string $familyCode = null,
int $position = 0,
): ComposantProductSlot {
$slot = new ComposantProductSlot();
$slot->setComposant($composant);
$slot->setFamilyCode($familyCode);
$slot->setPosition($position);
if (null !== $typeProduct) {
$slot->setTypeProduct($typeProduct);
}
if (null !== $selectedProduct) {
$slot->setSelectedProduct($selectedProduct);
}
$em = $this->getEntityManager();
$em->persist($slot);
$em->flush();
return $slot;
}
protected function createPieceProductSlot(
Piece $piece,
?ModelType $typeProduct = null,
?Product $selectedProduct = null,
?string $familyCode = null,
int $position = 0,
): PieceProductSlot {
$slot = new PieceProductSlot();
$slot->setPiece($piece);
$slot->setFamilyCode($familyCode);
$slot->setPosition($position);
if (null !== $typeProduct) {
$slot->setTypeProduct($typeProduct);
}
if (null !== $selectedProduct) {
$slot->setSelectedProduct($selectedProduct);
}
$em = $this->getEntityManager();
$em->persist($slot);
$em->flush();
return $slot;
}
// ── Assertion helpers ───────────────────────────────────────────
protected function assertJsonContainsHydraCollection(): void
{
$this->assertJsonContains(['@type' => 'Collection']);
}
protected static function iri(string $resource, string $id): string
{
return sprintf('/api/%s/%s', $resource, $id);
}
}