From 3b8984eec51d9a4334bf2ef1490bd3b902f98cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 23 Feb 2026 11:44:32 +0100 Subject: [PATCH] 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. --- .../AuditDumpAlreadyGeneratedException.php | 14 ++ .../Audit/Exception/AuditDumpTooMuchLines.php | 20 +++ .../Messenger/AuditDumpRequestHandler.php | 106 +++++++++++ .../Messenger/AuditDumpRequestMessage.php | 24 +++ .../Messenger/AuditDumpRequestHandlerTest.php | 167 ++++++++++++++++++ 5 files changed, 331 insertions(+) create mode 100644 src/Bundle/ChillMainBundle/Audit/Exception/AuditDumpAlreadyGeneratedException.php create mode 100644 src/Bundle/ChillMainBundle/Audit/Exception/AuditDumpTooMuchLines.php create mode 100644 src/Bundle/ChillMainBundle/Audit/Messenger/AuditDumpRequestHandler.php create mode 100644 src/Bundle/ChillMainBundle/Audit/Messenger/AuditDumpRequestMessage.php create mode 100644 src/Bundle/ChillMainBundle/Tests/Audit/Messenger/AuditDumpRequestHandlerTest.php diff --git a/src/Bundle/ChillMainBundle/Audit/Exception/AuditDumpAlreadyGeneratedException.php b/src/Bundle/ChillMainBundle/Audit/Exception/AuditDumpAlreadyGeneratedException.php new file mode 100644 index 000000000..dc13d6d9c --- /dev/null +++ b/src/Bundle/ChillMainBundle/Audit/Exception/AuditDumpAlreadyGeneratedException.php @@ -0,0 +1,14 @@ +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(); + } + } +} diff --git a/src/Bundle/ChillMainBundle/Audit/Messenger/AuditDumpRequestMessage.php b/src/Bundle/ChillMainBundle/Audit/Messenger/AuditDumpRequestMessage.php new file mode 100644 index 000000000..8b6a3d04c --- /dev/null +++ b/src/Bundle/ChillMainBundle/Audit/Messenger/AuditDumpRequestMessage.php @@ -0,0 +1,24 @@ +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); + } +}