瀏覽代碼

Merge remote-tracking branch 'origin/feature/V8-3376' into develop

Conflicts:
	config/services.yaml
	src/Service/Storage/LocalStorage.php
Olivier Massot 2 年之前
父節點
當前提交
4e9c7ea118

+ 13 - 14
config/packages/docker/monolog.yaml

@@ -1,5 +1,6 @@
 # Voir doc/logging.md
 monolog:
+    channels: ['cron']
     handlers:
         # sorties standards (stdout, stderr, console)
         stderr:
@@ -45,33 +46,31 @@ monolog:
             max_files: 3
             channels: security
 
-        # logs spécifiques à certains process
-        # * synchro dolibarr
-        dolibarrsync:
+        # logs dédiés à l'exécution des cron-jobs
+        cron:
             type: group
-            members: [dolibarrsync_file]
-            channels: dolibarrsync
-        dolibarrsync_file:
+            members: [cron_file]
+            channels: cron
+        cron_file:
             type: rotating_file
-            path: "%kernel.logs_dir%/%kernel.environment%.dolibarrsync.log"
+            path: "%kernel.logs_dir%/%kernel.environment%.cron.log"
             level: debug
             max_files: 7
             formatter: monolog.formatter.message
-
-#        dolibarrsync_critical:
+#        cron_critical:
 #            type:           fingers_crossed
 #            action_level:   critical
-#            handler:        dolibarrsync_deduplicated
-#        dolibarrsync_deduplicated:
+#            handler:        cron_deduplicated
+#        cron_deduplicated:
 #            type: deduplication
 #            # the time in seconds during which duplicate entries are discarded (default: 60)
 #            time: 10
-#            handler: dolibarrsync_mailer
-#        dolibarrsync_mailer:
+#            handler: cron_mailer
+#        cron_mailer:
 #            type:           symfony_mailer
 #            from_email:     "process@opentalent.fr"
 #            to_email:       "exploitation@opentalent.fr"
-#            subject:        "Dolibarr Sync - Critical Error"
+#            subject:        "Cron - Critical Error"
 #            level:          error
 #            formatter:      monolog.formatter.html
 #            content_type:   text/html

+ 24 - 25
config/packages/prod/monolog.yaml

@@ -1,5 +1,6 @@
 # Voir doc/logging.md
 monolog:
+    channels: ['cron']
     handlers:
         # sorties standards (stdout, stderr, console)
         stderr:
@@ -45,36 +46,34 @@ monolog:
             max_files: 3
             channels: security
 
-        # logs spécifiques à certains process
-        # * synchro dolibarr
-        dolibarrsync:
+        # logs dédiés à l'exécution des cron-jobs
+        cron:
             type: group
-            members: [dolibarrsync_file]
-            channels: dolibarrsync
-        dolibarrsync_file:
+            members: [cron_file]
+            channels: cron
+        cron_file:
             type: rotating_file
-            path: "%kernel.logs_dir%/%kernel.environment%.dolibarrsync.log"
+            path: "%kernel.logs_dir%/%kernel.environment%.cron.log"
             level: debug
             max_files: 7
             formatter: monolog.formatter.message
-
-        #        dolibarrsync_critical:
-        #            type:           fingers_crossed
-        #            action_level:   critical
-        #            handler:        dolibarrsync_deduplicated
-        #        dolibarrsync_deduplicated:
-        #            type: deduplication
-        #            # the time in seconds during which duplicate entries are discarded (default: 60)
-        #            time: 10
-        #            handler: dolibarrsync_mailer
-        #        dolibarrsync_mailer:
-        #            type:           symfony_mailer
-        #            from_email:     "process@opentalent.fr"
-        #            to_email:       "exploitation@opentalent.fr"
-        #            subject:        "Dolibarr Sync - Critical Error"
-        #            level:          error
-        #            formatter:      monolog.formatter.html
-        #            content_type:   text/html
+#        cron_critical:
+#            type:           fingers_crossed
+#            action_level:   critical
+#            handler:        cron_deduplicated
+#        cron_deduplicated:
+#            type: deduplication
+#            # the time in seconds during which duplicate entries are discarded (default: 60)
+#            time: 10
+#            handler: cron_mailer
+#        cron_mailer:
+#            type:           symfony_mailer
+#            from_email:     "process@opentalent.fr"
+#            to_email:       "exploitation@opentalent.fr"
+#            subject:        "Cron - Critical Error"
+#            level:          error
+#            formatter:      monolog.formatter.html
+#            content_type:   text/html
 
 
         # uncomment to get logging in your browser

+ 4 - 4
config/services.yaml

@@ -62,6 +62,8 @@ services:
             tags: [ 'app.mailer.builder' ]
         App\Service\Twig\AssetsExtension:
             tags: [ 'twig.extension' ]
+        App\Service\Cron\CronjobInterface:
+            tags: [ 'app.cronjob' ]
 
     App\Service\ServiceIterator\CurrentAccessExtensionIterator:
         - !tagged_iterator app.extensions.access
@@ -73,10 +75,8 @@ services:
         - !tagged_iterator app.encoder
     App\Service\ServiceIterator\Mailer\BuilderIterator:
         - !tagged_iterator app.mailer.builder
-
-    App\Service\Dolibarr\DolibarrSyncService:
-        tags:
-            - { name: monolog.logger, channel: dolibarrsync }
+    App\Service\ServiceIterator\CronjobIterator:
+        - !tagged_iterator app.cronjob
 
     #########################################
     ##  SERIALIZER Decorates ##

+ 214 - 0
src/Commands/CronCommand.php

