Просмотр исходного кода

fix tests for CleanTempFiles cron and adds HelloAsso tests

Changes the file deletion process to set a deletion requested status instead of hard deleting files, improves transaction management, and adds comprehensive unit tests for HelloAsso service.

Adds an EntityManager to the CleanTempFiles job to allow for the status changes of the file entities to be persisted to the database and improves test coverage and reliability of file cleanup operations.

The HelloAssoServiceTest provides extensive tests for most of HelloAsso service methods.
Olivier Massot 2 месяцев назад
Родитель
Сommit
3249fc560c

+ 2 - 0
src/Service/HelloAsso/HelloAssoService.php

@@ -279,6 +279,8 @@ class HelloAssoService extends ApiRequestService
             throw new \RuntimeException('HelloAsso form slug not found');
         }
 
+        $organizationId = $event->getOrganization()->getId();
+
         return $this->getHelloAssoEventForm($organizationId, $helloAssoFormSlug);
     }
 

+ 67 - 99
tests/Unit/Service/Cron/Job/CleanTempFilesTest.php

@@ -11,6 +11,7 @@ use App\Service\Cron\UI\CronUIInterface;
 use App\Service\File\Storage\LocalStorage;
 use App\Service\Utils\DatesUtils;
 use Doctrine\DBAL\Connection;
+use Doctrine\ORM\EntityManagerInterface;
 use Doctrine\ORM\Query;
 use Doctrine\ORM\Query\Expr;
 use Doctrine\ORM\Query\Expr\Comparison;
@@ -44,6 +45,7 @@ class CleanTempFilesTest extends TestCase
     private FileRepository|MockObject $fileRepository;
     private Connection|MockObject $connection;
     private LocalStorage|MockObject $storage;
+    private EntityManagerInterface|MockObject $em;
 
     public function setUp(): void
     {
@@ -53,12 +55,13 @@ class CleanTempFilesTest extends TestCase
         $this->fileRepository = $this->getMockBuilder(FileRepository::class)->disableOriginalConstructor()->getMock();
         $this->connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->getMock();
         $this->storage = $this->getMockBuilder(LocalStorage::class)->disableOriginalConstructor()->getMock();
+        $this->em = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
     }
 
     private function getMockFor(string $method): MockObject|TestableCleanTempFile
     {
         $cleanTempFiles = $this->getMockBuilder(TestableCleanTempFile::class)
-            ->setConstructorArgs([$this->connection, $this->fileRepository, $this->storage])
+            ->setConstructorArgs([$this->connection, $this->fileRepository, $this->storage, $this->em])
             ->setMethodsExcept([$method, 'setUI', 'setLoggerInterface'])
             ->getMock();
         $cleanTempFiles->setUI($this->ui);
@@ -166,50 +169,34 @@ class CleanTempFilesTest extends TestCase
         $cleanTempFiles = $this->getMockFor('deleteFiles');
 
         $file1 = $this->getMockBuilder(File::class)->getMock();
-        $file1->method('getId')->willReturn(1);
-
         $file2 = $this->getMockBuilder(File::class)->getMock();
-        $file2->method('getId')->willReturn(2);
-
         $file3 = $this->getMockBuilder(File::class)->getMock();
-        $file3->method('getId')->willReturn(3);
 
         $files = [$file1, $file2, $file3];
 
-        $this->connection->expects($this->exactly(3))->method('beginTransaction');
+        // Set expectations for connection
         $this->connection->expects($this->once())->method('setAutoCommit')->with(false);
+        $this->connection->expects($this->exactly(3))->method('beginTransaction');
+        $this->connection->expects($this->exactly(3))->method('commit');
+        $this->connection->expects($this->never())->method('rollback');
 
-        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)->disableOriginalConstructor()->getMock();
-        $this->fileRepository->expects($this->once())->method('createQueryBuilder')->with('f')->willReturn($queryBuilder);
-
-        $queryBuilder
-            ->expects(self::exactly(3))
-            ->method('delete')
-            ->willReturnSelf();
-
-        $queryBuilder
-            ->expects(self::exactly(3))
-            ->method('where')
-            ->with('f.id = :id')
-            ->willReturnSelf();
-
-        $queryBuilder
-            ->expects(self::exactly(3))
-            ->method('setParameter')
-            ->withConsecutive(['id', 1], ['id', 2], ['id', 3]);
+        // Set expectations for file status changes
+        $file1->expects($this->once())->method('setStatus')->with(FileStatusEnum::DELETION_REQUESTED);
+        $file2->expects($this->once())->method('setStatus')->with(FileStatusEnum::DELETION_REQUESTED);
+        $file3->expects($this->once())->method('setStatus')->with(FileStatusEnum::DELETION_REQUESTED);
 
-        $this->storage
-            ->expects(self::exactly(3))
-            ->method('hardDelete')
-            ->withConsecutive([$file1], [$file1], [$file3]);
+        // Set expectations for entity manager operations
+        $this->em->expects($this->exactly(3))->method('persist')->withConsecutive([$file1], [$file2], [$file3]);
+        $this->em->expects($this->exactly(3))->method('flush');
 
