Pārlūkot izejas kodu

Merge branch 'feature/files-downloader' into develop

Olivier Massot 3 gadi atpakaļ
vecāks
revīzija
b48499661d
58 mainītis faili ar 3122 papildinājumiem un 438 dzēšanām
  1. 4 0
      .env.docker
  2. 4 0
      .env.preprod
  3. 4 0
      .env.prod
  4. 4 0
      .env.test
  5. 1 0
      composer.json
  6. 45 1
      composer.lock
  7. 1 0
      config/opentalent/products.yaml
  8. 2 0
      config/packages/framework.yaml
  9. 5 5
      config/packages/knp_gaufrette.yaml
  10. 1 0
      config/routes.yaml
  11. 1 0
      config/services.yaml
  12. 44 0
      src/ApiResources/DownloadRequest.php
  13. 2 3
      src/ApiResources/Export/ExportRequest.php
  14. 149 0
      src/Commands/TestCommand.php
  15. 8 24
      src/DataPersister/Export/LicenceCmf/ExportRequestDataPersister.php
  16. 67 0
      src/DataProvider/Core/DownloadRequestDataProvider.php
  17. 104 82
      src/Entity/Core/File.php
  18. 2 0
      src/Enum/Core/FileStatusEnum.php
  19. 17 0
      src/Enum/Core/FileTypeEnum.php
  20. 0 21
      src/Enum/Export/ExportFormatEnum.php
  21. 4 0
      src/Security/Voter/ModuleVoter.php
  22. 8 2
      src/Service/Access/OptionalsRoles/CriteriaNotationOptionalRole.php
  23. 63 0
      src/Service/ApiLegacy/ApiLegacyRequestService.php
  24. 11 12
      src/Service/Constraint/AbstractTimeConstraintUtils.php
  25. 15 12
      src/Service/Constraint/ActivityYearConstraint.php
  26. 7 7
      src/Service/Constraint/DateTimeConstraint.php
  27. 1 1
      src/Service/Core/AddressPostalUtils.php
  28. 5 4
      src/Service/Cotisation/Utils.php
  29. 3 4
      src/Service/Dolibarr/DolibarrSyncService.php
  30. 47 52
      src/Service/Export/BaseExporter.php
  31. 9 0
      src/Service/Export/ExporterInterface.php
  32. 16 17
      src/Service/Export/LicenceCmfExporter.php
  33. 3 3
      src/Service/Rest/ApiRequestService.php
  34. 1 2
      src/Service/ServiceIterator/EncoderIterator.php
  35. 33 0
      src/Service/Storage/ApiLegacyStorage.php
  36. 0 22
      src/Service/Storage/FileStorage.php
  37. 10 0
      src/Service/Storage/FileStorageInterface.php
  38. 351 0
      src/Service/Storage/LocalStorage.php
  39. 0 43
      src/Service/Storage/TemporaryFileStorage.php
  40. 0 31
      src/Service/Storage/UploadStorage.php
  41. 24 0
      src/Service/Utils/Uuid.php
  42. 23 23
      templates/export/licence_cmf.html.twig
  43. 109 0
      tests/Service/Access/OptionalsRoles/CriteriaNotationOptionalRoleTest.php
  44. 194 0
      tests/Service/ApiLegacy/ApiLegacyRequestServiceTest.php
  45. 311 0
      tests/Service/Constraint/ActivityYearConstraintTest.php
  46. 243 12
      tests/Service/Constraint/DateTimeConstraintTest.php
  47. 44 0
      tests/Service/Core/AddressPostalUtilsTest.php
  48. 29 1
      tests/Service/Core/ContactPointUtilsTest.php
  49. 21 1
      tests/Service/Cotisation/UtilsTest.php
  50. 102 0
      tests/Service/Dolibarr/DolibarrApiServiceTest.php
  51. 73 0
      tests/Service/Dolibarr/DolibarrSyncServiceTest.php
  52. 35 0
      tests/Service/Elasticsearch/EducationNotationUpdaterTest.php
  53. 186 0
      tests/Service/Export/BaseExporterTest.php
  54. 10 14
      tests/Service/Export/LicenceCmfExporterTest.php
  55. 36 0
      tests/Service/Storage/ApiLegacyStorageTest.php
  56. 615 0
      tests/Service/Storage/LocalStorageTest.php
  57. 0 39
      tests/Service/Storage/TemporaryFileStorageTest.php
  58. 15 0
      tests/Service/Utils/UuidTest.php

+ 4 - 0
.env.docker

@@ -10,6 +10,10 @@ DATABASE_URL=mysql://root:mysql660@db:3306/opentalent?serverVersion=5.7
 CORS_ALLOW_ORIGIN=^https?://(localhost|127\.0\.0\.1)(:[0-9]+)|local.admin.opentalent.fr|local.app.opentalent.fr?$
 ###< nelmio/cors-bundle ###
 
+###> api v1 ###
+API_LEG_BASE_URL=http://nginx/
+###< files management ###
+
 ###> BlackFire configuration ###
 BLACKFIRE_CLIENT_ID=988fcba8-552d-48df-a9c2-035c76535b69
 BLACKFIRE_CLIENT_TOKEN=8cfbeb263d044da9678dc2612531504da3790c308da7448e35724a5da91c136f

+ 4 - 0
.env.preprod

@@ -10,6 +10,10 @@ DATABASE_URL=mysql://root:mysql2iopenservice369566@preprod:3306/opentalent?serve
 CORS_ALLOW_ORIGIN=^https?://(localhost|127\.0\.0\.1)(:[0-9]+)$
 ###< nelmio/cors-bundle ###
 
+###> api v1 ###
+API_LEG_BASE_URL=https://api.preprod.opentalent.fr/api
+###< files management ###
+
 ###> typo3 client ###
 TYPO3_BASE_URI=http://preprod.opentalent.fr/ohcluses
 ###< typo3 client ###

+ 4 - 0
.env.prod

@@ -12,6 +12,10 @@ DATABASE_URL=mysql://root:mysql2iopenservice369566@prod-back:3306/opentalent?ser
 CORS_ALLOW_ORIGIN=^https?://(localhost|127\.0\.0\.1)(:[0-9]+)$
 ###< nelmio/cors-bundle ###
 
+###> api v1 ###
+API_LEG_BASE_URL=https://api.opentalent.fr/api
+###< files management ###
+
 ###> BlackFire configuration ###
 BLACKFIRE_CLIENT_ID=988fcba8-552d-48df-a9c2-035c76535b69
 BLACKFIRE_CLIENT_TOKEN=8cfbeb263d044da9678dc2612531504da3790c308da7448e35724a5da91c136f

+ 4 - 0
.env.test

@@ -2,6 +2,10 @@
 APP_DEBUG=1
 ###< symfony/framework-bundle ###
 
+###> api v1 ###
+API_LEG_BASE_URL=https://api.test.opentalent.fr/api
+###< files management ###
+
 # define your env variables for the test env here
 KERNEL_CLASS='App\Kernel'
 APP_SECRET='$ecretf0rt3st'

+ 1 - 0
composer.json

@@ -28,6 +28,7 @@
         "nelmio/cors-bundle": "^2.1",
         "odolbeau/phone-number-bundle": "^3.1",
         "phpdocumentor/reflection-docblock": "^5.2",
+        "ralouphie/mimey": "^1.0",
         "ramsey/uuid": "^4.2",
         "symfony/asset": "5.4.*",
         "symfony/console": "5.4.*",

+ 45 - 1
composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "378b9f072670572212d31cd607d806d6",
+    "content-hash": "33f34a695bb495d5bb86099e78f2670d",
     "packages": [
         {
             "name": "api-platform/core",
@@ -3579,6 +3579,50 @@
             },
             "time": "2021-05-03T11:20:27+00:00"
         },
+        {
+            "name": "ralouphie/mimey",
+            "version": "1.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ralouphie/mimey.git",
+                "reference": "2a0e997c733b7c2f9f8b61cafb006fd5fb9fa15a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ralouphie/mimey/zipball/2a0e997c733b7c2f9f8b61cafb006fd5fb9fa15a",
+                "reference": "2a0e997c733b7c2f9f8b61cafb006fd5fb9fa15a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "~3.7.0",
+                "satooshi/php-coveralls": ">=1.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Mimey\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ralph Khattar",
+                    "email": "ralph.khattar@gmail.com"
+                }
+            ],
+            "description": "PHP package for converting file extensions to MIME types and vice versa.",
+            "support": {
+                "issues": "https://github.com/ralouphie/mimey/issues",
+                "source": "https://github.com/ralouphie/mimey/tree/master"
+            },
+            "time": "2016-09-28T03:36:23+00:00"
+        },
         {
             "name": "ramsey/collection",
             "version": "1.2.2",

+ 1 - 0
config/opentalent/products.yaml

@@ -14,6 +14,7 @@ opentalent:
           - Tagg
           - Enum
           - LicenceCmfOrganizationER
+          - DownloadRequest
         roles:
           - ROLE_IMPORT
           - ROLE_TAGG

+ 2 - 0
config/packages/framework.yaml

@@ -36,3 +36,5 @@ framework:
                 base_uri: '%env(MOBYT_API_BASE_URI)%'
                 headers:
                     Content-Type: 'application/json'
+            apiLegacyClient:
+                base_uri: '%env(API_LEG_BASE_URL)%'

+ 5 - 5
config/packages/knp_gaufrette.yaml

@@ -1,11 +1,11 @@
 # @see https://github.com/KnpLabs/KnpGaufretteBundle
+# Documentation : https://knplabs.github.io/Gaufrette/basic-usage.html
 knp_gaufrette:
   adapters:
-    temp:
+    storage:
       local:
-        directory: '%kernel.project_dir%/var/files/temp'
+        directory: '%kernel.project_dir%/var/files/storage'
         create: true
   filesystems:
-    temp:
-      adapter: temp
-      alias: temp
+    storage:
+      adapter: storage

+ 1 - 0
config/routes.yaml

@@ -6,6 +6,7 @@ swagger_ui:
   path: /docs
   controller: api_platform.swagger.action.ui
 
+# todo: comment ça s'articule avec le LocalStorage ça?
 ot_internal_secure_file_download:
   path: /_internal/secure/files/{id}
   controller: App\Controller\FileController::downloadFile

+ 1 - 0
config/services.yaml

@@ -9,6 +9,7 @@ services:
         autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
         bind:
             $opentalentConfig: '%kernel.project_dir%%env(OPENTALENT_CONFIG)%'
+            $apiLegacyBaseUrl: '%env(API_LEG_BASE_URL)%'
             $internalFilesUploadUri: '%env(INTERNAL_FILES_DOWNLOAD_URI)%'
             $bindfileBufferFile: '%env(BIND_FILE_BUFFER_FILE)%'
             $contextAwareDataPersister: '@api_platform.doctrine.orm.data_persister'

+ 44 - 0
src/ApiResources/DownloadRequest.php

@@ -0,0 +1,44 @@
+<?php
+declare(strict_types=1);
+
+namespace App\ApiResources;
+
+use ApiPlatform\Core\Annotation\ApiProperty;
+use ApiPlatform\Core\Annotation\ApiResource;
+
+/**
+ * A request for a file from the LocalStorage
+ */
+#[ApiResource(
+    collectionOperations:[],
+    itemOperations: [
+        'get' => [
+            'security' => 'is_granted("ROLE_FILE")',
+            'method' => 'GET',
+            'path' => '/download/{fileId}',
+            'requirements' => ['fileId' => '\d+']
+        ],
+    ],
+    compositeIdentifier: false,
+)]
+class DownloadRequest
+{
+    #[ApiProperty(identifier: true)]
+    private int $fileId;
+
+    /**
+     * @return int
+     */
+    public function getFileId(): int
+    {
+        return $this->fileId;
+    }
+
+    /**
+     * @param int $fileId
+     */
+    public function setFileId(int $fileId): void
+    {
+        $this->fileId = $fileId;
+    }
+}

+ 2 - 3
src/ApiResources/Export/ExportRequest.php

@@ -4,9 +4,8 @@ declare(strict_types=1);
 namespace App\ApiResources\Export;
 
 use ApiPlatform\Core\Annotation\ApiProperty;
-use ApiPlatform\Core\Annotation\ApiResource;
-use App\Entity\Access\Access;
 use Symfony\Component\Validator\Constraints as Assert;
+use App\Enum\Export\ExportFormatEnum;
 
 /**
  * Demande d'export d'un fichier
@@ -27,7 +26,7 @@ abstract class ExportRequest
      * Format de sortie attendu (pdf, txt...)
      * @var string
      */
