Quellcode durchsuchen

Merge branch 'develop' of
git@gitlab.2iopenservice.com:opentalent/ap2i.git into develop

Conflicts:
.env
.env.preprod
config/packages/dev/messenger.yaml
src/DataPersister/Export/LicenceCmf/ExportRequestDataPersister.php

Olivier Massot vor 3 Jahren
Ursprung
Commit
e17d5e8108
77 geänderte Dateien mit 2743 neuen und 539 gelöschten Zeilen
  1. 4 0
      .env
  2. 8 0
      .env.ci
  3. 8 0
      .env.preprod
  4. 8 0
      .env.prod
  5. 8 0
      .env.test
  6. 1 1
      composer.json
  7. 233 173
      composer.lock
  8. 10 0
      config/packages/dev/mailer.yaml
  9. 8 5
      config/packages/dev/messenger.yaml
  10. 5 0
      config/packages/framework.yaml
  11. 5 1
      config/packages/messenger.yaml
  12. 7 12
      config/services.yaml
  13. 25 0
      doc/bindfile.md
  14. 0 15
      src/ApiResources/Profile/OrganizationProfile.php
  15. 149 0
      src/Commands/PostUpgrade/V0_2/PostUpgradeCommand.php
  16. 7 7
      src/DataPersister/Access/AdminAccessDataPersister.php
  17. 69 0
      src/DataPersister/EntityDataPersister.php
  18. 9 32
      src/DataPersister/Organization/OrganizationDataPersister.php
  19. 9 51
      src/DataPersister/Organization/ParametersDataPersister.php
  20. 27 0
      src/DataPersister/Organization/SubdomainDataPersister.php
  21. 25 0
      src/Entity/Organization/Organization.php
  22. 17 22
      src/Entity/Organization/Parameters.php
  23. 118 0
      src/Entity/Organization/Subdomain.php
  24. 7 0
      src/Entity/Person/Person.php
  25. 3 4
      src/Entity/Traits/ActivityYearTrait.php
  26. 2 1
      src/Enum/Organization/LegalEnum.php
  27. 2 1
      src/Message/Command/Parameters/AverageChange.php
  28. 31 0
      src/Message/Command/Typo3/Typo3DeleteCommand.php
  29. 31 0
      src/Message/Command/Typo3/Typo3UndeleteCommand.php
  30. 31 0
      src/Message/Command/Typo3/Typo3UpdateCommand.php
  31. 21 0
      src/Message/Handler/Typo3/Typo3DeleteCommandHandler.php
  32. 21 0
      src/Message/Handler/Typo3/Typo3UndeleteCommandHandler.php
  33. 21 0
      src/Message/Handler/Typo3/Typo3UpdateCommandHandler.php
  34. 22 0
      src/Repository/Organization/SubdomainRepository.php
  35. 2 3
      src/Security/Voter/CotisationVoter.php
  36. 2 2
      src/Security/Voter/ModuleVoter.php
  37. 5 3
      src/Service/Access/AdminAccessUtils.php
  38. 1 3
      src/Service/Access/OptionalsRoles/CriteriaNotationOptionalRole.php
  39. 7 3
      src/Service/Access/Utils.php
  40. 0 89
      src/Service/ApiRequestService.php
  41. 7 6
      src/Service/Constraint/ActivityYearConstraint.php
  42. 12 11
      src/Service/Constraint/DateTimeConstraint.php
  43. 16 8
      src/Service/Core/ContactPointUtils.php
  44. 2 0
      src/Service/Cotisation/Utils.php
  45. 68 0
      src/Service/MailHub.php
  46. 2 1
      src/Service/Network/Utils.php
  47. 37 0
      src/Service/OnChange/OnChangeContext.php
  48. 16 0
      src/Service/OnChange/OnChangeDefault.php
  49. 39 0
      src/Service/OnChange/OnChangeInterface.php
  50. 11 2
      src/Service/OnChange/Organization/OnOrganizationChange.php
  51. 102 12
      src/Service/OnChange/Organization/OnParametersChange.php
  52. 92 0
      src/Service/OnChange/Organization/OnSubdomainChange.php
  53. 6 5
      src/Service/Organization/OrganizationProfileCreator.php
  54. 53 4
      src/Service/Organization/Utils.php
  55. 1 1
      src/Service/Rest/ApiRequestService.php
  56. 3 3
      src/Service/Security/Module.php
  57. 30 0
      src/Service/Typo3/BindFileService.php
  58. 122 0
      src/Service/Typo3/Typo3Service.php
  59. 3 3
      src/Service/Utils/UrlBuilder.php
  60. 17 0
      templates/emails/subdomain.html.twig
  61. 6 0
      templates/layout/noreply/footer.html.twig
  62. 4 4
      tests/Service/Access/AdminAccessUtilsTest.php
  63. 6 6
      tests/Service/Access/UtilsTest.php
  64. 3 2
      tests/Service/Constraint/DateTimeConstraintTest.php
  65. 37 0
      tests/Service/Cotisation/CotisationCreatorTest.php
  66. 103 0
      tests/Service/MailHubTest.php
  67. 33 0
      tests/Service/OnChange/OnChangeContextTest.php
  68. 24 0
      tests/Service/OnChange/OnChangeDefaultTest.php
  69. 50 1
      tests/Service/OnChange/Organization/OnOrganizationChangeTest.php
  70. 282 9
      tests/Service/OnChange/Organization/OnParametersChangeTest.php
  71. 227 0
      tests/Service/OnChange/Organization/OnSubdomainChangeTest.php
  72. 7 2
      tests/Service/Organization/OrganizationProfileCreatorTest.php
  73. 8 6
      tests/Service/Organization/UtilsTest.php
  74. 145 20
      tests/Service/Rest/ApiRequestServiceTest.php
  75. 26 2
      tests/Service/Security/ModuleTest.php
  76. 171 0
      tests/Service/Typo3/Typo3ServiceTest.php
  77. 3 3
      tests/Service/Utils/UrlBuilderTest.php

+ 4 - 0
.env

@@ -81,6 +81,10 @@ LOCK_DSN=semaphore
 MAILER_DSN=smtp://localhost
 ###< symfony/mailer ###
 
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=/env/subdomain.txt
+###< bindfile populate buffer file
+
 ###> elasticsearch ###
 ELASTICSEARCH_HOST=localhost
 ELASTICSEARCH_PORT=9200

+ 8 - 0
.env.ci

@@ -27,6 +27,14 @@ BLACKFIRE_SERVER_TOKEN=dbd1cfbea015fe83cccfc189a36ca3c16f3a1b43b94f50032a15e41e5
 DATABASE_ADMINASSOS_URL=mysql://root:xxx@preprod:3306/none?serverVersion=5.7
 ###< AdminAssos configuration ###
 
+###> typo3 client ###
+TYPO3_BASE_URI=http://docker.sub.opentalent.fr
+###< typo3 client ###
+
 ###> dolibarr client ###
 DOLIBARR_API_BASE_URI='https://dev-erp.2iopenservice.com/api/index.php/'
 ###< dolibarr client ###
+
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=var/subdomain.txt
+###< bindfile populate buffer file

+ 8 - 0
.env.preprod

@@ -16,6 +16,10 @@ DATABASE_URL=mysql://root:mysql2iopenservice369566@preprod:3306/opentalent?serve
 CORS_ALLOW_ORIGIN=^https?://(localhost|127\.0\.0\.1)(:[0-9]+)$
 ###< nelmio/cors-bundle ###
 
+###> typo3 client ###
+TYPO3_BASE_URI=http://preprod.opentalent.fr/ohcluses
+###< typo3 client ###
+
 ###> BlackFire configuration ###
 BLACKFIRE_CLIENT_ID=988fcba8-552d-48df-a9c2-035c76535b69
 BLACKFIRE_CLIENT_TOKEN=8cfbeb263d044da9678dc2612531504da3790c308da7448e35724a5da91c136f
@@ -40,3 +44,7 @@ MERCURE_PUBLIC_URL=https://preprod.mercure.opentalent.fr/.well-known/mercure
 # The secret used to sign the JWTs
 MERCURE_JWT_SECRET=NQEupdREijrfYvCmF2mnvZQFL9zLKDH9RCYter6tUWzjemPqzicffhc2fSf0yEmM
 ###< symfony/mercure-bundle ###
+
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=var/subdomain.txt
+###< bindfile populate buffer file

+ 8 - 0
.env.prod

@@ -23,6 +23,14 @@ BLACKFIRE_SERVER_ID=1171e53b-459b-41da-a292-80ff68cee8c2
 BLACKFIRE_SERVER_TOKEN=dbd1cfbea015fe83cccfc189a36ca3c16f3a1b43b94f50032a15e41e53548e8b
 ###< BlackFire configuration ###
 
+###> typo3 client ###
+TYPO3_BASE_URI=http://ohcluses.opentalent.fr
+###< typo3 client ###
+
 ###> AdminAssos configuration ###
 DATABASE_ADMINASSOS_URL=mysql://root:mysql2iopenservice369566@prod-back:3306/adminassos?serverVersion=5.7
 ###< AdminAssos configuration ###
+
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=/env/subdomain.txt
+###< bindfile populate buffer file

+ 8 - 0
.env.test

@@ -4,6 +4,14 @@ APP_SECRET='$ecretf0rt3st'
 SYMFONY_DEPRECATIONS_HELPER=999999
 PANTHER_APP_ENV=panther
 
+###> typo3 client ###
+TYPO3_BASE_URI=http://test.opentalent.fr/ohcluses
+###< typo3 client ###
+
 ###> dolibarr client ###
 DOLIBARR_API_BASE_URI='https://dev-erp.2iopenservice.com/api/index.php/'
 ###< dolibarr client ###
+
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=var/subdomain.txt
+###< bindfile populate buffer file

+ 1 - 1
composer.json

@@ -57,7 +57,7 @@
     },
     "require-dev": {
         "cyclonedx/cyclonedx-php-composer": "^3.4",
-        "rector/rector": "^0.12.*",
+        "rector/rector": "^0.12",
         "symfony/debug-bundle": "5.4.*",
         "symfony/maker-bundle": "^1.21",
         "symfony/phpunit-bridge": "^5.4",

Datei-Diff unterdrückt, da er zu groß ist
+ 233 - 173
composer.lock


+ 10 - 0
config/packages/dev/mailer.yaml

@@ -0,0 +1,10 @@
+framework:
+  mailer:
+    # @see https://symfony.com/doc/5.4/mailer.html#development-debugging
+
+    # Disable the mailing in dev mode
+    dsn: 'null://null'
+
+    # Or send all mails to the same address:
+    #envelope:
+    #  recipients: ['exploitation@opentalent.fr']

+ 8 - 5
config/packages/dev/messenger.yaml

@@ -1,6 +1,9 @@
+# Désactive le fonctionnement asynchrone de messenger en mode dev
+# > commenter pour tester avec un fonctionnement asynchrone
+#   (dans ce cas, la commande `messenger:consume async` doit être en cours d'exécution)
 framework:
-  messenger:
-    transports:
-      # https://symfony.com/doc/current/messenger.html#transport-configuration
-      async: 'sync://'
-      failed: 'sync://'
+    messenger:
+        transports:
+            # https://symfony.com/doc/current/messenger.html#transport-configuration
+            async: 'sync://'
+            failed: 'sync://'

+ 5 - 0
config/packages/framework.yaml

@@ -27,6 +27,11 @@ framework:
                 headers:
                     DOLAPIKEY: '%env(DOLIBARR_API_TOKEN)%'
                     Accept: 'application/json'
+            typo3_client:
+                base_uri: '%env(TYPO3_BASE_URI)%'
+                headers:
+                    Content-Type: 'application/json'
+                    Accept: 'application/json'
             mobyt_client:
                 base_uri: '%env(MOBYT_API_BASE_URI)%'
                 headers:

+ 5 - 1
config/packages/messenger.yaml

@@ -11,5 +11,9 @@ framework:
 
         routing:
             # Route your messages to the transports
+            'App\Message\Command\Parameters\AverageChange': async
             'App\Message\Command\Export': async
-            'App\Message\Command\Parameters\AverageChange': async
+            'App\Message\Command\Typo3\Typo3UpdateCommand': async
+            'App\Message\Command\Typo3\Typo3DeleteCommand': async
+            'App\Message\Command\Typo3\Typo3UndeleteCommand': async
+            'Symfony\Component\Mailer\Messenger\SendEmailMessage': async

+ 7 - 12
config/services.yaml

@@ -10,6 +10,8 @@ services:
         bind:
             $opentalentConfig: '%kernel.project_dir%%env(OPENTALENT_CONFIG)%'
             $internalFilesUploadUri: '%env(INTERNAL_FILES_DOWNLOAD_URI)%'
+            $bindfileBufferFile: '%env(BIND_FILE_BUFFER_FILE)%'
+            $contextAwareDataPersister: '@api_platform.doctrine.orm.data_persister'
 
     # Logging: a shorter version of the default monolog line formatter
     monolog.formatter.message:
@@ -44,6 +46,10 @@ services:
 
     Gaufrette\Filesystem: '@knp_gaufrette.filesystem_map'
 
+    App\Service\MailHub:
+        bind:
+            $opentalentNoReplyEmailAddress: 'noreply@opentalent.fr'
+
     #########################################
     ##  TAG Services ##
     _instanceof:
@@ -65,7 +71,6 @@ services:
     App\Service\ServiceIterator\EncoderIterator:
         - !tagged_iterator app.encoder
 
-
     App\Service\Dolibarr\DolibarrSyncService:
         tags:
             - { name: monolog.logger, channel: dolibarrsync }
@@ -91,18 +96,8 @@ services:
         tags:
             - { name: kernel.event_listener, event: kernel.request }
 
-    #########################################
-    ##  DATAPERSISTER ##
-    App\DataPersister\Organization\ParametersDataPersister:
-        bind:
-            $decorated: '@api_platform.doctrine.orm.data_persister'
-
-    App\DataPersister\Organization\OrganizationDataPersister:
-        bind:
-            $decorated: '@api_platform.doctrine.orm.data_persister'
-
     #########################################
     ##  ELASTIC SERVICE ##
     App\Service\Elasticsearch\EducationNotationUpdate:
         arguments:
-            - '@fos_elastica.object_persister.search.educationNotation'
+            - '@fos_elastica.object_persister.search.educationNotation'

+ 25 - 0
doc/bindfile.md

@@ -0,0 +1,25 @@
+# Fichier Bind
+
+Le fichier "bind" rend possible la résolution des sous-domaines au niveau du serveur DNS (situé sur prod-back).
+Chaque fois qu'un nouveau sous-domaine est enregistré, ce fichier doit être mis à jour. Cependant, pour des questions de 
+droits, c'est l'utilisateur root qui doit procéder à cette mise à jour. 
+
+Voilà ce qu'il se passe lorsqu'une structure enregistre un nouveau sous-domaine depuis `prod-back`:
+
+1. Le logiciel ajoute le sous-domaine en question dans une nouvelle ligne du fichier tampon `/env/subdomain.txt`
+2. Un cron tourne toutes les 5 minutes et consomme le contenu de ce fichier avant de le vider
+3. Ce cron exécute le script `/env/add-subdomain`, qui met à jour le fichier bind `/etc/bind/zones/opentalent.fr.db`
+4. Une fois mis à jour le fichier bind, le cron exécuté sur prod-back (le serveur DNS) envoie par ssh le contenu du 
+   fichier subdomain.txt sur un même fichier `/env/subdomain.txt` situé sur le serveur `vpn` 
+5. Le même cron tourne toutes les 5 minutes sur le serveur vpn procède à son tour à la mise à jour de son fichier bind
+
+La mise à jour des deux fichiers bind est nécessaire, pour permettre la prise en compte des sous-domaines depuis
+l'intérieur ou l'extérieur du VPN.
+
+Lorsqu'un nouveau sous-domaine est enregistré depuis `prod-v2`:
+
+1. Le logiciel remplit de la même façon un fichier `/env/subdomain.txt`
+2. Un cron lit ce fichier et ajoute les sous-domaines listés au subdomain.txt sur `prod-back`
+3. La suite se déroule comme dans le premier scénario
+
+

+ 0 - 15
src/ApiResources/Profile/OrganizationProfile.php

@@ -33,9 +33,6 @@ class OrganizationProfile implements ApiResourcesInterface
     #[Groups('access_profile_read')]
     private ?string $legalStatus = null;
 
-    #[Groups('access_profile_read')]
-    private ?string $subDomain = null;
-
     #[Groups('access_profile_read')]
     private array $networks = [];
 
@@ -120,18 +117,6 @@ class OrganizationProfile implements ApiResourcesInterface
         return $this;
     }
 
-    public function getSubDomain(): ?string
-    {
-        return $this->subDomain;
-    }
-
-    public function setSubDomain(?string $subDomain): self
-    {
-        $this->subDomain = $subDomain;
-
-        return $this;
-    }
-
     public function getWebsite(): ?string
     {
         return $this->website;

+ 149 - 0
src/Commands/PostUpgrade/V0_2/PostUpgradeCommand.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: 'opentalent: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();
+
+        $opentassosCnn = 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 = $opentassosCnn->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;
+        }
+    }
+}