-        $this->connection->expects($this->exactly(3))->method('commit');
-        $this->connection->expects($this->never())->method('rollback');
+        // Set expectations for UI progress calls
+        $this->ui->expects($this->exactly(4))->method('progress');
 
-        $this->logger->expects(self::atLeastOnce())->method('info')->withConsecutive(
-            ['3 temporary files to be removed'],
-            ['Deleting files...'],
-            ['3 files deleted']
+        // Set expectations for logger calls
+        $this->logger->expects($this->exactly(3))->method('info')->withConsecutive(
+            ['3 temporary files to be marked for deletion'],
+            ['Marking files for deletion...'],
+            ['3 files marked for deletion']
         );
 
         $cleanTempFiles->deleteFiles($files);
@@ -220,51 +207,42 @@ class CleanTempFilesTest extends TestCase
         $cleanTempFiles = $this->getMockFor('deleteFiles');
 
         $file1 = $this->getMockBuilder(File::class)->getMock();
-        $file1->method('getId')->willReturn(1);
-
         $file2 = $this->getMockBuilder(File::class)->getMock();
-        $file2->method('getId')->willReturn(2);
 
         $files = [$file1, $file2];
 
-        $this->connection->expects($this->exactly(2))->method('beginTransaction');
+        // Set expectations for connection
         $this->connection->expects($this->once())->method('setAutoCommit')->with(false);
+        $this->connection->expects($this->exactly(2))->method('beginTransaction');
+        $this->connection->expects($this->never())->method('commit');
+        $this->connection->expects($this->exactly(2))->method('rollback');
 
-        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)->disableOriginalConstructor()->getMock();
-        $this->fileRepository->expects($this->once())->method('createQueryBuilder')->with('f')->willReturn($queryBuilder);
-
-        $queryBuilder
-            ->expects(self::never())
-            ->method('delete')
-            ->willReturnSelf();
+        // Set expectations for file status changes
+        $file1->expects($this->once())->method('setStatus')->with(FileStatusEnum::DELETION_REQUESTED);
+        $file2->expects($this->once())->method('setStatus')->with(FileStatusEnum::DELETION_REQUESTED);
 
-        $this->storage
-            ->expects(self::exactly(2))
-            ->method('hardDelete')
-            ->willReturnCallback(function ($file) {
-                switch ($file->getId()) {
-                    case 1:
-                        throw new \RuntimeException('Some error');
-                    case 2:
-                        throw new \InvalidArgumentException('Some other error');
-                }
-            });
+        // Set expectations for entity manager operations that throw errors
+        $this->em->expects($this->exactly(2))->method('persist')->withConsecutive([$file1], [$file2]);
+        $this->em->expects($this->exactly(2))->method('flush')
+            ->willReturnOnConsecutiveCalls(
+                $this->throwException(new \RuntimeException('Some error')),
+                $this->throwException(new \InvalidArgumentException('Some other error'))
+            );
 
-        $this->connection->expects($this->never())->method('commit');
-        $this->connection->expects($this->exactly(2))->method('rollback');
+        // Set expectations for UI progress calls
+        $this->ui->expects($this->exactly(3))->method('progress');
 
-        $this->logger->expects(self::atLeastOnce())->method('info')->withConsecutive(
-            ['2 temporary files to be removed'],
-            ['Deleting files...'],
-            ['0 files deleted']
+        // Set expectations for logger calls
+        $this->logger->expects($this->exactly(3))->method('info')->withConsecutive(
+            ['2 temporary files to be marked for deletion'],
+            ['Marking files for deletion...'],
+            ['0 files marked for deletion']
         );
 
-        $this->logger
-            ->expects(self::exactly(2))
-            ->method('error')
+        $this->logger->expects($this->exactly(2))->method('error')
             ->withConsecutive(
                 ['ERROR : Some error'],
-                ['ERROR : Some other error'],
+                ['ERROR : Some other error']
             );
 
         $cleanTempFiles->deleteFiles($files);
@@ -275,40 +253,34 @@ class CleanTempFilesTest extends TestCase
         $cleanTempFiles = $this->getMockFor('deleteFiles');
 
         $file1 = $this->getMockBuilder(File::class)->getMock();
-        $file1->method('getId')->willReturn(1);
 
         $files = [$file1];
 
-        $this->connection->expects($this->once())->method('beginTransaction');
+        // Set expectations for connection
         $this->connection->expects($this->once())->method('setAutoCommit')->with(false);
+        $this->connection->expects($this->once())->method('beginTransaction');
+        $this->connection->expects($this->never())->method('commit');
+        $this->connection->expects($this->once())->method('rollback');
 
-        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)->disableOriginalConstructor()->getMock();
-        $this->fileRepository->expects($this->once())->method('createQueryBuilder')->with('f')->willReturn($queryBuilder);
-
-        $queryBuilder
-            ->expects(self::never())
-            ->method('delete')
-            ->willReturnSelf();
+        // Set expectations for file status changes
+        $file1->expects($this->once())->method('setStatus')->with(FileStatusEnum::DELETION_REQUESTED);
 