-    #[Assert\Choice(callback: ['\App\Enum\Export\ExportFormatEnum', 'toArray'], message: 'invalid-output-format')]
+    #[Assert\Choice(callback: [ExportFormatEnum::class, 'toArray'], message: 'invalid-output-format')]
     protected string $format;
 
     /**

+ 149 - 0
src/Commands/TestCommand.php

@@ -0,0 +1,149 @@
+<?php
+
+namespace App\Commands\PostUpgrade\V0_2;
+
+use PDO;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+#[AsCommand(
+    name: 'ot:upgrade:0.2',
+    description: 'Execute the post-upgrade scripts for Ap2i v0.2'
+)]
+class PostUpgradeCommand extends Command
+{
+    public const TARGETED_VERSION = "0.2";
+
+    public function __construct(private LoggerInterface $logger) {
+        parent::__construct();
+    }
+
+    protected function configure(): void
+    {}
+
+    /**
+     * @throws \Exception
+     */
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $this->logger->info('Run post-upgrade scripts for version ' . self::TARGETED_VERSION);
+
+        $this->populateSubdomains();
+
+        $output->writeln("Post-upgrade operations successfully executed");
+        return Command::SUCCESS;
+    }
+
+    /**
+     * Populate the new Subdomain table
+     * @throws \Exception
+     */
+    public function populateSubdomains() {
+
+        $dbUrl = $_ENV['DATABASE_URL'];
+        $matches = [];
+        preg_match(
+            "/^mysql:\/\/(\w+):([^\s@]+)@([\w\-]+):(\d+)\/(\w+)/",
+            $dbUrl,
+            $matches
+        );
+        [$dbUser, $dbPwd, $dbHost, $dbPort, $dbName] = array_slice($matches, 1);
+
+        $opentalentCnn = new PDO(
+            "mysql:host=" . $dbHost . ";dbname=" . $dbName,
+            $dbUser,
+            $dbPwd,
+            array(PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8'));
+        $opentalentCnn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+        $opentalentCnn->beginTransaction();
+
+        $openassosCnn = new PDO(
+            "mysql:host=prod-front;dbname=openassos",
+            'dbcloner',
+            'wWZ4hYcrmHLW2mUK',
+            array(PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8'));
+
+        try {
+            $stmt = $opentalentCnn->query("select count(*) from opentalent.Subdomain;");
+            if ($stmt->fetchColumn(0)[0] > 0) {
+                throw new \RuntimeException('Subdomain table is not empty');
+            }
+
+            $this->logger->info('Populate with reserved subdomains');
+            $reservedSubdomains = [
+                'app', 'my', 'api', 'ap2i', 'assistance', 'local', 'ressources', 'logs', 'stats', 'support', 'preprod',
+                'test', 'admin', 'statistiques', 'drive', 'cloud', 'git', 'frames', 'v6', 'v59', 'www', 'myadmin'
+            ];
+            foreach ($reservedSubdomains as $reserved) {
+                $sql = "insert into opentalent.Subdomain (organization_id, subdomain, active)
+                    values (13, '" . $reserved . "', 0);";
+                $opentalentCnn->query($sql);
+            }
+
+            $this->logger->info('Populate Subdomain table from openassos.sys_domain');
+
+            $sql = "SELECT d.pid, REGEXP_REPLACE(d.domainName, '^(.+)\\\\.opentalent\\\\.fr$', '\\\\1')
+                    FROM openassos.sys_domain d
+                    where d.domainName like '%.opentalent.fr';";
+            $statement = $openassosCnn->query($sql);
+
+            foreach ($statement->fetchAll() as $row) {
+                [$cmsId, $subdomain] = $row;
+                if (!empty($subdomain) and is_numeric($cmsId)) {
+                    $sql = "INSERT INTO opentalent.Subdomain (organization_id, subdomain)
+                        SELECT o.id, '" . $subdomain . "'
+                        from opentalent.Organization o 
+                        where o.cmsId = " . $cmsId . ";";
+                    $opentalentCnn->query($sql);
+                }
+            }
+
+            $sql = "delete
+                    from opentalent.Subdomain
+                    where subdomain REGEXP '^(.*)\\\\.(.*)$'
+                    and REGEXP_REPLACE(subdomain, '\\\\.', '-') in (select subdomain from opentalent.Subdomain);";
+            $opentalentCnn->query($sql);
+
+            $sql = "update opentalent.Subdomain
+                    set subdomain = REGEXP_REPLACE(subdomain, '\\\\.', '-')
+                    where subdomain REGEXP '^(.*)\\\\.(.*)$';";
+            $opentalentCnn->query($sql);
+
+            $this->logger->info('Complete with subdomains from Parameters table');
+            $sql = "insert into opentalent.Subdomain (organization_id, subdomain)
+                    select distinct o.id, p.subDomain
+                    from opentalent.Parameters p
+                    inner join opentalent.Organization o on o.parameters_id = p.id
+                    left join opentalent.Subdomain s on s.organization_id = o.id
+                    where p.subDomain is not null and not p.subDomain in (select subdomain from opentalent.Subdomain);";
+            $opentalentCnn->query($sql);
+
+            $this->logger->info('Set the current subdomains');
+            $sql = "update opentalent.Subdomain s set s.active = false;";
+            $opentalentCnn->query($sql);
+
+            $sql = "update opentalent.Subdomain s
+                    inner join opentalent.Organization o on o.id = s.organization_id
+                    inner join opentalent.Parameters p on p.id = o.parameters_id and s.subdomain = p.subDomain
+                    set s.active = true;";
+            $opentalentCnn->query($sql);
+
+            $this->logger->info('Set the custom domains');
+            $sql = "update opentalent.Parameters
+                    set customDomain = otherWebsite
+                    where otherWebsite not like '%.opentalent.fr'";
+            $opentalentCnn->query($sql);
+
+            $opentalentCnn->commit();
+            $this->logger->info('Subdomain table was successfully populated');
+        } catch (\Exception $e) {
+            $opentalentCnn->rollBack();
+            $this->logger->critical('Error while running the post-upgrade script, abort and rollback');
+            throw $e;
+        }
+    }
+}

+ 8 - 24
src/DataPersister/Export/LicenceCmf/ExportRequestDataPersister.php

@@ -7,19 +7,15 @@ use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
 use App\ApiResources\Export\ExportRequest;
 use App\Entity\Access\Access;
 use App\Entity\Core\File;
-use App\Enum\Core\FileStatusEnum;
 use App\Message\Command\Export;
 use App\Service\ServiceIterator\ExporterIterator;
-use Doctrine\ORM\EntityManagerInterface;
 use Exception;
-use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\Messenger\MessageBusInterface;
 use Symfony\Component\Security\Core\Security;
 
 class ExportRequestDataPersister implements ContextAwareDataPersisterInterface
 {
     public function __construct(
-        private EntityManagerInterface $em,
         private Security               $security,
         private MessageBusInterface    $messageBus,
         private ExporterIterator       $handler
@@ -33,8 +29,9 @@ class ExportRequestDataPersister implements ContextAwareDataPersisterInterface
     /**
      * @param $exportRequest ExportRequest Une requête d'export
      * @param array $context
-     * @return JsonResponse
+     * @return File
      * @throws Exception
+     * @noinspection PhpParameterNameChangedDuringInheritanceInspection
      */
     public function persist($exportRequest, array $context = []): File
     {
@@ -42,24 +39,14 @@ class ExportRequestDataPersister implements ContextAwareDataPersisterInterface
         $access = $this->security->getUser();
         $exportRequest->setRequesterId($access->getId());
 
-        $file = new File();
-        $file->setOrganization($access->getOrganization());
-        $file->setVisibility('NOBODY');
-        $file->setFolder('DOCUMENTS');
-        $file->setCreateDate(new \DateTime());
-        $file->setCreatedBy($access->getId());
-        $file->setStatus(FileStatusEnum::PENDING()->getValue());
-        $file->setName('');
-        $file->setPath('');
-        $file->setSlug('');
-        $file->setIsTemporaryFile(true);
-        $this->em->persist($file);
-        $this->em->flush();
+        // Prepare the file record and attach its id to the export request
+        $exporter = $this->handler->getExporterFor($exportRequest);
+        $file = $exporter->prepareFile($exportRequest, true);
 
         $exportRequest->setFileId($file->getId());
 
         if (!$exportRequest->isAsync()) {
-            return $this->handler->getExporterFor($exportRequest)->export($exportRequest);
+            return $exporter->export($exportRequest);
         }
 
         // Send the export request to Messenger (@see App\Message\Handler\ExportHandler)
@@ -69,11 +56,8 @@ class ExportRequestDataPersister implements ContextAwareDataPersisterInterface
         return $file;
     }
 
-    /**
-     * @throws Exception
-     */
-    public function remove($data, array $context = [])
+    public function remove($data, array $context = []): void
     {
-        throw new Exception('not supported', 500);
+        throw new \RuntimeException('not supported', 500);
     }
 }

+ 67 - 0
src/DataProvider/Core/DownloadRequestDataProvider.php

@@ -0,0 +1,67 @@
+<?php
+declare(strict_types=1);
+
+namespace App\DataProvider\Core;
+
+use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
+use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
+use App\ApiResources\DownloadRequest;
+use App\Enum\Core\FileStatusEnum;
+use App\Repository\Core\FileRepository;
+use App\Service\Storage\ApiLegacyStorage;
+use App\Service\Storage\LocalStorage;
+use Symfony\Component\HttpFoundation\HeaderUtils;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Custom provider pour le téléchargement des fichiers du LocalStorage
+ */
+final class DownloadRequestDataProvider implements ItemDataProviderInterface, RestrictedDataProviderInterface
+{
+    public function __construct(
+        private FileRepository $fileRepository,
+        private LocalStorage   $localStorage,
+        private ApiLegacyStorage $apiLegacyStorage
+    )
+    {}
+
+    public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
+    {
+        return DownloadRequest::class === $resourceClass;
+    }
+
+    public function getItem(string $resourceClass, $id, string $operationName = null, array $context = []): Response | RedirectResponse
+    {
+        $file = $this->fileRepository->find($id);
+        if (empty($file)) {
+            throw new \RuntimeException("File " . $id . " does not exist; abort.");
+        }
+        if ($file->getStatus() !== FileStatusEnum::READY()->getValue()) {
+            throw new \RuntimeException("File " . $id . " has " . $file->getStatus() . " status; abort.");
+        }
+
+        // Storage depends on the file's host :
+        $storage = $file->getHost() === 'ap2i' ? $this->localStorage : $this->apiLegacyStorage;
+
+        // Serves the file :
+        // @see https://symfony.com/doc/current/components/http_foundation.html#serving-files
+        $content = $storage->read($file);
+
+        $response = new Response($content);
+
+        $response->headers->set('Charset', 'UTF-8');
+        $response->headers->set('Access-Control-Expose-Headers', 'Content-Disposition');
+
+        if (!empty($file->getMimeType())) {
+            $response->headers->set('Content-Type', $file->getMimeType());
+        }
+
+        $response->headers->set(
+            'Content-Disposition',
+            HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $file->getName())
+        );
+
+        return $response;
+    }
+}

+ 104 - 82
src/Entity/Core/File.php

@@ -9,7 +9,6 @@ use App\Entity\Booking\Event;
 use App\Entity\Booking\EventReport;
 use App\Entity\Booking\Work;
 use App\Entity\Message\TemplateSystem;
-use App\Entity\Network\Network;
 use App\Entity\Organization\Activity;
 use App\Entity\Organization\OnlineRegistrationSettings;
 use App\Entity\Organization\Organization;
@@ -43,33 +42,33 @@ class File
     /**
      * Propriétaire du fichier
      *
-     * @var Person
+     * @var Person|null
      */
     #[ORM\ManyToOne(inversedBy: 'files')]
     #[ORM\JoinColumn(name: 'person_id', referencedColumnName: 'id')]
-    private Person $person;
+    private ?Person $person;
 
     /**
      * Organisation propriétaire du fichier
-     * @var Organization
+     * @var Organization|null
      */
     #[ORM\ManyToOne(inversedBy: 'files')]
     #[ORM\JoinColumn(name: 'organization_id', referencedColumnName: 'id')]
-    private Organization $organization;
+    private ?Organization $organization;
 
     /**
      * Slug du fichier (i.e. le chemin d'accès relatif)
-     * @var string
+     * @var string|null
      */
     #[ORM\Column(length: 255)]
-    private string $slug;
+    private ?string $slug = null;
 
     /**
      * Chemin d'accès du fichier
-     * @var string
+     * @var string|null
      */
     #[ORM\Column(length: 255)]
-    private string $path;
+    private ?string $path = null;
 
     /**
      * Nom du fichier
@@ -99,13 +98,6 @@ class File
     #[ORM\Column(type: 'text', length: 255, nullable: true)]
     private ?string $config;
 
-    /**
-     * Dossier contenant le fichier
-     * @var string
-     */
-    #[ORM\Column(length: 24)]
-    private string $folder;
-
     /**
      * Type de document (uploaded, mail, bill...etc)
      * @var string
@@ -166,6 +158,14 @@ class File
     #[Assert\Choice(callback: [FileStatusEnum::class, 'toArray'])]
     private ?string $status = null;
 
+    /**
+     * Statut du fichier (en cours de génération, prêt, supprimé, etc.)
+     * @var string | null
+     */
+    #[ORM\Column(length: 5)]
+    #[Assert\Choice(['api', 'ap2i'])]
+    private ?string $host = 'ap2i';
+
 //    #[ORM\Column]
 //    private ?int $eventReport_id;
 //
@@ -250,54 +250,60 @@ class File
     }
 
     /**
-     * @return Person
+     * @return Person|null
      */
-    public function getPerson(): Person
+    public function getPerson(): ?Person
     {
         return $this->person;
     }
 
     /**
-     * @param Person $person
+     * @param Person|null $person
+     * @return File
      */
-    public function setPerson(Person $person): void
+    public function setPerson(?Person $person): self
     {
         $this->person = $person;
+
+        return $this;
     }
 
     /**
-     * @return Organization
+     * @return Organization|null
      */
-    public function getOrganization(): Organization
+    public function getOrganization(): ?Organization
     {
         return $this->organization;
     }
 
     /**
-     * @param Organization $organization
+     * @param Organization|null $organization
+     * @return File
      */
-    public function setOrganization(Organization $organization): void
+    public function setOrganization(?Organization $organization): self
     {
         $this->organization = $organization;
+
+        return $this;
     }
 
-    public function getSlug(): string
+    public function getSlug(): ?string
     {
         return $this->slug;
     }
 
-    public function setSlug(string $slug): self
+    public function setSlug(?string $slug): self
     {
         $this->slug = $slug;
         return $this;
     }
 
-    public function getPath(): string
+    public function getPath(): ?string
     {
         return $this->path;
     }
 
-    public function setPath(string $path): self
+    public function setPath(?string $path): self
     {
         $this->path = $path;
         return $this;
@@ -354,11 +360,9 @@ class File
 
     public function removePersonImage(Person $person): self
     {
-        if ($this->personImages->removeElement($person)) {
-            // set the owning side to null (unless already changed)
-            if ($person->getImage() === $this) {
-                $person->setImage(null);
-            }
+        // set the owning side to null (unless already changed)
+        if ($this->personImages->removeElement($person) && $person->getImage() === $this) {
+            $person->setImage(null);
         }
 
         return $this;
@@ -374,26 +378,13 @@ class File
 
     /**
      * @param string $visibility
+     * @return File
      */
-    public function setVisibility(string $visibility): void
+    public function setVisibility(string $visibility): self
     {
         $this->visibility = $visibility;
-    }
-
-    /**
-     * @return string
-     */
-    public function getFolder(): string
-    {
-        return $this->folder;
-    }
 
-    /**
-     * @param string $folder
-     */
-    public function setFolder(string $folder): void
-    {
-        $this->folder = $folder;
+        return $this;
     }
 
     /**
@@ -406,10 +397,13 @@ class File
 
     /**
      * @param string $type
+     * @return File
      */
-    public function setType(string $type): void
+    public function setType(string $type): self
     {
         $this->type = $type;
+
+        return $this;
     }
 
     /**
@@ -422,10 +416,13 @@ class File
 
     /**
      * @param int|null $size
+     * @return File
      */
-    public function setSize(?int $size): void
+    public function setSize(?int $size): self
     {
         $this->size = $size;
+
+        return $this;
     }
 
     /**
@@ -438,10 +435,13 @@ class File
 
     /**
      * @param bool $isTemporaryFile
+     * @return File
      */
-    public function setIsTemporaryFile(bool $isTemporaryFile): void
+    public function setIsTemporaryFile(bool $isTemporaryFile): self
     {
         $this->isTemporaryFile = $isTemporaryFile;
+
+        return $this;
     }
 
     /**
@@ -454,10 +454,13 @@ class File
 
     /**
      * @param DateTime $createDate
+     * @return File
      */
-    public function setCreateDate(DateTime $createDate): void
+    public function setCreateDate(DateTime $createDate): self
     {
         $this->createDate = $createDate;
+
+        return $this;
     }
 
     /**
@@ -470,10 +473,13 @@ class File
 
     /**
      * @param int|null $createdBy
+     * @return File
      */
-    public function setCreatedBy(?int $createdBy): void
+    public function setCreatedBy(?int $createdBy): self
     {
         $this->createdBy = $createdBy;
+
+        return $this;
     }
 
     /**
@@ -486,10 +492,13 @@ class File
 
     /**
      * @param DateTime $updateDate
+     * @return File
      */
-    public function setUpdateDate(DateTime $updateDate): void
+    public function setUpdateDate(DateTime $updateDate): self
     {
         $this->updateDate = $updateDate;
+
+        return $this;
     }
 
     /**
@@ -502,10 +511,13 @@ class File
 
     /**
      * @param int|null $updatedBy
+     * @return File
      */
-    public function setUpdatedBy(?int $updatedBy): void
+    public function setUpdatedBy(?int $updatedBy): self
     {
         $this->updatedBy = $updatedBy;
+
+        return $this;
     }
 
     /**
@@ -518,10 +530,29 @@ class File
 
     /**
      * @param string|null $status
+     * @return File
      */
-    public function setStatus(?string $status): void
+    public function setStatus(?string $status): self
     {
         $this->status = $status;
+
+        return $this;
+    }
+
+    /**
+     * @return string|null
+     */
+    public function getHost(): ?string
+    {
+        return $this->host;
+    }
+
+    /**
+     * @param string|null $host
+     */
+    public function setHost(?string $host): void
+    {
+        $this->host = $host;
     }
 
     public function getOrganizationLogos(): Collection
@@ -541,11 +572,9 @@ class File
 
     public function removeOrganizationLogo(Organization $organization): self
     {
-        if ($this->organizationLogos->removeElement($organization)) {
-            // set the owning side to null (unless already changed)
-            if ($organization->getLogo() === $this) {
-                $organization->setLogo(null);
-            }
+        // set the owning side to null (unless already changed)
+        if ($this->organizationLogos->removeElement($organization) && $organization->getLogo() === $this) {
+            $organization->setLogo(null);
         }
 
         return $this;
@@ -568,11 +597,9 @@ class File
 
     public function removeOrganizationImage(Organization $organization): self
     {
-        if ($this->organizationImages->removeElement($organization)) {
-            // set the owning side to null (unless already changed)
-            if ($organization->getImage() === $this) {
-                $organization->setImage(null);
-            }
+        // set the owning side to null (unless already changed)
+        if ($this->organizationImages->removeElement($organization) && $organization->getImage() === $this) {
+            $organization->setImage(null);
         }
 
         return $this;
@@ -586,6 +613,7 @@ class File
     public function setQrCode(Parameters $qrCode): self
     {
         $this->qrCode = $qrCode;
+
         return $this;
     }
 
@@ -633,11 +661,9 @@ class File
 
     public function removeEvent(Event $event): self
     {
-        if ($this->events->removeElement($event)) {
-            // set the owning side to null (unless already changed)
-            if ($event->getImage() === $this) {
-                $event->setImage(null);
-            }
+        // set the owning side to null (unless already changed)
+        if ($this->events->removeElement($event) && $event->getImage() === $this) {
+            $event->setImage(null);
         }
 
         return $this;
@@ -663,11 +689,9 @@ class File
 
     public function removeActivityLogo(Activity $activityLogo): self
     {
-        if ($this->activityLogos->removeElement($activityLogo)) {
-            // set the owning side to null (unless already changed)
-            if ($activityLogo->getLogo() === $this) {
-                $activityLogo->setLogo(null);
-            }
+        // set the owning side to null (unless already changed)
+        if ($this->activityLogos->removeElement($activityLogo) && $activityLogo->getLogo() === $this) {
+            $activityLogo->setLogo(null);
         }
 
         return $this;
@@ -693,11 +717,9 @@ class File
 
     public function removeActivityImage(Activity $activityImage): self
     {
-        if ($this->activityImages->removeElement($activityImage)) {
-            // set the owning side to null (unless already changed)
-            if ($activityImage->getImageActivity() === $this) {
-                $activityImage->setImageActivity(null);
-            }
+        // set the owning side to null (unless already changed)
+        if ($this->activityImages->removeElement($activityImage) && $activityImage->getImageActivity() === $this) {
+            $activityImage->setImageActivity(null);
         }
 
         return $this;

+ 2 - 0
src/Enum/Core/FileStatusEnum.php

@@ -8,6 +8,8 @@ use MyCLabs\Enum\Enum;
  * Statuts des fichiers
  * @method static PENDING()
  * @method static READY()
+ * @method static DELETED()
+ * @method static ERROR()
  */
 class FileStatusEnum extends Enum
 {

+ 17 - 0
src/Enum/Core/FileTypeEnum.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Enum\Core;
+
+use MyCLabs\Enum\Enum;
+
+/**
+ * @method static UNKNOWN()
+ * @method static NONE()
+ * @method static LICENCE_CMF()
+ */
+class FileTypeEnum extends Enum
+{
+    private const UNKNOWN = 'UNKNOWN';
+    private const NONE = 'NONE';
+    private const LICENCE_CMF ='LICENCE_CMF';
+}

+ 0 - 21
src/Enum/Export/ExportFormatEnum.php

@@ -15,25 +15,4 @@ class ExportFormatEnum extends Enum
     private const TXT = 'txt';
     private const XLSX = 'xlsx';
     private const XML = 'xml';
-
-    /** @var array */
-    protected static array $mimeType = [
-        self::PDF => 'application/pdf',
-        self::CSV => 'text/csv',
-        self::TXT => 'text/plain',
-        self::XLSX => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
-        self::XML => 'application/xml'
-    ];
-
-    /**
-     * @param  string $formatShortName
-     * @return string
-     */
-    public static function getMimeType($formatShortName)
-    {
-        if (!isset(static::$mimeType[$formatShortName])) {
-            return "Unknown format ($formatShortName)";
-        }
-        return static::$mimeType[$formatShortName];
-    }
 }

+ 4 - 0
src/Security/Voter/ModuleVoter.php

@@ -8,6 +8,7 @@ use App\Entity\Access\Access;
 use App\Entity\Organization\Organization;
 use App\Service\Security\Module;
 use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+use Symfony\Component\Security\Core\Authentication\Token\NullToken;
 use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
 use Symfony\Component\Security\Core\Authorization\Voter\Voter;
 
@@ -42,6 +43,9 @@ class ModuleVoter extends Voter
         if (!$subject->attributes->get('_api_resource_class') || !$resourceMetadata = $this->resourceMetadataFactory->create($subject->attributes->get('_api_resource_class'))) {
             throw new AccessDeniedHttpException(sprintf('Missing resource class'));
         }
+        if ($token instanceof NullToken) {
+            return false;
+        }
 
         $module = $this->module->getModuleByResourceName($resourceMetadata->getShortName());
 

+ 8 - 2
src/Service/Access/OptionalsRoles/CriteriaNotationOptionalRole.php

@@ -12,9 +12,15 @@ class CriteriaNotationOptionalRole implements OptionalsRolesInterface {
     public function __construct(
         private AccessRepository $accessRepository
     )
-    {
-    }
+    {}
 
+    /**
+     * Return true if the given access is an active teacher and administration has not restricted criteria edition to
+     * the admin users.
+     *
+     * @param Access $access
+     * @return bool
+     */
     public function support(Access $access): bool
     {
         $isActiveTeacher = $this->accessRepository->hasGotFunctionAtThisDate($access, FunctionEnum::TEACHER(), new \DateTime('now'));

+ 63 - 0
src/Service/ApiLegacy/ApiLegacyRequestService.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace App\Service\ApiLegacy;
+
+use App\Service\Rest\ApiRequestService;
+use JetBrains\PhpStorm\Pure;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Component\Security\Core\Authentication\Token\NullToken;
+use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
+use Symfony\Component\Security\Core\Security;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+/**
+ * Service d'appel à l'API opentalent V1
+ */
+class ApiLegacyRequestService extends ApiRequestService
+{
+    #[Pure]
+    public function __construct(HttpClientInterface $apiLegacyClient, private Security $security)
+    {
+        parent::__construct($apiLegacyClient);
+    }
+
+    /** @noinspection PhpPossiblePolymorphicInvocationInspection */
+    public function request(
+        string $method,
+        string $url,
+        array $parameters = [],
+        array $options = []
+    ): ResponseInterface {
+
+        $token = $this->security->getToken();
+        if ($token === null || $token instanceof NullToken || $token->getUser() === null) {
+            throw new HttpException(500, 'Request error : Invalid security token');
+        }
+
+        $headers = [
+            'authorization' => 'BEARER ' . $_REQUEST['BEARER'],
+            'Accept' => '*/*',
+            'Charset' => 'UTF-8',
+            'Accept-Encoding' => 'gzip, deflate, br',
+            'Content-Type' => 'application/ld+json',
+        ];
+
+        if ($token instanceof SwitchUserToken) {
+            $originalUser = $token->getOriginalToken()->getUser();
+            if ($originalUser === null) {
+                throw new HttpException(500, 'Request error : Switch original user missing');
+            }
+
+            $headers['x-accessid'] = $originalUser->getId();
+            $headers['x-switch-access'] = $token->getUser()->getId();
+        } else {
+            $headers['x-accessid'] = $token->getUser()->getId();
+        }
+
+        $options['headers'] = array_merge($options['headers'] ?? [], $headers);
+
+        return parent::request($method, $url, $parameters, $options);
+    }
+
+}

+ 11 - 12
src/Service/Constraint/AbstractTimeConstraintUtils.php

@@ -8,14 +8,14 @@ namespace App\Service\Constraint;
  */
 abstract class AbstractTimeConstraintUtils
 {
-    const NULL = 0;
-    const INF = 1;
-    const EQUAL = 3;
-    const SUP = 5;
-    const CANCEL_OPERATION = 9;
-    const START_KEY = 'start';
-    const END_KEY = 'end';
-    const NULL_VALUE = 'NULL';
+    public const NULL = 0;
+    public const INF = 1;
+    public const EQUAL = 3;
+    public const SUP = 5;
+    public const CANCEL_OPERATION = 9;
+    public const START_KEY = 'start';
+    public const END_KEY = 'end';
+    public const NULL_VALUE = 'NULL';
 
     /**
      * Retourne true si l'utilisateur veux une période précise
@@ -34,10 +34,9 @@ abstract class AbstractTimeConstraintUtils
      * @return array
      * @see DateTimeConstraintTest::testAddConstraint()
      */
-    protected function addConstraint(array $contraints, array $newContraint): array{
+    protected function addConstraint(array $contraints, array $newContraint): array {
         $contraints = $this->mergeConstraint($contraints,$newContraint,self::START_KEY);
-        $contraints = $this->mergeConstraint($contraints,$newContraint,self::END_KEY);
-        return $contraints;
+        return $this->mergeConstraint($contraints,$newContraint,self::END_KEY);
     }
 
     /**
@@ -106,4 +105,4 @@ abstract class AbstractTimeConstraintUtils
             $constraints[$key] = [];
         return $constraints[$key];
     }
-}
+}

+ 15 - 12
src/Service/Constraint/ActivityYearConstraint.php

@@ -9,7 +9,7 @@ use Doctrine\ORM\EntityManagerInterface;
 
 /**
  * Classe ActivityYearConstraint qui définie l'année de début (et de fin dans le cas d'une période custom)
- * par rapport au contraintes temporelles choisies par un utilisateur.
+ * par rapport aux contraintes temporelles choisies par un utilisateur.
  */
 class ActivityYearConstraint extends AbstractTimeConstraintUtils
 {
@@ -20,7 +20,8 @@ class ActivityYearConstraint extends AbstractTimeConstraintUtils
     { }
 
     /**
-     * Main méthode
+     * Main method
+     *
      * @param int $accessID
      * @return array
      * @throws \Exception
@@ -42,19 +43,19 @@ class ActivityYearConstraint extends AbstractTimeConstraintUtils
             $year = $access->getActivityYear();
             if($historical['present']) $contraints = $this->addConstraint($contraints, $this->presentConstraint($year));
             if($historical['past']) $contraints = $this->addConstraint($contraints, $this->pastConstraint($year));
-            if($historical['future']) $contraints = $this->addConstraint($contraints, $this->futurConstraint($year));
+            if($historical['future']) $contraints = $this->addConstraint($contraints, $this->futureConstraint($year));
         }
         return $this->cleanConstraints($contraints);
     }
 
     /**
-     * Retourne le tableau des années comprise dans la période custom
+     * Retourne le tableau des années comprises dans la période custom
      * @param Access $access
      * @param string $dateStart
      * @param string $dateEnd
      * @return string[]
      */
-    private function getRangeYear(Access $access, string $dateStart, string $dateEnd): array{
+    protected function getRangeYear(Access $access, string $dateStart, string $dateEnd): array {
         $organization = $access->getOrganization();
         return [
             OrganizationUtils::START_DATE_KEY => $this->organizationUtils->getActivityYearSwitchDate($organization, new \DateTime($dateStart)),
@@ -65,10 +66,10 @@ class ActivityYearConstraint extends AbstractTimeConstraintUtils
     /**
      * Une période est dans le présent si :
      * - l'année de début est égale (=) à l'année à afficher
-     * @param $year
+     * @param int $year
      * @return array
      */
-    private function presentConstraint(int $year): array{
+    protected function presentConstraint(int $year): array {
         return [
           self::START_KEY => [
               $year => self::EQUAL
@@ -82,7 +83,7 @@ class ActivityYearConstraint extends AbstractTimeConstraintUtils
      * @param $year
      * @return array
      */
-    private function pastConstraint($year): array{
+    protected function pastConstraint($year): array {
         return [
             self::END_KEY => [
                 $year => self::INF
@@ -91,12 +92,12 @@ class ActivityYearConstraint extends AbstractTimeConstraintUtils
     }
 
     /**
-     * Une période est dans le future si :
+     * Une période est dans le futur si :
      * - l'année de début est plus grande (>) à l'année à afficher
      * @param $year
      * @return array
      */
-    private function futurConstraint($year): array{
+    protected function futureConstraint($year): array {
         return [
             self::START_KEY => [
                 $year => self::SUP
@@ -106,12 +107,14 @@ class ActivityYearConstraint extends AbstractTimeConstraintUtils
 
     /**
      * Une période est dans une contrainte custom si :
+     *
      * - l'année de début est plus grande ou égale (>=) à l'année de départ
      * - l'année de début est plus petite ou égale (<=) à l'année de fin
-     * @param $year
+     *
+     * @param $years
      * @return array
      */
-    private function customConstraint($years): array{
+    protected function customConstraint($years): array {
         return [
             self::START_KEY => [
                 $years[OrganizationUtils::START_DATE_KEY]  => self::SUP + self::EQUAL,

+ 7 - 7
src/Service/Constraint/DateTimeConstraint.php

@@ -22,12 +22,12 @@ class DateTimeConstraint extends AbstractTimeConstraintUtils
     { }
 
     /**
-     * Main méthode
+     * Main méthod
      * @param int $accessID
      * @return array
      * @throws \Exception
      */
-    public function invoke(int $accessID): array
+    public function invoke(int $accessID): array  // TODO: pas moyen de refactorer avec la méthode invoke de ActivityYearConstraint?
     {
         $access = $this->entityManager->getRepository(Access::class)->find($accessID);
         $historical = $access->getHistorical();
@@ -57,7 +57,7 @@ class DateTimeConstraint extends AbstractTimeConstraintUtils
      * @param string $dateEnd
      * @return string[]
      */
-    private function getCustomPeriods(string $dateStart, string $dateEnd): array{
+    protected function getCustomPeriods(string $dateStart, string $dateEnd): array {
         return [
             OrganizationUtils::START_DATE_KEY => $dateStart,
             OrganizationUtils::END_DATE_KEY => $dateEnd
@@ -72,7 +72,7 @@ class DateTimeConstraint extends AbstractTimeConstraintUtils
      * @throws \Exception
      * @see DateTimeConstraintTest::testGetPeriodsToday()
      */
-    private function getPeriods(Access $access): array {
+    protected function getPeriods(Access $access): array {
         $organization = $access->getOrganization();
         $activityYear = $access->getActivityYear();
         $currentActivityYear = $this->organizationUtils->getOrganizationCurrentActivityYear($organization);
@@ -99,7 +99,7 @@ class DateTimeConstraint extends AbstractTimeConstraintUtils
      *
      * @see DateTimeConstraintTest::testPresentConstrain()
      */
-    private function presentConstraint(array $periods): array {
+    protected function presentConstraint(array $periods): array {
         return [
           self::START_KEY => [
               $periods[OrganizationUtils::END_DATE_KEY] => self::INF + self::EQUAL
@@ -118,7 +118,7 @@ class DateTimeConstraint extends AbstractTimeConstraintUtils
      * @return array
      * @see DateTimeConstraintTest::testPastConstrain()
      */
-    private function pastConstraint($periods): array{
+    protected function pastConstraint($periods): array{
         return [
             self::END_KEY => [
                 $periods[OrganizationUtils::START_DATE_KEY] => self::INF
@@ -133,7 +133,7 @@ class DateTimeConstraint extends AbstractTimeConstraintUtils
      * @return array
      * @see DateTimeConstraintTest::testFuturConstrain()
      */
-    private function futureConstraint($periods): array{
+    protected function futureConstraint($periods): array{
         return [
             self::START_KEY => [
                 $periods[OrganizationUtils::END_DATE_KEY] => self::SUP

+ 1 - 1
src/Service/Core/AddressPostalUtils.php

@@ -7,7 +7,7 @@ use App\Entity\Core\AddressPostal;
 class AddressPostalUtils
 {
     /**
-     * Concatenate and return the three streetAddress parts of the given address
+     * Concatenate the three streetAddress parts of the given address
      *
      * @param AddressPostal $addressPostal
      * @param string $separator

+ 5 - 4
src/Service/Cotisation/Utils.php

@@ -113,18 +113,19 @@ class Utils
 
     /**
      * Retourne dans quelle année de cotisation on est aujourd'hui
+     * @param \DateTime|null $date
      * @return int
      * @throws \Exception
      * @see UtilsTest::testGetCurrentCotisationYear()
      */
-    public function getCurrentCotisationYear(): int {
-        $today = new \DateTime('now');
-        $year = intval($today->format('Y'));
+    public function getCurrentCotisationYear(\DateTime $date = null): int {
+        $date = $date ?? new \DateTime('now');
+        $year = (int)$date->format('Y');
 
         $base_date = new \DateTime($year . '-09-01');
         $dateStart = new \DateTime($year . '-01-01');
 
-        if ($today >= $dateStart && $today <= $base_date) {
+        if ($date >= $dateStart && $date <= $base_date) {
             $cotisationYear = $year;
         } else {
             $cotisationYear = $year + 1;

+ 3 - 4
src/Service/Dolibarr/DolibarrSyncService.php

@@ -711,18 +711,17 @@ class DolibarrSyncService
 
     /**
      * Post-validation of the execution of the operation.
+     * Compare the actual result to the expected one to ensure that the data was correctly updated.
+     *
      * In the case of a validation error, throw an HttpException
      *
      * @param ResponseInterface $response
      * @param BaseRestOperation $operation
      * @throws RuntimeException
      */
-    protected function validateResponse(ResponseInterface $response, BaseRestOperation $operation): void
+    protected function validateResponse(ResponseInterface $response, UpdateOperation | CreateOperation $operation): void
     {
         $updated = $operation->getData();
-        if ($updated === null) {
-            return;
-        }
 
         try {
             $responseData = $response->toArray();

+ 47 - 52
src/Service/Export/BaseExporter.php

@@ -5,16 +5,14 @@ namespace App\Service\Export;
 
 use App\ApiResources\Export\ExportRequest;
 use App\Entity\Core\File;
-use App\Enum\Core\FileStatusEnum;
-use App\Enum\Export\ExportFormatEnum;
+use App\Enum\Core\FileTypeEnum;
 use App\Repository\Access\AccessRepository;
 use App\Repository\Core\FileRepository;
 use App\Service\Export\Model\ExportModelInterface;
 use App\Service\ServiceIterator\EncoderIterator;
-use App\Service\Storage\TemporaryFileStorage;
+use App\Service\Storage\LocalStorage;
 use App\Service\Utils\StringsUtils;
 use Doctrine\ORM\EntityManagerInterface;
-use Exception;
 use Psr\Log\LoggerInterface;
 use Symfony\Contracts\Service\Attribute\Required;
 use Twig\Environment;
@@ -30,7 +28,7 @@ abstract class BaseExporter implements ExporterInterface
     protected Environment $twig;
     protected EncoderIterator $encoderIterator;
     protected EntityManagerInterface $entityManager;
-    protected TemporaryFileStorage $storage;
+    protected LocalStorage $storage;
     protected LoggerInterface $logger;
 
     #[Required]
@@ -49,7 +47,7 @@ abstract class BaseExporter implements ExporterInterface
     public function setEntityManager(EntityManagerInterface $entityManager): void
     { $this->entityManager = $entityManager; }
     #[Required]
-    public function setStorage(TemporaryFileStorage $storage): void
+    public function setStorage(LocalStorage $storage): void
     { $this->storage = $storage; }
     #[Required]
     public function setLogger(LoggerInterface $logger): void
@@ -66,7 +64,6 @@ abstract class BaseExporter implements ExporterInterface
      *
      * @param ExportRequest $exportRequest
      * @return File
-     * @throws Exception
      */
     public function export(ExportRequest $exportRequest): File
     {
@@ -79,45 +76,57 @@ abstract class BaseExporter implements ExporterInterface
         // Encode le html au format voulu
         $content = $this->encode($html, $exportRequest->getFormat());
 
-        // Créé le fichier dans le storage adapté
-        $filename = $this->getFileBasename($model);
-        if (!preg_match('/^.+\.' . $exportRequest->getFormat() . '$/i', $filename)) {
-            $filename .= '.' . $exportRequest->getFormat();
+        $requesterId = $exportRequest->getRequesterId();
+        $requester = $this->accessRepository->find($requesterId);
+        if ($requester === null) {
+            throw new \RuntimeException('Unable to determine the user; abort.');
         }
 
-        $path = $this->store($filename, $content);
-
         // Met à jour ou créé l'enregistrement du fichier en base
         if ($exportRequest->getFileId() !== null) {
             $file = $this->fileRepository->find($exportRequest->getFileId());
         } else {
             // Todo: voir si ce else est nécessaire une fois tous les exports implémentés
-            $file = new File();
-
-            $requesterId = $exportRequest->getRequesterId();
-            $organization = $this->accessRepository->find($requesterId)?->getOrganization();
+            $organization = $requester->getOrganization();
             if ($organization === null) {
                 throw new \RuntimeException('Unable to determine the organization of the curent user; abort.');
             }
-            $file->setOrganization($organization);
-            $file->setVisibility('NOBODY');
-            $file->setFolder('DOCUMENTS');
-            $file->setCreateDate(new \DateTime());
-            $file->setCreatedBy($requesterId);
+            $file = $this->prepareFile($exportRequest, false);
         }
 
-        $file->setType($this->getFileType());
-        $file->setMimeType(ExportFormatEnum::getMimeType($exportRequest->getFormat()));
-        $file->setName($filename);
-        $file->setPath($path);
-        $file->setSlug($path);
-        $file->setStatus(FileStatusEnum::READY()->getValue());
+        return $this->storage->writeFile($file, $content, $requester);
+    }
+
+    /**
+     * Create a pending file record in the database
+     *
+     * @param ExportRequest $exportRequest
+     * @param bool $flushFile
+     * @return File
+     */
+    public function prepareFile(ExportRequest $exportRequest, bool $flushFile = true): File {
+
+        $requesterId = $exportRequest->getRequesterId();
+        $requester = $this->accessRepository->find($requesterId);
+        if ($requester === null) {
+            throw new \RuntimeException('Unable to determine the current user; abort.');
+        }
 
-        $this->entityManager->persist($file);
-        $this->entityManager->flush();
+        $filename = $this->getFileBasename($exportRequest);
+        if (!preg_match('/^.+\.' . $exportRequest->getFormat() . '$/i', $filename)) {
+            $filename .= '.' . $exportRequest->getFormat();
+        }
 
-        // Retourne l'objet File ainsi créé
-        return $file;
+        return $this->storage->prepareFile(
+            $requester,
+            $filename,
+            $this->getFileType(),
+            $requester,
+            true,
+            'NOBODY',
+            LocalStorage::getMimeTypeFromExt($exportRequest->getFormat()),
+            $flushFile
+        );
     }
 
     /**
@@ -125,7 +134,6 @@ abstract class BaseExporter implements ExporterInterface
      *
      * @param ExportRequest $exportRequest
      * @return ExportModelInterface
-     * @throws Exception
      */
     protected function buildModel(ExportRequest $exportRequest): ExportModelInterface
     {
@@ -165,7 +173,6 @@ abstract class BaseExporter implements ExporterInterface
      *
      * @param ExportModelInterface $model
      * @return string Rendu HTML
-     * @throws Exception
      */
     protected function render(ExportModelInterface $model): string
     {
@@ -176,7 +183,7 @@ abstract class BaseExporter implements ExporterInterface
             );
         }
         catch (\Twig\Error\LoaderError | \Twig\Error\RuntimeError | \Twig\Error\SyntaxError $e) {
-            throw new \Exception('error during template rendering : ' . $e);
+            throw new \RuntimeException('error during template rendering : ' . $e);
         }
     }
 
@@ -186,7 +193,6 @@ abstract class BaseExporter implements ExporterInterface
      * @param string $html
      * @param string $format @see ExportFormatEnum
      * @return string
-     * @throws Exception
      */
     protected function encode(string $html, string $format): string
     {
@@ -196,10 +202,10 @@ abstract class BaseExporter implements ExporterInterface
     /**
      * Retourne le nom du fichier exporté
      *
-     * @param ExportModelInterface $model
+     * @param ExportRequest $exportRequest
      * @return string
      */
-    protected function getFileBasename(ExportModelInterface $model): string
+    protected function getFileBasename(ExportRequest $exportRequest): string
     {
         return $this->getBasename();
     }
@@ -207,20 +213,9 @@ abstract class BaseExporter implements ExporterInterface
     /**
      * Retourne le type de fichier tel qu'il apparait au niveau du champ File.type
      *
-     * @return string
+     * @return FileTypeEnum
      */
-    protected function getFileType(): string {
-        return 'UNKNOWN';
-    }
-
-    /**
-     * Créé le fichier
-     *
-     * @return mixed
-     * @throws Exception
-     */
-    protected function store(string $name, string $content): string
-    {
-        return $this->storage->write($name, $content);
+    protected function getFileType(): FileTypeEnum {
+        return FileTypeEnum::UNKNOWN();
     }
 }

+ 9 - 0
src/Service/Export/ExporterInterface.php

@@ -26,4 +26,13 @@ interface ExporterInterface
      * @param ExportRequest $exportRequest
      */
     public function export(ExportRequest $exportRequest): File;
+
+    /**
+     * Create a pending file record in the database
+     *
+     * @param ExportRequest $exportRequest
+     * @param bool $flushFile
+     * @return File
+     */
+    public function prepareFile(ExportRequest $exportRequest, bool $flushFile = true): File;
 }

+ 16 - 17
src/Service/Export/LicenceCmfExporter.php

@@ -5,12 +5,12 @@ namespace App\Service\Export;
 
 use App\ApiResources\Export\ExportRequest;
 use App\ApiResources\Export\LicenceCmf\LicenceCmfOrganizationER;
-use App\Service\Export\Model\ExportModelInterface;
+use App\Enum\Core\FileTypeEnum;
 use App\Service\Export\Model\LicenceCmf;
 use App\Enum\Access\FunctionEnum;
 use App\Repository\Organization\OrganizationRepository;
 use App\Service\Export\Model\LicenceCmfCollection;
-use App\Service\Storage\UploadStorage;
+use App\Service\Storage\LocalStorage;
 
 /**
  * Exporte la licence CMF de la structure ou du ou des access, au format demandé
@@ -26,8 +26,7 @@ class LicenceCmfExporter extends BaseExporter
     public const LICENCE_CMF_COLOR = [0 => '931572', 1 => 'C2981A', 2 =>  '003882', 3 =>  '27AAE1', 4 =>  '2BB673'];
 
     public function __construct(
-        private OrganizationRepository $organizationRepository,
-        private UploadStorage $uploadStorage,
+        private OrganizationRepository $organizationRepository
     )
     {}
 
@@ -59,10 +58,10 @@ class LicenceCmfExporter extends BaseExporter
             $this->getLicenceColor($exportRequest->getYear())
         );
 
-        $logoId = $organization->getLogo()?->getId();
-        if ($logoId) {
+        $logo = $organization->getLogo();
+        if ($logo) {
             $licenceCmf->setLogoUri(
-                $this->uploadStorage->getUri($logoId)
+                $this->storage->getDownloadIri($logo)
             );
         }
 
@@ -70,17 +69,17 @@ class LicenceCmfExporter extends BaseExporter
         if (count($presidents) > 0) {
             $president = $presidents[0]->getPerson();
             $licenceCmf->setPersonId($president->getId());
-            $licenceCmf->setPersonGender($president->getGender());
+            $licenceCmf->setPersonGender($president->getGender() ?? '');
             $licenceCmf->setPersonFirstName($president->getGivenName());
             $licenceCmf->setPersonLastName($president->getName());
         }
 
         $cmf = $this->organizationRepository->find(self::CMF_ID);
         /** @noinspection NullPointerExceptionInspection */
-        $qrCodeId = $cmf->getParameters()?->getQrCode()?->getId();
-        if ($qrCodeId) {
+        $qrCode = $cmf->getParameters()?->getQrCode();
+        if ($qrCode) {
             $licenceCmf->setQrCodeUri(
-                $this->uploadStorage->getUri($qrCodeId)
+                $this->storage->getDownloadIri($qrCode)
             );
         }
 
@@ -90,21 +89,21 @@ class LicenceCmfExporter extends BaseExporter
     }
 
     /**
-     * @param LicenceCmfCollection $model
+     * @param ExportRequest $exportRequest
      * @return string
      */
-    protected function getFileBasename(ExportModelInterface $model): string
+    protected function getFileBasename(ExportRequest $exportRequest): string
     {
-        return 'licence_cmf_' . $model->getLicences()[0]->getYear() . '.pdf';
+        return 'licence_cmf_' . $exportRequest->getYear() . '.pdf';
     }
 
     /**
      * Retourne le type de fichier tel qu'il apparait au niveau du champ File.type
      *
-     * @return string
+     * @return FileTypeEnum
      */
-    protected function getFileType(): string {
-        return 'LICENCE_CMF';
+    protected function getFileType(): FileTypeEnum {
+        return FileTypeEnum::LICENCE_CMF();
     }
 
     /**

+ 3 - 3
src/Service/Rest/ApiRequestService.php

@@ -48,7 +48,7 @@ class ApiRequestService implements ApiRequestInterface
         try {
             return $this->get($path, $parameters, $options)->getContent();
         } catch (ClientExceptionInterface | TransportExceptionInterface | RedirectionExceptionInterface | ServerExceptionInterface $e) {
-            throw new HttpException(500, 'Request error : ', $e);
+            throw new HttpException(500, 'Request error : ' . $e->getMessage(), $e);
         }
     }
 
@@ -129,8 +129,8 @@ class ApiRequestService implements ApiRequestInterface
         $url = UrlBuilder::concatParameters($url, $parameters);
         try {
             return $this->client->request($method, $url, $options);
-        } catch (HttpExceptionInterface | TransportExceptionInterface $e) {
-            throw new HttpException(500, 'Request error : ', $e);
+        } catch (TransportExceptionInterface $e) {
+            throw new HttpException(500, 'Request error : ' . $e->getMessage(), $e);
         }
     }
 }

+ 1 - 2
src/Service/ServiceIterator/EncoderIterator.php

@@ -26,7 +26,6 @@ class EncoderIterator
      *
      * @param string $format
      * @return EncoderInterface
-     * @throws Exception
      */
     public function getEncoderFor(string $format): EncoderInterface
     {
@@ -35,6 +34,6 @@ class EncoderIterator
             if($encoder->support($format))
                 return $encoder;
         }
-        throw new Exception('no encoder found for this export request');
+        throw new \RuntimeException('no encoder found for this export request');
     }
 }

+ 33 - 0
src/Service/Storage/ApiLegacyStorage.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Service\Storage;
+
+use App\Entity\Core\File;
+use App\Service\ApiLegacy\ApiLegacyRequestService;
+use App\Service\Utils\UrlBuilder;
+use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+
+/**
+ * Read and write files into the Opentalent API v1 storage
+ */
+class ApiLegacyStorage implements FileStorageInterface
+{
+    public function __construct(
+        private ApiLegacyRequestService $apiLegacyRequestService
+    ) {}
+
+    /**
+     * Reads the given file and returns its content as a string
+     *
+     * @param File $file
+     * @return string
+     */
+    public function read(File $file): string
+    {
+        return $this->apiLegacyRequestService->getContent('api/files/' . $file->getId() .'/download');
+    }
+}

+ 0 - 22
src/Service/Storage/FileStorage.php

@@ -1,22 +0,0 @@
-<?php
-declare(strict_types=1);
-
-namespace App\Service\Storage;
-
-use App\Service\Utils\Path;
-use Knp\Bundle\GaufretteBundle\FilesystemMap;
-
-/**
- * Base class for file storage
- */
-abstract class FileStorage
-{
-    public function __construct(
-        protected FilesystemMap $filesystem
-    )
-    {}
-
-    protected function getStorageBaseDir(): string {
-        return Path::join(Path::getProjectDir(), 'var', 'files');
-    }
-}

+ 10 - 0
src/Service/Storage/FileStorageInterface.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Service\Storage;
+
+use App\Entity\Core\File;
+
+interface FileStorageInterface
+{
+    public function read(File $file): string;
+}

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

@@ -0,0 +1,351 @@
+<?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());
+
+        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];
+    }
+}

+ 0 - 43
src/Service/Storage/TemporaryFileStorage.php

@@ -1,43 +0,0 @@
-<?php
-declare(strict_types=1);
-
-namespace App\Service\Storage;
-
-use App\Service\Utils\Path;
-use Exception;
-use Ramsey\Uuid\Uuid;
-
-/**
- * Gère le stockage des fichiers temporaires, comme les documents générés par les utilisateurs
- * comme des fichiers d'export
- */
-class TemporaryFileStorage extends FileStorage
-{
-    protected function getRelativeStorageBaseDir(): string {
-        return 'temp';
-    }
-
-    protected function getStorageBaseDir(): string {
-        // TODO: remplacer par une reference à config/packages/knp_gaufrette.yaml
-        return Path::join(parent::getStorageBaseDir(), $this->getRelativeStorageBaseDir());
-    }
-
-    /**
-     * Write the given content to a temporary file
-     *
-     * @param string $filename
-     * @param string $content
-     * @return string
-     * @throws Exception
-     */
-    public function write(string $filename, string $content): string
-    {
-        // Temp dir name is a concatenation of current time (for convenience and sorting) and a short uuid4
-        $tempDirName = date('Ymd_His') . '_' . substr(Uuid::uuid4()->toString(), 0, 8);
-
-        $filePath = Path::join($tempDirName, $filename);
-        $this->filesystem->get('temp')->getAdapter()->write($filePath, $content);
-
-        return Path::join('temp', $filePath);
-    }
-}

+ 0 - 31
src/Service/Storage/UploadStorage.php

@@ -1,31 +0,0 @@
-<?php
-declare(strict_types=1);
-
-namespace App\Service\Storage;
-
-/**
- * Gère l'upload et le téléchargement de fichiers par les utilisateurs
- *
- * Pour la durée de la migration vers Symfony 5, la gestion des fichiers est déléguée à l'ancienne API
- *
- */
-// TODO: revoir le fonctionnement de ce storage pour le mettre sur le même format que les autres
-class UploadStorage
-{
-    public function __construct(private string $internalFilesUploadUri)
-    {}
-
-    private function getBaseDownloadUri(): string {
-        return $this->internalFilesUploadUri;
-    }
-
-    private static function getUploadUri(): string
-    {
-        return '';
-    }
-
-    public function getUri(int $fileId): string {
-        return rtrim(self::getBaseDownloadUri(), '/') . '/' . $fileId;
-    }
-
-}

+ 24 - 0
src/Service/Utils/Uuid.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Service\Utils;
+
+class Uuid
+{
+    /**
+     * Generate an UUID v4
+     *
+     * @var int $length Max length of the uuid
+     */
+    public static function uuid(int $length = 8): string
+    {
+        try {
+            $uuid = \Ramsey\Uuid\Uuid::uuid4()->toString();
+        } catch (\Exception $e) {
+            throw new \RuntimeException('An error occurred while generating the UUID', 0, $e);
+        }
+        if ($length !== null) {
+            return substr($uuid, 0, $length);
+        }
+        return $uuid;
+    }
+}

+ 23 - 23
templates/export/licence_cmf.html.twig

@@ -176,14 +176,14 @@
                             <tbody>
                             <tr>
                                 <td width="340" class="relative">
-                                    <img src="{{ asset('static/cmf_licence.png') }}"
-                                            width="170" height="86"/>
+{#                                    <img src="{{ asset('#{app.server.request_scheme}://#{app.server.http_host}/static/cmf_licence.png') }}"#}
+{#                                            width="170" height="86"/>#}
                                     <span id="year_head">{{ licence.year }}</span>
                                 </td>
                                 <td width="340">
                                     <div align="right">
-                                        <img src="{{ asset('static/cmf-reseau.png') }}"
-                                                width="200" height="86"/>
+{#                                        <img src="{{ asset('#{app.server.request_scheme}://#{app.server.http_host}/static/cmf-reseau.png') }}"#}
+{#                                                width="200" height="86"/>#}
                                     </div>
                                 </td>
                             </tr>
@@ -239,13 +239,13 @@
                             <td width="80" id="avatar">
                                 <div align="center">
                                     {% if(licence.logoUri is null) %}
-                                        <img src="{{ asset('public/static/picto_face.png') }}"
-                                             width="85"
-                                             height="82"/>
+{#                                        <img src="{{ asset('#{app.server.request_scheme}://#{app.server.http_host}/static/picto_face.png') }}"#}
+{#                                             width="85"#}
+{#                                             height="82"/>#}
                                     {% else %}
-                                        <img src="{{ licence.logoUri }}"
-                                             width="85"
-                                             height="82"/>
+{#                                        <img src="{{ licence.logoUri }}"#}
+{#                                             width="85"#}
+{#                                             height="82"/>#}
                                     {% endif %}
                                 </div>
                             </td>
@@ -262,13 +262,13 @@
                             <td width="80" id="avatar">
                                 <div align="center">
                                     {% if(licence.personAvatarUri is null) %}
-                                        <img
-                                                src="{{ asset('public/static/picto_face.png') }}"
-                                                width="85"
-                                                height="82"/>
+{#                                        <img#}
+{#                                                src="{{ asset('#{app.server.request_scheme}://#{app.server.http_host}/static/picto_face.png') }}"#}
+{#                                                width="85"#}
+{#                                                height="82"/>#}
                                     {% else %}
-                                        <img class="avatar"
-                                             src="{{ asset(licence.personAvatarUri) }}"/>
+{#                                        <img class="avatar"#}
+{#                                             src="{{ asset(licence.personAvatarUri) }}"/>#}
                                     {% endif %}
                                 </div>
                             </td>
@@ -288,8 +288,8 @@
                         <td width="70" valign="middle"
                             style="vertical-align: top;">
                             <div align="center">
-                                <img src="{{ asset('public/static/cmf_licence.png') }}"
-                                     height="45"/>
+{#                                <img src="{{ asset('#{app.server.request_scheme}://#{app.server.http_host}/static/cmf_licence.png') }}"#}
+{#                                     height="45"/>#}
                                 <span id="year_card">{{ licence.year }}</span>
                             </div>
                         </td>
@@ -302,11 +302,11 @@
                             </div>
                         </td>
                         <td width="70" align="right" valign="middle" id="qrCode">
-                            {% if(licence.qrCodeUri is not null) %}
-                                <img style="margin-right: 10px;"
-                                     src="{{ asset(licence.qrCodeUri) }}"
-                                     alt=""
-                                     width="65" height="65"/>
+                            {% if(licence.qrCodeUri) %}
+{#                                <img style="margin-right: 10px;"#}
+{#                                     src="{{ asset(licence.qrCodeUri) }}"#}
+{#                                     alt=""#}
+{#                                     width="65" height="65"/>#}
                             {% endif %}
                         </td>
                     </tr>

+ 109 - 0
tests/Service/Access/OptionalsRoles/CriteriaNotationOptionalRoleTest.php

@@ -0,0 +1,109 @@
+<?php /** @noinspection DuplicatedCode */
+
+use App\Entity\Access\Access;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\Parameters;
+use App\Enum\Access\FunctionEnum;
+use App\Repository\Access\AccessRepository;
+use App\Service\Access\OptionalsRoles\CriteriaNotationOptionalRole;
+use PHPUnit\Framework\TestCase;
+
+class CriteriaNotationOptionalRoleTest extends TestCase
+{
+    private AccessRepository $accessRepository;
+
+    public function setUp(): void {
+        $this->accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+    }
+
+    public function testSupportIsSupported(): void
+    {
+        $criteriaNotationOptionalRole = $this
+            ->getMockBuilder(CriteriaNotationOptionalRole::class)
+            ->setConstructorArgs([$this->accessRepository])
+            ->setMethodsExcept(['support'])
+            ->getMock();
+
+        $parameters = $this->getMockBuilder(Parameters::class)->getMock();
+        $parameters->method('getEditCriteriaNotationByAdminOnly')->willReturn(false);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getParameters')->willReturn($parameters);
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $access->method('getOrganization')->willReturn($organization);
+
+        $this->accessRepository
+            ->method('hasGotFunctionAtThisDate')
+            ->with($access, FunctionEnum::TEACHER(), self::isInstanceOf(DateTime::class))
+            ->willReturn(true);
+
+        $this->assertTrue(
+            $criteriaNotationOptionalRole->support($access)
+        );
+    }
+
+    public function testSupportIsNotActiveTeacher(): void
+    {
+        $criteriaNotationOptionalRole = $this
+            ->getMockBuilder(CriteriaNotationOptionalRole::class)
+            ->setConstructorArgs([$this->accessRepository])
+            ->setMethodsExcept(['support'])
+            ->getMock();
+
+        $parameters = $this->getMockBuilder(Parameters::class)->getMock();
+        $parameters->method('getEditCriteriaNotationByAdminOnly')->willReturn(false);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getParameters')->willReturn($parameters);
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $access->method('getOrganization')->willReturn($organization);
+
+        $this->accessRepository
+            ->method('hasGotFunctionAtThisDate')
+            ->with($access, FunctionEnum::TEACHER(), self::isInstanceOf(DateTime::class))
+            ->willReturn(false);
+
+        $this->assertFalse(
+            $criteriaNotationOptionalRole->support($access)
+        );
+    }
+
+    public function testSupportIsActiveTeacherButAdminOnly(): void
+    {
+        $criteriaNotationOptionalRole = $this
+            ->getMockBuilder(CriteriaNotationOptionalRole::class)
+            ->setConstructorArgs([$this->accessRepository])
+            ->setMethodsExcept(['support'])
+            ->getMock();
+
+        $parameters = $this->getMockBuilder(Parameters::class)->getMock();
+        $parameters->method('getEditCriteriaNotationByAdminOnly')->willReturn(true);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getParameters')->willReturn($parameters);
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $access->method('getOrganization')->willReturn($organization);
+
+        $this->accessRepository
+            ->method('hasGotFunctionAtThisDate')
+            ->with($access, FunctionEnum::TEACHER(), self::isInstanceOf(DateTime::class))
+            ->willReturn(true);
+
+        $this->assertFalse(
+            $criteriaNotationOptionalRole->support($access)
+        );
+    }
+
+    public function testGetRole() {
+        $criteriaNotationOptionalRole = $this
+            ->getMockBuilder(CriteriaNotationOptionalRole::class)
+            ->setConstructorArgs([$this->accessRepository])
+            ->setMethodsExcept(['getRole'])
+            ->getMock();
+
+        $this->assertEquals("ROLE_CRITERIANOTATION", $criteriaNotationOptionalRole->getRole());
+    }
+}

+ 194 - 0
tests/Service/ApiLegacy/ApiLegacyRequestServiceTest.php

@@ -0,0 +1,194 @@
+<?php /** @noinspection DuplicatedCode */
+
+namespace App\Test\Service\ApiLegacy;
+
+use App\Entity\Access\Access;
+use App\Service\ApiLegacy\ApiLegacyRequestService;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Component\Security\Core\Authentication\Token\NullToken;
+use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
+use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
+use Symfony\Component\Security\Core\Security;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+class ApiLegacyRequestServiceTest extends TestCase
+{
+    private HttpClientInterface $apiLegacyClient;
+    private Security $security;
+
+    public function setUp(): void
+    {
+        $this->apiLegacyClient = $this->getMockBuilder(HttpClientInterface::class)->disableOriginalConstructor()->getMock();
+        $this->security = $this->getMockBuilder(Security::class)->disableOriginalConstructor()->getMock();
+    }
+
+    public function testRequest(): void
+    {
+        $api1RequestService = $this
+            ->getMockBuilder(ApiLegacyRequestService::class)
+            ->setConstructorArgs([$this->apiLegacyClient, $this->security])
+            ->setMethodsExcept(['request'])
+            ->getMock();
+
+        $user = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+        $user->method('getId')->willReturn(1);
+
+        $token = $this->getMockBuilder(UsernamePasswordToken::class)->disableOriginalConstructor()->getMock();
+        $token->method('getUser')->willReturn($user);
+
+        $this->security->method('getToken')->willReturn($token);
+
+        $_REQUEST['BEARER'] = '123';
+        $expectedHeaders = [
+            'custom' => 'foo',
+            'authorization' => 'BEARER 123',
+            'Accept' => '*/*',
+            'Charset' => 'UTF-8',
+            'Accept-Encoding' => 'gzip, deflate, br',
+            'Content-Type' => 'application/ld+json',
+            'x-accessid' => '1'
+        ];
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+
+        $this->apiLegacyClient
+            ->expects(self::once())
+            ->method('request')
+            ->with('GET', 'an/url', ['headers' => $expectedHeaders])
+            ->willReturn($response);
+
+        $api1RequestService->request(
+            'GET',
+            '/an/url',
+            [],
+            ['headers' => ['custom' => 'foo']]
+        );
+    }
+
+    public function testRequestNoToken(): void
+    {
+        $api1RequestService = $this
+            ->getMockBuilder(ApiLegacyRequestService::class)
+            ->setConstructorArgs([$this->apiLegacyClient, $this->security])
+            ->setMethodsExcept(['request'])
+            ->getMock();
+
+        $this->security->method('getToken')->willReturn(null);
+
+        $this->expectException(HttpException::class);
+        $this->expectExceptionMessage('Request error : Invalid security token');
+
+        $api1RequestService->request('GET', '/an/url');
+    }
+
+    public function testRequestNullToken(): void
+    {
+        $api1RequestService = $this
+            ->getMockBuilder(ApiLegacyRequestService::class)
+            ->setConstructorArgs([$this->apiLegacyClient, $this->security])
+            ->setMethodsExcept(['request'])
+            ->getMock();
+
+        $token = $this->getMockBuilder(NullToken::class)->disableOriginalConstructor()->getMock();
+
+        $this->security->method('getToken')->willReturn($token);
+
+        $this->expectException(HttpException::class);
+        $this->expectExceptionMessage('Request error : Invalid security token');
+
+        $api1RequestService->request('GET', '/an/url');
+    }
+
+    public function testRequestInvalidToken(): void
+    {
+        $api1RequestService = $this
+            ->getMockBuilder(ApiLegacyRequestService::class)
+            ->setConstructorArgs([$this->apiLegacyClient, $this->security])
+            ->setMethodsExcept(['request'])
+            ->getMock();
+
+        $token = $this->getMockBuilder(UsernamePasswordToken::class)->disableOriginalConstructor()->getMock();
+        $token->method('getUser')->willReturn(null);
+
+        $this->security->method('getToken')->willReturn($token);
+
+        $this->expectException(HttpException::class);
+        $this->expectExceptionMessage('Request error : Invalid security token');
+
+        $api1RequestService->request('GET', '/an/url');
+    }
+
+    public function testRequestSwitchUser(): void
+    {
+        $api1RequestService = $this
+            ->getMockBuilder(ApiLegacyRequestService::class)
+            ->setConstructorArgs([$this->apiLegacyClient, $this->security])
+            ->setMethodsExcept(['request'])
+            ->getMock();
+
+        $user = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+        $user->method('getId')->willReturn(10);
+
+        $originalUser = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+        $originalUser->method('getId')->willReturn(20);
+
+        $originalToken = $this->getMockBuilder(SwitchUserToken::class)->disableOriginalConstructor()->getMock();
+        $originalToken->method('getUser')->willReturn($originalUser);
+
+        $token = $this->getMockBuilder(SwitchUserToken::class)->disableOriginalConstructor()->getMock();
+        $token->method('getUser')->willReturn($user);
+        $token->method('getOriginalToken')->willReturn($originalToken);
+
+        $this->security->method('getToken')->willReturn($token);
+
+        $_REQUEST['BEARER'] = '123';
+
+        $expectedHeaders = [
+            'authorization' => 'BEARER 123',
+            'Accept' => '*/*',
+            'Charset' => 'UTF-8',
+            'Accept-Encoding' => 'gzip, deflate, br',
+            'Content-Type' => 'application/ld+json',
+            'x-accessid' => '20',
+            'x-switch-access' => '10',
+        ];
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+
+        $this->apiLegacyClient
+            ->expects(self::once())
+            ->method('request')
+            ->with('GET', 'an/url', ['headers' => $expectedHeaders])
+            ->willReturn($response);
+
+        $api1RequestService->request('GET', '/an/url');
+    }
+
+    public function testRequestSwitchInvalidUser(): void
+    {
+        $api1RequestService = $this
+            ->getMockBuilder(ApiLegacyRequestService::class)
+            ->setConstructorArgs([$this->apiLegacyClient, $this->security])
+            ->setMethodsExcept(['request'])
+            ->getMock();
+
+        $user = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+        $user->method('getId')->willReturn(10);
+
+        $originalToken = $this->getMockBuilder(SwitchUserToken::class)->disableOriginalConstructor()->getMock();
+        $originalToken->method('getUser')->willReturn(null);
+
+        $token = $this->getMockBuilder(SwitchUserToken::class)->disableOriginalConstructor()->getMock();
+        $token->method('getUser')->willReturn($user);
+        $token->method('getOriginalToken')->willReturn($originalToken);
+
+        $this->security->method('getToken')->willReturn($token);
+
+        $this->expectException(HttpException::class);
+        $this->expectExceptionMessage('Request error : Switch original user missing');
+
+        $api1RequestService->request('GET', '/an/url');
+    }
+}

+ 311 - 0
tests/Service/Constraint/ActivityYearConstraintTest.php

@@ -0,0 +1,311 @@
+<?php /** @noinspection DuplicatedCode */
+
+namespace App\Tests\Service\Constraint;
+
+use App\Entity\Access\Access;
+use App\Entity\Organization\Organization;
+use App\Repository\Access\AccessRepository;
+use App\Service\Constraint\ActivityYearConstraint;
+use App\Service\Organization\Utils as OrganizationUtils;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\TestCase;
+
+class TestableActivityYearConstraint extends ActivityYearConstraint {
+    public function hasCustomPeriods($historical): bool { return parent::hasCustomPeriods($historical); }
+    public function addConstraint(array $contraints, array $newContraint): array { return parent::addConstraint($contraints, $newContraint); }
+    public function cleanConstraints(array $constraints): array { return parent::cleanConstraints($constraints); }
+
+    public function getRangeYear(Access $access, string $dateStart, string $dateEnd): array { return parent::getRangeYear($access, $dateStart, $dateEnd); }
+    public function presentConstraint(int $year): array { return parent::presentConstraint($year); }
+    public function pastConstraint($year): array { return parent::pastConstraint($year); }
+    public function futureConstraint($year): array { return parent::futureConstraint($year); }
+    public function customConstraint($years): array { return parent::customConstraint($years); }
+}
+
+
+class ActivityYearConstraintTest extends TestCase
+{
+    private EntityManagerInterface $entityManager;
+    private OrganizationUtils $organizationUtils;
+
+    public function setUp(): void {
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->organizationUtils = $this->getMockBuilder(OrganizationUtils::class)->disableOriginalConstructor()->getMock();
+    }
+
+    public function testInvokePresentNoCustomPeriods(): void
+    {
+        $activityYearConstraint = $this->getMockBuilder(TestableActivityYearConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['invoke'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        $accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $accessRepository->method('find')->with(123)->willReturn($access);
+
+        $this->entityManager->method('getRepository')->with(Access::class)->willReturn($accessRepository);
+
+        $historical = ["future" => false,"past" => false,"present" => true,"dateStart" => null,"dateEnd" => null];
+
+        $access->method('getHistorical')->willReturn($historical);
+        $access->method('getActivityYear')->willReturn(2020);
+
+        $activityYearConstraint->method('hasCustomPeriods')->with($historical)->willReturn(false);
+
+        $constraint = ['foo'];
+        $activityYearConstraint->method('presentConstraint')->willReturn($constraint);
+
+        $activityYearConstraint
+            ->expects(self::once())
+            ->method('addConstraint')
+            ->with(['start' => [], 'end' => []] , $constraint)
+            ->willReturn(['bar']);
+
+        $activityYearConstraint
+            ->expects(self::once())
+            ->method('cleanConstraints')
+            ->with(['bar']);
+
+        $activityYearConstraint->invoke(123);
+    }
+
+    public function testInvokePastNoCustomPeriods(): void
+    {
+        $activityYearConstraint = $this->getMockBuilder(TestableActivityYearConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['invoke'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        $accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $accessRepository->method('find')->with(123)->willReturn($access);
+
+        $this->entityManager->method('getRepository')->with(Access::class)->willReturn($accessRepository);
+
+        $historical = ["future" => false,"past" => true,"present" => false,"dateStart" => null,"dateEnd" => null];
+
+        $access->method('getHistorical')->willReturn($historical);
+        $access->method('getActivityYear')->willReturn(2020);
+
+        $activityYearConstraint->method('hasCustomPeriods')->with($historical)->willReturn(false);
+
+        $constraint = ['foo'];
+        $activityYearConstraint->method('pastConstraint')->willReturn($constraint);
+
+        $activityYearConstraint
+            ->expects(self::once())
+            ->method('addConstraint')
+            ->with(['start' => [], 'end' => []] , $constraint)
+            ->willReturn(['bar']);
+
+        $activityYearConstraint
+            ->expects(self::once())
+            ->method('cleanConstraints')
+            ->with(['bar']);
+
+        $activityYearConstraint->invoke(123);
+    }
+
+    public function testInvokeFutureNoCustomPeriods(): void
+    {
+        $activityYearConstraint = $this->getMockBuilder(TestableActivityYearConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['invoke'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        $accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $accessRepository->method('find')->with(123)->willReturn($access);
+
+        $this->entityManager->method('getRepository')->with(Access::class)->willReturn($accessRepository);
+
+        $historical = ["future" => true,"past" => false,"present" => false,"dateStart" => null,"dateEnd" => null];
+
+        $access->method('getHistorical')->willReturn($historical);
+        $access->method('getActivityYear')->willReturn(2020);
+
+        $activityYearConstraint->method('hasCustomPeriods')->with($historical)->willReturn(false);
+
+        $constraint = ['foo'];
+        $activityYearConstraint->method('futureConstraint')->willReturn($constraint);
+
+        $activityYearConstraint
+            ->expects(self::once())
+            ->method('addConstraint')
+            ->with(['start' => [], 'end' => []] , $constraint)
+            ->willReturn(['bar']);
+
+        $activityYearConstraint
+            ->expects(self::once())
+            ->method('cleanConstraints')
+            ->with(['bar']);
+
+        $activityYearConstraint->invoke(123);
+    }
+
+    public function testInvokeMultiplePeriodsNoCustom(): void
+    {
+        $activityYearConstraint = $this->getMockBuilder(TestableActivityYearConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['invoke'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        $accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $accessRepository->method('find')->with(123)->willReturn($access);
+
+        $this->entityManager->method('getRepository')->with(Access::class)->willReturn($accessRepository);
+
+        $historical = ["future" => true,"past" => true,"present" => true,"dateStart" => null,"dateEnd" => null];
+
+        $access->method('getHistorical')->willReturn($historical);
+        $access->method('getActivityYear')->willReturn(2020);
+
+        $activityYearConstraint->method('hasCustomPeriods')->with($historical)->willReturn(false);
+
+        $activityYearConstraint->method('pastConstraint')->willReturn(['pastConstraint']);
+        $activityYearConstraint->method('presentConstraint')->willReturn(['presentConstraint']);
+        $activityYearConstraint->method('futureConstraint')->willReturn(['futureConstraint']);
+
+        $activityYearConstraint
+            ->expects(self::exactly(3))
+            ->method('addConstraint')
+            ->withConsecutive(
+                [['start' => [], 'end' => []] , ['presentConstraint']],
+                [['start' => [], 'end' => []] , ['pastConstraint']],
+                [['start' => [], 'end' => []] , ['futureConstraint']]
+            )->willReturn(['start' => [], 'end' => []]);
+
+        $activityYearConstraint
+            ->expects(self::once())
+            ->method('cleanConstraints')
+            ->with(['start' => [], 'end' => []]);
+
+        $activityYearConstraint->invoke(123);
+    }
+
+    public function testInvokeWithCustom(): void {
+        $activityYearConstraint = $this->getMockBuilder(TestableActivityYearConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['invoke'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        $accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $accessRepository->method('find')->with(123)->willReturn($access);
+
+        $this->entityManager->method('getRepository')->with(Access::class)->willReturn($accessRepository);
+
+        $historical = ["future" => false,"past" => false,"present" => true,"dateStart" => '2020-01-01',"dateEnd" => '2020-02-01'];
+
+        $access->method('getHistorical')->willReturn($historical);
+        $access->method('getActivityYear')->willReturn(2020);
+
+        $activityYearConstraint->method('hasCustomPeriods')->with($historical)->willReturn(true);
+
+        $activityYearConstraint->expects(self::never())->method('pastConstraint');
+        $activityYearConstraint->expects(self::never())->method('presentConstraint');
+        $activityYearConstraint->expects(self::never())->method('futureConstraint');
+
+        $activityYearConstraint
+            ->method('getRangeYear')
+            ->with($access, '2020-01-01', '2020-02-01')
+            ->willReturn(['dateStart' => 2020, 'dateEnd' => 2020]);
+
+        $activityYearConstraint->method('customConstraint')->with(['dateStart' => 2020, 'dateEnd' => 2020])->willReturn(['bar']);
+
+        $activityYearConstraint
+            ->expects(self::once())
+            ->method('addConstraint')
+            ->with(['start' => [], 'end' => []], ['bar'])
+            ->willReturn(['start' => [], 'end' => []]);
+
+        $activityYearConstraint
+            ->expects(self::once())
+            ->method('cleanConstraints')
+            ->with(['start' => [], 'end' => []]);
+
+        $activityYearConstraint->invoke(123);
+    }
+
+    public function testGetRangeYear(): void {
+        $activityYearConstraint = $this->getMockBuilder(TestableActivityYearConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['getRangeYear'])
+            ->getMock();
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $access->method('getOrganization')->willReturn($organization);
+
+        $this->organizationUtils
+            ->method('getActivityYearSwitchDate')
+            ->with($organization, self::isInstanceOf(\DateTime::class))
+            ->willReturnOnConsecutiveCalls(
+              2020, 2020, 2020, 2022
+            );
+
+        $this->assertEquals(
+            ['dateStart' => 2020, 'dateEnd' => 2020],
+            $activityYearConstraint->getRangeYear($access, '2020-01-01', '2020-12-31')
+        );
+        $this->assertEquals(
+            ['dateStart' => 2020, 'dateEnd' => 2022],
+            $activityYearConstraint->getRangeYear($access, '2020-01-01', '2022-12-31')
+        );
+    }
+
+    public function testPresentConstraint(): void {
+        $activityYearConstraint = $this->getMockBuilder(TestableActivityYearConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['presentConstraint'])
+            ->getMock();
+
+        $this->assertEquals(
+            ['start' => [2020 => 3]],
+            $activityYearConstraint->presentConstraint(2020)
+        );
+    }
+
+    public function testPastConstraint(): void {
+        $activityYearConstraint = $this->getMockBuilder(TestableActivityYearConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['pastConstraint'])
+            ->getMock();
+
+        $this->assertEquals(
+            ['end' => [2020 => 1]],
+            $activityYearConstraint->pastConstraint(2020)
+        );
+    }
+
+    public function testFutureConstraint(): void {
+        $activityYearConstraint = $this->getMockBuilder(TestableActivityYearConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['futureConstraint'])
+            ->getMock();
+
+        $this->assertEquals(
+            ['start' => [2020 => 5]],
+            $activityYearConstraint->futureConstraint(2020)
+        );
+    }
+
+    public function testCustomConstraint(): void {
+        $activityYearConstraint = $this->getMockBuilder(TestableActivityYearConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['customConstraint'])
+            ->getMock();
+
+        $this->assertEquals(
+            ['start' => [2020 => 8, 2022 => 4]],
+            $activityYearConstraint->customConstraint(['dateStart' => 2020, 'dateEnd' => 2022])
+        );
+    }
+}

+ 243 - 12
tests/Service/Constraint/DateTimeConstraintTest.php

@@ -4,6 +4,7 @@ namespace App\Tests\Service\Constraint;
 use App\Entity\Access\Access;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Parameters;
+use App\Repository\Access\AccessRepository;
 use App\Service\Constraint\DateTimeConstraint;
 use App\Service\Organization\Utils as OrganizationUtils;
 use App\Tests\TestToolsTrait;
@@ -11,19 +12,31 @@ use Doctrine\ORM\EntityManagerInterface;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 
+class TestableDateTimeConstraint extends DateTimeConstraint {
+    public function hasCustomPeriods($historical): bool { return parent::hasCustomPeriods($historical); }
+    public function addConstraint(array $contraints, array $newContraint): array { return parent::addConstraint($contraints, $newContraint); }
+    public function cleanConstraints(array $constraints): array { return parent::cleanConstraints($constraints); }
+
+    public function getCustomPeriods(string $dateStart, string $dateEnd): array { return parent::getCustomPeriods($dateStart, $dateEnd); }
+    public function getPeriods(Access $access): array { return parent::getPeriods($access); }
+    public function presentConstraint(array $periods): array { return parent::presentConstraint($periods); }
+    public function pastConstraint($periods): array { return parent::pastConstraint($periods); }
+    public function futureConstraint($periods): array { return parent::futureConstraint($periods); }
+}
+
 
 class DateTimeConstraintTest extends TestCase
 {
    use TestToolsTrait;
 
    private MockObject | OrganizationUtils $organizationUtils;
-   private MockObject | EntityManagerInterface $em;
+   private MockObject | EntityManagerInterface $entityManager;
 
    private array $periods;
 
    public function setUp(): void
    {
-       $this->em = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+       $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
        $this->organizationUtils = $this->getMockBuilder(OrganizationUtils::class)->disableOriginalConstructor()->getMock();
 
        $this->periods = [
@@ -32,6 +45,224 @@ class DateTimeConstraintTest extends TestCase
        ];
    }
 
+   public function testInvokePresentNoCustomPeriods(): void {
+       $dateTimeConstraint = $this->getMockBuilder(TestableDateTimeConstraint::class)
+           ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+           ->setMethodsExcept(['invoke'])
+           ->getMock();
+
+       $access = $this->getMockBuilder(Access::class)->getMock();
+
+       $accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+       $accessRepository->method('find')->with(123)->willReturn($access);
+
+       $this->entityManager->method('getRepository')->with(Access::class)->willReturn($accessRepository);
+
+       $historical = ["future" => false,"past" => false,"present" => true,"dateStart" => null,"dateEnd" => null];
+       $access->method('getHistorical')->willReturn($historical);
+
+       $dateTimeConstraint->method('hasCustomPeriods')->with($historical)->willReturn(false);
+
+       $periods = ['dateStart' => '2020-01-01', 'dateEnd' => '2020-12-31'];
+       $dateTimeConstraint->method('getPeriods')->with($access)->willReturn($periods);
+
+       $constraint = ['foo'];
+       $dateTimeConstraint->method('presentConstraint')->with($periods)->willReturn($constraint);
+
+       $dateTimeConstraint
+           ->expects(self::once())
+           ->method('addConstraint')
+           ->with(['start' => [], 'end' => []] , $constraint)
+           ->willReturn(['bar']);
+
+       $dateTimeConstraint
+           ->expects(self::once())
+           ->method('cleanConstraints')
+           ->with(['bar']);
+
+       $dateTimeConstraint->invoke(123);
+   }
+
+    public function testInvokePastNoCustomPeriods(): void
+    {
+        $dateTimeConstraint = $this->getMockBuilder(TestableDateTimeConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['invoke'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        $accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $accessRepository->method('find')->with(123)->willReturn($access);
+
+        $this->entityManager->method('getRepository')->with(Access::class)->willReturn($accessRepository);
+
+        $historical = ["future" => false,"past" => true,"present" => false,"dateStart" => null,"dateEnd" => null];
+
+        $access->method('getHistorical')->willReturn($historical);
+
+        $dateTimeConstraint->method('hasCustomPeriods')->with($historical)->willReturn(false);
+
+        $periods = ['dateStart' => '2020-01-01', 'dateEnd' => '2020-12-31'];
+        $dateTimeConstraint->method('getPeriods')->with($access)->willReturn($periods);
+
+        $constraint = ['foo'];
+        $dateTimeConstraint->method('pastConstraint')->with($periods)->willReturn($constraint);
+
+        $dateTimeConstraint
+            ->expects(self::once())
+            ->method('addConstraint')
+            ->with(['start' => [], 'end' => []] , $constraint)
+            ->willReturn(['bar']);
+
+        $dateTimeConstraint
+            ->expects(self::once())
+            ->method('cleanConstraints')
+            ->with(['bar']);
+
+        $dateTimeConstraint->invoke(123);
+    }
+
+    public function testInvokeFutureNoCustomPeriods(): void
+    {
+        $dateTimeConstraint = $this->getMockBuilder(TestableDateTimeConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['invoke'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        $accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $accessRepository->method('find')->with(123)->willReturn($access);
+
+        $this->entityManager->method('getRepository')->with(Access::class)->willReturn($accessRepository);
+
+        $historical = ["future" => true,"past" => false,"present" => false,"dateStart" => null,"dateEnd" => null];
+
+        $access->method('getHistorical')->willReturn($historical);
+
+        $dateTimeConstraint->method('hasCustomPeriods')->with($historical)->willReturn(false);
+
+        $periods = ['dateStart' => '2020-01-01', 'dateEnd' => '2020-12-31'];
+        $dateTimeConstraint->method('getPeriods')->with($access)->willReturn($periods);
+
+        $constraint = ['foo'];
+        $dateTimeConstraint->method('futureConstraint')->with($periods)->willReturn($constraint);
+
+        $dateTimeConstraint
+            ->expects(self::once())
+            ->method('addConstraint')
+            ->with(['start' => [], 'end' => []] , $constraint)
+            ->willReturn(['bar']);
+
+        $dateTimeConstraint
+            ->expects(self::once())
+            ->method('cleanConstraints')
+            ->with(['bar']);
+
+        $dateTimeConstraint->invoke(123);
+    }
+
+    public function testInvokeMultiplePeriodsNoCustom(): void
+    {
+        $dateTimeConstraint = $this->getMockBuilder(TestableDateTimeConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['invoke'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        $accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $accessRepository->method('find')->with(123)->willReturn($access);
+
+        $this->entityManager->method('getRepository')->with(Access::class)->willReturn($accessRepository);
+
+        $historical = ["future" => true,"past" => true,"present" => true,"dateStart" => null,"dateEnd" => null];
+
+        $access->method('getHistorical')->willReturn($historical);
+        $dateTimeConstraint->method('getPeriods')->with($access)->willReturn(['dateStart' => '2020-01-01', 'dateEnd' => '2020-12-31']);
+
+        $dateTimeConstraint->method('hasCustomPeriods')->with($historical)->willReturn(false);
+
+        $dateTimeConstraint->method('pastConstraint')->willReturn(['pastConstraint']);
+        $dateTimeConstraint->method('presentConstraint')->willReturn(['presentConstraint']);
+        $dateTimeConstraint->method('futureConstraint')->willReturn(['futureConstraint']);
+
+        $dateTimeConstraint
+            ->expects(self::exactly(3))
+            ->method('addConstraint')
+            ->withConsecutive(
+                [['start' => [], 'end' => []] , ['presentConstraint']],
+                [['start' => [], 'end' => []] , ['pastConstraint']],
+                [['start' => [], 'end' => []] , ['futureConstraint']]
+            )->willReturn(['start' => [], 'end' => []]);
+
+        $dateTimeConstraint
+            ->expects(self::once())
+            ->method('cleanConstraints')
+            ->with(['start' => [], 'end' => []]);
+
+        $dateTimeConstraint->invoke(123);
+    }
+
+    public function testInvokeWithCustom(): void {
+        $dateTimeConstraint = $this->getMockBuilder(TestableDateTimeConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+            ->setMethodsExcept(['invoke'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+
+        $accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $accessRepository->method('find')->with(123)->willReturn($access);
+
+        $this->entityManager->method('getRepository')->with(Access::class)->willReturn($accessRepository);
+
+        $historical = ["future" => false,"past" => false,"present" => true,"dateStart" => '2020-01-01',"dateEnd" => '2020-02-01'];
+
+        $access->method('getHistorical')->willReturn($historical);
+
+        $dateTimeConstraint->method('hasCustomPeriods')->with($historical)->willReturn(true);
+
+        $dateTimeConstraint->expects(self::never())->method('pastConstraint');
+        $dateTimeConstraint->expects(self::never())->method('futureConstraint');
+
+        $dateTimeConstraint
+            ->method('getCustomPeriods')
+            ->with('2020-01-01', '2020-02-01')
+            ->willReturn(['dateStart' => 2020, 'dateEnd' => 2020]);
+
+        $dateTimeConstraint->expects(self::once())
+            ->method('presentConstraint')
+            ->with(['dateStart' => 2020, 'dateEnd' => 2020])
+            ->willReturn(['bar']);
+
+        $dateTimeConstraint
+            ->expects(self::once())
+            ->method('addConstraint')
+            ->with(['start' => [], 'end' => []], ['bar'])
+            ->willReturn(['start' => [], 'end' => []]);
+
+        $dateTimeConstraint
+            ->expects(self::once())
+            ->method('cleanConstraints')
+            ->with(['start' => [], 'end' => []]);
+
+        $dateTimeConstraint->invoke(123);
+    }
+
+   public function testGetCustomPeriods(): void {
+       $dateTimeConstraint = $this->getMockBuilder(TestableDateTimeConstraint::class)
+           ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
+           ->setMethodsExcept(['getCustomPeriods'])
+           ->getMock();
+
+       $this->assertEquals(
+           ['dateStart' => '2020-01-01', 'dateEnd' => '2020-12-31'],
+           $dateTimeConstraint->getCustomPeriods('2020-01-01', '2020-12-31')
+       );
+   }
+
    /**
     * @see DateTimeConstraint::presentConstraint()
     */
@@ -47,8 +278,8 @@ class DateTimeConstraintTest extends TestCase
            ]
        ];
 
-       $dateTimeConstraint = $this->getMockBuilder(DateTimeConstraint::class)
-           ->setConstructorArgs([$this->em, $this->organizationUtils])
+       $dateTimeConstraint = $this->getMockBuilder(TestableDateTimeConstraint::class)
+           ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
            ->setMethodsExcept(['presentConstraint'])
            ->getMock();
 
@@ -68,8 +299,8 @@ class DateTimeConstraintTest extends TestCase
             ]
         ];
 
-        $dateTimeConstraint = $this->getMockBuilder(DateTimeConstraint::class)
-            ->setConstructorArgs([$this->em, $this->organizationUtils])
+        $dateTimeConstraint = $this->getMockBuilder(TestableDateTimeConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
             ->setMethodsExcept(['pastConstraint'])
             ->getMock();
 
@@ -89,8 +320,8 @@ class DateTimeConstraintTest extends TestCase
             ]
         ];
 
-        $dateTimeConstraint = $this->getMockBuilder(DateTimeConstraint::class)
-            ->setConstructorArgs([$this->em, $this->organizationUtils])
+        $dateTimeConstraint = $this->getMockBuilder(TestableDateTimeConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
             ->setMethodsExcept(['futureConstraint'])
             ->getMock();
 
@@ -130,8 +361,8 @@ class DateTimeConstraintTest extends TestCase
             ->with($organization, 2020)
             ->willReturn(['dateStart' => 'YEAR-09-01', 'dateEnd' => ($activityYear + 1) . '-08-31']); // dateStart will be overwritten
 
-        $dateTimeConstraint = $this->getMockBuilder(DateTimeConstraint::class)
-            ->setConstructorArgs([$this->em, $this->organizationUtils])
+        $dateTimeConstraint = $this->getMockBuilder(TestableDateTimeConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
             ->setMethodsExcept(['getPeriods'])
             ->getMock();
 
@@ -160,8 +391,8 @@ class DateTimeConstraintTest extends TestCase
             ->with($organization, 2020)
             ->willReturn(['dateStart' => '2020-09-01', 'dateEnd' => '2021-08-31']);
 
-        $dateTimeConstraint = $this->getMockBuilder(DateTimeConstraint::class)
-            ->setConstructorArgs([$this->em, $this->organizationUtils])
+        $dateTimeConstraint = $this->getMockBuilder(TestableDateTimeConstraint::class)
+            ->setConstructorArgs([$this->entityManager, $this->organizationUtils])
             ->setMethodsExcept(['getPeriods'])
             ->getMock();
 

+ 44 - 0
tests/Service/Core/AddressPostalUtilsTest.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Test\Service\Access;
+
+use App\Entity\Core\AddressPostal;
+use App\Service\Core\AddressPostalUtils;
+use PHPUnit\Framework\TestCase;
+
+class AddressPostalUtilsTest extends TestCase
+{
+    public function testGetFullStreetAddress(): void {
+        $addressPostalUtils = new AddressPostalUtils(); // Cf. bug when SUT has only one method, can't be mocked
+
+        $addressPostal = $this->getMockBuilder(AddressPostal::class)->getMock();
+        $addressPostal->method('getStreetAddress')->willReturn('Abc');
+        $addressPostal->method('getStreetAddressSecond')->willReturn('Def  ');
+        $addressPostal->method('getStreetAddressThird')->willReturn('  Ghi ');
+
+        $this->assertEqualsCanonicalizing(
+            "Abc\nDef\nGhi",
+            $addressPostalUtils->getFullStreetAddress($addressPostal)
+        );
+
+        $this->assertEqualsCanonicalizing(
+            "Abc Def Ghi",
+            $addressPostalUtils->getFullStreetAddress($addressPostal, ' ')
+        );
+    }
+
+    public function testGetFullStreetAddressWithMissing(): void {
+        $addressPostalUtils = new AddressPostalUtils(); // Cf. bug when SUT has only one method, can't be mocked
+
+        $addressPostal = $this->getMockBuilder(AddressPostal::class)->getMock();
+        $addressPostal->method('getStreetAddress')->willReturn('Abc');
+        $addressPostal->method('getStreetAddressSecond')->willReturn('');
+        $addressPostal->method('getStreetAddressThird')->willReturn('  Def');
+
+        $this->assertEqualsCanonicalizing(
+            "Abc\nDef",
+            $addressPostalUtils->getFullStreetAddress($addressPostal)
+        );
+    }
+
+}

+ 29 - 1
tests/Service/Core/ContactPointUtilsTest.php

@@ -52,7 +52,8 @@ class ContactPointUtilsTest extends TestCase
     /**
      * @see Utils::getPersonContactPointPrincipal()
      */
-    public function testGetPersonContactPointPrincipalNotExisting(){
+    public function testGetPersonContactPointPrincipalNotExisting(): void
+    {
         $person = $this->getMockBuilder(Person::class)->disableOriginalConstructor()->getMock();
         $access = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
         $access->method('getPerson')->willReturn($person);
@@ -72,4 +73,31 @@ class ContactPointUtilsTest extends TestCase
             $contactPointUtils->getPersonContactPointPrincipal($access)
         );
     }
+
+    /**
+     * @see Utils::getPersonContactPointPrincipal()
+     */
+    public function testGetPersonContactPointPrincipalMoreThanOne(): void {
+        $person = $this->getMockBuilder(Person::class)->disableOriginalConstructor()->getMock();
+        $access = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+        $access->method('getPerson')->willReturn($person);
+
+        $contactPoint1 = $this->getMockBuilder(ContactPoint::class)->disableOriginalConstructor()->getMock();
+        $contactPoint2 = $this->getMockBuilder(ContactPoint::class)->disableOriginalConstructor()->getMock();
+
+        $this->contactPointRepository
+            ->method('getByTypeAndPerson')
+            ->with(ContactPointTypeEnum::PRINCIPAL()->getValue(), $access->getPerson())
+            ->willReturn([$contactPoint1, $contactPoint2]);
+
+        $contactPointUtils = $this
+            ->getMockBuilder(ContactPointUtils::class)
+            ->setConstructorArgs([$this->contactPointRepository])
+            ->setMethodsExcept(['getPersonContactPointPrincipal'])
+            ->getMock();
+
+        $this->expectException(\RuntimeException::class);
+
+        $contactPointUtils->getPersonContactPointPrincipal($access);
+    }
 }

+ 21 - 1
tests/Service/Cotisation/UtilsTest.php

@@ -328,7 +328,7 @@ class UtilsTest extends TestCase
     /**
      * @see Utils::getCurrentCotisationYear()
      */
-    public function testGetCurrentCotisationYear(): void
+    public function testGetCurrentCotisationYearDefault(): void
     {
         $cotisationUtils = $this->getMockBuilder(CotisationUtils::class)
             ->setConstructorArgs([$this->networkUtils, $this->organizationUtils, $this->networkOrganizationRepository, $this->cotisationApiResourcesRepository])
@@ -343,4 +343,24 @@ class UtilsTest extends TestCase
 
         $this->assertEquals($expectedYear, $cotisationUtils->getCurrentCotisationYear());
     }
+
+    /**
+     * @see Utils::getCurrentCotisationYear()
+     */
+    public function testGetCurrentCotisationYear(): void
+    {
+        $cotisationUtils = $this->getMockBuilder(CotisationUtils::class)
+            ->setConstructorArgs([$this->networkUtils, $this->organizationUtils, $this->networkOrganizationRepository, $this->cotisationApiResourcesRepository])
+            ->setMethodsExcept(['getCurrentCotisationYear'])
+            ->getMock();
+
+        $this->assertEquals(
+            2022,
+            $cotisationUtils->getCurrentCotisationYear(new \DateTime('2022-03-01'))
+        );
+        $this->assertEquals(
+            2023,
+            $cotisationUtils->getCurrentCotisationYear(new \DateTime('2022-10-01'))
+        );
+    }
 }

+ 102 - 0
tests/Service/Dolibarr/DolibarrApiServiceTest.php

@@ -80,6 +80,28 @@ class DolibarrApiServiceTest extends TestCase
         $this->assertEquals(null, $dolibarrApiService->getActiveContract($socId));
     }
 
+    /**
+     * @see DolibarrApiService::getActiveContract()
+     */
+    public function testGetActiveContractError(): void {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['getActiveContract'])
+            ->getMock();
+
+        $socId = 1;
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with("contracts", ["limit" => "1", "sqlfilters" => "statut=1", "thirdparty_ids" => $socId])
+            ->willThrowException(new HttpException(500));
+
+        $this->expectException(HttpException::class);
+
+        $dolibarrApiService->getActiveContract($socId);
+    }
+
     /**
      * @see DolibarrApiService::getBills()
      */
@@ -120,6 +142,31 @@ class DolibarrApiServiceTest extends TestCase
         $this->assertEquals([], $dolibarrApiService->getBills($socId));
     }
 
+    /**
+     * @see DolibarrApiService::getBills()
+     */
+    public function testGetBillsError(): void {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['getBills'])
+            ->getMock();
+
+        $socId = 1;
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with("invoices", ["sortfield" => "datef", "sortorder" => "DESC", "limit" => 5, "sqlfilters" => "fk_soc=" . $socId])
+            ->willThrowException(new HttpException(500));
+
+        $this->expectException(HttpException::class);
+
+        $dolibarrApiService->getBills($socId);
+    }
+
+    /**
+     * @see DolibarrApiService::getAllClients()
+     */
     public function testGetAllClients(): void
     {
         $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
@@ -136,6 +183,9 @@ class DolibarrApiServiceTest extends TestCase
         $this->assertEquals([['id' => 10], ['id' => 20]], $dolibarrApiService->getAllClients());
     }
 
+    /**
+     * @see DolibarrApiService::getContacts()
+     */
     public function testGetContacts() {
         $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
             ->setConstructorArgs([$this->client])
@@ -153,6 +203,9 @@ class DolibarrApiServiceTest extends TestCase
         $this->assertEquals([['id' => 10], ['id' => 20]], $dolibarrApiService->getContacts($socId));
     }
 
+    /**
+     * @see DolibarrApiService::getContacts()
+     */
     public function testGetContactsMissing(): void {
         $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
             ->setConstructorArgs([$this->client])
@@ -170,6 +223,32 @@ class DolibarrApiServiceTest extends TestCase
         $this->assertEquals([], $dolibarrApiService->getContacts($socId));
     }
 
+    /**
+     * @see DolibarrApiService::getContacts()
+     */
+    public function testGetContactsError(): void {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['getContacts'])
+            ->getMock();
+
+        $socId = 1;
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with("contacts", ['limit' => 1000, 'thirdparty_ids' => $socId])
+            ->willThrowException(new HttpException(500));
+
+
+        $this->expectException(HttpException::class);
+
+        $dolibarrApiService->getContacts($socId);
+    }
+
+    /**
+     * @see DolibarrApiService::getActiveOpentalentContacts()
+     */
     public function testGetActiveOpentalentContacts() {
         $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
             ->setConstructorArgs([$this->client])
@@ -187,6 +266,9 @@ class DolibarrApiServiceTest extends TestCase
         $this->assertEquals([['id' => 10], ['id' => 20]], $dolibarrApiService->getActiveOpentalentContacts($socId));
     }
 
+    /**
+     * @see DolibarrApiService::getActiveOpentalentContacts()
+     */
     public function testGetActiveOpentalentContactsMissing(): void {
         $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
             ->setConstructorArgs([$this->client])
@@ -204,5 +286,25 @@ class DolibarrApiServiceTest extends TestCase
         $this->assertEquals([], $dolibarrApiService->getActiveOpentalentContacts($socId));
     }
 
+    /**
+     * @see DolibarrApiService::getActiveOpentalentContacts()
+     */
+    public function testGetActiveOpentalentContactsError(): void {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['getActiveOpentalentContacts'])
+            ->getMock();
 
+        $socId = 1;
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('getJsonContent')
+            ->with("contacts?limit=1000&t.statut=1&thirdparty_ids=" . $socId . "&sqlfilters=(te.2iopen_person_id%3A%3E%3A0)")
+            ->willThrowException(new HttpException(500));
+
+        $this->expectException(HttpException::class);
+
+        $dolibarrApiService->getActiveOpentalentContacts($socId);
+    }
 }

+ 73 - 0
tests/Service/Dolibarr/DolibarrSyncServiceTest.php

@@ -38,6 +38,7 @@ use Psr\Log\LoggerInterface;
 use RuntimeException;
 use Symfony\Contracts\HttpClient\ResponseInterface;
 use Symfony\Contracts\Translation\TranslatorInterface;
+use Symfony\Component\HttpClient\Exception\ServerException;
 
 class TestableDolibarrSyncService extends DolibarrSyncService {
     public function getDolibarrSocietiesIndex(): array { return parent::getDolibarrSocietiesIndex(); }
@@ -1247,4 +1248,76 @@ class DolibarrSyncServiceTest extends TestCase
             $dolibarrSyncService->formatPhoneNumber($phoneNumber)
         );
     }
+
+    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();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $response->method('toArray')->willReturn(['a' => 1]);
+
+        $operation = $this->getMockBuilder(CreateOperation::class)->disableOriginalConstructor()->getMock();
+        $operation->method('getData')->willReturn(['a' => 1]);
+
+        $dolibarrSyncService->expects(self::exactly(2))->method('sanitizeDolibarrData')->with(['a' => 1])->willReturn(['a' => 1]);
+
+        $this->arrayUtils->expects(self::once())->method('getChanges')->with(['a' => 1], ['a' => 1], true)->willReturn([]);
+
+        $dolibarrSyncService->validateResponse($response, $operation);
+    }
+
+    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();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $response->method('toArray')->willReturn(['a' => 1]);
+
+        $operation = $this->getMockBuilder(CreateOperation::class)->disableOriginalConstructor()->getMock();
+        $operation->method('getData')->willReturn(['a' => 0]);
+
+        $dolibarrSyncService->expects(self::exactly(2))
+            ->method('sanitizeDolibarrData')
+            ->willReturnMap([
+                [['a' => 1], ['a' => 1]],
+                [['a' => 0], ['a' => 0]]
+            ]);
+
+        $this->arrayUtils->expects(self::once())->method('getChanges')->with(['a' => 1], ['a' => 0], true)->willReturn(['a' => 0]);
+
+        $this->expectException(RuntimeException::class);
+
+        $dolibarrSyncService->validateResponse($response, $operation);
+    }
+
+    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();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $response->method('getInfo')->willReturnMap([
+            ['http_code', '200'], ['url', 'http://url.com'], ['response_headers', []]
+        ]);
+        $response->method('getContent')->willReturn("");
+        $response->method('toArray')->willThrowException(new ServerException($response));
+
+        $operation = $this->getMockBuilder(CreateOperation::class)->disableOriginalConstructor()->getMock();
+        $operation->method('getData')->willReturn(['a' => 0]);
+
+        $dolibarrSyncService->expects(self::never())->method('sanitizeDolibarrData');
+        $this->arrayUtils->expects(self::never())->method('getChanges');
+
+        $this->expectException(RuntimeException::class);
+
+        $dolibarrSyncService->validateResponse($response, $operation);
+    }
 }

+ 35 - 0
tests/Service/Elasticsearch/EducationNotationUpdaterTest.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Tests\Service\Elasticsearch;
+
+use App\Service\Elasticsearch\EducationNotationUpdater;
+use FOS\ElasticaBundle\Persister\ObjectPersister;
+use PHPUnit\Framework\TestCase;
+
+class TestableEducationNotationUpdater extends EducationNotationUpdater {
+    // To avoid the bug when mocking classes with only one method;
+    // can be removed when the tested class will have at least 2 methods
+    public function foo() {}
+}
+
+class EducationNotationUpdaterTest extends TestCase
+{
+    private ObjectPersister $objectPersister;
+
+    public function setUp(): void {
+        $this->objectPersister = $this->getMockBuilder(ObjectPersister::class)->disableOriginalConstructor()->getMock();
+    }
+
+    public function testUpdate(): void {
+        $educationNotationUpdater = $this->getMockBuilder(TestableEducationNotationUpdater::class)
+            ->setConstructorArgs([$this->objectPersister])
+            ->setMethodsExcept(['update'])
+            ->getMock();
+
+        $educationNotations = ['foo'];
+
+        $this->objectPersister->expects(self::once())->method('replaceMany')->with($educationNotations);
+
+        $educationNotationUpdater->update($educationNotations);
+    }
+}

+ 186 - 0
tests/Service/Export/BaseExporterTest.php

@@ -0,0 +1,186 @@
+<?php
+
+namespace App\Tests\Service\Export;
+
+use App\ApiResources\Export\ExportRequest;
+use App\Entity\Access\Access;
+use App\Entity\Core\File;
+use App\Entity\Organization\Organization;
+use App\Enum\Core\FileTypeEnum;
+use App\Repository\Access\AccessRepository;
+use App\Repository\Core\FileRepository;
+use App\Service\Export\BaseExporter;
+use App\Service\Export\Model\ExportModelInterface;
+use App\Service\ServiceIterator\EncoderIterator;
+use App\Service\Storage\LocalStorage;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Twig\Environment;
+
+class TestableBaseExporter extends BaseExporter {
+    public function buildModel(ExportRequest $exportRequest): ExportModelInterface { return parent::buildModel($exportRequest); }
+    public function getBasename(): string { return parent::getBasename(); }
+    public function getTemplatePath(): string { return parent::getTemplatePath(); }
+    public function render(ExportModelInterface $model): string { return parent::render($model); }
+    public function encode(string $html, string $format): string { return parent::encode($html, $format); }
+    public function getFileBasename(ExportRequest $exportRequest): string { return parent::getFileBasename($exportRequest); }
+    public function getFileType(): FileTypeEnum { return parent::getFileType(); }
+}
+
+
+class BaseExporterTest extends TestCase
+{
+    private AccessRepository $accessRepository;
+    private FileRepository $fileRepository;
+    private Environment $twig;
+    private EncoderIterator $encoderIterator;
+    private EntityManagerInterface $entityManager;
+    private LocalStorage $storage;
+    private LoggerInterface $logger;
+
+    public function setUp(): void {
+        $this->accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $this->fileRepository = $this->getMockBuilder(FileRepository::class)->disableOriginalConstructor()->getMock();
+        $this->twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock();
+        $this->encoderIterator = $this->getMockBuilder(EncoderIterator::class)->disableOriginalConstructor()->getMock();
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->storage = $this->getMockBuilder(LocalStorage::class)->disableOriginalConstructor()->getMock();
+        $this->logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock();
+    }
+
+    private function getBaseExporterMockFor(string $method): MockObject | TestableBaseExporter
+    {
+        $exporter = $this->getMockBuilder(TestableBaseExporter::class)
+            ->setMethodsExcept([$method, 'setAccessRepository', 'setFileRepository', 'setTwig', 'setEncoderIterator',
+                'setEntityManager', 'setStorage', 'setLogger'])
+            ->getMock();
+
+        $exporter->setAccessRepository($this->accessRepository);
+        $exporter->setFileRepository($this->fileRepository);
+        $exporter->setTwig($this->twig);
+        $exporter->setEncoderIterator($this->encoderIterator);
+        $exporter->setEntityManager($this->entityManager);
+        $exporter->setStorage($this->storage);
+        $exporter->setLogger($this->logger);
+
+        return $exporter;
+    }
+
+    public function testSupport(): void {
+        $exporter = $this->getBaseExporterMockFor('support');
+
+        $exportRequest = $this->getMockBuilder(ExportRequest::class)->disableOriginalConstructor()->getMock();
+
+        $this->assertFalse($exporter->support($exportRequest));
+    }
+
+    public function testExport(): void {
+        $exporter = $this->getBaseExporterMockFor('export');
+
+        $exportRequest = $this->getMockBuilder(ExportRequest::class)->disableOriginalConstructor()->getMock();
+        $exportRequest->method('getRequesterId')->willReturn(123);
+        $exportRequest->method('getFileId')->willReturn(456);
+
+        $model = $this->getMockBuilder(ExportModelInterface::class)->disableOriginalConstructor()->getMock();
+        $exporter->expects(self::once())->method('buildModel')->with($exportRequest)->willReturn($model);
+
+        $html = "<div>foo</div>";
+        $exporter->expects(self::once())->method('render')->with($model)->willReturn($html);
+
+        $data = 'azerty';
+        $exporter->expects(self::once())->method('encode')->with($html)->willReturn($data);
+
+        $access = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+        $this->accessRepository->method('find')->with(123, null, null)->willReturn($access);
+
+        $file = $this->getMockBuilder(File::class)->disableOriginalConstructor()->getMock();
+        $this->fileRepository->method('find')->with(456, null, null)->willReturn($file);
+
+        $this->storage->expects(self::once())->method('writeFile')->with($file, $data, $access);
+
+        $exporter->export($exportRequest);
+    }
+
+    public function testExportNewFile(): void {
+        $exporter = $this->getBaseExporterMockFor('export');
+
+        $exportRequest = $this->getMockBuilder(ExportRequest::class)->disableOriginalConstructor()->getMock();
+        $exportRequest->method('getRequesterId')->willReturn(123);
+        $exportRequest->method('getFileId')->willReturn(null);
+
+        $model = $this->getMockBuilder(ExportModelInterface::class)->disableOriginalConstructor()->getMock();
+        $exporter->expects(self::once())->method('buildModel')->with($exportRequest)->willReturn($model);
+
+        $html = "<div>foo</div>";
+        $exporter->expects(self::once())->method('render')->with($model)->willReturn($html);
+
+        $data = 'azerty';
+        $exporter->expects(self::once())->method('encode')->with($html)->willReturn($data);
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $access = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+        $access->method('getOrganization')->willReturn($organization);
+        $this->accessRepository->method('find')->with(123, null, null)->willReturn($access);
+
+        $file = $this->getMockBuilder(File::class)->disableOriginalConstructor()->getMock();
+        $exporter->expects(self::once())->method('prepareFile')->willReturn($file);
+
+        $this->storage->expects(self::once())->method('writeFile')->with($file, $data, $access);
+
+        $exporter->export($exportRequest);
+    }
+
+    public function testExportUndefinedRequester(): void {
+        $exporter = $this->getBaseExporterMockFor('export');
+
+        $exportRequest = $this->getMockBuilder(ExportRequest::class)->disableOriginalConstructor()->getMock();
+        $exportRequest->method('getRequesterId')->willReturn(123);
+        $exportRequest->method('getFileId')->willReturn(456);
+
+        $model = $this->getMockBuilder(ExportModelInterface::class)->disableOriginalConstructor()->getMock();
+        $exporter->expects(self::once())->method('buildModel')->with($exportRequest)->willReturn($model);
+
+        $html = "<div>foo</div>";
+        $exporter->expects(self::once())->method('render')->with($model)->willReturn($html);
+
+        $data = 'azerty';
+        $exporter->expects(self::once())->method('encode')->with($html)->willReturn($data);
+
+        $this->accessRepository->method('find')->with(123, null, null)->willReturn(null);
+
+        $this->storage->expects(self::never())->method('writeFile');
+
+        $this->expectException(\RuntimeException::class);
+
+        $exporter->export($exportRequest);
+    }
+
+    public function testExportUndefinedOrganization(): void {
+        $exporter = $this->getBaseExporterMockFor('export');
+
+        $exportRequest = $this->getMockBuilder(ExportRequest::class)->disableOriginalConstructor()->getMock();
+        $exportRequest->method('getRequesterId')->willReturn(123);
+        $exportRequest->method('getFileId')->willReturn(null);
+
+        $model = $this->getMockBuilder(ExportModelInterface::class)->disableOriginalConstructor()->getMock();
+        $exporter->expects(self::once())->method('buildModel')->with($exportRequest)->willReturn($model);
+
+        $html = "<div>foo</div>";
+        $exporter->expects(self::once())->method('render')->with($model)->willReturn($html);
+
+        $data = 'azerty';
+        $exporter->expects(self::once())->method('encode')->with($html)->willReturn($data);
+
+        $access = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+        $access->method('getOrganization')->willReturn(null);
+        $this->accessRepository->method('find')->with(123, null, null)->willReturn($access);
+
+        $this->storage->expects(self::never())->method('writeFile');
+
+        $this->expectException(\RuntimeException::class);
+
+        $exporter->export($exportRequest);
+    }
+}

+ 10 - 14
tests/Service/Export/LicenceCmfExporterTest.php

@@ -4,7 +4,6 @@ use App\ApiResources\Export\ExportRequest;
 use App\ApiResources\Export\LicenceCmf\LicenceCmfOrganizationER;
 use App\Entity\Access\Access;
 use App\Entity\Core\File;
-use App\Entity\Network\Network;
 use App\Entity\Network\NetworkOrganization;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Parameters;
@@ -13,12 +12,10 @@ use App\Repository\Access\AccessRepository;
 use App\Repository\Organization\OrganizationRepository;
 use App\Service\Export\Encoder\PdfEncoder;
 use App\Service\Export\LicenceCmfExporter;
-use App\Service\Export\Model\ExportModelInterface;
 use App\Service\Export\Model\LicenceCmf;
 use App\Service\Export\Model\LicenceCmfCollection;
 use App\Service\ServiceIterator\EncoderIterator;
-use App\Service\Storage\TemporaryFileStorage;
-use App\Service\Storage\UploadStorage;
+use App\Service\Storage\LocalStorage;
 use App\Tests\TestToolsTrait;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\EntityManagerInterface;
@@ -35,9 +32,8 @@ class LicenceCmfExporterTest extends TestCase
     private MockObject | Environment $twig;
     private MockObject | EncoderIterator $encoderIterator;
     private MockObject | EntityManagerInterface $em;
-    private MockObject | TemporaryFileStorage $storage;
+    private MockObject | LocalStorage $storage;
     private MockObject | OrganizationRepository $organizationRepo;
-    private MockObject | UploadStorage $uploadStorage;
     private MockObject | Access $access;
     private MockObject | Organization $organization;
     private MockObject | Organization $cmf;
@@ -58,9 +54,8 @@ class LicenceCmfExporterTest extends TestCase
         $this->encoderIterator = $this->getMockBuilder(EncoderIterator::class)->disableOriginalConstructor()->getMock();
         $this->encoder = $this->getMockBuilder(PdfEncoder::class)->disableOriginalConstructor()->getMock();
         $this->em = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
-        $this->storage = $this->getMockBuilder(TemporaryFileStorage::class)->disableOriginalConstructor()->getMock();
+        $this->storage = $this->getMockBuilder(LocalStorage::class)->disableOriginalConstructor()->getMock();
         $this->organizationRepo = $this->getMockBuilder(OrganizationRepository::class)->disableOriginalConstructor()->getMock();
-        $this->uploadStorage = $this->getMockBuilder(UploadStorage::class)->disableOriginalConstructor()->getMock();
         $this->access = $this->getMockBuilder(Access::class)->getMock();
         $this->organization = $this->getMockBuilder(Organization::class)->getMock();
         $this->cmf = $this->getMockBuilder(Organization::class)->getMock();
@@ -77,7 +72,7 @@ class LicenceCmfExporterTest extends TestCase
     private function makeExporterMock(string $methodUnderTest): LicenceCmfExporter | MockObject
     {
         $exporter = $this->getMockBuilder(LicenceCmfExporter::class)
-            ->setConstructorArgs([$this->organizationRepo, $this->uploadStorage])
+            ->setConstructorArgs([$this->organizationRepo])
             ->setMethodsExcept(['setAccessRepository', 'setTwig', 'setEncoderIterator',
                 'setEntityManager', 'setEntityManager', 'setStorage', $methodUnderTest])
             ->getMock();
@@ -101,7 +96,8 @@ class LicenceCmfExporterTest extends TestCase
         $this->assertFalse($exporter->support($unsupportedExportRequest));
     }
 
-    private function prepareModelBuilding() {
+    private function prepareModelBuilding(): void
+    {
         $this->exportRequest->method('getRequesterId')->willReturn(1);
         $this->exportRequest->method('getYear')->willReturn(2020);
         $this->exportRequest->method('getFormat')->willReturn('pdf');
@@ -117,7 +113,7 @@ class LicenceCmfExporterTest extends TestCase
         $this->parent->expects(self::once())->method('getName')->willReturn('my_network');
         $this->organization->method('getLogo')->willReturn($this->logo);
         $this->logo->method('getId')->willReturn(1);
-        $this->uploadStorage->method('getUri')->willReturn('http:://foo.bar/1');
+        $this->storage->method('getDownloadIri')->willReturn('http:://foo.bar/1');
         $this->president->method('getId')->willReturn(1);
         $this->president->method('getGender')->willReturn('M');
         $this->president->method('getGivenName')->willReturn('Joe');
@@ -174,10 +170,10 @@ class LicenceCmfExporterTest extends TestCase
         $licence = $this->getMockBuilder(LicenceCmf::class)->getMock();
         $licence->method('getYear')->willReturn(2020);
 
-        $model = $this->getMockBuilder(LicenceCmfCollection::class)->getMock();
-        $model->method('getLicences')->willReturn([$licence]);
+        $exportRequest = $this->getMockBuilder(LicenceCmfOrganizationER::class)->getMock();
+        $exportRequest->method('getYear')->willReturn(2020);
 
-        $result = $this->invokeMethod($exporter, 'getFileBasename', [$model]);
+        $result = $this->invokeMethod($exporter, 'getFileBasename', [$exportRequest]);
 
         $this->assertEquals(
             'licence_cmf_2020.pdf',

+ 36 - 0
tests/Service/Storage/ApiLegacyStorageTest.php

@@ -0,0 +1,36 @@
+<?php
+
+use App\Entity\Core\File;
+use App\Service\ApiLegacy\ApiLegacyRequestService;
+use App\Service\Storage\ApiLegacyStorage;
+use PHPUnit\Framework\TestCase;
+
+class ApiLegacyStorageTest extends TestCase
+{
+    public function testRead(): void
+    {
+        $apiLegacyRequestService = $this->getMockBuilder(ApiLegacyRequestService::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $apiLegacyStorageTest = $this
+            ->getMockBuilder(ApiLegacyStorage::class)
+            ->setConstructorArgs([$apiLegacyRequestService])
+            ->setMethodsExcept(['read'])
+            ->getMock();
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getId')->willReturn(123);
+
+        $apiLegacyRequestService
+            ->expects(self::once())
+            ->method('getContent')
+            ->with('api/files/123/download')
+            ->willReturn('xyz');
+
+        $result = $apiLegacyStorageTest->read($file);
+
+        $this->assertEquals('xyz', $result);
+    }
+
+}

+ 615 - 0
tests/Service/Storage/LocalStorageTest.php

@@ -0,0 +1,615 @@
+<?php /** @noinspection DuplicatedCode */
+
+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\Storage\LocalStorage;
+use Doctrine\ORM\EntityManagerInterface;
+use Gaufrette\Filesystem;
+use JetBrains\PhpStorm\Pure;
+use Knp\Bundle\GaufretteBundle\FilesystemMap;
+use PHPUnit\Framework\TestCase;
+
+class TestableLocalStorage extends LocalStorage {
+    public const FS_KEY = parent::FS_KEY;
+
+    public function getPrefix(mixed $owner, bool $isTemporary, string $type = null): string {
+        return parent::getPrefix($owner, $isTemporary, $type);
+    }
+    #[Pure] public function getOrganizationAndPersonFromOwner(mixed $owner): array {
+        return parent::getOrganizationAndPersonFromOwner($owner);
+    }
+}
+
+
+class LocalStorageTest extends TestCase
+{
+    private FilesystemMap $filesystemMap;
+    private EntityManagerInterface $entityManager;
+    private AccessRepository $accessRepository;
+    private Filesystem $filesystem;
+    private IriConverterInterface $iriConverter;
+
+    public function setUp(): void
+    {
+        $this->filesystemMap = $this->getMockBuilder(FilesystemMap::class)->disableOriginalConstructor()->getMock();
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->accessRepository = $this->getMockBuilder(AccessRepository::class)->disableOriginalConstructor()->getMock();
+        $this->iriConverter = $this->getMockBuilder(IriConverterInterface::class)->disableOriginalConstructor()->getMock();
+
+        $this->filesystem = $this->getMockBuilder(Filesystem::class)->disableOriginalConstructor()->getMock();
+        $this->filesystemMap->method('get')->with(TestableLocalStorage::FS_KEY)->willReturn($this->filesystem);
+    }
+
+    public function testExists(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['exists'])
+            ->getMock();
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getSlug')->willReturn('foo');
+
+        $this->filesystem->expects(self::once())->method('has')->with('foo')->willReturn(true);
+
+        $this->assertTrue($fileStorage->exists($file));
+    }
+
+    public function testExistsInexistant(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['exists'])
+            ->getMock();
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getSlug')->willReturn('foo');
+
+        $this->filesystem->expects(self::once())->method('has')->with('foo')->willReturn(false);
+
+        $this->assertFalse($fileStorage->exists($file));
+    }
+
+    public function testGetDownloadIri(): void
+    {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['getDownloadIri'])
+            ->getMock();
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getId')->willReturn(1);
+
+        $this->iriConverter
+            ->expects(self::once())
+            ->method('getItemIriFromResourceClass')
+            ->with(DownloadRequest::class, ['fileId' => 1])
+            ->willReturn('/api/download/1');
+
+        $this->assertEquals(
+            '/api/download/1',
+            $fileStorage->getDownloadIri($file)
+        );
+    }
+
+    public function testListByOwner(): void
+    {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['listByOwner'])
+            ->getMock();
+
+        $owner = $this->getMockBuilder(Organization::class)->getMock();
+
+        $fileStorage->method('getPrefix')->with($owner, false, FileTypeEnum::LICENCE_CMF()->getValue())->willReturn('foo');
+
+        $this->filesystem->method('listKeys')->with('foo')->willReturn(['foo/a.txt', 'foo/b.pdf']);
+
+        $this->assertEquals(
+            ['foo/a.txt', 'foo/b.pdf'],
+            $fileStorage->listByOwner($owner, FileTypeEnum::LICENCE_CMF())
+        );
+    }
+
+    public function testRead(): void
+    {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['read'])
+            ->getMock();
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getSlug')->willReturn('foo');
+
+        $this->filesystem->method('read')->with('foo')->willReturn('12345679');
+
+        $this->assertEquals(
+            '12345679',
+            $fileStorage->read($file)
+        );
+    }
+
+    public function testPrepareFile(): void
+    {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['prepareFile'])
+            ->getMock();
+
+        $owner = $this->getMockBuilder(Organization::class)->getMock();
+        $author = $this->getMockBuilder(Access::class)->getMock();
+        $author->method('getId')->willReturn(123);
+
+        $fileStorage->method('getOrganizationAndPersonFromOwner')->with($owner)->willReturn([$owner, null]);
+
+        $this->entityManager->expects(self::once())->method('persist');
+        $this->entityManager->expects(self::once())->method('flush');
+
+        $file = $fileStorage->prepareFile(
+            $owner,
+            'file.ext',
+            FileTypeEnum::LICENCE_CMF(),
+            $author,
+            true,
+            'ONLY_ORGANIZATION',
+            'application/pdf'
+        );
+
+        $this->assertEquals($owner, $file->getOrganization());
+        $this->assertEquals(null, $file->getPerson());
+        $this->assertEquals('file.ext', $file->getName());
+        $this->assertEquals(null, $file->getSlug());
+        $this->assertEquals(FileTypeEnum::LICENCE_CMF()->getValue(), $file->getType());
+        $this->assertEquals(true, $file->getIsTemporaryFile());
+        $this->assertEquals('ONLY_ORGANIZATION', $file->getVisibility());
+        $this->assertEquals('application/pdf', $file->getMimeType());
+        $this->assertEquals(123, $file->getCreatedBy());
+
+    }
+
+    public function testPrepareFileDefaultValues(): void
+    {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['prepareFile'])
+            ->getMock();
+
+        $owner = $this->getMockBuilder(Person::class)->getMock();
+        $author = $this->getMockBuilder(Access::class)->getMock();
+
+        $fileStorage->method('getOrganizationAndPersonFromOwner')->with($owner)->willReturn([null, $owner]);
+
+        $this->entityManager->expects(self::once())->method('persist');
+        $this->entityManager->expects(self::once())->method('flush');
+
+        $file = $fileStorage->prepareFile($owner, 'file.txt', FileTypeEnum::NONE(), $author);
+
+        $this->assertEquals(null, $file->getOrganization());
+        $this->assertEquals($owner, $file->getPerson());
+        $this->assertEquals('file.txt', $file->getName());
+        $this->assertEquals(FileTypeEnum::NONE()->getValue(), $file->getType());
+        $this->assertEquals(false, $file->getIsTemporaryFile());
+        $this->assertEquals('NOBODY', $file->getVisibility());
+        $this->assertEquals('text/plain', $file->getMimeType());
+    }
+
+    public function testPrepareFileNoFlush(): void
+    {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['prepareFile'])
+            ->getMock();
+
+        $owner = $this->getMockBuilder(Organization::class)->getMock();
+        $author = $this->getMockBuilder(Access::class)->getMock();
+
+        $fileStorage->method('getOrganizationAndPersonFromOwner')->with($owner)->willReturn([$owner, null]);
+
+        $this->entityManager->expects(self::once())->method('persist');
+        $this->entityManager->expects(self::never())->method('flush');
+
+        $fileStorage->prepareFile(
+            $owner,
+            'file.txt',
+            FileTypeEnum::NONE(),
+            $author,
+            false,
+            'NOBODY',
+            null,
+            false
+        );
+    }
+
+    public function testWriteFileNewFile(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['writeFile'])
+            ->getMock();
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $author = $this->getMockBuilder(Access::class)->getMock();
+        $author->method('getId')->willReturn(123);
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getName')->willReturn('foo.txt');
+        $file->method('getOrganization')->willReturn($organization);
+        $file->method('getPerson')->willReturn(null);
+        $file->method('getIsTemporaryFile')->willReturn(false);
+        $file->method('getSlug')->willReturn(null);
+        $file->method('getType')->willReturn(FileTypeEnum::NONE()->getValue());
+
+        $fileStorage
+            ->method('getPrefix')
+            ->with($organization, false, FileTypeEnum::NONE()->getValue())
+            ->willReturn('prefix/');
+
+        $content = '123456789';
+        $size = strlen($content);
+
+        $this->filesystem
+            ->expects(self::once())
+            ->method('write')
+            ->with(self::matchesRegularExpression('/^prefix\/\w{16,24}\/foo.txt/'), $content, true)
+            ->willReturn($size);
+
+        $file->expects(self::once())->method('setSize')->with($size)->willReturnSelf();
+        $file->expects(self::once())->method('setStatus')->with(FileStatusEnum::READY()->getValue())->willReturnSelf();
+        $file->expects(self::once())
+            ->method('setSlug')
+            ->with(self::matchesRegularExpression('/^prefix\/\w{16,24}\/foo.txt/'))
+            ->willReturnSelf();
+        $file->expects(self::once())->method('setCreateDate')->with(self::isInstanceOf(DateTime::class))->willReturnSelf();
+        $file->expects(self::once())->method('setCreatedBy')->with(123)->willReturnSelf();
+        $file->expects(self::never())->method('setUpdateDate');
+        $file->expects(self::never())->method('setUpdatedBy');
+
+        $this->entityManager->expects(self::once())->method('flush');
+
+        $returned = $fileStorage->writeFile($file, $content, $author);
+
+        $this->assertEquals($file, $returned);
+    }
+
+    public function testWriteFileExistingFile(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['writeFile'])
+            ->getMock();
+
+        $person = $this->getMockBuilder(Person::class)->getMock();
+
+        $author = $this->getMockBuilder(Access::class)->getMock();
+        $author->method('getId')->willReturn(123);
+
+        $key = 'prefix/uid/bar.txt';
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getName')->willReturn('bar.txt');
+        $file->method('getOrganization')->willReturn(null);
+        $file->method('getPerson')->willReturn($person);
+        $file->method('getIsTemporaryFile')->willReturn(true);
+        $file->method('getSlug')->willReturn($key);
+        $file->method('getType')->willReturn(FileTypeEnum::NONE()->getValue());
+
+        $fileStorage->expects(self::never())->method('getPrefix');
+
+        $content = '123 Soleil';
+        $size = strlen($content);
+
+        $this->filesystem
+            ->expects(self::once())
+            ->method('write')
+            ->with($key, $content, true)
+            ->willReturn($size);
+
+        $this->filesystem->method('has')->with($key)->willReturn(true);
+
+        $file->expects(self::once())->method('setSize')->with($size)->willReturnSelf();
+        $file->expects(self::once())->method('setStatus')->with(FileStatusEnum::READY()->getValue())->willReturnSelf();
+        $file->expects(self::never())->method('setSlug');
+        $file->expects(self::never())->method('setCreateDate');
+        $file->expects(self::never())->method('setCreatedBy');
+        $file->expects(self::once())->method('setUpdateDate')->with(self::isInstanceOf(DateTime::class))->willReturnSelf();
+        $file->expects(self::once())->method('setUpdatedBy')->with(123)->willReturnSelf();
+
+        $this->entityManager->expects(self::once())->method('flush');
+
+        $returned = $fileStorage->writeFile($file, $content, $author);
+
+        $this->assertEquals($file, $returned);
+    }
+
+    public function testWriteFileExistingButMissingFile(): void
+    {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['writeFile'])
+            ->getMock();
+
+        $person = $this->getMockBuilder(Person::class)->getMock();
+        $author = $this->getMockBuilder(Access::class)->getMock();
+
+        $key = 'prefix/uid/bar.txt';
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getName')->willReturn('bar.txt');
+        $file->method('getOrganization')->willReturn(null);
+        $file->method('getPerson')->willReturn($person);
+        $file->method('getIsTemporaryFile')->willReturn(true);
+        $file->method('getSlug')->willReturn($key);
+        $file->method('getType')->willReturn(FileTypeEnum::NONE()->getValue());
+
+        $this->filesystem->expects(self::never())->method('write');
+        $this->entityManager->expects(self::never())->method('flush');
+
+        $this->filesystem->method('has')->with($key)->willReturn(false);
+
+        $this->expectException(RuntimeException::class);
+        $this->expectDeprecationMessage('The file `' . $key . '` does not exist in the file storage');
+
+        $returned = $fileStorage->writeFile($file, '12346', $author);
+    }
+
+    public function testWriteFileWithAccessOwner(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['writeFile'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $person = $this->getMockBuilder(Person::class)->getMock();
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $author = $this->getMockBuilder(Access::class)->getMock();
+        $author->method('getId')->willReturn(123);
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getName')->willReturn('bar.txt');
+        $file->method('getOrganization')->willReturn($organization);
+        $file->method('getPerson')->willReturn($person);
+        $file->method('getIsTemporaryFile')->willReturn(true);
+        $file->method('getSlug')->willReturn(null);
+        $file->method('getType')->willReturn(FileTypeEnum::NONE()->getValue());
+
+        $this->accessRepository
+            ->expects(self::once())
+            ->method('findOneBy')
+            ->with(['organization' => $organization, 'person' => $person])
+            ->willReturn($access);
+
+        $fileStorage
+            ->expects(self::once())
+            ->method('getPrefix')
+            ->with($access, true, FileTypeEnum::NONE()->getValue())
+            ->willReturn('prefix/');
+
+        $content = '1';
+        $this->filesystem->method('write')->willReturn(1);
+
+        $fileStorage->writeFile($file, $content, $author);
+    }
+
+
+    public function testWriteFileWithNoName(): void
+    {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['writeFile'])
+            ->getMock();
+
+        $author = $this->getMockBuilder(Access::class)->getMock();
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getName')->willReturn('');
+
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage('File has no filename');
+
+        $fileStorage->writeFile($file, '...', $author);
+    }
+
+    public function testMakeFile(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['makeFile'])
+            ->getMock();
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $author = $this->getMockBuilder(Access::class)->getMock();
+        $file = $this->getMockBuilder(File::class)->getMock();
+
+        $fileStorage
+            ->expects(self::once())
+            ->method('prepareFile')
+            ->with($organization, 'foo.txt', FileTypeEnum::NONE(), $author, true, 'ONLY_ORGANIZATION', 'mime/type')
+            ->willReturn($file);
+
+        $fileStorage
+            ->expects(self::once())
+            ->method('writeFile')
+            ->with($file, '...', $author)
+            ->willReturn($file);
+
+        $fileStorage->makeFile(
+            $organization,
+            'foo.txt',
+            FileTypeEnum::NONE(),
+            '...',
+            $author,
+            true,
+            'ONLY_ORGANIZATION',
+            'mime/type');
+    }
+
+    public function testDelete(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['delete'])
+            ->getMock();
+
+        $author = $this->getMockBuilder(Access::class)->getMock();
+        $author->method('getId')->willReturn(123);
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getSlug')->willReturn('key');
+
+        $this->filesystem->expects(self::once())->method('delete')->with('key')->willReturn(true);
+
+        $file->expects(self::once())->method('setStatus')->with(FileStatusEnum::DELETED()->getValue())->willReturnSelf();
+        $file->expects(self::once())->method('setSize')->with(0)->willReturnSelf();
+        $file->expects(self::once())->method('setUpdatedBy')->with(123)->willReturnSelf();
+
+        $returned = $fileStorage->delete($file, $author);
+
+        $this->assertEquals($file, $returned);
+    }
+
+    public function testDeleteFailed(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['delete'])
+            ->getMock();
+
+        $author = $this->getMockBuilder(Access::class)->getMock();
+        $author->method('getId')->willReturn(123);
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getSlug')->willReturn('key');
+
+        $this->filesystem->expects(self::once())->method('delete')->with('key')->willReturn(false);
+
+        $file->expects(self::never())->method('setStatus');
+        $file->expects(self::never())->method('setSize');
+        $file->expects(self::never())->method('setUpdatedBy');
+
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage('File `' . $file->getSlug() . '` could\'nt be deleted');
+
+        $fileStorage->delete($file, $author);
+    }
+
+    public function testGetPrefixAccess(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['getPrefix'])
+            ->getMock();
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(2);
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $access->method('getOrganization')->willReturn($organization);
+        $access->method('getId')->willReturn(1);
+
+        $prefix = $fileStorage->getPrefix($access, false);
+
+        $this->assertEquals('organization/2/1', $prefix);
+    }
+
+    public function testGetPrefixOrganization(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['getPrefix'])
+            ->getMock();
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(1);
+
+        $prefix = $fileStorage->getPrefix($organization, false);
+
+        $this->assertEquals('organization/1', $prefix);
+    }
+
+    public function testGetPrefixPerson(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['getPrefix'])
+            ->getMock();
+
+        $person = $this->getMockBuilder(Person::class)->getMock();
+        $person->method('getId')->willReturn(1);
+
+        $prefix = $fileStorage->getPrefix($person, false);
+
+        $this->assertEquals('person/1', $prefix);
+    }
+
+    public function testGetPrefixTemp(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['getPrefix'])
+            ->getMock();
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(1);
+
+        $prefix = $fileStorage->getPrefix($organization, true);
+
+        $this->assertEquals('temp/organization/1', $prefix);
+    }
+
+    public function testGetPrefixWithType(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['getPrefix'])
+            ->getMock();
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(1);
+
+        $prefix = $fileStorage->getPrefix($organization, false, FileTypeEnum::LICENCE_CMF()->getValue());
+
+        $this->assertEquals('organization/1/licence_cmf', $prefix);
+    }
+
+    public function testGuessMimeTypeFromFilename(): void {
+        $this->assertEquals('application/pdf', TestableLocalStorage::guessMimeTypeFromFilename('file.pdf'));
+        $this->assertEquals('text/csv', TestableLocalStorage::guessMimeTypeFromFilename('file.csv'));
+        $this->assertEquals('text/plain', TestableLocalStorage::guessMimeTypeFromFilename('file.txt'));
+        $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', TestableLocalStorage::guessMimeTypeFromFilename('file.xlsx'));
+        $this->assertEquals('application/xml', TestableLocalStorage::guessMimeTypeFromFilename('file.xml'));
+
+        $this->assertEquals(null, TestableLocalStorage::guessMimeTypeFromFilename('file'));
+        $this->assertEquals(null, TestableLocalStorage::guessMimeTypeFromFilename('file.invalid'));
+    }
+
+    public function testGuessMimeTypeFromExt(): void {
+        $this->assertEquals('application/pdf', TestableLocalStorage::getMimeTypeFromExt('pdf'));
+        $this->assertEquals('text/csv', TestableLocalStorage::getMimeTypeFromExt('csv'));
+        $this->assertEquals('text/plain', TestableLocalStorage::getMimeTypeFromExt('txt'));
+        $this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', TestableLocalStorage::getMimeTypeFromExt('xlsx'));
+        $this->assertEquals('application/xml', TestableLocalStorage::getMimeTypeFromExt('xml'));
+
+        $this->assertEquals('text/plain', TestableLocalStorage::getMimeTypeFromExt('.txt'));
+        $this->assertEquals(null, TestableLocalStorage::getMimeTypeFromExt(''));
+        $this->assertEquals(null, TestableLocalStorage::getMimeTypeFromExt('invalid'));
+    }
+
+    public function testGetOrganizationAndPersonFromOwner(): void {
+        $fileStorage = $this->getMockBuilder(TestableLocalStorage::class)
+            ->setConstructorArgs([$this->filesystemMap, $this->entityManager, $this->accessRepository, $this->iriConverter])
+            ->setMethodsExcept(['getOrganizationAndPersonFromOwner'])
+            ->getMock();
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(2);
+
+        $person = $this->getMockBuilder(Person::class)->getMock();
+        $person->method('getId')->willReturn(1);
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $access->method('getOrganization')->willReturn($organization);
+        $access->method('getPerson')->willReturn($person);
+        $access->method('getId')->willReturn(1);
+
+        $this->assertEquals([$organization, $person], $fileStorage->getOrganizationAndPersonFromOwner($access));
+        $this->assertEquals([$organization, null], $fileStorage->getOrganizationAndPersonFromOwner($organization));
+        $this->assertEquals([null, $person], $fileStorage->getOrganizationAndPersonFromOwner($person));
+    }
+}

+ 0 - 39
tests/Service/Storage/TemporaryFileStorageTest.php

@@ -1,39 +0,0 @@
-<?php
-
-use App\Service\Storage\TemporaryFileStorage;
-use Gaufrette\Adapter\Local;
-use Gaufrette\Filesystem;
-use Knp\Bundle\GaufretteBundle\FilesystemMap;
-use PHPUnit\Framework\TestCase;
-
-class TemporaryFileStorageTest extends TestCase
-{
-    public function testGetStorageBaseDir(): void
-    {
-        $fileSystemMap = $this->getMockBuilder(FilesystemMap::class)
-            ->disableOriginalConstructor()
-            ->getMock();
-        $fileSystem = $this->getMockBuilder(Filesystem::class)
-            ->disableOriginalConstructor()
-            ->getMock();
-        $adapter = $this->getMockBuilder(Local::class)
-            ->disableOriginalConstructor()
-            ->getMock();
-
-        $fileSystemMap->method('get')->willReturn($fileSystem);
-        $fileSystem->method('getAdapter')->willReturn($adapter);
-        $adapter->expects($this->once())->method('write')->willReturnSelf();
-
-        $storage = $this->getMockBuilder(TemporaryFileStorage::class)
-            ->setConstructorArgs([$fileSystemMap])
-            ->setMethodsExcept(['write'])
-            ->getMock();
-
-        $path = $storage->write('my_file.txt', 'some content');
-
-        $this->assertMatchesRegularExpression(
-            '/temp\/\d{8}_\d{6}_[\w\d]{8}\/my_file.txt/',
-            $path
-        );
-    }
-}

+ 15 - 0
tests/Service/Utils/UuidTest.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Tests\Service\Utils;
+
+use App\Service\Utils\Uuid;
+use PHPUnit\Framework\TestCase;
+
+class UuidTest extends TestCase
+{
+    public function testUuid(): void
+    {
+        $this->assertMatchesRegularExpression('/\w{8}/', Uuid::uuid());
+        $this->assertMatchesRegularExpression('/\w{5}/', Uuid::uuid(5));
+    }
+}