Compare commits

..

4 Commits

Author SHA1 Message Date
914c741578 Add support for motive descendants in filters and improve Motive entity
- Added `getDescendants` method to `Motive` entity to retrieve all descendants recursively.
- Updated `TicketACLAwareRepository` to include descendants when filtering by motives.
- Updated API specification to clarify that motive descendants are included in query filters.
- Added unit tests for `Motive` entity to validate descendant logic.
2025-09-24 21:48:15 +02:00
f50b7ee1cd Refactor LoadMotives fixture to support hierarchical motive relationships
- Implemented parent/child relationships in motive construction logic.
- Refactored data parsing to handle nested motives with labels and associated attributes.
- Updated fixtures to align with the new hierarchical motive structure.
2025-09-24 21:06:49 +02:00
433b6c2d71 Add MotiveNormalizer and support for parent/child relationships in motives
- Introduced `MotiveNormalizer` to handle normalization of `Motive` entities.
- Enhanced serialization groups to support parent-to-children and children-to-parent relationships.
- Updated TypeScript types and entities to align with the new schema.
- Adjusted API responses and query behavior to reflect hierarchical motive relationships.
2025-09-24 21:06:41 +02:00
0e1ec389a5 Add parent / child relationship top motives 2025-09-24 14:57:12 +02:00
12 changed files with 518 additions and 52 deletions

View File

@@ -112,7 +112,7 @@ paths:
- no
- name: byMotives
in: query
description: the motives of the ticket
description: the motives of the ticket. All the descendants of the motive are taken into account.
required: false
style: form
explode: false

View File

@@ -13,6 +13,7 @@ namespace Chill\TicketBundle\Controller;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\TicketBundle\Serializer\Normalizer\MotiveNormalizer;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\Request;
@@ -21,13 +22,13 @@ final class MotiveApiController extends ApiController
protected function customizeQuery(string $action, Request $request, $query): void
{
/* @var $query QueryBuilder */
$query->andWhere('e.active = TRUE');
$query->andWhere('e.active = TRUE')->andWhere('e.parent IS NULL');
}
protected function getContextForSerialization(string $action, Request $request, string $_format, $entity): array
{
return match ($request->getMethod()) {
Request::METHOD_GET => ['groups' => ['read', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY]],
Request::METHOD_GET => ['groups' => ['read', 'read:extended', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY, MotiveNormalizer::GROUP_PARENT_TO_CHILDREN]],
default => parent::getContextForSerialization($action, $request, $_format, $entity),
};
}

View File

