Add AuditDumpRequestHandler to process audit dump requests and ensure transactional consistency

- Introduced `AuditDumpRequestHandler` to handle `AuditDumpRequestMessage` with transactional safety.
- Added custom exceptions `AuditDumpTooMuchLines` and `AuditDumpAlreadyGeneratedException` for enhanced error reporting during processing.
- Created unit tests in `AuditDumpRequestHandlerTest` to validate normal processing, exception handling, and edge cases.
- Implemented locking mechanism and state management for `StoredObject` while processing requests.
This commit is contained in:
2026-02-23 11:44:32 +01:00
parent 07417387bb
commit 3b8984eec5
5 changed files with 331 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
<?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\MainBundle\Audit\Exception;
class AuditDumpAlreadyGeneratedException extends \RuntimeException {}

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\MainBundle\Audit\Exception;
class AuditDumpTooMuchLines extends \RuntimeException
{
public function __construct(int $lines, int $maxLines)
{
parent::__construct(sprintf('Audit dump contains too much lines (%d) compared to maximum allowed (%d)', $lines, $maxLines));
}
}

View File

@@ -0,0 +1,106 @@
<?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\MainBundle\Audit\Messenger;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
use Chill\MainBundle\Audit\AuditEventDumper;
use Chill\MainBundle\Audit\Exception\AuditDumpAlreadyGeneratedException;
use Chill\MainBundle\Audit\Exception\AuditDumpTooMuchLines;
use Chill\MainBundle\Audit\Subject;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
final readonly class AuditDumpRequestHandler implements MessageHandlerInterface
{
public function __construct(
private AuditEventDumper $auditEventDumper,
private StoredObjectRepositoryInterface $storedObjectRepository,
private EntityManagerInterface $entityManager,
private UserRepositoryInterface $userRepository,
) {}
public function __invoke(AuditDumpRequestMessage $message): void
{
$conn = $this->entityManager->getConnection();
$beganTx = false;
if (!$conn->isTransactionActive()) {
$conn->beginTransaction();
$beganTx = true;
}
try {
// Lock the StoredObject until the end of the process
$storedObject = $this->entityManager->find(StoredObject::class, $message->storedObjectId, LockMode::PESSIMISTIC_WRITE);
if (!$storedObject instanceof StoredObject) {
// Nothing to do if stored object does not exist anymore
if ($beganTx) {
$conn->commit();
}
return;
}
// Build criteria expected by AuditTrailRepository
$criteria = [];
if (null !== $message->from) {
$criteria['from_date'] = $message->from;
}
if (null !== $message->to) {
$criteria['to_date'] = $message->to;
}
$subjects = [];
foreach ($message->subjects as $s) {
if (is_array($s)) {
$subjects[] = Subject::fromArray($s);
}
}
if ([] !== $subjects) {
$criteria['subjects'] = $subjects;
}
$byUsers = [];
foreach ($message->byUsers as $userId) {
$byUsers[] = $this->userRepository->find($userId);
}
if ([] !== $byUsers) {
$criteria['by_users'] = $byUsers;
}
$this->auditEventDumper->dump($criteria, $message->lang, $storedObject);
$this->entityManager->flush();
if ($beganTx) {
$conn->commit();
}
} catch (AuditDumpTooMuchLines|AuditDumpAlreadyGeneratedException $e) {
if ($beganTx) {
$this->entityManager->flush();
$conn->commit();
}
throw new UnrecoverableMessageHandlingException(previous: $e);
} catch (\Throwable $e) {
if ($beganTx && $conn->isTransactionActive()) {
$conn->rollBack();
}
throw $e;
} finally {
// Clear the EntityManager state at the end
$this->entityManager->clear();
}
}
}

View File

@@ -0,0 +1,24 @@
<?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\MainBundle\Audit\Messenger;
final readonly class AuditDumpRequestMessage
{
public function __construct(
public string $lang,
public int $storedObjectId,
public ?\DateTimeImmutable $from = null,
public ?\DateTimeImmutable $to = null,
public array $subjects = [],
public array $byUsers = [],
) {}
}

View File