+ 7 - 7
src/DataPersister/Access/AdminAccessDataPersister.php

@@ -5,7 +5,7 @@ namespace App\DataPersister\Access;
 
 use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
 use App\Entity\Access\Access;
-use App\Service\Access\Utils;
+use App\Service\Access\Utils as AccessUtils;
 use App\Service\Core\ContactPointUtils;
 use Exception;
 use App\ApiResources\Access\AdminAccess;
@@ -21,7 +21,7 @@ class AdminAccessDataPersister implements ContextAwareDataPersisterInterface
     public function __construct(
         private Security $security,
         private EntityManagerInterface $entityManager,
-        private Utils $accessUtils,
+        private AccessUtils $accessUtils,
         private ContactPointUtils $contactPointUtils
     )
     { }
@@ -41,14 +41,14 @@ class AdminAccessDataPersister implements ContextAwareDataPersisterInterface
         /** @var Access $access */
         $access = $this->security->getUser();
 
-        $administrator = $this->accessUtils->getAdminAccess($access->getOrganization());
+        $administrator = $this->accessUtils->findAdminFor($access->getOrganization());
         if(!$administrator){
-            throw new Exception('administrator_not_found', 404);
+            throw new \RuntimeException('administrator_not_found', 404);
         }
 
         $contactPoint = $this->contactPointUtils->getPersonContactPointPrincipal($administrator);
         if(!$contactPoint){
-            throw new Exception('administrator_contact_point_not_found', 404);
+            throw new \RuntimeException('administrator_contact_point_not_found', 404);
         }
 
         $contactPoint->setEmail($data->getEmail());
@@ -57,6 +57,6 @@ class AdminAccessDataPersister implements ContextAwareDataPersisterInterface
 
     public function remove($data, array $context = [])
     {
-        throw new Exception('not supported', 500);
+        throw new \RuntimeException('not supported', 500);
     }
-}
+}

+ 69 - 0
src/DataPersister/EntityDataPersister.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace App\DataPersister;
+
+use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
+use App\Service\OnChange\OnChangeContext;
+use App\Service\OnChange\OnChangeDefault;
+use App\Service\OnChange\OnChangeInterface;
+use Symfony\Contracts\Service\Attribute\Required;
+
+/**
+ * Classe de base pour les DataPersister classiques, proposant des hook pre et post persist,
+ * ainsi que certaines méthodes liées au contexte de la mise à jour.
+ */
+class EntityDataPersister implements ContextAwareDataPersisterInterface
+{
+    // <-- dependencies injections
+    protected ContextAwareDataPersisterInterface $contextAwareDataPersister;
+    protected OnChangeInterface $onChange;
+
+    #[Required]
+    public function setContextAwareDataPersister(ContextAwareDataPersisterInterface $contextAwareDataPersister): void {$this->contextAwareDataPersister = $contextAwareDataPersister;}
+
+    public function __construct(
+        OnChangeDefault $onChange
+    ) {
+        $this->onChange = $onChange;
+    }
+    // dependencies injections -->
+
+    public function supports($data, array $context = []): bool
+    {
+        return false;
+    }
+
+    /**
+     * Persist l'entité et déclenche les différents hooks de la classe OnChangeInterface définie par le data persister
+     *
+     * @param mixed $data
+     * @param array $context
+     * @return object|void
+     */
+    public function persist($data, array $context = [])
+    {
+        $onChangeContext = new OnChangeContext($context);
+
+        $this->onChange->validate($data, $onChangeContext);
+
+        $data = $this->onChange->preProcess($data, $onChangeContext);
+
+        $this->onChange->beforeChange($data, $onChangeContext);
+
+        $result = $this->contextAwareDataPersister->persist($data, $context);
+
+        $this->onChange->onChange($data, $onChangeContext);
+
+        return $result;
+    }
+
+    /**
+     * La fonction native 'remove' n'est pas supportée par défaut, la réimplémenter au besoin
+     *
+     * @param mixed $data
+     * @param array $context
+     */
+    public function remove($data, array $context = []): void {
+        throw new \RuntimeException('not supported', 500);
+    }
+}

+ 9 - 32
src/DataPersister/Organization/OrganizationDataPersister.php

@@ -3,49 +3,26 @@ declare(strict_types=1);
 
 namespace App\DataPersister\Organization;
 
-use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
+use App\DataPersister\EntityDataPersister;
 use App\Entity\Organization\Organization;
 use App\Service\OnChange\Organization\OnOrganizationChange;
-use Exception;
+use JetBrains\PhpStorm\Pure;
 
 /**
  * Classe OrganizationDataPersister qui est un custom dataPersister gérant la resource Organization
  */
-class OrganizationDataPersister implements ContextAwareDataPersisterInterface
+class OrganizationDataPersister extends EntityDataPersister
 {
+    #[Pure]
     public function __construct(
-        private ContextAwareDataPersisterInterface $decorated,
-        private OnOrganizationChange $onOrganizationChange
+        OnOrganizationChange $onChange
     )
-    { }
-
-    public function supports($data, array $context = []): bool
-    {
-        return $data instanceof Organization;
-    }
-
-    /**
-     * @param Organization $data
-     * @param array $context
-     * @return object|void
-     */
-    public function persist($data, array $context = [])
     {
-        $this->prePersist($context['previous_data'], $data);
-
-        $result = $this->decorated->persist($data, $context);
-
-        return $result;
+        parent::__construct($onChange);
     }
 
-    public function remove($data, array $context = [])
+    public function supports($data, array $context = []): bool
     {
-        throw new Exception('not supported', 500);
-    }
-
-    public function prePersist(Organization $previousOrganization, Organization $organization): void{
-        if($previousOrganization->getLegalStatus() != $organization->getLegalStatus()){
-            $this->onOrganizationChange->onLegalStatusChange($organization);
-        }
+        return $data instanceof Organization;
     }
-}
+}

+ 9 - 51
src/DataPersister/Organization/ParametersDataPersister.php

@@ -3,68 +3,26 @@ declare(strict_types=1);
 
 namespace App\DataPersister\Organization;
 
-use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
+use App\DataPersister\EntityDataPersister;
 use App\Entity\Organization\Parameters;
-use App\Message\Command\Parameters\AverageChange;
 use App\Service\OnChange\Organization\OnParametersChange;
-use Exception;
-use Symfony\Component\Messenger\MessageBusInterface;
+use JetBrains\PhpStorm\Pure;
 
 /**
  * Classe ParametersDataPersister qui est un custom dataPersister gérant la resource Parameters
  */
-class ParametersDataPersister implements ContextAwareDataPersisterInterface
+class ParametersDataPersister extends EntityDataPersister
 {
+    #[Pure]
     public function __construct(
-        private ContextAwareDataPersisterInterface $decorated,
-        private MessageBusInterface $messageBus,
-        private OnParametersChange $onParametersChange
+        OnParametersChange $onChange
     )
-    { }
-
-    public function supports($data, array $context = []): bool
-    {
-        return $data instanceof Parameters;
-    }
-
-    /**
-     * @param Parameters $data
-     * @param array $context
-     * @return object|void
-     */
-    public function persist($data, array $context = [])
     {
-        $this->prePersist($context['previous_data'], $data);
-
-        $result = $this->decorated->persist($data, $context);
-
-        $this->postPersist($context['previous_data'], $data);
-
-        return $result;
+        parent::__construct($onChange);
     }
 
-    public function remove($data, array $context = [])
+    public function supports($data, array $context = []): bool
     {
-        throw new Exception('not supported', 500);
-    }
-
-    public function prePersist(Parameters $previousParameters, Parameters $parameters): void{
-        if($previousParameters->getAdvancedEducationNotationType() != $parameters->getAdvancedEducationNotationType()){
-            $this->onParametersChange->onAdvancedEducationNotationTypeChange($parameters);
-        }
-
-        //La date de début d'activité change
-        if($previousParameters->getMusicalDate() != $parameters->getMusicalDate()){
-            $this->onParametersChange->onMusicalDateChange($parameters->getOrganization(), $previousParameters->getMusicalDate());
-        }
-    }
-
-    public function postPersist(Parameters $previousParameters, Parameters $parameters): void{
-        //La note maximale du suivi pédagogique change
-        if($previousParameters->getAverage() != $parameters->getAverage()){
-            $this->messageBus->dispatch(
-                new AverageChange($parameters->getId())
-            );
-        }
+        return $data instanceof Parameters;
     }
-}
+}

+ 27 - 0
src/DataPersister/Organization/SubdomainDataPersister.php

@@ -0,0 +1,27 @@
+<?php
+declare(strict_types=1);
+
+namespace App\DataPersister\Organization;
+
+use App\DataPersister\EntityDataPersister;
+use App\Entity\Organization\Subdomain;
+use App\Service\OnChange\Organization\OnSubdomainChange;
+use JetBrains\PhpStorm\Pure;
+
+/**
+ * Ccustom dataPersister gérant la resource Subdomain
+ */
+class SubdomainDataPersister extends EntityDataPersister
+{
+    #[Pure]
+    public function __construct(
+        OnSubdomainChange $onChange
+    ) {
+        parent::__construct($onChange);
+    }
+
+    public function supports($data, array $context = []): bool
+    {
+        return $data instanceof Subdomain;
+    }
+}

+ 25 - 0
src/Entity/Organization/Organization.php

@@ -246,6 +246,10 @@ class Organization
     #[ORM\OneToMany(mappedBy: 'organization', targetEntity: EducationTiming::class, orphanRemoval: true)]
     private Collection $educationTimings;
 
+    #[ORM\OneToMany( mappedBy: 'organization', targetEntity: Subdomain::class)]
+    #[ApiSubresource]
+    private Collection $subdomains;
+
     #[ORM\ManyToOne(inversedBy: 'organizationContacts')]
     #[ORM\JoinColumn(referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
     private ?Access $contactPerson;
@@ -333,6 +337,7 @@ class Organization
         $this->cycles = new ArrayCollection();
         $this->educationTimings = new ArrayCollection();
         $this->educationNotationConfigs = new ArrayCollection();
+        $this->subdomains = new ArrayCollection();
         $this->holidays = new ArrayCollection();
         $this->courses = new ArrayCollection();
         $this->educationalProjects = new ArrayCollection();
@@ -1179,6 +1184,26 @@ class Organization
         return $this->educationTimings;
     }
 
+    public function getSubdomains(): Collection
+    {
+        return $this->subdomains;
+    }
+
+    public function addSubdomain($subdomain): self
+    {
+        $subdomain->setOrganization($this);
+        $this->subdomains[] = $subdomain;
+        return $this;
+    }
+
+    public function removeSubdomain($subdomain): self
+    {
+        $subdomain->setOrganization(null);
+        $this->subdomains->removeElement($subdomain);
+
+        return $this;
+    }
+
     public function addAccess(Access $access): self
     {
         if (!$this->accesses->contains($access)) {

+ 17 - 22
src/Entity/Organization/Parameters.php

@@ -80,6 +80,9 @@ class Parameters
     #[ORM\Column(length: 150, nullable: true)]
     private ?string $otherWebsite = null;
 
+    #[ORM\Column(length: 150, nullable: true)]
+    private ?string $customDomain = null;
+
     #[ORM\Column(options: ['default' => false])]
     private bool $desactivateOpentalentSiteWeb = false;
 
@@ -290,40 +293,32 @@ class Parameters
         return $this;
     }
 
-    public function getSubDomain(): ?string
-    {
-        return $this->subDomain;
-    }
-
-    public function setSubDomain(?string $subDomain): self
-    {
-        $this->subDomain = $subDomain;
-
-        return $this;
-    }
-
-    public function getWebsite(): ?string
+    public function getOtherWebsite(): ?string
     {
-        return $this->website;
+        return $this->otherWebsite;
     }
 
-    public function setWebsite(?string $website): self
+    public function setOtherWebsite(?string $otherWebsite): self
     {
-        $this->website = $website;
+        $this->otherWebsite = $otherWebsite;
 
         return $this;
     }
 
-    public function getOtherWebsite(): ?string
+    /**
+     * @return string|null
+     */
+    public function getCustomDomain(): ?string
     {
-        return $this->otherWebsite;
+        return $this->customDomain;
     }
 
-    public function setOtherWebsite(?string $otherWebsite): self
+    /**
+     * @param string|null $customDomain
+     */
+    public function setCustomDomain(?string $customDomain): void
     {
-        $this->otherWebsite = $otherWebsite;
-
-        return $this;
+        $this->customDomain = $customDomain;
     }
 
     public function getDesactivateOpentalentSiteWeb(): bool

+ 118 - 0
src/Entity/Organization/Subdomain.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Entity\Organization;
+
+use ApiPlatform\Core\Annotation\ApiFilter;
+use App\Annotation\OrganizationDefaultValue;
+use App\Repository\Organization\SubdomainRepository;
+use Doctrine\ORM\Mapping as ORM;
+use ApiPlatform\Core\Annotation\ApiResource;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Annotation\Groups;
+use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * Sous-domaine enregistré par une organisation
+ */
+#[ApiResource(
+    collectionOperations: [
+        'get',
+        'post'
+    ],
+    itemOperations: [
+        'get' => [
+            'security' => '(is_granted("ROLE_ORGANIZATION_VIEW") or is_granted("ROLE_ORGANIZATION")) and object.getOrganization().getId() == user.getOrganization().getId()'
+        ],
+        'put' => [
+            'security' => 'is_granted("ROLE_ORGANIZATION") and object.getOrganization().getId() == user.getOrganization().getId()'
+        ]
+    ]
+)]
+#[ORM\Entity(repositoryClass: SubdomainRepository::class)]
+#[OrganizationDefaultValue(fieldName: "organization")]
+#[ApiFilter(SearchFilter::class, properties: ['subdomain' => 'exact'])]
+#[UniqueEntity('subdomain')]
+class Subdomain
+{
+    #[ORM\Id]
+    #[ORM\Column]
+    #[ORM\GeneratedValue]
+    private ?int $id = null;
+
+    #[ORM\ManyToOne(inversedBy: 'subdomains')]
+    private Organization $organization;
+
+    #[ORM\Column(type: 'string', length: 60, unique: true, nullable: false)]
+    #[Groups("subdomain")]
+    #[Assert\Regex('/^[\w\-]+$/', 'Subdomains can not contains whitespaces or special characters')]
+    private string $subdomain;
+
+    #[ORM\Column(options: ['default' => false])]
+    #[Groups("subdomain")]
+    private bool $active = false;
+
+    /**
+     * @return int|null
+     */
+    public function getId(): ?int
+    {
+        return $this->id;
+    }
+
+    /**
+     * @param int|null $id
+     */
+    public function setId(?int $id): void
+    {
+        $this->id = $id;
+    }
+
+    /**
+     * @return Organization
+     */
+    public function getOrganization(): Organization
+    {
+        return $this->organization;
+    }
+
+    /**
+     * @param Organization $organization
+     */
+    public function setOrganization(Organization $organization): void
+    {
+        $this->organization = $organization;
+    }
+
+    /**
+     * @return string
+     */
+    public function getSubdomain(): string
+    {
+        return $this->subdomain;
+    }
+
+    /**
+     * @param string $subdomain
+     */
+    public function setSubdomain(string $subdomain): void
+    {
+        $this->subdomain = $subdomain;
+    }
+
+    /**
+     * @return bool
+     */
+    public function isActive(): bool
+    {
+        return $this->active;
+    }
+
+    /**
+     * @param bool $active
+     */
+    public function setActive(bool $active): void
+    {
+        $this->active = $active;
+    }
+}

+ 7 - 0
src/Entity/Person/Person.php

@@ -204,6 +204,13 @@ class Person implements UserInterface
         return $this;
     }
 
+    public function getFullName(): ?string {
+        if (!$this->getName() || !$this->getGivenName()) {
+            return null;
+        }
+        return "{$this->getName()} {$this->getGivenName()}";
+    }
+
     public function setGender(?string $gender): self
     {
         $this->gender = $gender;

+ 3 - 4
src/Entity/Traits/ActivityYearTrait.php

@@ -18,9 +18,8 @@ trait ActivityYearTrait
         return $this->startYear;
     }
 
-    public function setStartYear(?int $startYear = null):self {
-        if($startYear == null) $startYear = date('Y');
-        $this->startYear = $startYear;
+    public function setStartYear(?int $startYear = null): self {
+        $this->startYear = $startYear ?? (int)(date('Y'));
         return $this;
     }
 
@@ -32,4 +31,4 @@ trait ActivityYearTrait
         $this->endYear = $endYear;
         return $this;
     }
-}
+}

+ 2 - 1
src/Enum/Organization/LegalEnum.php

