Explorar el Código

Merge branch 'develop' into feature/freemium

Vincent hace 4 meses
padre
commit
ca1ce30886

+ 4 - 1
src/Entity/Access/Access.php

@@ -63,6 +63,7 @@ use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Filter\ApiPlatform\Person\FullNameFilter;
 use App\Filter\ApiPlatform\Utils\InFilter;
 use App\Repository\Access\AccessRepository;
+use App\State\Processor\Access\AccessProcessor;
 use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
@@ -80,7 +81,9 @@ use Symfony\Component\Serializer\Annotation\Groups;
  *     @see ~/config/api_platform/Access/access.yaml
  *     @see \App\Doctrine\Access\CurrentAccessExtension
  */
-#[ApiResource]
+#[ApiResource(
+    processor: AccessProcessor::class
+)]
 #[Auditable]
 #[ORM\Entity(repositoryClass: AccessRepository::class)]
 #[ApiFilter(filterClass: BooleanFilter::class, properties: ['person.isPhysical'])]

+ 2 - 2
src/Entity/Access/Preferences.php

@@ -7,7 +7,7 @@ namespace App\Entity\Access;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 use ApiPlatform\Metadata\ApiResource;
 use ApiPlatform\Metadata\Get;
-use ApiPlatform\Metadata\Put;
+use ApiPlatform\Metadata\Patch;
 use Doctrine\ORM\Mapping as ORM;
 
 /**
@@ -20,7 +20,7 @@ use Doctrine\ORM\Mapping as ORM;
         new Get(
             security: 'object.getAccess().getId() == user.getId()'
         ),
-        new Put(
+        new Patch(
             security: 'object.getAccess().getId() == user.getId()'
         ),
     ]

+ 24 - 10
src/Entity/Core/File.php

@@ -23,6 +23,7 @@ use App\Entity\Organization\OnlineRegistrationSettings;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Parameters;
 use App\Entity\Person\Person;
+use App\Enum\Core\FileFolderEnum;
 use App\Enum\Core\FileHostEnum;
 use App\Enum\Core\FileStatusEnum;
 use App\Enum\Core\FileTypeEnum;
@@ -82,12 +83,6 @@ class File
     #[ORM\Column(length: 255, nullable: true)]
     private ?string $slug;
 
-    /**
-     * Chemin d'accès du fichier.
-     */
-    #[ORM\Column(length: 255, nullable: true)]
-    private ?string $path;
-
     /**
      * Nom du fichier.
      */
@@ -168,6 +163,12 @@ class File
     #[ORM\Column(length: 5, enumType: FileHostEnum::class, options: ['default' => FileHostEnum::AP2I])]
     private FileHostEnum $host = FileHostEnum::AP2I;
 
+    /**
+     * Dossier dans lequel le fichier est stocké, conçu en prévision de la gestion documentaire.
+     */
+    #[ORM\Column(length: 20, enumType: FileFolderEnum::class, options: ['default' => FileFolderEnum::UNRATED], nullable: true)]
+    private ?FileFolderEnum $folder = FileFolderEnum::UNRATED;
+
     //    #[ORM\Column]
     //    private ?int $eventReport_id;
 
@@ -312,16 +313,17 @@ class File
         return $this;
     }
 
+    /**
+     * @deprecated
+     */
     public function getPath(): ?string
     {
-        return $this->path;
+        return $this->slug;
     }
 
     public function setPath(?string $path): self
     {
-        $this->path = $path;
-
-        return $this;
+        throw new \Exception('Deprecated : use setSlug instead');
     }
 
     public function getName(): string
@@ -513,6 +515,18 @@ class File
         return $this;
     }
 
