Record that a ticket can be in emergency, or not

This commit is contained in:
Julien Fastré 2025-06-24 10:42:51 +00:00
parent d43b739654
commit 0a331aab37
22 changed files with 896 additions and 5 deletions

View File

@ -22,7 +22,7 @@ Chill is a comprehensive web application built as a set of Symfony bundles. It i
- **Backend**: PHP 8.3+, Symfony 5.4 - **Backend**: PHP 8.3+, Symfony 5.4
- **Frontend**: JavaScript/TypeScript, Vue.js 3, Bootstrap 5 - **Frontend**: JavaScript/TypeScript, Vue.js 3, Bootstrap 5
- **Build Tools**: Webpack Encore, Yarn - **Build Tools**: Webpack Encore, Yarn
- **Database**: PostgreSQL with materialized views - **Database**: PostgreSQL with materialized views. We do not support other databases.
- **Other Services**: Redis, AMQP (RabbitMQ), SMTP - **Other Services**: Redis, AMQP (RabbitMQ), SMTP
## Project Structure ## Project Structure
@ -149,7 +149,35 @@ Key configuration files:
- `package.json`: JavaScript dependencies and scripts - `package.json`: JavaScript dependencies and scripts
- `.env`: Default environment variables. Must usually not be updated: use `.env.local` instead. - `.env`: Default environment variables. Must usually not be updated: use `.env.local` instead.
### Development guidelines ### Database migrations
Each time a doctrine entity is created, we generate migration to adapt the database.
The migration are created using the command `symfony console doctrine:migrations:diff --no-interaction --namespace <namespace>`, where the namespace is the relevant namespace for migration. As this is a bash script, do not forget to quote the `\` (`\` must become `\\` in your command).
Each bundle has his own namespace for migration (always ask me to confirm that command, with a list of updated / created entities so that I can confirm you that it is ok):
- `Chill\Bundle\ActivityBundle` writes migrations to `Chill\Migrations\Activity`;
- `Chill\Bundle\BudgetBundle` writes migrations to `Chill\Migrations\Budget`;
- `Chill\Bundle\CustomFieldsBundle` writes migrations to `Chill\Migrations\CustomFields`;
- `Chill\Bundle\DocGeneratorBundle` writes migrations to `Chill\Migrations\DocGenerator`;
- `Chill\Bundle\DocStoreBundle` writes migrations to `Chill\Migrations\DocStore`;
- `Chill\Bundle\EventBundle` writes migrations to `Chill\Migrations\Event`;
- `Chill\Bundle\CalendarBundle` writes migrations to `Chill\Migrations\Calendar`;
- `Chill\Bundle\FamilyMembersBundle` writes migrations to `Chill\Migrations\FamilyMembers`;
- `Chill\Bundle\FranceTravailApiBundle` writes migrations to `Chill\Migrations\FranceTravailApi`;
- `Chill\Bundle\JobBundle` writes migrations to `Chill\Migrations\Job`;
- `Chill\Bundle\MainBundle` writes migrations to `Chill\Migrations\Main`;
- `Chill\Bundle\PersonBundle` writes migrations to `Chill\Migrations\Person`;
- `Chill\Bundle\ReportBundle` writes migrations to `Chill\Migrations\Report`;
- `Chill\Bundle\TaskBundle` writes migrations to `Chill\Migrations\Task`;
- `Chill\Bundle\ThirdPartyBundle` writes migrations to `Chill\Migrations\ThirdParty`;
- `Chill\Bundle\TicketBundle` writes migrations to `Chill\Migrations\Ticket`;
- `Chill\Bundle\WopiBundle` writes migrations to `Chill\Migrations\Wopi`;
Once created the, comment's classes should be removed and a description of the changes made to the entities should be added to the migrations, using the `getDescription` method. The migration should not be cleaned by any artificial intelligence, as modifying this migration is error prone.
### Guidelines related to code structure and requirements
#### Usage of clock #### Usage of clock

View File

@ -255,3 +255,36 @@ paths:
description: "OK" description: "OK"
401: 401:
description: "UNAUTHORIZED" description: "UNAUTHORIZED"
/1.0/ticket/ticket/{id}/emergency/{emergency}:
post:
tags:
- ticket
summary: Set a ticket as emergency
description: |
Re-open an existing ticket.
If the ticket is already opened, no action will be performed on this ticket: his state will remains unchanged, and the
ticket will be returned.
parameters:
- name: id
in: path
required: true
description: The ticket id
schema:
type: integer
format: integer
minimum: 1
- name: emergency
in: path
required: true
description: the new state of emergency
schema:
type: string
enum:
- yes
- no
responses:
200:
description: "OK"
401:
description: "UNAUTHORIZED"

View File

@ -0,0 +1,24 @@
<?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\Action\Ticket;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
/**
* Command to change the emergency status of a ticket.
*/
final readonly class ChangeEmergencyStateCommand
{
public function __construct(
public EmergencyStatusEnum $newEmergencyStatus,
) {}
}

View File

@ -0,0 +1,49 @@
<?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\Action\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand;
use Chill\TicketBundle\Entity\EmergencyStatusHistory;
use Chill\TicketBundle\Entity\Ticket;
use Symfony\Component\Clock\ClockInterface;
/**
* Handler for changing the emergency status of a ticket.
*/
class ChangeEmergencyStateCommandHandler
{
public function __construct(private readonly ClockInterface $clock) {}
public function __invoke(Ticket $ticket, ChangeEmergencyStateCommand $command): Ticket
{
// If the ticket is already in the requested emergency status, return it without changes
if ($command->newEmergencyStatus === $ticket->getEmergencyStatus()) {
return $ticket;
}
// End the current emergency status history (if any)
foreach ($ticket->getEmergencyStatusHistories() as $emergencyStatusHistory) {
if (null === $emergencyStatusHistory->getEndDate()) {
$emergencyStatusHistory->setEndDate($this->clock->now());
}
}
// Create a new emergency status history with the new status
new EmergencyStatusHistory(
$command->newEmergencyStatus,
$ticket,
$this->clock->now(),
);
return $ticket;
}
}

View File

@ -12,6 +12,8 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Action\Ticket\Handler; namespace Chill\TicketBundle\Action\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\CreateTicketCommand; use Chill\TicketBundle\Action\Ticket\CreateTicketCommand;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\EmergencyStatusHistory;
use Chill\TicketBundle\Entity\StateEnum; use Chill\TicketBundle\Entity\StateEnum;
use Chill\TicketBundle\Entity\StateHistory; use Chill\TicketBundle\Entity\StateHistory;
use Chill\TicketBundle\Entity\Ticket; use Chill\TicketBundle\Entity\Ticket;
@ -26,7 +28,9 @@ class CreateTicketCommandHandler
$ticket = new Ticket(); $ticket = new Ticket();
$ticket->setExternalRef($command->externalReference); $ticket->setExternalRef($command->externalReference);
// initialize the first states
new StateHistory(StateEnum::OPEN, $ticket, $this->clock->now()); new StateHistory(StateEnum::OPEN, $ticket, $this->clock->now());
new EmergencyStatusHistory(EmergencyStatusEnum::NO, $ticket, $this->clock->now());
return $ticket; return $ticket;
} }

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Action\Ticket\Handler; namespace Chill\TicketBundle\Action\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand;
use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand; use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand;
use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\Ticket; use Chill\TicketBundle\Entity\Ticket;
@ -22,6 +23,7 @@ final readonly class ReplaceMotiveCommandHandler
public function __construct( public function __construct(
private ClockInterface $clock, private ClockInterface $clock,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler,
) {} ) {}
public function handle(Ticket $ticket, ReplaceMotiveCommand $command): void public function handle(Ticket $ticket, ReplaceMotiveCommand $command): void
@ -50,6 +52,12 @@ final readonly class ReplaceMotiveCommandHandler
if ($readyToAdd) { if ($readyToAdd) {
$history = new MotiveHistory($command->motive, $ticket, $this->clock->now()); $history = new MotiveHistory($command->motive, $ticket, $this->clock->now());
$this->entityManager->persist($history); $this->entityManager->persist($history);
// Check if the motive has makeTicketEmergency set and update the ticket's emergency status if needed
if ($command->motive->isMakeTicketEmergency()) {
$changeEmergencyCommand = new ChangeEmergencyStateCommand($command->motive->getMakeTicketEmergency());
($this->changeEmergencyStateCommandHandler)($ticket, $changeEmergencyCommand);
}
} }
} }
} }

View File

@ -0,0 +1,73 @@
<?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\Controller;
use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand;
use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Controller for changing the emergency status of a ticket.
*/
final readonly class ChangeEmergencyStateApiController
{
public function __construct(
private ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler,
private Security $security,
private EntityManagerInterface $entityManager,
private SerializerInterface $serializer,
) {}
#[Route('/api/1.0/ticket/ticket/{id}/emergency/yes', name: 'chill_ticket_ticket_emergency_yes_api', requirements: ['id' => '\d+'], methods: ['POST'])]
public function setEmergencyYes(Ticket $ticket): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException('Only users are allowed to set emergency status to YES.');
}
$command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::YES);
$this->changeEmergencyStateCommandHandler->__invoke($ticket, $command);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($ticket, 'json', ['groups' => ['read']]),
json: true
);
}
#[Route('/api/1.0/ticket/ticket/{id}/emergency/no', name: 'chill_ticket_ticket_emergency_no_api', requirements: ['id' => '\d+'], methods: ['POST'])]
public function setEmergencyNo(Ticket $ticket): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException('Only users are allowed to set emergency status to NO.');
}
$command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::NO);
$this->changeEmergencyStateCommandHandler->__invoke($ticket, $command);
$this->entityManager->flush();
return new JsonResponse(
$this->serializer->serialize($ticket, 'json', ['groups' => ['read']]),
json: true
);
}
}

View File

@ -0,0 +1,21 @@
<?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\Entity;
/**
* Represents the emergency status of a ticket (yes or no).
*/
enum EmergencyStatusEnum: string
{
case YES = 'yes';
case NO = 'no';
}

View File

@ -0,0 +1,85 @@
<?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\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
/**
* Represents the history of an emergency status associated with a ticket.
*
* This entity is used to track the changes in a ticket's emergency status over time.
* Implements the TrackCreationInterface for tracking entity lifecycle creation.
*/
#[ORM\Entity]
#[ORM\Table(name: 'emergency_status_history', schema: 'chill_ticket')]
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_emergency_status_history' => EmergencyStatusHistory::class])]
class EmergencyStatusHistory implements TrackCreationInterface
{
use TrackCreationTrait;
#[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::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])]
#[Serializer\Groups(['read'])]
private ?\DateTimeImmutable $endDate = null;
public function __construct(
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: false, enumType: EmergencyStatusEnum::class)]
#[Serializer\Groups(['read'])]
private EmergencyStatusEnum $emergencyStatus,
#[ORM\ManyToOne(targetEntity: Ticket::class)]
#[ORM\JoinColumn(nullable: false)]
private Ticket $ticket,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)]
#[Serializer\Groups(['read'])]
private \DateTimeImmutable $startDate = new \DateTimeImmutable('now'),
) {
$ticket->addEmergencyStatusHistory($this);
}
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
public function getId(): ?int
{
return $this->id;
}
public function getEmergencyStatus(): EmergencyStatusEnum
{
return $this->emergencyStatus;
}
public function getStartDate(): \DateTimeImmutable
{
return $this->startDate;
}
public function getTicket(): Ticket
{
return $this->ticket;
}
public function setEndDate(?\DateTimeImmutable $endDate): void
{
$this->endDate = $endDate;
}
}

View File

@ -33,6 +33,10 @@ class Motive
#[Serializer\Groups(['read'])] #[Serializer\Groups(['read'])]
private bool $active = true; private bool $active = true;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: true, enumType: EmergencyStatusEnum::class)]
#[Serializer\Groups(['read'])]
private ?EmergencyStatusEnum $makeTicketEmergency = null;
public function isActive(): bool public function isActive(): bool
{ {
return $this->active; return $this->active;
@ -57,4 +61,19 @@ class Motive
{ {
$this->label = $label; $this->label = $label;
} }
public function getMakeTicketEmergency(): ?EmergencyStatusEnum
{
return $this->makeTicketEmergency;
}
public function setMakeTicketEmergency(?EmergencyStatusEnum $makeTicketEmergency): void
{
$this->makeTicketEmergency = $makeTicketEmergency;
}
public function isMakeTicketEmergency(): bool
{
return null !== $this->makeTicketEmergency;
}
} }

View File

@ -90,6 +90,12 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface
#[ORM\OneToMany(targetEntity: StateHistory::class, mappedBy: 'ticket', cascade: ['persist', 'remove'])] #[ORM\OneToMany(targetEntity: StateHistory::class, mappedBy: 'ticket', cascade: ['persist', 'remove'])]
private Collection $stateHistories; private Collection $stateHistories;
/**
* @var Collection<int, EmergencyStatusHistory>
*/
#[ORM\OneToMany(targetEntity: EmergencyStatusHistory::class, mappedBy: 'ticket', cascade: ['persist', 'remove'])]
private Collection $emergencyStatusHistories;
public function __construct() public function __construct()
{ {
$this->addresseeHistory = new ArrayCollection(); $this->addresseeHistory = new ArrayCollection();
@ -98,6 +104,7 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface
$this->personHistories = new ArrayCollection(); $this->personHistories = new ArrayCollection();
$this->inputHistories = new ArrayCollection(); $this->inputHistories = new ArrayCollection();
$this->stateHistories = new ArrayCollection(); $this->stateHistories = new ArrayCollection();
$this->emergencyStatusHistories = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@ -260,4 +267,31 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface
{ {
return $this->stateHistories; return $this->stateHistories;
} }
/**
* @internal use @see{EmergencyStatusHistory::__construct} instead
*/
public function addEmergencyStatusHistory(EmergencyStatusHistory $emergencyStatusHistory): void
{
$this->emergencyStatusHistories->add($emergencyStatusHistory);
}
public function getEmergencyStatus(): ?EmergencyStatusEnum
{
foreach ($this->emergencyStatusHistories as $emergencyStatusHistory) {
if (null === $emergencyStatusHistory->getEndDate()) {
return $emergencyStatusHistory->getEmergencyStatus();
}
}
return null;
}
/**
* @return ReadableCollection<int, EmergencyStatusHistory>
*/
public function getEmergencyStatusHistories(): ReadableCollection
{
return $this->emergencyStatusHistories;
}
} }

View File

@ -15,6 +15,8 @@ export interface Motive {
export type TicketState = "open"|"closed"; export type TicketState = "open"|"closed";
export type TicketEmergencyState = "yes"|"no";
interface TicketHistory<T extends string, D extends object> { interface TicketHistory<T extends string, D extends object> {
event_type: T; event_type: T;
at: DateTime; at: DateTime;
@ -116,6 +118,7 @@ export interface Ticket {
createdAt: DateTime | null; createdAt: DateTime | null;
updatedBy: User | null; updatedBy: User | null;
currentState: TicketState | null; currentState: TicketState | null;
emergency: TicketEmergencyState | null;
} }
export interface addNewPersons { export interface addNewPersons {

View File

@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\UserGroup;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\TicketBundle\Entity\AddresseeHistory; use Chill\TicketBundle\Entity\AddresseeHistory;
use Chill\TicketBundle\Entity\Comment; use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Entity\EmergencyStatusHistory;
use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\PersonHistory; use Chill\TicketBundle\Entity\PersonHistory;
use Chill\TicketBundle\Entity\StateHistory; use Chill\TicketBundle\Entity\StateHistory;
@ -50,7 +51,8 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
'updatedAt' => $this->normalizer->normalize($object->getUpdatedAt(), $format, $context), 'updatedAt' => $this->normalizer->normalize($object->getUpdatedAt(), $format, $context),
'updatedBy' => $this->normalizer->normalize($object->getUpdatedBy(), $format, $context), 'updatedBy' => $this->normalizer->normalize($object->getUpdatedBy(), $format, $context),
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context), 'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
'currentState' => $object->getState()?->value, 'currentState' => $object->getState()?->value ?? 'open',
'emergency' => $object->getEmergencyStatus()?->value ?? 'no',
]; ];
} }
@ -102,6 +104,17 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
), ),
...$this->addresseesStates($ticket), ...$this->addresseesStates($ticket),
...$this->personStates($ticket), ...$this->personStates($ticket),
...array_map(
fn (EmergencyStatusHistory $stateHistory) => [
'event_type' => 'emergency_change',
'at' => $stateHistory->getStartDate(),
'by' => $stateHistory->getCreatedBy(),
'data' => [
'new_emergency' => $stateHistory->getEmergencyStatus()->value,
],
],
$ticket->getEmergencyStatusHistories()->toArray(),
),
]; ];
if (null !== $ticket->getCreatedBy() && null !== $ticket->getCreatedAt()) { if (null !== $ticket->getCreatedBy() && null !== $ticket->getCreatedAt()) {

View File

@ -0,0 +1,82 @@
<?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 Version20250620145414 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add emergency status to ticket';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE SEQUENCE chill_ticket.emergency_status_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE chill_ticket.emergency_status_history (
id INT NOT NULL,
ticket_id INT NOT NULL,
endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
emergencyStatus VARCHAR(255) NOT NULL,
startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_16CF4FDB700047D2 ON chill_ticket.emergency_status_history (ticket_id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_16CF4FDB3174800F ON chill_ticket.emergency_status_history (createdBy_id)
SQL);
$this->addSql(<<<'SQL'
COMMENT ON COLUMN chill_ticket.emergency_status_history.endDate IS '(DC2Type:datetime_immutable)'
SQL);
$this->addSql(<<<'SQL'
COMMENT ON COLUMN chill_ticket.emergency_status_history.startDate IS '(DC2Type:datetime_immutable)'
SQL);
$this->addSql(<<<'SQL'
COMMENT ON COLUMN chill_ticket.emergency_status_history.createdAt IS '(DC2Type:datetime_immutable)'
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE chill_ticket.emergency_status_history ADD CONSTRAINT FK_16CF4FDB700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE chill_ticket.emergency_status_history ADD CONSTRAINT FK_16CF4FDB3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE chill_ticket.emergency_status_history ADD CONSTRAINT ticket_emergency_state_not_overlaps
exclude using gist (ticket_id with =, tsrange(startdate, enddate) with &&)
deferrable initially deferred
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE chill_ticket.emergency_status_history DROP CONSTRAINT FK_16CF4FDB700047D2
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE chill_ticket.emergency_status_history DROP CONSTRAINT FK_16CF4FDB3174800F
SQL);
$this->addSql(<<<'SQL'
DROP TABLE chill_ticket.emergency_status_history
SQL);
$this->addSql(<<<'SQL'
DROP SEQUENCE chill_ticket.emergency_status_history_id_seq
SQL);
}
}

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 Version20250620164517 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add makeTicketEmergency field to Motive entity to allow automatic emergency status changes when a motive is associated with a ticket';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE chill_ticket.motive ADD makeTicketEmergency VARCHAR(255) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE chill_ticket.motive DROP makeTicketEmergency
SQL);
}
}

View File

@ -0,0 +1,118 @@
<?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\Action\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand;
use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\EmergencyStatusHistory;
use Chill\TicketBundle\Entity\Ticket;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\MockClock;
/**
* @internal
*
* @coversNothing
*/
final class ChangeEmergencyStateCommandHandlerTest extends TestCase
{
use ProphecyTrait;
public function testInvokeWithAlreadyYesEmergencyStatus(): void
{
$ticket = new Ticket();
// Create a YES emergency status history
new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket);
$handler = new ChangeEmergencyStateCommandHandler(new MockClock());
$command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::YES);
$result = $handler->__invoke($ticket, $command);
// Assert that the ticket is returned unchanged
$this->assertSame($ticket, $result);
$this->assertSame(EmergencyStatusEnum::YES, $ticket->getEmergencyStatus());
// Assert that no new emergency status history was created
$emergencyStatusHistories = $ticket->getEmergencyStatusHistories();
$this->assertCount(1, $emergencyStatusHistories);
}
public function testInvokeWithYesEmergencyStatusToNo(): void
{
$ticket = new Ticket();
// Create a YES emergency status history
new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket);
$handler = new ChangeEmergencyStateCommandHandler(new MockClock());
$command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::NO);
$result = $handler->__invoke($ticket, $command);
// Assert that the ticket is returned
$this->assertSame($ticket, $result);
// Assert that the ticket emergency status is now NO
$this->assertSame(EmergencyStatusEnum::NO, $ticket->getEmergencyStatus());
// Assert that the old emergency status history was ended and a new one was created
$emergencyStatusHistories = $ticket->getEmergencyStatusHistories();
$this->assertCount(2, $emergencyStatusHistories);
// The first emergency status history should be ended
$yesEmergencyStatusHistory = $emergencyStatusHistories->first();
$this->assertNotNull($yesEmergencyStatusHistory->getEndDate());
$this->assertSame(EmergencyStatusEnum::YES, $yesEmergencyStatusHistory->getEmergencyStatus());
// The last emergency status history should be NO and active
$noEmergencyStatusHistory = $emergencyStatusHistories->last();
$this->assertNull($noEmergencyStatusHistory->getEndDate());
$this->assertSame(EmergencyStatusEnum::NO, $noEmergencyStatusHistory->getEmergencyStatus());
}
public function testInvokeWithNoEmergencyStatusToYes(): void
{
$ticket = new Ticket();
// Create a NO emergency status history
new EmergencyStatusHistory(EmergencyStatusEnum::NO, $ticket);
$handler = new ChangeEmergencyStateCommandHandler(new MockClock());
$command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::YES);
$result = $handler->__invoke($ticket, $command);
// Assert that the ticket is returned
$this->assertSame($ticket, $result);
// Assert that the ticket emergency status is now YES
$this->assertSame(EmergencyStatusEnum::YES, $ticket->getEmergencyStatus());
// Assert that the old emergency status history was ended and a new one was created
$emergencyStatusHistories = $ticket->getEmergencyStatusHistories();
$this->assertCount(2, $emergencyStatusHistories);
// The first emergency status history should be ended
$noEmergencyStatusHistory = $emergencyStatusHistories->first();
$this->assertNotNull($noEmergencyStatusHistory->getEndDate());
$this->assertSame(EmergencyStatusEnum::NO, $noEmergencyStatusHistory->getEmergencyStatus());
// The last emergency status history should be YES and active
$yesEmergencyStatusHistory = $emergencyStatusHistories->last();
$this->assertNull($yesEmergencyStatusHistory->getEndDate());
$this->assertSame(EmergencyStatusEnum::YES, $yesEmergencyStatusHistory->getEmergencyStatus());
}
}

View File

@ -13,6 +13,7 @@ namespace Chill\TicketBundle\Tests\Action\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\CreateTicketCommand; use Chill\TicketBundle\Action\Ticket\CreateTicketCommand;
use Chill\TicketBundle\Action\Ticket\Handler\CreateTicketCommandHandler; use Chill\TicketBundle\Action\Ticket\Handler\CreateTicketCommandHandler;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\StateEnum; use Chill\TicketBundle\Entity\StateEnum;
use Chill\TicketBundle\Entity\Ticket; use Chill\TicketBundle\Entity\Ticket;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -38,6 +39,7 @@ class CreateTicketCommandHandlerTest extends TestCase
self::assertInstanceOf(Ticket::class, $actual); self::assertInstanceOf(Ticket::class, $actual);
self::assertEquals('', $actual->getExternalRef()); self::assertEquals('', $actual->getExternalRef());
self::assertEquals(StateEnum::OPEN, $actual->getState()); self::assertEquals(StateEnum::OPEN, $actual->getState());
self::assertEquals(EmergencyStatusEnum::NO, $actual->getEmergencyStatus());
} }
public function testHandleWithReference(): void public function testHandleWithReference(): void
@ -48,5 +50,6 @@ class CreateTicketCommandHandlerTest extends TestCase
self::assertInstanceOf(Ticket::class, $actual); self::assertInstanceOf(Ticket::class, $actual);
self::assertEquals($ref, $actual->getExternalRef()); self::assertEquals($ref, $actual->getExternalRef());
self::assertEquals(StateEnum::OPEN, $actual->getState()); self::assertEquals(StateEnum::OPEN, $actual->getState());
self::assertEquals(EmergencyStatusEnum::NO, $actual->getEmergencyStatus());
} }
} }

View File

@ -11,8 +11,11 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Tests\Action\Ticket\Handler; namespace Chill\TicketBundle\Tests\Action\Ticket\Handler;
use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand;
use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler;
use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler; use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler;
use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand; use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\Ticket; use Chill\TicketBundle\Entity\Ticket;
@ -33,10 +36,15 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase
private function buildHandler( private function buildHandler(
EntityManagerInterface $entityManager, EntityManagerInterface $entityManager,
?ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler = null,
): ReplaceMotiveCommandHandler { ): ReplaceMotiveCommandHandler {
$clock = new MockClock(); $clock = new MockClock();
return new ReplaceMotiveCommandHandler($clock, $entityManager); if (null === $changeEmergencyStateCommandHandler) {
$changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class)->reveal();
}
return new ReplaceMotiveCommandHandler($clock, $entityManager, $changeEmergencyStateCommandHandler);
} }
public function testHandleOnTicketWithoutMotive(): void public function testHandleOnTicketWithoutMotive(): void
@ -105,4 +113,69 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase
self::assertSame($motive, $ticket->getMotive()); self::assertSame($motive, $ticket->getMotive());
self::assertCount(1, $ticket->getMotiveHistories()); self::assertCount(1, $ticket->getMotiveHistories());
} }
public function testHandleUpdatesEmergencyStatusWhenMotiveHasMakeTicketEmergency(): void
{
// Create a motive with makeTicketEmergency set to YES
$motive = new Motive();
$motive->setMakeTicketEmergency(EmergencyStatusEnum::YES);
// Create a ticket with no emergency status
$ticket = new Ticket();
// Mock the entity manager
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::type(MotiveHistory::class))->shouldBeCalled();
// Mock the ChangeEmergencyStateCommandHandler
$changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class);
$changeEmergencyStateCommandHandler->__invoke(
$ticket,
Argument::that(fn (ChangeEmergencyStateCommand $command) => EmergencyStatusEnum::YES === $command->newEmergencyStatus)
)->shouldBeCalled();
// Create the handler with our mocks
$handler = $this->buildHandler(
$entityManager->reveal(),
$changeEmergencyStateCommandHandler->reveal()
);
// Handle the command
$handler->handle($ticket, new ReplaceMotiveCommand($motive));
// Assert that the motive was set on the ticket
self::assertSame($motive, $ticket->getMotive());
}
public function testHandleDoesNotUpdateEmergencyStatusWhenMotiveHasNoMakeTicketEmergency(): void
{
// Create a motive with makeTicketEmergency set to null
$motive = new Motive();
$motive->setMakeTicketEmergency(null);
// Create a ticket with no emergency status
$ticket = new Ticket();
// Mock the entity manager
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::type(MotiveHistory::class))->shouldBeCalled();
// Mock the ChangeEmergencyStateCommandHandler - it should NOT be called
$changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class);
$changeEmergencyStateCommandHandler->__invoke(
Argument::cetera()
)->shouldNotBeCalled();
// Create the handler with our mocks
$handler = $this->buildHandler(
$entityManager->reveal(),
$changeEmergencyStateCommandHandler->reveal()
);
// Handle the command
$handler->handle($ticket, new ReplaceMotiveCommand($motive));
// Assert that the motive was set on the ticket
self::assertSame($motive, $ticket->getMotive());
}
} }

View File

@ -0,0 +1,144 @@
<?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\Controller;
use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand;
use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler;
use Chill\TicketBundle\Controller\ChangeEmergencyStateApiController;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @internal
*
* @coversNothing
*/
final class ChangeEmergencyStateApiControllerTest extends TestCase
{
use ProphecyTrait;
public function testSetEmergencyYesWithoutPermission(): void
{
$ticket = new Ticket();
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(false);
$changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class);
$entityManager = $this->prophesize(EntityManagerInterface::class);
$serializer = $this->prophesize(SerializerInterface::class);
$controller = new ChangeEmergencyStateApiController(
$changeEmergencyStateCommandHandler->reveal(),
$security->reveal(),
$entityManager->reveal(),
$serializer->reveal(),
);
$this->expectException(AccessDeniedHttpException::class);
$controller->setEmergencyYes($ticket);
}
public function testSetEmergencyYesWithPermission(): void
{
$ticket = new Ticket();
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(true);
$changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class);
$changeEmergencyStateCommandHandler->__invoke(
$ticket,
Argument::that(fn (ChangeEmergencyStateCommand $command) => EmergencyStatusEnum::YES === $command->newEmergencyStatus)
)->willReturn($ticket)->shouldBeCalled();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->flush()->shouldBeCalled();
$serializer = $this->prophesize(SerializerInterface::class);
$serializer->serialize($ticket, 'json', ['groups' => ['read']])
->willReturn('{}')
->shouldBeCalled();
$controller = new ChangeEmergencyStateApiController(
$changeEmergencyStateCommandHandler->reveal(),
$security->reveal(),
$entityManager->reveal(),
$serializer->reveal(),
);
$response = $controller->setEmergencyYes($ticket);
$this->assertInstanceOf(JsonResponse::class, $response);
}
public function testSetEmergencyNoWithoutPermission(): void
{
$ticket = new Ticket();
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(false);
$changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class);
$entityManager = $this->prophesize(EntityManagerInterface::class);
$serializer = $this->prophesize(SerializerInterface::class);
$controller = new ChangeEmergencyStateApiController(
$changeEmergencyStateCommandHandler->reveal(),
$security->reveal(),
$entityManager->reveal(),
$serializer->reveal(),
);
$this->expectException(AccessDeniedHttpException::class);
$controller->setEmergencyNo($ticket);
}
public function testSetEmergencyNoWithPermission(): void
{
$ticket = new Ticket();
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(true);
$changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class);
$changeEmergencyStateCommandHandler->__invoke(
$ticket,
Argument::that(fn (ChangeEmergencyStateCommand $command) => EmergencyStatusEnum::NO === $command->newEmergencyStatus)
)->willReturn($ticket)->shouldBeCalled();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->flush()->shouldBeCalled();
$serializer = $this->prophesize(SerializerInterface::class);
$serializer->serialize($ticket, 'json', ['groups' => ['read']])
->willReturn('{}')
->shouldBeCalled();
$controller = new ChangeEmergencyStateApiController(
$changeEmergencyStateCommandHandler->reveal(),
$security->reveal(),
$entityManager->reveal(),
$serializer->reveal(),
);
$response = $controller->setEmergencyNo($ticket);
$this->assertInstanceOf(JsonResponse::class, $response);
}
}

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Tests\Controller; namespace Chill\TicketBundle\Tests\Controller;
use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler;
use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler; use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler;
use Chill\TicketBundle\Controller\ReplaceMotiveController; use Chill\TicketBundle\Controller\ReplaceMotiveController;
use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\Motive;
@ -97,9 +98,12 @@ class ReplaceMotiveControllerTest extends KernelTestCase
$entityManager->persist(Argument::type(MotiveHistory::class))->shouldBeCalled(); $entityManager->persist(Argument::type(MotiveHistory::class))->shouldBeCalled();
$entityManager->flush()->shouldBeCalled(); $entityManager->flush()->shouldBeCalled();
$changeEmergencyCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class);
$handler = new ReplaceMotiveCommandHandler( $handler = new ReplaceMotiveCommandHandler(
new MockClock(), new MockClock(),
$entityManager->reveal() $entityManager->reveal(),
$changeEmergencyCommandHandler->reveal(),
); );
return new ReplaceMotiveController( return new ReplaceMotiveController(

View File

@ -18,6 +18,8 @@ use Chill\TicketBundle\Entity\AddresseeHistory;
use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\PersonHistory; use Chill\TicketBundle\Entity\PersonHistory;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\EmergencyStatusHistory;
use Chill\TicketBundle\Entity\StateEnum; use Chill\TicketBundle\Entity\StateEnum;
use Chill\TicketBundle\Entity\StateHistory; use Chill\TicketBundle\Entity\StateHistory;
use Chill\TicketBundle\Entity\Ticket; use Chill\TicketBundle\Entity\Ticket;
@ -133,4 +135,27 @@ class TicketTest extends KernelTestCase
self::assertCount(2, $ticket->getStateHistories()); self::assertCount(2, $ticket->getStateHistories());
self::assertSame(StateEnum::CLOSED, $ticket->getState()); self::assertSame(StateEnum::CLOSED, $ticket->getState());
} }
public function testGetEmergencyStatus(): void
{
$ticket = new Ticket();
// Initially, the ticket has no emergency status
self::assertNull($ticket->getEmergencyStatus());
// Create an emergency status history entry with the YES status
$history = new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket);
// Verify that the ticket now has the YES status
self::assertSame(EmergencyStatusEnum::YES, $ticket->getEmergencyStatus());
self::assertCount(1, $ticket->getEmergencyStatusHistories());
// Change the emergency status to NO
$history->setEndDate(new \DateTimeImmutable());
$history2 = new EmergencyStatusHistory(EmergencyStatusEnum::NO, $ticket);
// Verify that the ticket now has the NO status
self::assertCount(2, $ticket->getEmergencyStatusHistories());
self::assertSame(EmergencyStatusEnum::NO, $ticket->getEmergencyStatus());
}
} }

View File

@ -16,6 +16,8 @@ use Chill\MainBundle\Entity\UserGroup;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\TicketBundle\Entity\AddresseeHistory; use Chill\TicketBundle\Entity\AddresseeHistory;
use Chill\TicketBundle\Entity\Comment; use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\EmergencyStatusHistory;
use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\PersonHistory; use Chill\TicketBundle\Entity\PersonHistory;
@ -109,6 +111,7 @@ class TicketNormalizerTest extends KernelTestCase
'currentState' => 'open', 'currentState' => 'open',
'updatedAt' => $t->getUpdatedAt()->getTimestamp(), 'updatedAt' => $t->getUpdatedAt()->getTimestamp(),
'updatedBy' => ['user'], 'updatedBy' => ['user'],
'emergency' => 'no',
], ],
]; ];
@ -117,6 +120,7 @@ class TicketNormalizerTest extends KernelTestCase
// added by action // added by action
new StateHistory(StateEnum::OPEN, $ticket, new \DateTimeImmutable('2024-06-16T00:00:00Z')); new StateHistory(StateEnum::OPEN, $ticket, new \DateTimeImmutable('2024-06-16T00:00:00Z'));
new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket, new \DateTimeImmutable('2024-06-16T00:00:10Z'));
// those are added by doctrine listeners // those are added by doctrine listeners
$ticket->setCreatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); $ticket->setCreatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z'));
@ -155,10 +159,12 @@ class TicketNormalizerTest extends KernelTestCase
['event_type' => 'addressees_state'], ['event_type' => 'addressees_state'],
['event_type' => 'create_ticket'], ['event_type' => 'create_ticket'],
['event_type' => 'state_change'], ['event_type' => 'state_change'],
['event_type' => 'emergency_change'],
], ],
'currentState' => 'open', 'currentState' => 'open',
'updatedAt' => $ticket->getUpdatedAt()->getTimestamp(), 'updatedAt' => $ticket->getUpdatedAt()->getTimestamp(),
'updatedBy' => ['user'], 'updatedBy' => ['user'],
'emergency' => 'yes',
], ],
]; ];
} }
@ -213,6 +219,11 @@ class TicketNormalizerTest extends KernelTestCase
'json', 'json',
['groups' => 'read'] ['groups' => 'read']
)->will(fn ($args): array => $args[0]); )->will(fn ($args): array => $args[0]);
$normalizer->normalize(
Argument::that(fn ($arg) => is_array($arg) && 1 === count($arg) && array_key_exists('new_emergency', $arg)),
'json',
['groups' => 'read']
)->will(fn ($args): array => $args[0]);
// datetime // datetime
$normalizer->normalize(Argument::type(\DateTimeImmutable::class), 'json', Argument::type('array')) $normalizer->normalize(Argument::type(\DateTimeImmutable::class), 'json', Argument::type('array'))