diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 8ef0b7c8d..899c890b1 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -149,6 +149,14 @@ Key configuration files: - `package.json`: JavaScript dependencies and scripts - `.env`: Default environment variables. Must usually not be updated: use `.env.local` instead. +### Development guidelines + +#### Usage of clock + +When we need to use a DateTime or DateTimeImmutable that need to express "now", we prefer the usage of +`Symfony\Component\Clock\ClockInterface`, where possible. This is usually not possible in doctrine entities, +where injection does not work when restoring an entity from database, but usually possible in services. + ### Testing Information The project uses PHPUnit for testing. Each bundle has its own test suite, and there's also a global test suite at the root level. @@ -218,7 +226,7 @@ class TicketTest extends TestCase #### Test Database -For tests that require a database, the project uses an in-memory SQLite database by default. You can configure a different database for testing in the `.env.test` file. +For tests that require a database, the project uses postgresql database filled by fixtures (usage of doctrine-fixtures). You can configure a different database for testing in the `.env.test` file. ### Code Quality Tools diff --git a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml index 5be9ac0c0..3aac7f2ec 100644 --- a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml @@ -27,6 +27,23 @@ components: - type paths: + /1.0/ticket/ticket/{id}: + get: + tags: + - ticket + summary: Details of a ticket + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "OK" /1.0/ticket/motive.json: get: tags: @@ -94,6 +111,34 @@ paths: 422: description: "UNPROCESSABLE ENTITY" + /1.0/ticket/{id}/persons/set: + post: + tags: + - ticket + summary: Associate a person with the ticket + 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: + persons: + type: array + items: + $ref: '#/components/schemas/PersonById' + responses: + 200: + description: "OK" /1.0/ticket/{id}/addressees/set: post: tags: @@ -161,3 +206,52 @@ paths: description: "ACCEPTED" 422: description: "UNPROCESSABLE ENTITY" + + /1.0/ticket/ticket/{id}/close: + post: + tags: + - ticket + summary: Close a ticket + description: | + Close an existing ticket. + + If the ticket is already close, 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 + responses: + 200: + description: "OK" + 401: + description: "UNAUTHORIZED" + /1.0/ticket/ticket/{id}/open: + post: + tags: + - ticket + summary: Open a ticket + 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 + responses: + 200: + description: "OK" + 401: + description: "UNAUTHORIZED" diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/ChangeStateCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/ChangeStateCommand.php new file mode 100644 index 000000000..f668a2455 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/ChangeStateCommand.php @@ -0,0 +1,24 @@ +newState === $ticket->getState()) { + return $ticket; + } + + // End the current state history (if any) + foreach ($ticket->getStateHistories() as $stateHistory) { + if (null === $stateHistory->getEndDate()) { + $stateHistory->setEndDate($this->clock->now()); + } + } + + // Create a new state history with the new state + new StateHistory( + $command->newState, + $ticket, + $this->clock->now(), + ); + + return $ticket; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/CreateTicketCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/CreateTicketCommandHandler.php index 4fdbfa8f3..c354294d0 100644 --- a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/CreateTicketCommandHandler.php +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/CreateTicketCommandHandler.php @@ -12,15 +12,22 @@ declare(strict_types=1); namespace Chill\TicketBundle\Action\Ticket\Handler; use Chill\TicketBundle\Action\Ticket\CreateTicketCommand; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Entity\StateHistory; use Chill\TicketBundle\Entity\Ticket; +use Symfony\Component\Clock\ClockInterface; class CreateTicketCommandHandler { + public function __construct(private readonly ClockInterface $clock) {} + public function __invoke(CreateTicketCommand $command): Ticket { $ticket = new Ticket(); $ticket->setExternalRef($command->externalReference); + new StateHistory(StateEnum::OPEN, $ticket, $this->clock->now()); + return $ticket; } } diff --git a/src/Bundle/ChillTicketBundle/src/Controller/ChangeStateApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/ChangeStateApiController.php new file mode 100644 index 000000000..55e09162d --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/ChangeStateApiController.php @@ -0,0 +1,73 @@ + '\d+'], methods: ['POST'])] + public function close(Ticket $ticket): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users are allowed to close tickets.'); + } + + $command = new ChangeStateCommand(StateEnum::CLOSED); + $this->changeStateCommandHandler->__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}/open', name: 'chill_ticket_ticket_open_api', requirements: ['id' => '\d+'], methods: ['POST'])] + public function open(Ticket $ticket): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users are allowed to open tickets.'); + } + + $command = new ChangeStateCommand(StateEnum::OPEN); + $this->changeStateCommandHandler->__invoke($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => ['read']]), + json: true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php b/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php index fcff2aa56..c9886759c 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php @@ -19,7 +19,7 @@ use Twig\Environment; class EditTicketController { public function __construct( - private Environment $templating, + private readonly Environment $templating, ) {} #[Route('/{_locale}/ticket/ticket/{id}/edit', name: 'chill_ticket_ticket_edit')] diff --git a/src/Bundle/ChillTicketBundle/src/Controller/FindCallerController.php b/src/Bundle/ChillTicketBundle/src/Controller/FindCallerController.php index 94febedaa..c5d476361 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/FindCallerController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/FindCallerController.php @@ -28,7 +28,7 @@ use Symfony\Component\Routing\Annotation\Route; */ class FindCallerController { - public function __construct(private PhonenumberHelper $phonenumberHelper, private PersonRepository $personRepository, private PersonRenderInterface $personRender) {} + public function __construct(private readonly PhonenumberHelper $phonenumberHelper, private readonly PersonRepository $personRepository, private readonly PersonRenderInterface $personRender) {} #[Route('/public/api/1.0/ticket/find-caller', name: 'find-caller', methods: ['GET'])] public function findCaller(Request $request): Response diff --git a/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php b/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php new file mode 100644 index 000000000..36cde311a --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php @@ -0,0 +1,31 @@ +serializer->serialize($ticket, 'json', ['groups' => ['read']]), + json: true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/TicketListController.php b/src/Bundle/ChillTicketBundle/src/Controller/TicketListController.php index ab335f108..f15329689 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/TicketListController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/TicketListController.php @@ -67,7 +67,7 @@ final readonly class TicketListController // $motives = $this->motiveRepository->findAll(); return $this->filterOrderHelperFactory - ->create(__CLASS__) + ->create(self::class) ->addSingleCheckbox('to_me', 'chill_ticket.list.filter.to_me') ->addSingleCheckbox('in_alert', 'chill_ticket.list.filter.in_alert') ->addDateRange('created_between', 'chill_ticket.list.filter.created_between') diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts index 88047673d..3615887f7 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts @@ -3,8 +3,8 @@ import { TranslatableString, User, UserGroupOrUser, -} from "../../../../ChillMainBundle/Resources/public/types"; -import { Person } from "../../../../ChillPersonBundle/Resources/public/types"; +} from "ChillMainAssets/types"; +import { Person } from "ChillPersonAssets/types"; export interface Motive { type: "ticket_motive"; @@ -13,6 +13,8 @@ export interface Motive { label: TranslatableString; } +export type TicketState = "open"|"closed"; + interface TicketHistory { event_type: T; at: DateTime; @@ -72,6 +74,10 @@ export interface PersonsState { persons: Person[]; } +export interface StateChange { + new_state: TicketState +} + export interface CreateTicketState {} //interface AddPersonEvent extends TicketHistory<"add_person", PersonHistory> {}; @@ -87,6 +93,8 @@ export interface CreateTicketEvent extends TicketHistory<"create_ticket", CreateTicketState> {} export interface PersonStateEvent extends TicketHistory<"persons_state", PersonsState> {} +export interface ChangeStateEvent + extends TicketHistory<"state_change", StateChange> {} export type TicketHistoryLine = /* AddPersonEvent */ @@ -94,7 +102,8 @@ export type TicketHistoryLine = | AddCommentEvent | SetMotiveEvent /*AddAddressee | RemoveAddressee | */ | AddresseesStateEvent - | PersonStateEvent; + | PersonStateEvent + | ChangeStateEvent; export interface Ticket { type: "ticket_ticket"; @@ -106,6 +115,7 @@ export interface Ticket { history: TicketHistoryLine[]; createdAt: DateTime | null; updatedBy: User | null; + currentState: TicketState | null; } export interface addNewPersons { diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php index 2b255f207..36e68fae9 100644 --- a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php @@ -18,6 +18,7 @@ use Chill\TicketBundle\Entity\AddresseeHistory; use Chill\TicketBundle\Entity\Comment; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\PersonHistory; +use Chill\TicketBundle\Entity\StateHistory; use Chill\TicketBundle\Entity\Ticket; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; @@ -49,6 +50,7 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte 'updatedAt' => $this->normalizer->normalize($object->getUpdatedAt(), $format, $context), 'updatedBy' => $this->normalizer->normalize($object->getUpdatedBy(), $format, $context), 'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context), + 'currentState' => $object->getState()?->value, ]; } @@ -60,6 +62,17 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte private function serializeHistory(Ticket $ticket, string $format, array $context): array { $events = [ + ...array_map( + fn (StateHistory $stateHistory) => [ + 'event_type' => 'state_change', + 'at' => $stateHistory->getStartDate(), + 'by' => $stateHistory->getCreatedBy(), + 'data' => [ + 'new_state' => $stateHistory->getState()->value, + ], + ], + $ticket->getStateHistories()->toArray(), + ), ...array_map( fn (MotiveHistory $motiveHistory) => [ 'event_type' => 'set_motive', @@ -87,26 +100,6 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte ], $ticket->getComments()->toArray(), ), - /* - ...array_map( - fn (AddresseeHistory $history) => [ - 'event_type' => 'add_addressee', - 'at' => $history->getStartDate(), - 'by' => $history->getCreatedBy(), - 'data' => $history, - ], - $ticket->getAddresseeHistories()->toArray(), - ), - ...array_map( - fn (AddresseeHistory $history) => [ - 'event_type' => 'remove_addressee', - 'at' => $history->getStartDate(), - 'by' => $history->getRemovedBy(), - 'data' => $history, - ], - $ticket->getAddresseeHistories()->filter(fn (AddresseeHistory $history) => null !== $history->getEndDate())->toArray() - ), - */ ...$this->addresseesStates($ticket), ...$this->personStates($ticket), ]; @@ -115,7 +108,8 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte $events[] = [ 'event_type' => 'create_ticket', - 'at' => $ticket->getCreatedAt()->sub(new \DateInterval('PT1S')), // TODO hack to avoid collision with creation of the ticket event, + 'at' => \DateTimeImmutable::createFromInterface($ticket->getCreatedAt()) + ->sub(new \DateInterval('PT1S')), // TODO hack to avoid collision with creation of the ticket event, 'by' => $ticket->getCreatedBy(), 'data' => [], ]; @@ -123,9 +117,7 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte usort( $events, - static function (array $a, array $b): int { - return $a['at'] <=> $b['at']; - } + static fn (array $a, array $b): int => $a['at'] <=> $b['at'] ); return array_map( diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ChangeStateCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ChangeStateCommandHandlerTest.php new file mode 100644 index 000000000..a19c89479 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ChangeStateCommandHandlerTest.php @@ -0,0 +1,118 @@ +__invoke($ticket, $command); + + // Assert that the ticket is returned unchanged + $this->assertSame($ticket, $result); + $this->assertSame(StateEnum::CLOSED, $ticket->getState()); + + // Assert that no new state history was created + $stateHistories = $ticket->getStateHistories(); + $this->assertCount(1, $stateHistories); + } + + public function testInvokeWithOpenTicketToClose(): void + { + $ticket = new Ticket(); + + // Create an open state history + new StateHistory(StateEnum::OPEN, $ticket); + + $handler = new ChangeStateCommandHandler(new MockClock()); + $command = new ChangeStateCommand(StateEnum::CLOSED); + + $result = $handler->__invoke($ticket, $command); + + // Assert that the ticket is returned + $this->assertSame($ticket, $result); + + // Assert that the ticket state is now closed + $this->assertSame(StateEnum::CLOSED, $ticket->getState()); + + // Assert that the old state history was ended and a new one was created + $stateHistories = $ticket->getStateHistories(); + $this->assertCount(2, $stateHistories); + + // The first state history should be ended + $openStateHistory = $stateHistories->first(); + $this->assertNotNull($openStateHistory->getEndDate()); + $this->assertSame(StateEnum::OPEN, $openStateHistory->getState()); + + // The last state history should be closed and active + $closedStateHistory = $stateHistories->last(); + $this->assertNull($closedStateHistory->getEndDate()); + $this->assertSame(StateEnum::CLOSED, $closedStateHistory->getState()); + } + + public function testInvokeWithClosedTicketToOpen(): void + { + $ticket = new Ticket(); + + // Create a closed state history + new StateHistory(StateEnum::CLOSED, $ticket); + + $handler = new ChangeStateCommandHandler(new MockClock()); + $command = new ChangeStateCommand(StateEnum::OPEN); + + $result = $handler->__invoke($ticket, $command); + + // Assert that the ticket is returned + $this->assertSame($ticket, $result); + + // Assert that the ticket state is now open + $this->assertSame(StateEnum::OPEN, $ticket->getState()); + + // Assert that the old state history was ended and a new one was created + $stateHistories = $ticket->getStateHistories(); + $this->assertCount(2, $stateHistories); + + // The first state history should be ended + $closedStateHistory = $stateHistories->first(); + $this->assertNotNull($closedStateHistory->getEndDate()); + $this->assertSame(StateEnum::CLOSED, $closedStateHistory->getState()); + + // The last state history should be open and active + $openStateHistory = $stateHistories->last(); + $this->assertNull($openStateHistory->getEndDate()); + $this->assertSame(StateEnum::OPEN, $openStateHistory->getState()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/CreateTicketCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/CreateTicketCommandHandlerTest.php index 73f84b171..5d4e11566 100644 --- a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/CreateTicketCommandHandlerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/CreateTicketCommandHandlerTest.php @@ -13,8 +13,10 @@ namespace Chill\TicketBundle\Tests\Action\Ticket\Handler; use Chill\TicketBundle\Action\Ticket\CreateTicketCommand; use Chill\TicketBundle\Action\Ticket\Handler\CreateTicketCommandHandler; +use Chill\TicketBundle\Entity\StateEnum; use Chill\TicketBundle\Entity\Ticket; use PHPUnit\Framework\TestCase; +use Symfony\Component\Clock\MockClock; /** * @internal @@ -25,7 +27,7 @@ class CreateTicketCommandHandlerTest extends TestCase { private function getHandler(): CreateTicketCommandHandler { - return new CreateTicketCommandHandler(); + return new CreateTicketCommandHandler(new MockClock()); } public function testHandleWithoutReference(): void @@ -35,6 +37,7 @@ class CreateTicketCommandHandlerTest extends TestCase self::assertInstanceOf(Ticket::class, $actual); self::assertEquals('', $actual->getExternalRef()); + self::assertEquals(StateEnum::OPEN, $actual->getState()); } public function testHandleWithReference(): void @@ -44,5 +47,6 @@ class CreateTicketCommandHandlerTest extends TestCase self::assertInstanceOf(Ticket::class, $actual); self::assertEquals($ref, $actual->getExternalRef()); + self::assertEquals(StateEnum::OPEN, $actual->getState()); } } diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php index cef1ae153..3fc45db8a 100644 --- a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php @@ -39,12 +39,8 @@ final class SetAddressesCommandHandlerTest extends TestCase $command = new SetAddresseesCommand([$user1 = new User(), $group1 = new UserGroup()]); $entityManager = $this->prophesize(EntityManagerInterface::class); - $entityManager->persist(Argument::that(function ($arg) use ($user1) { - return $arg instanceof AddresseeHistory && $arg->getAddressee() === $user1; - }))->shouldBeCalledOnce(); - $entityManager->persist(Argument::that(function ($arg) use ($group1) { - return $arg instanceof AddresseeHistory && $arg->getAddressee() === $group1; - }))->shouldBeCalledOnce(); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof AddresseeHistory && $arg->getAddressee() === $user1))->shouldBeCalledOnce(); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof AddresseeHistory && $arg->getAddressee() === $group1))->shouldBeCalledOnce(); $handler = $this->buildHandler($entityManager->reveal()); @@ -61,9 +57,7 @@ final class SetAddressesCommandHandlerTest extends TestCase $command = new SetAddresseesCommand([$user]); $entityManager = $this->prophesize(EntityManagerInterface::class); - $entityManager->persist(Argument::that(function ($arg) use ($user) { - return $arg instanceof AddresseeHistory && $arg->getAddressee() === $user; - }))->shouldNotBeCalled(); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof AddresseeHistory && $arg->getAddressee() === $user))->shouldNotBeCalled(); $handler = $this->buildHandler($entityManager->reveal()); @@ -82,9 +76,7 @@ final class SetAddressesCommandHandlerTest extends TestCase $command = new SetAddresseesCommand([$group]); $entityManager = $this->prophesize(EntityManagerInterface::class); - $entityManager->persist(Argument::that(function ($arg) use ($group) { - return $arg instanceof AddresseeHistory && $arg->getAddressee() === $group; - }))->shouldBeCalled(); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof AddresseeHistory && $arg->getAddressee() === $group))->shouldBeCalled(); $handler = $this->buildHandler($entityManager->reveal()); @@ -101,9 +93,7 @@ final class SetAddressesCommandHandlerTest extends TestCase $command = new SetAddresseesCommand([$group, $group]); $entityManager = $this->prophesize(EntityManagerInterface::class); - $entityManager->persist(Argument::that(function ($arg) use ($group) { - return $arg instanceof AddresseeHistory && $arg->getAddressee() === $group; - }))->shouldBeCalledOnce(); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof AddresseeHistory && $arg->getAddressee() === $group))->shouldBeCalledOnce(); $handler = $this->buildHandler($entityManager->reveal()); diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetPersonsCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetPersonsCommandHandlerTest.php index 3db50e7e6..5c609b543 100644 --- a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetPersonsCommandHandlerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetPersonsCommandHandlerTest.php @@ -39,12 +39,8 @@ final class SetPersonsCommandHandlerTest extends TestCase $command = new SetPersonsCommand([$person1 = new Person(), $group1 = new Person()]); $entityManager = $this->prophesize(EntityManagerInterface::class); - $entityManager->persist(Argument::that(function ($arg) use ($person1) { - return $arg instanceof PersonHistory && $arg->getPerson() === $person1; - }))->shouldBeCalledOnce(); - $entityManager->persist(Argument::that(function ($arg) use ($group1) { - return $arg instanceof PersonHistory && $arg->getPerson() === $group1; - }))->shouldBeCalledOnce(); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $person1))->shouldBeCalledOnce(); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $group1))->shouldBeCalledOnce(); $handler = $this->buildHandler($entityManager->reveal()); @@ -61,9 +57,7 @@ final class SetPersonsCommandHandlerTest extends TestCase $command = new SetPersonsCommand([$person]); $entityManager = $this->prophesize(EntityManagerInterface::class); - $entityManager->persist(Argument::that(function ($arg) use ($person) { - return $arg instanceof PersonHistory && $arg->getPerson() === $person; - }))->shouldNotBeCalled(); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $person))->shouldNotBeCalled(); $handler = $this->buildHandler($entityManager->reveal()); @@ -82,9 +76,7 @@ final class SetPersonsCommandHandlerTest extends TestCase $command = new SetPersonsCommand([$person2]); $entityManager = $this->prophesize(EntityManagerInterface::class); - $entityManager->persist(Argument::that(function ($arg) use ($person2) { - return $arg instanceof PersonHistory && $arg->getPerson() === $person2; - }))->shouldBeCalled(); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $person2))->shouldBeCalled(); $handler = $this->buildHandler($entityManager->reveal()); @@ -101,9 +93,7 @@ final class SetPersonsCommandHandlerTest extends TestCase $command = new SetPersonsCommand([$person, $person]); $entityManager = $this->prophesize(EntityManagerInterface::class); - $entityManager->persist(Argument::that(function ($arg) use ($person) { - return $arg instanceof PersonHistory && $arg->getPerson() === $person; - }))->shouldBeCalledOnce(); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $person))->shouldBeCalledOnce(); $handler = $this->buildHandler($entityManager->reveal()); diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/ChangeStateApiControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/ChangeStateApiControllerTest.php new file mode 100644 index 000000000..ab8c2b3cb --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/ChangeStateApiControllerTest.php @@ -0,0 +1,144 @@ +prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(false); + + $changeStateCommandHandler = $this->prophesize(ChangeStateCommandHandler::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + + $controller = new ChangeStateApiController( + $changeStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $this->expectException(AccessDeniedHttpException::class); + $controller->close($ticket); + } + + public function testCloseWithPermission(): void + { + $ticket = new Ticket(); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $changeStateCommandHandler = $this->prophesize(ChangeStateCommandHandler::class); + $changeStateCommandHandler->__invoke( + $ticket, + Argument::that(fn (ChangeStateCommand $command) => StateEnum::CLOSED === $command->newState) + )->willReturn($ticket); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize($ticket, 'json', ['groups' => ['read']]) + ->willReturn('{}') + ->shouldBeCalled(); + + $controller = new ChangeStateApiController( + $changeStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $response = $controller->close($ticket); + + $this->assertInstanceOf(JsonResponse::class, $response); + } + + public function testOpenWithoutPermission(): void + { + $ticket = new Ticket(); + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(false); + + $changeStateCommandHandler = $this->prophesize(ChangeStateCommandHandler::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + + $controller = new ChangeStateApiController( + $changeStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $this->expectException(AccessDeniedHttpException::class); + $controller->open($ticket); + } + + public function testOpenWithPermission(): void + { + $ticket = new Ticket(); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $changeStateCommandHandler = $this->prophesize(ChangeStateCommandHandler::class); + $changeStateCommandHandler->__invoke( + $ticket, + Argument::that(fn (ChangeStateCommand $command) => StateEnum::OPEN === $command->newState) + )->willReturn($ticket); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize($ticket, 'json', ['groups' => ['read']]) + ->willReturn('{}') + ->shouldBeCalled(); + + $controller = new ChangeStateApiController( + $changeStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $response = $controller->open($ticket); + + $this->assertInstanceOf(JsonResponse::class, $response); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php index 1455f711f..5ff4bfd77 100644 --- a/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php @@ -81,7 +81,7 @@ class SetAddresseesControllerTest extends KernelTestCase $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertIsArray($asArray); - self::arrayHasKey('violations', $asArray); + self::arrayHasKey('violations'); self::assertGreaterThan(0, count($asArray['violations'])); } @@ -147,7 +147,7 @@ class SetAddresseesControllerTest extends KernelTestCase $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); self::assertIsArray($asArray); - self::arrayHasKey('violations', $asArray); + self::arrayHasKey('violations'); self::assertGreaterThan(0, count($asArray['violations'])); } diff --git a/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php index 6ce8b53ea..5776b3a00 100644 --- a/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php @@ -114,25 +114,23 @@ class TicketTest extends KernelTestCase public function testGetState(): void { $ticket = new Ticket(); - $openState = new StateEnum(StateEnum::OPEN, ['en' => 'Open', 'fr' => 'Ouvert']); // Initially, the ticket has no state self::assertNull($ticket->getState()); // Create a state history entry with the open state - $history = new StateHistory($openState, $ticket); + $history = new StateHistory(StateEnum::OPEN, $ticket); // Verify that the ticket now has the open state - self::assertSame($openState, $ticket->getState()); + self::assertSame(StateEnum::OPEN, $ticket->getState()); self::assertCount(1, $ticket->getStateHistories()); // Change the state to closed - $closedState = new StateEnum(StateEnum::CLOSED, ['en' => 'Closed', 'fr' => 'Fermé']); $history->setEndDate(new \DateTimeImmutable()); - $history2 = new StateHistory($closedState, $ticket); + $history2 = new StateHistory(StateEnum::CLOSED, $ticket); // Verify that the ticket now has the closed state self::assertCount(2, $ticket->getStateHistories()); - self::assertSame($closedState, $ticket->getState()); + self::assertSame(StateEnum::CLOSED, $ticket->getState()); } } diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php index 035ce95a6..940c93d97 100644 --- a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php @@ -19,6 +19,8 @@ use Chill\TicketBundle\Entity\Comment; use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\PersonHistory; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Entity\StateHistory; use Chill\TicketBundle\Entity\Ticket; use Chill\TicketBundle\Serializer\Normalizer\TicketNormalizer; use Prophecy\Argument; @@ -41,7 +43,6 @@ class TicketNormalizerTest extends KernelTestCase public function testNormalize(Ticket $ticket, array $expected): void { $actual = $this->buildNormalizer()->normalize($ticket, 'json', ['groups' => 'read']); - self::assertEqualsCanonicalizing(array_keys($expected), array_keys($actual)); foreach (array_keys($expected) as $k) { @@ -61,23 +62,67 @@ class TicketNormalizerTest extends KernelTestCase public static function provideTickets(): iterable { + $t = new Ticket(); + + // added by action + new StateHistory(StateEnum::OPEN, $t, new \DateTimeImmutable('2024-06-16T00:00:00Z')); + + // those are added by doctrine listeners + $t->setCreatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); + $t->setCreatedBy(new User()); + $t->setUpdatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); + $t->setUpdatedBy(new User()); + yield [ // this a nearly empty ticket - new Ticket(), + $t, [ 'type' => 'ticket_ticket', + 'createdAt' => $t->getCreatedAt()?->getTimestamp(), + 'createdBy' => ['user'], 'id' => null, 'externalRef' => '', 'currentPersons' => [], 'currentAddressees' => [], 'currentInputs' => [], 'currentMotive' => null, - 'history' => [], + 'history' => [ + [ + 'event_type' => 'create_ticket', + 'at' => 1718495999, + 'by' => [ + 0 => 'user', + ], + 'data' => [], + ], + [ + 'event_type' => 'state_change', + 'at' => 1718495999, + 'by' => [ + 0 => 'user', + ], + 'data' => [ + 'new_state' => 'open', + ], + ], + ], + 'currentState' => 'open', + 'updatedAt' => $t->getUpdatedAt()->getTimestamp(), + 'updatedBy' => ['user'], ], ]; // ticket with more features $ticket = new Ticket(); + + // added by action + new StateHistory(StateEnum::OPEN, $ticket, new \DateTimeImmutable('2024-06-16T00:00:00Z')); + + // those are added by doctrine listeners + $ticket->setCreatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); + $ticket->setCreatedBy(new User()); + $ticket->setUpdatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); + $ticket->setUpdatedBy(new User()); $ticket->setExternalRef('2134'); $personHistory = new PersonHistory(new Person(), $ticket, new \DateTimeImmutable('2024-04-01T12:00:00')); $ticketHistory = new MotiveHistory(new Motive(), $ticket, new \DateTimeImmutable('2024-04-01T12:02:00')); @@ -92,6 +137,8 @@ class TicketNormalizerTest extends KernelTestCase $ticket, [ 'type' => 'ticket_ticket', + 'createdAt' => $ticket->getCreatedAt()?->getTimestamp(), + 'createdBy' => ['user'], 'id' => null, 'externalRef' => '2134', 'currentPersons' => ['embedded'], @@ -100,12 +147,18 @@ class TicketNormalizerTest extends KernelTestCase 'currentMotive' => ['type' => 'motive', 'id' => 0], 'history' => [ ['event_type' => 'add_person'], + ['event_type' => 'persons_state'], ['event_type' => 'set_motive'], ['event_type' => 'add_comment'], - ['event_type' => 'add_addressee'], - ['event_type' => 'remove_addressee'], - ['event_type' => 'add_addressee'], + ['event_type' => 'addressees_state'], + ['event_type' => 'addressees_state'], + ['event_type' => 'addressees_state'], + ['event_type' => 'create_ticket'], + ['event_type' => 'state_change'], ], + 'currentState' => 'open', + 'updatedAt' => $ticket->getUpdatedAt()->getTimestamp(), + 'updatedBy' => ['user'], ], ]; } @@ -126,9 +179,7 @@ class TicketNormalizerTest extends KernelTestCase Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_object($arg[0])), 'json', Argument::type('array') - )->will(function ($args) { - return array_fill(0, count($args[0]), 'embedded'); - }); + )->will(fn ($args) => array_fill(0, count($args[0]), 'embedded')); // array of event type $normalizer->normalize( @@ -144,10 +195,28 @@ class TicketNormalizerTest extends KernelTestCase return $events; }); + // array of persons + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 1 === count($arg) && array_key_exists('persons', $arg)), + 'json', + ['groups' => 'read'] + )->will(fn ($args): array => ['persons' => []]); + // array of addresses + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 1 === count($arg) && array_key_exists('addressees', $arg)), + 'json', + ['groups' => 'read'] + )->will(fn ($args): array => ['addressees' => []]); + // state data + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 1 === count($arg) && array_key_exists('new_state', $arg)), + 'json', + ['groups' => 'read'] + )->will(fn ($args): array => $args[0]); // datetime $normalizer->normalize(Argument::type(\DateTimeImmutable::class), 'json', Argument::type('array')) - ->will(function ($args) { return $args[0]->getTimestamp(); }); + ->will(fn ($args) => $args[0]->getTimestamp()); // user $normalizer->normalize(Argument::type(User::class), 'json', Argument::type('array')) ->willReturn(['user']);