浏览代码

complete unit tests

Olivier Massot 3 年之前
父节点
当前提交
c921a898d4

+ 11 - 6
src/Service/Cron/Job/CleanTempFiles.php

@@ -7,6 +7,7 @@ use App\Repository\Core\FileRepository;
 use App\Service\Cron\BaseCronJob;
 use App\Service\Cron\CronjobInterface;
 use App\Service\Storage\LocalStorage;
+use App\Service\Utils\DatesUtils;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\Common\Collections\Criteria;
 use Doctrine\ORM\EntityManagerInterface;
@@ -92,9 +93,10 @@ class CleanTempFiles extends BaseCronJob implements CronjobInterface
      * @return Collection
      * @throws Exception
      */
-    private function listFilesToDelete(): Collection {
+    protected function listFilesToDelete(): Collection {
 
-        $maxDate = new \DateTime(self::DELETE_OLDER_THAN . ' days ago');
+        $maxDate = DatesUtils::new();
+        $maxDate->sub(new \DateInterval('P' . self::DELETE_OLDER_THAN . 'D'));
 
         $this->ui->print('List temporary files created before ' . $maxDate->format('c'));
 
@@ -117,7 +119,7 @@ class CleanTempFiles extends BaseCronJob implements CronjobInterface
      *
      * @param Collection $files
      */
-    private function deleteFiles(Collection $files): void {
+    protected function deleteFiles(Collection $files): void {
         $total = count($files);
         $this->ui->print($total . " temporary files to be removed");
 
@@ -129,18 +131,20 @@ class CleanTempFiles extends BaseCronJob implements CronjobInterface
 
         $this->ui->print('Deleting files...');
         $i = 0;
+        $deleted = 0;
         $this->ui->progress(0, $total);
         foreach ($files as $file) {
             try {
                 $i++;
                 $this->ui->progress($i, $total);
                 $this->storage->delete($file, $author);
+                $deleted++;
             } catch (\RuntimeException $e) {
                 $this->ui->print('ERROR : ' . $e->getMessage());
             }
         }
 
-        $this->ui->print($i . ' files deleted');
+        $this->ui->print($deleted . ' files deleted');
     }
 
     /**
@@ -149,9 +153,10 @@ class CleanTempFiles extends BaseCronJob implements CronjobInterface
      * @param bool $commit
      * @throws Exception
      */
-    private function purgeDb(bool $commit = true): void {
+    protected function purgeDb(bool $commit = true): void {
 
-        $maxDate = new \DateTime(self::PURGE_RECORDS_OLDER_THAN . ' days ago');
+        $maxDate = DatesUtils::new();
+        $maxDate->sub(new \DateInterval('P' . self::PURGE_RECORDS_OLDER_THAN . 'D'));
 
         $this->ui->print('Purge DB from records of files deleted before ' . $maxDate->format('c'));
 

+ 3 - 16
src/Service/Cron/Job/DolibarrSync.php

@@ -5,7 +5,6 @@ namespace App\Service\Cron\Job;
 use App\Service\Cron\BaseCronJob;
 use App\Service\Cron\CronjobInterface;
 use App\Service\Dolibarr\DolibarrSyncService;
-use Closure;
 use JetBrains\PhpStorm\Pure;
 
 /**
@@ -32,7 +31,7 @@ class DolibarrSync extends BaseCronJob implements CronjobInterface
      */
     public function preview(): void
     {
-        $operations = $this->dolibarrSyncService->scan($this->getProgressCallback());
+        $operations = $this->dolibarrSyncService->scan(function($i, $total) { $this->ui->progress($i, $total); });
 
         foreach ($operations as $i => $iValue) {
             $this->ui->print($i . '. ' . $iValue->getLabel());
@@ -49,27 +48,15 @@ class DolibarrSync extends BaseCronJob implements CronjobInterface
      */
     public function execute(): void
     {
-        $operations = $this->dolibarrSyncService->scan($this->getProgressCallback());
+        $operations = $this->dolibarrSyncService->scan(function($i, $total) { $this->ui->progress($i, $total); });
 
         $this->ui->print("Executing...");
 
-        $operations = $this->dolibarrSyncService->execute($operations, $this->getProgressCallback());
+        $operations = $this->dolibarrSyncService->execute($operations, function($i, $total) { $this->ui->progress($i, $total); });
 
         $successes = count(array_filter($operations, static function ($o) { return $o->getStatus() === $o::STATUS_DONE; } ));
         $errors = count(array_filter($operations, static function ($o) { return $o->getStatus() === $o::STATUS_ERROR; } ));
         $this->ui->print($successes . " operations successfully executed");
         $this->ui->print($errors . " errors");
     }
-
-    /**
-     * Pass a callback to update the progress bar state from DolibarrSyncService
-     *
-     * @return Closure
-     */
-    public function getProgressCallback(): Closure
-    {
-        return function($i, $total) {
-            $this->ui->progress($i, $total);
-        };
-    }
 }

+ 2 - 2
src/Service/Cron/UI/ConsoleUI.php

@@ -2,7 +2,7 @@
 
 namespace App\Service\Cron\UI;
 
-use Psr\Log\LoggerInterface;
+use App\Tests\Service\Cron\UI\MockableProgressBar;
 use Symfony\Component\Console\Helper\ProgressBar;
 use Symfony\Component\Console\Output\OutputInterface;
 
@@ -13,7 +13,7 @@ use Symfony\Component\Console\Output\OutputInterface;
  */
 class ConsoleUI implements CronUIInterface
 {
-    private ProgressBar $progressBar;
+    protected ProgressBar | MockableProgressBar $progressBar;
 
     public function __construct(
         private OutputInterface $output

+ 68 - 0
tests/Service/Cron/BaseCronJobTest.php

@@ -0,0 +1,68 @@
+<?php
+namespace App\Tests\Service\Cron;
+
+use App\Service\Cron\BaseCronJob;
+use App\Service\Cron\UI\ConsoleUI;
+use App\Service\Cron\UI\CronUIInterface;
+use App\Service\Cron\UI\SilentUI;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+class TestableBaseCronJob extends BaseCronJob {
+    public function getUI(): CronUIInterface { return $this->ui; }
+    public function getLogger(): LoggerInterface { return $this->logger; }
+}
+
+class BaseCronJobTest extends TestCase
+{
+    public function testName(): void
+    {
+        $job = new TestableBaseCronJob(); // can't use a mock here, because it'll mess up with the class's name
+
+        $this->assertEquals(
+            'testable-base-cron-job',
+            $job->name()
+        );
+    }
+
+    public function testSetLogger(): void {
+        $job = $this->getMockBuilder(TestableBaseCronJob::class)
+            ->setMethodsExcept(['setLoggerInterface', 'getLogger'])
+            ->getMock();
+
+        $logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock();
+
+        $job->setLoggerInterface($logger);
+
+        $this->assertEquals(
+            $logger,
+            $job->getLogger()
+        );
+    }
+
+    public function testDefaultUi(): void {
+        $job = new TestableBaseCronJob(); // can't use a mock here, because we need the constructor to run
+
+        $this->assertInstanceOf(
+            SilentUI::class,
+            $job->getUI()
+        );
+    }
+
+    public function testSetUi(): void {
+        $job = $this->getMockBuilder(TestableBaseCronJob::class)
+            ->setMethodsExcept(['setUI', 'getUI'])
+            ->getMock();
+
+        $ui = $this->getMockBuilder(ConsoleUI::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $job->setUI($ui);
+
+        $this->assertEquals(
+            $ui,
+            $job->getUI()
+        );
+    }
+}

+ 300 - 0
tests/Service/Cron/Job/CleanTempFilesTest.php

@@ -0,0 +1,300 @@
+<?php
+namespace App\Tests\Service\Cron\Job;
+
+use App\Entity\Access\Access;
+use App\Entity\Core\File;
+use App\Repository\Access\AccessRepository;
+use App\Repository\Core\FileRepository;
+use App\Service\Cron\Job\CleanTempFiles;
+use App\Service\Cron\UI\CronUIInterface;
+use App\Service\Storage\LocalStorage;
+use App\Service\Utils\DatesUtils;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\Common\Collections\Criteria;
+use Doctrine\ORM\AbstractQuery;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\Query;
+use Doctrine\ORM\Query\Expr;
+use Doctrine\ORM\QueryBuilder;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\Contracts\Service\Attribute\Required;
+
+class TestableCleanTempFile extends CleanTempFiles {
+    public function listFilesToDelete(): Collection { return parent::listFilesToDelete(); }
+    public function deleteFiles(Collection $files): void { parent::deleteFiles($files); }
+    public function purgeDb(bool $commit = true): void { parent::purgeDb($commit); }
+}
+
+class CleanTempFilesTest extends TestCase
+{
+    private CronUIInterface|MockObject $ui;
+    private MockObject|LoggerInterface $logger;
+    private FileRepository|MockObject $fileRepository;
+    private EntityManagerInterface|MockObject $em;
+    private AccessRepository|MockObject $accessRepository;
+    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->fileRepository = $this->getMockBuilder(FileRepository::class)->disableOriginalConstructor()->getMock();
+        $this->em = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $this->storage = $this->getMockBuilder(LocalStorage::class)->disableOriginalConstructor()->getMock();
+    }
+
+    private function getMockFor(string $method) {
+        $cleanTempFiles = $this->getMockBuilder(TestableCleanTempFile::class)
+            ->setConstructorArgs([$this->fileRepository, $this->em, $this->accessRepository, $this->storage])
+            ->setMethodsExcept([$method, 'setUI', 'setLoggerInterface'])
+            ->getMock();
+        $cleanTempFiles->setUI($this->ui);
+        $cleanTempFiles->setLoggerInterface($this->logger);
+
+        return $cleanTempFiles;
+    }
+
+    public function testPreview(): void
+    {
+        $cleanTempFiles = $this->getMockFor('preview');
+
+        $file1 = $this->getMockBuilder(File::class)->getMock();
+        $file1->method('getPath')->willReturn('/foo');
+
+        $file2 = $this->getMockBuilder(File::class)->getMock();
+        $file2->method('getPath')->willReturn('/bar');
+
+        $file3 = $this->getMockBuilder(File::class)->getMock();
+        $file3->method('getPath')->willReturn('/foo/bar');
+
+        $cleanTempFiles->method('listFilesToDelete')->willReturn(new ArrayCollection([$file1, $file2, $file3]));
+
+        $this->ui->expects(self::atLeastOnce())->method('print')->withConsecutive(
+            ["3 temporary files to be removed"],
+            ["> Printing the first and last 50 :"],
+            ["  * /foo"],
+            ["  * /bar"],
+            ["  * /foo/bar"]
+        );
+
+        $cleanTempFiles->expects(self::once())->method('purgeDb')->with(false);
+
+        $cleanTempFiles->preview();
+    }
+
+    public function testExecute(): void {
+        $cleanTempFiles = $this->getMockFor('execute');
+
+        $files = new ArrayCollection([
+            $this->getMockBuilder(File::class)->getMock(),
+            $this->getMockBuilder(File::class)->getMock(),
+            $this->getMockBuilder(File::class)->getMock()
+        ]);
+
+        $cleanTempFiles->method('listFilesToDelete')->willReturn($files);
+
+        $cleanTempFiles->expects(self::once())->method('deleteFiles')->with($files);
+
+        $cleanTempFiles->expects(self::once())->method('purgeDb')->with();
+
+        $cleanTempFiles->execute();
+    }
+
+    public function testListFilesToDelete(): void {
+        $cleanTempFiles = $this->getMockFor('listFilesToDelete');
+
+        DatesUtils::setFakeDatetime('2022-01-08 12:00:00');
+
+        $this->ui->expects(self::once())->method('print')->with(
+            'List temporary files created before 2022-01-01T12:00:00+00:00'
+        );
+
+        $files = new ArrayCollection([
+            $this->getMockBuilder(File::class)->getMock(),
+            $this->getMockBuilder(File::class)->getMock(),
+            $this->getMockBuilder(File::class)->getMock()
+        ]);
+
+        $this->fileRepository
+            ->expects(self::once())
+            ->method('matching')
+            ->with(
+                self::callback(static function (Criteria $c) {
+                    return $c instanceof Criteria; // TODO: trouver un moyen de tester le critère de façon plus précise
+                })
+            )->willReturn($files);
+
+        $this->assertEquals(
+            $files,
+            $cleanTempFiles->listFilesToDelete()
+        );
+    }
+
+    public function testDeleteFiles(): void
+    {
+        $cleanTempFiles = $this->getMockFor('deleteFiles');
+
+        $file1 = $this->getMockBuilder(File::class)->getMock();
+        $file2 = $this->getMockBuilder(File::class)->getMock();
+        $file3 = $this->getMockBuilder(File::class)->getMock();
+        $files = new ArrayCollection([$file1, $file2, $file3]);
+
+        $this->ui->expects(self::atLeastOnce())->method('print')->withConsecutive(
+            ['3 temporary files to be removed'],
+            ['Deleting files...'],
+            ['3 files deleted'],
+        );
+
+        $author = $this->getMockBuilder(Access::class)->getMock();
+        $this->accessRepository->method('find')->with(10984)->willReturn($author);
+
+        $this->ui->expects(self::exactly(4))->method('progress')->withConsecutive([0, 3], [1, 3], [2, 3], [3, 3]);
+
+        $this->storage
+            ->expects(self::exactly(3))
+            ->method('delete')
+            ->withConsecutive(
+                [$file1, $author],
+                [$file2, $author],
+                [$file3, $author]
+            );
+
+        $cleanTempFiles->deleteFiles($files);
+    }
+
+    public function testDeleteFilesUnknownAuthor(): void
+    {
+        $cleanTempFiles = $this->getMockFor('deleteFiles');
+
+        $file1 = $this->getMockBuilder(File::class)->getMock();
+        $file2 = $this->getMockBuilder(File::class)->getMock();
+        $file3 = $this->getMockBuilder(File::class)->getMock();
+        $files = new ArrayCollection([$file1, $file2, $file3]);
+
+        $this->accessRepository->method('find')->with(10984)->willReturn(null);
+
+        $this->ui->expects(self::never())->method('progress');
+        $this->storage->expects(self::never())->method('delete');
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Access 10984 could not be found');
+
+        $cleanTempFiles->deleteFiles($files);
+    }
+
+    public function testDeleteFilesDeletionError(): void
+    {
+        $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 = new ArrayCollection([$file1, $file2]);
+
+        $this->ui->expects(self::atLeastOnce())->method('print')->withConsecutive(
+            ['2 temporary files to be removed'],
+            ['Deleting files...'],
+            ['ERROR : foo'],
+            ['1 files deleted'],
+        );
+
+        $author = $this->getMockBuilder(Access::class)->getMock();
+        $this->accessRepository->method('find')->with(10984)->willReturn($author);
+
+        $this->ui->expects(self::exactly(3))->method('progress')->withConsecutive([0, 2], [1, 2], [2, 2]);
+
+        $this->storage
+            ->expects(self::exactly(2))
+            ->method('delete')
+            ->willReturnCallback(static function ($file, $author): File {
+                if ($file->getId() === 1) {
+                    throw new \RuntimeException('foo');
+                }
+                return $file;
+            });
+
+        $cleanTempFiles->deleteFiles($files);
+    }
+
+    public function testPurgeDb(): void {
+        $cleanTempFiles = $this->getMockFor('purgeDb');
+
+        DatesUtils::setFakeDatetime('2022-01-01 12:00:00');
+        $this->ui->expects(self::exactly(2))->method('print')->withConsecutive(
+            ['Purge DB from records of files deleted before 2021-01-01T12:00:00+00:00'],
+            ['DB purged - 3 records permanently deleted'],
+        );
+
+        $qb = $this->getMockBuilder(QueryBuilder::class)->disableOriginalConstructor()->getMock();
+        $this->em->expects(self::once())->method('createQueryBuilder')->willReturn($qb);
+
+        $exprBuilder = $this->getMockBuilder(Expr::class)->disableOriginalConstructor()->getMock();
+        $qb->method('expr')->willReturn($exprBuilder);
+
+        $cmp1 = $this->getMockBuilder(Expr::class)->disableOriginalConstructor()->getMock();
+        $exprBuilder->expects(self::once())->method('eq')->with('f.isTemporaryFile', 1)->willReturn($cmp1);
+
+        $cmp2 = $this->getMockBuilder(Expr::class)->disableOriginalConstructor()->getMock();
+        $exprBuilder->expects(self::once())->method('lt')->with('f.updateDate', ':maxDate')->willReturn($cmp2);
+
+        $qb->expects(self::once())->method('delete')->with('File', 'f')->willReturnSelf();
+        $qb->method('where')->with($cmp1)->willReturnSelf();
+        $qb->method('andWhere')->with($cmp2)->willReturnSelf();
+        $qb->method('setParameter')->with('maxDate', DatesUtils::new('2021-01-01T12:00:00+00:00'))->willReturnSelf();
+
+        $q = $this->getMockBuilder(AbstractQuery::class)->disableOriginalConstructor()->getMock();
+        $qb->method('getQuery')->willReturn($q);
+
+        $q->method('getResult')->willReturn(3);
+
+        $this->em->expects(self::once())->method('commit');
+
+        $cleanTempFiles->purgeDb();
+    }
+
+    public function testPurgeDbNoCommit(): void {
+        $cleanTempFiles = $this->getMockFor('purgeDb');
+
+        DatesUtils::setFakeDatetime('2022-01-01 12:00:00');
+        $this->ui->expects(self::exactly(2))->method('print')->withConsecutive(
+            ['Purge DB from records of files deleted before 2021-01-01T12:00:00+00:00'],
+            ['DB purged - 3 records would be permanently deleted'],
+        );
+
+        $qb = $this->getMockBuilder(QueryBuilder::class)->disableOriginalConstructor()->getMock();
+        $this->em->expects(self::once())->method('createQueryBuilder')->willReturn($qb);
+
+        $exprBuilder = $this->getMockBuilder(Expr::class)->disableOriginalConstructor()->getMock();
+        $qb->method('expr')->willReturn($exprBuilder);
+
+        $cmp1 = $this->getMockBuilder(Expr::class)->disableOriginalConstructor()->getMock();
+        $exprBuilder->expects(self::once())->method('eq')->with('f.isTemporaryFile', 1)->willReturn($cmp1);
+
+        $cmp2 = $this->getMockBuilder(Expr::class)->disableOriginalConstructor()->getMock();
+        $exprBuilder->expects(self::once())->method('lt')->with('f.updateDate', ':maxDate')->willReturn($cmp2);
+
+        $qb->expects(self::once())->method('delete')->with('File', 'f')->willReturnSelf();
+        $qb->method('where')->willReturnSelf();
+        $qb->method('andWhere')->willReturnSelf();
+        $qb->method('setParameter')->willReturnSelf();
+
+        $q = $this->getMockBuilder(AbstractQuery::class)->disableOriginalConstructor()->getMock();
+        $qb->method('getQuery')->willReturn($q);
+
+        $q->method('getResult')->willReturn(3);
+
+        $this->em->expects(self::never())->method('commit');
+        $this->em->expects(self::once())->method('rollback');
+
+        $cleanTempFiles->purgeDb(false);
+    }
+}

+ 103 - 0
tests/Service/Cron/Job/DolibarrSyncTest.php

@@ -0,0 +1,103 @@
+<?php
+
+namespace App\Tests\Service\Cron\Job;
+
+use App\Service\Cron\Job\DolibarrSync;
+use App\Service\Cron\UI\CronUIInterface;
+use App\Service\Dolibarr\DolibarrSyncService;
+use App\Service\Rest\Operation\UpdateOperation;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+class DolibarrSyncTest extends TestCase
+{
+    private CronUIInterface|MockObject $ui;
+    private MockObject|LoggerInterface $logger;
+    private DolibarrSyncService|MockObject $dolibarrSyncService;
+
+    public function setUp(): void {
+        $this->ui = $this->getMockBuilder(CronUIInterface::class)->disableOriginalConstructor()->getMock();
+        $this->logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->dolibarrSyncService = $this->getMockBuilder(DolibarrSyncService::class)->disableOriginalConstructor()->getMock();
+    }
+
+    private function getMockFor(string $method) {
+        $dolibarrSync = $this->getMockBuilder(DolibarrSync::class)
+            ->setConstructorArgs([$this->dolibarrSyncService])
+            ->setMethodsExcept([$method, 'setUI', 'setLoggerInterface'])
+            ->getMock();
+        $dolibarrSync->setUI($this->ui);
+        $dolibarrSync->setLoggerInterface($this->logger);
+
+        return $dolibarrSync;
+    }
+
+    public function testPreview(): void
+    {
+        $dolibarrSync = $this->getMockFor('preview');
+
+        $operation1 = $this->getMockBuilder(UpdateOperation::class)->disableOriginalConstructor()->getMock();
+        $operation1->method('getLabel')->willReturn('Op 1');
+        $operation1->method('getChangeLog')->willReturn(['foo', 'bar']);
+
+        $operation2 = $this->getMockBuilder(UpdateOperation::class)->disableOriginalConstructor()->getMock();
+        $operation2->method('getLabel')->willReturn('Op 2');
+        $operation2->method('getChangeLog')->willReturn([]);
+
+        $operation3 = $this->getMockBuilder(UpdateOperation::class)->disableOriginalConstructor()->getMock();
+        $operation3->method('getLabel')->willReturn('Op 3');
+        $operation3->method('getChangeLog')->willReturn(['hello', 'world']);
+
+        $this->dolibarrSyncService
+            ->expects(self::once())
+            ->method('scan')
+            ->willReturn([1 => $operation1, 2 => $operation2, 3 => $operation3]);
+
+        $this->ui->expects(self::atLeastOnce())->method('print')->withConsecutive(
+            ["1. Op 1"],
+            ["   foo"],
+            ["   bar"],
+            ["2. Op 2"],
+            ["3. Op 3"],
+            ["   hello"],
+            ["   world"],
+        );
+
+        $dolibarrSync->preview();
+    }
+
+    public function testExecute(): void {
+        $dolibarrSync = $this->getMockFor('execute');
+
+        $operation1 = $this->getMockBuilder(UpdateOperation::class)->disableOriginalConstructor()->getMock();
+        $operation1->method('getStatus')->willReturn(UpdateOperation::STATUS_DONE);
+
+        $operation2 = $this->getMockBuilder(UpdateOperation::class)->disableOriginalConstructor()->getMock();
+        $operation2->method('getStatus')->willReturn(UpdateOperation::STATUS_DONE);
+
+        $operation3 = $this->getMockBuilder(UpdateOperation::class)->disableOriginalConstructor()->getMock();
+        $operation3->method('getStatus')->willReturn(UpdateOperation::STATUS_ERROR);
+
+        $operations = [1 => $operation1, 2 => $operation2, 3 => $operation3];
+
+        $this->dolibarrSyncService
+            ->expects(self::once())
+            ->method('scan')
+            ->willReturn($operations);
+
+        $this->dolibarrSyncService
+            ->expects(self::once())
+            ->method('execute')
+            ->with($operations, self::isInstanceOf(\Closure::class))
+            ->willReturn($operations);
+
+        $this->ui->expects(self::atLeastOnce())->method('print')->withConsecutive(
+            ["Executing..."],
+            ["2 operations successfully executed"],
+            ["1 errors"],
+        );
+
+        $dolibarrSync->execute();
+    }
+}

+ 81 - 0
tests/Service/Cron/UI/ConsoleUITest.php

@@ -0,0 +1,81 @@
+<?php
+namespace App\Tests\Service\Cron\UI;
+
+use App\Service\Cron\UI\ConsoleUI;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class MockableProgressBar {
+    public function setProgress(int $step): void {}
+    public function setMaxSteps(int $max): void {}
+}
+
+class TestableConsoleUI extends ConsoleUI {
+    public function setProgressBar(MockableProgressBar $progressBar): void
+    {
+        $this->progressBar = $progressBar;
+    }
+}
+
+class ConsoleUITest extends TestCase
+{
+    private mixed $output;
+
+    public function setUp(): void
+    {
+        $this->output = $this->getMockBuilder(OutputInterface::class)->disableOriginalConstructor()->getMock();
+
+        $this->progressBar = $this->getMockBuilder(MockableProgressBar::class)->disableOriginalConstructor()->getMockForAbstractClass();
+    }
+
+    public function testPrint(): void
+    {
+        $consoleUI = $this->getMockBuilder(ConsoleUI::class)
+            ->setConstructorArgs([$this->output])
+            ->setMethodsExcept(['print'])
+            ->getMock();
+
+        $this->output->expects(self::once())->method('writeln')->with('foo');
+
+        $consoleUI->print('foo');
+    }
+
+    public function testProgress(): void {
+        $progressBar = $this->getMockBuilder(MockableProgressBar::class)->getMock();
+
+        $consoleUI = $this->getMockBuilder(TestableConsoleUI::class)
+            ->setConstructorArgs([$this->output])
+            ->setMethodsExcept(['progress', 'setProgressBar'])
+            ->getMock();
+        $consoleUI->setProgressBar($progressBar);
+
+        $progressBar->expects(self::once())
+            ->method('setProgress')
+            ->with(21);
+        $progressBar->expects(self::once())
+            ->method('setMaxSteps')
+            ->with(100);
+
+        $consoleUI->progress(21, 100);
+    }
+
+    public function testProgressEnd(): void {
+        $progressBar = $this->getMockBuilder(MockableProgressBar::class)->getMock();
+
+        $consoleUI = $this->getMockBuilder(TestableConsoleUI::class)
+            ->setConstructorArgs([$this->output])
+            ->setMethodsExcept(['progress', 'setProgressBar'])
+            ->getMock();
+        $consoleUI->setProgressBar($progressBar);
+
+        $progressBar->expects(self::once())
+            ->method('setProgress')
+            ->with(100);
+        $progressBar->expects(self::once())
+            ->method('setMaxSteps')
+            ->with(100);
+        $consoleUI->expects(self::once())->method('print')->with('');
+
+        $consoleUI->progress(100, 100);
+    }
+}

+ 68 - 0
tests/Service/ServiceIterator/CronjobIteratorTest.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Tests\Service\ServiceIterator;
+
+use App\Service\Cron\CronjobInterface;
+use App\Service\ServiceIterator\CronjobIterator;
+use PHPUnit\Framework\TestCase;
+
+class CronjobIteratorTest extends TestCase
+{
+    public function testGetByNameExisting(): void
+    {
+
+        $cronjob1 = $this->getMockBuilder(CronjobInterface::class)->getMock();
+        $cronjob1->expects(self::once())->method('name')->willReturn('foo');
+
+        $cronjob2 = $this->getMockBuilder(CronjobInterface::class)->getMock();
+        $cronjob2->expects(self::once())->method('name')->willReturn('bar');
+
+        $cronjob3 = $this->getMockBuilder(CronjobInterface::class)->getMock();
+        $cronjob3->expects(self::never())->method('name')->willReturn('zou');
+
+        $cronjobIterator = $this->getMockBuilder(CronjobIterator::class)
+            ->setConstructorArgs([[$cronjob1, $cronjob2, $cronjob3]])
+            ->setMethodsExcept(['getByName'])
+            ->getMock();
+
+        $this->assertEquals(
+            $cronjob2,
+            $cronjobIterator->getByName('bar')
+        );
+    }
+
+    public function testGetByNameNotExisting(): void
+    {
+
+        $cronjob1 = $this->getMockBuilder(CronjobInterface::class)->getMock();
+        $cronjob1->expects(self::once())->method('name')->willReturn('foo');
+
+        $cronjob2 = $this->getMockBuilder(CronjobInterface::class)->getMock();
+        $cronjob2->expects(self::once())->method('name')->willReturn('bar');
+
+        $cronjobIterator = $this->getMockBuilder(CronjobIterator::class)
+            ->setConstructorArgs([[$cronjob1, $cronjob2]])
+            ->setMethodsExcept(['getByName'])
+            ->getMock();
+
+        $this->expectException(\RuntimeException::class);
+
+        $cronjobIterator->getByName('other');
+    }
+
+    public function testGetAll(): void
+    {
+        $cronjob1 = $this->getMockBuilder(CronjobInterface::class)->getMock();
+        $cronjob2 = $this->getMockBuilder(CronjobInterface::class)->getMock();
+
+        $cronjobIterator = $this->getMockBuilder(CronjobIterator::class)
+            ->setConstructorArgs([[$cronjob1, $cronjob2]])
+            ->setMethodsExcept(['getAll'])
+            ->getMock();
+
+        $this->assertEquals(
+            [$cronjob1, $cronjob2],
+            $cronjobIterator->getAll()
+        );
+    }
+}

+ 52 - 0
tests/Service/ServiceIterator/Mailer/BuilderIteratorTest.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Tests\Service\ServiceIterator\Mailer;
+
+use App\Service\Mailer\Builder\BuilderInterface;
+use App\Service\Mailer\Model\MailerModelInterface;
+use App\Service\ServiceIterator\Mailer\BuilderIterator;
+use PHPUnit\Framework\TestCase;
+
+class BuilderIteratorTest extends TestCase
+{
+    public function testGetBuilderFor(): void {
+        $mailModel = $this->getMockBuilder(MailerModelInterface::class)->getMock();
+
+        $builder1 = $this->getMockBuilder(BuilderInterface::class)->getMock();
+        $builder1->expects(self::once())->method('support')->with($mailModel)->willReturn(false);
+
+        $builder2 = $this->getMockBuilder(BuilderInterface::class)->getMock();
+        $builder2->expects(self::once())->method('support')->with($mailModel)->willReturn(true);
+
+        $builder3 = $this->getMockBuilder(BuilderInterface::class)->getMock();
+        $builder3->expects(self::never())->method('support');
+
+        $builderIterator = $this->getMockBuilder(BuilderIterator::class)
+            ->setConstructorArgs([[$builder1, $builder2, $builder3]])
+            ->setMethodsExcept(['getBuilderFor'])
+            ->getMock();
+
+        $this->assertEquals(
+            $builder2,
+            $builderIterator->getBuilderFor($mailModel)
+        );
+    }
+
+    public function testGetBuilderForNotFound(): void {
+        $mailModel = $this->getMockBuilder(MailerModelInterface::class)->getMock();
+
+        $builder1 = $this->getMockBuilder(BuilderInterface::class)->getMock();
+        $builder1->expects(self::once())->method('support')->with($mailModel)->willReturn(false);
+
+        $builder2 = $this->getMockBuilder(BuilderInterface::class)->getMock();
+        $builder2->expects(self::once())->method('support')->with($mailModel)->willReturn(false);
+
+        $builderIterator = $this->getMockBuilder(BuilderIterator::class)
+            ->setConstructorArgs([[$builder1, $builder2]])
+            ->setMethodsExcept(['getBuilderFor'])
+            ->getMock();
+
+        $this->expectException(\Exception::class);
+        $builderIterator->getBuilderFor($mailModel);
+    }
+}