+    public function getFolder(): ?FileFolderEnum
+    {
+        return $this->folder;
+    }
+
+    public function setFolder(?FileFolderEnum $folder): self
+    {
+        $this->folder = $folder;
+
+        return $this;
+    }
+
     public function getOrganizationLogos(): Collection
     {
         return $this->organizationLogos;

+ 15 - 0
src/Entity/Organization/Parameters.php

@@ -211,6 +211,9 @@ class Parameters
     #[ORM\Column(options: ['default' => false])]
     private bool $showEducationIsACollectivePractice = false;
 
+    #[ORM\Column(options: ['default' => false])]
+    private bool $handlePresence = false;
+
     #[Pure]
     public function __construct()
     {
@@ -854,4 +857,16 @@ class Parameters
 
         return $this;
     }
+
+    public function isHandlePresence(): bool
+    {
+        return $this->handlePresence;
+    }
+
+    public function setHandlePresence(bool $handlePresence): self
+    {
+        $this->handlePresence = $handlePresence;
+
+        return $this;
+    }
 }

+ 20 - 0
src/Enum/Core/FileFolderEnum.php

@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enum\Core;
+
+use App\Enum\EnumMethodsTrait;
+
+/**
+ * Type of folder for document management.
+ */
+enum FileFolderEnum: string
+{
+    use EnumMethodsTrait;
+
+    case DOCUMENTS = 'DOCUMENTS';
+    case IMAGES = 'IMAGES';
+    case PREVIEW = 'PREVIEW';
+    case UNRATED = 'UNRATED';
+}

+ 1 - 1
src/Service/Cron/Job/CleanTempFiles.php

@@ -61,7 +61,7 @@ class CleanTempFiles extends BaseCronJob
             if (($total - $i) === 50) {
                 $this->ui->print('  (...)');
             }
-            $this->ui->print('  * '.$file->getPath());
+            $this->ui->print('  * '.$file->getSlug());
         }
     }
 

+ 8 - 3
src/Service/Export/BaseExporter.php

@@ -6,6 +6,7 @@ namespace App\Service\Export;
 
 use App\ApiResources\Export\ExportRequest;
 use App\Entity\Core\File;
+use App\Enum\Core\FileFolderEnum;
 use App\Enum\Core\FileTypeEnum;
 use App\Enum\Core\FileVisibilityEnum;
 use App\Repository\Access\AccessRepository;
@@ -128,8 +129,11 @@ abstract class BaseExporter
     /**
      * Create a pending file record in the database.
      */