@@ -7,11 +7,12 @@ use MyCLabs\Enum\Enum;
 
 /**
  * Statut légal
+ * @method static COMMERCIAL_SOCIETY()
+ * @method static ASSOCIATION_LAW_1901()
  */
 class LegalEnum extends Enum
 {
     private const LOCAL_AUTHORITY = 'LOCAL_AUTHORITY';
     private const ASSOCIATION_LAW_1901 = 'ASSOCIATION_LAW_1901';
     private const COMMERCIAL_SOCIETY = 'COMMERCIAL_SOCIETY';
-
 }

+ 2 - 1
src/Message/Command/Parameters/AverageChange.php

@@ -12,6 +12,7 @@ class AverageChange
     { }
 
     public function getParametersId(){
+        // Todo: penser à ajouter cette commande à la section routing du fichier config/packages/messenger.yaml
         return $this->parametersId;
     }
-}
+}

+ 31 - 0
src/Message/Command/Typo3/Typo3DeleteCommand.php

@@ -0,0 +1,31 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Message\Command\Typo3;
+
+/**
+ * Envoi d'une requête Delete à l'api Typo3
+ */
+class Typo3DeleteCommand
+{
+    public function __construct(
+        private int $organizationId
+    )
+    {}
+
+    /**
+     * @return int
+     */
+    public function getOrganizationId(): int
+    {
+        return $this->organizationId;
+    }
+
+    /**
+     * @param int $organizationId
+     */
+    public function setOrganizationId(int $organizationId): void
+    {
+        $this->organizationId = $organizationId;
+    }
+}

+ 31 - 0
src/Message/Command/Typo3/Typo3UndeleteCommand.php

@@ -0,0 +1,31 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Message\Command\Typo3;
+
+/**
+ * Envoi d'une requête Undelete à l'api Typo3
+ */
+class Typo3UndeleteCommand
+{
+    public function __construct(
+        private int $organizationId
+    )
+    {}
+
+    /**
+     * @return int
+     */
+    public function getOrganizationId(): int
+    {
+        return $this->organizationId;
+    }
+
+    /**
+     * @param int $organizationId
+     */
+    public function setOrganizationId(int $organizationId): void
+    {
+        $this->organizationId = $organizationId;
+    }
+}

+ 31 - 0
src/Message/Command/Typo3/Typo3UpdateCommand.php

@@ -0,0 +1,31 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Message\Command\Typo3;
+
+/**
+ * Envoi d'une requête Update à l'api Typo3
+ */
+class Typo3UpdateCommand
+{
+    public function __construct(
+        private int $organizationId
+    )
+    {}
+
+    /**
+     * @return int
+     */
+    public function getOrganizationId(): int
+    {
+        return $this->organizationId;
+    }
+
+    /**
+     * @param int $organizationId
+     */
+    public function setOrganizationId(int $organizationId): void
+    {
+        $this->organizationId = $organizationId;
+    }
+}

+ 21 - 0
src/Message/Handler/Typo3/Typo3DeleteCommandHandler.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Message\Handler\Typo3;
+
+use App\Message\Command\Typo3\Typo3DeleteCommand;
+use App\Service\Typo3\Typo3Service;
+use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
+
+class Typo3DeleteCommandHandler implements MessageHandlerInterface
+{
+    public function __construct(
+        private Typo3Service $typo3Service
+    ) {}
+
+    /**
+     * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
+     */
+    public function __invoke(Typo3DeleteCommand $command) {
+        $this->typo3Service->deleteSite($command->getOrganizationId());
+    }
+}

+ 21 - 0
src/Message/Handler/Typo3/Typo3UndeleteCommandHandler.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Message\Handler\Typo3;
+
+use App\Message\Command\Typo3\Typo3UndeleteCommand;
+use App\Service\Typo3\Typo3Service;
+use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
+
+class Typo3UndeleteCommandHandler implements MessageHandlerInterface
+{
+    public function __construct(
+        private Typo3Service $typo3Service
+    ) {}
+
+    /**
+     * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
+     */
+    public function __invoke(Typo3UndeleteCommand $command) {
+        $this->typo3Service->undeleteSite($command->getOrganizationId());
+    }
+}

+ 21 - 0
src/Message/Handler/Typo3/Typo3UpdateCommandHandler.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Message\Handler\Typo3;
+
+use App\Message\Command\Typo3\Typo3UpdateCommand;
+use App\Service\Typo3\Typo3Service;
+use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
+
+class Typo3UpdateCommandHandler implements MessageHandlerInterface
+{
+    public function __construct(
+        private Typo3Service $typo3Service
+    ) {}
+
+    /**
+     * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
+     */
+    public function __invoke(Typo3UpdateCommand $command) {
+        $this->typo3Service->updateSite($command->getOrganizationId());
+    }
+}

+ 22 - 0
src/Repository/Organization/SubdomainRepository.php

@@ -0,0 +1,22 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Repository\Organization;
+
+use App\Entity\Organization\Subdomain;
+use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\Persistence\ManagerRegistry;
+
+/**
+ * @method Subdomain|null find($id, $lockMode = null, $lockVersion = null)
+ * @method Subdomain|null findOneBy(array $criteria, array $orderBy = null)
+ * @method Subdomain[]    findAll()
+ * @method Subdomain[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+class SubdomainRepository extends ServiceEntityRepository
+{
+    public function __construct(ManagerRegistry $registry)
+    {
+        parent::__construct($registry, Subdomain::class);
+    }
+}

+ 2 - 3
src/Security/Voter/CotisationVoter.php

@@ -5,8 +5,7 @@ namespace App\Security\Voter;
 
 use App\ApiResources\Cotisation\Cotisation;
 use App\Entity\Access\Access;
-use App\Enum\Network\NetworkEnum;
-use App\Service\Network\Utils;
+use App\Service\Network\Utils as NetworkUtils;
 use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
 use Symfony\Component\Security\Core\Authorization\Voter\Voter;
 use Symfony\Component\Security\Core\Security;
@@ -16,7 +15,7 @@ class CotisationVoter extends Voter
 {
     public function __construct(
         private Security $security,
-        private Utils $networkUtils)
+        private NetworkUtils $networkUtils)
     { }
 
     protected function supports($attribute, $subject): bool

+ 2 - 2
src/Security/Voter/ModuleVoter.php

@@ -21,7 +21,7 @@ class ModuleVoter extends Voter
 
     public function __construct(private Module $module, private ResourceMetadataFactoryInterface $resourceMetadataFactory)
     { }
-    
+
     protected function supports(string $attribute, $subject): bool
     {
         if (!in_array($attribute, [self::HAVING_MODULE])) {
@@ -47,7 +47,7 @@ class ModuleVoter extends Voter
 
         //Check if there is a module for this entity : eq configuration problem
         if (null === $module) {
-            throw new AccessDeniedHttpException(sprintf('There no module for the entity (%s) !', $resourceMetadata->getShortName()));
+            throw new AccessDeniedHttpException(sprintf('There are no module for the entity (%s) !', $resourceMetadata->getShortName()));
         }
 
         /** @var Access $currentAccess */

+ 5 - 3
src/Service/Access/AdminAccessUtils.php

@@ -21,13 +21,15 @@ class AdminAccessUtils
     { }
 
     /**
-     * Renvoi l'objet AdminAccess initialisé par rapport à l'organization passée en paramètre
+     * Renvoie l'objet AdminAccess initialisé par rapport à l'organization passée en paramètre
+     *
      * @param Organization $organization
      * @return AdminAccess|null
+     * @throws \Exception
      * @see AdminAccessUtilsTest::testGetAdminAccess()
      */
     public function getAdminAccess(Organization $organization): ?AdminAccess{
-        $administrator = $this->accessUtils->getAdminAccess($organization);
+        $administrator = $this->accessUtils->findAdminFor($organization);
         if(!$administrator) return null;
 
         $contactPoint = $this->contactPointUtils->getPersonContactPointPrincipal($administrator);
@@ -41,4 +43,4 @@ class AdminAccessUtils
         ;
         return $adminAccess;
     }
-}
+}

+ 1 - 3
src/Service/Access/OptionalsRoles/CriteriaNotationOptionalRole.php

@@ -6,12 +6,10 @@ namespace App\Service\Access\OptionalsRoles;
 use App\Entity\Access\Access;
 use App\Repository\Access\AccessRepository;
 use App\Service\Access\OptionalsRolesInterface;
-use App\Service\Access\Utils;
 use App\Enum\Access\FunctionEnum;
 
 class CriteriaNotationOptionalRole implements OptionalsRolesInterface {
     public function __construct(
-        private Utils $accessUtils,
         private AccessRepository $accessRepository
     )
     {
@@ -27,4 +25,4 @@ class CriteriaNotationOptionalRole implements OptionalsRolesInterface {
     {
         return 'ROLE_CRITERIANOTATION';
     }
-}
+}

+ 7 - 3
src/Service/Access/Utils.php

@@ -8,6 +8,7 @@ use App\Entity\Organization\Organization;
 use App\Repository\Access\AccessRepository;
 use App\Service\ServiceIterator\OptionalsRolesIterator;
 use App\Test\Service\Access\UtilsTest;
+use Doctrine\Common\Collections\Criteria;
 use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
 
 /**
@@ -16,6 +17,8 @@ use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
  */
 class Utils
 {
+    // TODO: Renommer en AccessUtils
+
     public function __construct(
         private RoleHierarchyInterface $roleHierarchy,
         private AccessRepository $accessRepository,
@@ -60,15 +63,16 @@ class Utils
     }
 
     /**
-     * Renvoi l'access de l'organization qui est le "super admin"
+     * Renvoie l'access de l'organization qui est le "super admin"
+     *
      * @param Organization $organization
      * @return Access|null
      * @see UtilsTest::testGetAdminAccess()
      */
-    public function getAdminAccess(Organization $organization): Access|null{
+    public function findAdminFor(Organization $organization): Access|null{
         return $this->accessRepository->findOneBy([
             'adminAccess' => true,
             'organization' => $organization
-        ]) ?? null;
+        ]);
     }
 }

+ 0 - 89
src/Service/ApiRequestService.php

@@ -1,89 +0,0 @@
-<?php
-declare(strict_types=1);
-
-namespace App\Service;
-
-use App\Service\Utils\UrlBuilder;
-use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
-use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
-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;
-use Symfony\Contracts\HttpClient\ResponseInterface;
-
-/**
- * Base class for services sending requests to an external API
- */
-class ApiRequestService
-{
-    function __construct(
-        protected HttpClientInterface $client
-    ) {}
-
-    /**
-     * Sends a GET request and returns the response's body decoded as json
-     * @param string $path
-     * @param array $parameters
-     * @param array $options
-     * @return array
-     */
-    public function getJsonContent(string $path, array $parameters = [], array $options = []): array {
-        return json_decode($this->getContent($path, $parameters, $options), true);
-    }
-
-    /**
-     * Sends a GET request and returns the response's body
-     *
-     * @param string $path
-     * @param array $parameters
-     * @param array $options
-     * @return string
-     */
-    public function getContent(string $path, array $parameters = [], array $options = []): string {
-        try {
-            return $this->get($path, $parameters, $options)->getContent();
-        } catch (ClientExceptionInterface | TransportExceptionInterface | RedirectionExceptionInterface | ServerExceptionInterface $e) {
-            throw new NotFoundHttpException('ApiRequestService->getContent: an error occurred', $e, 404);
-        }
-    }
-
-    /**
-     * Sends a GET request and returns the response
-     *
-     * @param string $path
-     * @param array $parameters
-     * @param array $options
-     * @return ResponseInterface
-     */
-    protected function get(string $path, array $parameters = [], array $options = []): ResponseInterface {
-        return $this->request('GET', $path, $parameters, $options);
-    }
-
-    /**
-     * Send an HTTP request to the Dolibarr API,
-     * and return the decoded content of the response's body
-     *
-     * @param string $method
-     * @param string $url
-     * @param array $parameters
-     * @param array $options
-     * @return ResponseInterface
-     */
-    protected function request(
-        string $method,
-        string $url,
-        array $parameters = [],
-        array $options = []
-    ): ResponseInterface
-    {
-        $url = ltrim($url, '/');
-        $url = UrlBuilder::concatParameters($url, $parameters);
-        try {
-            return $this->client->request($method, $url, $options);
-        } catch (HttpExceptionInterface | TransportExceptionInterface $e) {
-            throw new NotFoundHttpException('fetch error', $e, 500);
-        }
-    }
-}

+ 7 - 6
src/Service/Constraint/ActivityYearConstraint.php

@@ -4,7 +4,7 @@ declare(strict_types=1);
 namespace App\Service\Constraint;
 
 use App\Entity\Access\Access;
-use App\Service\Organization\Utils as organizationUtils;
+use App\Service\Organization\Utils as OrganizationUtils;
 use Doctrine\ORM\EntityManagerInterface;
 
 /**
@@ -15,6 +15,7 @@ class ActivityYearConstraint extends AbstractTimeConstraintUtils
 {
     public function __construct(
         private EntityManagerInterface $entityManager,
+        private OrganizationUtils $organizationUtils
     )
     { }
 
@@ -56,8 +57,8 @@ class ActivityYearConstraint extends AbstractTimeConstraintUtils
     private function getRangeYear(Access $access, string $dateStart, string $dateEnd): array{
         $organization = $access->getOrganization();
         return [
-            organizationUtils::START_DATE_KEY => organizationUtils::getActivityYearSwitchDate($organization, new \DateTime($dateStart)),
-            organizationUtils::END_DATE_KEY => organizationUtils::getActivityYearSwitchDate($organization, new \DateTime($dateEnd))
+            OrganizationUtils::START_DATE_KEY => $this->organizationUtils->getActivityYearSwitchDate($organization, new \DateTime($dateStart)),
+            OrganizationUtils::END_DATE_KEY => $this->organizationUtils->getActivityYearSwitchDate($organization, new \DateTime($dateEnd))
         ];
     }
 
@@ -113,9 +114,9 @@ class ActivityYearConstraint extends AbstractTimeConstraintUtils
     private function customConstraint($years): array{
         return [
             self::START_KEY => [
-                $years[organizationUtils::START_DATE_KEY]  => self::SUP + self::EQUAL,
-                $years[organizationUtils::END_DATE_KEY] => self::INF + self::EQUAL
+                $years[OrganizationUtils::START_DATE_KEY]  => self::SUP + self::EQUAL,
+                $years[OrganizationUtils::END_DATE_KEY] => self::INF + self::EQUAL
             ]
         ];
     }
-}
+}

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

@@ -4,7 +4,7 @@ declare(strict_types=1);
 namespace App\Service\Constraint;
 
 use App\Entity\Access\Access;
-use App\Service\Organization\Utils as organizationUtils;
+use App\Service\Organization\Utils as OrganizationUtils;
 use App\Tests\Service\Constraint\DateTimeConstraintTest;
 use Doctrine\ORM\EntityManagerInterface;
 
@@ -16,6 +16,7 @@ class DateTimeConstraint extends AbstractTimeConstraintUtils
 {
     public function __construct(
         private EntityManagerInterface $entityManager,
+        private OrganizationUtils $organizationUtils
     )
     { }
 
@@ -57,8 +58,8 @@ class DateTimeConstraint extends AbstractTimeConstraintUtils
      */
     private function getCustomPeriods(string $dateStart, string $dateEnd): array{
         return [
-            organizationUtils::START_DATE_KEY => $dateStart,
-            organizationUtils::END_DATE_KEY => $dateEnd
+            OrganizationUtils::START_DATE_KEY => $dateStart,
+            OrganizationUtils::END_DATE_KEY => $dateEnd
         ];
     }
 
@@ -72,13 +73,13 @@ class DateTimeConstraint extends AbstractTimeConstraintUtils
     private function getPeriods(Access $access): array{
         $organization = $access->getOrganization();
         $activityYear = $access->getActivityYear();
-        $currentActivityYear = organizationUtils::getOrganizationCurrentActivityYear($organization);
+        $currentActivityYear = $this->organizationUtils->getOrganizationCurrentActivityYear($organization);
 
-        $periods = organizationUtils::getActivityPeriodsSwitchYear($organization, $activityYear);
+        $periods = $this->organizationUtils->getActivityPeriodsSwitchYear($organization, $activityYear);
         //Si l'année courante est l'année d'affichage choisie par l'utilisateur, alors la date de début est aujourd'hui
         if($activityYear === $currentActivityYear){
             $today = new \DateTime('now');
-            $periods[organizationUtils::START_DATE_KEY] = $today->format('Y-m-d');
+            $periods[OrganizationUtils::START_DATE_KEY] = $today->format('Y-m-d');
         }
 
         return $periods;
@@ -96,10 +97,10 @@ class DateTimeConstraint extends AbstractTimeConstraintUtils
     private function presentConstraint(array $periods): array{
         return [
           self::START_KEY => [
-              $periods[organizationUtils::END_DATE_KEY] => self::INF + self::EQUAL
+              $periods[OrganizationUtils::END_DATE_KEY] => self::INF + self::EQUAL
           ],
           self::END_KEY => [
-              $periods[organizationUtils::START_DATE_KEY] => self::SUP + self::EQUAL,
+              $periods[OrganizationUtils::START_DATE_KEY] => self::SUP + self::EQUAL,
               self::NULL_VALUE => self::NULL
           ]
         ];
@@ -115,7 +116,7 @@ class DateTimeConstraint extends AbstractTimeConstraintUtils
     private function pastConstraint($periods): array{
         return [
             self::END_KEY => [
-                $periods[organizationUtils::START_DATE_KEY] => self::INF
+                $periods[OrganizationUtils::START_DATE_KEY] => self::INF
             ]
         ];
     }
@@ -130,8 +131,8 @@ class DateTimeConstraint extends AbstractTimeConstraintUtils
     private function futurConstraint($periods): array{
         return [
             self::START_KEY => [
-                $periods[organizationUtils::END_DATE_KEY] => self::SUP
+                $periods[OrganizationUtils::END_DATE_KEY] => self::SUP
             ]
         ];
     }
-}
+}

