Files
Inventory/docs/superpowers/plans/2026-04-02-machine-context-fields-backend.md
2026-04-03 09:25:07 +02:00

28 KiB

Machine Context Custom Fields — Backend Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. Parallel plan: This is the backend half. The frontend plan is at 2026-04-02-machine-context-fields-frontend.md. Both can run in parallel on separate worktrees — they share no files.

Goal: Add machineContextOnly flag on CustomField, link FKs on CustomFieldValue, structure controller normalization, upsert support, clone support, and tests.

Architecture: CustomField.machineContextOnly flags definitions. CustomFieldValue gets nullable FKs to MachineComponentLink/MachinePieceLink. The structure response returns contextCustomFields and contextCustomFieldValues per link. Upsert and clone are extended.

Tech Stack: Symfony 8, Doctrine ORM, API Platform 4, PostgreSQL 16, PHPUnit 12

Spec: docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md


File Map

Create

  • migrations/VersionXXXX_MachineContextCustomFields.php — migration
  • tests/Api/Entity/MachineContextCustomFieldTest.php — test class

Modify

  • src/Entity/CustomField.php — add machineContextOnly property
  • src/Entity/CustomFieldValue.php — add machineComponentLink and machinePieceLink FKs
  • src/Entity/MachineComponentLink.php — add contextFieldValues collection
  • src/Entity/MachinePieceLink.php — add contextFieldValues collection
  • src/Controller/MachineStructureController.php — normalize context fields + clone
  • src/Controller/CustomFieldValueController.php — link-based upsert
  • tests/AbstractApiTestCase.php — extend factories

Task 1: Migration + Entity CustomFieldmachineContextOnly

Files:

  • Modify: src/Entity/CustomField.php (add property after line 56)

  • Step 1: Add machineContextOnly property to CustomField entity

In src/Entity/CustomField.php, add after the $required property (line 56):

#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false], name: 'machinecontextonly')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private bool $machineContextOnly = false;

Add getter/setter before the closing }:

public function isMachineContextOnly(): bool
{
    return $this->machineContextOnly;
}

public function setMachineContextOnly(bool $machineContextOnly): static
{
    $this->machineContextOnly = $machineContextOnly;

    return $this;
}
  • Step 2: Generate and adjust migration
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff

Edit the generated migration to use idempotent SQL:

ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machinecontextonly BOOLEAN DEFAULT false NOT NULL;
  • Step 3: Run migration
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
  • Step 4: Run linter
make php-cs-fixer-allow-risky
  • Step 5: Commit
git add src/Entity/CustomField.php migrations/
git commit -m "feat(custom-fields) : add machineContextOnly flag to CustomField entity"

Files:

  • Modify: src/Entity/CustomFieldValue.php (add after line 67 — $product property)

  • Modify: src/Entity/MachineComponentLink.php (add after line 72 — $productLinks)

  • Modify: src/Entity/MachinePieceLink.php (add after line 61 — $productLinks)

  • Step 1: Add FKs to CustomFieldValue

In src/Entity/CustomFieldValue.php, add after the $product property (line 67):

#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(name: 'machinecomponentlinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachineComponentLink $machineComponentLink = null;

#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')]
#[ORM\JoinColumn(name: 'machinepiecelinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachinePieceLink $machinePieceLink = null;

Add getters/setters before the closing }:

public function getMachineComponentLink(): ?MachineComponentLink
{
    return $this->machineComponentLink;
}

public function setMachineComponentLink(?MachineComponentLink $machineComponentLink): static
{
    $this->machineComponentLink = $machineComponentLink;

    return $this;
}

public function getMachinePieceLink(): ?MachinePieceLink
{
    return $this->machinePieceLink;
}

public function setMachinePieceLink(?MachinePieceLink $machinePieceLink): static
{
    $this->machinePieceLink = $machinePieceLink;

    return $this;
}
  • Step 2: Add contextFieldValues collection to MachineComponentLink

In src/Entity/MachineComponentLink.php, add after the $productLinks collection (line 72):

/**
 * @var Collection<int, CustomFieldValue>
 */
#[ORM\OneToMany(mappedBy: 'machineComponentLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;

In the constructor (line 95), add:

$this->contextFieldValues = new ArrayCollection();

Add getter before the closing }:

/**
 * @return Collection<int, CustomFieldValue>
 */
public function getContextFieldValues(): Collection
{
    return $this->contextFieldValues;
}
  • Step 3: Add contextFieldValues collection to MachinePieceLink

In src/Entity/MachinePieceLink.php, add after the $productLinks collection (line 61):

/**
 * @var Collection<int, CustomFieldValue>
 */
