diff --git a/src/Bundle/ChillMainBundle/Controller/UserGroupApiController.php b/src/Bundle/ChillMainBundle/Controller/UserGroupApiController.php new file mode 100644 index 000000000..602b84ec5 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Controller/UserGroupApiController.php @@ -0,0 +1,16 @@ +getRepository(User::class)->findOneBy(['username' => 'center a_social']); + $centerBSocial = $manager->getRepository(User::class)->findOneBy(['username' => 'center b_social']); + $multiCenter = $manager->getRepository(User::class)->findOneBy(['username' => 'multi_center']); + $administrativeA = $manager->getRepository(User::class)->findOneBy(['username' => 'center a_administrative']); + $administrativeB = $manager->getRepository(User::class)->findOneBy(['username' => 'center b_administrative']); + + $level1 = $this->generateLevelGroup('Niveau 1', '#eec84aff', '#000000ff', 'level'); + $level1->addUser($centerASocial)->addUser($centerBSocial); + $manager->persist($level1); + + $level2 = $this->generateLevelGroup('Niveau 2', ' #e2793dff', '#000000ff', 'level'); + $level2->addUser($multiCenter); + $manager->persist($level2); + + $level3 = $this->generateLevelGroup('Niveau 3', ' #df4949ff', '#000000ff', 'level'); + $level3->addUser($multiCenter); + $manager->persist($level3); + + $tss = $this->generateLevelGroup('Travailleur sociaux', '#43b29dff', '#000000ff', ''); + $tss->addUser($multiCenter)->addUser($centerASocial)->addUser($centerBSocial); + $manager->persist($tss); + $admins = $this->generateLevelGroup('Administratif', '#334d5cff', '#000000ff', ''); + $admins->addUser($administrativeA)->addUser($administrativeB); + $manager->persist($admins); + + $manager->flush(); + } + + private function generateLevelGroup(string $title, string $backgroundColor, string $foregroundColor, string $excludeKey): UserGroup + { + $userGroup = new UserGroup(); + + return $userGroup + ->setLabel(['fr' => $title]) + ->setBackgroundColor($backgroundColor) + ->setForegroundColor($foregroundColor) + ->setExcludeKey($excludeKey) + ; + } +} diff --git a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php index 4dde2076c..3170cadee 100644 --- a/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php +++ b/src/Bundle/ChillMainBundle/DependencyInjection/ChillMainExtension.php @@ -24,6 +24,7 @@ use Chill\MainBundle\Controller\LocationTypeController; use Chill\MainBundle\Controller\NewsItemController; use Chill\MainBundle\Controller\RegroupmentController; use Chill\MainBundle\Controller\UserController; +use Chill\MainBundle\Controller\UserGroupApiController; use Chill\MainBundle\Controller\UserJobApiController; use Chill\MainBundle\Controller\UserJobController; use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface; @@ -59,6 +60,7 @@ use Chill\MainBundle\Entity\LocationType; use Chill\MainBundle\Entity\NewsItem; use Chill\MainBundle\Entity\Regroupment; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Form\CenterType; use Chill\MainBundle\Form\CivilityType; @@ -803,6 +805,21 @@ class ChillMainExtension extends Extension implements ], ], ], + [ + 'class' => UserGroup::class, + 'controller' => UserGroupApiController::class, + 'name' => 'user-group', + 'base_path' => '/api/1.0/main/user-group', + 'base_role' => 'ROLE_USER', + 'actions' => [ + '_index' => [ + 'methods' => [ + Request::METHOD_GET => true, + Request::METHOD_HEAD => true, + ], + ], + ], + ], ], ]); } diff --git a/src/Bundle/ChillMainBundle/Entity/UserGroup.php b/src/Bundle/ChillMainBundle/Entity/UserGroup.php index 3bd9870a6..470a21a70 100644 --- a/src/Bundle/ChillMainBundle/Entity/UserGroup.php +++ b/src/Bundle/ChillMainBundle/Entity/UserGroup.php @@ -14,17 +14,21 @@ namespace Chill\MainBundle\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; #[ORM\Entity] #[ORM\Table(name: 'chill_main_user_group')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['user_group' => UserGroup::class])] class UserGroup { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] + #[Serializer\Groups(['read'])] private ?int $id = null; #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])] + #[Serializer\Groups(['read'])] private array $label = []; /** @@ -34,6 +38,24 @@ class UserGroup #[ORM\JoinTable(name: 'chill_main_user_group_user')] private Collection $users; + #[ORM\Column(type: 'text', nullable: false, options: ['default' => '#ffffffff'])] + #[Serializer\Groups(['read'])] + private string $backgroundColor = '#ffffffff'; + + #[ORM\Column(type: 'text', nullable: false, options: ['default' => '#000000ff'])] + #[Serializer\Groups(['read'])] + private string $foregroundColor = '#000000ff'; + + /** + * Groups with same exclude key are mutually exclusive: adding one in a many-to-one relationship + * will exclude others. + * + * An empty string means "no exclusion" + */ + #[ORM\Column(type: 'text', nullable: false, options: ['default' => ''])] + #[Serializer\Groups(['read'])] + private string $excludeKey = ''; + public function __construct() { $this->users = new ArrayCollection(); @@ -71,4 +93,47 @@ class UserGroup { return $this->users; } + + public function getForegroundColor(): string + { + return $this->foregroundColor; + } + + public function getExcludeKey(): string + { + return $this->excludeKey; + } + + public function getBackgroundColor(): string + { + return $this->backgroundColor; + } + + public function setForegroundColor(string $foregroundColor): self + { + $this->foregroundColor = $foregroundColor; + + return $this; + } + + public function setBackgroundColor(string $backgroundColor): self + { + $this->backgroundColor = $backgroundColor; + + return $this; + } + + public function setExcludeKey(string $excludeKey): self + { + $this->excludeKey = $excludeKey; + + return $this; + } + + public function setLabel(array $label): self + { + $this->label = $label; + + return $this; + } } diff --git a/src/Bundle/ChillMainBundle/Resources/public/types.ts b/src/Bundle/ChillMainBundle/Resources/public/types.ts index 2e33b8248..ada3089a0 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/types.ts +++ b/src/Bundle/ChillMainBundle/Resources/public/types.ts @@ -42,6 +42,17 @@ export interface User { // todo: mainCenter; mainJob; etc.. } +export interface UserGroup { + type: "chill_main_user_group", + id: number, + label: TranslatableString, + backgroundColor: string, + foregroundColor: string, + excludeKey: string, +} + +export type UserGroupOrUser = User | UserGroup; + export interface UserAssociatedInterface { type: "user"; id: number; diff --git a/src/Bundle/ChillMainBundle/Tests/Validation/Validator/UserGroupDoNotExcludeTest.php b/src/Bundle/ChillMainBundle/Tests/Validation/Validator/UserGroupDoNotExcludeTest.php new file mode 100644 index 000000000..d6d1c9887 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Validation/Validator/UserGroupDoNotExcludeTest.php @@ -0,0 +1,91 @@ +validator->validate([], new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude()); + + $this->assertNoViolation(); + } + + public function testMixedUserGroupAndUsersIsValid(): void + { + $this->validator->validate( + [new User(), new UserGroup()], + new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude() + ); + + $this->assertNoViolation(); + } + + public function testDifferentExcludeKeysIsValid(): void + { + $this->validator->validate( + [(new UserGroup())->setExcludeKey('A'), (new UserGroup())->setExcludeKey('B')], + new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude() + ); + + $this->assertNoViolation(); + } + + public function testMultipleGroupsWithEmptyExcludeKeyIsValid(): void + { + $this->validator->validate( + [(new UserGroup())->setExcludeKey(''), (new UserGroup())->setExcludeKey('')], + new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude() + ); + + $this->assertNoViolation(); + } + + public function testSameExclusionKeyWillRaiseError(): void + { + $this->validator->validate( + [ + (new UserGroup())->setExcludeKey('A')->setLabel(['fr' => 'Group 1']), + (new UserGroup())->setExcludeKey('A')->setLabel(['fr' => 'Group 2']), + ], + new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude() + ); + + $this->buildViolation('The groups {{ excluded_groups }} do exclude themselves. Please choose one between them') + ->setParameter('excluded_groups', 'Group 1, Group 2') + ->setCode('e16c8226-0090-11ef-8560-f7239594db09') + ->assertRaised(); + } +} diff --git a/src/Bundle/ChillMainBundle/Validation/Constraint/UserGroupDoNotExclude.php b/src/Bundle/ChillMainBundle/Validation/Constraint/UserGroupDoNotExclude.php new file mode 100644 index 000000000..5ec688e5b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Validation/Constraint/UserGroupDoNotExclude.php @@ -0,0 +1,31 @@ +getExcludeKey()][] = $gr; + } + } + + foreach ($groups as $excludeKey => $groupByKey) { + if ('' === $excludeKey) { + continue; + } + + if (1 < count($groupByKey)) { + $excludedGroups = implode( + ', ', + array_map( + fn (UserGroup $group) => $this->translatableStringHelper->localize($group->getLabel()), + $groupByKey + ) + ); + + $this->context + ->buildViolation($constraint->message) + ->setCode($constraint->code) + ->setParameters(['excluded_groups' => $excludedGroups]) + ->addViolation(); + } + } + } +} diff --git a/src/Bundle/ChillMainBundle/chill.api.specs.yaml b/src/Bundle/ChillMainBundle/chill.api.specs.yaml index f37ee723d..958afab07 100644 --- a/src/Bundle/ChillMainBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillMainBundle/chill.api.specs.yaml @@ -29,6 +29,42 @@ components: type: string text: type: string + UserById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - user + UserGroup: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - user_group + label: + type: object + additionalProperties: true + backgroundColor: + type: string + foregroundColor: + type: string + exclusionKey: + type: string + UserGroupById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - user_group Center: type: object properties: @@ -908,3 +944,19 @@ paths: $ref: '#/components/schemas/NewsItem' 403: description: "Unauthorized" + /1.0/main/user-group.json: + get: + tags: + - user-group + summary: Return a list of users-groups + responses: + 200: + description: "ok" + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UserGroup' + 403: + description: "Unauthorized" diff --git a/src/Bundle/ChillMainBundle/config/services/validator.yaml b/src/Bundle/ChillMainBundle/config/services/validator.yaml index b3b60b9d6..32b8903cc 100644 --- a/src/Bundle/ChillMainBundle/config/services/validator.yaml +++ b/src/Bundle/ChillMainBundle/config/services/validator.yaml @@ -3,6 +3,9 @@ services: autowire: true autoconfigure: true + Chill\MainBundle\Validation\: + resource: '../../Validation' + chill_main.validator_user_circle_consistency: class: Chill\MainBundle\Validator\Constraints\Entity\UserCircleConsistencyValidator arguments: diff --git a/src/Bundle/ChillMainBundle/migrations/Version20240422091752.php b/src/Bundle/ChillMainBundle/migrations/Version20240422091752.php new file mode 100644 index 000000000..960b3cc79 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20240422091752.php @@ -0,0 +1,41 @@ +addSql('ALTER TABLE chill_main_user_group ADD backgroundColor TEXT DEFAULT \'#ffffffff\' NOT NULL'); + $this->addSql('ALTER TABLE chill_main_user_group ADD foregroundColor TEXT DEFAULT \'#000000ff\' NOT NULL'); + $this->addSql('ALTER TABLE chill_main_user_group ADD excludeKey TEXT DEFAULT \'\' NOT NULL'); + $this->addSql('ALTER INDEX idx_1e07f044d2112630 RENAME TO IDX_738BC82BD2112630'); + $this->addSql('ALTER INDEX idx_1e07f044a76ed395 RENAME TO IDX_738BC82BA76ED395'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_main_user_group DROP backgroundColor'); + $this->addSql('ALTER TABLE chill_main_user_group DROP foregroundColor'); + $this->addSql('ALTER TABLE chill_main_user_group DROP excludeKey'); + $this->addSql('ALTER INDEX idx_738bc82bd2112630 RENAME TO idx_1e07f044d2112630'); + $this->addSql('ALTER INDEX idx_738bc82ba76ed395 RENAME TO idx_1e07f044a76ed395'); + } +} diff --git a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml index ce53732ff..5be9ac0c0 100644 --- a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml @@ -64,3 +64,100 @@ paths: description: "ACCEPTED" 422: description: "UNPROCESSABLE ENTITY" + + /1.0/ticket/{id}/comment/add: + post: + tags: + - ticket + summary: Add a comment to an existing 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: + content: + type: string + responses: + 201: + description: "ACCEPTED" + 422: + description: "UNPROCESSABLE ENTITY" + + /1.0/ticket/{id}/addressees/set: + post: + tags: + - ticket + summary: Set the addresses for an existing ticket (will replace all the existing addresses) + 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: + addressees: + type: array + items: + oneOf: + - $ref: '#/components/schemas/UserGroupById' + - $ref: '#/components/schemas/UserById' + + + responses: + 201: + description: "ACCEPTED" + 422: + description: "UNPROCESSABLE ENTITY" + + /1.0/ticket/{id}/addressee/add: + post: + tags: + - ticket + summary: Add an addressee to a ticket, without removing existing ones. + 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: + addressee: + oneOf: + - $ref: '#/components/schemas/UserGroupById' + - $ref: '#/components/schemas/UserById' + + + responses: + 201: + description: "ACCEPTED" + 422: + description: "UNPROCESSABLE ENTITY" diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddAddresseeCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddAddresseeCommand.php new file mode 100644 index 000000000..d7f9c545f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddAddresseeCommand.php @@ -0,0 +1,29 @@ +content, $ticket); + + $this->entityManager->persist($comment); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php new file mode 100644 index 000000000..b2805ed2f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php @@ -0,0 +1,56 @@ +getAddresseeHistories() as $addressHistory) { + if (null !== $addressHistory->getEndDate()) { + continue; + } + + if (!in_array($addressHistory->getAddressee(), $command->addressees, true)) { + $addressHistory->setEndDate($this->clock->now()); + if (($user = $this->security->getUser()) instanceof User) { + $addressHistory->setRemovedBy($user); + } + } + } + + // add new addresses + foreach ($command->addressees as $address) { + if (in_array($address, $ticket->getCurrentAddressee(), true)) { + continue; + } + + $history = new AddresseeHistory($address, $this->clock->now(), $ticket); + $this->entityManager->persist($history); + } + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetAddresseesCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetAddresseesCommand.php new file mode 100644 index 000000000..e0d474d11 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetAddresseesCommand.php @@ -0,0 +1,40 @@ + + */ + #[UserGroupDoNotExclude] + #[GreaterThan(0)] + #[Groups(['read'])] + public array $addressees + ) {} + + public static function fromAddAddresseeCommand(AddAddresseeCommand $command, Ticket $ticket): self + { + return new self([ + $command->addressee, + ...$ticket->getCurrentAddressee(), + ]); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php b/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php new file mode 100644 index 000000000..157e69698 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php @@ -0,0 +1,68 @@ +security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only user can add ticket comments.'); + } + + $command = $this->serializer->deserialize($request->getContent(), AddCommentCommand::class, 'json', ['groups' => 'write']); + + $errors = $this->validator->validate($command); + + if (count($errors) > 0) { + return new JsonResponse( + $this->serializer->serialize($errors, 'json'), + Response::HTTP_UNPROCESSABLE_ENTITY, + [], + true + ); + } + + $this->addCommentCommandHandler->handle($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => 'read']), + Response::HTTP_CREATED, + [], + true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php b/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php index 8a6d8dd32..a3bdf31c8 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php @@ -15,6 +15,7 @@ use Chill\TicketBundle\Action\Ticket\AssociateByPhonenumberCommand; use Chill\TicketBundle\Action\Ticket\Handler\AssociateByPhonenumberCommandHandler; use Chill\TicketBundle\Action\Ticket\CreateTicketCommand; use Chill\TicketBundle\Action\Ticket\Handler\CreateTicketCommandHandler; +use Chill\TicketBundle\Repository\TicketRepositoryInterface; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -31,7 +32,8 @@ final readonly class CreateTicketController private AssociateByPhonenumberCommandHandler $associateByPhonenumberCommandHandler, private Security $security, private UrlGeneratorInterface $urlGenerator, - private EntityManagerInterface $entityManager + private EntityManagerInterface $entityManager, + private TicketRepositoryInterface $ticketRepository, ) {} #[Route('{_locale}/ticket/ticket/create')] @@ -41,6 +43,14 @@ final readonly class CreateTicketController throw new AccessDeniedHttpException('Only users are allowed to create tickets.'); } + if ('' !== $extId = $request->query->get('extId', '')) { + if (null !== $ticket = $this->ticketRepository->findOneByExternalRef($extId)) { + return new RedirectResponse( + $this->urlGenerator->generate('chill_ticket_ticket_edit', ['id' => $ticket->getId()]) + ); + } + } + $createCommand = new CreateTicketCommand($request->query->get('extId', '')); $ticket = $this->createTicketCommandHandler->__invoke($createCommand); diff --git a/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php b/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php index d6f1acf94..8af2773e8 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php @@ -59,6 +59,11 @@ final readonly class ReplaceMotiveController $this->entityManager->flush(); - return new JsonResponse(null, Response::HTTP_CREATED); + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => 'read']), + Response::HTTP_CREATED, + [], + true + ); } } diff --git a/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php b/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php new file mode 100644 index 000000000..9fa6dc366 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php @@ -0,0 +1,85 @@ +security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users can set addressees.'); + } + + $command = $this->serializer->deserialize($request->getContent(), SetAddresseesCommand::class, 'json', [AbstractNormalizer::GROUPS => ['read']]); + + return $this->registerSetAddressees($command, $ticket); + } + + #[Route('/api/1.0/ticket/{id}/addressee/add', methods: ['POST'])] + public function addAddressee(Ticket $ticket, Request $request): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users can add addressees.'); + } + + $command = $this->serializer->deserialize($request->getContent(), AddAddresseeCommand::class, 'json', [AbstractNormalizer::GROUPS => ['read']]); + + return $this->registerSetAddressees(SetAddresseesCommand::fromAddAddresseeCommand($command, $ticket), $ticket); + } + + private function registerSetAddressees(SetAddresseesCommand $command, Ticket $ticket): Response + { + if (0 < count($errors = $this->validator->validate($command))) { + return new JsonResponse( + $this->serializer->serialize($errors, 'json'), + Response::HTTP_UNPROCESSABLE_ENTITY, + [], + true + ); + } + + $this->addressesCommandHandler->handle($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => 'read']), + Response::HTTP_OK, + [], + true, + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php index 3939a24c3..2717df2a5 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php @@ -18,9 +18,11 @@ use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\UserGroup; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; #[ORM\Entity()] #[ORM\Table(name: 'addressee_history', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_addressee_history' => AddresseeHistory::class])] class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface { use TrackCreationTrait; @@ -29,6 +31,7 @@ class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface #[ORM\Id] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] #[ORM\GeneratedValue(strategy: 'AUTO')] + #[Serializer\Groups(['read'])] private ?int $id = null; #[ORM\ManyToOne(targetEntity: User::class)] @@ -39,11 +42,19 @@ class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface #[ORM\JoinColumn(nullable: true)] private ?UserGroup $addresseeGroup = null; + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => 'null'])] + #[Serializer\Groups(['read'])] private ?\DateTimeImmutable $endDate = null; + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: true)] + #[Serializer\Groups(['read'])] + private ?User $removedBy = null; + public function __construct( User|UserGroup $addressee, #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)] + #[Serializer\Groups(['read'])] private \DateTimeImmutable $startDate, #[ORM\ManyToOne(targetEntity: Ticket::class)] #[ORM\JoinColumn(nullable: false)] @@ -54,8 +65,11 @@ class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface } else { $this->addresseeGroup = $addressee; } + + $this->ticket->addAddresseeHistory($this); } + #[Serializer\Groups(['read'])] public function getAddressee(): UserGroup|User { if (null !== $this->addresseeGroup) { @@ -94,4 +108,23 @@ class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface { return $this->ticket; } + + public function getRemovedBy(): ?User + { + return $this->removedBy; + } + + public function setRemovedBy(?User $removedBy): self + { + $this->removedBy = $removedBy; + + return $this; + } + + public function setEndDate(?\DateTimeImmutable $endDate): self + { + $this->endDate = $endDate; + + return $this; + } } diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Comment.php b/src/Bundle/ChillTicketBundle/src/Entity/Comment.php index d62e6d8b4..8e11ff489 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Comment.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Comment.php @@ -17,9 +17,11 @@ use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping\JoinColumn; +use Symfony\Component\Serializer\Annotation as Serializer; #[ORM\Entity()] #[ORM\Table(name: 'comment', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_comment' => Comment::class])] class Comment implements TrackCreationInterface, TrackUpdateInterface { use TrackCreationTrait; @@ -28,15 +30,19 @@ class Comment implements TrackCreationInterface, TrackUpdateInterface #[ORM\Id] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] #[ORM\GeneratedValue(strategy: 'AUTO')] + #[Serializer\Groups(['read'])] private ?int $id = null; public function __construct( + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] + #[Serializer\Groups(['read'])] + private string $content, #[ORM\ManyToOne(targetEntity: Ticket::class, inversedBy: 'comments')] #[JoinColumn(nullable: false)] private Ticket $ticket, - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] - private string $content = '' - ) {} + ) { + $ticket->addComment($this); + } public function getId(): ?int { diff --git a/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php index 57a9809e2..927ea978f 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php @@ -84,4 +84,11 @@ class PersonHistory implements TrackCreationInterface { return $this->removedBy; } + + public function setEndDate(?\DateTimeImmutable $endDate): self + { + $this->endDate = $endDate; + + return $this; + } } diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php index de369379b..04cfe828b 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php @@ -104,21 +104,40 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface ->getValues(); } + /** + * @internal use @see{Comment::__construct} instead + */ + public function addComment(Comment $comment): void + { + $this->comments->add($comment); + } + /** * Add a PersonHistory. * - * This method should not be used, use @see{PersonHistory::__construct()} insted. + * @internal use @see{PersonHistory::__construct} instead */ public function addPersonHistory(PersonHistory $personHistory): void { $this->personHistories->add($personHistory); } + /** + * @internal use @see{MotiveHistory::__construct} instead + */ public function addMotiveHistory(MotiveHistory $motiveHistory): void { $this->motiveHistories->add($motiveHistory); } + /** + * @internal use @see{AddresseHistory::__construct} instead + */ + public function addAddresseeHistory(AddresseeHistory $addresseeHistory): void + { + $this->addresseeHistory->add($addresseeHistory); + } + /** * @return list */ @@ -184,4 +203,12 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface { return $this->personHistories; } + + /** + * @return ReadableCollection + */ + public function getAddresseeHistories(): ReadableCollection + { + return $this->addresseeHistory; + } } diff --git a/src/Bundle/ChillTicketBundle/src/Repository/TicketRepository.php b/src/Bundle/ChillTicketBundle/src/Repository/TicketRepository.php new file mode 100644 index 000000000..d4301585a --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Repository/TicketRepository.php @@ -0,0 +1,56 @@ +repository = $objectManager->getRepository($this->getClassName()); + } + + public function find($id): ?Ticket + { + return $this->repository->find($id); + } + + public function findAll(): array + { + return $this->repository->findAll(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?Ticket + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName() + { + return Ticket::class; + } + + public function findOneByExternalRef(string $extId): ?Ticket + { + return $this->repository->findOneBy(['externalRef' => $extId]); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Repository/TicketRepositoryInterface.php b/src/Bundle/ChillTicketBundle/src/Repository/TicketRepositoryInterface.php new file mode 100644 index 000000000..0b2cb4d66 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Repository/TicketRepositoryInterface.php @@ -0,0 +1,23 @@ + + */ +interface TicketRepositoryInterface extends ObjectRepository +{ + public function findOneByExternalRef(string $extId): ?Ticket; +} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts index b0c6a37df..0bcd1ecbb 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts @@ -1,4 +1,10 @@ -import {DateTime, TranslatableString, User} from "../../../../ChillMainBundle/Resources/public/types"; +import { + DateTime, + TranslatableString, + User, + UserGroup, + UserGroupOrUser +} from "../../../../ChillMainBundle/Resources/public/types"; import {Person} from "../../../../ChillPersonBundle/Resources/public/types"; export interface Motive { @@ -36,17 +42,43 @@ interface MotiveHistory { createdAt: DateTime|null, } -interface AddPersonEvent extends TicketHistory<"add_person", PersonHistory> {}; -interface SetMotiveEvent extends TicketHistory<"set_motive", MotiveHistory> {}; +interface Comment { + type: "ticket_comment", + id: number, + content: string, + createdBy: User|null, + createdAt: DateTime|null, + updatedBy: User|null, + updatedAt: DateTime|null, +} -type TicketHistoryLine = AddPersonEvent | SetMotiveEvent; +interface AddresseeHistory { + type: "ticket_addressee_history", + id: number, + startDate: DateTime|null, + addressee: UserGroupOrUser, + endDate: DateTime|null, + removedBy: User|null, + createdBy: User|null, + createdAt: DateTime|null, + updatedBy: User|null, + updatedAt: DateTime|null, +} + +interface AddPersonEvent extends TicketHistory<"add_person", PersonHistory> {}; +interface AddCommentEvent extends TicketHistory<"add_comment", Comment> {}; +interface SetMotiveEvent extends TicketHistory<"set_motive", MotiveHistory> {}; +interface AddAddressee extends TicketHistory<"add_addressee", AddresseeHistory> {}; + +type TicketHistoryLine = AddPersonEvent | AddCommentEvent | SetMotiveEvent | AddAddressee; export interface Ticket { - type: "ticket_ticket" - id: number - externalRef: string - currentPersons: Person[] - currentMotive: null|Motive + type: "ticket_ticket", + id: number, + externalRef: string, + currentAddressees: UserGroupOrUser[], + currentPersons: Person[], + currentMotive: null|Motive, history: TicketHistoryLine[], } diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/SetAddresseesCommandDenormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/SetAddresseesCommandDenormalizer.php new file mode 100644 index 000000000..d97ca5d92 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/SetAddresseesCommandDenormalizer.php @@ -0,0 +1,56 @@ + $this->denormalizer->denormalize($address, UserGroup::class, $format, $context), + 'user' => $this->denormalizer->denormalize($address, User::class, $format, $context), + default => throw new UnexpectedValueException('the type is not set or not supported') + }; + } + + return new SetAddresseesCommand($addresses); + } + + public function supportsDenormalization($data, string $type, ?string $format = null) + { + return SetAddresseesCommand::class === $type && 'json' === $format; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php index c69ac5a7f..f7aff602b 100644 --- a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php @@ -11,6 +11,8 @@ declare(strict_types=1); namespace Chill\TicketBundle\Serializer\Normalizer; +use Chill\TicketBundle\Entity\AddresseeHistory; +use Chill\TicketBundle\Entity\Comment; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\PersonHistory; use Chill\TicketBundle\Entity\Ticket; @@ -69,6 +71,33 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte ], $ticket->getPersonHistories()->toArray(), ), + ...array_map( + fn (Comment $comment) => [ + 'event_type' => 'add_comment', + 'at' => $comment->getCreatedAt(), + 'by' => $comment->getCreatedBy(), + 'data' => $comment, + ], + $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() + ), ]; usort( diff --git a/src/Bundle/ChillTicketBundle/src/config/services.yaml b/src/Bundle/ChillTicketBundle/src/config/services.yaml index 490e215c4..4b662ecdc 100644 --- a/src/Bundle/ChillTicketBundle/src/config/services.yaml +++ b/src/Bundle/ChillTicketBundle/src/config/services.yaml @@ -3,16 +3,21 @@ services: autoconfigure: true autowire: true + Chill\TicketBundle\Action\Ticket\Handler\: + resource: '../Action/Ticket/Handler/' + Chill\TicketBundle\Controller\: resource: '../Controller/' tags: - controller.service_arguments - Chill\TicketBundle\Action\Ticket\Handler\: - resource: '../Action/Ticket/Handler/' + Chill\TicketBundle\Repository\: + resource: '../Repository/' Chill\TicketBundle\Serializer\: resource: '../Serializer/' - Chill\TicketBundle\DataFixtures\: - resource: '../DataFixtures/' +when@dev: + services: + Chill\TicketBundle\DataFixtures\: + resource: '../DataFixtures/' diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20240423212824.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20240423212824.php new file mode 100644 index 000000000..3c025d763 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20240423212824.php @@ -0,0 +1,40 @@ +addSql('ALTER TABLE chill_ticket.addressee_history ADD endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT null'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history ADD removedBy_id INT DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN chill_ticket.addressee_history.endDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBDB8346CCF FOREIGN KEY (removedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_434EBDBDB8346CCF ON chill_ticket.addressee_history (removedBy_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBDB8346CCF'); + $this->addSql('DROP INDEX chill_ticket.IDX_434EBDBDB8346CCF'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP endDate'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP removedBy_id'); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/AddCommentCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/AddCommentCommandHandlerTest.php new file mode 100644 index 000000000..5ff18b436 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/AddCommentCommandHandlerTest.php @@ -0,0 +1,52 @@ +buildCommand(); + + $ticket = new Ticket(); + $command = new AddCommentCommand(content: 'test'); + + $handler->handle($ticket, $command); + + self::assertCount(1, $ticket->getComments()); + self::assertEquals('test', $ticket->getComments()[0]->getContent()); + } + + private function buildCommand(): AddCommentCommandHandler + { + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::type(Comment::class))->shouldBeCalled(); + + return new AddCommentCommandHandler($entityManager->reveal()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php new file mode 100644 index 000000000..cef1ae153 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php @@ -0,0 +1,122 @@ +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(); + + $handler = $this->buildHandler($entityManager->reveal()); + + $handler->handle($ticket, $command); + + self::assertCount(2, $ticket->getCurrentAddressee()); + } + + public function testHandleExistingUserIsNotRemovedNorCreatingDouble(): void + { + $ticket = new Ticket(); + $user = new User(); + $history = new AddresseeHistory($user, new \DateTimeImmutable('1 month ago'), $ticket); + $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(); + + $handler = $this->buildHandler($entityManager->reveal()); + + $handler->handle($ticket, $command); + + self::assertNull($history->getEndDate()); + self::assertCount(1, $ticket->getCurrentAddressee()); + } + + public function testHandleRemoveExistingAddressee(): void + { + $ticket = new Ticket(); + $user = new User(); + $group = new UserGroup(); + $history = new AddresseeHistory($user, new \DateTimeImmutable('1 month ago'), $ticket); + $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(); + + $handler = $this->buildHandler($entityManager->reveal()); + + $handler->handle($ticket, $command); + + self::assertNotNull($history->getEndDate()); + self::assertContains($group, $ticket->getCurrentAddressee()); + } + + public function testAddingDoublingAddresseeDoesNotCreateDoubleHistories(): void + { + $ticket = new Ticket(); + $group = new UserGroup(); + $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(); + + $handler = $this->buildHandler($entityManager->reveal()); + + $handler->handle($ticket, $command); + + self::assertCount(1, $ticket->getCurrentAddressee()); + } + + private function buildHandler(EntityManagerInterface $entityManager): SetAddresseesCommandHandler + { + $security = $this->prophesize(Security::class); + $security->getUser()->willReturn(new User()); + + return new SetAddresseesCommandHandler(new MockClock(), $entityManager, $security->reveal()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php new file mode 100644 index 000000000..2826c360d --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php @@ -0,0 +1,104 @@ +validator = self::getContainer()->get(ValidatorInterface::class); + $this->serializer = self::getContainer()->get(SerializerInterface::class); + } + + public function testAddComment(): void + { + $controller = $this->buildController(willFlush: true); + + $ticket = new Ticket(); + $request = new Request(content: <<<'JSON' + {"content": "test"} + JSON); + + $response = $controller->__invoke($ticket, $request); + + self::assertEquals(201, $response->getStatusCode()); + } + + public function testAddCommentWithBlankContent(): void + { + $controller = $this->buildController(willFlush: false); + + $ticket = new Ticket(); + $request = new Request(content: <<<'JSON' + {"content": ""} + JSON); + + $response = $controller->__invoke($ticket, $request); + + self::assertEquals(422, $response->getStatusCode()); + + $request = new Request(content: <<<'JSON' + {"content": null} + JSON); + + $response = $controller->__invoke($ticket, $request); + + self::assertEquals(422, $response->getStatusCode()); + } + + private function buildController(bool $willFlush): AddCommentController + { + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + + if ($willFlush) { + $entityManager->persist(Argument::type(Comment::class))->shouldBeCalled(); + $entityManager->flush()->shouldBeCalled(); + } + + $commandHandler = new AddCommentCommandHandler($entityManager->reveal()); + + return new AddCommentController( + $security->reveal(), + $this->serializer, + $this->validator, + $commandHandler, + $entityManager->reveal(), + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php new file mode 100644 index 000000000..af0d48e87 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php @@ -0,0 +1,212 @@ +serializer = self::getContainer()->get(SerializerInterface::class); + } + + /** + * @dataProvider getContentData + */ + public function testSetAddresseesWithValidData(array $bodyAsArray): void + { + $controller = $this->buildController(true, true); + $request = new Request(content: json_encode(['addressees' => $bodyAsArray], JSON_THROW_ON_ERROR, 512)); + $ticket = new Ticket(); + + $response = $controller->setAddressees($ticket, $request); + + self::assertEquals(200, $response->getStatusCode()); + + $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($asArray); + self::assertArrayHasKey('type', $asArray); + self::assertEquals('ticket_ticket', $asArray['type']); + } + + /** + * @dataProvider getContentData + */ + public function testSetAddresseesWithInvalidData(array $bodyAsArray): void + { + $controller = $this->buildController(false, false); + $request = new Request(content: json_encode(['addressees' => $bodyAsArray], JSON_THROW_ON_ERROR, 512)); + $ticket = new Ticket(); + + $response = $controller->setAddressees($ticket, $request); + + self::assertEquals(422, $response->getStatusCode()); + + $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($asArray); + self::arrayHasKey('violations', $asArray); + self::assertGreaterThan(0, count($asArray['violations'])); + } + + /** + * @dataProvider getContentDataUnique + */ + public function testAddAddresseeWithValidData(array $bodyAsArray): void + { + $controller = $this->buildController(true, true); + $request = new Request(content: json_encode(['addressee' => $bodyAsArray], JSON_THROW_ON_ERROR, 512)); + $ticket = new Ticket(); + + $response = $controller->addAddressee($ticket, $request); + + self::assertEquals(200, $response->getStatusCode()); + + $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($asArray); + self::assertArrayHasKey('type', $asArray); + self::assertEquals('ticket_ticket', $asArray['type']); + } + + /** + * @throws \JsonException + * + * @dataProvider getContentDataUnique + */ + public function testAddAddresseeWithInvalidData(array $bodyAsArray): void + { + $controller = $this->buildController(false, false); + $request = new Request(content: json_encode(['addressee' => $bodyAsArray], JSON_THROW_ON_ERROR, 512)); + $ticket = new Ticket(); + + $response = $controller->addAddressee($ticket, $request); + + self::assertEquals(422, $response->getStatusCode()); + + $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($asArray); + self::arrayHasKey('violations', $asArray); + self::assertGreaterThan(0, count($asArray['violations'])); + } + + public static function getContentDataUnique(): iterable + { + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + + $userGroup = $entityManager->createQuery('SELECT ug FROM '.UserGroup::class.' ug ') + ->setMaxResults(1)->getOneOrNullResult(); + + if (null === $userGroup) { + throw new \RuntimeException('User group not existing in database'); + } + + $user = $entityManager->createQuery('SELECT u FROM '.User::class.' u') + ->setMaxResults(1)->getOneOrNullResult(); + + if (null === $user) { + throw new \RuntimeException('User not existing in database'); + } + + self::ensureKernelShutdown(); + + yield [['type' => 'user', 'id' => $user->getId()]]; + yield [['type' => 'user_group', 'id' => $userGroup->getId()]]; + } + + public static function getContentData(): iterable + { + self::bootKernel(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + + $userGroup = $entityManager->createQuery('SELECT ug FROM '.UserGroup::class.' ug ') + ->setMaxResults(1)->getOneOrNullResult(); + + if (null === $userGroup) { + throw new \RuntimeException('User group not existing in database'); + } + + $user = $entityManager->createQuery('SELECT u FROM '.User::class.' u') + ->setMaxResults(1)->getOneOrNullResult(); + + if (null === $user) { + throw new \RuntimeException('User not existing in database'); + } + + self::ensureKernelShutdown(); + + yield [[['type' => 'user', 'id' => $user->getId()]]]; + yield [[['type' => 'user', 'id' => $user->getId()], ['type' => 'user_group', 'id' => $userGroup->getId()]]]; + yield [[['type' => 'user_group', 'id' => $userGroup->getId()]]]; + } + + private function buildController(bool $willSave, bool $isValid): SetAddresseesController + { + $user = new User(); + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + $security->getUser()->willReturn($user); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + + if ($willSave) { + $entityManager->flush()->shouldBeCalled(); + $entityManager->persist(Argument::type(AddresseeHistory::class))->shouldBeCalled(); + } + + $validator = $this->prophesize(ValidatorInterface::class); + + if ($isValid) { + $validator->validate(Argument::type(SetAddresseesCommand::class))->willReturn(new ConstraintViolationList([])); + } else { + $validator->validate(Argument::type(SetAddresseesCommand::class))->willReturn( + new ConstraintViolationList([ + new ConstraintViolation('Fake constraint', 'fake message template', [], [], 'addresses', []), + ]) + ); + } + + return new SetAddresseesController( + $security->reveal(), + $entityManager->reveal(), + $this->serializer, + new SetAddresseesCommandHandler(new MockClock(), $entityManager->reveal(), $security->reveal()), + $validator->reveal() + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php index 6f710f1aa..f0efe5493 100644 --- a/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php @@ -11,7 +11,10 @@ declare(strict_types=1); namespace Chill\TicketBundle\Tests\Entity; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Entity\AddresseeHistory; use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\PersonHistory; @@ -57,5 +60,33 @@ class TicketTest extends KernelTestCase self::assertCount(1, $ticket->getPersons()); self::assertSame($person, $ticket->getPersons()[0]); + + $history->setEndDate(new \DateTimeImmutable('now')); + + self::assertCount(0, $ticket->getPersons()); + } + + public function testGetAddresse(): void + { + $ticket = new Ticket(); + $user = new User(); + $group = new UserGroup(); + + self::assertEquals([], $ticket->getCurrentAddressee()); + + $history = new AddresseeHistory($user, new \DateTimeImmutable('now'), $ticket); + + self::assertCount(1, $ticket->getCurrentAddressee()); + self::assertSame($user, $ticket->getCurrentAddressee()[0]); + + $history2 = new AddresseeHistory($group, new \DateTimeImmutable('now'), $ticket); + + self::assertCount(2, $ticket->getCurrentAddressee()); + self::assertContains($group, $ticket->getCurrentAddressee()); + + $history->setEndDate(new \DateTimeImmutable('now')); + + self::assertCount(1, $ticket->getCurrentAddressee()); + self::assertSame($group, $ticket->getCurrentAddressee()[0]); } } diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/SetAddresseesCommandDenormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/SetAddresseesCommandDenormalizerTest.php new file mode 100644 index 000000000..a4b9bbf49 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/SetAddresseesCommandDenormalizerTest.php @@ -0,0 +1,66 @@ +supportsDenormalization('', SetAddresseesCommand::class, 'json')); + self::assertFalse($denormalizer->supportsDenormalization('', stdClass::class, 'json')); + } + + public function testDenormalize() + { + $denormalizer = $this->buildDenormalizer(); + + $actual = $denormalizer->denormalize(['addressees' => [['type' => 'user'], ['type' => 'user_group']]], SetAddresseesCommand::class, 'json'); + + self::assertInstanceOf(SetAddresseesCommand::class, $actual); + self::assertIsArray($actual->addressees); + self::assertCount(2, $actual->addressees); + self::assertInstanceOf(User::class, $actual->addressees[0]); + self::assertInstanceOf(UserGroup::class, $actual->addressees[1]); + } + + private function buildDenormalizer(): SetAddresseesCommandDenormalizer + { + $normalizer = $this->prophesize(DenormalizerInterface::class); + $normalizer->denormalize(Argument::any(), User::class, 'json', Argument::any()) + ->willReturn(new User()); + $normalizer->denormalize(Argument::any(), UserGroup::class, 'json', Argument::any()) + ->willReturn(new UserGroup()); + + $denormalizer = new SetAddresseesCommandDenormalizer(); + $denormalizer->setDenormalizer($normalizer->reveal()); + + return $denormalizer; + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php index e876341cd..415fc15fb 100644 --- a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php @@ -11,7 +11,11 @@ declare(strict_types=1); namespace Chill\TicketBundle\Tests\Serializer\Normalizer; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Entity\AddresseeHistory; +use Chill\TicketBundle\Entity\Comment; use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\MotiveHistory; use Chill\TicketBundle\Entity\PersonHistory; @@ -93,11 +97,22 @@ class TicketNormalizerTest extends KernelTestCase // datetime $normalizer->normalize(Argument::type(\DateTimeImmutable::class), 'json', Argument::type('array')) ->will(function ($args) { return $args[0]->getTimestamp(); }); + // user + $normalizer->normalize(Argument::type(User::class), 'json', Argument::type('array')) + ->willReturn(['user']); + // motive $normalizer->normalize(Argument::type(Motive::class), 'json', Argument::type('array'))->willReturn(['type' => 'motive', 'id' => 0]); + // person history $normalizer->normalize(Argument::type(PersonHistory::class), 'json', Argument::type('array')) ->willReturn(['personHistory']); + // motive history $normalizer->normalize(Argument::type(MotiveHistory::class), 'json', Argument::type('array')) ->willReturn(['motiveHistory']); + $normalizer->normalize(Argument::type(Comment::class), 'json', Argument::type('array')) + ->willReturn(['comment']); + $normalizer->normalize(Argument::type(AddresseeHistory::class), 'json', Argument::type('array')) + ->willReturn(['addresseeHistory']); + // null values $normalizer->normalize(null, 'json', Argument::type('array'))->willReturn(null); $ticketNormalizer = new TicketNormalizer(); @@ -109,6 +124,7 @@ class TicketNormalizerTest extends KernelTestCase public static function provideTickets(): iterable { yield [ + // this a nearly empty ticket new Ticket(), [ 'type' => 'ticket_ticket', @@ -122,10 +138,17 @@ class TicketNormalizerTest extends KernelTestCase ], ]; + // ticket with more features $ticket = new Ticket(); $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')); + $comment = new Comment('blabla test', $ticket); + $comment->setCreatedAt(new \DateTimeImmutable('2024-04-01T12:04:00')); + $comment->setCreatedBy(new User()); + $addresseeHistory = new AddresseeHistory(new User(), new \DateTimeImmutable('2024-04-01T12:05:00'), $ticket); + $addresseeHistory->setEndDate(new \DateTimeImmutable('2024-04-01T12:06:00')); + new AddresseeHistory(new UserGroup(), new \DateTimeImmutable('2024-04-01T12:07:00'), $ticket); yield [ $ticket, @@ -134,10 +157,17 @@ class TicketNormalizerTest extends KernelTestCase 'id' => null, 'externalRef' => '2134', 'currentPersons' => ['embedded'], - 'currentAddressees' => [], + 'currentAddressees' => ['embedded'], 'currentInputs' => [], 'currentMotive' => ['type' => 'motive', 'id' => 0], - 'history' => [['event_type' => 'add_person'], ['event_type' => 'set_motive']], + 'history' => [ + ['event_type' => 'add_person'], + ['event_type' => 'set_motive'], + ['event_type' => 'add_comment'], + ['event_type' => 'add_addressee'], + ['event_type' => 'remove_addressee'], + ['event_type' => 'add_addressee'], + ], ], ]; }