@@ -0,0 +1,214 @@
+<?php
+
+namespace App\Commands;
+
+use App\Service\Cron\CronJobInterface;
+use App\Service\Cron\UI\ConsoleUI;
+use App\Service\ServiceIterator\CronjobIterator;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Command\LockableTrait;
+use Symfony\Component\Console\Helper\FormatterHelper;
+use Symfony\Component\Console\Helper\ProgressBar;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Contracts\Service\Attribute\Required;
+
+/**
+ * CLI Command to run the cron-jobs
+ *
+ * Ex:
+ *
+ *     bin/console ot:cron list
+ *     bin/console ot:cron run clean-db --preview
+ *     bin/console ot:cron run clean-db
+ *
+ * @see ~/src/Service/Cron/Readme.md
+ */
+#[AsCommand(
+    name: 'ot:cron',
+    description: 'Executes cron jobs'
+)]
+class CronCommand extends Command
+{
+    use LockableTrait;
+
+    private const ACTION_LIST = 'list';
+    private const ACTION_RUN = 'run';
+    private const ACTION_RUN_ALL = 'all';
+
+    private const ACTIONS = [
+        self::ACTION_LIST => 'List registered jobs',
+        self::ACTION_RUN => 'Run the given job',
+        self::ACTION_RUN_ALL => 'Successively run all the registered cron jobs'
+    ];
+
+    private OutputInterface $output;
+    private LoggerInterface $logger;
+    private CronjobIterator $cronjobIterator;
+
+    #[Required]
+    /** @see https://symfony.com/doc/current/logging/channels_handlers.html#how-to-autowire-logger-channels */
+    public function setLoggerInterface(LoggerInterface $cronLogger): void { $this->logger = $cronLogger; }
+    #[Required]
+    public function setCronjobIterator(CronjobIterator $cronjobIterator): void { $this->cronjobIterator = $cronjobIterator; }
+
+    /**
+     * Configures the command
+     */
+    protected function configure(): void
+    {
+        $this->addArgument(
+            'action',
+            InputArgument::REQUIRED,
+            'Action to execute among : ' .
+            implode(
+                ', ',
+                array_map(
+                    static function ($v, $k) { return "'" . $k . "' (" . $v . ")"; },
+                    self::ACTIONS,
+                    array_keys(self::ACTIONS)
+                )
+            )
+        );
+        $this->addArgument(
+            'jobs',
+            InputArgument::OPTIONAL,
+            "Name(s) of the cron-job(s) to execute with '" . self::ACTION_RUN . "' (comma-separated)"
+        );
+        $this->addOption(
+            'preview',
+            'p',
+            InputOption::VALUE_NONE,
+            'Only preview the operations instead of executing them'
+        );
+    }
+
+    /**
+     * Executes the command
+     *
+     * @param InputInterface $input
+     * @param OutputInterface $output
+     * @return int
+     */
+    final protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $this->output = $output;
+        $formatter = $this->getHelper('formatter');
+
+        $action = $input->getArgument('action');
+        $jobNames = $input->getArgument('jobs');
+        $preview = $input->getOption('preview');
+        $jobs = [];
+
+        if (!array_key_exists($action, self::ACTIONS)) {
+            $this->output->writeln($formatter->formatBlock('Error: unrecognized action', 'error'));
+            return Command::INVALID;
+        }
+
+        if ($action === self::ACTION_LIST) {
+            $this->listJobs();
+            return Command::SUCCESS;
+        }
+
+        if ($action === self::ACTION_RUN_ALL) {
+            $jobs = $this->cronjobIterator->getAll();
+        }
+
+        if ($action === self::ACTION_RUN) {
+            foreach (explode(',', $jobNames) as $name) {
+                try {
+                    $jobs[] = $this->cronjobIterator->getByName($name);
+                } catch (\RuntimeException $e) {
+                    $this->output->writeln($e->getMessage());
+                    $this->listJobs();
+                    return Command::INVALID;
+                }
+            }
+        }
+
+        $this->logger->info(
+            'CronCommand will ' .
+            ($preview ? 'preview' : 'execute') . ' ' .
+            implode(', ', array_map(static function($job) { return $job->name(); }, $jobs))
+        );
+
+        $results = [];
+
+        foreach ($jobs as $job) {
+            $results[] = $this->runJob($job, $preview);
+        }
+
+        return max($results); // If there is one failure result, the whole command is shown as a failure too
+    }
+
+    /**
+     * List all available cron jobs
+     */
+    private function listJobs(): void {
+        $availableJobs = $this->cronjobIterator->getAll();
+
+        if (empty($availableJobs)) {
+            $this->output->writeln('No cronjob found');
+            return;
+        }
+
+        $this->output->writeln('Available cron jobs : ');
+        foreach ($this->cronjobIterator->getAll() as $job) {
+            $this->output->writeln('* ' . $job->name());
+        }
+    }
+
+    /**
+     * Run one Cronjob
+     *
+     * @param CronJobInterface $job
+     * @param bool $preview
+     * @return int
+     */
+    private function runJob(CronjobInterface $job, bool $preview = false): int
+    {
+        $formatter = $this->getHelper('formatter');
+
+        if (!$this->lock($job->name())) {
+            $msg = 'The command ' . $job->name() . ' is already running in another process. Abort.';
+            $this->output->writeln($formatter->formatBlock($msg, 'error'));
+            $this->logger->error($msg);
+            return Command::FAILURE;
+        }
+
+        $t0 = microtime(true);
+
+        $this->output->writeln(
+            $formatter->formatSection($job->name(),"Start" . ($preview ? ' [PREVIEW MODE]' : ''))
+        );
+
+        // Establish communication between job and the console
+        $ui = new ConsoleUI($this->output);
+        $job->setUI($ui);
+
+        try {
+            if ($preview) {
+                $job->preview();
+            } else {
+                $job->execute();
+            }
+        } catch (RuntimeException $e) {
+            $this->logger->critical($e);
+            $this->output->write("An error happened while running the process : " . $e);
+            return Command::FAILURE;
+        }
+
+        $t1 = microtime(true);
+
+        $msg = "Job has been successfully executed (" . round($t1 - $t0, 2) . " sec.)" . ($preview ? ' [PREVIEW MODE]' : '');
+        $this->output->writeln($formatter->formatSection($job->name(), $msg));
+        $this->logger->info($job->name() . ' - ' . $msg);
+
+        return Command::SUCCESS;
+    }
+}

+ 0 - 94
src/Commands/DolibarrSyncCommand.php

@@ -1,94 +0,0 @@
-<?php
-
-namespace App\Commands;
-
-use App\Service\Dolibarr\DolibarrSyncService;
-use Symfony\Component\Console\Attribute\AsCommand;
-use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Command\LockableTrait;
-use Symfony\Component\Console\Helper\ProgressBar;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Output\OutputInterface;
-
-#[AsCommand(
-    name: 'ot:dolibarr-sync',
-    description: 'Push the latest data from the Opentalent DB to dolibarr'
-)]
-class DolibarrSyncCommand extends Command
-{
-    use LockableTrait;
-
-    /**
-     * How many operations are shown each time the preview choice is made
-     */
-    const PREVIEW_CHUNK = 20;
-
-    public function __construct(
-        private DolibarrSyncService $dolibarrSyncService
-    ) {
-        parent::__construct();
-    }
-
-    protected function configure()
-    {
-        $this->addOption(
-            'preview',
-            'p',
-            InputOption::VALUE_NONE,
-            'Only preview the sync operations instead of executing it'
-        );
-    }
-
-    protected function execute(InputInterface $input, OutputInterface $output): int
-    {
-        if (!$this->lock()) {
-            $output->writeln('The command is already running in another process.');
-            return Command::SUCCESS;
-        }
-
-        $output->writeln("Start the synchronization");
-        $t0 = microtime(true);
-        $output->writeln("Scanning...");
-
-        $progressBar = new ProgressBar($output, 0);
-        $progressCallback = function($i, $total) use ($progressBar) {
-            if (!$progressBar->getMaxSteps() !== $total) {
-                $progressBar->setMaxSteps($total);
-            }
-            $progressBar->setProgress($i);
-        };
-
-        $operations = $this->dolibarrSyncService->scan($progressCallback);
-
-        $t1 = microtime(true);
-        $output->writeln("Scan lasted " . ($t1 - $t0) . " sec.");
-
-        $output->writeln(count($operations) . " operations to be executed");
-
-        if ($input->getOption('preview')) {
-            $output->writeln("-- Preview --");
-            foreach ($operations as $i => $iValue) {
-                $output->writeln($i . '. ' . $iValue->getLabel());
-                foreach ($iValue->getChangeLog() as $message) {
-                    $output->writeln('   ' . $message);
-                }
-            }
-        } else {
-            $t0 = microtime(true);
-            $output->writeln("Executing...");
-
-            $operations = $this->dolibarrSyncService->execute($operations, $progressCallback);
-
-            $successes = count(array_filter($operations, function ($o) { return $o->getStatus() === $o::STATUS_DONE; } ));
-            $errors = count(array_filter($operations, function ($o) { return $o->getStatus() === $o::STATUS_ERROR; } ));
-            $output->writeln($successes . " operations successfully executed");
-            $output->writeln($errors . " errors");
-
-            $t1 = microtime(true);
-            $output->writeln("Execution lasted " . ($t1 - $t0) . " sec.");
-        }
-
-        return Command::SUCCESS;
-    }
-}