#[ORM\OneToMany(mappedBy: 'machinePieceLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $contextFieldValues;

In the constructor (line 86), add:

$this->contextFieldValues = new ArrayCollection();

Add getter:

/**
 * @return Collection<int, CustomFieldValue>
 */
public function getContextFieldValues(): Collection
{
    return $this->contextFieldValues;
}
  • Step 4: Generate and adjust migration
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff

Edit migration to use idempotent SQL:

ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinecomponentlinkid VARCHAR(36) DEFAULT NULL;
ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinepiecelinkid VARCHAR(36) DEFAULT NULL;

DO $$ BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_component_link') THEN
        ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_component_link
            FOREIGN KEY (machinecomponentlinkid) REFERENCES machine_component_links(id) ON DELETE CASCADE;
    END IF;
END $$;

DO $$ BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_piece_link') THEN
        ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_piece_link
            FOREIGN KEY (machinepiecelinkid) REFERENCES machine_piece_links(id) ON DELETE CASCADE;
    END IF;
END $$;

CREATE INDEX IF NOT EXISTS idx_cfv_machine_component_link ON custom_field_values(machinecomponentlinkid);
CREATE INDEX IF NOT EXISTS idx_cfv_machine_piece_link ON custom_field_values(machinepiecelinkid);
  • Step 5: Run migration + linter
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
make php-cs-fixer-allow-risky
  • Step 6: Commit
git add src/Entity/CustomFieldValue.php src/Entity/MachineComponentLink.php src/Entity/MachinePieceLink.php migrations/
git commit -m "feat(custom-fields) : add link FKs to CustomFieldValue for machine context"

Task 3: Test factories — extend helpers

Files:

  • Modify: tests/AbstractApiTestCase.php:399-461

  • Step 1: Update createCustomField() factory

In tests/AbstractApiTestCase.php, add bool $machineContextOnly = false parameter at the end of createCustomField (line 399), and add $cf->setMachineContextOnly($machineContextOnly); after $cf->setOrderIndex($orderIndex); (line 411).

  • Step 2: Update createCustomFieldValue() factory

Add two new nullable parameters at the end of createCustomFieldValue (line 432):

?MachineComponentLink $machineComponentLink = null,
?MachinePieceLink $machinePieceLink = null,

Add the corresponding setter calls after the closing } of the if (null !== $product) block (after line 454, NOT inside it):

if (null !== $machineComponentLink) {
    $cfv->setMachineComponentLink($machineComponentLink);
}
if (null !== $machinePieceLink) {
    $cfv->setMachinePieceLink($machinePieceLink);
}
  • Step 3: Run linter
make php-cs-fixer-allow-risky
  • Step 4: Commit
git add tests/AbstractApiTestCase.php
git commit -m "test(custom-fields) : extend factories for machineContextOnly and link params"

Task 4: MachineStructureController — normalize context fields

Files:

  • Modify: src/Controller/MachineStructureController.php

  • Step 1: Add machineContextOnly to all normalization methods

In normalizeCustomFields (line 601), add to the output array at line 615:

'machineContextOnly' => $customField->isMachineContextOnly(),

In normalizeCustomFieldDefinitions (line 838), add to the output array at line 852:

'machineContextOnly' => $cf->isMachineContextOnly(),

In normalizeCustomFieldValues (line 861), add to the nested customField array at line 879:

'machineContextOnly' => $cf->isMachineContextOnly(),
  • Step 2: Add normalizeContextCustomFieldDefinitions helper

Add a new private method after normalizeCustomFieldValues:

private function normalizeContextCustomFieldDefinitions(Collection $customFields): array
{
    $items = [];
    foreach ($customFields as $cf) {
        if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) {
            continue;
        }
        $items[] = [
            'id'                 => $cf->getId(),
            'name'               => $cf->getName(),
            'type'               => $cf->getType(),
            'required'           => $cf->isRequired(),
            'options'            => $cf->getOptions(),
            'defaultValue'       => $cf->getDefaultValue(),
            'orderIndex'         => $cf->getOrderIndex(),
            'machineContextOnly' => true,
        ];
    }

    usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);

    return $items;
}
  • Step 3: Update normalizeComponentLinks to include context fields

In normalizeComponentLinks (line 622), add $type variable and context field keys to the returned array:

