diff --git a/src/Bundle/ChillMainBundle/chill.api.specs.yaml b/src/Bundle/ChillMainBundle/chill.api.specs.yaml index fec36312d..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: @@ -921,6 +957,6 @@ paths: schema: type: array items: - $ref: '#/components/schemas/NewsItem' + $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/ChillTicketBundle/chill.api.specs.yaml b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml index 86049cc24..232982f01 100644 --- a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml @@ -93,3 +93,39 @@ paths: 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: + addresses: + type: array + items: + 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/Handler/SetAddresseesCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php new file mode 100644 index 000000000..25ac97e5d --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php @@ -0,0 +1,50 @@ +getAddresseeHistories() as $addressHistory) { + if (null !== $addressHistory->getEndDate()) { + continue; + } + + if (!in_array($addressHistory->getAddressee(), $command->addressees, true)) { + $addressHistory->setEndDate($this->clock->now()); + } + } + + // 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..824e2506f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetAddresseesCommand.php @@ -0,0 +1,31 @@ + + */ + #[UserGroupDoNotExclude] + #[GreaterThan(0)] + #[Groups(['read'])] + public array $addressees + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php b/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php new file mode 100644 index 000000000..add8d480a --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php @@ -0,0 +1,67 @@ +security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users can set addressees.'); + } + + $command = $this->serializer->deserialize($request->getContent(), SetAddresseesCommand::class, 'json', [AbstractNormalizer::GROUPS => ['read']]); + + 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..b3718a9ca 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php @@ -54,6 +54,8 @@ class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface } else { $this->addresseeGroup = $addressee; } + + $this->ticket->addAddresseeHistory($this); } public function getAddressee(): UserGroup|User @@ -94,4 +96,11 @@ class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface { return $this->ticket; } + + public function setEndDate(?\DateTimeImmutable $endDate): self + { + $this->endDate = $endDate; + + return $this; + } } 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 cd49d7377..04cfe828b 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php @@ -130,6 +130,14 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface $this->motiveHistories->add($motiveHistory); } + /** + * @internal use @see{AddresseHistory::__construct} instead + */ + public function addAddresseeHistory(AddresseeHistory $addresseeHistory): void + { + $this->addresseeHistory->add($addresseeHistory); + } + /** * @return list */ @@ -195,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/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/config/services.yaml b/src/Bundle/ChillTicketBundle/src/config/services.yaml index 490e215c4..006fa5cb6 100644 --- a/src/Bundle/ChillTicketBundle/src/config/services.yaml +++ b/src/Bundle/ChillTicketBundle/src/config/services.yaml @@ -14,5 +14,7 @@ services: Chill\TicketBundle\Serializer\: resource: '../Serializer/' - Chill\TicketBundle\DataFixtures\: - resource: '../DataFixtures/' +when@dev: + services: + Chill\TicketBundle\DataFixtures\: + resource: '../DataFixtures/' 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..efb545bac --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php @@ -0,0 +1,118 @@ +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 + { + return new SetAddresseesCommandHandler(new MockClock(), $entityManager); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php new file mode 100644 index 000000000..ed842f742 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php @@ -0,0 +1,146 @@ +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'])); + } + + 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 + { + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $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()), + $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; + } +}