+ 43 - 0
src/Service/Cron/BaseCronJob.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Service\Cron;
+
+use App\Service\Cron\UI\CronUIInterface;
+use App\Service\Cron\UI\SilentUI;
+use App\Service\Utils\StringsUtils;
+use JetBrains\PhpStorm\Pure;
+use Psr\Log\LoggerInterface;
+use Symfony\Contracts\Service\Attribute\Required;
+
+/**
+ * Base class for the Cron-jobs defined in \App\Service\Cron\Job
+ *
+ * This class shouldn't implement directly the CronjobInterface because it shall not be injected itself into the
+ * CronjobIterator, but all its subclasses should.
+ */
+abstract class BaseCronJob
+{
+    protected CronUIInterface $ui;
+    protected LoggerInterface $logger;
+
+    #[Pure]
+    public function __construct()
+    {
+        $this->ui = new SilentUI();
+    }
+
+    #[Required]
+    /** @see https://symfony.com/doc/current/logging/channels_handlers.html#how-to-autowire-logger-channels */
+    public function setLoggerInterface(LoggerInterface $cronLogger): void { $this->logger = $cronLogger; }
+
+    final public function name(): string {
+        return StringsUtils::camelToSnake(
+            preg_replace('/(?:\w+\\\)*(\w+)$/', '$1', static::class),
+            '-'
+        );
+    }
+
+    public function setUI(CronUIInterface $ui): void {
+        $this->ui = $ui;
+    }
+}

+ 20 - 0
src/Service/Cron/CronjobInterface.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Service\Cron;
+
+use App\Service\Cron\UI\CronUIInterface;
+use Psr\Log\LoggerInterface;
+
+/**
+ * A cron-job
+ *
+ * @see ~/src/Service/Cron/Readme.md
+ */
+interface CronjobInterface
+{
+    public function name(): string;
+    public function setUI(CronUIInterface $io): void;
+    public function setLoggerInterface(LoggerInterface $cronLogger): void;
+    public function preview(): void;
+    public function execute(): void;
+}

+ 160 - 0
src/Service/Cron/Job/CleanDb.php

@@ -0,0 +1,160 @@
+<?php
+
+namespace App\Service\Cron\Job;
+
+use App\Service\Cron\BaseCronJob;
+use App\Service\Cron\CronjobInterface;
+use App\Service\Utils\DatesUtils;
+use DateTime;
+use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\DBALException;
+use Exception;
+use JetBrains\PhpStorm\Pure;
+
+/**
+ * Cronjob that delete records older than N days in DB tables like Audit_ or Message
+ *
+ * >>> ot:cron clean-db
+ */
+// TODO: voir si ajouter :
+// * Information (282.000 lignes)? (avec NotificationUser, 169.000 lignes)
+// * MigrationLog (972.000 lignes)?
+// * ReportMessage? (4.500.000 lignes)
+
+// TODO: table potentiellement à supprimer dans la foulée : AccessTmp, Person_ori, Person_save, MigrationLog
+
+class CleanDb extends BaseCronJob implements CronjobInterface
+{
+    /**
+     * Delay before deleting the record of the temporary file from the DB
+     */
+    private const PURGE_RECORDS_OLDER_THAN = 365;
+
+    /**
+     * @param Connection $connection
+     */
+    #[Pure]
+    public function __construct(
+        private Connection $connection
+    ) {
+        parent::__construct();
+    }
+
+    /**
+     * Preview the result of the execution, without actually deleting anything
+     * @throws Exception
+     */
+    public function preview(): void
+    {
+        $this->purgeDb(false);
+    }
+
+    /**
+     * Proceed to the deletion of the files and the purge of the DB
+     * @throws Exception
+     */
+    public function execute(): void
+    {
+        $this->purgeDb();
+    }
+
+    /**
+     * Purge the DB from temporary file records older than N days
+     *
+     * @param bool $commit
+     * @throws Exception
+     * @throws \Doctrine\DBAL\Driver\Exception
+     */
+    protected function purgeDb(bool $commit = true): void
+    {
+        $maxDate = DatesUtils::new();
+        $maxDate->sub(new \DateInterval('P' . self::PURGE_RECORDS_OLDER_THAN . 'D'));
+
+        $this->ui->print('Purge DB from temporary records modified before ' . $maxDate->format('c'));
+        $this->connection->beginTransaction();
+
+        $purged = 0;
+
+        $purged += $this->purgeAuditTables($maxDate);
+//        $purged += $this->purgeMessages($maxDate);
+        $purged += $this->purgeLoginLog($maxDate);
+
+        if ($commit) {
+            $this->connection->commit();
+            $this->ui->print('DB purged - ' . $purged . ' records permanently deleted');
+            return;
+        }
+
+        $this->connection->rollback();
+        $this->ui->print('DB purged - ' . $purged . ' records would be permanently deleted');
+    }
+
+    /**
+     * Purge Access tables and returns the number of deleted records
+     *
+     * @param DateTime $maxDate
+     * @return int
+     * @throws DBALException
+     * @throws \Doctrine\DBAL\Driver\Exception
+     * @throws \Doctrine\DBAL\Exception
+     */
+    protected function purgeAuditTables(DateTime $maxDate): int
+    {
+        $this->ui->print('Purge Audit tables');
+
+        $tableNames = $this->connection->getSchemaManager()->listTableNames();
+
+        $total = 0;
+
+        foreach (array_values($tableNames) as $tableName) {
+            if (preg_match("/Audit_\w+/", $tableName)) {
+                /** @noinspection SqlWithoutWhere */
+                $sql = "DELETE a, r 
+                     FROM opentalent.{$tableName} a
+                     INNER JOIN opentalent.revisions r ON r.id = a.rev
+                     WHERE r.timestamp < :maxDate;";
+
+                $stmt = $this->connection->prepare($sql);
+                $purged = $stmt->executeStatement(['maxDate' => $maxDate->format('Y-m-d')]);
+
+                $this->ui->print('* ' . $tableName . ' : ' . $purged . ' lines to delete');
+
+                $total += $purged;
+            }
+        }
+
+        return $total;
+    }
+
+    /**
+     * Purge Message table and returns the number of deleted records
+     * TODO: à confirmer
+     *
+     * @param DateTime $maxDate
+     * @return int
+     * @throws \Doctrine\DBAL\Exception
+     */
+    protected function purgeMessages(DateTime $maxDate): int {
+        $q = $this->connection->createQueryBuilder();
+        $q->delete('Message')->where($q->expr()->lt('dateSent', $maxDate->format('Y-m-d')));
+
+        // TODO: tester si les tables message_documents et message_files sont bien nettoyées en cascade ou s'il faut
+        //       le faire manuellement
+        return $q->execute();
+    }
+
+    /**
+     * Purge LoginLog table and returns the number of deleted records
+     *
+     * @param DateTime $maxDate
+     * @return int
+     * @throws \Doctrine\DBAL\Exception
+     */
+    protected function purgeLoginLog(DateTime $maxDate): int {
+        $q = $this->connection->createQueryBuilder();
+        $q->delete('LoginLog')->where($q->expr()->lt('date', $maxDate->format('Y-m-d')));
+
+        return $q->execute();
+    }
+
+}