private function normalizeComponentLinks(array $links): array
{
    return array_map(function (MachineComponentLink $link): array {
        $composant  = $link->getComposant();
        $parentLink = $link->getParentLink();
        $type       = $composant->getTypeComposant();

        return [
            'id'                       => $link->getId(),
            'linkId'                   => $link->getId(),
            'machineId'                => $link->getMachine()->getId(),
            'composantId'              => $composant->getId(),
            'composant'                => $this->normalizeComposant($composant),
            'parentLinkId'             => $parentLink?->getId(),
            'parentComponentLinkId'    => $parentLink?->getId(),
            'parentComponentId'        => $parentLink?->getComposant()->getId(),
            'overrides'                => $this->normalizeOverrides($link),
            'childLinks'               => [],
            'pieceLinks'               => [],
            'contextCustomFields'      => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [],
            'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
        ];
    }, $links);
}
  • Step 4: Update normalizePieceLinks to include context fields

In normalizePieceLinks (line 644):

private function normalizePieceLinks(array $links): array
{
    return array_map(function (MachinePieceLink $link): array {
        $piece      = $link->getPiece();
        $parentLink = $link->getParentLink();
        $type       = $piece->getTypePiece();

        return [
            'id'                       => $link->getId(),
            'linkId'                   => $link->getId(),
            'machineId'                => $link->getMachine()->getId(),
            'pieceId'                  => $piece->getId(),
            'piece'                    => $this->normalizePiece($piece),
            'parentLinkId'             => $parentLink?->getId(),
            'parentComponentLinkId'    => $parentLink?->getId(),
            'parentComponentId'        => $parentLink?->getComposant()->getId(),
            'overrides'                => $this->normalizeOverrides($link),
            'quantity'                 => $this->resolvePieceQuantity($link),
            'contextCustomFields'      => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [],
            'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
        ];
    }, $links);
}
  • Step 5: Run linter
make php-cs-fixer-allow-risky
  • Step 6: Commit
git add src/Controller/MachineStructureController.php
git commit -m "feat(custom-fields) : expose context custom fields in machine structure response"

Files:

  • Modify: src/Controller/CustomFieldValueController.php

  • Step 1: Inject link repositories in constructor

In the constructor (line 24), add:

private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,

Add use statements at the top:

use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
  • Step 2: Extend resolveTarget to support link entities

In resolveTarget (line 211), the method applies strtolower() on entityType at line 213. The candidate loop and match block must handle this.

Update the candidate list in the foreach (line 217):

foreach (['machine', 'composant', 'piece', 'product', 'machineComponentLink', 'machinePieceLink'] as $candidate) {

IMPORTANT: The candidate loop assigns $entityType in camelCase, but strtolower() (line 213) only applies when entityType comes from the payload directly. Add a normalization after the loop closes (after the existing break; at line 226), before the empty-check at line 229:

$entityType = strtolower($entityType);

This ensures both code paths (direct payload entityType and candidate loop) deliver lowercase to the match block.

Update the match block (line 233) — all keys lowercase, but resolveEntity returns camelCase type for applyTarget:

return match ($entityType) {
    'machine'              => $this->resolveEntity('machine', $entityId, $this->machineRepository),
    'composant'            => $this->resolveEntity('composant', $entityId, $this->composantRepository),
    'piece'                => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
    'product'              => $this->resolveEntity('product', $entityId, $this->productRepository),
    'machinecomponentlink' => $this->resolveEntity('machineComponentLink', $entityId, $this->machineComponentLinkRepository),
    'machinepiecelink'     => $this->resolveEntity('machinePieceLink', $entityId, $this->machinePieceLinkRepository),
    default                => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
};
  • Step 3: Extend applyTarget for link entities

Add two new cases in applyTarget (line 252):

case 'machineComponentLink':
    $value->setMachineComponentLink($entity);
    $value->setComposant($entity->getComposant());

    break;

case 'machinePieceLink':
    $value->setMachinePieceLink($entity);
    $value->setPiece($entity->getPiece());

    break;
  • Step 4: Run linter
make php-cs-fixer-allow-risky
  • Step 5: Commit
git add src/Controller/CustomFieldValueController.php
git commit -m "feat(custom-fields) : support link-based upsert in CustomFieldValueController"

Task 6: Clone support — copy context field values

Files:

  • Modify: src/Controller/MachineStructureController.php (after cloneProductLinks, before flush in cloneMachine)

  • Step 1: Add cloneContextFieldValues helper method

Add after cloneProductLinks:

/**
 * @param array<string, MachineComponentLink> $componentLinkMap
 * @param array<string, MachinePieceLink>     $pieceLinkMap
 */
private function cloneContextFieldValues(
    array $componentLinkMap,
    array $pieceLinkMap,
): void {
    foreach ($componentLinkMap as $oldLinkId => $newLink) {
        $oldLink = $this->machineComponentLinkRepository->find($oldLinkId);
        if (!$oldLink) {
            continue;
        }
        foreach ($oldLink->getContextFieldValues() as $cfv) {
            $newValue = new CustomFieldValue();
            $newValue->setCustomField($cfv->getCustomField());
            $newValue->setValue($cfv->getValue());
            $newValue->setMachineComponentLink($newLink);
            $newValue->setComposant($newLink->getComposant());
            $this->entityManager->persist($newValue);
        }
    }

    foreach ($pieceLinkMap as $oldLinkId => $newLink) {
        $oldLink = $this->machinePieceLinkRepository->find($oldLinkId);
        if (!$oldLink) {
            continue;
        }
        foreach ($oldLink->getContextFieldValues() as $cfv) {
            $newValue = new CustomFieldValue();
            $newValue->setCustomField($cfv->getCustomField());
            $newValue->setValue($cfv->getValue());
            $newValue->setMachinePieceLink($newLink);
            $newValue->setPiece($newLink->getPiece());
            $this->entityManager->persist($newValue);
        }
    }
}
  • Step 2: Call from cloneMachine method

In cloneMachine (line 113), after $this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap); (line 163) and before $this->entityManager->flush(); (line 165), add:

$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
  • Step 3: Run linter
make php-cs-fixer-allow-risky
  • Step 4: Commit
git add src/Controller/MachineStructureController.php
git commit -m "feat(custom-fields) : clone context field values on machine clone"

Task 7: Backend tests

Files:

  • Create: tests/Api/Entity/MachineContextCustomFieldTest.php

  • Step 1: Write test class

<?php

declare(strict_types=1);

namespace App\Tests\Api\Entity;

use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;

class MachineContextCustomFieldTest extends AbstractApiTestCase
{
    public function testStructureReturnsContextFieldsOnComponentLink(): void
    {
        $client = $this->createGestionnaireClient();

        $site      = $this->createSite('Site A');
        $modelType = $this->createModelType('Motor', 'MOT', ModelCategory::COMPONENT);

        $contextField = $this->createCustomField(
            name: 'Voltage',
            type: 'number',
            typeComposant: $modelType,
            machineContextOnly: true,
        );
        $normalField = $this->createCustomField(
            name: 'Serial',
            type: 'text',
            typeComposant: $modelType,
        );

        $machine   = $this->createMachine('Machine A', $site);
        $composant = $this->createComposant('Motor 1', 'MOT-001', $modelType);
        $link      = $this->createMachineComponentLink($machine, $composant);

        $this->createCustomFieldValue(
            customField: $contextField,
            value: '220',
            composant: $composant,
            machineComponentLink: $link,
        );

        $response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
        $this->assertResponseIsSuccessful();

        $data          = $response->toArray();
        $componentLink = $data['componentLinks'][0];

        $this->assertArrayHasKey('contextCustomFields', $componentLink);
        $this->assertCount(1, $componentLink['contextCustomFields']);
        $this->assertSame('Voltage', $componentLink['contextCustomFields'][0]['name']);
        $this->assertTrue($componentLink['contextCustomFields'][0]['machineContextOnly']);

        $this->assertArrayHasKey('contextCustomFieldValues', $componentLink);
        $this->assertCount(1, $componentLink['contextCustomFieldValues']);
        $this->assertSame('220', $componentLink['contextCustomFieldValues'][0]['value']);

        $normalFields = array_filter(
            $componentLink['composant']['customFields'],
            fn (array $f) => $f['name'] === 'Serial',
        );
        $this->assertCount(1, $normalFields);
    }

    public function testStructureReturnsContextFieldsOnPieceLink(): void
    {
        $client = $this->createGestionnaireClient();

        $site      = $this->createSite('Site B');
        $modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
        $contextField = $this->createCustomField(
            name: 'Wear Level',
            type: 'select',
            typePiece: $modelType,
            machineContextOnly: true,
        );
        $contextField->setOptions(['Good', 'Fair', 'Replace']);
        $this->getEntityManager()->flush();

        $machine = $this->createMachine('Machine B', $site);
        $piece   = $this->createPiece('Bearing 1', 'BRG-001', $modelType);
        $link    = $this->createMachinePieceLink($machine, $piece);

        $this->createCustomFieldValue(
            customField: $contextField,
            value: 'Fair',
            piece: $piece,
            machinePieceLink: $link,
        );

        $response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
        $data     = $response->toArray();

        $pieceLink = $data['pieceLinks'][0];
        $this->assertCount(1, $pieceLink['contextCustomFields']);
        $this->assertSame('Wear Level', $pieceLink['contextCustomFields'][0]['name']);
        $this->assertCount(1, $pieceLink['contextCustomFieldValues']);
        $this->assertSame('Fair', $pieceLink['contextCustomFieldValues'][0]['value']);
    }

