From 3400656d7c6895c3c476209b1f16011af8f60ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 11 Jul 2025 12:56:19 +0000 Subject: [PATCH] Tickets: edit comments and mark them as deleted --- package.json | 2 +- .../ChillTicketBundle/chill.api.specs.yaml | 100 +++++++++++++ .../UpdateCommentContentCommandHandler.php | 26 ++++ ...eCommentContentCommandHandlerInterface.php | 20 +++ ...dateCommentDeletedStatusCommandHandler.php | 26 ++++ ...ntDeletedStatusCommandHandlerInterface.php | 20 +++ .../Comment/UpdateCommentContentCommand.php | 30 ++++ .../UpdateCommentDeletedStatusCommand.php | 27 ++++ .../Handler/AddCommentCommandHandler.php | 4 +- .../src/Controller/AddCommentController.php | 7 +- .../Controller/UpdateCommentController.php | 69 +++++++++ .../UpdateCommentDeletedStatusController.php | 66 +++++++++ .../ChillTicketBundle/src/Entity/Comment.php | 19 +++ .../src/Security/Voter/CommentVoter.php | 50 +++++++ .../src/Security/Voter/TicketVoter.php | 37 +++++ .../Normalizer/TicketNormalizer.php | 6 +- .../src/config/services.yaml | 6 + .../src/migrations/Version20250711115128.php | 33 +++++ ...UpdateCommentContentCommandHandlerTest.php | 50 +++++++ ...CommentDeletedStatusCommandHandlerTest.php | 55 +++++++ .../Controller/AddCommentControllerTest.php | 2 + .../Controller/TicketListControllerTest.php | 0 .../UpdateCommentControllerTest.php | 134 ++++++++++++++++++ ...dateCommentDeletedStatusControllerTest.php | 114 +++++++++++++++ .../Normalizer/TicketNormalizerTest.php | 49 ++++++- 25 files changed, 945 insertions(+), 7 deletions(-) create mode 100644 src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandler.php create mode 100644 src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandlerInterface.php create mode 100644 src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandler.php create mode 100644 src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerInterface.php create mode 100644 src/Bundle/ChillTicketBundle/src/Action/Comment/UpdateCommentContentCommand.php create mode 100644 src/Bundle/ChillTicketBundle/src/Action/Comment/UpdateCommentDeletedStatusCommand.php create mode 100644 src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentController.php create mode 100644 src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentDeletedStatusController.php create mode 100644 src/Bundle/ChillTicketBundle/src/Security/Voter/CommentVoter.php create mode 100644 src/Bundle/ChillTicketBundle/src/Security/Voter/TicketVoter.php create mode 100644 src/Bundle/ChillTicketBundle/src/migrations/Version20250711115128.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentContentCommandHandlerTest.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerTest.php rename src/Bundle/ChillTicketBundle/tests/{Chill/TicketBundle => }/Controller/TicketListControllerTest.php (100%) create mode 100644 src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentControllerTest.php create mode 100644 src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentDeletedStatusControllerTest.php diff --git a/package.json b/package.json index a7d959d7e..ccabc5c25 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "dev": "encore dev", "watch": "encore dev --watch", "build": "encore production --progress", - "specs-build": "yaml-merge src/Bundle/ChillMainBundle/chill.api.specs.yaml src/Bundle/ChillPersonBundle/chill.api.specs.yaml src/Bundle/ChillCalendarBundle/chill.api.specs.yaml src/Bundle/ChillThirdPartyBundle/chill.api.specs.yaml src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml> templates/api/specs.yaml", + "specs-build": "yaml-merge src/Bundle/ChillMainBundle/chill.api.specs.yaml src/Bundle/ChillPersonBundle/chill.api.specs.yaml src/Bundle/ChillCalendarBundle/chill.api.specs.yaml src/Bundle/ChillThirdPartyBundle/chill.api.specs.yaml src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml src/Bundle/ChillTicketBundle/chill.api.specs.yaml> templates/api/specs.yaml", "specs-validate": "swagger-cli validate templates/api/specs.yaml", "specs-create-dir": "mkdir -p templates/api", "specs": "yarn run specs-create-dir && yarn run specs-build && yarn run specs-validate", diff --git a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml index 9b579fbcc..da3de8a88 100644 --- a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml @@ -1,5 +1,10 @@ components: schemas: + TicketComment: + type: object + properties: + id: + type: integer TicketSimple: type: object properties: @@ -162,6 +167,101 @@ paths: description: "ACCEPTED" 422: description: "UNPROCESSABLE ENTITY" + /1.0/ticket/comment/{id}/edit: + post: + tags: + - ticket + summary: Edit the comment' content + parameters: + - name: id + in: path + required: true + description: The comment's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + content: + type: string + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: '#/components/schemas/TicketComment' + 403: + description: "Unauthorized" + + /1.0/ticket/comment/{id}/delete: + post: + tags: + - ticket + summary: Soft-delete a comment within a ticket + description: | + This will soft delete a comment within a ticket. + + Only the author of the comment is allowed to edit the comment. + + If a comment is deleted, it can be restored by using the "restore" call. + + The method is idempotent: it will have no effect on an already deleted comment. + parameters: + - name: id + in: path + required: true + description: The comment's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: '#/components/schemas/TicketComment' + 403: + description: "Unauthorized" + + /1.0/ticket/comment/{id}/restore: + post: + tags: + - ticket + summary: Restore a comment within a ticket + description: | + This will restore a comment of a ticket. + + Only the author of the comment is allowed to restore the comment. + + If the comment is not deleted, this method has no effect. + + parameters: + - name: id + in: path + required: true + description: The comment's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: '#/components/schemas/TicketComment' + 403: + description: "Unauthorized" /1.0/ticket/{id}/persons/set: post: diff --git a/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandler.php new file mode 100644 index 000000000..a9a44070c --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandler.php @@ -0,0 +1,26 @@ +setContent($command->content); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandlerInterface.php b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandlerInterface.php new file mode 100644 index 000000000..b57838ad2 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandlerInterface.php @@ -0,0 +1,20 @@ +setDeleted($command->delete); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerInterface.php b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerInterface.php new file mode 100644 index 000000000..7136c2aad --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerInterface.php @@ -0,0 +1,20 @@ +content, $ticket); $this->entityManager->persist($comment); + + return $comment; } } diff --git a/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php b/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php index 157e69698..08ff93453 100644 --- a/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php +++ b/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php @@ -14,6 +14,7 @@ namespace Chill\TicketBundle\Controller; use Chill\TicketBundle\Action\Ticket\AddCommentCommand; use Chill\TicketBundle\Action\Ticket\Handler\AddCommentCommandHandler; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\CommentVoter; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -54,7 +55,11 @@ final readonly class AddCommentController ); } - $this->addCommentCommandHandler->handle($ticket, $command); + $comment = $this->addCommentCommandHandler->handle($ticket, $command); + + if (!$this->security->isGranted(CommentVoter::CREATE, $comment)) { + throw new AccessDeniedHttpException('You are not allowed to add comments to this ticket.'); + } $this->entityManager->flush(); diff --git a/src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentController.php b/src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentController.php new file mode 100644 index 000000000..5e35c534d --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentController.php @@ -0,0 +1,69 @@ +security->isGranted(CommentVoter::EDIT, $comment)) { + throw new AccessDeniedHttpException('You are not allowed to edit this comment.'); + } + + $command = $this->serializer->deserialize($request->getContent(), UpdateCommentContentCommand::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->updateCommentContentCommandHandler->handle($comment, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($comment, 'json', ['groups' => 'read']), + Response::HTTP_OK, + [], + true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentDeletedStatusController.php b/src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentDeletedStatusController.php new file mode 100644 index 000000000..dcdb93a81 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentDeletedStatusController.php @@ -0,0 +1,66 @@ +updateCommentDeletedStatus($comment, true); + } + + #[Route('/api/1.0/ticket/comment/{id}/restore', name: 'chill_ticket_comment_restore', methods: ['POST'])] + public function restoreComment(Comment $comment): Response + { + return $this->updateCommentDeletedStatus($comment, false); + } + + private function updateCommentDeletedStatus(Comment $comment, bool $delete): Response + { + if (!$this->security->isGranted(CommentVoter::EDIT, $comment)) { + throw new AccessDeniedHttpException('You are not allowed to edit this comment.'); + } + + $command = new UpdateCommentDeletedStatusCommand(delete: $delete); + + $this->updateCommentDeletedStatusCommandHandler->handle($comment, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($comment, 'json', ['groups' => 'read']), + Response::HTTP_OK, + [], + true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Comment.php b/src/Bundle/ChillTicketBundle/src/Entity/Comment.php index 8da2342ee..f33fab4e1 100644 --- a/src/Bundle/ChillTicketBundle/src/Entity/Comment.php +++ b/src/Bundle/ChillTicketBundle/src/Entity/Comment.php @@ -38,6 +38,10 @@ class Comment implements TrackCreationInterface, TrackUpdateInterface #[Serializer\Groups(['read'])] private ?int $id = null; + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => false])] + #[Serializer\Groups(['read'])] + private bool $deleted = false; + public function __construct( #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] #[Serializer\Groups(['read'])] @@ -63,4 +67,19 @@ class Comment implements TrackCreationInterface, TrackUpdateInterface { return $this->ticket; } + + public function setContent(string $content): void + { + $this->content = $content; + } + + public function isDeleted(): bool + { + return $this->deleted; + } + + public function setDeleted(bool $deleted): void + { + $this->deleted = $deleted; + } } diff --git a/src/Bundle/ChillTicketBundle/src/Security/Voter/CommentVoter.php b/src/Bundle/ChillTicketBundle/src/Security/Voter/CommentVoter.php new file mode 100644 index 000000000..03622aa4b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Security/Voter/CommentVoter.php @@ -0,0 +1,50 @@ + !$subject->isDeleted() || $subject->getCreatedBy() === $token->getUser(), + self::CREATE => $this->decisionManager->decide($token, [TicketVoter::WRITE], $subject->getTicket()), + self::EDIT => $token->getUser() === $subject->getCreatedBy(), + default => throw new \Symfony\Component\Security\Core\Exception\LogicException('Invalid attribute'), + }; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Security/Voter/TicketVoter.php b/src/Bundle/ChillTicketBundle/src/Security/Voter/TicketVoter.php new file mode 100644 index 000000000..284dd55a8 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Security/Voter/TicketVoter.php @@ -0,0 +1,37 @@ + $comment->getCreatedBy(), 'data' => $comment, ], - $ticket->getComments()->toArray(), + $ticket->getComments()->filter(fn (Comment $comment) => $this->security->isGranted(CommentVoter::READ, $comment))->toArray(), ), ...$this->addresseesStates($ticket), ...$this->personStates($ticket), diff --git a/src/Bundle/ChillTicketBundle/src/config/services.yaml b/src/Bundle/ChillTicketBundle/src/config/services.yaml index 7e3d6050b..659121f8c 100644 --- a/src/Bundle/ChillTicketBundle/src/config/services.yaml +++ b/src/Bundle/ChillTicketBundle/src/config/services.yaml @@ -6,6 +6,9 @@ services: Chill\TicketBundle\Action\Ticket\Handler\: resource: '../Action/Ticket/Handler/' + Chill\TicketBundle\Action\Comment\Handler\: + resource: '../Action/Comment/Handler/' + Chill\TicketBundle\Controller\: resource: '../Controller/' tags: @@ -14,6 +17,9 @@ services: Chill\TicketBundle\Repository\: resource: '../Repository/' + Chill\TicketBundle\Security\Voter\: + resource: '../Security/Voter/' + Chill\TicketBundle\Serializer\: resource: '../Serializer/' diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20250711115128.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20250711115128.php new file mode 100644 index 000000000..260d3a396 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20250711115128.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE chill_ticket.comment ADD deleted BOOLEAN DEFAULT false NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.comment DROP deleted'); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentContentCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentContentCommandHandlerTest.php new file mode 100644 index 000000000..53cb003c3 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentContentCommandHandlerTest.php @@ -0,0 +1,50 @@ +buildCommand(); + + $ticket = new Ticket(); + $comment = new Comment('initial content', $ticket); + $command = new UpdateCommentContentCommand(content: 'updated content'); + + $handler->handle($comment, $command); + + self::assertEquals('updated content', $comment->getContent()); + } + + private function buildCommand(): UpdateCommentContentCommandHandler + { + $entityManager = $this->prophesize(EntityManagerInterface::class); + + return new UpdateCommentContentCommandHandler($entityManager->reveal()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerTest.php new file mode 100644 index 000000000..a128abc7b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerTest.php @@ -0,0 +1,55 @@ +buildCommand(); + + $ticket = new Ticket(); + $comment = new Comment('content', $ticket); + + // Initially, the comment should not be deleted + self::assertFalse($comment->isDeleted()); + + // Test setting deleted to true + $command = new UpdateCommentDeletedStatusCommand(delete: true); + $handler->handle($comment, $command); + self::assertTrue($comment->isDeleted()); + + // Test setting deleted back to false + $command = new UpdateCommentDeletedStatusCommand(delete: false); + $handler->handle($comment, $command); + self::assertFalse($comment->isDeleted()); + } + + private function buildCommand(): UpdateCommentDeletedStatusCommandHandler + { + return new UpdateCommentDeletedStatusCommandHandler(); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php index 2826c360d..f954fd084 100644 --- a/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php @@ -15,6 +15,7 @@ use Chill\TicketBundle\Action\Ticket\Handler\AddCommentCommandHandler; use Chill\TicketBundle\Controller\AddCommentController; use Chill\TicketBundle\Entity\Comment; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\CommentVoter; use Doctrine\ORM\EntityManagerInterface; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -87,6 +88,7 @@ class AddCommentControllerTest extends KernelTestCase $entityManager = $this->prophesize(EntityManagerInterface::class); if ($willFlush) { + $security->isGranted(CommentVoter::CREATE, Argument::type(Comment::class))->willReturn(true); $entityManager->persist(Argument::type(Comment::class))->shouldBeCalled(); $entityManager->flush()->shouldBeCalled(); } diff --git a/src/Bundle/ChillTicketBundle/tests/Chill/TicketBundle/Controller/TicketListControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListControllerTest.php similarity index 100% rename from src/Bundle/ChillTicketBundle/tests/Chill/TicketBundle/Controller/TicketListControllerTest.php rename to src/Bundle/ChillTicketBundle/tests/Controller/TicketListControllerTest.php diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentControllerTest.php new file mode 100644 index 000000000..599051702 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentControllerTest.php @@ -0,0 +1,134 @@ +validator = self::getContainer()->get(ValidatorInterface::class); + $this->serializer = self::getContainer()->get(SerializerInterface::class); + } + + public function testUpdateComment(): void + { + $ticket = new Ticket(); + $comment = new Comment('initial content', $ticket); + $controller = $this->buildController(willFlush: true, isGranted: true, comment: $comment); + + $request = new Request(content: <<<'JSON' + {"content": "updated content"} + JSON); + + $response = $controller->__invoke($comment, $request); + + self::assertEquals(200, $response->getStatusCode()); + } + + public function testUpdateCommentWithBlankContent(): void + { + $ticket = new Ticket(); + $comment = new Comment('initial content', $ticket); + $controller = $this->buildController(willFlush: false, isGranted: true, comment: $comment); + + $request = new Request(content: <<<'JSON' + {"content": ""} + JSON); + + $response = $controller->__invoke($comment, $request); + + self::assertEquals(422, $response->getStatusCode()); + } + + public function testUpdateCommentWithNullContent(): void + { + $ticket = new Ticket(); + $comment = new Comment('initial content', $ticket); + $controller = $this->buildController(willFlush: false, isGranted: true, comment: $comment); + + $request = new Request(content: <<<'JSON' + {"content": null} + JSON); + + $response = $controller->__invoke($comment, $request); + + self::assertEquals(422, $response->getStatusCode()); + } + + public function testUpdateCommentWithoutAuthorization(): void + { + $ticket = new Ticket(); + $comment = new Comment('initial content', $ticket); + $controller = $this->buildController(willFlush: false, isGranted: false, comment: $comment); + + $request = new Request(content: <<<'JSON' + {"content": "updated content"} + JSON); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('You are not allowed to edit this comment.'); + + $controller->__invoke($comment, $request); + } + + private function buildController(bool $willFlush, bool $isGranted, Comment $comment): UpdateCommentController + { + $security = $this->prophesize(Security::class); + $security->isGranted(CommentVoter::EDIT, $comment)->willReturn($isGranted); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + + if ($willFlush) { + $entityManager->flush()->shouldBeCalled(); + } + + $commandHandler = $this->prophesize(UpdateCommentContentCommandHandlerInterface::class); + + if ($isGranted && $willFlush) { + $commandHandler->handle($comment, Argument::any())->shouldBeCalled(); + } + + return new UpdateCommentController( + $security->reveal(), + $this->serializer, + $this->validator, + $commandHandler->reveal(), + $entityManager->reveal(), + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentDeletedStatusControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentDeletedStatusControllerTest.php new file mode 100644 index 000000000..fb70db76d --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentDeletedStatusControllerTest.php @@ -0,0 +1,114 @@ +serializer = self::getContainer()->get(SerializerInterface::class); + } + + public function testDeleteComment(): void + { + $ticket = new Ticket(); + $comment = new Comment('content', $ticket); + $controller = $this->buildController(willFlush: true, isGranted: true, comment: $comment); + + $response = $controller->deleteComment($comment); + + self::assertEquals(200, $response->getStatusCode()); + } + + public function testRestoreComment(): void + { + $ticket = new Ticket(); + $comment = new Comment('content', $ticket); + $controller = $this->buildController(willFlush: true, isGranted: true, comment: $comment); + + $response = $controller->restoreComment($comment); + + self::assertEquals(200, $response->getStatusCode()); + } + + public function testDeleteCommentWithoutAuthorization(): void + { + $ticket = new Ticket(); + $comment = new Comment('content', $ticket); + $controller = $this->buildController(willFlush: false, isGranted: false, comment: $comment); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('You are not allowed to edit this comment.'); + + $controller->deleteComment($comment); + } + + public function testRestoreCommentWithoutAuthorization(): void + { + $ticket = new Ticket(); + $comment = new Comment('content', $ticket); + $controller = $this->buildController(willFlush: false, isGranted: false, comment: $comment); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('You are not allowed to edit this comment.'); + + $controller->restoreComment($comment); + } + + private function buildController(bool $willFlush, bool $isGranted, Comment $comment): UpdateCommentDeletedStatusController + { + $security = $this->prophesize(Security::class); + $security->isGranted(CommentVoter::EDIT, $comment)->willReturn($isGranted); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + + if ($willFlush) { + $entityManager->flush()->shouldBeCalled(); + } + + $commandHandler = $this->prophesize(UpdateCommentDeletedStatusCommandHandlerInterface::class); + + if ($isGranted && $willFlush) { + $commandHandler->handle($comment, Argument::any())->shouldBeCalled(); + } + + return new UpdateCommentDeletedStatusController( + $security->reveal(), + $this->serializer, + $commandHandler->reveal(), + $entityManager->reveal(), + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php index bda2fd485..2fd983a05 100644 --- a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php @@ -25,10 +25,13 @@ use Chill\TicketBundle\Entity\PersonHistory; use Chill\TicketBundle\Entity\StateEnum; use Chill\TicketBundle\Entity\StateHistory; use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\CommentVoter; use Chill\TicketBundle\Serializer\Normalizer\TicketNormalizer; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Security\Core\Security; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** @@ -40,6 +43,13 @@ class TicketNormalizerTest extends KernelTestCase { use ProphecyTrait; + private ObjectProphecy $security; + + protected function setUp(): void + { + $this->security = $this->prophesize(Security::class); + } + /** * @dataProvider provideTickets */ @@ -192,6 +202,35 @@ class TicketNormalizerTest extends KernelTestCase ]; } + public function testNormalizeTicketWithCommentNotAllowed(): void + { + $ticket = new Ticket(); + $comment = new Comment('Test comment', $ticket); + $comment->setCreatedAt(new \DateTimeImmutable('2024-04-01T12:04:00')); + $comment->setCreatedBy(new User()); + + $this->security->isGranted(CommentVoter::READ, $comment)->willReturn(false)->shouldBeCalled(); + + $actual = $this->buildNormalizer()->normalize($ticket, 'json', ['groups' => ['read']]); + + self::assertEmpty($actual['history']); + } + + public function testNormalizeTicketWithCommentAllowed(): void + { + $ticket = new Ticket(); + $comment = new Comment('Test comment', $ticket); + $comment->setCreatedAt(new \DateTimeImmutable('2024-04-01T12:04:00')); + $comment->setCreatedBy(new User()); + + $this->security->isGranted(CommentVoter::READ, $comment)->willReturn(true)->shouldBeCalled(); + + $actual = $this->buildNormalizer()->normalize($ticket, 'json', ['groups' => ['read']]); + + self::assertCount(1, $actual['history']); + self::assertEquals('add_comment', $actual['history'][0]['event_type']); + } + public function testNormalizeReadSimple(): void { // Create a ticket with some data @@ -274,6 +313,10 @@ class TicketNormalizerTest extends KernelTestCase private function buildNormalizer(): TicketNormalizer { + $this->security->isGranted(CommentVoter::READ, Argument::type(Comment::class)) + ->willReturn(true); + + $normalizer = $this->prophesize(NormalizerInterface::class); // empty array @@ -285,14 +328,14 @@ class TicketNormalizerTest extends KernelTestCase // array of mixed objects $normalizer->normalize( - Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_object($arg[0])), + Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_object($arg[0] ?? null)), 'json', Argument::type('array') )->will(fn ($args) => array_fill(0, count($args[0]), 'embedded')); // array of event type $normalizer->normalize( - Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_array($arg[0]) && array_key_exists('event_type', $arg[0])), + Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_array($arg[0] ?? null) && array_key_exists('event_type', $arg[0] ?? null)), 'json', Argument::type('array') )->will(function ($args): array { @@ -357,7 +400,7 @@ class TicketNormalizerTest extends KernelTestCase // null values $normalizer->normalize(null, 'json', Argument::type('array'))->willReturn(null); - $ticketNormalizer = new TicketNormalizer(); + $ticketNormalizer = new TicketNormalizer($this->security->reveal()); $ticketNormalizer->setNormalizer($normalizer->reveal()); return $ticketNormalizer;