+ 187 - 0
src/Service/Cron/Job/CleanTempFiles.php

@@ -0,0 +1,187 @@
+<?php
+
+namespace App\Service\Cron\Job;
+
+use App\Repository\Access\AccessRepository;
+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;
+use Exception;
+use JetBrains\PhpStorm\Pure;
+
+/**
+ * Cronjob that delete temporary files older than N days
+ *
+ * >>> ot:cron clean-temp-files
+ */
+class CleanTempFiles extends BaseCronJob implements CronjobInterface
+{
+    /**
+     * Delay before removing a temporary file (in days)
+     */
+    private const DELETE_OLDER_THAN = 7;
+
+    /**
+     * ID of the Access who author the file deletion
+     */
+    private const AUTHOR = 10984;
+
+    /**
+     * Delay before deleting the record of the temporary file from the DB
+     */
+    private const PURGE_RECORDS_OLDER_THAN = 365;
+
+    /**
+     * @param FileRepository $fileRepository
+     * @param EntityManagerInterface $em
+     * @param AccessRepository $accessRepository
+     * @param LocalStorage $storage
+     */
+    #[Pure]
+    public function __construct(
+        private FileRepository $fileRepository,
+        private EntityManagerInterface $em,
+        private AccessRepository $accessRepository,
+        private LocalStorage $storage
+    ) {
+        parent::__construct();
+    }
+
+    /**
+     * Preview the result of the execution, without actually deleting anything
+     * @throws Exception
+     */
+    public function preview(): void
+    {
+        $files = $this->listFilesToDelete();
+        $total = count($files);
+        $this->ui->print($total . " temporary files to be removed");
+        $this->ui->print("> Printing the first and last 50 :");
+
+        $i = 0;
+        foreach ($files as $file) {
+            $i++;
+            if ($i > 50 && ($total - $i) > 50) { continue; }
+            if (($total - $i) === 50) {
+                $this->ui->print('  (...)');
+            }
+            $this->ui->print('  * ' . $file->getPath());
+        }
+
+        $this->purgeDb(false);
+    }
+
+    /**
+     * Proceed to the deletion of the files and the purge of the DB
+     * @throws Exception
+     */
+    public function execute(): void
+    {
+        $files = $this->listFilesToDelete();
+
+        $this->deleteFiles($files);
+
+        $this->purgeDb();
+    }
+
+    /**
+     * List the files to delete in the DB
+     *
+     * @return Collection
+     * @throws Exception
+     */
+    protected function listFilesToDelete(): Collection {
+
+        $maxDate = DatesUtils::new();
+        $maxDate->sub(new \DateInterval('P' . self::DELETE_OLDER_THAN . 'D'));
+
+        $this->ui->print('List temporary files created before ' . $maxDate->format('c'));
+
+        $criteria = new Criteria();
+        $criteria->where(
+            Criteria::expr()?->andX(
+                Criteria::expr()?->eq('isTemporaryFile', true),
+                Criteria::expr()?->orX(
+                    Criteria::expr()?->lt('createDate', $maxDate),
+                    Criteria::expr()?->isNull('createDate')
+                )
+            )
+        );
+
+        return $this->fileRepository->matching($criteria);
+    }
+
+    /**
+     * Delete the files
+     *
+     * @param Collection $files
+     */
+    protected function deleteFiles(Collection $files): void {
+        $total = count($files);
+        $this->ui->print($total . " temporary files to be removed");
+
+        $author = $this->accessRepository->find(self::AUTHOR);
+
+        if ($author === null) {
+            throw new \RuntimeException('Access ' . self::AUTHOR . ' could not be found');
+        }
+
+        $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($deleted . ' files deleted');
+    }
+
+    /**
+     * Purge the DB from temporary file records older than N days
+     *
+     * @param bool $commit
+     * @throws Exception
+     */
+    protected function purgeDb(bool $commit = true): void {
+
+        $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'));
+
+        $this->em->beginTransaction();
+
+        $queryBuilder = $this->em->createQueryBuilder();
+        $q = $queryBuilder
+            ->delete('File', 'f')
+            ->where($queryBuilder->expr()->eq('f.isTemporaryFile', 1))
+            ->andWhere($queryBuilder->expr()->lt('f.updateDate', ':maxDate'))
+            ->setParameter('maxDate', $maxDate)
+            ->getQuery();
+
+        $purged = $q->getResult();
+
+        if ($commit) {
+            $this->em->commit();
+            $this->ui->print('DB purged - ' . $purged . ' records permanently deleted');
+            return;
+        }
+
+        $this->em->rollback();
+        $this->ui->print('DB purged - ' . $purged . ' records would be permanently deleted');
+    }
+
+}

+ 64 - 0
src/Service/Cron/Job/DolibarrSync.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Service\Cron\Job;
+
+use App\Service\Cron\BaseCronJob;
+use App\Service\Cron\CronjobInterface;
+use App\Service\Dolibarr\DolibarrSyncService;
+use JetBrains\PhpStorm\Pure;
+
+/**
+ * Push the latest data from the Opentalent DB to dolibarr
+ *
+ * >>> ot:cron dolibarr-sync
+ */
+class DolibarrSync extends BaseCronJob implements CronjobInterface
+{
+    /**
+     * How many operations are shown each time the preview choice is made
+     */
+    public const PREVIEW_CHUNK = 20;
+
+    #[Pure]
+    public function __construct(
+        private DolibarrSyncService $dolibarrSyncService
+    ) {
+        parent::__construct();
+    }
+
+    /**
+     * Previews the sync operation without actually modifying anything
+     *
+     * @throws \Exception
+     */
+    public function preview(): void
+    {
+        $operations = $this->dolibarrSyncService->scan(function($i, $total) { $this->ui->progress($i, $total); });
+
+        foreach ($operations as $i => $iValue) {
+            $this->ui->print($i . '. ' . $iValue->getLabel());
+            foreach ($iValue->getChangeLog() as $message) {
+                $this->ui->print('   ' . $message);
+            }
+        }
+    }
+
+    /**
+     * Executes the sync
+     *
+     * @throws \Exception
+     */
+    public function execute(): void
+    {
+        $operations = $this->dolibarrSyncService->scan(function($i, $total) { $this->ui->progress($i, $total); });
+
+        $this->ui->print("Executing...");
+
+        $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");
+    }
+}