@@ -0,0 +1,167 @@
<?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\MainBundle\Tests\Audit\Messenger;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
use Chill\MainBundle\Audit\AuditEventDumper;
use Chill\MainBundle\Audit\Exception\AuditDumpAlreadyGeneratedException;
use Chill\MainBundle\Audit\Exception\AuditDumpTooMuchLines;
use Chill\MainBundle\Audit\Messenger\AuditDumpRequestHandler;
use Chill\MainBundle\Audit\Messenger\AuditDumpRequestMessage;
use Chill\MainBundle\Audit\Subject;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\UserRepositoryInterface;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
/**
* @internal
*
* @coversNothing
*/
class AuditDumpRequestHandlerTest extends TestCase
{
use ProphecyTrait;
private $auditEventDumper;
private $storedObjectRepository;
private $entityManager;
private $userRepository;
private $connection;
private $handler;
protected function setUp(): void
{
$this->auditEventDumper = $this->prophesize(AuditEventDumper::class);
$this->storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
$this->entityManager = $this->prophesize(EntityManagerInterface::class);
$this->userRepository = $this->prophesize(UserRepositoryInterface::class);
$this->connection = $this->prophesize(Connection::class);
$this->entityManager->getConnection()->willReturn($this->connection->reveal());
$this->handler = new AuditDumpRequestHandler(
$this->auditEventDumper->reveal(),
$this->storedObjectRepository->reveal(),
$this->entityManager->reveal(),
$this->userRepository->reveal()
);
}
public function testInvokeNormalFlow(): void
{
$message = new AuditDumpRequestMessage(
lang: 'fr',
storedObjectId: 123,
from: new \DateTimeImmutable('2023-01-01'),
to: new \DateTimeImmutable('2023-01-31'),
subjects: [['t' => 'person', 'id' => 456]],
byUsers: [1]
);
$storedObject = new StoredObject();
$user = $this->prophesize(User::class)->reveal();
$this->connection->isTransactionActive()->willReturn(false);
$this->connection->beginTransaction()->shouldBeCalled();
$this->entityManager->find(StoredObject::class, 123, LockMode::PESSIMISTIC_WRITE)
->willReturn($storedObject);
$this->userRepository->find(1)->willReturn($user);
$expectedCriteria = [
'from_date' => $message->from,
'to_date' => $message->to,
'subjects' => [Subject::fromArray(['t' => 'person', 'id' => 456])],
'by_users' => [$user],
];
$this->auditEventDumper->dump(
Argument::that(function ($criteria) use ($expectedCriteria) {
return $criteria['from_date'] === $expectedCriteria['from_date']
&& $criteria['to_date'] === $expectedCriteria['to_date']
&& 1 === count($criteria['subjects'])
&& $criteria['subjects'][0]->isEqual($expectedCriteria['subjects'][0])
&& $criteria['by_users'] === $expectedCriteria['by_users'];
}),
'fr',
$storedObject
)->shouldBeCalled();
$this->connection->commit()->shouldBeCalled();
$this->entityManager->flush()->shouldBeCalled();
$this->entityManager->clear()->shouldBeCalled();
($this->handler)($message);
}
/**
* @dataProvider provideExceptions
*/
public function testInvokeWithExceptions(\Exception $exception): void
{
$message = new AuditDumpRequestMessage('fr', 123);
$storedObject = new StoredObject();
$this->connection->isTransactionActive()->willReturn(false, true);
$this->connection->beginTransaction()->shouldBeCalled();
$this->entityManager->find(StoredObject::class, 123, LockMode::PESSIMISTIC_WRITE)
->willReturn($storedObject);
$this->auditEventDumper->dump(Argument::any(), 'fr', $storedObject)
->willThrow($exception);
$this->connection->commit()->shouldBeCalled();
$this->entityManager->flush()->shouldBeCalled();
$this->entityManager->clear()->shouldBeCalled();
$this->expectException(UnrecoverableMessageHandlingException::class);
try {
($this->handler)($message);
} catch (UnrecoverableMessageHandlingException $e) {
$this->assertSame($exception, $e->getPrevious());
throw $e;
}
}
public function provideExceptions(): array
{
return [
[new AuditDumpTooMuchLines(1000, 500)],
[new AuditDumpAlreadyGeneratedException('Already generated')],
];
}
public function testInvokeStoredObjectNotFound(): void
{
$message = new AuditDumpRequestMessage('fr', 123);
$this->connection->isTransactionActive()->willReturn(false);
$this->connection->beginTransaction()->shouldBeCalled();
$this->entityManager->find(StoredObject::class, 123, LockMode::PESSIMISTIC_WRITE)
->willReturn(null);
$this->connection->commit()->shouldBeCalled();
$this->entityManager->clear()->shouldBeCalled();
($this->handler)($message);
}
}