Implement functionality to replace ticket's motive

The commit introduces several features related to ticket motive management in the Chill-TicketBundle:
- Adds capability to replace a ticket's motive with a new one.
- Provides ticket motive history management features.
- Implements relevant changes in Controller, Action Handler, and Entity levels.
- Incorporates new API endpoints and updates the API specification file for the new feature.
- Includes tests to ensure the new functionality works as expected.
This commit is contained in:
Julien Fastré 2024-04-17 21:41:30 +02:00
parent a9760b323f
commit 670b8eb82b
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
8 changed files with 467 additions and 0 deletions

View File

@ -13,6 +13,18 @@ components:
fr: Retard de livraison
active:
type: boolean
MotiveById:
type: object
properties:
id:
type: integer
type:
type: string
enum:
- ticket_motive
required:
- id
- type
paths:
/1.0/ticket/motive.json:
@ -23,3 +35,32 @@ paths:
responses:
200:
description: "OK"
/1.0/ticket/{id}/motive/set:
post:
tags:
- ticket
summary: Replace the existing ticket's motive by a new one
parameters:
- name: id
in: path
required: true
description: The ticket id
schema:
type: integer
format: integer
minimum: 1
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
motive:
$ref: "#/components/schemas/MotiveById"
responses:
201:
description: "ACCEPTED"
422:
description: "UNPROCESSABLE ENTITY"

View File

@ -0,0 +1,56 @@
<?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\ReplaceMotiveCommand;
use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Clock\ClockInterface;
final readonly class ReplaceMotiveCommandHandler
{
public function __construct(
private ClockInterface $clock,
private EntityManagerInterface $entityManager,
) {}
public function handle(Ticket $ticket, ReplaceMotiveCommand $command): void
{
if (null === $command->motive) {
throw new \InvalidArgumentException('The new motive cannot be null');
}
// will add if there are no existing motive
$readyToAdd = 0 === count($ticket->getMotiveHistories());
foreach ($ticket->getMotiveHistories() as $history) {
if (null !== $history->getEndDate()) {
continue;
}
if ($history->getMotive() === $command->motive) {
// we apply the same motive, we do nothing
continue;
}
$history->setEndDate($this->clock->now());
$readyToAdd = true;
}
if ($readyToAdd) {
$history = new MotiveHistory($command->motive, $ticket, $this->clock->now());
$ticket->addMotiveHistory($history);
$this->entityManager->persist($history);
}
}
}

View File

@ -0,0 +1,25 @@
<?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\Motive;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class ReplaceMotiveCommand
{
public function __construct(
#[Assert\NotNull]
#[Groups(['write'])]
public ?Motive $motive,
) {}
}

View File

@ -0,0 +1,64 @@
<?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\Handler\ReplaceMotiveCommandHandler;
use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
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\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final readonly class ReplaceMotiveController
{
public function __construct(
private Security $security,
private ReplaceMotiveCommandHandler $replaceMotiveCommandHandler,
private SerializerInterface $serializer,
private ValidatorInterface $validator,
private EntityManagerInterface $entityManager,
) {}
#[Route('/api/1.0/ticket/{id}/motive/set', name: 'chill_ticket_motive_set', methods: ['POST'])]
public function __invoke(Ticket $ticket, Request $request): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException('');
}
$command = $this->serializer->deserialize($request->getContent(), ReplaceMotiveCommand::class, 'json', [
AbstractNormalizer::GROUPS => ['write'],
]);
$errors = $this->validator->validate($command);
if (0 < $errors->count()) {
return new JsonResponse(
$this->serializer->serialize($errors, 'json'),
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
$this->replaceMotiveCommandHandler->handle($ticket, $command);
$this->entityManager->flush();
return new JsonResponse(null, Response::HTTP_CREATED);
}
}

View File

@ -114,6 +114,11 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface
$this->personHistories->add($personHistory);
}
public function addMotiveHistory(MotiveHistory $motiveHistory): void
{
$this->motiveHistories->add($motiveHistory);
}
/**
* @return list<UserGroup|User>
*/
@ -163,4 +168,12 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface
return null;
}
/**
* @return ReadableCollection<int, MotiveHistory>
*/
public function getMotiveHistories(): ReadableCollection
{
return $this->motiveHistories;
}
}

View File