+ 11 - 0
src/Service/Cron/Readme.md

@@ -0,0 +1,11 @@
+## Cronjobs
+
+This namespace group the cronjobs that can be executed with 
+
+    ot:cron [options] <action> [<jobs>]
+
+Each of the cronjobs are in `App\Service\Cron\Job` and shall implement the CronjobInterface.
+
+The job name is the class name, formatted in SnakeCase with hyphen `-`
+
+Ex: `MySuperJob` job's name will be `my-super-job`

+ 44 - 0
src/Service/Cron/UI/ConsoleUI.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Service\Cron\UI;
+
+use App\Tests\Service\Cron\UI\MockableProgressBar;
+use Symfony\Component\Console\Helper\ProgressBar;
+use Symfony\Component\Console\Output\OutputInterface;
+
+/**
+ * Console user interface
+ *
+ * Use it to make communicate cron-jobs with command output
+ */
+class ConsoleUI implements CronUIInterface
+{
+    protected ProgressBar | MockableProgressBar $progressBar;
+
+    public function __construct(
+        private OutputInterface $output
+    ) {
+        $this->progressBar = new ProgressBar($output, 0);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function print(string $message): void
+    {
+        $this->output->writeln($message);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function progress(int $i, int $total): void
+    {
+        $this->progressBar->setMaxSteps($total);
+        $this->progressBar->setProgress($i);
+
+        if ($i === $total) {
+            $this->print(''); // to force a line break after the progression bar ends
+        }
+    }
+}

+ 24 - 0
src/Service/Cron/UI/CronUIInterface.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Service\Cron\UI;
+
+/**
+ * Establish communication between cron-jobs and the CLI output
+ */
+interface CronUIInterface
+{
+    /**
+     * Print a simple line of text
+     *
+     * @param string $message
+     */
+    public function print(string $message): void;
+
+    /**
+     * Update the current progress of the task
+     *
+     * @param int $i
+     * @param int $total
+     */
+    public function progress(int $i, int $total): void;
+}

+ 28 - 0
src/Service/Cron/UI/SilentUI.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Service\Cron\UI;
+
+/**
+ * Silent user interface
+ *
+ * This is the default interface between cron-jobs and commands, and it does nothing,
+ * but it can be replaced by other more verbose interfaces if needed.
+ */
+class SilentUI implements CronUIInterface
+{
+    /**
+     * @inheritDoc
+     */
+    public function print(string $message): void
+    {
+        // Do nothing
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function progress(int $i, int $total): void
+    {
+        // Do nothing
+    }
+}

+ 7 - 1
src/Service/Dolibarr/DolibarrSyncService.php

@@ -36,6 +36,7 @@ use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
 use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
 use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
 use Symfony\Contracts\HttpClient\ResponseInterface;
+use Symfony\Contracts\Service\Attribute\Required;
 use Symfony\Contracts\Translation\TranslatorInterface;
 
 /**
@@ -46,6 +47,8 @@ use Symfony\Contracts\Translation\TranslatorInterface;
  */
 class DolibarrSyncService
 {
+    private LoggerInterface $logger;
+
     public function __construct(
         private OrganizationRepository $organizationRepository,
         private AccessRepository $accessRepository,
@@ -54,9 +57,12 @@ class DolibarrSyncService
         private AddressPostalUtils $addressPostalUtils,
         private ArrayUtils $arrayUtils,
         private TranslatorInterface $translator,
-        private LoggerInterface $logger
     ) {}
 
+    #[Required]
+    /** @see https://symfony.com/doc/current/logging/channels_handlers.html#how-to-autowire-logger-channels */
+    public function setLoggerInterface(LoggerInterface $cronLogger): void { $this->logger = $cronLogger; }
+
     /**
      * Performs a scan, comparing data from the Opentalent DB and the data returned
      * by the Dolibarr API

+ 34 - 0
src/Service/ServiceIterator/CronjobIterator.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Service\ServiceIterator;
+
+use App\Service\Cron\CronjobInterface;
+
+/**
+ * Permet d'itérer sur les cronjobs
+ */
+class CronjobIterator
+{
+    /**
+     * Pour l'injection des services, voir config/services.yaml, section 'TAG Services'
+     * @param iterable $cronjobServices
+     */
+    public function __construct(
+        private iterable $cronjobServices,
+    ) {}
+
+    public function getByName(string $name): CronjobInterface
+    {
+        /** @var CronjobInterface $cronService */
+        foreach ($this->cronjobServices as $cronService){
+            if ($cronService->name() === $name) {
+                return $cronService;
+            }
+        }
+        throw new \RuntimeException('no cronjob service found with this name : ' . $name);
+    }
+
+    public function getAll(): iterable {
+        return $this->cronjobServices;
+    }
+}

+ 353 - 0
src/Service/Storage/LocalStorage.php

@@ -0,0 +1,353 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\Storage;
+
+use ApiPlatform\Core\Api\IriConverterInterface;
+use App\ApiResources\DownloadRequest;
+use App\Entity\Access\Access;
+use App\Entity\Core\File;
+use App\Entity\Organization\Organization;
+use App\Entity\Person\Person;
+use App\Enum\Core\FileStatusEnum;
+use App\Enum\Core\FileTypeEnum;
+use App\Repository\Access\AccessRepository;
+use App\Service\Utils\Path;
+use App\Service\Utils\Uuid;
+use DateTime;
+use Doctrine\ORM\EntityManagerInterface;
+use Gaufrette\Filesystem;
+use JetBrains\PhpStorm\Pure;
+use Knp\Bundle\GaufretteBundle\FilesystemMap;
+use Mimey\MimeTypes;
+use RuntimeException;
+
+/**
+ * Read and write files into the file storage
+ */
+class LocalStorage implements FileStorageInterface
+{
+    /**
+     * Key of the gaufrette storage, as defined in config/packages/knp_gaufrette.yaml
+     */
+    protected const FS_KEY = 'storage';
+
+    protected Filesystem $filesystem;
+
+    public function __construct(
+        protected FilesystemMap $filesystemMap,
+        protected EntityManagerInterface $entityManager,
+        protected AccessRepository $accessRepository,
+        protected IriConverterInterface $iriConverter
+    )
+    {
+        $this->filesystem = $filesystemMap->get(static::FS_KEY);
+    }
+
+    /**
+     * Return true if the file exists in the file storage
+     *
+     * @param File $file
+     * @return bool
+     */
+    public function exists(File $file): bool {
+        return $this->filesystem->has($file->getSlug());
+    }
+
+    /**
+     * Get the IRI to download this file (eg: /api/download/1)
+     *
+     * @param File $file
+     * @return string
+     */
+    public function getDownloadIri(File $file): string
+    {
+        return $this->iriConverter->getItemIriFromResourceClass(
+            DownloadRequest::class,
+            ['fileId' => $file->getId()]
+        );
+    }
+
+    /**
+     * Lists all the non-temporary files of the given owner
+     *
+     * @param Organization|Access|Person $owner
+     * @param FileTypeEnum|null $type
+     * @return array
+     */
+    public function listByOwner (
+        Organization | Access | Person $owner,
+        ?FileTypeEnum $type = null
+    ): array {
+        return $this->filesystem->listKeys(
+            $this->getPrefix($owner, false, $type?->getValue())
+        );
+    }
+
+    /**
+     * Reads the given file and returns its content as a string
+     *
+     * @param File $file
+     * @return string
+     */
+    public function read(File $file): string
+    {
+        return $this->filesystem->read($file->getSlug());
+    }
+
+    /**
+     * Prepare a File record with a PENDING status.
+     * This record will hold all the data needed to create the file, except its content.
+     *
+     * @param Organization|Access|Person $owner Owner of the file, either an organization, a person or both (access)
+     * @param string $filename The file's name (mandatory)
+     * @param FileTypeEnum $type The type of the new file
+     * @param Access $createdBy Id of the access responsible for this creation
+     * @param bool $isTemporary Is it a temporary file that can be deleted after some time
+     * @param string|null $mimeType Mimetype of the file, if not provided, the method will try to guess it from its file name's extension
+     * @param string $visibility
+     * @param bool $flushFile Should the newly created file be flushed after having been persisted?
+     * @return File
+     */
+    public function prepareFile(
+        Organization | Access | Person $owner,
+        string $filename,
+        FileTypeEnum $type,
+        Access $createdBy,
+        bool $isTemporary = false,
+        string $visibility = 'NOBODY',
+        string $mimeType = null,
+        bool $flushFile = true
+    ): File
+    {
+        [$organization, $person] = $this->getOrganizationAndPersonFromOwner($owner);
+
+        $file = (new File())
+            ->setName($filename)
+            ->setOrganization($organization)
+            ->setPerson($person)
+            ->setSlug(null)
+            ->setType($type->getValue())
+            ->setVisibility($visibility)
+            ->setIsTemporaryFile($isTemporary)
+            ->setMimeType($mimeType ?? self::guessMimeTypeFromFilename($filename))
+            ->setCreateDate(new DateTime())
+            ->setCreatedBy($createdBy->getId())
+            ->setStatus(FileStatusEnum::PENDING()->getValue());
+
+        $this->entityManager->persist($file);
+
+        if ($flushFile) {
+            $this->entityManager->flush();
+        }
+
+        return $file;
+    }
+
+    /**
+     * Write the $content into the file storage and update the given File object's size, slug, status (READY)...
+     *
+     * @param File $file The file object that is about to be written
+     * @param string $content The content of the file
+     * @param Access $author The access responsible for the creation / update of the file
+     * @return File
+     */
+    public function writeFile(File $file, string $content, Access $author): File
+    {
+        if (empty($file->getName())) {
+            throw new RuntimeException('File has no filename');
+        }
+
+        $isNewFile = $file->getSlug() === null;
+        if ($isNewFile) {
+            // Try to get the Access owner from the organization_id and person_id
+            $access = null;
+            if ($file->getOrganization() !== null && $file->getPerson() !== null) {
+                $access = $this->accessRepository->findOneBy(
+                    ['organization' => $file->getOrganization(), 'person' => $file->getPerson()]
+                );
+            }
+
+            $prefix = $this->getPrefix(
+                $access ?? $file->getOrganization() ?? $file->getPerson(),
+                $file->getIsTemporaryFile(),
+                $file->getType()
+            );
+
+            $uid = date('Ymd_His') . '_' . Uuid::uuid(5);
+
+            $key = Path::join($prefix, $uid, $file->getName());
+        } else {
+            $key = $file->getSlug();
+        }
+
+        if (!$isNewFile && !$this->filesystem->has($key)) {
+            throw new RuntimeException('The file `' . $key . '` does not exist in the file storage');
+        }
+
+        $size = $this->filesystem->write($key, $content, true);
+
+        $file->setSize($size)
+             ->setStatus(FileStatusEnum::READY()->getValue());
+
+        if ($isNewFile) {
+            $file->setSlug($key)
+                 ->setCreateDate(new DateTime())
+                 ->setCreatedBy($author->getId());
+        } else {
+            $file->setUpdateDate(new DateTime())
+                 ->setUpdatedBy($author->getId());
+        }
+
+        $this->entityManager->flush();
+
+        return $file;
+    }
+
+    /**
+     * Convenient method to successively prepare and write a file
+     *
+     * @param Organization|Access|Person $owner
+     * @param string $filename
+     * @param FileTypeEnum $type
+     * @param string $content
+     * @param Access $author
+     * @param bool $isTemporary
+     * @param string|null $mimeType
+     * @param string $visibility
+     * @return File
+     */
+    public function makeFile (
+        Organization | Access | Person $owner,
+        string                         $filename,
+        FileTypeEnum                   $type,
+        string                         $content,
+        Access                         $author,
+        bool                           $isTemporary = false,
+        string                         $visibility = 'NOBODY',
+        string                         $mimeType = null
+    ): File
+    {
+        $file = $this->prepareFile(
+            $owner,
+            $filename,
+            $type,
+            $author,
+            $isTemporary,
+            $visibility,
+            $mimeType,
+            false
+        );
+
+        return $this->writeFile($file, $content, $author);
+    }
+
+    /**
+     * Delete the given file from the filesystem and update the status of the File
+     *
+     * @param File $file
+     * @param Access $author
+     * @return File
+     */
+    public function delete(File $file, Access $author): File
+    {
+        $deleted = $this->filesystem->delete($file->getSlug());
+
+        if (!$deleted) {
+            throw new RuntimeException('File `' . $file->getSlug() . '` could\'nt be deleted');
+        }
+
+        $file->setStatus(FileStatusEnum::DELETED()->getValue())
+             ->setSize(0)
+             ->setUpdatedBy($author->getId());
+
+        $this->entityManager->flush();
+
+        return $file;
+    }
+
+    /**
+     * Return the mimetype corresponding to the givent file extension
+     *
+     * @param string $ext
+     * @return string|null
+     */
+    public static function getMimeTypeFromExt(string $ext): string | null {
+        return (new MimeTypes)->getMimeType(ltrim($ext, '.'));
+    }
+
+    /**
+     * Try to guess the mimetype from the filename
+     *
+     * Return null if it did not manage to guess it.
+     *
+     * @param string $filename
+     * @return string|null
+     */
+    public static function guessMimeTypeFromFilename(string $filename): string | null {
+        $ext = pathinfo($filename, PATHINFO_EXTENSION);
+        if (empty($ext)) {
+            return null;
+        }
+        return self::getMimeTypeFromExt($ext);
+    }
+
+    /**
+     * If an organization owns the file, the prefix will be '(_temp_/)organization/{id}(/{type})'.
+     * If a person owns it, the prefix will be '(_temp_/)person/{id}(/{type})'
+     * If access owns it, the prefix will be '(_temp_/)organization/{organization_id}/{access_id}(/{type})'
+     *
+     * With {id} being the id of the organization or of the person.
+     *
+     * If the file is temporary, '_temp_/' is prepended to the prefix.
+     * If a file type is given, this type is appended to the prefix (low case)
+     *
+     * @param Organization|Access|Person $owner
+     * @param bool $isTemporary
+     * @param string|null $type
+     * @return string
+     */
+    protected function getPrefix(
+        Organization | Access | Person $owner,
+        bool $isTemporary,
+        string $type = null
+    ): string
+    {
+        if ($owner instanceof Access) {
+            $prefix = Path::join('organization', $owner->getOrganization()?->getId(), $owner->getId());
+        } else if ($owner instanceof Organization) {
+            $prefix = Path::join('organization', $owner->getId());
+        } else {
+            $prefix = Path::join('person', $owner->getId());
+        }
+
+        if ($isTemporary) {
+            $prefix = Path::join('temp', $prefix);
+        }
+
+        if ($type !== null && $type !== FileTypeEnum::NONE()->getValue()) {
+            $prefix = Path::join($prefix, strtolower($type));
+        }
+
+        return $prefix;
+    }
+
+    /**
+     * Return an array [$organization, $person] from a given owner
+     *
+     * @param Organization|Access|Person $owner
+     * @return array
+     */
+    #[Pure]
+    protected function getOrganizationAndPersonFromOwner(Organization | Access | Person $owner): array {
+        if ($owner instanceof Access) {
+            return [$owner->getOrganization(), $owner->getPerson()];
+        }
+
+        if ($owner instanceof Organization) {
+            return [$owner, null];
+        }
+
+        return [null, $owner];
+    }
+}

+ 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()
+        );
+    }
+}

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

@@ -0,0 +1,312 @@
+<?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'
+        );
+
+        $file1 = $this->getMockBuilder(File::class)->getMock(); // Temporary, but recent : do not remove
+        $file1->method('getId')->willReturn(1);
+        $file1->method('getCreateDate')->willReturn(new \DateTime('2022-01-02'));
+        $file1->method('getIsTemporaryFile')->willReturn(true);
+
+        $file2 = $this->getMockBuilder(File::class)->getMock(); // Temporary and 1-year-old : remove
+        $file1->method('getId')->willReturn(2);
+        $file2->method('getCreateDate')->willReturn(new \DateTime('2021-01-01'));
+        $file2->method('getIsTemporaryFile')->willReturn(true);
+
+        $file3 = $this->getMockBuilder(File::class)->getMock(); // 2 years old but not temporary : do not remove
+        $file1->method('getId')->willReturn(3);
+        $file3->method('getCreateDate')->willReturn(new \DateTime('2020-01-01'));
+        $file3->method('getIsTemporaryFile')->willReturn(false);
+
+        $files = new ArrayCollection([$file1, $file2, $file3]);
+
+        $this->fileRepository
+            ->expects(self::once())
+            ->method('matching')
+            ->with(
+                self::callback(static function (Criteria $c) use ($files) {
+                    $matching = $files->matching($c)->toArray();
+                    return count($matching) === 1 && array_values($matching)[0] === $files[1];
+                })
+            )->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);
+    }
+}