-    public function prepareFile(ExportRequest $exportRequest, bool $flushFile = true): File
-    {
+    public function prepareFile(
+        ExportRequest $exportRequest,
+        bool $flushFile = true,
+        ?FileFolderEnum $folder = FileFolderEnum::DOCUMENTS,
+    ): File {
         $requesterId = $exportRequest->getRequesterId();
         $requester = $this->accessRepository->find($requesterId);
         if ($requester === null) {
@@ -149,7 +153,8 @@ abstract class BaseExporter
             true,
             FileVisibilityEnum::NOBODY,
             $this->fileUtils->getMimeTypeFromExt($exportRequest->getFormat()->value),
-            $flushFile
+            $flushFile,
+            $folder
         );
     }
 

+ 2 - 1
src/Service/Export/ExporterInterface.php

@@ -6,6 +6,7 @@ namespace App\Service\Export;
 
 use App\ApiResources\Export\ExportRequest;
 use App\Entity\Core\File;
+use App\Enum\Core\FileFolderEnum;
 use App\Service\Export\Model\ExportModelInterface;
 
 /**
@@ -27,7 +28,7 @@ interface ExporterInterface
     /**
      * Create a pending file record in the database.
      */
-    public function prepareFile(ExportRequest $exportRequest, bool $flushFile = true): File;
+    public function prepareFile(ExportRequest $exportRequest, bool $flushFile = true, ?FileFolderEnum $folder = FileFolderEnum::DOCUMENTS): File;
 
     /**
      * Construit le modèle de données qui servira au render du template.

+ 6 - 2
src/Service/File/FileManager.php

@@ -11,6 +11,7 @@ use App\Entity\Access\Access;
 use App\Entity\Core\File;
 use App\Entity\Organization\Organization;
 use App\Entity\Person\Person;
+use App\Enum\Core\FileFolderEnum;
 use App\Enum\Core\FileTypeEnum;
 use App\Enum\Core\FileVisibilityEnum;
 use App\Repository\Core\FileRepository;
@@ -82,6 +83,7 @@ class FileManager
      * @param bool                       $isTemporary Is it a temporary file that can be deleted after some time
      * @param string|null                $mimeType    Mimetype of the file, if not provided, the method will try to guess it from its file name's extension
      * @param bool                       $flushFile   Should the newly created file be flushed after having been persisted?
+     * @param FileFolderEnum|null        $folder      The folder where the file is stored, designed for document management
      */
     public function prepareFile(
         Organization|Access|Person $owner,
@@ -92,10 +94,11 @@ class FileManager
         FileVisibilityEnum $visibility = FileVisibilityEnum::NOBODY,
         ?string $mimeType = null,
         bool $flushFile = true,
+        ?FileFolderEnum $folder = FileFolderEnum::UNRATED,
     ): File {
         return $this
             ->localStorage
-            ->prepareFile($owner, $filename, $type, $createdBy, $isTemporary, $visibility, $mimeType, $flushFile);
+            ->prepareFile($owner, $filename, $type, $createdBy, $isTemporary, $visibility, $mimeType, $flushFile, $folder);
     }
 
     /**
@@ -123,10 +126,11 @@ class FileManager
         FileVisibilityEnum $visibility = FileVisibilityEnum::NOBODY,
         ?string $mimeType = null,
         ?string $config = null,
+        ?FileFolderEnum $folder = FileFolderEnum::UNRATED,
     ): File {
         return $this
             ->localStorage
-            ->makeFile($owner, $filename, $type, $content, $author, $isTemporary, $visibility, $mimeType);
+            ->makeFile($owner, $filename, $type, $content, $author, $isTemporary, $visibility, $mimeType, $config, $folder);
     }
 
     /**

+ 7 - 1
src/Service/File/Storage/LocalStorage.php

@@ -8,6 +8,7 @@ use App\Entity\Access\Access;
 use App\Entity\Core\File;
 use App\Entity\Organization\Organization;
 use App\Entity\Person\Person;
+use App\Enum\Core\FileFolderEnum;
 use App\Enum\Core\FileHostEnum;
 use App\Enum\Core\FileSizeEnum;
 use App\Enum\Core\FileStatusEnum;
@@ -148,6 +149,7 @@ class LocalStorage implements FileStorageInterface
      * @param bool                       $isTemporary Is it a temporary file that can be deleted after some time
      * @param string|null                $mimeType    Mimetype of the file, if not provided, the method will try to guess it from its file name's extension
      * @param bool                       $flushFile   Should the newly created file be flushed after having been persisted?
+     * @param FileFolderEnum|null        $folder      The folder where the file is stored, designed for document management
      */
     public function prepareFile(
         Organization|Access|Person $owner,
@@ -158,6 +160,7 @@ class LocalStorage implements FileStorageInterface
         FileVisibilityEnum $visibility = FileVisibilityEnum::NOBODY,
         ?string $mimeType = null,
         bool $flushFile = true,
+        ?FileFolderEnum $folder = FileFolderEnum::UNRATED,
     ): File {
         [$organization, $person] = $this->getOrganizationAndPersonFromOwner($owner);
 
@@ -172,6 +175,7 @@ class LocalStorage implements FileStorageInterface
             ->setMimeType($mimeType ?? $this->fileUtils->guessMimeTypeFromFilename($filename))
             ->setCreateDate(new \DateTime())
             ->setCreatedBy($createdBy->getId())
+            ->setFolder($folder)
             ->setStatus(FileStatusEnum::PENDING);
 
         $this->entityManager->persist($file);
@@ -260,6 +264,7 @@ class LocalStorage implements FileStorageInterface
         FileVisibilityEnum $visibility = FileVisibilityEnum::NOBODY,
         ?string $mimeType = null,
         ?string $config = null,
+        ?FileFolderEnum $folder = FileFolderEnum::UNRATED,
     ): File {
         $file = $this->prepareFile(
             $owner,
@@ -269,7 +274,8 @@ class LocalStorage implements FileStorageInterface
             $isTemporary,
             $visibility,
             $mimeType,
-            false
+            false,
+            $folder,
         );
 
         if (!empty($config)) {

+ 3 - 3
src/Service/MercureHub.php

@@ -13,10 +13,10 @@ use Symfony\Component\Serializer\SerializerInterface;
 /**
  * Sends private and encrypted mercure updates to the target users.
  *
- * Updates inform of modifications on entities : updates, creations, deletions.
+ * Updates inform of modifications on entities: updates, creations, deletions.
  *
  * The update topic is the id of the recipient user.
- * The content is a json containing the iri of the entity, the operation type, and the current data of this entity
+ * The content is a JSON containing the iri of the entity, the operation type, and the current data of this entity
  */
 class MercureHub
 {
@@ -42,7 +42,7 @@ class MercureHub
     }
 
     /**
-     * Send an update to the.
+     * Send an update to the client.
      */
     public function publish(int $accessId, mixed $entity, string $operationType = self::UPDATE): void
     {

+ 48 - 0
src/Service/OnChange/Access/OnAccessChange.php

@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\OnChange\Access;
+
+use App\Entity\Access\Access;
+use App\Service\Access\AccessProfileCreator;
+use App\Service\MercureHub;
+use App\Service\OnChange\OnChangeContext;
+use App\Service\OnChange\OnChangeDefault;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
+
+/**
+ * Classe OnAccessChange qui comporte toutes les opérations automatiques se produisant lors de l'évolution d'un Access.
+ */
+class OnAccessChange extends OnChangeDefault
+{
+    public function __construct(
+        private Security $security,
+        private AccessProfileCreator $accessProfileCreator,
+        private MercureHub $mercureHub,
+    ) {
+    }
+
+    public function onChange(mixed $access, OnChangeContext $context): void
+    {
+        $this->publishNewProfile($access);
+    }
+
+    /**
+     * Publie via mercure le nouveau profil de l'access.
+     *
+     * @throws \Exception
+     */
+    public function publishNewProfile(Access $access): void
+    {
+        $token = $this->security->getToken();
+
+        /** @var ?Access $originalAccess */
+        $originalAccess = $token instanceof SwitchUserToken ? $token->getOriginalToken()->getUser() : null;
+
+        $profile = $this->accessProfileCreator->getAccessProfile($access, $originalAccess);
+
+        $this->mercureHub->publishUpdate($access->getId(), $profile);
+    }
+}

+ 1 - 1
src/Service/OnChange/Organization/OnParametersChange.php

@@ -64,7 +64,7 @@ class OnParametersChange extends OnChangeDefault
         // La date de début d'activité change
         if (
             $context->previousData()
-            && $context->previousData()->getMusicalDate()->getTimestamp() !== $parameters->getMusicalDate()->getTimestamp()
+            && $context->previousData()->getMusicalDate()?->getTimestamp() !== $parameters->getMusicalDate()?->getTimestamp()
         ) {
             $this->onMusicalDateChange(
                 $parameters,

+ 2 - 2
src/Service/Typo3/SubdomainService.php

@@ -77,11 +77,11 @@ class SubdomainService
     {
         $reservedSubdomains = $this->parameterBag->get('opentalent.subdomains')['reserved'];
         $subRegexes = array_map(
-            function (string $s) { return '(\b'.trim($s, '^$/\s').'\b)'; },
+            function (string $s) { return '(\b'.trim($s).'\b)'; },
             $reservedSubdomains
         );
 
-        $regex = '/^'.strtolower(implode('|', $subRegexes)).'$/';
+        $regex = '/^(?:'.strtolower(implode('|', $subRegexes)).')$/';
 
         return preg_match($regex, $subdomainValue) !== 0;
     }

+ 22 - 0
src/State/Processor/Access/AccessProcessor.php

@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Processor\Access;
+
+use App\Service\OnChange\Access\OnAccessChange;
+use App\State\Processor\EntityProcessor;
+use JetBrains\PhpStorm\Pure;
+
+/**
+ * Classe AccessProcessor qui est un custom dataPersister gérant la resource Access.
+ */
+class AccessProcessor extends EntityProcessor
+{
+    #[Pure]
+    public function __construct(
+        OnAccessChange $onChange,
+    ) {
+        parent::__construct($onChange);
+    }
+}

+ 0 - 3
src/State/Processor/Core/FileProcessor.php

@@ -8,9 +8,6 @@ use App\Service\OnChange\Core\OnFileChange;
 use App\State\Processor\EntityProcessor;
 use JetBrains\PhpStorm\Pure;
 
-/**
- * Classe OrganizationProcessor qui est un custom dataPersister gérant la resource Organization.
- */
 class FileProcessor extends EntityProcessor
 {
     #[Pure]

+ 3 - 3
tests/Unit/Service/Cron/Job/CleanTempFilesTest.php

@@ -76,13 +76,13 @@ class CleanTempFilesTest extends TestCase
         $cleanTempFiles = $this->getMockFor('preview');
 
         $file1 = $this->getMockBuilder(File::class)->getMock();
-        $file1->method('getPath')->willReturn('/foo');
+        $file1->method('getSlug')->willReturn('/foo');
 
         $file2 = $this->getMockBuilder(File::class)->getMock();
-        $file2->method('getPath')->willReturn('/bar');
+        $file2->method('getSlug')->willReturn('/bar');
 
         $file3 = $this->getMockBuilder(File::class)->getMock();
-        $file3->method('getPath')->willReturn('/foo/bar');
+        $file3->method('getSlug')->willReturn('/foo/bar');
 
         $cleanTempFiles->method('listFilesToDelete')->with($maxDate)->willReturn([$file1, $file2, $file3]);
 

+ 10 - 1
tests/Unit/Service/Export/BaseExporterTest.php

@@ -6,6 +6,7 @@ use App\ApiResources\Export\ExportRequest;
 use App\Entity\Access\Access;
 use App\Entity\Core\File;
 use App\Entity\Organization\Organization;
+use App\Enum\Core\FileFolderEnum;
 use App\Enum\Core\FileTypeEnum;
 use App\Enum\Core\FileVisibilityEnum;
 use App\Enum\Export\ExportFormatEnum;
@@ -261,7 +262,15 @@ class BaseExporterTest extends TestCase
         $this->fileManager->expects(self::once())
             ->method('prepareFile')
             ->with(
-                $access, 'Foo.pdf', FileTypeEnum::UNKNOWN, $access, true, FileVisibilityEnum::NOBODY, 'application/pdf', false
+                $access,
+                'Foo.pdf',
+                FileTypeEnum::UNKNOWN,
+                $access,
+                true,
+                FileVisibilityEnum::NOBODY,
+                'application/pdf',
+                false,
+                FileFolderEnum::DOCUMENTS
             )->willReturn($file);
 
         $result = $exporter->prepareFile($exportRequest, false);

+ 3 - 2
tests/Unit/Service/File/FileManagerTest.php

@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\UrlGeneratorInterface;
 use App\Entity\Access\Access;
 use App\Entity\Core\File;
 use App\Entity\Organization\Organization;
+use App\Enum\Core\FileFolderEnum;
 use App\Enum\Core\FileTypeEnum;
 use App\Enum\Core\FileVisibilityEnum;
 use App\Repository\Core\FileRepository;
@@ -120,7 +121,7 @@ class FileManagerTest extends TestCase
         $this->localStorage
             ->expects(self::once())
             ->method('prepareFile')
-            ->with($owner, $filename, $fileType, $createdBy, $isTemporary, $visibility, $mimeType, $flushFile);
+            ->with($owner, $filename, $fileType, $createdBy, $isTemporary, $visibility, $mimeType, $flushFile, FileFolderEnum::UNRATED);
 
         $file = $fileManager->prepareFile($owner, $filename, $fileType, $createdBy, $isTemporary, $visibility, $mimeType, $flushFile);
     }
@@ -157,7 +158,7 @@ class FileManagerTest extends TestCase
         $this->localStorage
             ->expects(self::once())
             ->method('makeFile')
-            ->with($owner, $filename, $fileType, $content, $author, $isTemporary, $visibility, $mimeType);
+            ->with($owner, $filename, $fileType, $content, $author, $isTemporary, $visibility, $mimeType, null, FileFolderEnum::UNRATED);
 
         $file = $fileManager->makeFile($owner, $filename, $fileType, $content, $author, $isTemporary, $visibility, $mimeType);
     }

+ 11 - 5
tests/Unit/Service/File/Storage/LocalStorageTest.php

@@ -6,6 +6,7 @@ use App\Entity\Access\Access;
 use App\Entity\Core\File;
 use App\Entity\Organization\Organization;
 use App\Entity\Person\Person;
+use App\Enum\Core\FileFolderEnum;
 use App\Enum\Core\FileHostEnum;
 use App\Enum\Core\FileSizeEnum;
 use App\Enum\Core\FileStatusEnum;
@@ -391,7 +392,9 @@ class LocalStorageTest extends TestCase
             $author,
             true,
             FileVisibilityEnum::ONLY_ORGANIZATION,
-            'application/pdf'
+            'application/pdf',
+            true,
+            FileFolderEnum::UNRATED
         );
 
         $this->assertEquals($owner, $file->getOrganization());
@@ -421,7 +424,7 @@ class LocalStorageTest extends TestCase
 
         $this->fileUtils->method('guessMimeTypeFromFilename')->with('file.txt')->willReturn('text/plain');
 
-        $file = $fileStorage->prepareFile($owner, 'file.txt', FileTypeEnum::NONE, $author);
+        $file = $fileStorage->prepareFile($owner, 'file.txt', FileTypeEnum::NONE, $author, false, FileVisibilityEnum::NOBODY, null, true, FileFolderEnum::UNRATED);
 
         $this->assertEquals(null, $file->getOrganization());
         $this->assertEquals($owner, $file->getPerson());
@@ -457,7 +460,8 @@ class LocalStorageTest extends TestCase
             false,
             FileVisibilityEnum::NOBODY,
             'text/plain',
-            false
+            false,
+            FileFolderEnum::UNRATED
         );
     }
 
@@ -666,7 +670,7 @@ class LocalStorageTest extends TestCase
         $fileStorage
             ->expects(self::once())
             ->method('prepareFile')
-            ->with($organization, 'foo.txt', FileTypeEnum::NONE, $author, true, FileVisibilityEnum::ONLY_ORGANIZATION, 'mime/type')
+            ->with($organization, 'foo.txt', FileTypeEnum::NONE, $author, true, FileVisibilityEnum::ONLY_ORGANIZATION, 'mime/type', false, FileFolderEnum::UNRATED)
             ->willReturn($file);
 
         $fileStorage
@@ -683,7 +687,9 @@ class LocalStorageTest extends TestCase
             $author,
             true,
             FileVisibilityEnum::ONLY_ORGANIZATION,
-            'mime/type');
+            'mime/type',
+            null,
+            FileFolderEnum::UNRATED);
     }
 
     /**

+ 126 - 0
tests/Unit/Service/OnChange/Access/OnAccessChangeTest.php

@@ -0,0 +1,126 @@
+<?php
+
+/** @noinspection PhpUnhandledExceptionInspection */
+
+namespace App\Tests\Unit\Service\OnChange\Access;
+
+use App\ApiResources\Profile\AccessProfile;
+use App\Entity\Access\Access;
+use App\Service\Access\AccessProfileCreator;
+use App\Service\MercureHub;
+use App\Service\OnChange\Access\OnAccessChange;
+use App\Service\OnChange\OnChangeContext;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
+use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
+
+class OnAccessChangeTest extends TestCase
+{
+    private Security|MockObject $security;
+    private AccessProfileCreator|MockObject $accessProfileCreator;
+    private MercureHub|MockObject $mercureHub;
+
+    public function setUp(): void
+    {
+        $this->security = $this->getMockBuilder(Security::class)->disableOriginalConstructor()->getMock();
+        $this->accessProfileCreator = $this->getMockBuilder(AccessProfileCreator::class)->disableOriginalConstructor()->getMock();
+        $this->mercureHub = $this->getMockBuilder(MercureHub::class)->disableOriginalConstructor()->getMock();
+    }
+
+    /**
+     * @see OnAccessChange::onChange()
+     */
+    public function testOnChange(): void
+    {
+        $onAccessChange = $this->getMockBuilder(OnAccessChange::class)
+            ->setConstructorArgs([$this->security, $this->accessProfileCreator, $this->mercureHub])
+            ->setMethodsExcept(['onChange'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $context = $this->getMockBuilder(OnChangeContext::class)->disableOriginalConstructor()->getMock();
+
+        // The onChange method should call publishNewProfile
+        $onAccessChange->expects($this->once())
+            ->method('publishNewProfile')
+            ->with($access);
+
+        $onAccessChange->onChange($access, $context);
+    }
+
+    /**
+     * @see OnAccessChange::publishNewProfile()
+     */
+    public function testPublishNewProfileWithRegularToken(): void
+    {
+        $onAccessChange = $this->getMockBuilder(OnAccessChange::class)
+            ->setConstructorArgs([$this->security, $this->accessProfileCreator, $this->mercureHub])
+            ->setMethodsExcept(['publishNewProfile'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $access->method('getId')->willReturn(123);
+
+        $token = $this->getMockBuilder(TokenInterface::class)->getMock();
+
+        $this->security->expects($this->once())
+            ->method('getToken')
+            ->willReturn($token);
+
+        $accessProfile = $this->getMockBuilder(AccessProfile::class)->disableOriginalConstructor()->getMock();
+
+        $this->accessProfileCreator->expects($this->once())
+            ->method('getAccessProfile')
+            ->with($access, null)
+            ->willReturn($accessProfile);
+
+        $this->mercureHub->expects($this->once())
+            ->method('publishUpdate')
+            ->with(123, $accessProfile);
+
+        $onAccessChange->publishNewProfile($access);
+    }
+
+    /**
+     * @see OnAccessChange::publishNewProfile()
+     */
+    public function testPublishNewProfileWithSwitchUserToken(): void
+    {
+        $onAccessChange = $this->getMockBuilder(OnAccessChange::class)
+            ->setConstructorArgs([$this->security, $this->accessProfileCreator, $this->mercureHub])
+            ->setMethodsExcept(['publishNewProfile'])
+            ->getMock();
+
+        $access = $this->getMockBuilder(Access::class)->getMock();
+        $access->method('getId')->willReturn(123);
+
+        $originalAccess = $this->getMockBuilder(Access::class)->getMock();
+
+        $originalToken = $this->getMockBuilder(TokenInterface::class)->getMock();
+        $originalToken->method('getUser')->willReturn($originalAccess);
+
+        $switchUserToken = $this->getMockBuilder(SwitchUserToken::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $switchUserToken->method('getOriginalToken')->willReturn($originalToken);
+
+        $this->security->expects($this->once())
+            ->method('getToken')
+            ->willReturn($switchUserToken);
+
+        $accessProfile = $this->getMockBuilder(AccessProfile::class)->disableOriginalConstructor()->getMock();
+
+        $this->accessProfileCreator->expects($this->once())
+            ->method('getAccessProfile')
+            ->with($access, $originalAccess)
+            ->willReturn($accessProfile);
+
+        $this->mercureHub->expects($this->once())
+            ->method('publishUpdate')
+            ->with(123, $accessProfile);
+
+        $onAccessChange->publishNewProfile($access);
+    }
+}

+ 8 - 0
tests/Unit/Service/Typo3/SubdomainServiceTest.php

@@ -159,12 +159,20 @@ class SubdomainServiceTest extends TestCase
                 ],
             ]);
 
+        // Exact match for 'abcd' should be reserved
         $this->assertTrue($subdomainService->isReservedSubdomain('abcd'));
+
+        // Pattern match for 'abc\d+' should be reserved
         $this->assertTrue($subdomainService->isReservedSubdomain('abc123'));
 
+        // These should not match any reserved pattern
         $this->assertFalse($subdomainService->isReservedSubdomain('abcde'));
         $this->assertFalse($subdomainService->isReservedSubdomain('abc'));
         $this->assertFalse($subdomainService->isReservedSubdomain('foo'));
+
+        // Test word boundary behavior
+        $this->assertFalse($subdomainService->isReservedSubdomain('myabcd')); // Should not match due to word boundary
+        $this->assertFalse($subdomainService->isReservedSubdomain('abcdfoo')); // Should not match due to word boundary
     }
 
     public function testIsRegisteredSubdomain(): void