+ 16 - 8
src/Service/Core/ContactPointUtils.php

@@ -19,17 +19,25 @@ class ContactPointUtils
     }
 
     /**
-     * Renvoie le point de contact principal de l'Access passé en paramètre
+     * Renvoie le point de contact principal de l'Access, ou null si aucun trouvé.
+     *
      * @param Access $access
      * @return ContactPoint|null
      * @see ContactPointUtilsTest::testGetPersonContactPointPrincipal()
      */
-    public function getPersonContactPointPrincipal(Access $access): ?ContactPoint{
-        $contactPoint = $this->contactPointRepository->getByTypeAndPerson(ContactPointTypeEnum::PRINCIPAL()->getValue(), $access->getPerson());
-        if(count($contactPoint) === 0) return null;
-        if(count($contactPoint) !== 1){
-            throw new \Exception('more_than_one_result');
+    public function getPersonContactPointPrincipal(Access $access): ?ContactPoint {
+        $contactPoints = $this->contactPointRepository->getByTypeAndPerson(
+            ContactPointTypeEnum::PRINCIPAL()->getValue(),
+            $access->getPerson()
+        );
+
+        if (count($contactPoints) === 0) {
+            return null;
+        }
+
+        if (count($contactPoints) !== 1){
+            throw new \RuntimeException('more_than_one_result');
         }
-        return $contactPoint[0];
+        return $contactPoints[0];
     }
-}
+}

+ 2 - 0
src/Service/Cotisation/Utils.php