-        $this->storage
-            ->expects(self::exactly(1))
-            ->method('hardDelete')
-            ->willReturnCallback(function ($file) {
-                switch ($file->getId()) {
-                    case 1:
-                        throw new \Exception('Some unknown error');
-                }
-            });
+        // Set expectations for entity manager operations that throw blocking error
+        $this->em->expects($this->once())->method('persist')->with($file1);
+        $this->em->expects($this->once())->method('flush')
+            ->willThrowException(new \Exception('Some unknown error'));
 
-        $this->connection->expects($this->never())->method('commit');
-        $this->connection->expects($this->once())->method('rollback');
+        // Set expectations for UI progress calls
+        $this->ui->expects($this->exactly(2))->method('progress');
 
-        $this->logger->expects(self::atLeastOnce())->method('info')->withConsecutive(
-            ['1 temporary files to be removed'],
-            ['Deleting files...'],
+        // Set expectations for logger calls
+        $this->logger->expects($this->exactly(2))->method('info')->withConsecutive(
+            ['1 temporary files to be marked for deletion'],
+            ['Marking files for deletion...']
         );
 
         $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Some unknown error');
 
         $cleanTempFiles->deleteFiles($files);
     }
@@ -330,17 +302,15 @@ class CleanTempFilesTest extends TestCase
         $cmp1 = $this->getMockBuilder(Comparison::class)->disableOriginalConstructor()->getMock();
         $cmp2 = $this->getMockBuilder(Comparison::class)->disableOriginalConstructor()->getMock();
         $cmp3 = $this->getMockBuilder(Comparison::class)->disableOriginalConstructor()->getMock();
-        $cmp4 = $this->getMockBuilder(Comparison::class)->disableOriginalConstructor()->getMock();
 