@@ -13,7 +13,6 @@ namespace Chill\TicketBundle\DataFixtures\ORM;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Motive;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
@@ -35,6 +34,7 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
['label' => '☀️ De 07h à 21h', 'path' => __DIR__.'/docs/peloton_2.pdf'],
['label' => 'Dimanche et jours fériés', 'path' => __DIR__.'/docs/schema_1.png'],
];
$motivesByLabel = [];
foreach (explode("\n", self::MOTIVES) as $row) {
if ('' === trim($row)) {
@@ -46,50 +46,65 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
continue;
}
$motive = new Motive();
$motive->setLabel(['fr' => trim((string) $data[0])]);
$motive->setMakeTicketEmergency(match ($data[1]) {
'true' => EmergencyStatusEnum::YES,
'false' => EmergencyStatusEnum::NO,
default => throw new \UnexpectedValueException('Unexpected value'),
});
$labels = explode(' > ', $data[0]);
$parent = null;
$numberOfDocs = (int) $data[2];
for ($i = 1; $i <= $numberOfDocs; ++$i) {
$doc = $docs[$i - 1];
$storedObject = new StoredObject();
$storedObject->setTitle($doc['label']);
while (count($labels) > 0) {
$label = array_shift($labels);
dump($labels);
if (isset($motivesByLabel[$label])) {
$motive = $motivesByLabel[$label];
} else {
$motive = new Motive();
$motive->setLabel(['fr' => $label]);
$motivesByLabel[$label] = $motive;
}
$content = file_get_contents($doc['path']);
$contentType = match (substr($doc['path'], -3, 3)) {
'pdf' => 'application/pdf',
'png' => 'image/png',
default => throw new \UnexpectedValueException('Not supported content type here'),
};
$this->storedObjectManager->write($storedObject, $content, $contentType);
if (null !== $parent) {
$motive->setParent($parent);
}
$motive->addStoredObject($storedObject);
$manager->persist($storedObject);
}
$manager->persist($motive);
$parent = $motive;
if (0 === count($labels)) {
// this is the last one, we add data
$numberOfDocs = (int) $data[2];
for ($i = 1; $i <= $numberOfDocs; ++$i) {
$doc = $docs[$i - 1];
$storedObject = new StoredObject();
$storedObject->setTitle($doc['label']);
$content = file_get_contents($doc['path']);
$contentType = match (substr($doc['path'], -3, 3)) {
'pdf' => 'application/pdf',
'png' => 'image/png',
default => throw new \UnexpectedValueException('Not supported content type here'),
};
$this->storedObjectManager->write($storedObject, $content, $contentType);
$motive->addStoredObject($storedObject);
$manager->persist($storedObject);
}
foreach (array_slice($data, 3) as $supplementaryComment) {
if ('' !== trim((string) $supplementaryComment)) {
$motive->addSupplementaryComment(['label' => trim((string) $supplementaryComment)]);
foreach (array_slice($data, 3) as $supplementaryComment) {
if ('' !== trim((string) $supplementaryComment)) {
$motive->addSupplementaryComment(['label' => trim((string) $supplementaryComment)]);
}
}
}
}
$manager->persist($motive);
}
$manager->flush();
}
private const MOTIVES = <<<'CSV'
"Coordonnées",false,"3","Nouvelles coordonnées",
"Horaire de passage",false,"0",
"Retard de livraison",false,"0",
"Erreur de livraison",false,"0",
"Colis incomplet",false,"0",
"Motif administratif > Coordonnées",false,"3","Nouvelles coordonnées",
"Organisation > Horaire de passage",false,"0",
"Organisation > Livraison > Retard de livraison",false,"0",
"Organisation > Livraison > Erreur de livraison",false,"0",
"Organisation > Livraison > Colis incomplet",false,"0",
"MATLOC",false,"0",
"Retard DASRI",false,"1",
"Planning d'astreintes",false,"0",
@@ -116,7 +131,7 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
"Mauvaise adresse",false,"0",
"Patient absent",false,"0",
"Annulation",false,"0",
"Colis perdu",false,"0",
"Organisation > Livraison > Colis perdu",false,"0",
"Changement de rendez-vous",false,"0",
"Coordination interservices",false,"0",
"Problème de substitution produits",true,"0",

View File

@@ -15,30 +15,25 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
#[ORM\Entity()]
#[ORM\Table(name: 'motive', schema: 'chill_ticket')]
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_motive' => Motive::class])]
class Motive
{
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
#[Serializer\Groups(['read'])]
private array $label = [];
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])]
#[Serializer\Groups(['read'])]
private bool $active = true;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: true, enumType: EmergencyStatusEnum::class)]
#[Serializer\Groups(['read'])]
private ?EmergencyStatusEnum $makeTicketEmergency = null;
/**
@@ -49,12 +44,22 @@ class Motive
private Collection $storedObjects;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['jsonb' => true, 'default' => '[]'])]
#[Serializer\Groups(['read'])]
private array $supplementaryComments = [];
#[ORM\ManyToOne(targetEntity: Motive::class, inversedBy: 'children')]
private ?Motive $parent = null;
/**
* @var Collection<int, Motive>&Selectable<int, Motive>
*/
#[ORM\OneToMany(targetEntity: Motive::class, mappedBy: 'parent')]
private Collection&Selectable $children;
public function __construct()
{
$this->storedObjects = new ArrayCollection();
$this->children = new ArrayCollection();
}
public function addStoredObject(StoredObject $storedObject): void
@@ -69,7 +74,6 @@ class Motive
$this->storedObjects->removeElement($storedObject);
}
#[Serializer\Groups(['read'])]
public function getStoredObjects(): ReadableCollection
{
return $this->storedObjects;
@@ -142,4 +146,74 @@ class Motive
$this->supplementaryComments[$key] = $supplementaryComment;
}
}
public function isParent(): bool
{
return $this->children->count() > 0;
}
public function isChild(): bool
{
return null !== $this->parent;
}
public function setParent(?Motive $parent): void
{
if (null !== $parent) {
$parent->addChild($this);
} else {
$this->parent->removeChild($this);
}
$this->parent = $parent;
}
/**
* @internal use @see{setParent} instead
*/
public function addChild(Motive $child): void
{
if (!$this->children->contains($child)) {
$this->children->add($child);
}
}
/**
* @internal use @see{setParent} with null as argument instead
*/
public function removeChild(Motive $child): void
{
$this->children->removeElement($child);
}
public function getChildren(): ReadableCollection&Selectable
{
return $this->children;
}
public function getParent(): ?Motive
{
return $this->parent;
}
/**
* Get the descendants of the current entity.
*
* This method collects all descendant entities recursively, starting from the current entity
* and including all of its children and their descendants.
*
* @return ReadableCollection&Selectable A collection containing the current entity and all its descendants
*/
public function getDescendants(): ReadableCollection&Selectable
{
$collection = new ArrayCollection([$this]);
foreach ($this->getChildren() as $child) {
foreach ($child->getDescendants() as $descendant) {
$collection->add($descendant);
}
}
return $collection;
}
}