@@ -17,6 +17,8 @@ use App\Service\Network\Utils as NetworkUtils;
  */
 class Utils
 {
+    // TODO: Renommer en CotisationUtils
+
     const MEMBERSHIP_WAITING = 495; // Affiliation in progress
     const MEMBERSHIP_NOPAYMENT = 517; // Waiting paiement
     const SUBMIT_IN_PROGRESS = 540; // Affiliation in progress

+ 68 - 0
src/Service/MailHub.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Service;
+
+use App\Entity\Access\Access;
+use App\Entity\Organization\Organization;
+use App\Service\Access\Utils as AccessUtils;
+use App\Service\Core\ContactPointUtils;
+use Symfony\Bridge\Twig\Mime\TemplatedEmail;
+use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Mime\Address;
+
+class MailHub
+{
+    public function __construct(
+        private MailerInterface $mailer,
+        private string $opentalentNoReplyEmailAddress,
+        private ContactPointUtils $contactPointUtils,
+        private AccessUtils $accessUtils
+    ) {}
+
+    /**
+     * Sends an automatic 'do-not-reply'-type email to the user
+     *
+     * NB: These emails are not registered in the DB
+     *
+     * @param Access $access
+     * @param string $subject
+     * @param string $template
+     * @param array $data
+     * @throws TransportExceptionInterface
+     */
+    public function sendAutomaticEmailTo(Access $access, string $subject, string $template, array $data): void
+    {
+        $contactPoint = $this->contactPointUtils->getPersonContactPointPrincipal($access);
+        if ($contactPoint === null || empty($contactPoint->getEmail()) || $contactPoint->getEmailInvalid()) {
+            throw new \RuntimeException('Access has no principal email address, abort');
+        }
+        /** @noinspection NullPointerExceptionInspection */
+        $to = new Address($contactPoint->getEmail(), $access->getPerson()->getFullName() ?? $access->getPerson()->getUsername());
+
+        $context = ['_to' => $to];
+        $context = array_merge($context, $data);
+
+        $email = (new TemplatedEmail())
+            ->from($this->opentalentNoReplyEmailAddress)
+            ->to($to)
+            ->subject($subject)
+            ->htmlTemplate('@templates/emails/' . $template . '.html.twig')
+            ->context($context);
+
+        $this->mailer->send($email);
+    }
+
+    /**
+     * Sends an automatic 'do-not-reply'-type email to the admin of the organization
+     * @throws TransportExceptionInterface
+     */
+    public function sendAutomaticEmailToAdmin(Organization $organization, string $subject, string $template, array $data): void
+    {
+        $admin = $this->accessUtils->findAdminFor($organization);
+        if ($admin === null) {
+            throw new \RuntimeException('No admin found for organization ' . $organization->getId());
+        }
+        $this->sendAutomaticEmailTo($admin, $subject, $template, $data);
+    }
+}

+ 2 - 1
src/Service/Network/Utils.php

@@ -15,6 +15,7 @@ use App\Tests\Service\Network\UtilsTest;
  */
 class Utils
 {
+    // TODO: Renommer en NetworkUtils
     /**
      * Test si l'organisation appartient au réseau de la CMF
      * @param Organization $organization
@@ -63,4 +64,4 @@ class Utils
     public function doesNetworkOrganizationIsActiveNow(NetworkOrganization $networksOrganization): bool{
         return DatesUtils::isIntervalIsValidNow($networksOrganization->getStartDate(), $networksOrganization->getEndDate());
     }
-}
+}

+ 37 - 0
src/Service/OnChange/OnChangeContext.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Service\OnChange;
+
+class OnChangeContext
+{
+    public function __construct(
+        private array $context
+    ) {}
+
+    /**
+     * La requête traitée est de type POST
+     *
+     * @return bool
+     */
+    public function isPostRequest(): bool {
+        return $this->context['collection_operation_name'] ?? null === 'post';
+    }
+
+    /**
+     * La requête traitée est de type PUT
+     *
+     * @return bool
+     */
+    public function isPutRequest(): bool {
+        return $this->context['item_operation_name'] ?? null === 'put';
+    }
+
+    /**
+     * Retourne l'entité existante (avant mise à jour) si elle existe, null sinon
+     *
+     * @return mixed|null
+     */
+    public function previousData() {
+        return $this->context['previous_data'] ?? null;
+    }
+}

+ 16 - 0
src/Service/OnChange/OnChangeDefault.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Service\OnChange;
+
+class OnChangeDefault implements OnChangeInterface
+{
+    public function validate($data, OnChangeContext $context): void {}
+
+    public function preProcess($data, OnChangeContext $context): mixed {
+        return $data;
+    }
+
+    public function beforeChange($data, OnChangeContext $context): void {}
+
+    public function onChange($data, OnChangeContext $context): void {}
+}

+ 39 - 0
src/Service/OnChange/OnChangeInterface.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Service\OnChange;
+
+interface OnChangeInterface
+{
+    /**
+     * Validate the new data, throw a RuntimeException if data is not valid
+     *
+     * @param $data
+     * @param OnChangeContext $context
+     */
+    public function validate($data, OnChangeContext $context): void;
+
+    /**
+     * Apply transformations to the new data before it to be persisted
+     *
+     * @param $data
+     * @param OnChangeContext $context
+     * @return mixed
+     */
+    public function preProcess($data, OnChangeContext $context): mixed;
+
+    /**
+     * Other operations to run before the data to be persisted
+     *
+     * @param $data
+     * @param OnChangeContext $context
+     */
+    public function beforeChange($data, OnChangeContext $context): void;
+
+    /**
+     * Operations to run after the data has been persisted
+     *
+     * @param $data
+     * @param OnChangeContext $context
+     */
+    public function onChange($data, OnChangeContext $context): void;
+}

+ 11 - 2
src/Service/OnChange/Organization/OnOrganizationChange.php

@@ -5,13 +5,22 @@ namespace App\Service\OnChange\Organization;
 
 use App\Entity\Organization\Organization;
 use App\Enum\Organization\LegalEnum;
+use App\Service\OnChange\OnChangeContext;
+use App\Service\OnChange\OnChangeDefault;
 use App\Test\Service\OnChange\Organization\OnOrganizationChangeTest;
 
 /**
  * Classe OnOrganizationChange qui comporte toutes les opérations automatiquent se produisant lors de l'évolution d'une organisation
  */
-class OnOrganizationChange
+class OnOrganizationChange extends OnChangeDefault
 {
+    public function beforeChange($organization, OnChangeContext $context): void
+    {
+        if($context->previousData() && $context->previousData()->getLegalStatus() !== $organization->getLegalStatus()) {
+            $this->onLegalStatusChange($organization);
+        }
+    }
+
     /**
      * Si le statut de l'organization évolue, on doit faire évoluer d'autre paramètres.
      * @param Organization $organization
@@ -26,4 +35,4 @@ class OnOrganizationChange
             $organization->getBillingSetting()->setApplyVat(true);
         }
     }
-}
+}

+ 102 - 12
src/Service/OnChange/Organization/OnParametersChange.php

@@ -6,22 +6,111 @@ namespace App\Service\OnChange\Organization;
 
 use App\Entity\Booking\Course;
 use App\Entity\Education\EducationNotationConfig;
-use App\Entity\Organization\Organization;
 use App\Entity\Organization\Parameters;
 use App\Enum\Education\AdvancedEducationNotationTypeEnum;
+use App\Message\Command\Parameters\AverageChange;
+use App\Message\Command\Typo3\Typo3DeleteCommand;
+use App\Message\Command\Typo3\Typo3UndeleteCommand;
+use App\Message\Command\Typo3\Typo3UpdateCommand;
 use App\Repository\Booking\CourseRepository;
+use App\Service\Network\Utils as NetworkUtils;
+use App\Service\OnChange\OnChangeContext;
+use App\Service\OnChange\OnChangeDefault;
 use App\Test\Service\OnChange\Organization\OnParametersChangeTest;
-use App\Service\Organization\Utils as organizationUtils;
+use App\Service\Organization\Utils as OrganizationUtils;
+use Symfony\Component\Messenger\MessageBusInterface;
 
 /**
  * Classe OnParametersChange qui comporte toutes les opérations automatiquent se produisant lors de l'évolution des paramètres
  */
-class OnParametersChange
+class OnParametersChange extends OnChangeDefault
 {
     public function __construct(
-        private CourseRepository $courseRepository
+        private CourseRepository $courseRepository,
+        private NetworkUtils $networkUtils,
+        private OrganizationUtils $organizationUtils,
+        private MessageBusInterface $messageBus
     ){ }
 
+    public function validate($parameters, OnChangeContext $context): void
+    {
+        // Une structure CMF n'a pas le droit de désactiver son site typo3
+        if (
+            $parameters->getDesactivateOpentalentSiteWeb() === true &&
+            $this->networkUtils->isCMFAndActiveNow($parameters->getOrganization())
+        ) {
+            throw new \RuntimeException('This structure is currently active in the CMF network, the website can not be disabled.');
+        }
+    }
+
+    /**
+     * @param Parameters $parameters
+     */
+    public function beforeChange($parameters, OnChangeContext $context): void{
+        if(
+            $context->previousData() &&
+            $context->previousData()->getAdvancedEducationNotationType() !== $parameters->getAdvancedEducationNotationType()
+        ){
+            $this->onAdvancedEducationNotationTypeChange($parameters);
+        }
+
+        //La date de début d'activité change
+        if(
+            $context->previousData() &&
+            $context->previousData()->getMusicalDate() !== $parameters->getMusicalDate()
+        ){
+            $this->onMusicalDateChange(
+                $parameters,
+                $context->previousData()->getMusicalDate()
+            );
+        }
+    }
+
+    /**
+     * @param Parameters $parameters
+     */
+    public function onChange($parameters, OnChangeContext $context): void{
+        //La note maximale du suivi pédagogique change
+        if(
+            $context->previousData() &&
+            $context->previousData()->getAverage() !== $parameters->getAverage()
+        ){
+            $this->messageBus->dispatch(
+                new AverageChange($parameters->getId())
+            );
+        }
+
+        // Le customDomain a été modifié, on met à jour le site typo3 (s'il est actif)
+        if(
+            $context->previousData() &&
+            !$parameters->getDesactivateOpentalentSiteWeb() &&
+            $context->previousData()->getCustomDomain() !== $parameters->getCustomDomain()
+        ){
+            $this->messageBus->dispatch(
+                new Typo3UpdateCommand($parameters->getOrganization()->getId())
+            );
+        }
+
+        // Le site web opentalent a été désactivé / réactivé
+        if(
+            $context->previousData() &&
+            $context->previousData()->getDesactivateOpentalentSiteWeb() !== $parameters->getDesactivateOpentalentSiteWeb()
+        ){
+            if ($parameters->getDesactivateOpentalentSiteWeb()) {
+                $this->messageBus->dispatch(
+                    new Typo3DeleteCommand($parameters->getOrganization()->getId())
+                );
+            } else {
+                $this->messageBus->dispatch(
+                    new Typo3UndeleteCommand($parameters->getOrganization()->getId())
+                );
+                $this->messageBus->dispatch(
+                    new Typo3UpdateCommand($parameters->getOrganization()->getId())
+                );
+            }
+        }
+    }
+
     /**
      * Si le le type de grilles d'évaluation évolue, il faut "nettoyer" les curriculums/teachers associés au type précédent
      * @param Parameters $parameters
@@ -44,15 +133,16 @@ class OnParametersChange
     }
 
     /**
-     * Permet de mettre à jour l'année de début des cours concernés suivant la date de l'activité musicale qui vient d'être changés
-     * @param Organization $organization
+     * Permet de mettre à jour l'année de début des cours concernés suivant la date
+     * de l'activité musicale qui vient d'être changée
+     *
+     * @param Parameters $parameters
      * @param \DateTimeInterface $previousMusicalDate
-     * @param \DateTimeInterface $newMusicalDate
      * @throws \Exception
      * @see OnParametersChangeTest::testOnMusicalDateChange()
      */
-    public function onMusicalDateChange(Organization $organization, \DateTimeInterface $previousMusicalDate): void{
-        $currentMusicalDate = $organization->getParameters()->getMusicalDate();
+    public function onMusicalDateChange(Parameters $parameters, \DateTimeInterface $previousMusicalDate): void{
+        $currentMusicalDate = $parameters->getMusicalDate();
 
         if($previousMusicalDate > $currentMusicalDate){
             $lowerDate = $currentMusicalDate;
@@ -65,13 +155,13 @@ class OnParametersChange
         $startDate = new \DateTime(date('Y') . '-' . $lowerDate->format('m-d'));
         $endDate = new \DateTime(date('Y') . '-' . $higherDate->format('m-d'));
 
-        $coursesToUpdate = $this->courseRepository->getCoursesToFrom($organization, $startDate, $endDate);
+        $coursesToUpdate = $this->courseRepository->getCoursesToFrom($parameters->getOrganization(), $startDate, $endDate);
 
         /** @var Course $course */
         foreach ($coursesToUpdate as $course){
-            $year = organizationUtils::getActivityYearSwitchDate($organization, $course->getDatetimeStart());
+            $year = $this->organizationUtils->getActivityYearSwitchDate($parameters->getOrganization(), $course->getDatetimeStart());
             $course->setStartYear($year);
             $course->setEndYear($year + 1);
         }
     }
-}
+}

+ 92 - 0
src/Service/OnChange/Organization/OnSubdomainChange.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace App\Service\OnChange\Organization;
+
+use App\Entity\Organization\Subdomain;
+use App\Message\Command\Typo3\Typo3UpdateCommand;
+use App\Service\Access\Utils as AccessUtils;
+use App\Service\MailHub;
+use App\Service\OnChange\OnChangeContext;
+use App\Service\OnChange\OnChangeDefault;
+use App\Service\Organization\Utils as OrganizationUtils;
+use App\Service\Typo3\BindFileService;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\Messenger\MessageBusInterface;
+
+class OnSubdomainChange extends OnChangeDefault
+{
+    public function __construct(
+        private OrganizationUtils $organizationUtils,
+        private AccessUtils $accessUtils,
+        private MailHub $mailHub,
+        private BindFileService $bindFileService,
+        private MessageBusInterface $messageBus,
+        private EntityManagerInterface $entityManager
+    ) {}
+
+    public function validate($subdomain, OnChangeContext $context): void {
+        // Ensure we do not exceed the limit of 3 subdomains per organization
+        if (
+            $context->isPostRequest() &&
+            count($subdomain->getOrganization()->getSubdomains()) >= 3
+        ) {
+            throw new \RuntimeException('This organization has already registered 3 subdomains');
+        }
+    }
+
+    public function onChange($subdomain, OnChangeContext $context): void {
+        $shallPersist = false;
+
+        // Ensure it is the only active subdomain of this organization by disabling other organization subdomains
+        if ($subdomain->isActive()) {
+            foreach ($subdomain->getOrganization()->getSubdomains() as $other) {
+                if ($other !== $subdomain && $other->isActive()) {
+                    $other->setActive(false);
+                    $shallPersist = true;
+                }
+            }
+        }
+
+        // Register into the BindFile
+        if ($context->isPostRequest()) {
+            $this->bindFileService->registerSubdomain($subdomain->getSubdomain());
+        }
+
+        if ($shallPersist) {
+            $this->entityManager->flush();
+        }
+
+        // A new subdomain is active
+        // /!\ This has to be executed after everything has been persisted
+        if ($subdomain->isActive() && !($context->previousData() && $context->previousData()->isActive())) {
+
+            // Update the typo3 website (asynchronously with messenger)
+            $this->messageBus->dispatch(
+                new Typo3UpdateCommand($subdomain->getOrganization()->getId())
+            );
+
+            // Envoi d'un email
+            $this->sendEmailAfterSubdomainChange($subdomain);
+        }
+    }
+
+    /**
+     * @throws \Exception
+     */
+    public function sendEmailAfterSubdomainChange(Subdomain $subdomain): void
+    {
+
+        $admin = $this->accessUtils->findAdminFor($subdomain->getOrganization());
+
+        $this->mailHub->sendAutomaticEmailToAdmin(
+            $subdomain->getOrganization(),
+            'Nouveau sous domaine: ' . $subdomain->getSubdomain(),
+            'subdomain',
+            [
+                'access' => $admin,
+                'subdomain' => $subdomain,
+                'url' => $this->organizationUtils->getOrganizationWebsite($subdomain->getOrganization())
+            ]
+        );
+    }
+}

+ 6 - 5
src/Service/Organization/OrganizationProfileCreator.php

@@ -8,7 +8,7 @@ use App\Entity\Organization\Organization;
 use App\Enum\Organization\PrincipalTypeEnum;
 use App\Service\Network\Tree;
 use App\Service\Security\Module;
-use App\Service\Organization\Utils as organizationUtils;
+use App\Service\Organization\Utils as OrganizationUtils;
 use App\Test\Service\Organization\OrganizationProfileCreatorTest;
 
 /**
@@ -20,6 +20,7 @@ class OrganizationProfileCreator
     public function __construct(
         private Module $module,
         private Tree $tree,
+        private OrganizationUtils $organizationUtils
     )
     { }
 
@@ -48,7 +49,7 @@ class OrganizationProfileCreator
             $organizationProfile->addParent($parentProfile);
         }
 
-        $organizationProfile->setCurrentYear(organizationUtils::getOrganizationCurrentActivityYear($organization));
+        $organizationProfile->setCurrentYear($this->organizationUtils->getOrganizationCurrentActivityYear($organization));
 
         return $organizationProfile;
     }
@@ -63,8 +64,8 @@ class OrganizationProfileCreator
         $organizationProfile
             ->setId($organization->getId())
             ->setName($organization->getName())
-            ->setSubDomain($organization->getParameters()->getSubDomain())
-            ->setWebsite($organization->getParameters()->getWebsite());
+            ->setWebsite($this->organizationUtils->getOrganizationWebsite($organization));
+
         return $organizationProfile;
     }
-}
+}

+ 53 - 4
src/Service/Organization/Utils.php

@@ -6,7 +6,9 @@ namespace App\Service\Organization;
 use App\Entity\Organization\Organization;
 use App\Enum\Organization\OrganizationIdsEnum;
 use App\Enum\Organization\SettingsProductEnum;
+use App\Service\Utils\UrlBuilder;
 use App\Test\Service\Organization\UtilsTest;
+use Doctrine\Common\Collections\Criteria;
 
 /**
  * Class OrganizationUtils : service rassemblant des fonctions d'aides pour les questions se rapportant à l'organisation
@@ -14,6 +16,8 @@ use App\Test\Service\Organization\UtilsTest;
  */
 class Utils
 {
+    // TODO: Renommer en OrganizationUtils
+
     const START_DATE_KEY = 'dateStart';
     const END_DATE_KEY = 'dateEnd';
 
@@ -75,7 +79,7 @@ class Utils
      * @throws \Exception
      * @see UtilsTest::testGetOrganizationCurrentActivityYear()
      */
-    public static function getOrganizationCurrentActivityYear(Organization $organization): int{
+    public function getOrganizationCurrentActivityYear(Organization $organization): int{
         $today = new \DateTime();
         $year = intval($today->format('Y'));
 
@@ -99,7 +103,7 @@ class Utils
      * @throws \Exception
      * @see UtilsTest::testGetActivityPeriodsSwitchYear()
      */
-    public static function getActivityPeriodsSwitchYear(Organization $organization, int $year): array
+    public function getActivityPeriodsSwitchYear(Organization $organization, int $year): array
     {
         $musicalDate = $organization->getParameters()->getMusicalDate();
 
@@ -126,7 +130,7 @@ class Utils
      * @throws \Exception
      * @see UtilsTest::testgetActivityYearSwitchDate()
      */
-    public static function getActivityYearSwitchDate(Organization $organization, \DateTimeInterface $date): int
+    public function getActivityYearSwitchDate(Organization $organization, \DateTimeInterface $date): int
     {
         $year = $date->format('Y');
         $musicalDate = $organization->getParameters()->getMusicalDate();
@@ -143,4 +147,49 @@ class Utils
             return  (int) ($year - 1);
         else return (int) $year;
     }
-}
+
+    /**
+     * Return the active subdomain of an organization as a string, or null
+     *
+     * @param Organization $organization
+     * @return string | null
+     */
+    public function getOrganizationActiveSubdomain(Organization $organization): ?string {
+        foreach ($organization->getSubdomains() as $subdomain) {
+            if ($subdomain->isActive()) {
+                return $subdomain->getSubdomain();
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Get the URL of the current website of the organization
+     *
+     * @see https://ressources.opentalent.fr/display/SPEC/Preferences#Preferences-Siteinternet
+     *
+     * @param Organization $organization
+     * @return string | null
+     */
+    public function getOrganizationWebsite(Organization $organization): ?string {
+        $parameters = $organization->getParameters();
+
+        if ($parameters->getDesactivateOpentalentSiteWeb()) {
+            if ($parameters->getOtherWebsite()) {
+                return UrlBuilder::prependHttps($parameters->getOtherWebsite());
+            }
+            return null;
+        }
+
+        if (!empty($parameters->getCustomDomain())) {
+            return UrlBuilder::prependHttps($parameters->getCustomDomain());
+        }
+
+        $subdomain = $this->getOrganizationActiveSubdomain($organization);
+        if (!$subdomain) {
+            return null;
+        }
+
+        return 'https://' . $subdomain . '.opentalent.fr';
+    }
+}

+ 1 - 1
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(404, 'Data not found', $e);
+            throw new HttpException(500, 'Request error : ', $e);
         }
     }
 

+ 3 - 3
src/Service/Security/Module.php

@@ -20,7 +20,7 @@ class Module
     private array $moduleConfig;
     private array $moduleByConditionsConfig;
 
-    public function __construct(private Reflection $reflection, private Parser $parser,  private string $opentalentConfig)
+    public function __construct(private Reflection $reflection, private Parser $parser, private string $opentalentConfig)
     {
         $this->moduleConfig = $this->getModuleConfig();
         $this->moduleByConditionsConfig = $this->getModuleByConditionsConfig();
@@ -28,7 +28,7 @@ class Module
 
     /**
      * @todo activer le cache après que la fin de la migration.
-     * Récupère tous les modules de l'oganisation
+     * Récupère tous les modules de l'organisation
      * @param Organization $organization
      * @return array
      */
@@ -153,4 +153,4 @@ class Module
         }
         return null;
     }
-}
+}

+ 30 - 0
src/Service/Typo3/BindFileService.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Service\Typo3;
+
+/**
+ * Opérations sur le fichier Bind, qui gère entre autres l'adressage DNS lié aux sous-domaines
+ *
+ * @codeCoverageIgnore
+ */
+class BindFileService
+{
+    public function __construct(
+        private string $bindfileBufferFile
+    ) {}
+
+    /**
+     * Append the subdomain to the buffer file
+     *
+     * A cron consumes this file each 5 minutes to complete the bind file /etc/bind/zones/opentalent.fr.db
+     *
+     * @param string $subdomain
+     */
+    public function registerSubdomain(string $subdomain): void
+    {
+        $fsd = fopen($this->bindfileBufferFile, 'ab+');
+        fwrite($fsd, $subdomain);
+        fwrite($fsd, "\n");
+        fclose($fsd);
+    }
+}

+ 122 - 0
src/Service/Typo3/Typo3Service.php

@@ -0,0 +1,122 @@
+<?php
+
+namespace App\Service\Typo3;
+
+use App\Service\Utils\UrlBuilder;
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+/**
+ * Service d'appel à l'API de l'instance Typo3
+ */
+class Typo3Service
+{
+    public function __construct(
+        private HttpClientInterface $typo3_client,
+    )
+    {}
+
+    /**
+     * Send a command to the given route of the Typo3 API
+     *
+     * @param string $route
+     * @param array $parameters
+     *
+     * @return ResponseInterface
+     * @throws TransportExceptionInterface
+     */
+    protected function sendCommand(string $route, array $parameters): ResponseInterface
+    {
+        $url = UrlBuilder::concatParameters('/typo3/index.php?route=' . $route, $parameters);
+        return $this->typo3_client->request('GET', $url);
+    }
+
+    /**
+     * Clear the cache of the given organization's website
+     * @throws TransportExceptionInterface
+     */
+    public function clearSiteCache(int $organizationId): ResponseInterface
+    {
+        return $this->sendCommand('/otadmin/site/clear-cache', ['organization-id' => $organizationId]);
+    }
+
+    /**
+     * Create a new Typo3 website for the given organization
+     * @throws TransportExceptionInterface
+     */
+    public function createSite(int $organizationId): ResponseInterface
+    {
+        return $this->sendCommand('/otadmin/site/create', ['organization-id' => $organizationId]);
+    }
+
+    /**
+     * Update the given organization's website with the Opentalent DB data
+     * @throws TransportExceptionInterface
+     */
+    public function updateSite(int $organizationId): ResponseInterface
+    {
+        return $this->sendCommand('/otadmin/site/update', ['organization-id' => $organizationId]);
+    }
+
+    /**
+     * Mark the given organization's website as deleted. This can be reverted with 'undeleteSite'
+     * @throws TransportExceptionInterface
+     */
+    public function deleteSite(int $organizationId): ResponseInterface
+    {
+        return $this->sendCommand('/otadmin/site/delete', ['organization-id' => $organizationId]);
+    }
+
+    /**
+     * Restore a website that has been deleted with 'deleteSite'
+     * @throws TransportExceptionInterface
+     */
+    public function undeleteSite(int $organizationId): ResponseInterface
+    {
+        return $this->sendCommand('/otadmin/site/undelete', ['organization-id' => $organizationId]);
+    }
+
+    /**
+     * Set a custom domain for the given website
+     * @throws TransportExceptionInterface
+     */
+    public function setSiteDomain(int $organizationId, string $newDomain, bool $addRedirection=false): ResponseInterface
+    {
+        $params = ['organization-id' => $organizationId, 'domain' => $newDomain];
+        if ($addRedirection) {
+            $params['redirect'] = 1;
+        }
+        return $this->sendCommand('/otadmin/site/set-domain', $params);
+    }
+
+    /**
+     * Reset the permissions of the BE users of the given organization's website
+     * @throws TransportExceptionInterface
+     */
+    public function resetSitePerms(int $organizationId): ResponseInterface
+    {
+        return $this->sendCommand('/otadmin/site/reset-perms', ['organization-id' => $organizationId]);
+    }
+
+    /**
+     * Returns the given organization's website status
+     * @throws TransportExceptionInterface
+     */
+    public function getSiteStatus(int $organizationId): ResponseInterface
+    {
+        return $this->sendCommand('/otadmin/site/status', ['organization-id' => $organizationId]);
+    }
+
+    /**
+     * Returns the given organization's website status
+     * @throws TransportExceptionInterface
+     */
+    public function addRedirection(string $fromDomain, string $toDomain): ResponseInterface
+    {
+        return $this->sendCommand(
+            '/otadmin/redirect/add',
+            ['from-domain' => $fromDomain, 'to-domain' => $toDomain]
+        );
+    }
+}

+ 3 - 3
src/Service/Utils/UrlBuilder.php

@@ -47,12 +47,12 @@ class UrlBuilder
     }
 
     /**
-     * Prepend the 'https://' part if neither 'http://' of 'https://' is present, does nothing else
+     * Prepend the 'https://' part if neither 'http://' of 'https://' is present, else: does nothing
      *
      * @param $url
      * @return string
      */
-    public static function preprendHttps($url): string
+    public static function prependHttps($url): string
     {
         if (!preg_match('/^https?:\/\/.*/', $url)) {
             $url = 'https://' . $url;
@@ -72,7 +72,7 @@ class UrlBuilder
     public static function concat(string $url, string $path, array $parameters, bool $preprendHttps = false): string {
         $url = self::concatParameters(self::concatPath($url, $path), $parameters);
         if ($preprendHttps) {
-            $url = self::preprendHttps($url);
+            $url = self::prependHttps($url);
         }
         return $url;
     }

+ 17 - 0
templates/emails/subdomain.html.twig

@@ -0,0 +1,17 @@
+{% if  access.adminAccess %}
+Cher administrateur de {{access.organization.name}},
+{% else %}
+Cher {{access.person.givenName}} {{access.person.name}},
+{% endif %}
+votre demande d'activation du sous-domaine '{{ subdomain.subdomain }}' a été prise en compte et sera effective d'ici quelques minutes.
+
+Votre site sera dè lors accessible à l'adresse suivante :
+
+<a href="{{url}}">{{url}}</a>
+
+{% if  access.adminAccess %}
+Notez que votre identifiant est désormais : {{access.person.username}}
+Votre mot de passe reste inchangé.
+{% endif %}
+
+{% include '@templates/layout/noreply/footer.html.twig' with {'name': _to.name, 'email': _to.address } only %}

+ 6 - 0
templates/layout/noreply/footer.html.twig

@@ -0,0 +1,6 @@
+Merci !<br>
+L'équipe Opentalent<br>
+<hr/>
+<em>Cet e-mail a été envoyé automatiquement. Merci de ne pas y répondre.<br>
+    Cet e-mail a été adressé à :<br>
+    {{name}} <{{email}}></em>

+ 4 - 4
tests/Service/Access/AdminAccessUtilsTest.php

@@ -37,7 +37,7 @@ class AdminAccessUtilsTest extends TestCase
      */
     public function testGetAdminAccessWithoutAdministrator(){
         $this->accessUtilsMock
-            ->method('getAdminAccess')
+            ->method('findAdminFor')
             ->with($this->organization)
             ->willReturn(null);
 
@@ -51,7 +51,7 @@ class AdminAccessUtilsTest extends TestCase
         $administrator = new Access();
 
         $this->accessUtilsMock
-            ->method('getAdminAccess')
+            ->method('findAdminFor')
             ->with($this->organization)
             ->willReturn($administrator);
 
@@ -73,7 +73,7 @@ class AdminAccessUtilsTest extends TestCase
         $contactPoint = new ContactPoint();
 
         $this->accessUtilsMock
-            ->method('getAdminAccess')
+            ->method('findAdminFor')
             ->with($this->organization)
             ->willReturn($administrator);
 
@@ -84,4 +84,4 @@ class AdminAccessUtilsTest extends TestCase
 
         $this->assertInstanceOf(AdminAccess::class, $this->adminAccessUtils->getAdminAccess($this->organization));
     }
-}
+}

+ 6 - 6
tests/Service/Access/UtilsTest.php

@@ -95,24 +95,24 @@ class UtilsTest extends TestCase
     }
 
     /**
-     * @see Utils::getAdminAccess()
+     * @see Utils::findAdminFor()
      */
-    public function testGetAdminAccess(){
+    public function testFindAdminFor(){
         $this->accessRepositoryMock
             ->method('findOneBy')
             ->willReturn(new Access());
 
-        $this->assertNotEmpty($this->utils->getAdminAccess(new Organization()));
+        $this->assertNotEmpty($this->utils->findAdminFor(new Organization()));
     }
 
     /**
-     * @see Utils::getAdminAccess()
+     * @see Utils::findAdminFor()
      */
-    public function testGetAdminAccessNotFound(){
+    public function testFindAdminForNotFound(){
         $this->accessRepositoryMock
             ->method('findOneBy')
             ->willReturn(null);
 
-        $this->assertNull($this->utils->getAdminAccess(new Organization()));
+        $this->assertNull($this->utils->findAdminFor(new Organization()));
     }
 }

+ 3 - 2
tests/Service/Constraint/DateTimeConstraintTest.php

@@ -19,7 +19,8 @@ class DateTimeConstraintTest extends TestCase
    public function setUp(): void
    {
        $em = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
-       $this->dateTimeConstraint = new DateTimeConstraint($em);
+       $organizationUtils = new \App\Service\Organization\Utils();
+       $this->dateTimeConstraint = new DateTimeConstraint($em, $organizationUtils);
 
        $this->periods = [
            'dateStart' => '2021-12-20',
@@ -217,4 +218,4 @@ class DateTimeConstraintTest extends TestCase
         $historical = ['dateStart' => null, 'dateEnd' => null];
         $this->assertFalse($this->invokeMethod($this->dateTimeConstraint, 'hasCustomPeriods', [$historical]));
     }
-}
+}

+ 37 - 0
tests/Service/Cotisation/CotisationCreatorTest.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Tests\Service\Cotisation;
+
+use App\Entity\Organization\Organization;
+use App\Repository\Organization\OrganizationRepository;
+use App\Service\Cotisation\CotisationCreator;
+use App\Service\Cotisation\Utils;
+use PHPUnit\Framework\TestCase;
+
+class CotisationCreatorTest extends TestCase
+{
+    private OrganizationRepository $organizationRepository;
+    private Utils $cotisationUtils;
+    private CotisationCreator $cotisationCreator;
+
+    public function setUp():void
+    {
+        $this->organizationRepository = $this->getMockBuilder(OrganizationRepository::class)->disableOriginalConstructor()->getMock();
+        $this->cotisationUtils = $this->getMockBuilder(Utils::class)->disableOriginalConstructor()->getMock();
+
+        $this->cotisationCreator = new CotisationCreator(
+            $this->organizationRepository,
+            $this->cotisationUtils
+        );
+    }
+
+    public function testGetCotisation() {
+        $this->cotisationUtils->expects(self::once())->method('getCurrentCotisationYear')->willReturn(2000);
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $this->organizationRepository->expects(self::once())->method('find')->willReturn($organization);
+        $this->cotisationUtils->expects(self::once())->method('getAlertState')->with($organization, 2000);
+        $cotisation = $this->cotisationCreator->getCotisation(1);
+
+        $this->assertEquals(1, $cotisation->getOrganizationId());
+    }
+}

+ 103 - 0
tests/Service/MailHubTest.php

@@ -0,0 +1,103 @@
+<?php
+
+use App\Service\Core\ContactPointUtils;
+use App\Service\MailHub;
+use PHPUnit\Framework\TestCase;
+use Symfony\Bridge\Twig\Mime\TemplatedEmail;
+use Symfony\Component\Mailer\MailerInterface;
+
+class MailHubTest extends TestCase
+{
+    private MailerInterface $mailer;
+    private string $opentalentNoReplyEmailAddress;
+    private ContactPointUtils $contactPointUtils;
+    private \App\Service\Access\Utils $accessUtils;
+
+    public function setUp(): void {
+        $this->mailer = $this->getMockBuilder(MailerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->opentalentNoReplyEmailAddress = 'noreply@opentalent.fr';
+        $this->contactPointUtils = $this->getMockBuilder(ContactPointUtils::class)->disableOriginalConstructor()->getMock();
+        $this->accessUtils = $this->getMockBuilder(\App\Service\Access\Utils::class)->disableOriginalConstructor()->getMock();
+    }
+
+    public function testSendAutomaticEmailTo(): void
+    {
+        $contactPoint = $this->getMockBuilder(\App\Entity\Core\ContactPoint::class)->disableOriginalConstructor()->getMock();
+        $contactPoint->method('getEmail')->willReturn('mail@domain.net');
+
+        $person = $this->getMockBuilder(\App\Entity\Person\Person::class)->disableOriginalConstructor()->getMock();
+        $person->method('getFullName')->willReturn('Don Diego de la Vega');
+        $person->method('getUsername')->willReturn('zorro2000');
+
+        $access = $this->getMockBuilder(\App\Entity\Access\Access::class)->disableOriginalConstructor()->getMock();
+        $access->method('getPerson')->willReturn($person);
+
+        $this->contactPointUtils->expects(self::once())->method('getPersonContactPointPrincipal')->willReturn($contactPoint);
+
+        $this->mailer->expects(self::once())
+            ->method('send')
+            ->with(self::isInstanceOf(TemplatedEmail::class));
+
+        $mailerHub = new MailHub(
+            $this->mailer,
+            $this->opentalentNoReplyEmailAddress,
+            $this->contactPointUtils,
+            $this->accessUtils
+        );
+
+        $mailerHub->sendAutomaticEmailTo($access, 'subject', 'a_template', []);
+    }
+
+    public function testSendAutomaticEmailToButNoAddress(): void
+    {
+        $access = $this->getMockBuilder(\App\Entity\Access\Access::class)->disableOriginalConstructor()->getMock();
+
+        $this->contactPointUtils->expects(self::once())->method('getPersonContactPointPrincipal')->willReturn(null);
+
+        $mailerHub = new MailHub(
+            $this->mailer,
+            $this->opentalentNoReplyEmailAddress,
+            $this->contactPointUtils,
+            $this->accessUtils
+        );
+
+        try {
+            $mailerHub->sendAutomaticEmailTo($access, 'subject', 'a_template', []);
+            throw new AssertionError('A RuntimeException should have been thrown but has not');
+        } catch (\RuntimeException $e) {}
+    }
+
+    public function testSendAutomaticEmailToAdmin() {
+
+        $organization = $this->getMockBuilder(\App\Entity\Organization\Organization::class)->disableOriginalConstructor()->getMock();
+        $admin = $this->getMockBuilder(\App\Entity\Access\Access::class)->disableOriginalConstructor()->getMock();
+
+        $this->accessUtils->expects(self::once())->method('findAdminFor')->with($organization)->willReturn($admin);
+
+        $mailerHub = $this->getMockBuilder(MailHub::class)
+            ->onlyMethods(['sendAutomaticEmailTo'])
+            ->setConstructorArgs([$this->mailer, $this->opentalentNoReplyEmailAddress, $this->contactPointUtils, $this->accessUtils])
+            ->getMock();
+        $mailerHub->expects(self::once())->method('sendAutomaticEmailTo')->with($admin, 'subject', 'template', []);
+
+        $mailerHub->sendAutomaticEmailToAdmin($organization, 'subject', 'template', []);
+    }
+
+    public function testSendAutomaticEmailToAdminButNoAdmin() {
+
+        $organization = $this->getMockBuilder(\App\Entity\Organization\Organization::class)->disableOriginalConstructor()->getMock();
+        $this->accessUtils->expects(self::once())->method('findAdminFor')->with($organization)->willReturn(null);
+
+        $mailerHub = new MailHub(
+            $this->mailer,
+            $this->opentalentNoReplyEmailAddress,
+            $this->contactPointUtils,
+            $this->accessUtils
+        );
+
+        try {
+            $mailerHub->sendAutomaticEmailToAdmin($organization, 'subject', 'template', []);
+            throw new AssertionError('A RuntimeException should have been thrown but has not');
+        } catch (\RuntimeException $e) {}
+    }
+}

+ 33 - 0
tests/Service/OnChange/OnChangeContextTest.php

@@ -0,0 +1,33 @@
+<?php
+
+use App\Service\OnChange\OnChangeContext;
+use PHPUnit\Framework\TestCase;
+
+class OnChangeContextTest extends TestCase
+{
+    public function testIsPostRequest() {
+        $context = new OnChangeContext(
+            ['collection_operation_name' => 'post']
+        );
+
+        $this->assertTrue($context->isPostRequest());
+        $this->assertFalse($context->isPutRequest());
+    }
+
+    public function testIsPutRequest() {
+        $context = new OnChangeContext(
+            ['item_operation_name' => 'put']
+        );
+
+        $this->assertTrue($context->isPutRequest());
+        $this->assertFalse($context->isPostRequest());
+    }
+
+    public function testPreviousData() {
+        $context = new OnChangeContext(
+            ['previous_data' => 1]
+        );
+
+        $this->assertEquals(1, $context->previousData());
+    }
+}

+ 24 - 0
tests/Service/OnChange/OnChangeDefaultTest.php

@@ -0,0 +1,24 @@
+<?php
+
+use App\Service\OnChange\OnChangeContext;
+use App\Service\OnChange\OnChangeDefault;
+use PHPUnit\Framework\TestCase;
+
+class OnChangeDefaultTest extends TestCase
+{
+    /**
+     * Default OnChange service does nothing; it shouldn't change anything to the data nor raise excemptions
+     */
+    public function testDoesNothing() {
+        $data = 1;
+        $context = new OnChangeContext([]);
+
+        $onChange = new OnChangeDefault();
+        $onChange->validate($data, $context);
+        $data = $onChange->preProcess($data, $context);
+        $onChange->beforeChange($data, $context);
+        $onChange->onChange($data, $context);
+
+        $this->assertEquals(1, $data);
+    }
+}

+ 50 - 1
tests/Service/OnChange/Organization/OnOrganizationChangeTest.php

@@ -5,6 +5,7 @@ use App\Entity\Billing\BillingSetting;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Parameters;
 use App\Enum\Organization\LegalEnum;
+use App\Service\OnChange\OnChangeContext;
 use App\Service\OnChange\Organization\OnOrganizationChange;
 use PHPUnit\Framework\TestCase;
 
@@ -19,6 +20,54 @@ class OnOrganizationChangeTest extends TestCase
         $this->onOrganizationChange = new OnOrganizationChange();
     }
 
+    public function testBeforeChangeNoChange(): void
+    {
+        $onOrganizationChange =  $this
+            ->getMockBuilder(OnOrganizationChange::class)
+            ->onlyMethods(['onLegalStatusChange'])
+            ->disableOriginalConstructor()
+            ->getMock();
+        $onOrganizationChange
+            ->expects(self::never())
+            ->method('onLegalStatusChange')
+            ->willReturnSelf();
+
+        $previousOrganization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $previousOrganization->expects(self::once())->method('getLegalStatus')->willReturn('ASSOCIATION_LAW_1901');
+
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+        $context->method('previousData')->willReturn($previousOrganization);
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->expects(self::once())->method('getLegalStatus')->willReturn('ASSOCIATION_LAW_1901');
+
+        $onOrganizationChange->beforeChange($organization, $context);
+    }
+
+    public function testBeforeChangeLegalStatusChanged(): void
+    {
+        $onOrganizationChange =  $this
+            ->getMockBuilder(OnOrganizationChange::class)
+            ->onlyMethods(['onLegalStatusChange'])
+            ->disableOriginalConstructor()
+            ->getMock();
+        $onOrganizationChange
+            ->expects(self::once())
+            ->method('onLegalStatusChange')
+            ->willReturnSelf();
+
+        $previousOrganization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $previousOrganization->expects(self::once())->method('getLegalStatus')->willReturn('ASSOCIATION_LAW_1901');
+
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+        $context->method('previousData')->willReturn($previousOrganization);
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->expects(self::once())->method('getLegalStatus')->willReturn('LOCAL_AUTHORITY');
+
+        $onOrganizationChange->beforeChange($organization, $context);
+    }
+
     /**
      * @see OnOrganizationChange::onLegalStatusChange()
      */
@@ -36,4 +85,4 @@ class OnOrganizationChangeTest extends TestCase
         $this->assertFalse($this->organization->getParameters()->getShowAdherentList());
         $this->assertTrue($this->organization->getBillingSetting()->getApplyVat());
     }
