Tickets: edit comments and mark them as deleted

This commit is contained in:
Julien Fastré 2025-07-11 12:56:19 +00:00
parent 568c8be7fd
commit 3400656d7c
25 changed files with 945 additions and 7 deletions

View File

@ -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",

View File

@ -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:

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Action\Comment\Handler;
use Chill\TicketBundle\Action\Comment\UpdateCommentContentCommand;
use Chill\TicketBundle\Entity\Comment;
final readonly class UpdateCommentContentCommandHandler implements UpdateCommentContentCommandHandlerInterface
{
public function __construct(
) {}
public function handle(Comment $comment, UpdateCommentContentCommand $command): void
{
$comment->setContent($command->content);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Action\Comment\Handler;
use Chill\TicketBundle\Action\Comment\UpdateCommentContentCommand;
use Chill\TicketBundle\Entity\Comment;
interface UpdateCommentContentCommandHandlerInterface
{
public function handle(Comment $comment, UpdateCommentContentCommand $command): void;
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Action\Comment\Handler;
use Chill\TicketBundle\Action\Comment\UpdateCommentDeletedStatusCommand;
use Chill\TicketBundle\Entity\Comment;
final readonly class UpdateCommentDeletedStatusCommandHandler implements UpdateCommentDeletedStatusCommandHandlerInterface
{
public function __construct(
) {}
public function handle(Comment $comment, UpdateCommentDeletedStatusCommand $command): void
{
$comment->setDeleted($command->delete);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Action\Comment\Handler;
use Chill\TicketBundle\Action\Comment\UpdateCommentDeletedStatusCommand;
use Chill\TicketBundle\Entity\Comment;
interface UpdateCommentDeletedStatusCommandHandlerInterface
{
public function handle(Comment $comment, UpdateCommentDeletedStatusCommand $command): void;
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Action\Comment;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Annotation as Serializer;
final readonly class UpdateCommentContentCommand
{
public function __construct(
/**
* The new content of the command.
*
* The command accept null values, but it should raise a validation error.
*/
#[Assert\NotBlank()]
#[Assert\NotNull]
#[Serializer\Groups(['write'])]
public ?string $content,
) {}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Action\Comment;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Annotation as Serializer;
final readonly class UpdateCommentDeletedStatusCommand
{
public function __construct(
/**
* The deleted status to set for the comment.
*/
#[Assert\NotNull]
#[Serializer\Groups(['write'])]
public bool $delete,
) {}
}

View File

@ -22,10 +22,12 @@ final readonly class AddCommentCommandHandler
private EntityManagerInterface $entityManager,
) {}
public function handle(Ticket $ticket, AddCommentCommand $command): void
public function handle(Ticket $ticket, AddCommentCommand $command): Comment
{
$comment = new Comment($command->content, $ticket);
$this->entityManager->persist($comment);
return $comment;
}
}

View File

@ -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();

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Controller;
use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentContentCommandHandlerInterface;
use Chill\TicketBundle\Action\Comment\UpdateCommentContentCommand;
use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Security\Voter\CommentVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final readonly class UpdateCommentController
{
public function __construct(
private Security $security,
private SerializerInterface $serializer,
private ValidatorInterface $validator,
private UpdateCommentContentCommandHandlerInterface $updateCommentContentCommandHandler,
private EntityManagerInterface $entityManager,
) {}
#[Route('/api/1.0/ticket/comment/{id}/edit', name: 'chill_ticket_comment_edit', methods: ['POST'])]
public function __invoke(Comment $comment, Request $request): Response
{
if (!$this->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
);
}
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Controller;
use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentDeletedStatusCommandHandlerInterface;
use Chill\TicketBundle\Action\Comment\UpdateCommentDeletedStatusCommand;
use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Security\Voter\CommentVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
final readonly class UpdateCommentDeletedStatusController
{
public function __construct(
private Security $security,
private SerializerInterface $serializer,
private UpdateCommentDeletedStatusCommandHandlerInterface $updateCommentDeletedStatusCommandHandler,
private EntityManagerInterface $entityManager,
) {}
#[Route('/api/1.0/ticket/comment/{id}/delete', name: 'chill_ticket_comment_delete', methods: ['POST'])]
public function deleteComment(Comment $comment): Response
{
return $this->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
);
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Security\Voter;
use Chill\TicketBundle\Entity\Comment;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Check permissions on comments.
*
* - Adding a comment to the ticket requires to be allowed to write on the ticket;
* - Only the user who created the comment is allowed to edit it;
*/
final class CommentVoter extends Voter
{
public const CREATE = 'CHILL_TICKET_COMMENT_CREATE';
public const EDIT = 'CHILL_TICKET_COMMENT_EDIT';
public const READ = 'CHILL_TICKET_COMMENT_READ';
private const ALL = [self::CREATE, self::EDIT, self::READ];
public function __construct(private readonly AccessDecisionManagerInterface $decisionManager) {}
protected function supports(string $attribute, $subject): bool
{
return $subject instanceof Comment && in_array($attribute, self::ALL, true);
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
/* @var Comment $subject */
return match ($attribute) {
self::READ => !$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'),
};
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Security\Voter;
use Chill\TicketBundle\Entity\Ticket;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Check permission on Ticket.
*/
final class TicketVoter extends Voter
{
public const WRITE = 'CHILL_TICKET_TICKET_WRITE';
public const READ = 'CHILL_TICKET_TICKET_READ';
private const ALL = [self::WRITE, self::READ];
protected function supports(string $attribute, $subject): bool
{
return $subject instanceof Ticket && in_array($attribute, self::ALL, true);
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
return true;
}
}

View File

@ -22,6 +22,8 @@ use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\PersonHistory;
use Chill\TicketBundle\Entity\StateHistory;
use Chill\TicketBundle\Entity\Ticket;
use Chill\TicketBundle\Security\Voter\CommentVoter;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
@ -31,6 +33,8 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
{
use NormalizerAwareTrait;
public function __construct(private Security $security) {}
public function normalize($object, ?string $format = null, array $context = [])
{
if (!$object instanceof Ticket) {
@ -114,7 +118,7 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
'by' => $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),

View File

@ -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/'

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Ticket;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250711115128 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add deleted column to comment table to support soft deletion of comments';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Tests\Action\Comment\Handler;
use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentContentCommandHandler;
use Chill\TicketBundle\Action\Comment\UpdateCommentContentCommand;
use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
/**
* @internal
*
* @coversNothing
*/
class UpdateCommentContentCommandHandlerTest extends TestCase
{
use ProphecyTrait;
public function testUpdateCommentContent(): void
{
$handler = $this->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());
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Tests\Action\Comment\Handler;
use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentDeletedStatusCommandHandler;
use Chill\TicketBundle\Action\Comment\UpdateCommentDeletedStatusCommand;
use Chill\TicketBundle\Entity\Comment;
use Chill\TicketBundle\Entity\Ticket;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
/**
* @internal
*
* @coversNothing
*/
class UpdateCommentDeletedStatusCommandHandlerTest extends TestCase
{
use ProphecyTrait;
public function testUpdateCommentDeletedStatus(): void
{
$handler = $this->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();
}
}

View File

@ -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();
}

View File

@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Tests\Controller;
use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentContentCommandHandlerInterface;
use Chill\TicketBundle\Controller\UpdateCommentController;
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;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* @internal
*
* @coversNothing
*/
class UpdateCommentControllerTest extends KernelTestCase
{
use ProphecyTrait;
private SerializerInterface $serializer;
private ValidatorInterface $validator;
protected function setUp(): void
{
self::bootKernel();
$this->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(),
);
}
}

View File

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Tests\Controller;
use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentDeletedStatusCommandHandlerInterface;
use Chill\TicketBundle\Controller\UpdateCommentDeletedStatusController;
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;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @internal
*
* @coversNothing
*/
class UpdateCommentDeletedStatusControllerTest extends KernelTestCase
{
use ProphecyTrait;
private SerializerInterface $serializer;
protected function setUp(): void
{
self::bootKernel();
$this->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(),
);
}
}

View File

@ -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;