View File

@@ -42,7 +42,7 @@ class MotiveHistory implements TrackCreationInterface
public function __construct(
#[ORM\ManyToOne(targetEntity: Motive::class)]
#[ORM\JoinColumn(nullable: false)]
#[Serializer\Groups(['read'])]
#[Serializer\Groups(['read', 'read:children-to-parent'])]
private Motive $motive,
#[ORM\ManyToOne(targetEntity: Ticket::class)]
#[ORM\JoinColumn(nullable: false)]

View File

@@ -113,10 +113,11 @@ final readonly class TicketACLAwareRepository implements TicketACLAwareRepositor
if (array_key_exists('byMotives', $params)) {
$byMotives = $qb->expr()->orX();
foreach ($params['byMotives'] as $motive) {
$motivesWithDescendants = $motive->getDescendants()->toArray();
$byMotives->add(
$qb->expr()->exists(sprintf(
'SELECT 1 FROM %s tp_motive_%d WHERE tp_motive_%d.ticket = t
AND tp_motive_%d.motive = :motive_%d AND tp_motive_%d.endDate IS NULL
AND tp_motive_%d.motive IN (:motives_%d) AND tp_motive_%d.endDate IS NULL
',
MotiveHistory::class,
++$i,
@@ -126,7 +127,7 @@ final readonly class TicketACLAwareRepository implements TicketACLAwareRepositor
$i,
))
);
$qb->setParameter(sprintf('motive_%d', $i), $motive);
$qb->setParameter(sprintf('motives_%d', $i), $motivesWithDescendants);
}
$qb->andWhere($byMotives);
}

View File

@@ -8,14 +8,32 @@ import { Person } from "ChillPersonAssets/types";
import { Thirdparty } from "../../../../ChillThirdPartyBundle/Resources/public/types";
import { StoredObject } from "ChillDocStoreAssets/types";
export interface Motive {
interface MotiveBase {
type: "ticket_motive";
id: number;
active: boolean;
label: TranslatableString;
}
/**
* Represent a motive with basic information and parent motive.
*
* Match the "read" and "read:children-to-parent" serializer groups.
*/
export interface MotiveWithParent extends MotiveBase {
parent: MotiveWithParent|null;
}
/**
* Represents a motive for a ticket, including details like emergency status, stored objects, and supplementary comments.
*
* Match the "read:extended" serializer group in MotiveNormalizer.
*/
export interface Motive extends MotiveBase {
makeTicketEmergency: TicketEmergencyState;
storedObjects: StoredObject[];
supplementaryComments: { label: string }[];
children: Motive[];
}
export type TicketState = "open" | "closed" | "close";
@@ -45,7 +63,7 @@ export interface MotiveHistory {
id: number;
startDate: null;
endDate: null | DateTime;
motive: Motive;
motive: MotiveWithParent;
createdBy: User | null;
createdAt: DateTime | null;
}
@@ -140,7 +158,7 @@ interface BaseTicket<
createdAt: DateTime | null;
currentAddressees: UserGroupOrUser[];
currentPersons: Person[];
currentMotive: null | Motive;
currentMotive: null | MotiveWithParent;
currentState: TicketState | null;
emergency: TicketEmergencyState | null;
caller: Person | Thirdparty | null;

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Serializer\Normalizer;
use Chill\TicketBundle\Entity\Motive;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* Normalizes a Motive object into an array format, supporting different serialization groups
* to customize the output depending on the context.
*
* There are several serialization groups available:
* - 'read': Basic information about the motive.
* - 'read:extended': Includes additional details like stored objects and supplementary comments.
* - 'read:parent-to-children': Normalizes children recursively without exposing parent to avoid cycles.
* - 'read:children-to-parent': Normalizes parent recursively without exposing children to avoid cycles.
*/
final class MotiveNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public const GROUP_PARENT_TO_CHILDREN = 'read:parent-to-children';
public const GROUP_CHILDREN_TO_PARENT = 'read:children-to-parent';
public function normalize($object, ?string $format = null, array $context = []): array
{
if (!$object instanceof Motive) {
throw new UnexpectedValueException('Expected instance of '.Motive::class);
}
$groups = $context[AbstractNormalizer::GROUPS] ?? [];
if (is_string($groups)) {
$groups = [$groups];
}
$data = [];
if (in_array('read', $groups, true) || in_array('read:extended', $groups, true)) {
// Build base representation
$data = [
'type' => 'ticket_motive',
'id' => $object->getId(),
'label' => $object->getLabel(),
'active' => $object->isActive(),
];
}
if (in_array('read:extended', $groups, true)) {
$data['makeTicketEmergency'] = $object->getMakeTicketEmergency();
$data['supplementaryComments'] = $object->getSupplementaryComments();
// Normalize stored objects (delegated to their own normalizer when present)
$storedObjects = [];
foreach ($object->getStoredObjects() as $storedObject) {
$storedObjects[] = $this->normalizer->normalize($storedObject, $format, $context);
}
$data['storedObjects'] = $storedObjects;
}
if (in_array(self::GROUP_PARENT_TO_CHILDREN, $groups, true)) {
// Normalize children recursively (but we do not expose parent to avoid cycles)
$children = [];
foreach ($object->getChildren() as $child) {
$children[] = $this->normalizer->normalize($child, $format, $context);
}
$data['children'] = $children;
} elseif (in_array(self::GROUP_CHILDREN_TO_PARENT, $groups, true)) {
$data['parent'] = $this->normalizer->normalize($object->getParent(), $format, $context);
}
return $data;
}
public function supportsNormalization($data, ?string $format = null, array $context = []): bool
{
return $data instanceof Motive;
}
/**
* Optimization hint for the Serializer (available since Symfony 5.3+).
*
* @return array<class-string, bool>
*/
public function getSupportedTypes(?string $format): array
{
return [
Motive::class => true,
];
}
}

View File

@@ -51,7 +51,7 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
]),
'currentAddressees' => $this->normalizer->normalize($object->getCurrentAddressee(), $format, ['groups' => 'read']),
'currentInputs' => $this->normalizer->normalize($object->getCurrentInputs(), $format, ['groups' => 'read']),
'currentMotive' => $this->normalizer->normalize($object->getMotive(), $format, ['groups' => 'read']),
'currentMotive' => $this->normalizer->normalize($object->getMotive(), $format, ['groups' => ['read', MotiveNormalizer::GROUP_CHILDREN_TO_PARENT]]),
'currentState' => $object->getState()?->value ?? 'open',
'emergency' => $object->getEmergencyStatus()?->value ?? 'no',
'caller' => $this->normalizer->normalize($object->getCaller(), $format, ['groups' => 'read']),

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Ticket;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250924124214 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add parent to motive';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_ticket.motive ADD parent_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE chill_ticket.motive ADD CONSTRAINT FK_DE298BF8727ACA70 FOREIGN KEY (parent_id) REFERENCES chill_ticket.motive (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_DE298BF8727ACA70 ON chill_ticket.motive (parent_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_ticket.motive DROP CONSTRAINT FK_DE298BF8727ACA70');
$this->addSql('DROP INDEX chill_ticket.IDX_DE298BF8727ACA70');
$this->addSql('ALTER TABLE chill_ticket.motive DROP parent_id');
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Tests\Entity;
use Chill\TicketBundle\Entity\Motive;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @covers \Chill\TicketBundle\Entity\Motive::getWithDescendants
*/
final class MotiveTest extends TestCase
{
public function testGetDescendantsOnLeafReturnsSelfOnly(): void
{
$leaf = new Motive();
$leaf->setLabel(['fr' => 'Feuille']);
$collection = $leaf->getDescendants();
self::assertCount(1, $collection);
self::assertSame($leaf, $collection->first());
self::assertContains($leaf, $collection->toArray());
}
public function testGetWithDescendantsReturnsSelfAndAllDescendants(): void
{
$parent = new Motive();
$parent->setLabel(['fr' => 'Parent']);
$childA = new Motive();
$childA->setLabel(['fr' => 'Enfant A']);
$childA->setParent($parent);
$childB = new Motive();
$childB->setLabel(['fr' => 'Enfant B']);
$childB->setParent($parent);
$grandChildA1 = new Motive();
$grandChildA1->setLabel(['fr' => 'Petit-enfant A1']);
$grandChildA1->setParent($childA);
$descendants = $parent->getDescendants();
$asArray = $descendants->toArray();
// It should contain the parent itself, both children and the grand child
self::assertCount(4, $descendants, 'Expected parent + 2 children + 1 grandchild');
self::assertContains($parent, $asArray);
self::assertContains($childA, $asArray);
self::assertContains($childB, $asArray);
self::assertContains($grandChildA1, $asArray);
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Tests\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Serializer\Normalizer\MotiveNormalizer;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* @internal
*
* @covers \Chill\TicketBundle\Serializer\Normalizer\MotiveNormalizer
*/
final class MotiveNormalizerTest extends TestCase
{
public function testNormalizeReadBasic(): void
{
$motive = new Motive();
$motive->setLabel(['fr' => 'Logement', 'en' => 'Housing']);
// active is true by default
$normalizer = new MotiveNormalizer();
$normalizer->setNormalizer($this->buildDummyNormalizer());
$actual = $normalizer->normalize($motive, 'json', ['groups' => 'read']);
self::assertSame('ticket_motive', $actual['type']);
self::assertNull($actual['id']);
self::assertSame(['fr' => 'Logement', 'en' => 'Housing'], $actual['label']);
self::assertTrue($actual['active']);
// no extended fields here
self::assertArrayNotHasKey('makeTicketEmergency', $actual);
self::assertArrayNotHasKey('supplementaryComments', $actual);
self::assertArrayNotHasKey('storedObjects', $actual);
self::assertArrayNotHasKey('children', $actual);
}
public function testNormalizeExtended(): void
{
$motive = new Motive();
$motive->setLabel(['fr' => 'Financier']);
$motive->setMakeTicketEmergency(EmergencyStatusEnum::YES);
$motive->addSupplementaryComment(['label' => 'Justifier le revenu']);
$motive->addStoredObject(new StoredObject('pending'));
$normalizer = new MotiveNormalizer();
$normalizer->setNormalizer($this->buildDummyNormalizer());
$actual = $normalizer->normalize($motive, 'json', ['groups' => ['read', 'read:extended']]);
self::assertSame('ticket_motive', $actual['type']);
self::assertSame(['fr' => 'Financier'], $actual['label']);
self::assertSame(EmergencyStatusEnum::YES, $actual['makeTicketEmergency']);
self::assertSame([
['label' => 'Justifier le revenu'],
], $actual['supplementaryComments']);
self::assertSame([
['stored_object'],
], $actual['storedObjects']);
}
public function testNormalizeParentToChildren(): void
{
$parent = new Motive();
$parent->setLabel(['fr' => 'Parent']);
$child1 = new Motive();
$child1->setLabel(['fr' => 'Enfant 1']);
$child2 = new Motive();
$child2->setLabel(['fr' => 'Enfant 2']);
// build relation
$child1->setParent($parent);
$child2->setParent($parent);
$normalizer = new MotiveNormalizer();
$normalizer->setNormalizer($this->buildDummyNormalizer());
$actual = $normalizer->normalize($parent, 'json', ['groups' => [MotiveNormalizer::GROUP_PARENT_TO_CHILDREN]]);
// children must be normalized by the injected normalizer and parent not exposed
self::assertArrayHasKey('children', $actual);
self::assertSame([
['motive' => 'normalized'],
['motive' => 'normalized'],
], $actual['children']);
self::assertArrayNotHasKey('parent', $actual);
}
public function testNormalizeChildrenToParent(): void
{
$parent = new Motive();
$parent->setLabel(['fr' => 'Parent']);
$child = new Motive();
$child->setLabel(['fr' => 'Enfant']);
$child->setParent($parent);
$normalizer = new MotiveNormalizer();
$normalizer->setNormalizer($this->buildDummyNormalizer());
$actual = $normalizer->normalize($child, 'json', ['groups' => [MotiveNormalizer::GROUP_CHILDREN_TO_PARENT]]);
// parent must be normalized by the injected normalizer and children not exposed
self::assertArrayHasKey('parent', $actual);
self::assertSame(['motive' => 'normalized'], $actual['parent']);
self::assertArrayNotHasKey('children', $actual);
}
public function testSupportsAndSupportedTypes(): void
{
$motive = new Motive();
$normalizer = new MotiveNormalizer();
self::assertTrue($normalizer->supportsNormalization($motive, 'json'));
self::assertFalse($normalizer->supportsNormalization(new \stdClass(), 'json'));
$supported = $normalizer->getSupportedTypes('json');
self::assertArrayHasKey(Motive::class, $supported);
self::assertTrue($supported[Motive::class]);
}
private function buildDummyNormalizer(): NormalizerInterface
{
return new class () implements NormalizerInterface {
public function normalize($object, ?string $format = null, array $context = []): array
{
if ($object instanceof StoredObject) {
return ['stored_object'];
}
if ($object instanceof Motive) {
return ['motive' => 'normalized'];
}
return ['normalized'];
}
public function supportsNormalization($data, ?string $format = null): bool
{
return true;
}
};
}
}