-        $expr->expects(self::exactly(3))->method('eq')->willReturnMap(
+        $expr->expects(self::exactly(2))->method('eq')->willReturnMap(
             [
                 ['f.isTemporaryFile', ':temporaryTrue', $cmp1],
                 ['f.status', ':status', $cmp2],
-                ['f.host', ':host', $cmp3],
             ]
         );
 
-        $expr->expects(self::once())->method('lt')->with('f.createDate', ':maxDate')->willReturn($cmp4);
+        $expr->expects(self::once())->method('lt')->with('f.createDate', ':maxDate')->willReturn($cmp3);
 
         $expr->expects(self::once())->method('isNull')->with('f.createDate')->willReturn('f.createDate is null');
 
@@ -350,26 +320,24 @@ class CleanTempFilesTest extends TestCase
         $expr->expects(self::exactly(2))->method('orX')->willReturnMap(
             [
                 [$cmp1, $cmp2, $orX1],
-                [$cmp4, 'f.createDate is null', $orX2],
+                [$cmp3, 'f.createDate is null', $orX2],
             ]
         );
 
         $queryBuilder
-            ->expects(self::exactly(3))
+            ->expects(self::exactly(2))
             ->method('andWhere')
             ->withConsecutive(
                 [$orX1],
-                [$cmp3],
                 [$orX2],
             )
             ->willReturnSelf();
 
         $queryBuilder
-            ->expects(self::exactly(4))
+            ->expects(self::exactly(3))
             ->method('setParameter')
             ->withConsecutive(
                 ['temporaryTrue', true],
-                ['host', FileHostEnum::AP2I],
                 ['status', FileStatusEnum::DELETED],
                 ['maxDate', '2021-11-09'],
             )

+ 1037 - 0
tests/Unit/Service/HelloAsso/HelloAssoServiceTest.php

@@ -0,0 +1,1037 @@
+<?php
+
+namespace App\Tests\Unit\Service\HelloAsso;
+
+use App\ApiResources\HelloAsso\AuthUrl;
+use App\ApiResources\HelloAsso\EventForm;
+use App\ApiResources\HelloAsso\HelloAssoProfile;
+use App\Entity\Booking\Event;
+use App\Entity\HelloAsso\HelloAsso;
+use App\Entity\Organization\Organization;
+use App\Repository\Booking\EventRepository;
+use App\Repository\Organization\OrganizationRepository;
+use App\Service\HelloAsso\HelloAssoService;
+use App\Service\Security\OAuthPkceGenerator;
+use App\Service\Utils\DatesUtils;
+use App\Service\Utils\UrlBuilder;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+// Classe testable pour accéder aux méthodes protégées
+class TestableHelloAssoService extends HelloAssoService
+{
+    public function getHelloAssoEntityFor(int $organizationId, bool $shallHaveToken = true): HelloAsso
+    {
+        return parent::getHelloAssoEntityFor($organizationId, $shallHaveToken);
+    }
+
+    public function makeHelloAssoEventForm(array $formData): EventForm
+    {
+        return parent::makeHelloAssoEventForm($formData);
+    }
+
+    public function getCallbackUrl(): string
+    {
+        return parent::getCallbackUrl();
+    }
+
+    public function fetchAccessToken(?string $authorizationCode = null, ?string $challengeVerifier = null): array
+    {
+        return parent::fetchAccessToken($authorizationCode, $challengeVerifier);
+    }
+
+    public function updateDomain(array|string $accessToken, string $domain): void
+    {
+        if (is_array($accessToken)) {
+            $accessToken = $accessToken['access_token'];
+        }
+        parent::updateDomain($accessToken, $domain);
+    }
+
+    public function refreshTokenIfNeeded(HelloAsso $helloAssoEntity): HelloAsso
+    {
+        return parent::refreshTokenIfNeeded($helloAssoEntity);
+    }
+
+    public function refreshTokens(HelloAsso $helloAssoEntity): HelloAsso
+    {
+        return parent::refreshTokens($helloAssoEntity);
+    }
+}
+
+class HelloAssoServiceTest extends TestCase
+{
+    private HttpClientInterface|MockObject $httpClient;
+    private OrganizationRepository|MockObject $organizationRepository;
+    private EventRepository|MockObject $eventRepository;
+    private EntityManagerInterface|MockObject $entityManager;
+    private LoggerInterface|MockObject $logger;
+
+    private string $baseUrl = 'https://test-base.com';
+    private string $publicAppBaseUrl = 'https://test-public.com';
+    private string $helloAssoApiBaseUrl = 'https://api.helloasso.com/v5';
+    private string $helloAssoAuthBaseUrl = 'https://auth.helloasso.com';
+    private string $helloAssoClientId = 'test-client-id';
+    private string $helloAssoClientSecret = 'test-client-secret';
+
+    public function setUp(): void
+    {
+        $this->httpClient = $this->getMockBuilder(HttpClientInterface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->organizationRepository = $this->getMockBuilder(OrganizationRepository::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->eventRepository = $this->getMockBuilder(EventRepository::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->logger = $this->getMockBuilder(LoggerInterface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    private function getHelloAssoServiceMockFor(string $methodName): TestableHelloAssoService|MockObject
+    {
+        return $this->getMockBuilder(TestableHelloAssoService::class)
+            ->setConstructorArgs([
+                $this->httpClient,
+                $this->organizationRepository,
+                $this->eventRepository,
+                $this->baseUrl,
+                $this->publicAppBaseUrl,
+                $this->helloAssoApiBaseUrl,
+                $this->helloAssoAuthBaseUrl,
+                $this->helloAssoClientId,
+                $this->helloAssoClientSecret,
+                $this->entityManager,
+                $this->logger,
+            ])
+            ->setMethodsExcept([$methodName])
+            ->getMock();
+    }
+
+    /**
+     * @see HelloAssoService::setupOpentalentDomain()
+     */
+    public function testSetupOpentalentDomain(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('setupOpentalentDomain');
+
+        $tokensData = ['access_token' => 'test-token'];
+
+        $service->expects(self::once())
+            ->method('fetchAccessToken')
+            ->with(null)
+            ->willReturn($tokensData);
+
+        $service->expects(self::once())
+            ->method('updateDomain')
+            ->with($tokensData, 'https://*.opentalent.fr');
+
+        $service->setupOpentalentDomain();
+    }
+
+    /**
+     * @see HelloAssoService::getAuthUrl()
+     */
+    public function testGetAuthUrl(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('getAuthUrl');
+        $organizationId = 1;
+
+        $organization = $this->getMockBuilder(Organization::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $helloAssoEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->organizationRepository->expects(self::once())
+            ->method('find')
+            ->with($organizationId)
+            ->willReturn($organization);
+
+        $organization->expects(self::once())
+            ->method('getHelloAsso')
+            ->willReturn($helloAssoEntity);
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setChallengeVerifier')
+            ->with(self::isType('string'));
+
+        $this->entityManager->expects(self::once())
+            ->method('persist')
+            ->with($helloAssoEntity);
+
+        $this->entityManager->expects(self::once())
+            ->method('flush');
+
+        $result = $service->getAuthUrl($organizationId);
+
+        $this->assertInstanceOf(AuthUrl::class, $result);
+    }
+
+    /**
+     * @see HelloAssoService::connect()
+     */
+    public function testConnect(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('connect');
+        $organizationId = 1;
+        $authorizationCode = 'test-auth-code';
+
+        $organization = $this->getMockBuilder(Organization::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $helloAssoEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $challengeVerifier = 'test-verifier';
+        $tokensData = [
+            'access_token' => 'test-access-token',
+            'refresh_token' => 'test-refresh-token',
+            'expires_in' => 3600,
+            'token_type' => 'bearer',
+        ];
+
+        $this->organizationRepository->expects(self::once())
+            ->method('find')
+            ->with($organizationId)
+            ->willReturn($organization);
+
+        $organization->expects(self::once())
+            ->method('getHelloAsso')
+            ->willReturn($helloAssoEntity);
+
+        $helloAssoEntity->expects(self::once())
+            ->method('getChallengeVerifier')
+            ->willReturn($challengeVerifier);
+
+        $service->expects(self::once())
+            ->method('fetchAccessToken')
+            ->with($authorizationCode, $challengeVerifier)
+            ->willReturn($tokensData);
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setToken')
+            ->with('test-access-token');
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setTokenCreatedAt')
+            ->with(self::isInstanceOf(\DateTime::class));
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setRefreshToken')
+            ->with('test-refresh-token');
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setRefreshTokenCreatedAt')
+            ->with(self::isInstanceOf(\DateTime::class));
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setOrganizationSlug')
+            ->with(null);
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setChallengeVerifier')
+            ->with(null);
+
+        $this->entityManager->expects(self::once())
+            ->method('persist')
+            ->with($helloAssoEntity);
+
+        $this->entityManager->expects(self::once())
+            ->method('flush');
+
+        $result = $service->connect($organizationId, $authorizationCode);
+
+        $this->assertSame($helloAssoEntity, $result);
+    }
+
+    /**
+     * @see HelloAssoService::makeHelloAssoProfile()
+     */
+    public function testMakeHelloAssoProfile(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('makeHelloAssoProfile');
+        $organizationId = 1;
+
+        $organization = $this->getMockBuilder(Organization::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $helloAssoEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->organizationRepository->expects(self::once())
+            ->method('find')
+            ->with($organizationId)
+            ->willReturn($organization);
+
+        $organization->expects(self::once())
+            ->method('getHelloAsso')
+            ->willReturn($helloAssoEntity);
+
+        $helloAssoEntity->expects(self::once())
+            ->method('getToken')
+            ->willReturn('test-token');
+
+        $helloAssoEntity->expects(self::once())
+            ->method('getOrganizationSlug')
+            ->willReturn('test-org-slug');
+
+        $result = $service->makeHelloAssoProfile($organizationId);
+
+        $this->assertInstanceOf(HelloAssoProfile::class, $result);
+    }
+
+    /**
+     * @see HelloAssoService::unlinkHelloAssoAccount()
+     */
+    public function testUnlinkHelloAssoAccount(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('unlinkHelloAssoAccount');
+        $organizationId = 1;
+
+        $organization = $this->getMockBuilder(Organization::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $helloAssoEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->organizationRepository->expects(self::once())
+            ->method('find')
+            ->with($organizationId)
+            ->willReturn($organization);
+
+        $organization->expects(self::once())
+            ->method('getHelloAsso')
+            ->willReturn($helloAssoEntity);
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setToken')
+            ->with(null);
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setTokenCreatedAt')
+            ->with(null);
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setRefreshToken')
+            ->with(null);
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setRefreshTokenCreatedAt')
+            ->with(null);
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setOrganizationSlug')
+            ->with(null);
+
+        $this->entityManager->expects(self::once())
+            ->method('persist')
+            ->with($helloAssoEntity);
+
+        $this->entityManager->expects(self::once())
+            ->method('flush');
+
+        $service->unlinkHelloAssoAccount($organizationId);
+    }
+
+    /**
+     * @see HelloAssoService::getResource()
+     */
+    public function testGetResource(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('getResource');
+
+        $helloAssoEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $routeParts = ['organizations', 'test-slug', 'forms'];
+        $expectedData = ['test' => 'data'];
+
+        $helloAssoEntity->expects(self::once())
+            ->method('getOrganizationSlug')
+            ->willReturn('test-org-slug');
+
+        $helloAssoEntity->expects(self::atLeastOnce())
+            ->method('getToken')
+            ->willReturn('test-token');
+
+        $service->expects(self::once())
+            ->method('refreshTokenIfNeeded')
+            ->with($helloAssoEntity)
+            ->willReturn($helloAssoEntity);
+
+        $service->expects(self::once())
+            ->method('get')
+            ->with('https://api.helloasso.com/v5/v5/organizations/test-slug/forms', [], [
+                'headers' => [
+                    'accept' => 'application/json',
+                    'authorization' => 'Bearer test-token',
+                ],
+            ])
+            ->willReturn($response);
+
+        $response->expects(self::once())
+            ->method('getStatusCode')
+            ->willReturn(200);
+
+        $response->expects(self::once())
+            ->method('getContent')
+            ->willReturn(json_encode($expectedData));
+
+        $result = $service->getResource($helloAssoEntity, $routeParts);
+
+        $this->assertSame($expectedData, $result);
+    }
+
+    /**
+     * @see HelloAssoService::getHelloAssoEventForms()
+     */
+    public function testGetHelloAssoEventForms(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('getHelloAssoEventForms');
+        $organizationId = 1;
+
+        $helloAssoEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $formsData = [
+            'data' => [
+                ['formSlug' => 'form1', 'title' => 'Event 1'],
+                ['formSlug' => 'form2', 'title' => 'Event 2'],
+            ]
+        ];
+
+        $service->expects(self::once())
+            ->method('getHelloAssoEntityFor')
+            ->with($organizationId, true)
+            ->willReturn($helloAssoEntity);
+
+        $helloAssoEntity
+            ->method('getOrganizationSlug')
+            ->willReturn('test-org-slug');
+
+        $helloAssoEntity
+            ->method('getToken')
+            ->willReturn('abcd123');
+
+        $service->expects(self::once())
+            ->method('getResource')
+            ->with($helloAssoEntity, ['organizations', 'test-org-slug', 'forms'])
+            ->willReturn($formsData);
+
+        $result = $service->getHelloAssoEventForms($organizationId);
+
+        $this->assertIsArray($result);
+        $this->assertCount(2, $result);
+        $this->assertInstanceOf(EventForm::class, $result[0]);
+        $this->assertInstanceOf(EventForm::class, $result[1]);
+    }
+
+    /**
+     * @see HelloAssoService::getHelloAssoEventForms()
+     */
+    public function testGetHelloAssoEventFormsWhenIncomplete(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('getHelloAssoEventForms');
+        $organizationId = 1;
+
+        $service->expects(self::once())
+            ->method('getHelloAssoEntityFor')
+            ->with($organizationId, true)
+            ->willThrowException(new \RuntimeException('HelloAsso entity incomplete'));
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('HelloAsso entity incomplete');
+
+        $service->getHelloAssoEventForms($organizationId);
+    }
+
+    /**
+     * @see HelloAssoService::getHelloAssoEventForm()
+     */
+    public function testGetHelloAssoEventForm(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('getHelloAssoEventForm');
+        $organizationId = 1;
+        $formSlug = 'test-form-slug';
+
+        $helloAssoEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $formData = [
+            'formSlug' => 'test-form-slug',
+            'title' => 'Test Event',
+            'widgetFullUrl' => 'https://widget.url'
+        ];
+
+        $service->expects(self::once())
+            ->method('getHelloAssoEntityFor')
+            ->with($organizationId, true)
+            ->willReturn($helloAssoEntity);
+
+        $helloAssoEntity->expects(self::atLeastOnce())
+            ->method('getOrganizationSlug')
+            ->willReturn('test-org-slug');
+
+        $service->expects(self::once())
+            ->method('getResource')
+            ->with($helloAssoEntity, ['organizations', 'test-org-slug', 'forms', 'Event', 'test-form-slug', 'public'])
+            ->willReturn($formData);
+
+        $service->expects(self::once())
+            ->method('makeHelloAssoEventForm')
+            ->with($formData)
+            ->willReturn(new EventForm());
+
+        $result = $service->getHelloAssoEventForm($organizationId, $formSlug);
+
+        $this->assertInstanceOf(EventForm::class, $result);
+    }
+
+    /**
+     * @see HelloAssoService::getHelloAssoEventFormByEventId()
+     */
+    public function testGetHelloAssoEventFormByEventId(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('getHelloAssoEventFormByEventId');
+        $eventId = 1;
+
+        $organization = $this->getMockBuilder(Organization::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $event = $this->getMockBuilder(Event::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $eventForm = new EventForm();
+
+        $this->eventRepository->expects(self::once())
+            ->method('find')
+            ->with($eventId)
+            ->willReturn($event);
+
+        $event->expects(self::once())
+            ->method('getOrganization')
+            ->willReturn($organization);
+
+        $organization->expects(self::once())
+            ->method('getId')
+            ->willReturn(1);
+
+        $event->expects(self::once())
+            ->method('getHelloAssoSlug')
+            ->willReturn('test-hello-asso-slug');
+
+        $service->expects(self::once())
+            ->method('getHelloAssoEventForm')
+            ->with(1, 'test-hello-asso-slug')
+            ->willReturn($eventForm);
+
+        $result = $service->getHelloAssoEventFormByEventId($eventId);
+
+        $this->assertSame($eventForm, $result);
+    }
+
+    /**
+     * @see HelloAssoService::getHelloAssoEventFormByEventId()
+     */
+    public function testGetHelloAssoEventFormByEventIdWhenEventNotFound(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('getHelloAssoEventFormByEventId');
+        $eventId = 1;
+
+        $this->eventRepository->expects(self::once())
+            ->method('find')
+            ->with($eventId)
+            ->willReturn(null);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Event not found');
+
+        $service->getHelloAssoEventFormByEventId($eventId);
+    }
+
+    /**
+     * @see HelloAssoService::getHelloAssoEventFormByEventId()
+     */
+    public function testGetHelloAssoEventFormByEventIdWhenNoHelloAssoSlug(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('getHelloAssoEventFormByEventId');
+        $eventId = 1;
+
+        $organization = $this->getMockBuilder(Organization::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $event = $this->getMockBuilder(Event::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $helloAssoEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->eventRepository->expects(self::once())
+            ->method('find')
+            ->with($eventId)
+            ->willReturn($event);
+
+        $event->expects(self::once())
+            ->method('getHelloAssoSlug')
+            ->willReturn(null);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('HelloAsso form slug not found');
+
+        $service->getHelloAssoEventFormByEventId($eventId);
+    }
+
+    /**
+     * @see HelloAssoService::getHelloAssoEntityFor()
+     */
+    public function testGetHelloAssoEntityFor(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('getHelloAssoEntityFor');
+        $organizationId = 1;
+
+        $organization = $this->getMockBuilder(Organization::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $helloAssoEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->organizationRepository->expects(self::once())
+            ->method('find')
+            ->with($organizationId)
+            ->willReturn($organization);
+
+        $organization->expects(self::once())
+            ->method('getHelloAsso')
+            ->willReturn($helloAssoEntity);
+
+        $helloAssoEntity->expects(self::once())
+            ->method('getOrganizationSlug')
+            ->willReturn('test-org-slug');
+
+        $helloAssoEntity->expects(self::once())
+            ->method('getToken')
+            ->willReturn('test-token');
+
+        $result = $service->getHelloAssoEntityFor($organizationId);
+
+        $this->assertSame($helloAssoEntity, $result);
+    }
+
+    /**
+     * @see HelloAssoService::getHelloAssoEntityFor()
+     */
+    public function testGetHelloAssoEntityForWhenIncomplete(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('getHelloAssoEntityFor');
+        $organizationId = 1;
+
+        $organization = $this->getMockBuilder(Organization::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $helloAssoEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->organizationRepository->expects(self::once())
+            ->method('find')
+            ->with($organizationId)
+            ->willReturn($organization);
+
+        $organization->expects(self::once())
+            ->method('getHelloAsso')
+            ->willReturn($helloAssoEntity);
+
+        $helloAssoEntity->expects(self::once())
+            ->method('getOrganizationSlug')
+            ->willReturn('test-org-slug');
+
+        $helloAssoEntity->expects(self::once())
+            ->method('getToken')
+            ->willReturn(null);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('HelloAsso entity incomplete');
+
+        $service->getHelloAssoEntityFor($organizationId);
+    }
+
+    /**
+     * @see HelloAssoService::getHelloAssoEntityFor()
+     */
+    public function testGetHelloAssoEntityForWhenOrganizationNotFound(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('getHelloAssoEntityFor');
+        $organizationId = 1;
+
+        $this->organizationRepository->expects(self::once())
+            ->method('find')
+            ->with($organizationId)
+            ->willReturn(null);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Organization not found');
+
+        $service->getHelloAssoEntityFor($organizationId);
+    }
+
+    /**
+     * @see HelloAssoService::getHelloAssoEntityFor()
+     */
+    public function testGetHelloAssoEntityForWhenHelloAssoNotFound(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('getHelloAssoEntityFor');
+        $organizationId = 1;
+
+        $organization = $this->getMockBuilder(Organization::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->organizationRepository->expects(self::once())
+            ->method('find')
+            ->with($organizationId)
+            ->willReturn($organization);
+
+        $organization->expects(self::once())
+            ->method('getHelloAsso')
+            ->willReturn(null);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('HelloAsso entity not found');
+
+        $service->getHelloAssoEntityFor($organizationId);
+    }
+
+    /**
+     * @see HelloAssoService::makeHelloAssoEventForm()
+     */
+    public function testMakeHelloAssoEventForm(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('makeHelloAssoEventForm');
+
+        $formData = [
+            'formSlug' => 'test-form-slug',
+            'title' => 'Test Event Title',
+            'widgetFullUrl' => 'https://widget.full.url'
+        ];
+
+        $result = $service->makeHelloAssoEventForm($formData);
+
+        $this->assertInstanceOf(EventForm::class, $result);
+    }
+
+    /**
+     * @see HelloAssoService::getCallbackUrl()
+     */
+    public function testGetCallbackUrl(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('getCallbackUrl');
+
+        $result = $service->getCallbackUrl();
+
+        $this->assertEquals('https://test-public.com/helloasso/callback', $result);
+    }
+
+    /**
+     * @see HelloAssoService::fetchAccessToken()
+     */
+    public function testFetchAccessToken(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('fetchAccessToken');
+
+        $response = $this->getMockBuilder(ResponseInterface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $expectedTokenData = [
+            'access_token' => 'test-access-token',
+            'refresh_token' => 'test-refresh-token',
+            'token_type' => 'Bearer',
+            'expires_in' => 3600,
+            'organization_slug' => null
+        ];
+
+        $this->httpClient->expects(self::once())
+            ->method('request')
+            ->with('POST', 'https://api.helloasso.com/v5/oauth2/token', [
+                'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
+                'body' => [
+                    'grant_type' => 'client_credentials',
+                    'client_id' => 'test-client-id',
+                    'client_secret' => 'test-client-secret',
+                ]
+            ])
+            ->willReturn($response);
+
+        $response->expects(self::once())
+            ->method('getStatusCode')
+            ->willReturn(200);
+
+        $response->expects(self::once())
+            ->method('getContent')
+            ->willReturn(json_encode($expectedTokenData));
+
+        $result = $service->fetchAccessToken();
+
+        $this->assertSame($expectedTokenData, $result);
+    }
+
+    /**
+     * @see HelloAssoService::fetchAccessToken()
+     */
+    public function testFetchAccessTokenWithAuthCode(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('fetchAccessToken');
+
+        $response = $this->getMockBuilder(ResponseInterface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $expectedTokenData = [
+            'access_token' => 'test-access-token',
+            'refresh_token' => 'test-refresh-token',
+            'token_type' => 'Bearer',
+            'expires_in' => 3600,
+            'organization_slug' => null
+        ];
+
+        $service->expects(self::once())
+            ->method('getCallbackUrl')
+            ->willReturn('https://test-public.com/helloasso/callback');
+
+        $this->httpClient->expects(self::once())
+            ->method('request')
+            ->with('POST', 'https://api.helloasso.com/v5/oauth2/token', [
+                'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
+                'body' => [
+                    'grant_type' => 'authorization_code',
+                    'client_id' => 'test-client-id',
+                    'client_secret' => 'test-client-secret',
+                    'code' => 'test-auth-code',
+                    'redirect_uri' => 'https://test-public.com/helloasso/callback',
+                    'code_verifier' => 'test-verifier'
+                ]
+            ])
+            ->willReturn($response);
+
+        $response->expects(self::once())
+            ->method('getStatusCode')
+            ->willReturn(200);
+
+        $response->expects(self::once())
+            ->method('getContent')
+            ->willReturn(json_encode($expectedTokenData));
+
+        $result = $service->fetchAccessToken('test-auth-code', 'test-verifier');
+
+        $this->assertSame($expectedTokenData, $result);
+    }
+
+    /**
+     * @see HelloAssoService::updateDomain()
+     */
+    public function testUpdateDomain(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('updateDomain');
+
+        $response = $this->getMockBuilder(ResponseInterface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $service->expects(self::once())
+            ->method('put')
+            ->with('https://api.helloasso.com/v5/v5/partners/me/api-clients', ['domain' => 'https://test-domain.com'], [], [
+                'headers' => [
+                    'Authorization' => 'Bearer test-access-token',
+                    'Content-Type' => 'application/json',
+                ],
+            ])
+            ->willReturn($response);
+
+        $response->expects(self::once())
+            ->method('getStatusCode')
+            ->willReturn(200);
+
+        $service->updateDomain('test-access-token', 'https://test-domain.com');
+    }
+
+    /**
+     * @see HelloAssoService::refreshTokenIfNeeded()
+     */
+    public function testRefreshTokenIfNeeded(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('refreshTokenIfNeeded');
+
+        $helloAssoEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $futureDate = new \DateTime('+1 hour');
+
+        $helloAssoEntity->expects(self::once())
+            ->method('getRefreshToken')
+            ->willReturn('test-refresh-token');
+
+        $helloAssoEntity->expects(self::atLeastOnce())
+            ->method('getRefreshTokenCreatedAt')
+            ->willReturn($futureDate);
+
+        $result = $service->refreshTokenIfNeeded($helloAssoEntity);
+
+        $this->assertSame($helloAssoEntity, $result);
+    }
+
+    /**
+     * @see HelloAssoService::refreshTokenIfNeeded()
+     */
+    public function testRefreshTokenIfNeededWhenTokenExpired(): void
+    {
+        // Set fake current time to control the test scenario
+        DatesUtils::setFakeDatetime('2024-01-01 12:00:00');
+
+        $service = $this->getHelloAssoServiceMockFor('refreshTokenIfNeeded');
+
+        $helloAssoEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $refreshedEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        // Create a date that when 25 minutes are added, will be less than current fake time
+        $pastDate = DatesUtils::new('2024-01-01 11:30:00');
+
+        $helloAssoEntity->expects(self::once())
+            ->method('getRefreshToken')
+            ->willReturn('test-refresh-token');
+
+        $helloAssoEntity->expects(self::atLeastOnce())
+            ->method('getRefreshTokenCreatedAt')
+            ->willReturn($pastDate);
+
+        $service->expects(self::once())
+            ->method('refreshTokens')
+            ->with($helloAssoEntity)
+            ->willReturn($helloAssoEntity);
+
+        $result = $service->refreshTokenIfNeeded($helloAssoEntity);
+
+        $this->assertSame($helloAssoEntity, $result);
+
+        // Clean up fake datetime
+        DatesUtils::clearFakeDatetime();
+    }
+
+    /**
+     * @see HelloAssoService::refreshTokens()
+     */
+    public function testRefreshTokens(): void
+    {
+        $service = $this->getHelloAssoServiceMockFor('refreshTokens');
+
+        $helloAssoEntity = $this->getMockBuilder(HelloAsso::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $tokensData = [
+            'access_token' => 'new-access-token',
+            'refresh_token' => 'new-refresh-token',
+            'expires_in' => 3600
+        ];
+
+        $helloAssoEntity->expects(self::atLeastOnce())
+            ->method('getRefreshToken')
+            ->willReturn('current-refresh-token');
+
+        $helloAssoEntity->expects(self::once())
+            ->method('getRefreshTokenCreatedAt')
+            ->willReturn(new \DateTime());
+
+        $this->httpClient->expects(self::once())
+            ->method('request')
+            ->with('POST', 'https://api.helloasso.com/v5/oauth2/token', [
+                'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
+                'body' => [
+                    'grant_type' => 'refresh_token',
+                    'refresh_token' => 'current-refresh-token'
+                ]
+            ])
+            ->willReturn($response);
+
+        $response->expects(self::once())
+            ->method('getStatusCode')
+            ->willReturn(200);
+
+        $response->expects(self::once())
+            ->method('getContent')
+            ->willReturn(json_encode($tokensData));
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setToken')
+            ->with('new-access-token');
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setRefreshToken')
+            ->with('new-refresh-token');
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setTokenCreatedAt')
+            ->with(self::isInstanceOf(\DateTime::class));
+
+        $helloAssoEntity->expects(self::once())
+            ->method('setRefreshTokenCreatedAt')
+            ->with(self::isInstanceOf(\DateTime::class));
+
+        $this->entityManager->expects(self::once())
+            ->method('persist')
+            ->with($helloAssoEntity);
+
+        $this->entityManager->expects(self::once())
+            ->method('flush');
+
+        $result = $service->refreshTokens($helloAssoEntity);
+
+        $this->assertSame($helloAssoEntity, $result);
+    }
+}