Parcourir la source

complete unit tests

Olivier Massot il y a 2 mois
Parent
commit
ac18150f68

+ 430 - 0
tests/Unit/Service/Cron/Job/CleanWorkToDoTest.php

@@ -0,0 +1,430 @@
+<?php
+
+namespace App\Tests\Unit\Service\Cron\Job;
+
+use App\Entity\Booking\Work;
+use App\Entity\Booking\WorkByUser;
+use App\Entity\Booking\Course;
+use App\Entity\Core\File;
+use App\Enum\Core\FileStatusEnum;
+use App\Service\Cron\Job\CleanWorkToDo;
+use App\Service\Cron\UI\CronUIInterface;
+use App\Service\File\Storage\LocalStorage;
+use App\Service\Utils\DatesUtils;
+use Doctrine\DBAL\Connection;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\Query;
+use Doctrine\ORM\QueryBuilder;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+class TestableCleanWorkToDo extends CleanWorkToDo
+{
+    public function deleteWorks(bool $preview = false): void
+    {
+        parent::deleteWorks($preview);
+    }
+
+    public function listWorksToDelete(\DateTime $maxDate): array
+    {
+        return parent::listWorksToDelete($maxDate);
+    }
+
+    public function previewWorks(array $works): void
+    {
+        parent::previewWorks($works);
+    }
+}
+
+class CleanWorkToDoTest extends TestCase
+{
+    private CronUIInterface|MockObject $ui;
+    private MockObject|LoggerInterface $logger;
+    private Connection|MockObject $connection;
+    private EntityManagerInterface|MockObject $entityManager;
+    private LocalStorage|MockObject $storage;
+
+    public function setUp(): void
+    {
+        $this->ui = $this->getMockBuilder(CronUIInterface::class)->disableOriginalConstructor()->getMock();
+        $this->logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->getMock();
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->storage = $this->getMockBuilder(LocalStorage::class)->disableOriginalConstructor()->getMock();
+    }
+
+    private function getCleanWorkToDoMockFor(string $methodName): TestableCleanWorkToDo|MockObject
+    {
+        $cleanWorkToDo = $this->getMockBuilder(TestableCleanWorkToDo::class)
+            ->setConstructorArgs([$this->connection, $this->entityManager, $this->storage])
+            ->setMethodsExcept([$methodName, 'setUI', 'setLoggerInterface'])
+            ->getMock();
+        $cleanWorkToDo->setUI($this->ui);
+        $cleanWorkToDo->setLoggerInterface($this->logger);
+
+        return $cleanWorkToDo;
+    }
+
+    /**
+     * @see CleanWorkToDo::preview()
+     */
+    public function testPreview(): void
+    {
+        $cleanWorkToDo = $this->getCleanWorkToDoMockFor('preview');
+
+        $cleanWorkToDo->expects(self::once())
+            ->method('deleteWorks')
+            ->with(true);
+
+        $cleanWorkToDo->preview();
+    }
+
+    /**
+     * @see CleanWorkToDo::execute()
+     */
+    public function testExecute(): void
+    {
+        $cleanWorkToDo = $this->getCleanWorkToDoMockFor('execute');
+
+        $cleanWorkToDo->expects(self::once())
+            ->method('deleteWorks')
+            ->with();
+
+        $cleanWorkToDo->execute();
+    }
+
+    /**
+     * @see CleanWorkToDo::deleteWorks()
+     */
+    public function testDeleteWorks(): void
+    {
+        DatesUtils::setFakeDatetime('2023-12-08 00:00:00');
+        $expectedMaxDate = DatesUtils::new();
+        $expectedMaxDate->sub(new \DateInterval('P12M'));
+
+        $cleanWorkToDo = $this->getCleanWorkToDoMockFor('deleteWorks');
+
+        $work1 = $this->getMockBuilder(Work::class)->getMock();
+        $work1->method('getId')->willReturn(1);
+        $work2 = $this->getMockBuilder(Work::class)->getMock();
+        $work2->method('getId')->willReturn(2);
+
+        $works = [$work1, $work2];
+
+        $cleanWorkToDo->expects(self::once())
+            ->method('listWorksToDelete')
+            ->with($this->callback(function ($date) use ($expectedMaxDate) {
+                return $date->format('Y-m-d') === $expectedMaxDate->format('Y-m-d');
+            }))
+            ->willReturn($works);
+
+        $this->ui->expects(self::atLeastOnce())
+            ->method('print')
+            ->with('2 works to be removed');
+
+        $this->ui->expects(self::exactly(3))
+            ->method('progress');
+
+        $this->connection->expects(self::exactly(2))
+            ->method('beginTransaction');
+
+        $this->connection->expects(self::exactly(2))
+            ->method('commit');
+
+        $this->connection->expects(self::exactly(6))
+            ->method('executeStatement');
+
+        $this->logger->expects(self::exactly(2))
+            ->method('info')
+            ->withConsecutive(
+                [$this->stringContains('Finding works with courses created before')],
+                [$this->stringContains('Works cleanup completed')]
+            );
+
+        $cleanWorkToDo->deleteWorks();
+    }
+
+    /**
+     * @see CleanWorkToDo::deleteWorks()
+     */
+    public function testDeleteWorksWhenPreview(): void
+    {
+        DatesUtils::setFakeDatetime('2023-12-08 00:00:00');
+
+        $cleanWorkToDo = $this->getCleanWorkToDoMockFor('deleteWorks');
+
+        $work1 = $this->getMockBuilder(Work::class)->getMock();
+        $work2 = $this->getMockBuilder(Work::class)->getMock();
+
+        $works = [$work1, $work2];
+
+        $cleanWorkToDo->expects(self::once())
+            ->method('listWorksToDelete')
+            ->willReturn($works);
+
+        $cleanWorkToDo->expects(self::once())
+            ->method('previewWorks')
+            ->with($works);
+
+        $this->ui->expects(self::exactly(2))
+            ->method('print')
+            ->withConsecutive(
+                ['2 works to be removed'],
+                ['> Printing the first and last 10 :']
+            );
+
+        $cleanWorkToDo->deleteWorks(true);
+    }
+
+    /**
+     * @see CleanWorkToDo::deleteWorks()
+     */
+    public function testDeleteWorksWhenNoWorks(): void
+    {
+        DatesUtils::setFakeDatetime('2023-12-08 00:00:00');
+
+        $cleanWorkToDo = $this->getCleanWorkToDoMockFor('deleteWorks');
+
+        $cleanWorkToDo->expects(self::once())
+            ->method('listWorksToDelete')
+            ->willReturn([]);
+
+        $this->ui->expects(self::once())
+            ->method('print')
+            ->with('0 works to be removed');
+
+        $this->logger->expects(self::exactly(2))
+            ->method('info')
+            ->withConsecutive(
+                [$this->stringContains('Finding works with courses created before')],
+                ['No works found to delete']
+            );
+
+        $cleanWorkToDo->deleteWorks();
+    }
+
+    /**
+     * @see CleanWorkToDo::deleteWorks()
+     */
+    public function testDeleteWorksWithNonBlockingError(): void
+    {
+        DatesUtils::setFakeDatetime('2023-12-08 00:00:00');
+
+        $cleanWorkToDo = $this->getCleanWorkToDoMockFor('deleteWorks');
+
+        $work = $this->getMockBuilder(Work::class)->getMock();
+        $work->method('getId')->willReturn(1);
+
+        $works = [$work];
+
+        $cleanWorkToDo->expects(self::once())
+            ->method('listWorksToDelete')
+            ->willReturn($works);
+
+        $this->connection->expects(self::once())
+            ->method('beginTransaction');
+
+        $this->connection->expects(self::once())
+            ->method('executeStatement')
+            ->willThrowException(new \RuntimeException('Database error'));
+
+        $this->connection->expects(self::once())
+            ->method('rollback');
+
+        $this->logger->expects(self::once())
+            ->method('error')
+            ->with('ERROR deleting work 1: Database error');
+
+        $cleanWorkToDo->deleteWorks();
+    }
+
+    /**
+     * @see CleanWorkToDo::deleteWorks()
+     */
+    public function testDeleteWorksWithBlockingError(): void
+    {
+        DatesUtils::setFakeDatetime('2023-12-08 00:00:00');
+
+        $cleanWorkToDo = $this->getCleanWorkToDoMockFor('deleteWorks');
+
+        $work = $this->getMockBuilder(Work::class)->getMock();
+        $work->method('getId')->willReturn(1);
+
+        $works = [$work];
+
+        $cleanWorkToDo->expects(self::once())
+            ->method('listWorksToDelete')
+            ->willReturn($works);
+
+        $this->connection->expects(self::once())
+            ->method('beginTransaction');
+
+        $this->connection->expects(self::once())
+            ->method('executeStatement')
+            ->willThrowException(new \Exception('Serious database error'));
+
+        $this->connection->expects(self::once())
+            ->method('rollback');
+
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Serious database error');
+
+        $cleanWorkToDo->deleteWorks();
+    }
+
+    /**
+     * @see CleanWorkToDo::listWorksToDelete()
+     */
+    public function testListWorksToDelete(): void
+    {
+        $cleanWorkToDo = $this->getCleanWorkToDoMockFor('listWorksToDelete');
+
+        $maxDate = new \DateTime('2022-01-01');
+
+        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $query = $this->getMockBuilder(Query::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $work1 = $this->getMockBuilder(Work::class)->getMock();
+        $work2 = $this->getMockBuilder(Work::class)->getMock();
+
+        $expectedResult = [$work1, $work2];
+
+        $this->entityManager->expects(self::once())
+            ->method('createQueryBuilder')
+            ->willReturn($queryBuilder);
+
+        $queryBuilder->expects(self::once())
+            ->method('select')
+            ->with('w')
+            ->willReturnSelf();
+
+        $queryBuilder->expects(self::once())
+            ->method('from')
+            ->with(Work::class, 'w')
+            ->willReturnSelf();
+
+        $queryBuilder->expects(self::once())
+            ->method('leftJoin')
+            ->with('w.course', 'c')
+            ->willReturnSelf();
+
+        $queryBuilder->expects(self::once())
+            ->method('where')
+            ->with('c.datetimeStart <= :maxDate OR c.datetimeStart IS NULL')
+            ->willReturnSelf();
+
+        $queryBuilder->expects(self::once())
+            ->method('setParameter')
+            ->with('maxDate', $maxDate)
+            ->willReturnSelf();
+
+        $queryBuilder->expects(self::once())
+            ->method('getQuery')
+            ->willReturn($query);
+
+        $query->expects(self::once())
+            ->method('getResult')
+            ->willReturn($expectedResult);
+
+        $result = $cleanWorkToDo->listWorksToDelete($maxDate);
+
+        $this->assertEquals($expectedResult, $result);
+    }
+
+    /**
+     * @see CleanWorkToDo::previewWorks()
+     */
+    public function testPreviewWorks(): void
+    {
+        $cleanWorkToDo = $this->getCleanWorkToDoMockFor('previewWorks');
+
+        $course1 = $this->getMockBuilder(Course::class)->getMock();
+        $course1->method('getName')->willReturn('Test Course 1');
+
+        $course2 = $this->getMockBuilder(Course::class)->getMock();
+        $course2->method('getName')->willReturn('Test Course 2');
+
+        $work1 = $this->getMockBuilder(Work::class)->getMock();
+        $work1->method('getId')->willReturn(1);
+        $work1->method('getCourse')->willReturn($course1);
+        $work1->method('getFiles')->willReturn(new ArrayCollection([]));
+        $work1->method('getWorkByUsers')->willReturn(new ArrayCollection([]));
+
+        $work2 = $this->getMockBuilder(Work::class)->getMock();
+        $work2->method('getId')->willReturn(2);
+        $work2->method('getCourse')->willReturn($course2);
+        $work2->method('getFiles')->willReturn(new ArrayCollection([new File(), new File()]));
+        $work2->method('getWorkByUsers')->willReturn(new ArrayCollection([new WorkByUser()]));
+
+        $work3 = $this->getMockBuilder(Work::class)->getMock();
+        $work3->method('getId')->willReturn(3);
+        $work3->method('getCourse')->willReturn(null);
+        $work3->method('getFiles')->willReturn(new ArrayCollection([]));
+        $work3->method('getWorkByUsers')->willReturn(new ArrayCollection([]));
+
+        $works = [$work1, $work2, $work3];
+
+        $this->ui->expects(self::exactly(3))
+            ->method('print')
+            ->withConsecutive(
+                ['  * Work ID 1 - Course: Test Course 1 - Files: 0 - Users: 0'],
+                ['  * Work ID 2 - Course: Test Course 2 - Files: 2 - Users: 1'],
+                ['  * Work ID 3 - Course: No Course - Files: 0 - Users: 0']
+            );
+
+        $cleanWorkToDo->previewWorks($works);
+    }
+
+    /**
+     * @see CleanWorkToDo::previewWorks()
+     */
+    public function testPreviewWorksWithManyWorks(): void
+    {
+        $cleanWorkToDo = $this->getCleanWorkToDoMockFor('previewWorks');
+
+        $works = [];
+
+        // Create 25 mock works to test the preview logic
+        for ($i = 1; $i <= 25; $i++) {
+            $course = $this->getMockBuilder(Course::class)->getMock();
+            $course->method('getName')->willReturn("Course $i");
+
+            $work = $this->getMockBuilder(Work::class)->getMock();
+            $work->method('getId')->willReturn($i);
+            $work->method('getCourse')->willReturn($course);
+            $work->method('getFiles')->willReturn(new ArrayCollection([]));
+            $work->method('getWorkByUsers')->willReturn(new ArrayCollection([]));
+
+            $works[] = $work;
+        }
+
+        // Expect calls for first 10 works, then "(...)", then last 10 works
+        $expectedCalls = [];
+
+        // First 10
+        for ($i = 1; $i <= 10; $i++) {
+            $expectedCalls[] = ["  * Work ID $i - Course: Course $i - Files: 0 - Users: 0"];
+        }
+
+        // Ellipsis
+        $expectedCalls[] = ['  (...)'];
+
+        // Last 10
+        for ($i = 16; $i <= 25; $i++) {
+            $expectedCalls[] = ["  * Work ID $i - Course: Course $i - Files: 0 - Users: 0"];
+        }
+
+        $this->ui->expects(self::exactly(21))
+            ->method('print')
+            ->withConsecutive(...$expectedCalls);
+
+        $cleanWorkToDo->previewWorks($works);
+    }
+}

+ 418 - 0
tests/Unit/Service/Cron/Job/FilesGarbageCollectorTest.php

@@ -0,0 +1,418 @@
+<?php
+
+namespace App\Tests\Unit\Service\Cron\Job;
+
+use App\Entity\Core\File;
+use App\Enum\Core\FileHostEnum;
+use App\Enum\Core\FileStatusEnum;
+use App\Repository\Core\FileRepository;
+use App\Service\Cron\Job\FilesGarbageCollector;
+use App\Service\Cron\UI\CronUIInterface;
+use App\Service\File\Storage\LocalStorage;
+use Doctrine\DBAL\Connection;
+use Doctrine\ORM\Query;
+use Doctrine\ORM\QueryBuilder;
+use Doctrine\ORM\Query\Expr;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+class TestableFilesGarbageCollector extends FilesGarbageCollector
+{
+    public function listFilesToDelete(): array
+    {
+        return parent::listFilesToDelete();
+    }
+
+    public function deleteFiles(array $files): void
+    {
+        parent::deleteFiles($files);
+    }
+
+    public function getQueryConditions(QueryBuilder $queryBuilder): void
+    {
+        parent::getQueryConditions($queryBuilder);
+    }
+}
+
+class FilesGarbageCollectorTest extends TestCase
+{
+    private CronUIInterface|MockObject $ui;
+    private MockObject|LoggerInterface $logger;
+    private Connection|MockObject $connection;
+    private FileRepository|MockObject $fileRepository;
+    private LocalStorage|MockObject $storage;
+
+    public function setUp(): void
+    {
+        $this->ui = $this->getMockBuilder(CronUIInterface::class)->disableOriginalConstructor()->getMock();
+        $this->logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->getMock();
+        $this->fileRepository = $this->getMockBuilder(FileRepository::class)->disableOriginalConstructor()->getMock();
+        $this->storage = $this->getMockBuilder(LocalStorage::class)->disableOriginalConstructor()->getMock();
+    }
+
+    private function getFilesGarbageCollectorMockFor(string $methodName): TestableFilesGarbageCollector|MockObject
+    {
+        $filesGarbageCollector = $this->getMockBuilder(TestableFilesGarbageCollector::class)
+            ->setConstructorArgs([$this->connection, $this->fileRepository, $this->storage])
+            ->setMethodsExcept([$methodName, 'setUI', 'setLoggerInterface'])
+            ->getMock();
+        $filesGarbageCollector->setUI($this->ui);
+        $filesGarbageCollector->setLoggerInterface($this->logger);
+
+        return $filesGarbageCollector;
+    }
+
+    /**
+     * @see FilesGarbageCollector::preview()
+     */
+    public function testPreview(): void
+    {
+        $filesGarbageCollector = $this->getFilesGarbageCollectorMockFor('preview');
+
+        $file1 = $this->getMockBuilder(File::class)->getMock();
+        $file1->method('getSlug')->willReturn('file1.txt');
+        $file1->method('getStatus')->willReturn(FileStatusEnum::DELETION_REQUESTED);
+
+        $file2 = $this->getMockBuilder(File::class)->getMock();
+        $file2->method('getSlug')->willReturn('file2.txt');
+        $file2->method('getStatus')->willReturn(FileStatusEnum::DELETION_REQUESTED);
+
+        $files = [$file1, $file2];
+
+        $filesGarbageCollector->expects(self::once())
+            ->method('listFilesToDelete')
+            ->willReturn($files);
+
+        $this->storage->expects(self::exactly(2))
+            ->method('exists')
+            ->willReturnMap([
+                [$file1, true],
+                [$file2, false]
+            ]);
+
+        $this->ui->expects(self::exactly(4))
+            ->method('print')
+            ->withConsecutive(
+                ['2 files to be removed'],
+                ['> Printing the first and last 50 :'],
+                ['  * file1.txt (Status: DELETION_REQUESTED - Exists: Yes)'],
+                ['  * file2.txt (Status: DELETION_REQUESTED - Exists: No)']
+            );
+
+        $filesGarbageCollector->preview();
+    }
+
+
+    /**
+     * @see FilesGarbageCollector::execute()
+     */
+    public function testExecute(): void
+    {
+        $filesGarbageCollector = $this->getFilesGarbageCollectorMockFor('execute');
+
+        $file1 = $this->getMockBuilder(File::class)->getMock();
+        $file2 = $this->getMockBuilder(File::class)->getMock();
+
+        $files = [$file1, $file2];
+
+        $filesGarbageCollector->expects(self::once())
+            ->method('listFilesToDelete')
+            ->willReturn($files);
+
+        $filesGarbageCollector->expects(self::once())
+            ->method('deleteFiles')
+            ->with($files);
+
+        $this->logger->expects(self::once())
+            ->method('info')
+            ->with('2 files to be removed');
+
+        $filesGarbageCollector->execute();
+    }
+
+    /**
+     * @see FilesGarbageCollector::listFilesToDelete()
+     */
+    public function testListFilesToDelete(): void
+    {
+        $filesGarbageCollector = $this->getFilesGarbageCollectorMockFor('listFilesToDelete');
+
+        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $query = $this->getMockBuilder(Query::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $file1 = $this->getMockBuilder(File::class)->getMock();
+        $file2 = $this->getMockBuilder(File::class)->getMock();
+
+        $expectedResult = [$file1, $file2];
+
+        $this->fileRepository->expects(self::once())
+            ->method('createQueryBuilder')
+            ->with('f')
+            ->willReturn($queryBuilder);
+
+        $queryBuilder->expects(self::once())
+            ->method('select')
+            ->willReturnSelf();
+
+        $filesGarbageCollector->expects(self::once())
+            ->method('getQueryConditions')
+            ->with($queryBuilder);
+
+        $queryBuilder->expects(self::once())
+            ->method('getQuery')
+            ->willReturn($query);
+
+        $query->expects(self::once())
+            ->method('getResult')
+            ->willReturn($expectedResult);
+
+        $this->ui->expects(self::once())
+            ->method('print')
+            ->with('List files with host ap2i and status DELETION_REQUESTED');
+
+        $result = $filesGarbageCollector->listFilesToDelete();
+
+        $this->assertEquals($expectedResult, $result);
+    }
+
+    /**
+     * @see FilesGarbageCollector::deleteFiles()
+     */
+    public function testDeleteFiles(): void
+    {
+        $filesGarbageCollector = $this->getFilesGarbageCollectorMockFor('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];
+
+        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $query = $this->getMockBuilder(Query::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->fileRepository->expects(self::once())
+            ->method('createQueryBuilder')
+            ->with('f')
+            ->willReturn($queryBuilder);
+
+        $this->storage->expects(self::exactly(2))
+            ->method('exists')
+            ->willReturnMap([
+                [$file1, true],
+                [$file2, false]
+            ]);
+
+        $this->storage->expects(self::once())
+            ->method('hardDelete')
+            ->with($file1);
+
+        $queryBuilder->expects(self::exactly(2))
+            ->method('delete')
+            ->willReturnSelf();
+
+        $queryBuilder->expects(self::exactly(2))
+            ->method('where')
+            ->with('f.id = :id')
+            ->willReturnSelf();
+
+        $queryBuilder->expects(self::exactly(2))
+            ->method('setParameter')
+            ->withConsecutive(
+                ['id', 1],
+                ['id', 2]
+            )
+            ->willReturnSelf();
+
+        $queryBuilder->expects(self::exactly(2))
+            ->method('getQuery')
+            ->willReturn($query);
+
+        $query->expects(self::exactly(2))
+            ->method('execute');
+
+        $this->logger->expects(self::exactly(3))
+            ->method('info')
+            ->withConsecutive(
+                ['2 files to be removed'],
+                ['Deleting files...'],
+                ['2 files deleted']
+            );
+
+        $this->ui->expects(self::exactly(3))
+            ->method('progress');
+
+        $filesGarbageCollector->deleteFiles($files);
+    }
+
+    /**
+     * @see FilesGarbageCollector::deleteFiles()
+     */
+    public function testDeleteFilesWithNonBlockingErrors(): void
+    {
+        $filesGarbageCollector = $this->getFilesGarbageCollectorMockFor('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];
+
+        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->fileRepository->expects(self::once())
+            ->method('createQueryBuilder')
+            ->with('f')
+            ->willReturn($queryBuilder);
+
+        $this->storage->expects(self::exactly(2))
+            ->method('exists')
+            ->willReturnMap([
+                [$file1, true],
+                [$file2, true]
+            ]);
+
+        $this->storage->expects(self::exactly(2))
+            ->method('hardDelete')
+            ->willReturnOnConsecutiveCalls(
+                $this->throwException(new \RuntimeException('Storage error')),
+                $this->throwException(new \InvalidArgumentException('Invalid file'))
+            );
+
+        $this->logger->expects(self::exactly(3))
+            ->method('info')
+            ->withConsecutive(
+                ['2 files to be removed'],
+                ['Deleting files...'],
+                ['0 files deleted']
+            );
+
+        $this->logger->expects(self::exactly(2))
+            ->method('error')
+            ->withConsecutive(
+                ['ERROR : Storage error'],
+                ['ERROR : Invalid file']
+            );
+
+        $this->ui->expects(self::exactly(3))
+            ->method('progress');
+
+        $filesGarbageCollector->deleteFiles($files);
+    }
+
+    /**
+     * @see FilesGarbageCollector::deleteFiles()
+     */
+    public function testDeleteFilesWithBlockingError(): void
+    {
+        $filesGarbageCollector = $this->getFilesGarbageCollectorMockFor('deleteFiles');
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getId')->willReturn(1);
+
+        $files = [$file];
+
+        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->fileRepository->expects(self::once())
+            ->method('createQueryBuilder')
+            ->with('f')
+            ->willReturn($queryBuilder);
+
+        $this->storage->expects(self::once())
+            ->method('exists')
+            ->with($file)
+            ->willReturn(true);
+
+        $this->storage->expects(self::once())
+            ->method('hardDelete')
+            ->with($file)
+            ->willThrowException(new \Exception('Critical storage error'));
+
+        $this->logger->expects(self::exactly(2))
+            ->method('info')
+            ->withConsecutive(
+                ['1 files to be removed'],
+                ['Deleting files...']
+            );
+
+        $this->ui->expects(self::exactly(2))
+            ->method('progress');
+
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Critical storage error');
+
+        $filesGarbageCollector->deleteFiles($files);
+    }
+
+    /**
+     * @see FilesGarbageCollector::getQueryConditions()
+     */
+    public function testGetQueryConditions(): void
+    {
+        $filesGarbageCollector = $this->getFilesGarbageCollectorMockFor('getQueryConditions');
+
+        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $expr = $this->getMockBuilder(Expr::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $queryBuilder->expects(self::atLeastOnce())
+            ->method('expr')
+            ->willReturn($expr);
+
+        $hostComparison = $this->getMockBuilder(Expr\Comparison::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $statusComparison = $this->getMockBuilder(Expr\Comparison::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $expr->expects(self::exactly(2))
+            ->method('eq')
+            ->willReturnMap([
+                ['f.host', ':host', $hostComparison],
+                ['f.status', ':status', $statusComparison]
+            ]);
+
+        $queryBuilder->expects(self::exactly(2))
+            ->method('andWhere')
+            ->withConsecutive(
+                [$hostComparison],
+                [$statusComparison]
+            )
+            ->willReturnSelf();
+
+        $queryBuilder->expects(self::exactly(2))
+            ->method('setParameter')
+            ->withConsecutive(
+                ['host', FileHostEnum::AP2I],
+                ['status', FileStatusEnum::DELETION_REQUESTED]
+            )
+            ->willReturnSelf();
+
+        $filesGarbageCollector->getQueryConditions($queryBuilder);
+    }
+}

+ 280 - 0
tests/Unit/Service/Cron/Job/RefreshHelloassoTokensTest.php

@@ -0,0 +1,280 @@
+<?php
+
+namespace App\Tests\Unit\Service\Cron\Job;
+
+use App\Entity\HelloAsso\HelloAsso;
+use App\Entity\Organization\Organization;
+use App\Repository\HelloAsso\HelloAssoRepository;
+use App\Service\Cron\Job\RefreshHelloassoTokens;
+use App\Service\Cron\UI\CronUIInterface;
+use App\Service\HelloAsso\HelloAssoService;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+class TestableRefreshHelloassoTokens extends RefreshHelloassoTokens
+{
+    public function getHelloassoAccounts(): array
+    {
+        return parent::getHelloassoAccounts();
+    }
+}
+
+class RefreshHelloassoTokensTest extends TestCase
+{
+    private CronUIInterface|MockObject $ui;
+    private MockObject|LoggerInterface $logger;
+    private EntityManagerInterface|MockObject $entityManager;
+    private HelloAssoService|MockObject $helloAssoService;
+
+    public function setUp(): void
+    {
+        $this->ui = $this->getMockBuilder(CronUIInterface::class)->disableOriginalConstructor()->getMock();
+        $this->logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->helloAssoService = $this->getMockBuilder(HelloAssoService::class)->disableOriginalConstructor()->getMock();
+    }
+
+    private function getRefreshHelloassoTokensMockFor(string $methodName): TestableRefreshHelloassoTokens|MockObject
+    {
+        $refreshHelloassoTokens = $this->getMockBuilder(TestableRefreshHelloassoTokens::class)
+            ->setConstructorArgs([$this->entityManager, $this->helloAssoService])
+            ->setMethodsExcept([$methodName, 'setUI', 'setLoggerInterface'])
+            ->getMock();
+        $refreshHelloassoTokens->setUI($this->ui);
+        $refreshHelloassoTokens->setLoggerInterface($this->logger);
+
+        return $refreshHelloassoTokens;
+    }
+
+    /**
+     * @see RefreshHelloassoTokens::preview()
+     */
+    public function testPreview(): void
+    {
+        $refreshHelloassoTokens = $this->getRefreshHelloassoTokensMockFor('preview');
+
+        $organization1 = $this->getMockBuilder(Organization::class)->getMock();
+        $organization1->method('getId')->willReturn(1);
+
+        $organization2 = $this->getMockBuilder(Organization::class)->getMock();
+        $organization2->method('getId')->willReturn(2);
+
+        $helloAsso1 = $this->getMockBuilder(HelloAsso::class)->getMock();
+        $helloAsso1->method('getOrganization')->willReturn($organization1);
+        $helloAsso1->method('getRefreshTokenCreatedAt')->willReturn(new \DateTime('2023-01-01 10:00:00'));
+
+        $helloAsso2 = $this->getMockBuilder(HelloAsso::class)->getMock();
+        $helloAsso2->method('getOrganization')->willReturn($organization2);
+        $helloAsso2->method('getRefreshTokenCreatedAt')->willReturn(new \DateTime('2023-01-02 15:30:00'));
+
+        $helloAssoEntities = [$helloAsso1, $helloAsso2];
+
+        $refreshHelloassoTokens->expects(self::once())
+            ->method('getHelloassoAccounts')
+            ->willReturn($helloAssoEntities);
+
+        $this->ui->expects(self::exactly(3))
+            ->method('print')
+            ->withConsecutive(
+                ['Tokens to refresh :'],
+                [' * Organization 1 : 2023-01-01 10:00:00'],
+                [' * Organization 2 : 2023-01-02 15:30:00']
+            );
+
+        $refreshHelloassoTokens->preview();
+    }
+
+    /**
+     * @see RefreshHelloassoTokens::preview()
+     */
+    public function testPreviewWhenNoTokens(): void
+    {
+        $refreshHelloassoTokens = $this->getRefreshHelloassoTokensMockFor('preview');
+
+        $refreshHelloassoTokens->expects(self::once())
+            ->method('getHelloassoAccounts')
+            ->willReturn([]);
+
+        $this->ui->expects(self::once())
+            ->method('print')
+            ->with('No tokens to refresh');
+
+        $refreshHelloassoTokens->preview();
+    }
+
+    /**
+     * @see RefreshHelloassoTokens::execute()
+     */
+    public function testExecute(): void
+    {
+        $refreshHelloassoTokens = $this->getRefreshHelloassoTokensMockFor('execute');
+
+        $organization1 = $this->getMockBuilder(Organization::class)->getMock();
+        $organization1->method('getId')->willReturn(1);
+
+        $organization2 = $this->getMockBuilder(Organization::class)->getMock();
+        $organization2->method('getId')->willReturn(2);
+
+        $helloAsso1 = $this->getMockBuilder(HelloAsso::class)->getMock();
+        $helloAsso1->method('getOrganization')->willReturn($organization1);
+        $helloAsso1->method('getRefreshTokenCreatedAt')->willReturn(new \DateTime('2023-01-01 10:00:00'));
+
+        $helloAsso2 = $this->getMockBuilder(HelloAsso::class)->getMock();
+        $helloAsso2->method('getOrganization')->willReturn($organization2);
+        $helloAsso2->method('getRefreshTokenCreatedAt')->willReturn(new \DateTime('2023-01-02 15:30:00'));
+
+        $helloAssoEntities = [$helloAsso1, $helloAsso2];
+
+        $refreshHelloassoTokens->expects(self::once())
+            ->method('getHelloassoAccounts')
+            ->willReturn($helloAssoEntities);
+
+        $this->helloAssoService->expects(self::exactly(2))
+            ->method('refreshTokens')
+            ->withConsecutive(
+                [$helloAsso1],
+                [$helloAsso2]
+            );
+
+        $this->logger->expects(self::exactly(4))
+            ->method('info')
+            ->withConsecutive(
+                ['2 tokens to refresh'],
+                [' * Refresh token for organization 1 : 2023-01-01 10:00:00'],
+                [' * Refresh token for organization 2 : 2023-01-02 15:30:00'],
+                ['Tokens refreshed']
+            );
+
+        $refreshHelloassoTokens->execute();
+    }
+
+    /**
+     * @see RefreshHelloassoTokens::execute()
+     */
+    public function testExecuteWhenNoTokens(): void
+    {
+        $refreshHelloassoTokens = $this->getRefreshHelloassoTokensMockFor('execute');
+
+        $refreshHelloassoTokens->expects(self::once())
+            ->method('getHelloassoAccounts')
+            ->willReturn([]);
+
+        $this->logger->expects(self::once())
+            ->method('info')
+            ->with('No tokens to refresh');
+
+        $this->helloAssoService->expects(self::never())
+            ->method('refreshTokens');
+
+        $refreshHelloassoTokens->execute();
+    }
+
+    /**
+     * @see RefreshHelloassoTokens::execute()
+     */
+    public function testExecuteWithCallsLimit(): void
+    {
+        $refreshHelloassoTokens = $this->getRefreshHelloassoTokensMockFor('execute');
+
+        $helloAssoEntities = [];
+
+        // Create 15 entities to test the API calls limit (limit is 10)
+        for ($i = 1; $i <= 15; $i++) {
+            $organization = $this->getMockBuilder(Organization::class)->getMock();
+            $organization->method('getId')->willReturn($i);
+
+            $helloAsso = $this->getMockBuilder(HelloAsso::class)->getMock();
+            $helloAsso->method('getOrganization')->willReturn($organization);
+            $helloAsso->method('getRefreshTokenCreatedAt')->willReturn(new \DateTime("2023-01-" . str_pad($i, 2, '0', STR_PAD_LEFT) . " 10:00:00"));
+
+            $helloAssoEntities[] = $helloAsso;
+        }
+
+        $refreshHelloassoTokens->expects(self::once())
+            ->method('getHelloassoAccounts')
+            ->willReturn($helloAssoEntities);
+
+        // Should only process the first 10 due to API limit
+        $this->helloAssoService->expects(self::exactly(10))
+            ->method('refreshTokens');
+
+        $this->logger->expects(self::exactly(11))
+            ->method('info');
+
+        $this->logger->expects(self::once())
+            ->method('warning')
+            ->with('API calls limit reached, aborting');
+
+        $refreshHelloassoTokens->execute();
+    }
+
+    /**
+     * @see RefreshHelloassoTokens::execute()
+     */
+    public function testExecuteWithExactCallsLimit(): void
+    {
+        $refreshHelloassoTokens = $this->getRefreshHelloassoTokensMockFor('execute');
+
+        $helloAssoEntities = [];
+
+        // Create exactly 10 entities (the limit)
+        for ($i = 1; $i <= 10; $i++) {
+            $organization = $this->getMockBuilder(Organization::class)->getMock();
+            $organization->method('getId')->willReturn($i);
+
+            $helloAsso = $this->getMockBuilder(HelloAsso::class)->getMock();
+            $helloAsso->method('getOrganization')->willReturn($organization);
+            $helloAsso->method('getRefreshTokenCreatedAt')->willReturn(new \DateTime("2023-01-" . str_pad($i, 2, '0', STR_PAD_LEFT) . " 10:00:00"));
+
+            $helloAssoEntities[] = $helloAsso;
+        }
+
+        $refreshHelloassoTokens->expects(self::once())
+            ->method('getHelloassoAccounts')
+            ->willReturn($helloAssoEntities);
+
+        $this->helloAssoService->expects(self::exactly(10))
+            ->method('refreshTokens');
+
+        $this->logger->expects(self::exactly(11))
+            ->method('info');
+
+        $this->logger->expects(self::never())
+            ->method('warning');
+
+        $refreshHelloassoTokens->execute();
+    }
+
+    /**
+     * @see RefreshHelloassoTokens::getHelloassoAccounts()
+     */
+    public function testGetHelloassoAccounts(): void
+    {
+        $refreshHelloassoTokens = $this->getRefreshHelloassoTokensMockFor('getHelloassoAccounts');
+
+        $helloAssoRepository = $this->getMockBuilder(HelloAssoRepository::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $helloAsso1 = $this->getMockBuilder(HelloAsso::class)->getMock();
+        $helloAsso2 = $this->getMockBuilder(HelloAsso::class)->getMock();
+
+        $expectedResult = [$helloAsso1, $helloAsso2];
+
+        $this->entityManager->expects(self::once())
+            ->method('getRepository')
+            ->with(HelloAsso::class)
+            ->willReturn($helloAssoRepository);
+
+        $helloAssoRepository->expects(self::once())
+            ->method('findOldRefreshTokens')
+            ->with(24)
+            ->willReturn($expectedResult);
+
+        $result = $refreshHelloassoTokens->getHelloassoAccounts();
+
+        $this->assertEquals($expectedResult, $result);
+    }
+}

+ 133 - 0
tests/Unit/Service/Twig/PhoneNumberExtensionTest.php

@@ -0,0 +1,133 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Service\Twig;
+
+use App\Service\Twig\PhoneNumberExtension;
+use libphonenumber\PhoneNumber;
+use libphonenumber\PhoneNumberFormat;
+use libphonenumber\PhoneNumberUtil;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Twig\TwigFilter;
+
+class PhoneNumberExtensionTest extends TestCase
+{
+    private PhoneNumberUtil|MockObject $phoneNumberUtil;
+
+    public function setUp(): void
+    {
+        $this->phoneNumberUtil = $this->getMockBuilder(PhoneNumberUtil::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    private function getPhoneNumberExtensionMockFor(string $methodName): PhoneNumberExtension|MockObject
+    {
+        return $this->getMockBuilder(PhoneNumberExtension::class)
+            ->setConstructorArgs([$this->phoneNumberUtil])
+            ->setMethodsExcept([$methodName])
+            ->getMock();
+    }
+
+    /**
+     * @see PhoneNumberExtension::getFilters()
+     */
+    public function testGetFilters(): void
+    {
+        $extension = $this->getPhoneNumberExtensionMockFor('getFilters');
+        $filters = $extension->getFilters();
+
+        $this->assertIsArray($filters);
+        $this->assertCount(2, $filters);
+
+        // Test first filter (phone_international)
+        $this->assertInstanceOf(TwigFilter::class, $filters[0]);
+        $this->assertSame('phone_international', $filters[0]->getName());
+
+        $callable = $filters[0]->getCallable();
+        $this->assertIsArray($callable);
+        $this->assertSame($extension, $callable[0]);
+        $this->assertSame('formatPhoneInternational', $callable[1]);
+
+        // Test second filter (phone_national)
+        $this->assertInstanceOf(TwigFilter::class, $filters[1]);
+        $this->assertSame('phone_national', $filters[1]->getName());
+
+        $callable = $filters[1]->getCallable();
+        $this->assertIsArray($callable);
+        $this->assertSame($extension, $callable[0]);
+        $this->assertSame('formatPhoneNational', $callable[1]);
+    }
+
+    /**
+     * @see PhoneNumberExtension::formatPhoneInternational()
+     */
+    public function testFormatPhoneInternational(): void
+    {
+        $extension = $this->getPhoneNumberExtensionMockFor('formatPhoneInternational');
+
+        $phoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
+        $expectedResult = '+33 1 23 45 67 89';
+
+        $this->phoneNumberUtil->expects(self::once())
+            ->method('format')
+            ->with($phoneNumber, PhoneNumberFormat::INTERNATIONAL)
+            ->willReturn($expectedResult);
+
+        $result = $extension->formatPhoneInternational($phoneNumber);
+
+        $this->assertSame($expectedResult, $result);
+    }
+
+    /**
+     * @see PhoneNumberExtension::formatPhoneInternational()
+     */
+    public function testFormatPhoneInternationalWithNull(): void
+    {
+        $extension = $this->getPhoneNumberExtensionMockFor('formatPhoneInternational');
+
+        $this->phoneNumberUtil->expects(self::never())
+            ->method('format');
+
+        $result = $extension->formatPhoneInternational(null);
+
+        $this->assertSame('', $result);
+    }
+
+    /**
+     * @see PhoneNumberExtension::formatPhoneNational()
+     */
+    public function testFormatPhoneNational(): void
+    {
+        $extension = $this->getPhoneNumberExtensionMockFor('formatPhoneNational');
+
+        $phoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
+        $expectedResult = '01 23 45 67 89';
+
+        $this->phoneNumberUtil->expects(self::once())
+            ->method('format')
+            ->with($phoneNumber, PhoneNumberFormat::NATIONAL)
+            ->willReturn($expectedResult);
+
+        $result = $extension->formatPhoneNational($phoneNumber);
+
+        $this->assertSame($expectedResult, $result);
+    }
+
+    /**
+     * @see PhoneNumberExtension::formatPhoneNational()
+     */
+    public function testFormatPhoneNationalWithNull(): void
+    {
+        $extension = $this->getPhoneNumberExtensionMockFor('formatPhoneNational');
+
+        $this->phoneNumberUtil->expects(self::never())
+            ->method('format');
+
+        $result = $extension->formatPhoneNational(null);
+
+        $this->assertSame('', $result);
+    }
+}