    public function testUpsertContextFieldValueViaComponentLink(): void
    {
        $client = $this->createGestionnaireClient();

        $site      = $this->createSite('Site C');
        $modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
        $contextField = $this->createCustomField(
            name: 'Flow Rate',
            type: 'number',
            typeComposant: $modelType,
            machineContextOnly: true,
        );

        $machine   = $this->createMachine('Machine C', $site);
        $composant = $this->createComposant('Pump 1', 'PMP-001', $modelType);
        $link      = $this->createMachineComponentLink($machine, $composant);

        $response = $client->request('POST', '/api/custom-fields/values/upsert', [
            'json' => [
                'customFieldId'          => $contextField->getId(),
                'machineComponentLinkId' => $link->getId(),
                'value'                  => '380',
            ],
        ]);

        $this->assertResponseIsSuccessful();
        $data = $response->toArray();
        $this->assertSame('380', $data['value']);
    }

    public function testSameComposantDifferentValuesPerMachine(): void
    {
        $client = $this->createGestionnaireClient();

        $site      = $this->createSite('Site D');
        $modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
        $contextField = $this->createCustomField(
            name: 'Pressure',
            type: 'number',
            typeComposant: $modelType,
            machineContextOnly: true,
        );

        $machineA  = $this->createMachine('Machine A', $site);
        $machineB  = $this->createMachine('Machine B', $site);
        $composant = $this->createComposant('Valve 1', 'VLV-001', $modelType);

        $linkA = $this->createMachineComponentLink($machineA, $composant);
        $linkB = $this->createMachineComponentLink($machineB, $composant);

        $this->createCustomFieldValue(
            customField: $contextField,
            value: '100',
            composant: $composant,
            machineComponentLink: $linkA,
        );
        $this->createCustomFieldValue(
            customField: $contextField,
            value: '200',
            composant: $composant,
            machineComponentLink: $linkB,
        );

        $dataA = $client->request('GET', '/api/machines/'.$machineA->getId().'/structure')->toArray();
        $this->assertSame('100', $dataA['componentLinks'][0]['contextCustomFieldValues'][0]['value']);

        $dataB = $client->request('GET', '/api/machines/'.$machineB->getId().'/structure')->toArray();
        $this->assertSame('200', $dataB['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
    }

    public function testMachineContextOnlyFieldSerialization(): void
    {
        $client = $this->createGestionnaireClient();

        $site      = $this->createSite('Site E');
        $modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
        $contextField = $this->createCustomField(
            name: 'Calibration Date',
            type: 'date',
            typeComposant: $modelType,
            machineContextOnly: true,
        );

        $response = $client->request('GET', '/api/custom_fields/'.$contextField->getId());
        $this->assertResponseIsSuccessful();
        $data = $response->toArray();
        $this->assertTrue($data['machineContextOnly']);
    }

    public function testCloneMachineCopiesContextFieldValues(): void
    {
        $client = $this->createGestionnaireClient();

        $site      = $this->createSite('Site F');
        $modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
        $contextField = $this->createCustomField(
            name: 'RPM Setting',
            type: 'number',
            typeComposant: $modelType,
            machineContextOnly: true,
        );

        $source    = $this->createMachine('Source Machine', $site);
        $composant = $this->createComposant('Motor C', 'MOTC-001', $modelType);
        $link      = $this->createMachineComponentLink($source, $composant);

        $this->createCustomFieldValue(
            customField: $contextField,
            value: '3000',
            composant: $composant,
            machineComponentLink: $link,
        );

        $response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
            'json' => [
                'name'   => 'Cloned Machine',
                'siteId' => $site->getId(),
            ],
        ]);

        $this->assertResponseStatusCodeSame(201);
        $data = $response->toArray();

        $clonedLink = $data['componentLinks'][0] ?? null;
        $this->assertNotNull($clonedLink);
        $this->assertCount(1, $clonedLink['contextCustomFieldValues']);
        $this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
    }
}
  • Step 2: Run tests
make test FILES=tests/Api/Entity/MachineContextCustomFieldTest.php

Expected: All 6 tests pass.

  • Step 3: Run linter
make php-cs-fixer-allow-risky
  • Step 4: Run full test suite
make test

Expected: All existing tests still pass.

  • Step 5: Commit
git add tests/Api/Entity/MachineContextCustomFieldTest.php
git commit -m "test(custom-fields) : add tests for machine context custom fields"