@ -0,0 +1,108 @@
<?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\Handler\ReplaceMotiveCommandHandler;
use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand;
use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
/**
* @internal
*
* @coversNothing
*/
final class ReplaceMotiveCommandHandlerTest extends KernelTestCase
{
use ProphecyTrait;
private function buildHandler(
EntityManagerInterface $entityManager,
): ReplaceMotiveCommandHandler {
$clock = new MockClock();
return new ReplaceMotiveCommandHandler($clock, $entityManager);
}
public function testHandleOnTicketWithoutMotive(): void
{
$motive = new Motive();
$ticket = new Ticket();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(static function ($arg) use ($motive): bool {
if (!$arg instanceof MotiveHistory) {
return false;
}
return $arg->getMotive() === $motive;
}))->shouldBeCalled();
$handler = $this->buildHandler($entityManager->reveal());
$handler->handle($ticket, new ReplaceMotiveCommand($motive));
self::assertSame($motive, $ticket->getMotive());
}
public function testHandleReplaceMotiveOnTicketWithExistingMotive(): void
{
$motive = new Motive();
$ticket = new Ticket();
$ticket->addMotiveHistory(new MotiveHistory(new Motive(), $ticket));
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(static function ($arg) use ($motive): bool {
if (!$arg instanceof MotiveHistory) {
return false;
}
return $arg->getMotive() === $motive;
}))->shouldBeCalled();
$handler = $this->buildHandler($entityManager->reveal());
$handler->handle($ticket, new ReplaceMotiveCommand($motive));
self::assertSame($motive, $ticket->getMotive());
self::assertCount(2, $ticket->getMotiveHistories());
}
public function testHandleReplaceMotiveOnTicketWithSameMotive(): void
{
$motive = new Motive();
$ticket = new Ticket();
$ticket->addMotiveHistory(new MotiveHistory($motive, $ticket));
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(static function ($arg) use ($motive): bool {
if (!$arg instanceof MotiveHistory) {
return false;
}
return $arg->getMotive() === $motive;
}))->shouldNotBeCalled();
$handler = $this->buildHandler($entityManager->reveal());
$handler->handle($ticket, new ReplaceMotiveCommand($motive));
self::assertSame($motive, $ticket->getMotive());
self::assertCount(1, $ticket->getMotiveHistories());
}
}

View File

@ -0,0 +1,113 @@
<?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\Handler\ReplaceMotiveCommandHandler;
use Chill\TicketBundle\Controller\ReplaceMotiveController;
use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* @internal
*
* @coversNothing
*/
class ReplaceMotiveControllerTest extends KernelTestCase
{
use ProphecyTrait;
private SerializerInterface $serializer;
private ValidatorInterface $validator;
protected function setUp(): void
{
self::bootKernel();
$this->serializer = self::getContainer()->get(SerializerInterface::class);
$this->validator = self::getContainer()->get(ValidatorInterface::class);
}
protected function tearDown(): void
{
self::ensureKernelShutdown();
}
/**
* @dataProvider generateMotiveId
*/
public function testAddValidMotive(int $motiveId): void
{
$ticket = new Ticket();
$payload = <<<JSON
{"motive": {"type": "ticket_motive", "id": {$motiveId}}}
JSON;
$request = new Request(content: $payload);
$controller = $this->buildController();
$response = $controller($ticket, $request);
self::assertEquals(201, $response->getStatusCode());
}
private function buildController(): ReplaceMotiveController
{
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(true);
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::type(MotiveHistory::class))->shouldBeCalled();
$entityManager->flush()->shouldBeCalled();
$handler = new ReplaceMotiveCommandHandler(
new MockClock(),
$entityManager->reveal()
);
return new ReplaceMotiveController(
$security->reveal(),
$handler,
$this->serializer,
$this->validator,
$entityManager->reveal(),
);
}
public static function generateMotiveId(): iterable
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
$motive = $em->createQuery('SELECT m FROM '.Motive::class.' m ')
->setMaxResults(1)
->getOneOrNullResult();
if (null === $motive) {
throw new \RuntimeException('the motive table seems to be empty');
}
self::ensureKernelShutdown();
yield [$motive->getId()];
}
}

View File

@ -0,0 +1,47 @@
<?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 Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\Ticket;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class TicketTest extends KernelTestCase
{
public function testGetMotive(): void
{
$ticket = new Ticket();
$motive = new Motive();
self::assertNull($ticket->getMotive());
$history = new MotiveHistory($motive, $ticket);
$ticket->addMotiveHistory($history);
self::assertSame($motive, $ticket->getMotive());
self::assertCount(1, $ticket->getMotiveHistories());
// replace motive
$motive2 = new Motive();
$history->setEndDate(new \DateTimeImmutable());
$ticket->addMotiveHistory(new MotiveHistory($motive2, $ticket));
self::assertCount(2, $ticket->getMotiveHistories());
self::assertSame($motive2, $ticket->getMotive());
}
}