+ 41 - 142
tests/Service/Dolibarr/DolibarrSyncServiceTest.php

@@ -99,6 +99,17 @@ class DolibarrSyncServiceTest extends TestCase
         $this->logger->method('error')->willReturnSelf();
     }
 
+    private function getMockForMethod(string $method) {
+        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
+            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
+                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator])
+            ->setMethodsExcept([$method, 'setLoggerInterface'])
+            ->getMock();
+        $dolibarrSyncService->setLoggerInterface($this->logger);
+
+        return $dolibarrSyncService;
+    }
+
     /**
      * Full test of the scan method
      *
@@ -106,12 +117,7 @@ class DolibarrSyncServiceTest extends TestCase
      */
     public function testScan(): void
     {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['scan'])
-            ->getMock();
-
+        $dolibarrSyncService = $this->getMockForMethod('scan');
 
         // ----- Opentalent Organizations -----
         $orgId1 = 10;
@@ -575,11 +581,7 @@ class DolibarrSyncServiceTest extends TestCase
      */
     public function testExecute(): void
     {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['execute'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('execute');
 
         $progressionCallbackExpectedCalls = [[1, 3], [2, 3], [3, 3]];
 
@@ -622,11 +624,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::run()
      */
     public function testRun() {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['run'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('run');
 
         $operations = ['operation1', 'operation2'];
 
@@ -643,11 +641,7 @@ class DolibarrSyncServiceTest extends TestCase
      */
     public function testGetDolibarrSocietiesIndex(): void
     {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['getDolibarrSocietiesIndex'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('getDolibarrSocietiesIndex');
 
         $this->dolibarrApiService
             ->expects($this->once())
@@ -685,11 +679,7 @@ class DolibarrSyncServiceTest extends TestCase
      */
     public function testGetActiveMembersIndex(): void
     {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['getActiveMembersIndex'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('getActiveMembersIndex');
 
         $this->accessRepository
             ->expects($this->once())
@@ -716,11 +706,7 @@ class DolibarrSyncServiceTest extends TestCase
      */
     public function testFindDolibarrContactForById(): void
     {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['findDolibarrContactFor'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('findDolibarrContactFor');
 
         $contacts = [
             ['id' => 1, 'array_options' => ['options_2iopen_person_id' => 101]],
@@ -740,11 +726,7 @@ class DolibarrSyncServiceTest extends TestCase
      */
     public function testFindDolibarrContactForByName(): void
     {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['findDolibarrContactFor'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('findDolibarrContactFor');
 
         $contacts = [
             ['id' => 1, 'firstname' => 'mister', 'lastname' => 'X', 'array_options' => ['options_2iopen_person_id' => null]],
@@ -767,11 +749,7 @@ class DolibarrSyncServiceTest extends TestCase
      */
     public function testFindDolibarrContactForByNameWithConflict(): void
     {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['findDolibarrContactFor'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('findDolibarrContactFor');
 
         $contacts = [
             ['id' => 1, 'firstname' => 'mister', 'lastname' => 'X', 'array_options' => ['options_2iopen_person_id' => 1]],
@@ -795,11 +773,7 @@ class DolibarrSyncServiceTest extends TestCase
      */
     public function testFindDolibarrContactNotFound(): void
     {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['findDolibarrContactFor'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('findDolibarrContactFor');
 
         $contacts = [
             ['id' => 1, 'firstname' => 'mister', 'lastname' => 'X', 'array_options' => ['options_2iopen_person_id' => 1]],
@@ -822,11 +796,7 @@ class DolibarrSyncServiceTest extends TestCase
      */
     public function testSanitizeDolibarrData(): void
     {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['sanitizeDolibarrData'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('sanitizeDolibarrData');
 
         $result = $dolibarrSyncService->sanitizeDolibarrData(['a' => 'A', 'b' => '', 'c' => ['d' => 'D', 'e' => '']]);
 
@@ -841,11 +811,7 @@ class DolibarrSyncServiceTest extends TestCase
      */
     public function testSanitizeDolibarrDataWithNull(): void
     {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['sanitizeDolibarrData'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('sanitizeDolibarrData');
 
         $result = $dolibarrSyncService->sanitizeDolibarrData(null);
         $this->assertEquals(null, $result);
@@ -856,11 +822,7 @@ class DolibarrSyncServiceTest extends TestCase
      */
     public function testGetOrganizationPostalAddress(): void
     {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['getOrganizationPostalAddress'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('getOrganizationPostalAddress');
 
         $organization = $this->getMockBuilder(Organization::class)->getMock();
         $organizationAddressPostal1 = $this->getMockBuilder(OrganizationAddressPostal::class)->getMock();
@@ -891,11 +853,7 @@ class DolibarrSyncServiceTest extends TestCase
      */
     public function testGetOrganizationPostalAddressNoResult(): void
     {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['getOrganizationPostalAddress'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('getOrganizationPostalAddress');
 
         $organization = $this->getMockBuilder(Organization::class)->getMock();
         $organization->expects($this->once())
@@ -913,11 +871,7 @@ class DolibarrSyncServiceTest extends TestCase
      */
     public function testGetOrganizationPhoneWithExistingPhone(): void
     {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['getOrganizationPhone'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('getOrganizationPhone');
 
         $contactPoint1 = $this->getMockBuilder(ContactPoint::class)->getMock();
         $contactPoint2 = $this->getMockBuilder(ContactPoint::class)->getMock();
@@ -949,12 +903,9 @@ class DolibarrSyncServiceTest extends TestCase
     /**
      * @see DolibarrSyncService::getOrganizationPhone()
      */
-    public function testGetOrganizationPhoneWithMobilePhone() {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['getOrganizationPhone'])
-            ->getMock();
+    public function testGetOrganizationPhoneWithMobilePhone()
+    {
+        $dolibarrSyncService = $this->getMockForMethod('getOrganizationPhone');
 
         $contactPoint1 = $this->getMockBuilder(ContactPoint::class)->getMock();
         $contactPoint2 = $this->getMockBuilder(ContactPoint::class)->getMock();
@@ -989,11 +940,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::getOrganizationPhone()
      */
     public function testGetOrganizationPhoneWithNoPhone() {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['getOrganizationPhone'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('getOrganizationPhone');
 
         $organization = $this->getMockBuilder(Organization::class)->getMock();
         $organization
@@ -1013,11 +960,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::getOrganizationEmail()
      */
     public function testGetOrganizationEmailWithExistingEmail() {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['getOrganizationEmail'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('getOrganizationEmail');
 
         $contactPoint1 = $this->getMockBuilder(ContactPoint::class)->getMock();
         $contactPoint2 = $this->getMockBuilder(ContactPoint::class)->getMock();
@@ -1047,11 +990,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::getOrganizationEmail()
      */
     public function testGetOrganizationEmailWithNoEmail() {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['getOrganizationEmail'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('getOrganizationEmail');
 
         $organization = $this->getMockBuilder(Organization::class)->getMock();
         $organization
@@ -1069,11 +1008,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::getOrganizationNetworkId()
      */
     public function testGetOrganizationNetworkId() {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['getOrganizationNetworkId'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('getOrganizationNetworkId');
 
         $organization = $this->getMockBuilder(Organization::class)->getMock();
         $network = $this->getMockBuilder(Network::class)->getMock();
@@ -1092,11 +1027,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::getOrganizationNetworkId()
      */
     public function testGetOrganizationNetworkIdWithMultipleResult() {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['getOrganizationNetworkId'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('getOrganizationNetworkId');
 
         $network1 = $this->getMockBuilder(Network::class)->getMock();
         $network1->method('getId')->willReturn(3);
@@ -1125,11 +1056,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::getOrganizationNetworkId()
      */
     public function testGetOrganizationNetworkIdWithNoResult() {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['getOrganizationNetworkId'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('getOrganizationNetworkId');
 
         $organization = $this->getMockBuilder(Organization::class)->getMock();
         $network = $this->getMockBuilder(Network::class)->getMock();
@@ -1149,11 +1076,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::countWithMission()
      */
     public function testCountWithMission() {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['countWithMission'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('countWithMission');
 
         $members = [
             123 => [FunctionEnum::PRESIDENT()->getValue(), FunctionEnum::TEACHER()->getValue()],
@@ -1190,11 +1113,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::getPersonContact()
      */
     public function testGetPersonContact() {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['getPersonContact'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('getPersonContact');
 
         $person = $this->getMockBuilder(Person::class)->getMock();
 
@@ -1224,11 +1143,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::formatContactPosition()
      */
     public function testFormatContactPosition() {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['formatContactPosition'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('formatContactPosition');
 
         $this->translator->method('trans')->willReturnMap(
             [
@@ -1304,11 +1219,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::formatPhoneNumber()
      */
     public function testFormatPhoneNumber() {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['formatPhoneNumber'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('formatPhoneNumber');
 
         $phoneUtil = PhoneNumberUtil::getInstance();
         $phoneNumber = $phoneUtil->parse('01 02 03 04 05', "FR");
@@ -1322,11 +1233,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::validateResponse()
      */
     public function testValidateResponse(): void {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['validateResponse'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('validateResponse');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
         $response->method('toArray')->willReturn(['a' => 1]);
@@ -1345,11 +1252,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::validateResponse()
      */
     public function testValidateResponseInvalid(): void {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['validateResponse'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('validateResponse');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
         $response->method('toArray')->willReturn(['a' => 1]);
@@ -1375,11 +1278,7 @@ class DolibarrSyncServiceTest extends TestCase
      * @see DolibarrSyncService::validateResponse()
      */
     public function testValidateResponseRequestError(): void {
-        $dolibarrSyncService = $this->getMockBuilder(TestableDolibarrSyncService::class)
-            ->setConstructorArgs([$this->organizationRepository, $this->accessRepository, $this->functionTypeRepository,
-                $this->dolibarrApiService, $this->addressPostalUtils, $this->arrayUtils, $this->translator, $this->logger])
-            ->setMethodsExcept(['validateResponse'])
-            ->getMock();
+        $dolibarrSyncService = $this->getMockForMethod('validateResponse');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
         $response->method('getInfo')->willReturnMap([

+ 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);
+    }
+}