-}
+}

+ 282 - 9
tests/Service/OnChange/Organization/OnParametersChangeTest.php

@@ -8,27 +8,263 @@ use App\Entity\Education\EducationNotationConfig;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Parameters;
 use App\Enum\Education\AdvancedEducationNotationTypeEnum;
+use App\Message\Command\Parameters\AverageChange;
+use App\Message\Command\Typo3\Typo3DeleteCommand;
+use App\Message\Command\Typo3\Typo3UndeleteCommand;
+use App\Message\Command\Typo3\Typo3UpdateCommand;
 use App\Repository\Booking\CourseRepository;
+use App\Service\OnChange\OnChangeContext;
 use App\Service\OnChange\Organization\OnParametersChange;
+use AssertionError;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\Messenger\Envelope;
+use Symfony\Component\Messenger\MessageBusInterface;
 
 class OnParametersChangeTest extends TestCase
 {
     private Parameters $parameters;
     private OnParametersChange $onParametersChange;
     private CourseRepository $courseRepositoryMock;
+    private \App\Service\Network\Utils $networkUtils;
+    private MessageBusInterface $messageBus;
+    private \App\Service\Organization\Utils $organizationUtils;
 
-    public function setUp():void
+    public function setUp(): void
     {
         $this->courseRepositoryMock = $this->getMockBuilder(CourseRepository::class)->disableOriginalConstructor()->getMock();
+        $this->networkUtils = $this->getMockBuilder(\App\Service\Network\Utils::class)->disableOriginalConstructor()->getMock();
+        $this->organizationUtils = $this->getMockBuilder(\App\Service\Organization\Utils::class)->disableOriginalConstructor()->getMock();
+        $this->messageBus = $this->getMockBuilder(MessageBusInterface::class)->disableOriginalConstructor()->getMock();
         $this->parameters = new Parameters();
-        $this->onParametersChange = new OnParametersChange($this->courseRepositoryMock);
+        $this->onParametersChange = new OnParametersChange(
+            $this->courseRepositoryMock,
+            $this->networkUtils,
+            $this->organizationUtils,
+            $this->messageBus
+        );
+    }
+
+    public function testValidate(): void
+    {
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+
+        // 1. Is CMF and site web enabled ; 2. Is not CMF and site web disabled ; 3. Is not CMF and site web enabled
+        foreach ([[false, true], [true, false], [false, false]] as $params) {
+            $parameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+            $parameters->expects(self::once())->method('getDesactivateOpentalentSiteWeb')->willReturn($params[0]);
+
+            $this->networkUtils = $this->getMockBuilder(\App\Service\Network\Utils::class)->disableOriginalConstructor()->getMock();
+            $this->networkUtils->method('isCMFAndActiveNow')->willReturn($params[1]);
+
+            $this->onParametersChange->validate($parameters, $context);
+        }
+    }
+
+    public function testValidateInvalid(): void
+    {
+        $parameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+
+        // Is CMF and site web disabled
+        $parameters->expects(self::once())->method('getDesactivateOpentalentSiteWeb')->willReturn(true);
+        $this->networkUtils->expects(self::once())->method('isCMFAndActiveNow')->willReturn(true);
+
+        try {
+            $this->onParametersChange->validate($parameters, $context);
+            throw new AssertionError('OnParametersChange::validate should have thrown an error');
+        } catch (\RuntimeException) {}
+    }
+
+    public function testBeforeChange(): void
+    {
+        $onParametersChange =  $this
+            ->getMockBuilder(OnParametersChange::class)
+            ->onlyMethods(['onAdvancedEducationNotationTypeChange', 'onMusicalDateChange'])
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $onParametersChange
+            ->expects(self::once())
+            ->method('onAdvancedEducationNotationTypeChange')
+            ->willReturnSelf();
+        $onParametersChange
+            ->expects(self::once())
+            ->method('onMusicalDateChange')
+            ->willReturnSelf();
+
+        $previousParameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $previousParameters->method('getAdvancedEducationNotationType')->willReturn('BY_EDUCATION');
+        $musicalDate = new \DateTime('2022-01-01');
+        $previousParameters->method('getMusicalDate')->willReturn($musicalDate);
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+        $context->method('previousData')->willReturn($previousParameters);
+
+        $parameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $parameters->method('getAdvancedEducationNotationType')->willReturn('SOMETHING_ELSE');
+        $parameters->method('getMusicalDate')->willReturn(new \DateTime('2023-01-01'));
+
+        // Both mocked methods should be called once here
+        $onParametersChange->beforeChange($parameters, $context);
+
+        $parameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $parameters->method('getId')->willReturn(1);
+        $parameters->method('getAdvancedEducationNotationType')->willReturn('BY_EDUCATION');
+        $parameters->method('getMusicalDate')->willReturn($musicalDate);
+
+        // None of the mocked methods should be called again here
+        $onParametersChange->beforeChange($parameters, $context);
+    }
+
+    public function testOnChangeNoChange(): void
+    {
+        $this->messageBus->expects($this->never())->method('dispatch');
+
+        $previousParameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $previousParameters->method('getId')->willReturn(1);
+        $previousParameters->expects(self::once())->method('getAverage')->willReturn(20);
+        $previousParameters->expects(self::once())->method('getDesactivateOpentalentSiteWeb')->willReturn(false);
+        $previousParameters->expects(self::once())->method('getCustomDomain')->willReturn(null);
+
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+        $context->method('previousData')->willReturn($previousParameters);
+
+        $parameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $parameters->method('getId')->willReturn(1);
+        $parameters->method('getAverage')->willReturn(20);
+        $parameters->method('getDesactivateOpentalentSiteWeb')->willReturn(false);
+        $parameters->method('getCustomDomain')->willReturn(null);
+
+        $this->onParametersChange->onChange($parameters, $context);
+    }
+
+    public function testOnChangeAverageChanged(): void
+    {
+        $this->messageBus
+            ->expects(self::once())
+            ->method('dispatch')
+            ->with(self::isInstanceOf(AverageChange::class))
+            ->willReturn(new Envelope(new AverageChange(1)));
+
+        $previousParameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $previousParameters->method('getId')->willReturn(1);
+        $previousParameters->expects(self::once())->method('getAverage')->willReturn(20);
+        $previousParameters->method('getDesactivateOpentalentSiteWeb')->willReturn(false);
+        $previousParameters->method('getCustomDomain')->willReturn(null);
+
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+        $context->method('previousData')->willReturn($previousParameters);
+
+        $parameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $parameters->method('getId')->willReturn(1);
+        $parameters->method('getDesactivateOpentalentSiteWeb')->willReturn(false);
+        $parameters->method('getCustomDomain')->willReturn(null);
+        $parameters->expects(self::once())->method('getAverage')->willReturn(30);
+
+        $this->onParametersChange->onChange($parameters, $context);
+    }
+
+    public function testOnChangeCustomDomainChanged(): void
+    {
+        $this->messageBus
+            ->expects(self::once())
+            ->method('dispatch')
+            ->with(self::isInstanceOf(Typo3UpdateCommand::class))
+            ->willReturn(new Envelope(new Typo3UpdateCommand(1)));
+
+        $previousParameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $previousParameters->method('getId')->willReturn(1);
+        $previousParameters->method('getAverage')->willReturn(20);
+        $previousParameters->method('getDesactivateOpentalentSiteWeb')->willReturn(false);
+        $previousParameters->expects(self::once())->method('getCustomDomain')->willReturn(null);
+
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+        $context->method('previousData')->willReturn($previousParameters);
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->method('getId')->willReturn(1);
+
+        $parameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $parameters->method('getId')->willReturn(1);
+        $parameters->method('getOrganization')->willReturn($organization);
+        $parameters->method('getDesactivateOpentalentSiteWeb')->willReturn(false);
+        $parameters->expects(self::once())->method('getCustomDomain')->willReturn('custom');
+        $parameters->method('getAverage')->willReturn(20);
+
+        $this->onParametersChange->onChange($parameters, $context);
+    }
+
+    public function testOnChangeWebsiteDisabled(): void
+    {
+        $this->messageBus
+            ->expects(self::once())
+            ->method('dispatch')
+            ->with(self::isInstanceOf(Typo3DeleteCommand::class))
+            ->willReturn(new Envelope(new Typo3DeleteCommand(1)));
+
+        $previousParameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $previousParameters->method('getId')->willReturn(1);
+        $previousParameters->method('getAverage')->willReturn(20);
+        $previousParameters->expects(self::once())->method('getDesactivateOpentalentSiteWeb')->willReturn(false);
+        $previousParameters->method('getCustomDomain')->willReturn(null);
+
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+        $context->method('previousData')->willReturn($previousParameters);
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->method('getId')->willReturn(1);
+
+        $parameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $parameters->method('getId')->willReturn(1);
+        $parameters->method('getOrganization')->willReturn($organization);
+        $parameters->method('getDesactivateOpentalentSiteWeb')->willReturn(true);
+        $parameters->method('getCustomDomain')->willReturn(null);
+        $parameters->method('getAverage')->willReturn(20);
+
+        $this->onParametersChange->onChange($parameters, $context);
+    }
+
+    public function testOnChangeWebsiteEnabled(): void
+    {
+        $this->messageBus
+            ->expects(self::exactly(2))
+            ->method('dispatch')
+            ->willReturnCallback(function ($message, $stamps = []) {
+                if ($message instanceof Typo3UndeleteCommand) {
+                    return new Envelope(new Typo3UndeleteCommand(1));
+                }
+                if($message instanceof Typo3UpdateCommand) {
+                    return new Envelope(new Typo3UpdateCommand(1));
+                }
+                throw new AssertionError('unexpected message : ' . $message::class);
+            });
+
+        $previousParameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $previousParameters->method('getId')->willReturn(1);
+        $previousParameters->method('getAverage')->willReturn(20);
+        $previousParameters->expects(self::once())->method('getDesactivateOpentalentSiteWeb')->willReturn(true);
+        $previousParameters->method('getCustomDomain')->willReturn(null);
+
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+        $context->method('previousData')->willReturn($previousParameters);
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->method('getId')->willReturn(1);
+
+        $parameters = $this->getMockBuilder(Parameters::class)->disableOriginalConstructor()->getMock();
+        $parameters->method('getId')->willReturn(1);
+        $parameters->method('getOrganization')->willReturn($organization);
+        $parameters->method('getDesactivateOpentalentSiteWeb')->willReturn(false);
+        $parameters->method('getCustomDomain')->willReturn(null);
+        $parameters->method('getAverage')->willReturn(20);
+
+        $this->onParametersChange->onChange($parameters, $context);
     }
 
     /**
      * @see OnParametersChange::onAdvancedEducationNotationTypeChange()
      */
-    public function testOnAdvancedEducationNotationTypeByTeachersChange(){
+    public function testOnAdvancedEducationNotationTypeByTeachersChange(): void
+    {
         $educationNotationConfig = new EducationNotationConfig();
         $educationCurriculum = new EducationCurriculum();
         $educationNotationConfig->addEducationCurriculum($educationCurriculum);
@@ -47,7 +283,8 @@ class OnParametersChangeTest extends TestCase
     /**
      * @see OnParametersChange::onAdvancedEducationNotationTypeChange()
      */
-    public function testOnAdvancedEducationNotationTypeByEducationChange(){
+    public function testOnAdvancedEducationNotationTypeByEducationChange(): void
+    {
         $educationNotationConfig = new EducationNotationConfig();
         $teacher = new Access();
         $educationNotationConfig->addTeacher($teacher);
@@ -64,15 +301,19 @@ class OnParametersChangeTest extends TestCase
     }
 
     /**
-     * Un cours qui débute le 2/09/2022, si l'année musical passe du 05/09 au 01/09 alors le cours passe de l'année 2021/2022 à 2022/2023
+     * Un cours qui débute le 02/09/2022, si l'année musical passe du 05/09 au 01/09 alors le cours passe de l'année 2021/2022 à 2022/2023
      * @throws \Exception
      * @see OnParametersChange::onMusicalDateChange()
      */
-    public function testOnMusicalDateChange(){
-        $organization = new Organization();
+    public function testOnMusicalDateChangeToPast(): void
+    {
         $this->parameters->setMusicalDate(new \DateTime('2022-09-01'));
+
+        $organization = new Organization();
+        $this->parameters->setOrganization($organization);
         $organization->setParameters($this->parameters);
 
+        $this->organizationUtils->expects(self::once())->method('getActivityYearSwitchDate')->willReturn(2022);
 
         $course = new Course();
         $course->setStartYear(2021);
@@ -84,9 +325,41 @@ class OnParametersChangeTest extends TestCase
             ->willReturn([$course])
         ;
 
-        $this->onParametersChange->onMusicalDateChange($organization, new \DateTime('2022-09-05'));
+        $this->onParametersChange->onMusicalDateChange($this->parameters, new \DateTime('2022-09-05'));
 
         $this->assertEquals(2022, $course->getStartYear());
         $this->assertEquals(2023, $course->getEndYear());
     }
-}
+
+    /**
+     * Un cours qui débute le 02/09/2022, si l'année musical passe du 01/09 au 05/09 alors le cours passe de l'année 2022/2023 à 2021/2022
+     *
+     * @throws \Exception
+     * @see OnParametersChange::onMusicalDateChange()
+     */
+    public function testOnMusicalDateChangeToFuture(): void
+    {
+        $this->parameters->setMusicalDate(new \DateTime('2022-09-05'));
+
+        $organization = new Organization();
+        $this->parameters->setOrganization($organization);
+        $organization->setParameters($this->parameters);
+
+        $this->organizationUtils->expects(self::once())->method('getActivityYearSwitchDate')->willReturn(2021);
+
+        $course = new Course();
+        $course->setStartYear(2022);
+        $course->setEndYear(2023);
+        $course->setDatetimeStart(new \DateTime('2022-09-02'));
+
+        $this->courseRepositoryMock
+            ->method('getCoursesToFrom')
+            ->willReturn([$course])
+        ;
+
+        $this->onParametersChange->onMusicalDateChange($this->parameters, new \DateTime('2022-09-01'));
+
+        $this->assertEquals(2021, $course->getStartYear());
+        $this->assertEquals(2022, $course->getEndYear());
+    }
+}

+ 227 - 0
tests/Service/OnChange/Organization/OnSubdomainChangeTest.php

@@ -0,0 +1,227 @@
+<?php
+
+namespace App\Test\Service\OnChange\Organization;
+
+use App\Entity\Access\Access;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\Subdomain;
+use App\Message\Command\Typo3\Typo3UpdateCommand;
+use App\Service\MailHub;
+use App\Service\OnChange\OnChangeContext;
+use App\Service\OnChange\Organization\OnSubdomainChange;
+use App\Service\Typo3\BindFileService;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Messenger\Envelope;
+use Symfony\Component\Messenger\MessageBusInterface;
+
+class OnSubdomainChangeTest extends TestCase
+{
+    private \App\Service\Organization\Utils $organizationUtils;
+    private \App\Service\Access\Utils $accessUtils;
+    private MailHub $mailHub;
+    private BindFileService $bindFileService;
+    private MessageBusInterface $messageBus;
+    private EntityManagerInterface $entityManager;
+    private OnSubdomainChange $onSubdomainChange;
+
+    public function setUp():void
+    {
+        $this->organizationUtils = $this->getMockBuilder(\App\Service\Organization\Utils::class)->disableOriginalConstructor()->getMock();
+        $this->accessUtils = $this->getMockBuilder(\App\Service\Access\Utils::class)->disableOriginalConstructor()->getMock();
+        $this->mailHub = $this->getMockBuilder(MailHub::class)->disableOriginalConstructor()->getMock();
+        $this->bindFileService = $this->getMockBuilder(BindFileService::class)->disableOriginalConstructor()->getMock();
+        $this->messageBus = $this->getMockBuilder(MessageBusInterface::class)->disableOriginalConstructor()->getMock();
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+
+        $this->onSubdomainChange = new OnSubdomainChange(
+            $this->organizationUtils,
+            $this->accessUtils,
+            $this->mailHub,
+            $this->bindFileService,
+            $this->messageBus,
+            $this->entityManager
+        );
+    }
+
+    public function testValidateIsOk(): void
+    {
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+        $context->method('isPostRequest')->willReturn(true);
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->expects(self::once())->method('getSubdomains')->willReturn(new \Doctrine\Common\Collections\ArrayCollection([1,2]));
+
+        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $subdomain->expects(self::once())->method('getOrganization')->willReturn($organization);
+
+        $this->onSubdomainChange->validate($subdomain, $context);
+    }
+
+    public function testValidateIsPutRequest(): void
+    {
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+        $context->method('isPostRequest')->willReturn(false);
+
+        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $subdomain->expects(self::never())->method('getOrganization');
+
+        $this->onSubdomainChange->validate($subdomain, $context);
+    }
+
+    public function testValidateMaxReached(): void
+    {
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+        $context->method('isPostRequest')->willReturn(true);
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->expects(self::once())->method('getSubdomains')->willReturn(new \Doctrine\Common\Collections\ArrayCollection([1,2,3]));
+
+        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $subdomain->expects(self::once())->method('getOrganization')->willReturn($organization);
+
+        try {
+            $this->onSubdomainChange->validate($subdomain, $context);
+            throw new \AssertionError('A validation error should have been thrown');
+        } catch (\RuntimeException) {}
+    }
+
+    public function testOnChangeNoChange(): void
+    {
+        $onChange =  $this
+            ->getMockBuilder(OnSubdomainChange::class)
+            ->onlyMethods(['sendEmailAfterSubdomainChange'])
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $this->bindFileService->expects(self::never())->method('registerSubdomain');
+        $this->entityManager->expects(self::never())->method('flush');
+        $this->messageBus->expects(self::never())->method('dispatch');
+        $onChange->expects(self::never())->method('sendEmailAfterSubdomainChange');
+
+        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+
+        $onChange->onChange($subdomain, $context);
+    }
+
+    public function testOnChangeActivated(): void {
+        $this->bindFileService->expects(self::never())->method('registerSubdomain');
+        $this->entityManager->expects(self::once())->method('flush');
+        $this->messageBus
+            ->expects(self::once())
+            ->method('dispatch')
+            ->with(self::isInstanceOf(Typo3UpdateCommand::class))
+            ->willReturn(new Envelope(new Typo3UpdateCommand(1)));
+
+        $onChange = $this
+            ->getMockBuilder(OnSubdomainChange::class)
+            ->onlyMethods(['sendEmailAfterSubdomainChange'])
+            ->setConstructorArgs(
+                [$this->organizationUtils, $this->accessUtils, $this->mailHub, $this->bindFileService, $this->messageBus, $this->entityManager]
+            )
+            ->getMock();
+        $onChange->expects(self::once())->method('sendEmailAfterSubdomainChange');
+
+        // Le sous-domaine qu'on vient d'activer
+        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $subdomain->method('isActive')->willReturn(true);
+
+        // Son état précédent
+        $previousData = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $previousData->method('isActive')->willReturn(false);
+
+        // Le sous domaine qui était actif jusqu'ici, et que le OnChange devrait désactiver
+        $otherSubdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $otherSubdomain->method('isActive')->willReturn(true);
+        $otherSubdomain->expects(self::once())->method('setActive')->with(false);
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->method('getId')->willReturn(1);
+        $organization->expects(self::once())->method('getSubdomains')->willReturn(new \Doctrine\Common\Collections\ArrayCollection([$subdomain, $otherSubdomain]));
+
+        $subdomain->expects(self::exactly(2))->method('getOrganization')->willReturn($organization);
+
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+        $context->method('previousData')->willReturn($previousData);
+        $context->method('isPutRequest')->willReturn(true);
+        $context->method('isPostRequest')->willReturn(false);
+
+        $onChange->onChange($subdomain, $context);
+    }
+
+    public function testOnChangeCreated(): void {
+        $this->bindFileService->expects(self::once())->method('registerSubdomain');
+        $this->entityManager->expects(self::once())->method('flush');
+        $this->messageBus
+            ->expects(self::once())
+            ->method('dispatch')
+            ->with(self::isInstanceOf(Typo3UpdateCommand::class))
+            ->willReturn(new Envelope(new Typo3UpdateCommand(1)));
+
+        $onChange = $this
+            ->getMockBuilder(OnSubdomainChange::class)
+            ->onlyMethods(['sendEmailAfterSubdomainChange'])
+            ->setConstructorArgs(
+                [$this->organizationUtils, $this->accessUtils, $this->mailHub, $this->bindFileService, $this->messageBus, $this->entityManager]
+            )
+            ->getMock();
+        $onChange->expects(self::once())->method('sendEmailAfterSubdomainChange');
+
+        // Le sous-domaine qu'on vient d'activer
+        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $subdomain->method('isActive')->willReturn(true);
+
+        // Le sous domaine qui était actif jusqu'ici, et que le OnChange devrait désactiver
+        $otherSubdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $otherSubdomain->method('isActive')->willReturn(true);
+        $otherSubdomain->expects(self::once())->method('setActive')->with(false);
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->method('getId')->willReturn(1);
+        $organization->expects(self::once())->method('getSubdomains')->willReturn(new \Doctrine\Common\Collections\ArrayCollection([$subdomain, $otherSubdomain]));
+
+        $subdomain->expects(self::exactly(2))->method('getOrganization')->willReturn($organization);
+
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+        $context->method('previousData')->willReturn(null);
+        $context->method('isPutRequest')->willReturn(false);
+        $context->method('isPostRequest')->willReturn(true);
+
+        $onChange->onChange($subdomain, $context);
+    }
+
+    public function testSendEmailAfterSubdomainChange(): void {
+        $admin = $this->getMockBuilder(Access::class)->disableOriginalConstructor()->getMock();
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+
+        $subdomain = $this->getMockBuilder(Subdomain::class)->disableOriginalConstructor()->getMock();
+        $subdomain->expects(self::once())->method('getSubdomain')->willReturn('mysubdomain');
+        $subdomain->expects(self::exactly(3))->method('getOrganization')->willReturn($organization);
+
+        $this->accessUtils->expects(self::once())->method('findAdminFor')->with($organization)->willReturn($admin);
+
+        $this->organizationUtils
+            ->expects(self::once())
+            ->method('getOrganizationWebsite')
+            ->with($organization)
+            ->willReturn('mysubdomain.opentalent.fr');
+
+        $this->mailHub
+            ->expects(self::once())
+            ->method('sendAutomaticEmailToAdmin')
+            ->with(
+                $organization,
+                'Nouveau sous domaine: mysubdomain',
+                'subdomain',
+                [
+                    'access' => $admin,
+                    'subdomain' => $subdomain,
+                    'url' => 'mysubdomain.opentalent.fr'
+                ]
+            );
+
+        $this->onSubdomainChange->sendEmailAfterSubdomainChange($subdomain);
+    }
+}

+ 7 - 2
tests/Service/Organization/OrganizationProfileCreatorTest.php

@@ -5,6 +5,7 @@ use App\ApiResources\Profile\OrganizationProfile;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Parameters;
 use App\Entity\Organization\Settings;
+use App\Entity\Organization\Subdomain;
 use App\Enum\Organization\PrincipalTypeEnum;
 use App\Service\Network\Tree;
 use App\Service\Organization\OrganizationProfileCreator;
@@ -37,17 +38,21 @@ class OrganizationProfileCreatorTest extends TestCase
             ->with($this->organization)
             ->willReturn([$parent, $parent]);
 
-        $this->organizationProfileCreator = new OrganizationProfileCreator($this->moduleMock,$this->treeMock);
+        $organizationUtils = new \App\Service\Organization\Utils();
+
+        $this->organizationProfileCreator = new OrganizationProfileCreator($this->moduleMock,$this->treeMock, $organizationUtils);
 
         $this->organization->setPrincipalType(PrincipalTypeEnum::ARTISTIC_EDUCATION_ONLY());
         $settings = new Settings();
         $settings->setProduct('adminassos');
         $parameters = new Parameters();
         $parameters->setShowAdherentList(true);
+        $subdomain = new Subdomain();
 
         $this->organization
             ->setParameters($parameters)
             ->setSettings($settings)
+            ->addSubdomain($subdomain)
             ->setName('Foo')
         ;
     }
@@ -77,4 +82,4 @@ class OrganizationProfileCreatorTest extends TestCase
         $organizationProfile = $this->organizationProfileCreator->createCompleteOrganizationProfile($this->organization);
         $this->assertTrue($organizationProfile->getShowAdherentList());
     }
-}
+}

+ 8 - 6
tests/Service/Organization/UtilsTest.php

@@ -101,9 +101,9 @@ class UtilsTest extends TestCase
 
         $today = new \DateTime('now');
         if($today->format('m') < 9)
-            $this->assertEquals( ($today->format('Y') - 1), Utils::getOrganizationCurrentActivityYear($this->organization));
+            $this->assertEquals( ($today->format('Y') - 1), $this->organizationUtils->getOrganizationCurrentActivityYear($this->organization));
         else
-            $this->assertEquals($today->format('Y'), Utils::getOrganizationCurrentActivityYear($this->organization));
+            $this->assertEquals($today->format('Y'), $this->organizationUtils->getOrganizationCurrentActivityYear($this->organization));
     }
 
     /**
@@ -113,7 +113,9 @@ class UtilsTest extends TestCase
         $parameters = new Parameters();
         $parameters->setMusicalDate(new \DateTime('2020-09-05'));
         $this->organization->setParameters($parameters);
-        $periods = Utils::getActivityPeriodsSwitchYear($this->organization, 2022);
+
+        $organizationUtils = new \App\Service\Organization\Utils();
+        $periods = $organizationUtils->getActivityPeriodsSwitchYear($this->organization, 2022);
 
         $this->assertEquals('2022-09-05', $periods['dateStart']);
         $this->assertEquals('2023-09-04', $periods['dateEnd']);
@@ -127,7 +129,7 @@ class UtilsTest extends TestCase
         $parameters->setMusicalDate(new \DateTime('2020-09-05'));
         $this->organization->setParameters($parameters);
 
-        $this->assertEquals(2022, Utils::getActivityYearSwitchDate($this->organization, new \DateTime('2022-09-10')));
-        $this->assertEquals(2021, Utils::getActivityYearSwitchDate($this->organization, new \DateTime('2022-09-02')));
+        $this->assertEquals(2022, $this->organizationUtils->getActivityYearSwitchDate($this->organization, new \DateTime('2022-09-10')));
+        $this->assertEquals(2021, $this->organizationUtils->getActivityYearSwitchDate($this->organization, new \DateTime('2022-09-02')));
     }
-}
+}

+ 145 - 20
tests/Service/Rest/ApiRequestServiceTest.php

@@ -3,44 +3,169 @@
 namespace App\Tests\Service;
 
 use App\Service\Rest\ApiRequestService;
+use AssertionError;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpClient\Exception\TransportException;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 use Symfony\Contracts\HttpClient\ResponseInterface;
+use Symfony\Component\HttpKernel\Exception\HttpException;
 
 class ApiRequestServiceTest extends TestCase
 {
-    private ApiRequestService $apiRequestService;
+    private HttpClientInterface $client;
 
     public function setUp(): void {
-        $client = $this->getMockBuilder(HttpClientInterface::class)
-            ->disableOriginalConstructor()
+        $this->client = $this->getMockBuilder(HttpClientInterface::class)->disableOriginalConstructor()->getMock();
+    }
+
+    public function testGetJsonContent() {
+        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+            ->setConstructorArgs([$this->client])
+            ->onlyMethods(['getContent'])
+            ->getMock();
+
+        $apiRequestService->expects(self::once())
+            ->method('getContent')
+            ->with('path/to/data', [], [])
+            ->willReturn('{"foo": "bar"}');
+
+        $data = $apiRequestService->getJsonContent('path/to/data');
+
+        $this->assertEquals(['foo' => 'bar'], $data);
+    }
+
+    public function testGetContent() {
+        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+            ->setConstructorArgs([$this->client])
+            ->onlyMethods(['get'])
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $response->expects(self::once())->method('getContent')->willReturn('{foo: bar}');
+
+        $apiRequestService->expects(self::once())
+            ->method('get')
+            ->with('path/to/data', [], [])
+            ->willReturn($response);
+
+        $content = $apiRequestService->getContent('path/to/data');
+
+        $this->assertEquals('{foo: bar}', $content);
+    }
+
+    public function testGetContentWithError() {
+        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+            ->setConstructorArgs([$this->client])
+            ->onlyMethods(['get'])
             ->getMock();
 
-        $response = $this->getMockBuilder(ResponseInterface::class)
-            ->disableOriginalConstructor()
+        $apiRequestService->expects(self::once())
+            ->method('get')
+            ->willThrowException(new TransportException());
+
+        try {
+            $apiRequestService->getContent('path/to/data');
+            throw new AssertionError('An HttpException should have been thrown, but has not');
+        } catch (HttpException $e) {
+            $this->assertEquals(500, $e->getStatusCode());
+        }
+    }
+
+    public function testGet() {
+        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+            ->setConstructorArgs([$this->client])
+            ->onlyMethods(['request'])
             ->getMock();
-        $response->method('getContent')->willReturn('{"a": 1}');
 
-        $client
-            ->expects($this->once())
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+
+        $apiRequestService->expects(self::once())
             ->method('request')
-            ->with("GET", "my_url.org")
+            ->with('GET', 'path/to/data', [], [])
             ->willReturn($response);
 
-        $this->apiRequestService = new ApiRequestService($client);
+        $actualResponse = $apiRequestService->get('path/to/data');
+
+        $this->assertEquals($response, $actualResponse);
     }
 
-    public function testGetJsonContent() {
-        $this->assertEquals(
-            ['a' => 1],
-            $this->apiRequestService->getJsonContent('my_url.org')
-        );
+    public function testPost() {
+        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+            ->setConstructorArgs([$this->client])
+            ->onlyMethods(['request'])
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+
+        $apiRequestService->expects(self::once())
+            ->method('request')
+            ->with('POST', 'path/to/data', [], [])
+            ->willReturn($response);
+
+        $actualResponse = $apiRequestService->post('path/to/data');
+
+        $this->assertEquals($response, $actualResponse);
     }
 
-    public function testGetContent() {
-        $this->assertEquals(
-            '{"a": 1}',
-            $this->apiRequestService->getContent('my_url.org')
-        );
+    public function testPut() {
+        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+            ->setConstructorArgs([$this->client])
+            ->onlyMethods(['request'])
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+
+        $apiRequestService->expects(self::once())
+            ->method('request')
+            ->with('PUT', 'path/to/data', [], [])
+            ->willReturn($response);
+
+        $actualResponse = $apiRequestService->put('path/to/data');
+
+        $this->assertEquals($response, $actualResponse);
+    }
+
+    public function testDelete() {
+        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+            ->setConstructorArgs([$this->client])
+            ->onlyMethods(['request'])
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+
+        $apiRequestService->expects(self::once())
+            ->method('request')
+            ->with('DELETE', 'path/to/data', [], [])
+            ->willReturn($response);
+
+        $actualResponse = $apiRequestService->delete('path/to/data');
+
+        $this->assertEquals($response, $actualResponse);
+    }
+
+    public function testRequest() {
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+
+        $this->client->expects(self::once())->method('request')->with('GET', 'path/to/data?param=1', [])->willReturn($response);
+
+        $apiRequestService = new ApiRequestService($this->client);
+
+        $actualResponse = $apiRequestService->request('GET', 'path/to/data', ['param' => 1]);
+
+        $this->assertEquals($response, $actualResponse);
+    }
+
+    public function testRequestWithError() {
+
+        $this->client->expects(self::once())->method('request')->willThrowException(new TransportException('error', 500));
+
+        $apiRequestService = new ApiRequestService($this->client);
+
+        try {
+            $apiRequestService->request('GET', 'path/to/data');
+            throw new AssertionError('An HttpException should have been thrown, but has not');
+        } catch (HttpException $e) {
+            $this->assertEquals(500, $e->getStatusCode());
+        }
     }
 }

+ 26 - 2
tests/Service/Security/ModuleTest.php

@@ -5,6 +5,7 @@ use App\Entity\Organization\Organization;
 use App\Entity\Organization\Settings;
 use App\Service\Utils\Parser;
 use App\Service\Utils\Reflection;
+use Doctrine\Common\Collections\ArrayCollection;
 use PHPUnit\Framework\TestCase;
 use App\Service\Security\Module;
 
@@ -18,7 +19,30 @@ class ModuleTest extends TestCase
     public function setUp():void
     {
         $this->reflectionMock = $this->getMockBuilder(Reflection::class)->disableOriginalConstructor()->getMock();
-        $this->parser= new Parser();
+        $this->parser = new Parser();
+    }
+
+    public function testGetOrganizationModules() {
+        $module = $this->getMockBuilder(Module::class)
+            ->onlyMethods(['getModuleBySettings', 'getModulesByConditions', 'getModulesByProductConfiguration'])
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $module->expects(self::once())->method('getModuleBySettings')->willReturn(['Sms']);
+        $module->expects(self::once())->method('getModulesByConditions')->willReturn(['CotisationCall']);
+
+        $settings = $this->getMockBuilder(Settings::class)->disableOriginalConstructor()->getMock();
+        $settings->expects(self::once())->method('getProduct')->willReturn('school');
+
+        $organization = $this->getMockBuilder(Organization::class)->disableOriginalConstructor()->getMock();
+        $organization->expects(self::once())->method('getSettings')->willReturn($settings);
+
+        $module->expects(self::once())->method('getModulesByProductConfiguration')->willReturn(['Notification']);
+
+        $this->assertEqualsCanonicalizing(
+            ['Sms', 'CotisationCall', 'Notification'],
+            $module->getOrganizationModules($organization)
+        );
     }
 
     /**
@@ -76,4 +100,4 @@ class ModuleTest extends TestCase
         // assert function to test whether 'value' is a value of array
         $this->assertContains($value, $module->getModulesByProductConfiguration('artist-premium')) ;
     }
-}
+}

+ 171 - 0
tests/Service/Typo3/Typo3ServiceTest.php

@@ -0,0 +1,171 @@
+<?php
+
+namespace App\Test\Service\Typo3;
+
+use App\Service\Typo3\Typo3Service;
+use PHPUnit\Framework\TestCase;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+class TestableTypo3Service extends Typo3Service {
+    public function sendCommand(string $route, array $parameters): ResponseInterface { return parent::sendCommand($route, $parameters); }
+}
+
+class Typo3ServiceTest extends TestCase
+{
+    private HttpClientInterface $typo3Client;
+
+    public function setUp(): void {
+        $this->typo3Client = $this->getMockBuilder(HttpClientInterface::class)->disableOriginalConstructor()->getMock();
+    }
+
+    public function testSendCommand(): void
+    {
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+
+        $this->typo3Client
+            ->expects(self::once())
+            ->method('request')
+            ->with('GET', '/typo3/index.php?route=foo&param=bar')
+            ->willReturn($response);
+
+        $typo3Service = new TestableTypo3Service($this->typo3Client);
+
+        $typo3Service->sendCommand('foo', ['param' => 'bar']);
+    }
+
+    public function testCreateSite() {
+        $typo3Service = $this->getMockBuilder(Typo3Service::class)
+            ->onlyMethods(['sendCommand'])
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $typo3Service->expects(self::once())
+            ->method('sendCommand')->
+            with('/otadmin/site/create', ['organization-id' => 1])
+            ->willReturn($response);
+
+        $typo3Service->createSite(1);
+    }
+
+    public function testClearSiteCache() {
+        $typo3Service = $this->getMockBuilder(Typo3Service::class)
+            ->onlyMethods(['sendCommand'])
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $typo3Service->expects(self::once())
+            ->method('sendCommand')->
+            with('/otadmin/site/update', ['organization-id' => 1])
+            ->willReturn($response);
+
+        $typo3Service->updateSite(1);
+    }
+
+    public function testDeleteSite() {
+        $typo3Service = $this->getMockBuilder(Typo3Service::class)
+            ->onlyMethods(['sendCommand'])
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $typo3Service->expects(self::once())
+            ->method('sendCommand')->
+            with('/otadmin/site/delete', ['organization-id' => 1])
+            ->willReturn($response);
+
+        $typo3Service->deleteSite(1);
+    }
+
+    public function testUndeleteSite() {
+        $typo3Service = $this->getMockBuilder(Typo3Service::class)
+            ->onlyMethods(['sendCommand'])
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $typo3Service->expects(self::once())
+            ->method('sendCommand')->
+            with('/otadmin/site/undelete', ['organization-id' => 1])
+            ->willReturn($response);
+
+        $typo3Service->undeleteSite(1);
+    }
+
+    public function testSetSiteDomain() {
+        $typo3Service = $this->getMockBuilder(Typo3Service::class)
+            ->onlyMethods(['sendCommand'])
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $typo3Service->expects(self::once())
+            ->method('sendCommand')->
+            with('/otadmin/site/set-domain', ['organization-id' => 1, 'domain' => 'new-domain'])
+            ->willReturn($response);
+
+        $typo3Service->setSiteDomain(1, 'new-domain', false);
+    }
+
+    public function testSetSiteDomainWithRedirection() {
+        $typo3Service = $this->getMockBuilder(Typo3Service::class)
+            ->onlyMethods(['sendCommand'])
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $typo3Service->expects(self::once())
+            ->method('sendCommand')->
+            with('/otadmin/site/set-domain', ['organization-id' => 1, 'domain' => 'new-domain', 'redirect' => 1])
+            ->willReturn($response);
+
+        $typo3Service->setSiteDomain(1, 'new-domain', true);
+    }
+
+    public function testResetSitePerms() {
+        $typo3Service = $this->getMockBuilder(Typo3Service::class)
+            ->onlyMethods(['sendCommand'])
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $typo3Service->expects(self::once())
+            ->method('sendCommand')->
+            with('/otadmin/site/reset-perms', ['organization-id' => 1])
+            ->willReturn($response);
+
+        $typo3Service->resetSitePerms(1);
+    }
+
+    public function testGetSiteStatus() {
+        $typo3Service = $this->getMockBuilder(Typo3Service::class)
+            ->onlyMethods(['sendCommand'])
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $typo3Service->expects(self::once())
+            ->method('sendCommand')->
+            with('/otadmin/site/status', ['organization-id' => 1])
+            ->willReturn($response);
+
+        $typo3Service->getSiteStatus(1);
+    }
+
+    public function testAddRedirection() {
+        $typo3Service = $this->getMockBuilder(Typo3Service::class)
+            ->onlyMethods(['sendCommand'])
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $typo3Service->expects(self::once())
+            ->method('sendCommand')->
+            with('/otadmin/redirect/add', ['from-domain' => 'foo', 'to-domain' => 'bar'])
+            ->willReturn($response);
+
+        $typo3Service->addRedirection('foo', 'bar');
+    }
+}

+ 3 - 3
tests/Service/Utils/UrlBuilderTest.php

@@ -40,15 +40,15 @@ class UrlBuilderTest extends TestCase
     public function testPrependHttps() {
         $this->assertEquals(
             'https://domain.org/abc',
-            UrlBuilder::preprendHttps('https://domain.org/abc')
+            UrlBuilder::prependHttps('https://domain.org/abc')
         );
         $this->assertEquals(
             'http://domain.org/abc',
-            UrlBuilder::preprendHttps('http://domain.org/abc')
+            UrlBuilder::prependHttps('http://domain.org/abc')
         );
         $this->assertEquals(
             'https://domain.org/abc',
-            UrlBuilder::preprendHttps('domain.org/abc')
+            UrlBuilder::prependHttps('domain.org/abc')
         );
     }
 

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.