Jelajahi Sumber

Merge tag '2.6' into develop

Vincent 3 bulan lalu
induk
melakukan
007ad154bb
81 mengubah file dengan 2463 tambahan dan 449 penghapusan
  1. 293 0
      .junie/guidelines.md
  2. 1 0
      Dockerfile
  3. 1 0
      config/opentalent/enum.yaml
  4. 5 2
      config/opentalent/products.yaml
  5. 5 1
      config/packages/messenger.yaml
  6. 4 4
      env/.env.prod
  7. 1 1
      env/.env.staging
  8. 4 4
      env/.env.test
  9. 4 4
      env/.env.test1
  10. 4 4
      env/.env.test2
  11. 4 4
      env/.env.test3
  12. 4 4
      env/.env.test4
  13. 4 4
      env/.env.test5
  14. 4 4
      env/.env.test6
  15. 4 4
      env/.env.test7
  16. 4 4
      env/.env.test8
  17. 4 4
      env/.env.test9
  18. 5 0
      src/ApiResources/Core/File/Image.php
  19. 13 2
      src/ApiResources/Freemium/FreemiumEvent.php
  20. 41 1
      src/ApiResources/Freemium/FreemiumOrganization.php
  21. 1 1
      src/ApiResources/Freemium/FreemiumPlace.php
  22. 1 1
      src/ApiResources/Organization/OrganizationCreationRequest.php
  23. 3 3
      src/ApiResources/Search/PlaceSearchItem.php
  24. 2 3
      src/Doctrine/Access/AdditionalExtension/DateTimeConstraintExtensionAdditional.php
  25. 50 16
      src/Entity/Booking/Event.php
  26. 31 1
      src/Entity/Booking/EventGender.php
  27. 0 1
      src/Entity/Core/Subfamilly.php
  28. 13 8
      src/Entity/Organization/TypeOfPractice.php
  29. 1 1
      src/Entity/Place/AbstractPlace.php
  30. 0 1
      src/Entity/Place/Place.php
  31. 19 0
      src/Enum/Booking/EventGenderTypeEnum.php
  32. 1 1
      src/Enum/Booking/PricingEventEnum.php
  33. 2 0
      src/Enum/Cotisation/CategoryTypeOfPracticeEnum.php
  34. 1 1
      src/Enum/Organization/OrganizationIdsEnum.php
  35. 19 0
      src/Enum/Organization/PrincipalTypeShortListEnum.php
  36. 0 2
      src/EventListener/OnKernelRequestPreRead.php
  37. 0 1
      src/Repository/Place/PlaceRepository.php
  38. 9 7
      src/Security/Voter/ModuleVoter.php
  39. 0 1
      src/Service/ApiLegacy/ApiLegacyRequestService.php
  40. 38 39
      src/Service/ApiResourceBuilder/Freemium/EventMappingBuilder.php
  41. 58 6
      src/Service/ApiResourceBuilder/Freemium/OrganizationMappingBuilder.php
  42. 14 2
      src/Service/Doctrine/FiltersConfigurationService.php
  43. 20 16
      src/Service/Export/Encoder/PdfEncoder.php
  44. 5 4
      src/Service/Export/LicenceCmfExporter.php
  45. 1 1
      src/Service/File/Storage/ApiLegacyStorage.php
  46. 2 4
      src/Service/Organization/OrganizationFactory.php
  47. 7 4
      src/Service/Security/Module.php
  48. 3 4
      src/Service/Shop/ShopService.php
  49. 4 4
      src/Service/State/Provider/ProviderUtils.php
  50. 25 0
      src/Service/Twig/AssetsExtension.php
  51. 4 5
      src/Service/Twig/ToBase64Extension.php
  52. 20 0
      src/Service/Utils/DebugUtils.php
  53. 14 0
      src/Service/Utils/StringsUtils.php
  54. 18 12
      src/State/Processor/Freemium/FreemiumEventProcessor.php
  55. 0 1
      src/State/Processor/Freemium/FreemiumOrganizationProcessor.php
  56. 1 1
      src/State/Provider/Core/DownloadProvider.php
  57. 2 2
      src/State/Provider/Core/EventCategoryProvider.php
  58. 11 11
      src/State/Provider/Freemium/FreemiumEventProvider.php
  59. 11 1
      src/State/Provider/Freemium/FreemiumOrganizationProvider.php
  60. 5 5
      src/State/Provider/Freemium/FreemiumPlaceProvider.php
  61. 8 8
      src/State/Provider/Search/PlaceSearchItemProvider.php
  62. 6 6
      src/Validator/Constraints/FieldLesserThan.php
  63. 4 3
      src/Validator/Constraints/FieldLesserThanValidator.php
  64. 7 7
      templates/export/licence_cmf.html.twig
  65. 0 2
      tests/Unit/Service/ApiLegacy/ApiLegacyRequestServiceTest.php
  66. 301 0
      tests/Unit/Service/ApiResourceBuilder/Freemium/EventMappingBuilderTest.php
  67. 259 0
      tests/Unit/Service/ApiResourceBuilder/Freemium/OrganizationMappingBuilderTest.php
  68. 42 3
      tests/Unit/Service/Doctrine/FiltersConfigurationServiceTest.php
  69. 41 14
      tests/Unit/Service/Export/Encoder/PdfEncoderTest.php
  70. 2 2
      tests/Unit/Service/File/Storage/ApiLegacyStorageTest.php
  71. 27 67
      tests/Unit/Service/Organization/OrganizationFactoryTest.php
  72. 4 1
      tests/Unit/Service/Organization/TrialTest.php
  73. 7 7
      tests/Unit/Service/Security/ModuleTest.php
  74. 4 52
      tests/Unit/Service/Shop/ShopServiceTest.php
  75. 366 0
      tests/Unit/Service/State/Provider/ProviderUtilsTest.php
  76. 110 3
      tests/Unit/Service/Twig/AssetsExtensionTest.php
  77. 56 0
      tests/Unit/Service/Twig/ToBase64ExtensionTest.php
  78. 26 52
      tests/Unit/Service/Typo3/Typo3ServiceTest.php
  79. 53 0
      tests/Unit/Service/Utils/StringsUtilsTest.php
  80. 147 0
      tests/Unit/State/Processor/Freemium/FreemiumEventProcessorTest.php
  81. 168 0
      tests/Unit/State/Provider/FreemiumEventProviderTest.php

+ 293 - 0
.junie/guidelines.md

@@ -0,0 +1,293 @@
+# AP2I Project Guidelines
+
+## Project Overview
+
+AP2I is a Symfony 7.3-based API application that is part of the OpenTalent ecosystem. 
+It's a comprehensive API platform built with modern PHP (8.2+) and leverages API Platform 4.1 for REST/GraphQL API capabilities.
+
+### Key Technologies
+- **Framework**: Symfony 7.3
+- **API Platform**: 4.1 (REST/GraphQL APIs)
+- **PHP**: 8.2+ (strict requirement)
+- **ORM**: Doctrine 3.3 with migrations
+- **Authentication**: JWT (Lexik JWT Authentication Bundle)
+- **Database**: Uses Doctrine DBAL 3.9 and a mariadb 10.4.26 DB
+- **Messaging**: Symfony Messenger for async processing
+- **Testing**: PHPUnit 9.6
+- **Code Quality**: PHPStan, PHP CS Fixer
+
+### Business Domains
+The application handles multiple business domains including:
+- Organization management
+- Education systems
+- Billing and payments
+- User profiles and access control
+- Network management
+- Booking systems
+- Export functionality
+- Shop/e-commerce features
+
+## Project Structure
+
+```
+src/
+├── Message/           # Symfony Messenger messages and handlers
+├── DataFixtures/      # Database fixtures for testing/development
+├── Validator/         # Custom validation logic
+├── Enum/              # Domain-specific enumerations
+├── Service/           # Business logic services
+└── ...               # Other domain-specific directories
+
+tests/
+├── Fixture/          # Test data fixtures and factories
+├── Application/      # Application-level integration tests
+├── Unit/            # Unit tests organized by domain
+└── ...
+
+config/               # Symfony configuration
+public/              # Web root directory
+templates/           # Twig templates
+migrations/          # Doctrine database migrations
+```
+
+## Development Guidelines
+
+### Testing
+- **Always run tests** before submitting changes to ensure correctness
+- The project uses PHPUnit for testing with comprehensive unit and application tests
+- Tests are organized by domain to mirror the application structure
+- Do not modify the SUT, except for converting private methods into protected ones, or because there is an obvious error in it
+- Take `tests/Unit/Service/Typo3/Typo3ServiceTest.php` as the reference example for writing service tests
+
+#### Test Class Structure
+Each test class should follow this structure:
+
+```php
+<?php
+
+namespace App\Tests\Unit\Service\MyDomain;
+
+use App\Service\MyDomain\MyService;
+use PHPUnit\Framework\TestCase;
+use SomeDependency\Interface;
+use PHPUnit\Framework\MockObject\MockObject;
+
+// Create testable class for protected methods access
+class TestableMyService extends MyService
+{
+    public function protectedMethod(): mixed
+    {
+        return parent::protectedMethod();
+    }
+}
+
+class MyServiceTest extends TestCase
+{
+    private Interface|MockObject $dependency;
+
+    public function setUp(): void
+    {
+        // Mock all dependencies in setUp
+        $this->dependency = $this->getMockBuilder(Interface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    private function getMyServiceMockFor(string $methodName): TestableMyService|MockObject
+    {
+        return $this->getMockBuilder(TestableMyService::class)
+            ->setConstructorArgs([$this->dependency])
+            ->setMethodsExcept([$methodName])
+            ->getMock();   
+    }
+}
+```
+
+#### Mocking Strategy
+- **Use `setMethodsExcept()`** to mock all methods except the one being tested (ignore deprecation warnings)
+- **Create a dedicated mock factory method** following the pattern `getMyClassMockFor(string $methodName)`
+- **Mock factory method should:**
+  - Create the SUT mock using `getMockBuilder()`
+  - Inject dependency mocks (created in `setUp`)
+  - Define methods to exclude from mocking via `setMethodsExcept()`
+  - Return the configured mock object
+
+#### Method Mocking Rules
+- **Only mock methods that are expected to be called** during test execution
+- **Exception:** Use `expects(self::never())` when verifying a method should NOT be called
+- **Mock each method called** in the currently tested method, including SUT methods
+- **Do not mock methods** that are not involved in the test scenario
+
+Example:
+```php
+public function testClearSiteCache(): void
+{
+    $typo3Service = $this->getTypo3ServiceMockFor('clearSiteCache');
+    
+    $response = $this->getMockBuilder(ResponseInterface::class)
+        ->disableOriginalConstructor()
+        ->getMock();
+        
+    $typo3Service->expects(self::once())
+        ->method('sendCommand')
+        ->with('/otadmin/site/clear-cache', ['organization-id' => 1])
+        ->willReturn($response);
+
+    $typo3Service->clearSiteCache(1);
+}
+```
+
+#### Visibility Issues
+- **For protected methods:** Create an intermediate testable class (see `TestableTypo3Service` example)
+- **For private methods in SUT:** Change visibility to protected to enable testing
+- **Testable class pattern:**
+
+```php
+class TestableMyService extends MyService
+{
+    public function protectedMethodToTest(string $param): ResponseInterface
+    {
+        return parent::protectedMethodToTest($param);
+    }
+}
+```
+
+#### Test Naming Convention
+- **Primary test** covering most common usage: `testMethod` (where "Method" is the actual method name)
+- **Variant tests** append specific situation:
+  - `testMethodWhenNoInput`
+  - `testMethodWithInvalidContext`
+  - `testMethodWithSpecialCondition`
+
+#### Test Documentation
+- **Use @see annotations** to reference the tested method:
+```php
+/**
+ * @see MyService::clearSiteCache()
+ */
+public function testClearSiteCache(): void
+```
+
+#### Test Structure Best Practices
+- **One test per execution branch/path**
+- **Use assertions** when the method should return a result
+- **Use expectations** when the method should call other methods
+- **Use `expects(self::never())`** when verifying a method should NOT be called
+- **Cover edge cases and error scenarios**
+- **Test method variants** (e.g., with/without optional parameters)
+
+#### Dependencies and Isolation
+- **All dependencies must be mocked** in `setUp` method
+- **Tests must be isolated and independent**
+- **Use `disableOriginalConstructor()`** for mock builders
+- **Mock return values** using `willReturn()` for expected behaviors
+
+#### Example Test Patterns
+
+**Simple method expectation:**
+```php
+public function testCreateSite(): void
+{
+    $typo3Service = $this->getTypo3ServiceMockFor('createSite');
+    
+    $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);
+}
+```
+
+**Method with conditional parameters:**
+```php
+public function testSetSiteDomainWithRedirection(): void
+{
+    $typo3Service = $this->getTypo3ServiceMockFor('setSiteDomain');
+    
+    $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);
+}
+```
+
+#### Running Tests
+```bash
+# Run all unit tests
+docker exec ap2i php -d memory_limit=-1 vendor/phpunit/phpunit/phpunit --testsuite unit --configuration phpunit.xml.dist
+
+# Run application tests (adjust testsuite as needed)
+docker exec ap2i vendor/bin/phpunit --configuration phpunit.xml.dist
+
+# Run specific test class
+docker exec ap2i vendor/bin/phpunit tests/Unit/Service/MyDomain/MyServiceTest.php
+
+# Run with coverage (if configured)
+docker exec ap2i vendor/bin/phpunit --coverage-html coverage/
+```
+
+### Code Quality
+The project enforces strict code quality standards:
+
+#### PHPStan (Static Analysis)
+```bash
+# Run analysis
+docker exec ap2i vendor/bin/phpstan analyse
+
+# Clear cache if needed
+docker exec ap2i vendor/bin/phpstan clear-result-cache
+```
+
+#### PHP CS Fixer (Code Style)
+```bash
+# Check code style
+docker exec ap2i php vendor/bin/php-cs-fixer check --config=.php-cs-fixer.dist.php
+
+# Fix code style automatically
+docker exec ap2i php vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php
+```
+
+### Messenger (Async Processing)
+The application uses Symfony Messenger for async processing:
+
+```bash
+# Start consuming messages
+docker exec ap2i php bin/console messenger:consume async
+
+# Setup transports if needed
+docker exec ap2i php bin/console messenger:setup-transports
+```
+
+### Build Process
+- No special build process required beyond standard Symfony practices
+- Run `docker exec ap2i composer install` for dependencies
+- Run database migrations: `docker exec ap2i php bin/console doctrine:migrations:migrate`
+- Clear cache: `docker exec ap2i php bin/console cache:clear`
+
+### Code Style Guidelines
+- Follow PSR-12 coding standards (enforced by PHP CS Fixer)
+- Use strict typing where possible (PHP 8.2+ features encouraged)
+- Follow Symfony best practices for service organization
+- Organize code by business domain
+- Write comprehensive tests for all business logic
+- Use PHPStan level checks for type safety
+
+### Important Notes
+- This is a proprietary project (license: proprietary)
+- Uses custom OpenTalent packages and private GitLab repositories
+- Requires PHP 8.2 minimum
+- Database changes must be done via Doctrine migrations
+- Always run the full test suite before submitting changes

+ 1 - 0
Dockerfile

@@ -1,3 +1,4 @@
+# Dockerfile utilisé pour la CI
 FROM php:8.2-fpm
 
 # Installation des dépendances système

+ 1 - 0
config/opentalent/enum.yaml

@@ -55,6 +55,7 @@ parameters:
           organization_legal: 'App\Enum\Organization\LegalEnum'
           organization_opca: 'App\Enum\Organization\OpcaEnum'
           organization_principal_type: 'App\Enum\Organization\PrincipalTypeEnum'
+          organization_principal_type_short_list: 'App\Enum\Organization\PrincipalTypeShortListEnum'
           organization_school_cat: 'App\Enum\Organization\SchoolCategoryEnum'
           organization_type_establishment_detail: 'App\Enum\Organization\TypeEstablishmentDetailEnum'
           organization_type_establishment: 'App\Enum\Organization\TypeEstablishmentEnum'

+ 5 - 2
config/opentalent/products.yaml

@@ -4,8 +4,6 @@ parameters:
         resources:
           - AccessProfile
           - Tips
-          - Notification
-          - NotificationUser
           - File
           - Image
           - City
@@ -23,8 +21,12 @@ parameters:
           - AccessProfile
           - EventCategory
           - PlaceSearchItem
+          - EventGender
+          - TypeOfPractice
       Common:
         resources:
+          - Notification
+          - NotificationUser
           - Preferences
           - ContactPoint
           - PersonalizedList
@@ -293,6 +295,7 @@ parameters:
           - Reward
 
       Basicompta:
+        resources: ~
         roles:
           - ROLE_BASICOMPTA
 

+ 5 - 1
config/packages/messenger.yaml

@@ -5,7 +5,10 @@ framework:
 
         transports:
             # https://symfony.com/doc/current/messenger.html#transport-configuration
-            async: '%env(MESSENGER_TRANSPORT_DSN)%'
+            async:
+                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
+                retry_strategy:
+                    max_retries: 0
             failed: '%env(MESSENGER_TRANSPORT_DSN)%?queue_name=failed'
             sync: 'sync://'
 
@@ -18,3 +21,4 @@ framework:
             'App\Message\Message\Typo3\Typo3Undelete': async
             'App\Message\Message\OrganizationCreation': async
             'App\Message\Message\OrganizationDeletion': async
+

+ 4 - 4
env/.env.prod

@@ -6,13 +6,13 @@ ADMIN_BASE_URL=https://admin.opentalent.fr
 ###
 
 ###> api v1 ###
-API_LEG_BASE_URL=https://api.opentalent.fr/api
-PUBLIC_API_LEG_BASE_URL=https://api.opentalent.fr/api
+API_LEG_BASE_URL=https://api.opentalent.fr
+PUBLIC_API_LEG_BASE_URL=https://api.opentalent.fr
 ###
 
 ###> url v2 ###
-API_BASE_URL=https://ap2i.opentalent.fr/api
-PUBLIC_API_BASE_URL=https://ap2i.opentalent.fr/api
+API_BASE_URL=https://ap2i.opentalent.fr
+PUBLIC_API_BASE_URL=https://ap2i.opentalent.fr
 ###
 
 ###> typo3 client ###

+ 1 - 1
env/.env.staging

@@ -18,7 +18,7 @@ PUBLIC_API_LEG_BASE_URL=https://none
 ###< api v1 ###
 
 ###> api v2 ###
-API_BASE_URL=https://ap2i.ci.opentalent.fr/api
+API_BASE_URL=https://ap2i.ci.opentalent.fr
 PUBLIC_API_BASE_URL=https://ap2i.ci.opentalent.fr
 ###< api v2 ###
 

+ 4 - 4
env/.env.test

@@ -8,13 +8,13 @@ ADMIN_BASE_URL=https://admin.test.opentalent.fr
 ###
 
 ###> api v1 ###
-API_LEG_BASE_URL=https://api.test.opentalent.fr/api
-PUBLIC_API_LEG_BASE_URL=https://api.test.opentalent.fr/api
+API_LEG_BASE_URL=https://api.test.opentalent.fr
+PUBLIC_API_LEG_BASE_URL=https://api.test.opentalent.fr
 ###< api v1 ###
 
 ###> api v2 ###
-API_BASE_URL=https://ap2i.test.opentalent.fr/api
-PUBLIC_API_BASE_URL=https://ap2i.test.opentalent.fr/api
+API_BASE_URL=https://ap2i.test.opentalent.fr
+PUBLIC_API_BASE_URL=https://ap2i.test.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 4 - 4
env/.env.test1

@@ -8,13 +8,13 @@ ADMIN_BASE_URL=https://admin.test1.opentalent.fr
 ###
 
 ###> api v1 ###
-API_LEG_BASE_URL=https://api.test1.opentalent.fr/api
-PUBLIC_API_LEG_BASE_URL=https://api.test1.opentalent.fr/api
+API_LEG_BASE_URL=https://api.test1.opentalent.fr
+PUBLIC_API_LEG_BASE_URL=https://api.test1.opentalent.fr
 ###< api v1 ###
 
 ###> api v2 ###
-API_BASE_URL=https://ap2i.test1.opentalent.fr/api
-PUBLIC_API_BASE_URL=https://ap2i.test1.opentalent.fr/api
+API_BASE_URL=https://ap2i.test1.opentalent.fr
+PUBLIC_API_BASE_URL=https://ap2i.test1.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 4 - 4
env/.env.test2

@@ -8,13 +8,13 @@ ADMIN_BASE_URL=https://admin.test2.opentalent.fr
 ###
 
 ###> api v1 ###
-API_LEG_BASE_URL=https://api.test2.opentalent.fr/api
-PUBLIC_API_LEG_BASE_URL=https://api.test2.opentalent.fr/api
+API_LEG_BASE_URL=https://api.test2.opentalent.fr
+PUBLIC_API_LEG_BASE_URL=https://api.test2.opentalent.fr
 ###< api v1 ###
 
 ###> api v2 ###
-API_BASE_URL=https://ap2i.test2.opentalent.fr/api
-PUBLIC_API_BASE_URL=https://ap2i.test2.opentalent.fr/api
+API_BASE_URL=https://ap2i.test2.opentalent.fr
+PUBLIC_API_BASE_URL=https://ap2i.test2.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 4 - 4
env/.env.test3

@@ -8,13 +8,13 @@ ADMIN_BASE_URL=https://admin.test3.opentalent.fr
 ###
 
 ###> api v1 ###
-API_LEG_BASE_URL=https://api.test3.opentalent.fr/api
-PUBLIC_API_LEG_BASE_URL=https://api.test3.opentalent.fr/api
+API_LEG_BASE_URL=https://api.test3.opentalent.fr
+PUBLIC_API_LEG_BASE_URL=https://api.test3.opentalent.fr
 ###< api v1 ###
 
 ###> api v2 ###
-API_BASE_URL=https://ap2i.test3.opentalent.fr/api
-PUBLIC_API_BASE_URL=https://ap2i.test3.opentalent.fr/api
+API_BASE_URL=https://ap2i.test3.opentalent.fr
+PUBLIC_API_BASE_URL=https://ap2i.test3.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 4 - 4
env/.env.test4

@@ -8,13 +8,13 @@ ADMIN_BASE_URL=https://admin.test4.opentalent.fr
 ###
 
 ###> api v1 ###
-API_LEG_BASE_URL=https://api.test4.opentalent.fr/api
-PUBLIC_API_LEG_BASE_URL=https://api.test4.opentalent.fr/api
+API_LEG_BASE_URL=https://api.test4.opentalent.fr
+PUBLIC_API_LEG_BASE_URL=https://api.test4.opentalent.fr
 ###< api v1 ###
 
 ###> api v2 ###
-API_BASE_URL=https://ap2i.test4.opentalent.fr/api
-PUBLIC_API_BASE_URL=https://ap2i.test4.opentalent.fr/api
+API_BASE_URL=https://ap2i.test4.opentalent.fr
+PUBLIC_API_BASE_URL=https://ap2i.test4.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 4 - 4
env/.env.test5

@@ -8,13 +8,13 @@ ADMIN_BASE_URL=https://admin.test5.opentalent.fr
 ###
 
 ###> api v1 ###
-API_LEG_BASE_URL=https://api.test5.opentalent.fr/api
-PUBLIC_API_LEG_BASE_URL=https://api.test5.opentalent.fr/api
+API_LEG_BASE_URL=https://api.test5.opentalent.fr
+PUBLIC_API_LEG_BASE_URL=https://api.test5.opentalent.fr
 ###< api v1 ###
 
 ###> api v2 ###
-API_BASE_URL=https://ap2i.test5.opentalent.fr/api
-PUBLIC_API_BASE_URL=https://ap2i.test5.opentalent.fr/api
+API_BASE_URL=https://ap2i.test5.opentalent.fr
+PUBLIC_API_BASE_URL=https://ap2i.test5.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 4 - 4
env/.env.test6

@@ -8,13 +8,13 @@ ADMIN_BASE_URL=https://admin.test6.opentalent.fr
 ###
 
 ###> api v1 ###
-API_LEG_BASE_URL=https://api.test6.opentalent.fr/api
-PUBLIC_API_LEG_BASE_URL=https://api.test6.opentalent.fr/api
+API_LEG_BASE_URL=https://api.test6.opentalent.fr
+PUBLIC_API_LEG_BASE_URL=https://api.test6.opentalent.fr
 ###< api v1 ###
 
 ###> api v2 ###
-API_BASE_URL=https://ap2i.test6.opentalent.fr/api
-PUBLIC_API_BASE_URL=https://ap2i.test6.opentalent.fr/api
+API_BASE_URL=https://ap2i.test6.opentalent.fr
+PUBLIC_API_BASE_URL=https://ap2i.test6.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 4 - 4
env/.env.test7

@@ -8,13 +8,13 @@ ADMIN_BASE_URL=https://admin.test7.opentalent.fr
 ###
 
 ###> api v1 ###
-API_LEG_BASE_URL=https://api.test7.opentalent.fr/api
-PUBLIC_API_LEG_BASE_URL=https://api.test7.opentalent.fr/api
+API_LEG_BASE_URL=https://api.test7.opentalent.fr
+PUBLIC_API_LEG_BASE_URL=https://api.test7.opentalent.fr
 ###< api v1 ###
 
 ###> api v2 ###
-API_BASE_URL=https://ap2i.test7.opentalent.fr/api
-PUBLIC_API_BASE_URL=https://ap2i.test7.opentalent.fr/api
+API_BASE_URL=https://ap2i.test7.opentalent.fr
+PUBLIC_API_BASE_URL=https://ap2i.test7.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 4 - 4
env/.env.test8

@@ -8,13 +8,13 @@ ADMIN_BASE_URL=https://admin.test8.opentalent.fr
 ###
 
 ###> api v1 ###
-API_LEG_BASE_URL=https://api.test8.opentalent.fr/api
-PUBLIC_API_LEG_BASE_URL=https://api.test8.opentalent.fr/api
+API_LEG_BASE_URL=https://api.test8.opentalent.fr
+PUBLIC_API_LEG_BASE_URL=https://api.test8.opentalent.fr
 ###< api v1 ###
 
 ###> api v2 ###
-API_BASE_URL=https://ap2i.test8.opentalent.fr/api
-PUBLIC_API_BASE_URL=https://ap2i.test8.opentalent.fr/api
+API_BASE_URL=https://ap2i.test8.opentalent.fr
+PUBLIC_API_BASE_URL=https://ap2i.test8.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 4 - 4
env/.env.test9

@@ -8,13 +8,13 @@ ADMIN_BASE_URL=https://admin.test9.opentalent.fr
 ###
 
 ###> api v1 ###
-API_LEG_BASE_URL=https://api.test9.opentalent.fr/api
-PUBLIC_API_LEG_BASE_URL=https://api.test9.opentalent.fr/api
+API_LEG_BASE_URL=https://api.test9.opentalent.fr
+PUBLIC_API_LEG_BASE_URL=https://api.test9.opentalent.fr
 ###< api v1 ###
 
 ###> api v2 ###
-API_BASE_URL=https://ap2i.test9.opentalent.fr/api
-PUBLIC_API_BASE_URL=https://ap2i.test9.opentalent.fr/api
+API_BASE_URL=https://ap2i.test9.opentalent.fr
+PUBLIC_API_BASE_URL=https://ap2i.test9.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 5 - 0
src/ApiResources/Core/File/Image.php

@@ -23,6 +23,11 @@ use Symfony\Component\Validator\Constraints as Assert;
             security: 'is_granted("ROLE_FILE")',
             provider: ImageProvider::class
         ),
+        new Get(
+            uriTemplate: '/internal/image/download/{fileId}/{size}',
+            requirements: ['fileId' => '\\d+', 'size' => new EnumRequirement(FileSizeEnum::class)],
+            provider: ImageProvider::class
+        ),
     ]
 )]
 class Image

+ 13 - 2
src/ApiResources/Freemium/FreemiumEvent.php

@@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Post;
 use App\ApiResources\ApiResourcesInterface;
 use App\Attribute\OrganizationDefaultValue;
 use App\Entity\Booking\Event;
+use App\Entity\Booking\EventGender;
 use App\Entity\Core\Categories;
 use App\Entity\Core\Country;
 use App\Entity\Core\File;
@@ -25,10 +26,11 @@ use App\Entity\Place\Place;
 use App\Enum\Booking\PricingEventEnum;
 use App\State\Processor\Freemium\FreemiumEventProcessor;
 use App\State\Provider\Freemium\FreemiumEventProvider;
+use App\Validator\Constraints as OpentalentAssert;
 use Doctrine\Common\Collections\ArrayCollection;
 use JetBrains\PhpStorm\Pure;
 use Symfony\Component\ObjectMapper\Attribute\Map;
-use App\Validator\Constraints as OpentalentAssert;
+use Symfony\Component\Validator\Constraints as Assert;
 
 /**
  * Classe resource contient tous les champs pour la gestion d'un événement pour un profile Freemium.
@@ -64,6 +66,7 @@ use App\Validator\Constraints as OpentalentAssert;
 #[ApiFilter(filterClass: OrderFilter::class, properties: ['datetimeStart'], arguments: ['orderParameterName' => 'order'])]
 #[Map(source: Event::class)]
 #[OpentalentAssert\FieldLesserThan(field: 'datetimeStart', comparedTo: 'datetimeEnd')]
+#[OpentalentAssert\FieldLesserThan(field: 'priceMini', comparedTo: 'priceMaxi')]
 class FreemiumEvent implements ApiResourcesInterface
 {
     #[ApiProperty(identifier: true)]
@@ -81,8 +84,10 @@ class FreemiumEvent implements ApiResourcesInterface
 
     public ?File $image = null;
 
+    #[Assert\Url(protocols: ['http', 'https'])]
     public ?string $url = null;
 
+    #[Assert\Url(protocols: ['http', 'https'])]
     public ?string $urlTicket = null;
 
     public ?Place $place = null;
@@ -119,8 +124,11 @@ class FreemiumEvent implements ApiResourcesInterface
 
     public ?PricingEventEnum $pricing = null;
 
-    public ?float $priceMini = null;
+    public ?EventGender $gender;
 
+    #[Assert\Positive()]
+    public ?float $priceMini = null;
+    #[Assert\Positive()]
     public ?float $priceMaxi = null;
 
     #[Pure]
@@ -129,6 +137,9 @@ class FreemiumEvent implements ApiResourcesInterface
         $this->categories = new ArrayCollection();
     }
 
+    /**
+     * @return array<Categories>
+     */
     public function getCategories(): array
     {
         // retourne un tableau proprement indexé

+ 41 - 1
src/ApiResources/Freemium/FreemiumOrganization.php

@@ -12,8 +12,12 @@ use App\ApiResources\ApiResourcesInterface;
 use App\Entity\Core\Country;
 use App\Entity\Core\File;
 use App\Entity\Organization\Organization;
+use App\Entity\Organization\TypeOfPractice;
+use App\Enum\Organization\LegalEnum;
+use App\Enum\Organization\PrincipalTypeEnum;
 use App\State\Processor\Freemium\FreemiumOrganizationProcessor;
 use App\State\Provider\Freemium\FreemiumOrganizationProvider;
+use Doctrine\Common\Collections\ArrayCollection;
 use libphonenumber\PhoneNumber;
 use Symfony\Component\ObjectMapper\Attribute\Map;
 use Symfony\Component\Validator\Constraints as Assert;
@@ -42,10 +46,17 @@ class FreemiumOrganization implements ApiResourcesInterface
     public ?int $id = null;
 
     #[Assert\Length(max: 128)]
-    public ?string $name = null;
+    public string $name;
 
     public ?string $description = null;
 
+    public ?LegalEnum $legalStatus = null;
+
+    public ?PrincipalTypeEnum $principalType = null;
+
+    #[Map(if: false)]
+    public ArrayCollection $typeOfPractices;
+
     #[Map(source: 'principalContactPoint?.email')]
     #[Assert\Email(message: 'invalid-email-format', mode: 'strict')]
     #[Assert\Length(max: 255)]
@@ -79,18 +90,47 @@ class FreemiumOrganization implements ApiResourcesInterface
     public ?float $longitude = null;
 
     #[Assert\Length(max: 255)]
+    #[Assert\Url(protocols: ['http', 'https'])]
     public ?string $facebook = null;
 
     #[Assert\Length(max: 255)]
+    #[Assert\Url(protocols: ['http', 'https'])]
     public ?string $twitter = null;
 
     #[Assert\Length(max: 255)]
+    #[Assert\Url(protocols: ['http', 'https'])]
     public ?string $youtube = null;
 
     #[Assert\Length(max: 255)]
+    #[Assert\Url(protocols: ['http', 'https'])]
     public ?string $instagram = null;
 
     public bool $portailVisibility = true;
 
     public ?File $logo = null;
+
+    /**
+     * @return array<TypeOfPractice>
+     */
+    public function getTypeOfPractices(): array
+    {
+        // retourne un tableau proprement indexé
+        return array_values($this->typeOfPractices->toArray());
+    }
+
+    public function addTypeOfPractice(TypeOfPractice $typeOfPractices): self
+    {
+        if (!$this->typeOfPractices->contains($typeOfPractices)) {
+            $this->typeOfPractices[] = $typeOfPractices;
+        }
+
+        return $this;
+    }
+
+    public function removeTypeOfPractice(TypeOfPractice $typeOfPractices): self
+    {
+        $this->typeOfPractices->removeElement($typeOfPractices);
+
+        return $this;
+    }
 }

+ 1 - 1
src/ApiResources/Freemium/FreemiumPlace.php

@@ -22,7 +22,7 @@ use Symfony\Component\ObjectMapper\Attribute\Map;
         new Get(
             uriTemplate: '/freemium/places/{id}',
             security: '(is_granted("ROLE_USER_FREEMIUM") and (object.organization == user.getOrganization()))',
-        )
+        ),
     ],
     provider: FreemiumPlaceProvider::class,
 )]

+ 1 - 1
src/ApiResources/Organization/OrganizationCreationRequest.php

@@ -103,7 +103,7 @@ class OrganizationCreationRequest
     private string $subdomain;
 
     #[Assert\Positive]
-    private int $parentId = OrganizationIdsEnum::_2IOS->value;
+    private int $parentId = OrganizationIdsEnum::OPENTALENT->value;
 
     private PrincipalTypeEnum $principalType;
 

+ 3 - 3
src/ApiResources/Search/PlaceSearchItem.php

@@ -4,8 +4,8 @@ declare(strict_types=1);
 
 namespace App\ApiResources\Search;
 
-use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
 use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
+use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
 use ApiPlatform\Metadata\ApiFilter;
 use ApiPlatform\Metadata\ApiProperty;
 use ApiPlatform\Metadata\ApiResource;
@@ -19,7 +19,7 @@ use App\State\Provider\Search\PlaceSearchItemProvider;
 use Symfony\Component\ObjectMapper\Attribute\Map;
 
 /**
- * Classe resource pour les recherches de lieux
+ * Classe resource pour les recherches de lieux.
  */
 #[ApiResource(
     operations: [
@@ -29,7 +29,7 @@ use Symfony\Component\ObjectMapper\Attribute\Map;
         ),
         new GetCollection(
             uriTemplate: '/search/places'
-        )
+        ),
     ],
     provider: PlaceSearchItemProvider::class,
 )]

+ 2 - 3
src/Doctrine/Access/AdditionalExtension/DateTimeConstraintExtensionAdditional.php

@@ -20,10 +20,9 @@ class DateTimeConstraintExtensionAdditional implements AdditionalAccessExtension
 
     public function support(string $name): bool
     {
-        return
+        $request = $this->requestStack->getMainRequest();
 
-                $this->requestStack->getMainRequest()->isMethod('GET')
-        ;
+        return $request->isMethod('GET') && $request->get('_time_constraint', true);
     }
 
     public function addWhere(QueryBuilder $queryBuilder): void

+ 50 - 16
src/Entity/Booking/Event.php

@@ -13,6 +13,7 @@ use App\Entity\Place\PlaceSystem;
 use App\Entity\Place\Room;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 use App\Enum\Booking\PricingEventEnum;
+use App\Enum\Booking\VisibilityEnum;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
@@ -92,14 +93,14 @@ class Event extends AbstractBooking
     #[ORM\Column(type: 'text', nullable: true)]
     protected ?string $description;
 
-    #[ORM\Column]
+    #[ORM\Column(nullable: true)]
     #[Assert\Url(
         message: 'url-error',
         protocols: ['http', 'https']
     )]
     protected ?string $url = null;
 
-    #[ORM\Column]
+    #[ORM\Column(nullable: true)]
     #[Assert\Url(
         message: 'url-ticket-error',
         protocols: ['http', 'https']
@@ -109,14 +110,17 @@ class Event extends AbstractBooking
     #[ORM\Column(length: 255, nullable: true, enumType: PricingEventEnum::class)]
     private ?PricingEventEnum $pricing = null;
 
-    #[ORM\Column]
+    #[ORM\Column(nullable: true)]
     #[Assert\Positive]
     protected ?float $priceMini = null;
 
-    #[ORM\Column]
+    #[ORM\Column(nullable: true)]
     #[Assert\Positive]
     protected ?float $priceMaxi = null;
 
+    #[ORM\Column(length: 255, nullable: false, enumType: VisibilityEnum::class)]
+    protected VisibilityEnum $visibility;
+
     public function __construct()
     {
         $this->eventRecur = new ArrayCollection();
@@ -421,57 +425,87 @@ class Event extends AbstractBooking
         return $this;
     }
 
-    public function getDescription(): ?string{
+    public function getDescription(): ?string
+    {
         return $this->description;
     }
 
-    public function setDescription(?string $description): self{
+    public function setDescription(?string $description): self
+    {
         $this->description = $description;
+
         return $this;
     }
 
-    public function getUrl(): ?string{
+    public function getUrl(): ?string
+    {
         return $this->url;
     }
 
-    public function setUrl(?string $url): self{
+    public function setUrl(?string $url): self
+    {
         $this->url = $url;
+
         return $this;
     }
 
-    public function getUrlTicket(): ?string{
+    public function getUrlTicket(): ?string
+    {
         return $this->urlTicket;
     }
 
-    public function setUrlTicket(?string $urlTicket): self{
+    public function setUrlTicket(?string $urlTicket): self
+    {
         $this->urlTicket = $urlTicket;
+
         return $this;
     }
 
-    public function getPricing(): ?PricingEventEnum{
+    public function getPricing(): ?PricingEventEnum
+    {
         return $this->pricing;
     }
 
-    public function setPricing(?PricingEventEnum $pricing): self{
+    public function setPricing(?PricingEventEnum $pricing): self
+    {
         $this->pricing = $pricing;
+
         return $this;
     }
 
-    public function getPriceMini(): ?float{
+    public function getPriceMini(): ?float
+    {
         return $this->priceMini;
     }
 
-    public function setPriceMini(?float $priceMini): self{
+    public function setPriceMini(?float $priceMini): self
+    {
         $this->priceMini = $priceMini;
+
         return $this;
     }
 
-    public function getPriceMaxi(): ?float{
+    public function getPriceMaxi(): ?float
+    {
         return $this->priceMaxi;
     }
 
-    public function setPriceMaxi(?float $priceMaxi): self{
+    public function setPriceMaxi(?float $priceMaxi): self
+    {
         $this->priceMaxi = $priceMaxi;
+
+        return $this;
+    }
+
+    public function getVisibility(): ?VisibilityEnum
+    {
+        return $this->visibility;
+    }
+
+    public function setVisibility(?VisibilityEnum $visibility): self
+    {
+        $this->visibility = $visibility;
+
         return $this;
     }
 }

+ 31 - 1
src/Entity/Booking/EventGender.php

@@ -5,14 +5,28 @@ declare(strict_types=1);
 namespace App\Entity\Booking;
 
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
+use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
+use ApiPlatform\Metadata\ApiFilter;
 use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use App\Enum\Booking\EventGenderTypeEnum;
 use Doctrine\ORM\Mapping as ORM;
 
 /**
  * Enum des genres d'évènements.
  */
 // #[Auditable]
-#[ApiResource(operations: [])]
+#[ApiResource(
+    operations: [
+        new Get(security: 'is_granted(\'ROLE_EVENTS\') or is_granted(\'ROLE_USER_FREEMIUM\')'),
+        new GetCollection(
+            paginationEnabled: false,
+            security: 'is_granted(\'ROLE_EVENTS\') or is_granted(\'ROLE_USER_FREEMIUM\')'
+        ),
+    ]
+)]
+#[ApiFilter(filterClass: SearchFilter::class, properties: ['type' => 'exact'])]
 #[ORM\Entity]
 class EventGender
 {
@@ -21,8 +35,24 @@ class EventGender
     #[ORM\GeneratedValue]
     private ?int $id = null;
 
+    #[ORM\Column]
+    protected string $name;
+
+    #[ORM\Column(length: 255, nullable: false, enumType: EventGenderTypeEnum::class)]
+    protected EventGenderTypeEnum $type;
+
     public function getId(): ?int
     {
         return $this->id;
     }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function getType(): EventGenderTypeEnum
+    {
+        return $this->type;
+    }
 }

+ 0 - 1
src/Entity/Core/Subfamilly.php

@@ -29,7 +29,6 @@ class Subfamilly
         return $this->id;
     }
 
-
     public function getName(): string
     {
         return $this->name;

+ 13 - 8
src/Entity/Organization/TypeOfPractice.php

@@ -5,6 +5,8 @@ declare(strict_types=1);
 namespace App\Entity\Organization;
 
 use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
 use App\Enum\Cotisation\CategoryTypeOfPracticeEnum;
 use App\Enum\Cotisation\TypeOfPracticeEnum;
 use App\Repository\Organization\TypeOfPracticeRepository;
@@ -13,12 +15,20 @@ use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
 use JetBrains\PhpStorm\Pure;
-use Symfony\Component\Serializer\Annotation\Groups;
 
 /**
  * Type des pratique d'une organisation.
  */
-#[ApiResource(operations: [])]
+#[ApiResource(
+    operations: [
+        new Get(
+            security: 'is_granted(\'ROLE_GENERAL_CONFIG\') or is_granted(\'ROLE_USER_FREEMIUM\')'),
+        new GetCollection(
+            paginationEnabled: false,
+            security: 'is_granted(\'ROLE_GENERAL_CONFIG\') or is_granted(\'ROLE_USER_FREEMIUM\')'
+        ),
+    ]
+)]
 // #[Auditable]
 #[ORM\Entity(repositoryClass: TypeOfPracticeRepository::class)]
 class TypeOfPractice
@@ -26,22 +36,17 @@ class TypeOfPractice
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\GeneratedValue]
-    #[Groups(['read'])]
     private ?int $id = null;
 
     #[ORM\Column(length: 50, nullable: true, enumType: TypeOfPracticeEnum::class)]
-    #[Groups(['read'])]
     private ?TypeOfPracticeEnum $name = null;
 
     #[ORM\Column(length: 50, nullable: true, enumType: CategoryTypeOfPracticeEnum::class)]
-    #[Groups(['read'])]
     private ?CategoryTypeOfPracticeEnum $category = null;
 
     /** @var Collection<int, Organization> */
-    #[ORM\ManyToMany(targetEntity: Organization::class, inversedBy: 'typeOfPractices')]
+    #[ORM\ManyToMany(targetEntity: Organization::class, inversedBy: 'typeOfPractices', fetch: 'EXTRA_LAZY')]
     #[ORM\JoinTable(name: 'organization_type_of_practices')]
-    #[ORM\JoinColumn(name: 'typeofpractice_id', referencedColumnName: 'id')]
-    #[ORM\InverseJoinColumn(name: 'organization_id', referencedColumnName: 'id')]
     private Collection $organizations;
 
     #[Pure]

+ 1 - 1
src/Entity/Place/AbstractPlace.php

@@ -38,7 +38,7 @@ abstract class AbstractPlace
 
     #[ORM\ManyToOne(cascade: ['persist'], inversedBy: 'places')]
     #[ORM\JoinColumn(referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
-    private ?AddressPostal $addressPostal;
+    private ?AddressPostal $addressPostal = null;
 
     /** @var Collection<int, Tagg> */
     #[ORM\ManyToMany(targetEntity: Tagg::class, inversedBy: 'places', cascade: ['persist'])]

+ 0 - 1
src/Entity/Place/Place.php

@@ -6,7 +6,6 @@ namespace App\Entity\Place;
 
 use ApiPlatform\Metadata\ApiResource;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
-use ApiPlatform\Metadata\GetCollection;
 use App\Entity\Booking\Course;
 use App\Entity\Booking\EducationalProject;
 use App\Entity\Booking\Event;

+ 19 - 0
src/Enum/Booking/EventGenderTypeEnum.php

@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enum\Booking;
+
+use App\Enum\EnumMethodsTrait;
+
+/**
+ * Groupe des genre des événements.
+ */
+enum EventGenderTypeEnum: string
+{
+    use EnumMethodsTrait;
+
+    case PEDAGOGIC_EVENT = 'PEDAGOGIC_EVENT';
+    case CULTURAL_EVENT = 'CULTURAL_EVENT';
+    case INTERNAL_EVENT = 'INTERNAL_EVENT';
+}

+ 1 - 1
src/Enum/Booking/PricingEventEnum.php

@@ -7,7 +7,7 @@ namespace App\Enum\Booking;
 use App\Enum\EnumMethodsTrait;
 
 /**
- * Prix des événements
+ * Prix des événements.
  */
 enum PricingEventEnum: string
 {

+ 2 - 0
src/Enum/Cotisation/CategoryTypeOfPracticeEnum.php

@@ -18,4 +18,6 @@ enum CategoryTypeOfPracticeEnum: string
     case CATEGORY_CHORUS = 'CATEGORY_CHORUS';
     case CATEGORY_BAND = 'CATEGORY_BAND';
     case CATEGORY_OTHER = 'CATEGORY_OTHER';
+    case CATEGORY_DANCES = 'CATEGORY_DANCES';
+    case CATEGORY_TEACHING = 'CATEGORY_TEACHING';
 }

+ 1 - 1
src/Enum/Organization/OrganizationIdsEnum.php

@@ -17,5 +17,5 @@ enum OrganizationIdsEnum: int
     case _2IOS = 32366;
     case FFEC = 91295;
     case OPENTALENT_BASE = 13;
-    case OUTOFNET_PARENT = 93931;
+    case OPENTALENT = 93931;
 }

+ 19 - 0
src/Enum/Organization/PrincipalTypeShortListEnum.php

@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enum\Organization;
+
+use App\Enum\EnumMethodsTrait;
+
+/**
+ * Types principaux raccourci d'une organisation.
+ */
+enum PrincipalTypeShortListEnum: string
+{
+    use EnumMethodsTrait;
+
+    case ARTISTIC_EDUCATION_ONLY = 'ARTISTIC_EDUCATION_ONLY';
+    case ARTISTIC_PRACTICE_ONLY = 'ARTISTIC_PRACTICE_ONLY';
+    case ARTISTIC_PRACTICE_EDUCATION = 'ARTISTIC_PRACTICE_EDUCATION';
+}

+ 0 - 2
src/EventListener/OnKernelRequestPreRead.php

@@ -10,14 +10,12 @@ use App\Service\Doctrine\FiltersConfigurationService;
 use App\Service\Utils\ObjectUtils;
 use Symfony\Bundle\SecurityBundle\Security;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\HttpFoundation\RequestStack;
 use Symfony\Component\HttpKernel\Event\RequestEvent;
 use Symfony\Component\HttpKernel\KernelEvents;
 
 class OnKernelRequestPreRead implements EventSubscriberInterface
 {
     public function __construct(
-        private RequestStack $requestStack,
         private Security $security,
         private FiltersConfigurationService $filtersConfigurationService,
     ) {

+ 0 - 1
src/Repository/Place/PlaceRepository.php

@@ -4,7 +4,6 @@ declare(strict_types=1);
 
 namespace App\Repository\Place;
 
-use App\Entity\Education\Cycle;
 use App\Entity\Place\Place;
 use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 use Doctrine\Persistence\ManagerRegistry;

+ 9 - 7
src/Security/Voter/ModuleVoter.php

@@ -43,10 +43,10 @@ class ModuleVoter extends Voter
         }
 
         $resourceMetadata = $this->resourceMetadataFactory->create($subject->attributes->get('_api_resource_class'));
-        $module = $this->module->getModuleByResourceName($resourceMetadata->getOperation()->getShortName());
+        $modules = $this->module->getModulesByResourceName($resourceMetadata->getOperation()->getShortName());
 
         // Check if there is a module for this entity : eq configuration problem
-        if ($module === null) {
+        if (empty($modules)) {
             throw new AccessDeniedHttpException(sprintf('There are no module for the entity (%s) !', $resourceMetadata->getOperation()->getShortName()));
         }
 
@@ -55,8 +55,8 @@ class ModuleVoter extends Voter
         /** @var Organization $organization */
         $organization = $currentAccess->getOrganization();
 
-        if (!$this->isOrganizationHaveThisModule($organization, $module)) {
-            throw new AccessDeniedHttpException(sprintf("The organization doesn't have the module '%s'", $module));
+        if (!$this->isOrganizationHaveThisModule($organization, $modules)) {
+            throw new AccessDeniedHttpException(sprintf("The organization doesn't have one of the module"));
         }
 
         return true;
@@ -64,12 +64,14 @@ class ModuleVoter extends Voter
 
     /**
      * Test si l'organisation possède le module parmis les modules possédés via le produit souscrit, les options souscrites
-     * ou les modules possédées via des conditions particulières (isCmf par exemple).
+     *  ou les modules possédées via des conditions particulières (isCmf par exemple).
+     *
+     * @param array<string> $modules
      */
-    private function isOrganizationHaveThisModule(Organization $organization, string $module): bool
+    private function isOrganizationHaveThisModule(Organization $organization, array $modules): bool
     {
         $organizationModules = $this->module->getOrganizationModules($organization);
 
-        return in_array($module, $organizationModules);
+        return !empty(array_intersect($organizationModules, $modules));
     }
 }

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

@@ -45,7 +45,6 @@ class ApiLegacyRequestService extends ApiRequestService
         $headers = [
             'Accept' => '*/*',
             'Charset' => 'UTF-8',
-            'Accept-Encoding' => 'gzip, deflate, br',
             'Content-Type' => 'application/ld+json',
         ];
 

+ 38 - 39
src/Service/ApiResourceBuilder/Freemium/EventMappingBuilder.php

@@ -8,39 +8,41 @@ use App\ApiResources\Freemium\FreemiumEvent;
 use App\Entity\Booking\Event;
 use App\Entity\Core\AddressPostal;
 use App\Entity\Place\Place;
+use App\Enum\Booking\VisibilityEnum;
 use Doctrine\ORM\EntityManagerInterface;
+use Ramsey\Uuid\Uuid;
 
 /**
- * Mapping des informations d'un Event avec comme source un FreemiumEvent
+ * Mapping des informations d'un Event avec comme source un FreemiumEvent.
  */
 class EventMappingBuilder
 {
     public function __construct(
-        private EntityManagerInterface $em
-    )
-    {}
+        private EntityManagerInterface $em,
+    ) {
+    }
 
     /**
      * Mapping des informations.
      *
-     * @param Event $event : objet target
+     * @param Event         $event         : objet target
      * @param FreemiumEvent $freemiumEvent : objet source
      */
-    public function mapInformations(Event $event, FreemiumEvent $freemiumEvent)
+    public function mapInformations(Event $event, FreemiumEvent $freemiumEvent): void
     {
-        $this->mapEventInformations( $event, $freemiumEvent);
-        $this->mapEventPlaceInformations( $event, $freemiumEvent);
+        $this->mapEventInformations($event, $freemiumEvent);
+        $this->mapEventPlaceInformations($event, $freemiumEvent);
     }
 
     /**
      * Mapping des informations générales.
      *
-     * @param Event $event : objet target
+     * @param Event         $event         : objet target
      * @param FreemiumEvent $freemiumEvent : objet source
      */
-    private function mapEventInformations(Event $event, FreemiumEvent $freemiumEvent)
+    protected function mapEventInformations(Event $event, FreemiumEvent $freemiumEvent): void
     {
-        //General informations
+        // General informations
         $event->setName($freemiumEvent->name);
         $event->setOrganization($freemiumEvent->organization);
         $event->setDatetimeStart($freemiumEvent->datetimeStart);
@@ -52,8 +54,11 @@ class EventMappingBuilder
         $event->setPricing($freemiumEvent->pricing);
         $event->setPriceMini($freemiumEvent->priceMini);
         $event->setPriceMaxi($freemiumEvent->priceMaxi);
+        $event->setGender($freemiumEvent->gender);
+        $event->setVisibility(VisibilityEnum::PUBLIC_VISIBILITY);
+        $event->setUuid(Uuid::uuid4()->toString());
 
-        //Catégories
+        // Catégories
         $event->removeAllCategories();
         foreach ($freemiumEvent->categories as $category) {
             $event->addCategory($category);
@@ -61,14 +66,12 @@ class EventMappingBuilder
     }
 
     /**
-     * Recherche et mapping du lieu de lévénement
-     * @param Event $event
-     * @param FreemiumEvent $freemiumEvent
-     * @return void
+     * Recherche et mapping du lieu de lévénement.
      */
-    private function mapEventPlaceInformations(Event $event, FreemiumEvent $freemiumEvent){
+    protected function mapEventPlaceInformations(Event $event, FreemiumEvent $freemiumEvent): void
+    {
         $place = $this->getPlace($freemiumEvent);
-        if($place !== null){
+        if ($place !== null) {
             $this->mapPlaceInformations($place, $freemiumEvent);
             $this->em->persist($place);
         }
@@ -76,15 +79,13 @@ class EventMappingBuilder
     }
 
     /**
-     * Mapping des informations du lieux et de son adresse postale
-     * @param FreemiumEvent $freemiumEvent
-     * @return Place|array|object[]
+     * Mapping des informations du lieux et de son adresse postale.
      */
-    private function mapPlaceInformations(Place $place, FreemiumEvent $freemiumEvent): void
+    protected function mapPlaceInformations(Place $place, FreemiumEvent $freemiumEvent): void
     {
         $addressPostal = $this->getAddressPostal($place);
 
-        //Mapping des informations de l'adresse
+        // Mapping des informations de l'adresse
         $addressPostal
             ->setStreetAddress($freemiumEvent->streetAddress)
             ->setStreetAddressSecond($freemiumEvent->streetAddressSecond)
@@ -95,45 +96,43 @@ class EventMappingBuilder
             ->setLatitude($freemiumEvent->latitude)
             ->setLongitude($freemiumEvent->longitude);
 
-        //Mapping des informations du lieu
+        // Mapping des informations du lieu
         $place
-            ->setName($freemiumEvent->placeName)
             ->setOrganization($freemiumEvent->organization)
+            ->setName($freemiumEvent->placeName)
             ->setAddressPostal($addressPostal);
     }
 
     /**
-     * Récupération de la place si définie, sinon on en créer une si un minimum d'information est fournies
-     * @param FreemiumEvent $freemiumEvent
-     * @return Place|null
+     * Récupération de la place si définie, sinon on en créer une si un minimum d'information est fournies.
      */
-    private function getPlace(FreemiumEvent $freemiumEvent): ?Place
+    protected function getPlace(FreemiumEvent $freemiumEvent): ?Place
     {
         if ($freemiumEvent->place) {
             return $freemiumEvent->place;
-        } else if (
-            $freemiumEvent->placeName ||
-            $freemiumEvent->streetAddress ||
-            $freemiumEvent->streetAddressSecond ||
-            $freemiumEvent->streetAddressThird ||
-            $freemiumEvent->postalCode ||
-            $freemiumEvent->addressCity
+        } elseif (
+            $freemiumEvent->placeName
+            || $freemiumEvent->streetAddress
+            || $freemiumEvent->streetAddressSecond
+            || $freemiumEvent->streetAddressThird
+            || $freemiumEvent->postalCode
+            || $freemiumEvent->addressCity
         ) {
             return new Place();
         }
+
         return null;
     }
 
     /**
      * Récupération de l'adresse postale si définie, sinon on en créer une nouvelle.
-     * @param Place $place
-     * @return AddressPostal
      */
-    private function getAddressPostal(Place $place): AddressPostal
+    protected function getAddressPostal(Place $place): AddressPostal
     {
         if ($place->getAddressPostal()) {
             return $place->getAddressPostal();
         }
+
         return new AddressPostal();
     }
 }

+ 58 - 6
src/Service/ApiResourceBuilder/Freemium/OrganizationMappingBuilder.php

@@ -9,11 +9,14 @@ use App\Entity\Core\AddressPostal;
 use App\Entity\Core\ContactPoint;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\OrganizationAddressPostal;
+use App\Entity\Organization\TypeOfPractice;
 use App\Enum\Core\ContactPointTypeEnum;
 use App\Enum\Organization\AddressPostalOrganizationTypeEnum;
 
 /**
- * Mapping des informations d'une Organization avec comme source un FreemiumOrganization
+ * Class OrganizationMappingBuilder.
+ *
+ * Mapping class that maps information from a FreemiumOrganization to an Organization entity.
  */
 class OrganizationMappingBuilder
 {
@@ -28,6 +31,9 @@ class OrganizationMappingBuilder
         // Mapping des infos principales
         $this->mapOrganizationInformations($organization, $freemiumOrganization);
 
+        // Mapping des type de pratiques
+        $this->updateOrganizationTypeOfPractices($organization, $freemiumOrganization);
+
         // Mapping des infos du point de contact principal
         $this->mapContactPointInformations($this->getPrincipalContactPointOrCreateNewOne($organization), $freemiumOrganization);
 
@@ -41,9 +47,11 @@ class OrganizationMappingBuilder
      * @param Organization         $organization         : objet target
      * @param FreemiumOrganization $freemiumOrganization : objet source
      */
-    private function mapOrganizationInformations(Organization $organization, FreemiumOrganization $freemiumOrganization): void
+    protected function mapOrganizationInformations(Organization $organization, FreemiumOrganization $freemiumOrganization): void
     {
         $organization->setName($freemiumOrganization->name);
+        $organization->setPrincipalType($freemiumOrganization->principalType);
+        $organization->setLegalStatus($freemiumOrganization->legalStatus);
         $organization->setDescription($freemiumOrganization->description);
         $organization->setFacebook($freemiumOrganization->facebook);
         $organization->setYoutube($freemiumOrganization->youtube);
@@ -53,13 +61,57 @@ class OrganizationMappingBuilder
         $organization->setLogo($freemiumOrganization->logo);
     }
 
+    /**
+     * Update the organization's type of practices based on the Freemium organization's type of practices.
+     *
+     * @param Organization         $organization         the organization to update the type of practices for
+     * @param FreemiumOrganization $freemiumOrganization the Freemium organization containing the desired type of practices
+     */
+    protected function updateOrganizationTypeOfPractices(Organization $organization, FreemiumOrganization $freemiumOrganization): void
+    {
+        foreach ($organization->getTypeOfPractices() as $organizationPractice) {
+            $this->removePracticeIfNotInFreemium($organization, $freemiumOrganization, $organizationPractice);
+        }
+
+        foreach ($freemiumOrganization->typeOfPractices as $typeOfPractice) {
+            $this->addPracticeIfNotInOrganization($organization, $typeOfPractice);
+        }
+    }
+
+    /**
+     * Removes a practice from the organization if it is not in the freemium organization.
+     *
+     * @param Organization         $organization         the organization from which to remove the practice
+     * @param FreemiumOrganization $freemiumOrganization the freemium organization used for comparison
+     * @param TypeOfPractice       $organizationPractice the practice to be removed if not in freemium organization
+     */
+    private function removePracticeIfNotInFreemium(Organization $organization, FreemiumOrganization $freemiumOrganization, TypeOfPractice $organizationPractice): void
+    {
+        if (!$freemiumOrganization->typeOfPractices->contains($organizationPractice)) {
+            $organization->removeTypeOfPractice($organizationPractice);
+        }
+    }
+
+    /**
+     * Adds the given TypeOfPractice to the Organization if it is not already associated with it.
+     *
+     * @param Organization   $organization   The organization to add the TypeOfPractice to
+     * @param TypeOfPractice $typeOfPractice The TypeOfPractice to add to the organization
+     */
+    private function addPracticeIfNotInOrganization(Organization $organization, TypeOfPractice $typeOfPractice): void
+    {
+        if (!$organization->getTypeOfPractices()->contains($typeOfPractice)) {
+            $organization->addTypeOfPractice($typeOfPractice);
+        }
+    }
+
     /**
      * Mapping des informations de ContactPoint depuis FreemiumOrganization.
      *
      * @param ContactPoint         $contactPoint         : objet target
      * @param FreemiumOrganization $freemiumOrganization : objet source
      */
-    private function mapContactPointInformations(ContactPoint $contactPoint, FreemiumOrganization $freemiumOrganization): void
+    protected function mapContactPointInformations(ContactPoint $contactPoint, FreemiumOrganization $freemiumOrganization): void
     {
         $contactPoint->setTelphone($freemiumOrganization->tel);
         $contactPoint->setEmail($freemiumOrganization->email);
@@ -71,7 +123,7 @@ class OrganizationMappingBuilder
      * @param AddressPostal        $address              : objet target
      * @param FreemiumOrganization $freemiumOrganization : objet source
      */
-    private function mapAddressPostalInformations(AddressPostal $address, FreemiumOrganization $freemiumOrganization): void
+    protected function mapAddressPostalInformations(AddressPostal $address, FreemiumOrganization $freemiumOrganization): void
     {
         $address->setStreetAddress($freemiumOrganization->streetAddress);
         $address->setStreetAddressSecond($freemiumOrganization->streetAddressSecond);
@@ -86,7 +138,7 @@ class OrganizationMappingBuilder
     /**
      * On récupère le point de contact principal de l'organisation. Si elle n'existe pas on l'à créer.
      */
-    private function getPrincipalContactPointOrCreateNewOne(Organization $organization): ContactPoint
+    protected function getPrincipalContactPointOrCreateNewOne(Organization $organization): ContactPoint
     {
         $principalContactPoint = $organization->getPrincipalContactPoint();
         if ($principalContactPoint) {
@@ -103,7 +155,7 @@ class OrganizationMappingBuilder
     /**
      * On récupère l'adresse principale de l'organisation. Si elle n'existe pas on l'à créer.
      */
-    private function getPrincipalAddressPostalOrCreateNewOne(Organization $organization): AddressPostal
+    protected function getPrincipalAddressPostalOrCreateNewOne(Organization $organization): AddressPostal
     {
         $principalAddressPostal = $organization->getPrincipalAddressPostal();
         if ($principalAddressPostal) {

+ 14 - 2
src/Service/Doctrine/FiltersConfigurationService.php

@@ -26,6 +26,8 @@ class FiltersConfigurationService
      */
     protected ?bool $previousTimeConstraintState = null;
 
+    protected bool $filtersConfigured = false;
+
     public function __construct(
         private EntityManagerInterface $entityManager,
         private DateTimeConstraint $dateTimeConstraint,
@@ -55,6 +57,8 @@ class FiltersConfigurationService
         $activityYearFilter = $this->getFilters()->enable('activity_year_filter');
         $activityYearFilter->setAccessId($accessId);
         $activityYearFilter->setTimeConstraint($this->activityYearConstraint);
+
+        $this->filtersConfigured = true;
     }
 
     /**
@@ -65,8 +69,12 @@ class FiltersConfigurationService
      */
     public function suspendTimeConstraintFilters(): void
     {
+        if (!$this->filtersConfigured) {
+            return;
+        }
+
         if ($this->previousTimeConstraintState !== null) {
-            throw new \RuntimeException('time constraints is already suspended');
+            throw new \RuntimeException('The time constraints are already suspended');
         }
 
         if ($this->timeFiltersAlreadyDisabled()) {
@@ -112,8 +120,12 @@ class FiltersConfigurationService
      */
     public function restoreTimeConstraintFilters(): void
     {
+        if (!$this->filtersConfigured) {
+            return;
+        }
+
         if ($this->previousTimeConstraintState === null) {
-            throw new \RuntimeException('time constraints has not been suspended, can not be restored');
+            throw new \RuntimeException('The time constraints have not been suspended, can not be restored');
         }
 
         if ($this->previousTimeConstraintState === false) {

+ 20 - 16
src/Service/Export/Encoder/PdfEncoder.php

@@ -14,18 +14,19 @@ use Dompdf\Options;
  */
 class PdfEncoder implements EncoderInterface
 {
-    protected Options $domPdfOptions;
-    protected Dompdf $dompdf;
+    public function support(string $format): bool
+    {
+        return $format === ExportFormatEnum::PDF->value;
+    }
 
-    public function __construct()
+    protected function getDomPdf(): Dompdf
     {
-        $this->domPdfOptions = new Options();
-        $this->dompdf = new Dompdf();
+        return new Dompdf();
     }
 
-    public function support(string $format): bool
+    protected function getDomPdfOptions(): Options
     {
-        return $format === ExportFormatEnum::PDF->value;
+        return new Options();
     }
 
     /**
@@ -40,16 +41,19 @@ class PdfEncoder implements EncoderInterface
      */
     public function encode(string $html, array $options = []): string
     {
-        $this->domPdfOptions->setIsRemoteEnabled(true);
-        $this->domPdfOptions->setChroot(PathUtils::getProjectDir().'/public');
-        $this->domPdfOptions->setDefaultPaperOrientation('portrait');
-        $this->domPdfOptions->setDefaultPaperSize('A4');
-        $this->domPdfOptions->set($options);
+        $domPdfOptions = $this->getDomPdfOptions();
+        $dompdf = $this->getDomPdf();
+
+        $domPdfOptions->setIsRemoteEnabled(true);
+        $domPdfOptions->setChroot(PathUtils::getProjectDir().'/public');
+        $domPdfOptions->setDefaultPaperOrientation('portrait');
+        $domPdfOptions->setDefaultPaperSize('A4');
+        $domPdfOptions->set($options);
 
-        $this->dompdf->setOptions($this->domPdfOptions);
-        $this->dompdf->loadHtml($html);
-        $this->dompdf->render();
+        $dompdf->setOptions($domPdfOptions);
+        $dompdf->loadHtml($html);
+        $dompdf->render();
 
-        return $this->dompdf->output();
+        return $dompdf->output();
     }
 }

+ 5 - 4
src/Service/Export/LicenceCmfExporter.php

@@ -11,6 +11,7 @@ use App\Enum\Core\FileTypeEnum;
 use App\Repository\Organization\OrganizationRepository;
 use App\Service\Export\Model\LicenceCmf;
 use App\Service\Export\Model\LicenceCmfCollection;
+use App\Service\Utils\StringsUtils;
 
 /**
  * Exporte la licence CMF de la structure ou du ou des access, au format demandé.
@@ -49,12 +50,12 @@ class LicenceCmfExporter extends BaseExporter implements ExporterInterface
         $licenceCmf->setId($organization->getId());
         $licenceCmf->setYear($exportRequest->getYear());
         $licenceCmf->setIsOrganizationLicence($exportRequest instanceof LicenceCmfOrganizationER);
-        $licenceCmf->setOrganizationName($organization->getName());
+        $licenceCmf->setOrganizationName(StringsUtils::elide($organization->getName(), 64));
         $licenceCmf->setOrganizationIdentifier($organization->getIdentifier());
 
         $parentFederation = $organization->getNetworkOrganizations()->get(0)?->getParent();
         if ($parentFederation !== null) {
-            $licenceCmf->setFederationName($parentFederation->getName());
+            $licenceCmf->setFederationName(StringsUtils::elide($parentFederation->getName(), 64));
         }
 
         $licenceCmf->setColor(
@@ -72,8 +73,8 @@ class LicenceCmfExporter extends BaseExporter implements ExporterInterface
             if ($president !== null) {
                 $licenceCmf->setPersonId($president->getId());
                 $licenceCmf->setPersonGender($president->getGender() ?? null);
-                $licenceCmf->setPersonFirstName($president->getGivenName());
-                $licenceCmf->setPersonLastName($president->getName());
+                $licenceCmf->setPersonFirstName(StringsUtils::elide($president->getGivenName(), 64));
+                $licenceCmf->setPersonLastName(StringsUtils::elide($president->getName(), 64));
             }
         }
 

+ 1 - 1
src/Service/File/Storage/ApiLegacyStorage.php

@@ -44,7 +44,7 @@ class ApiLegacyStorage implements FileStorageInterface
      */
     public function getImageUrl(File $file, string $size, bool $relativePath, bool $uncropped = false): string
     {
-        $url = sprintf('api/files/%s/download/%s?relativePath=1', $file->getId(), $size);
+        $url = sprintf('api/public/files/%s/download/%s?relativePath=1', $file->getId(), $size);
 
         if ($uncropped) {
             $url .= '&noCache=true';

+ 2 - 4
src/Service/Organization/OrganizationFactory.php

@@ -720,13 +720,11 @@ class OrganizationFactory
         $contactPoint->setEmail($organizationMemberCreationRequest->getEmail());
 
         if ($organizationMemberCreationRequest->getPhone() !== null) {
-            $phoneNumber = $this->phoneNumberUtil->parse($organizationMemberCreationRequest->getPhone());
-            $contactPoint->setTelphone($phoneNumber);
+            $contactPoint->setTelphone($organizationMemberCreationRequest->getPhone());
         }
 
         if ($organizationMemberCreationRequest->getMobile() !== null) {
-            $mobileNumber = $this->phoneNumberUtil->parse($organizationMemberCreationRequest->getMobile());
-            $contactPoint->setMobilPhone($mobileNumber);
+            $contactPoint->setMobilPhone($organizationMemberCreationRequest->getMobile());
         }
 
         $contactPoint->setCreateDate($creationDate);

+ 7 - 4
src/Service/Security/Module.php

@@ -149,16 +149,19 @@ class Module
 
     /**
      * Retourne le module possédant la resource passée en paramètre.
+     *
+     * @return array<string>
      */
-    public function getModuleByResourceName(string $resource): int|string|null
+    public function getModulesByResourceName(string $resource): array
     {
+        $resourceModules = [];
         $modules = $this->parameterBag->get('opentalent.modules');
         foreach ($modules as $module => $data) {
-            if ($data['resources'] && in_array($resource, $data['resources'], true)) {
-                return $module;
+            if (array_key_exists('resources', $data) && $data['resources'] && in_array($resource, $data['resources'], true)) {
+                $resourceModules[] = $module;
             }
         }
 
-        return null;
+        return $resourceModules;
     }
 }

+ 3 - 4
src/Service/Shop/ShopService.php

@@ -107,10 +107,9 @@ class ShopService
         // Dispatch appropriate job based on request type
         switch ($shopRequest->getType()->value) {
             case ShopRequestType::NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL->value:
-                $this->handleNewStructureArtistPremiumTrialRequest($shopRequest->getToken());
-//                $this->messageBus->dispatch(
-//                    new NewStructureArtistPremiumTrial($shopRequest->getToken())
-//                );
+                $this->messageBus->dispatch(
+                    new NewStructureArtistPremiumTrial($shopRequest->getToken())
+                );
                 break;
             default:
                 throw new \RuntimeException('request type not supported');

+ 4 - 4
src/Service/State/Provider/ProviderUtils.php

@@ -50,6 +50,7 @@ class ProviderUtils
         }
 
         $doctrinePaginator = new DoctrinePaginator($qb);
+
         return new TraversablePaginator(
             $doctrinePaginator,
             $this->pagination->getPage($context),
@@ -59,11 +60,10 @@ class ProviderUtils
     }
 
     /**
-     * @param $mappedItems
-     * @param $originalPaginator
-     * @return TraversablePaginator
+     * @param array<mixed> $mappedItems
      */
-    public function getTraversablePaginator(array $mappedItems, TraversablePaginator $originalPaginator) : TraversablePaginator{
+    public function getTraversablePaginator(array $mappedItems, TraversablePaginator $originalPaginator): TraversablePaginator
+    {
         return new TraversablePaginator(
             new \ArrayIterator($mappedItems),
             $originalPaginator->getCurrentPage(),

+ 25 - 0
src/Service/Twig/AssetsExtension.php

@@ -19,9 +19,13 @@ use Twig\TwigFunction;
  */
 class AssetsExtension extends AbstractExtension
 {
+    private string $publicDir;
+
     public function __construct(
+        string $projectDir,
         private readonly FileManager $fileManager,
     ) {
+        $this->publicDir = $projectDir.'/public';
     }
 
     public function getFunctions(): array
@@ -29,6 +33,8 @@ class AssetsExtension extends AbstractExtension
         return [
             new TwigFunction('absPath', [$this, 'absPath']),
             new TwigFunction('fileImagePath', [$this, 'fileImagePath']),
+            new TwigFunction('asset_absolute', [$this, 'getAssetAbsolutePath']),
+            new TwigFunction('asset_base64', [$this, 'getAssetBase64']),
         ];
     }
 
@@ -59,4 +65,23 @@ class AssetsExtension extends AbstractExtension
     {
         return ltrim($this->fileManager->getImageUrl($file, $size, true), '/');
     }
+
+    public function getAssetAbsolutePath(string $path): string
+    {
+        return $this->publicDir.'/'.ltrim($path, '/');
+    }
+
+    public function getAssetBase64(string $path): string
+    {
+        $absolutePath = $this->getAssetAbsolutePath($path);
+
+        if (!file_exists($absolutePath)) {
+            throw new \RuntimeException("Asset not found: {$absolutePath}");
+        }
+
+        $imageData = file_get_contents($absolutePath);
+        $mimeType = mime_content_type($absolutePath);
+
+        return 'data:'.$mimeType.';base64,'.base64_encode($imageData);
+    }
 }

+ 4 - 5
src/Service/Twig/ToBase64Extension.php

@@ -1,20 +1,19 @@
 <?php
+
 declare(strict_types=1);
 
 namespace App\Service\Twig;
 
-use App\Service\Utils\PathUtils;
 use Path\Path;
-use Symfony\Component\Asset\Packages;
 use Twig\Extension\AbstractExtension;
 use Twig\TwigFilter;
 
 class ToBase64Extension extends AbstractExtension
 {
     public function __construct(
-        private Packages $packages,
-        private string $projectDir
-    ) {}
+        private string $projectDir,
+    ) {
+    }
 
     public function getFilters(): array
     {

+ 20 - 0
src/Service/Utils/DebugUtils.php

@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Utils;
+
+use Path\Path;
+
+class DebugUtils
+{
+    public static function dumpToFile(mixed $var, bool $append = true): void
+    {
+        $debugFile = (new Path(PathUtils::getProjectDir()))->append('var', 'dump.log');
+
+        $datetime = date('Y-m-d H:i:s');
+        $content = json_encode($var, JSON_PRETTY_PRINT);
+
+        $debugFile->putContent("$datetime - $content\n", $append);
+    }
+}

+ 14 - 0
src/Service/Utils/StringsUtils.php

@@ -45,4 +45,18 @@ class StringsUtils
     {
         return strip_tags(preg_replace('{<(head|style)\b.*?</\1>}is', '', $html));
     }
+
+    /**
+     * Tronque un nom au nombre de caractères maximum demandé et ajoute "..." si nécessaire.
+     *
+     * @see StringsUtilsTest::testElide()
+     */
+    public static function elide(string $str, int $length): string
+    {
+        if ($length <= 5) {
+            throw new \InvalidArgumentException('Length must be greater than 5');
+        }
+
+        return mb_strlen($str) > $length ? mb_substr($str, 0, $length - 3).'...' : $str;
+    }
 }

+ 18 - 12
src/State/Processor/Freemium/FreemiumEventProcessor.php

@@ -11,9 +11,7 @@ use App\ApiResources\Freemium\FreemiumEvent;
 use App\Entity\Booking\Event;
 use App\Repository\Booking\EventRepository;
 use App\Service\ApiResourceBuilder\Freemium\EventMappingBuilder;
-use App\Service\ApiResourceBuilder\Freemium\FreemiumEventBuilder;
 use App\State\Processor\EntityProcessor;
-use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\ORM\EntityManagerInterface;
 
 /**
@@ -30,23 +28,17 @@ class FreemiumEventProcessor extends EntityProcessor
 
     /**
      * @param FreemiumEvent $data
-     * @param Operation $operation
-     * @param array $uriVariables
-     * @param array $context
-     * @return FreemiumEvent
+     * @param array<mixed>  $uriVariables
+     * @param array<mixed>  $context
      */
     public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): FreemiumEvent
     {
-        if($operation instanceof Post){
-            $event = new Event();
-        }else{
-            $event = $this->eventRepository->find($uriVariables['id']);
-        }
+        $event = $this->getEvent($operation, $uriVariables);
 
         if ($operation instanceof Delete) {
             $this->entityManager->remove($event);
             $freemiumEvent = new FreemiumEvent();
-        }else{
+        } else {
             $this->eventMappingBuilder->mapInformations($event, $data);
             $this->entityManager->persist($event);
             $freemiumEvent = $data;
@@ -60,4 +52,18 @@ class FreemiumEventProcessor extends EntityProcessor
 
         return $freemiumEvent;
     }
+
+    /**
+     * Retourne soit un nouvel Event en POST soit l'Event de base en PUT ou DELETE.
+     *
+     * @param array<mixed> $uriVariables
+     */
+    public function getEvent(Operation $operation, array $uriVariables = []): Event
+    {
+        if ($operation instanceof Post) {
+            return new Event();
+        } else {
+            return $this->eventRepository->find($uriVariables['id']);
+        }
+    }
 }

+ 0 - 1
src/State/Processor/Freemium/FreemiumOrganizationProcessor.php

@@ -8,7 +8,6 @@ use ApiPlatform\Metadata\Delete;
 use ApiPlatform\Metadata\Operation;
 use App\ApiResources\Freemium\FreemiumOrganization;
 use App\Entity\Access\Access;
-use App\Service\ApiResourceBuilder\Freemium\FreemiumOrganizationBuilder;
 use App\Service\ApiResourceBuilder\Freemium\OrganizationMappingBuilder;
 use App\State\Processor\EntityProcessor;
 use Doctrine\ORM\EntityManagerInterface;

+ 1 - 1
src/State/Provider/Core/DownloadProvider.php

@@ -52,7 +52,7 @@ final class DownloadProvider implements ProviderInterface
             throw new \RuntimeException('File '.$fileId.' does not exist; abort.');
         }
         if ($file->getStatus() !== FileStatusEnum::READY) {
-            throw new \RuntimeException('File '.$fileId.' has '.$file->getStatus().' status; abort.');
+            throw new \RuntimeException('File '.$fileId.' has '.$file->getStatus()->value.' status; abort.');
         }
 
         $content = $this->fileManager->read($file);

+ 2 - 2
src/State/Provider/Core/EventCategoryProvider.php

@@ -26,9 +26,9 @@ final class EventCategoryProvider implements ProviderInterface
      * @param mixed[] $uriVariables
      * @param mixed[] $context
      *
-     * @return EventCategory[]|EventCategory
+     * @return EventCategory[]
      */
-    public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|EventCategory
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
     {
         if (!$operation instanceof GetCollection) {
             throw new \RuntimeException('Only GetCollection operation is supported', Response::HTTP_METHOD_NOT_ALLOWED);

+ 11 - 11
src/State/Provider/Freemium/FreemiumEventProvider.php

@@ -20,13 +20,13 @@ use Symfony\Component\ObjectMapper\ObjectMapperInterface;
 /**
  * Class AccessProfileProvider : custom provider pour assurer l'alimentation de la réponse du GET my_profile.
  */
-final class FreemiumEventProvider implements ProviderInterface
+class FreemiumEventProvider implements ProviderInterface
 {
     public function __construct(
         private ProviderUtils $providerUtils,
         private ObjectMapperInterface $objectMapper,
         private EventRepository $eventRepository,
-        private FiltersConfigurationService $filtersConfigurationService
+        private FiltersConfigurationService $filtersConfigurationService,
     ) {
     }
 
@@ -37,18 +37,19 @@ final class FreemiumEventProvider implements ProviderInterface
      * @throws \Doctrine\ORM\Exception\ORMException
      * @throws \Doctrine\ORM\OptimisticLockException
      */
-    public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator|FreemiumEvent|null
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator|FreemiumEvent
     {
         if ($operation instanceof GetCollection) {
             return $this->provideCollection($operation, $context);
         }
+
         return $this->provideItem($uriVariables, $context);
     }
 
     /**
      * @param array<mixed> $context
      */
-    private function provideCollection(Operation $operation, array $context): TraversablePaginator
+    public function provideCollection(Operation $operation, array $context): TraversablePaginator
     {
         $this->filtersConfigurationService->suspendTimeConstraintFilters();
 
@@ -56,9 +57,8 @@ final class FreemiumEventProvider implements ProviderInterface
 
         $mappedItems = [];
         foreach ($originalPaginator as $item) {
-            $mappedItems[]= $this->objectMapper->map($item, FreemiumEvent::class);
+            $mappedItems[] = $this->objectMapper->map($item, FreemiumEvent::class);
         }
-
         $this->filtersConfigurationService->restoreTimeConstraintFilters();
 
         return $this->providerUtils->getTraversablePaginator($mappedItems, $originalPaginator);
@@ -71,21 +71,21 @@ final class FreemiumEventProvider implements ProviderInterface
      * @throws \Doctrine\ORM\Exception\ORMException
      * @throws \Doctrine\ORM\OptimisticLockException
      */
-    private function provideItem(array $uriVariables, array $context): ?FreemiumEvent
+    public function provideItem(array $uriVariables, array $context): FreemiumEvent
     {
         $this->filtersConfigurationService->suspendTimeConstraintFilters();
         /** @var Event $event */
         $event = $this->eventRepository->find($uriVariables['id']);
-        if(empty($event)){
+        if (empty($event)) {
             throw new NotFoundHttpException('event not found');
         }
         $this->filtersConfigurationService->restoreTimeConstraintFilters();
 
         $freemiumEvent = $this->objectMapper->map($event, FreemiumEvent::class);
 
-        //Afin de s'assurer que les catégories ne sont plus directement liées à l'Event source.
-        $categories = new ArrayCollection();;
-        foreach ($event->getCategories() as $cat){
+        // Afin de s'assurer que les catégories ne sont plus directement liées à l'Event source.
+        $categories = new ArrayCollection();
+        foreach ($event->getCategories() as $cat) {
             $categories->add($cat);
         }
         $freemiumEvent->categories = $categories;

+ 11 - 1
src/State/Provider/Freemium/FreemiumOrganizationProvider.php

@@ -10,6 +10,7 @@ use ApiPlatform\State\ProviderInterface;
 use App\ApiResources\Freemium\FreemiumOrganization;
 use App\Entity\Access\Access;
 use App\Entity\Organization\Organization;
+use Doctrine\Common\Collections\ArrayCollection;
 use Symfony\Bundle\SecurityBundle\Security;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\ObjectMapper\ObjectMapperInterface;
@@ -39,6 +40,15 @@ final class FreemiumOrganizationProvider implements ProviderInterface
         /** @var Organization $organization */
         $organization = $access->getOrganization();
 
-        return $this->objectMapper->map($organization, FreemiumOrganization::class);
+        $freemiumOrganization = $this->objectMapper->map($organization, FreemiumOrganization::class);
+
+        // Afin de s'assurer que les catégories ne sont plus directement liées à l'Event source.
+        $typeOfPractices = new ArrayCollection();
+        foreach ($organization->getTypeOfPractices() as $typeOfPractice) {
+            $typeOfPractices->add($typeOfPractice);
+        }
+        $freemiumOrganization->typeOfPractices = $typeOfPractices;
+
+        return $freemiumOrganization;
     }
 }

+ 5 - 5
src/State/Provider/Freemium/FreemiumPlaceProvider.php

@@ -6,7 +6,6 @@ namespace App\State\Provider\Freemium;
 
 use ApiPlatform\Metadata\GetCollection;
 use ApiPlatform\Metadata\Operation;
-use ApiPlatform\State\Pagination\TraversablePaginator;
 use ApiPlatform\State\ProviderInterface;
 use App\ApiResources\Freemium\FreemiumPlace;
 use App\Entity\Place\Place;
@@ -22,7 +21,7 @@ final class FreemiumPlaceProvider implements ProviderInterface
 {
     public function __construct(
         private ObjectMapperInterface $objectMapper,
-        private PlaceRepository $placeRepository
+        private PlaceRepository $placeRepository,
     ) {
     }
 
@@ -33,11 +32,12 @@ final class FreemiumPlaceProvider implements ProviderInterface
      * @throws \Doctrine\ORM\Exception\ORMException
      * @throws \Doctrine\ORM\OptimisticLockException
      */
-    public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator|FreemiumPlace|null
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): FreemiumPlace
     {
         if ($operation instanceof GetCollection) {
             throw new \RuntimeException('not supported', Response::HTTP_METHOD_NOT_ALLOWED);
         }
+
         return $this->provideItem($uriVariables, $context);
     }
 
@@ -48,11 +48,11 @@ final class FreemiumPlaceProvider implements ProviderInterface
      * @throws \Doctrine\ORM\Exception\ORMException
      * @throws \Doctrine\ORM\OptimisticLockException
      */
-    private function provideItem(array $uriVariables, array $context): ?FreemiumPlace
+    private function provideItem(array $uriVariables, array $context): FreemiumPlace
     {
         /** @var Place $place */
         $place = $this->placeRepository->find($uriVariables['id']);
-        if(empty($place)){
+        if (empty($place)) {
             throw new NotFoundHttpException('place not found');
         }
 

+ 8 - 8
src/State/Provider/Search/PlaceSearchItemProvider.php

@@ -9,7 +9,6 @@ use ApiPlatform\Metadata\Operation;
 use ApiPlatform\State\Pagination\TraversablePaginator;
 use ApiPlatform\State\ProviderInterface;
 use App\ApiResources\Search\PlaceSearchItem;
-use App\Entity\Booking\Event;
 use App\Entity\Place\Place;
 use App\Repository\Place\PlaceRepository;
 use App\Service\State\Provider\ProviderUtils;
@@ -35,11 +34,12 @@ final class PlaceSearchItemProvider implements ProviderInterface
      * @throws \Doctrine\ORM\Exception\ORMException
      * @throws \Doctrine\ORM\OptimisticLockException
      */
-    public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator|PlaceSearchItem|null
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator|PlaceSearchItem
     {
         if ($operation instanceof GetCollection) {
             return $this->provideCollection($operation, $context);
         }
+
         return $this->provideItem($uriVariables, $context);
     }
 
@@ -52,7 +52,7 @@ final class PlaceSearchItemProvider implements ProviderInterface
 
         $mappedItems = [];
         foreach ($originalPaginator as $item) {
-            $mappedItems[]= $this->objectMapper->map($item, PlaceSearchItem::class);
+            $mappedItems[] = $this->objectMapper->map($item, PlaceSearchItem::class);
         }
 
         return $this->providerUtils->getTraversablePaginator($mappedItems, $originalPaginator);
@@ -65,12 +65,12 @@ final class PlaceSearchItemProvider implements ProviderInterface
      * @throws \Doctrine\ORM\Exception\ORMException
      * @throws \Doctrine\ORM\OptimisticLockException
      */
-    private function provideItem(array $uriVariables, array $context): ?PlaceSearchItem
+    private function provideItem(array $uriVariables, array $context): PlaceSearchItem
     {
-        /** @var Event $event */
-        if(empty($event = $this->placeRepository->find($uriVariables['id']))){
-            throw new NotFoundHttpException('event not found');
+        if (empty($place = $this->placeRepository->find($uriVariables['id']))) {
+            throw new NotFoundHttpException('Place not found');
         }
-        return $this->objectMapper->map($event, PlaceSearchItem::class);
+
+        return $this->objectMapper->map($place, PlaceSearchItem::class);
     }
 }

+ 6 - 6
src/Validator/Constraints/FieldLesserThan.php

@@ -1,22 +1,22 @@
 <?php
+
 declare(strict_types=1);
 
 namespace App\Validator\Constraints;
 
-use Attribute;
-use Symfony\Component\Validator\Constraint;
 use Symfony\Component\Validator\Attribute\HasNamedArguments;
+use Symfony\Component\Validator\Constraint;
 
-#[Attribute(Attribute::TARGET_CLASS)]
+#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
 class FieldLesserThan extends Constraint
 {
-    public string $message = 'The value of "{{ field }}" must be less than "{{ comparedTo }}"';
+    public string $message = '{{ field }} must be less than {{ comparedTo }}';
 
     #[HasNamedArguments]
     public function __construct(
         public string $field,
         public string $comparedTo,
-        array $groups = null,
+        ?array $groups = null,
         mixed $payload = null,
     ) {
         parent::__construct([], $groups, $payload);
@@ -29,6 +29,6 @@ class FieldLesserThan extends Constraint
 
     public function validatedBy(): string
     {
-        return static::class . 'Validator';
+        return static::class.'Validator';
     }
 }

+ 4 - 3
src/Validator/Constraints/FieldLesserThanValidator.php

@@ -1,4 +1,5 @@
 <?php
+
 declare(strict_types=1);
 
 namespace App\Validator\Constraints;
@@ -28,9 +29,9 @@ class FieldLesserThanValidator extends ConstraintValidator
 
         if ($fieldValue instanceof \DateTimeInterface && $comparedValue instanceof \DateTimeInterface) {
             $test = $fieldValue->getTimestamp() >= $comparedValue->getTimestamp();
-        }else if(is_numeric($fieldValue) && is_numeric($comparedValue)){
-            $test = $fieldValue>= $comparedValue;
-        }else{
+        } elseif (is_numeric($fieldValue) && is_numeric($comparedValue)) {
+            $test = $fieldValue >= $comparedValue;
+        } else {
             return; // Skip if isn't date or numeric value
         }
 

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

@@ -203,7 +203,7 @@
                             <tbody>
                             <tr>
                                 <td width="250" class="relative">
-                                    <img src="{{ 'images/cmf_licence.png' }}"
+                                    <img src="{{ asset_base64('images/cmf_licence.png') }}"
                                             width="170" height="86"/>
                                     <span id="year_head">
                                         {{ licence.year }}
@@ -211,7 +211,7 @@
                                 </td>
                                 <td width="250">
                                     <div align="right">
-                                        <img src="{{ 'images/cmf-reseau.png' }}"
+                                        <img src="{{ asset_base64('images/cmf-reseau.png') }}"
                                                 width="200" height="86"/>
                                     </div>
                                 </td>
@@ -268,11 +268,11 @@
                                 <td height="62" width="62" id="avatar">
                                     <div align="center">
                                         {% if(licence.logo is null) %}
-                                            <img src="{{ 'images/picto_face.png' }}"
-                                                 height="62"/>
+                                            <img src="{{ asset_base64('images/picto_face.png') }}"
+                                                 style="max-width: 76px; max-height: 62px"/>
                                         {% else %}
                                             <img src="{{ fileImagePath(licence.logo, 'sm') }}"
-                                                 height="62"/>
+                                                 style="max-width: 76px; max-height: 62px"/>
                                         {% endif %}
                                     </div>
                                 </td>
@@ -290,7 +290,7 @@
                                     <div align="center">
                                         {% if(licence.personAvatar is null) %}
                                             <img
-                                                    src="{{ 'images/picto_face.png' }}"
+                                                    src="{{ asset_base64('images/picto_face.png') }}"
                                                     height="62"/>
                                         {% else %}
                                             <img class="avatar"
@@ -314,7 +314,7 @@
                             <td height="54" width="84" valign="middle"
                                 style="vertical-align: bottom;">
                                 <div align="center">
-                                    <img src="{{ 'images/cmf_licence.png' }}"
+                                    <img src="{{ asset_base64('images/cmf_licence.png') }}"
                                          height="35"/>
                                     <span id="year_card">{{ licence.year }}</span>
                                 </div>

+ 0 - 2
tests/Unit/Service/ApiLegacy/ApiLegacyRequestServiceTest.php

@@ -65,7 +65,6 @@ class ApiLegacyRequestServiceTest extends TestCase
             'authorization' => 'BEARER XYZ',
             'Accept' => '*/*',
             'Charset' => 'UTF-8',
-            'Accept-Encoding' => 'gzip, deflate, br',
             'Content-Type' => 'application/ld+json',
             'x-accessid' => '1',
             'internal-requests-token' => self::internalRequestsToken,
@@ -120,7 +119,6 @@ class ApiLegacyRequestServiceTest extends TestCase
             'authorization' => 'BEARER 123',
             'Accept' => '*/*',
             'Charset' => 'UTF-8',
-            'Accept-Encoding' => 'gzip, deflate, br',
             'Content-Type' => 'application/ld+json',
             'x-accessid' => '20',
             'x-switch-access' => '10',

+ 301 - 0
tests/Unit/Service/ApiResourceBuilder/Freemium/EventMappingBuilderTest.php

@@ -0,0 +1,301 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Service\ApiResourceBuilder\Freemium;
+
+use App\ApiResources\Freemium\FreemiumEvent;
+use App\Entity\Booking\Event;
+use App\Entity\Booking\EventGender;
+use App\Entity\Core\AddressPostal;
+use App\Entity\Core\Categories;
+use App\Entity\Core\Country;
+use App\Entity\Core\File;
+use App\Entity\Organization\Organization;
+use App\Entity\Place\Place;
+use App\Service\ApiResourceBuilder\Freemium\EventMappingBuilder;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+
+class TestableEventMappingBuilder extends EventMappingBuilder
+{
+    public function mapEventInformations(Event $event, FreemiumEvent $freemiumEvent): void
+    {
+        parent::mapEventInformations($event, $freemiumEvent);
+    }
+
+    public function mapEventPlaceInformations(Event $event, FreemiumEvent $freemiumEvent): void
+    {
+        parent::mapEventPlaceInformations($event, $freemiumEvent);
+    }
+
+    public function mapPlaceInformations(Place $place, FreemiumEvent $freemiumEvent): void
+    {
+        parent::mapPlaceInformations($place, $freemiumEvent);
+    }
+
+    public function getPlace(FreemiumEvent $freemiumEvent): ?Place
+    {
+        return parent::getPlace($freemiumEvent);
+    }
+
+    public function getAddressPostal(Place $place): AddressPostal
+    {
+        return parent::getAddressPostal($place);
+    }
+}
+
+class EventMappingBuilderTest extends TestCase
+{
+    private EntityManagerInterface|MockObject $entityManager;
+
+    public function setUp(): void
+    {
+        $this->entityManager = $this->createMock(EntityManagerInterface::class);
+    }
+
+    private function getEventMappingBuilderMockForMethod(string $methodName): TestableEventMappingBuilder|MockObject
+    {
+        return $this
+            ->getMockBuilder(TestableEventMappingBuilder::class)
+            ->setConstructorArgs([$this->entityManager])
+            ->setMethodsExcept([$methodName])
+            ->getMock();
+    }
+
+    public function testMapInformations(): void
+    {
+        $eventMappingBuilder = $this->getEventMappingBuilderMockForMethod('mapInformations');
+
+        $event = $this->createMock(Event::class);
+        $freemiumEvent = $this->createMock(FreemiumEvent::class);
+
+        $eventMappingBuilder
+            ->expects($this->once())
+            ->method('mapEventInformations')
+            ->with($event, $freemiumEvent);
+
+        $eventMappingBuilder
+            ->expects($this->once())
+            ->method('mapEventPlaceInformations')
+            ->with($event, $freemiumEvent);
+
+        $eventMappingBuilder->mapInformations($event, $freemiumEvent);
+    }
+
+    public function testMapEventInformations(): void
+    {
+        $eventMappingBuilder = $this->getEventMappingBuilderMockForMethod('mapEventInformations');
+
+        $event = $this->createMock(Event::class);
+        $organization = $this->createMock(Organization::class);
+        $image = $this->createMock(File::class);
+        $category1 = $this->createMock(Categories::class);
+        $category2 = $this->createMock(Categories::class);
+        $eventGender = $this->createMock(EventGender::class);
+
+        $freemiumEvent = new FreemiumEvent();
+        $freemiumEvent->name = 'Test Event';
+        $freemiumEvent->organization = $organization;
+        $freemiumEvent->datetimeStart = new \DateTime('2024-01-01 20:00:00');
+        $freemiumEvent->datetimeEnd = new \DateTime('2024-01-01 22:00:00');
+        $freemiumEvent->description = 'Test description';
+        $freemiumEvent->image = $image;
+        $freemiumEvent->url = 'https://example.com';
+        $freemiumEvent->urlTicket = 'https://tickets.example.com';
+        $freemiumEvent->pricing = null;
+        $freemiumEvent->priceMini = 0.0;
+        $freemiumEvent->priceMaxi = 50.0;
+        $freemiumEvent->gender = $eventGender;
+        $freemiumEvent->addCategory($category1);
+        $freemiumEvent->addCategory($category2);
+
+        $event->expects($this->once())->method('setName')->with('Test Event');
+        $event->expects($this->once())->method('setOrganization')->with($organization);
+        $event->expects($this->once())->method('setDatetimeStart')->with(new \DateTime('2024-01-01 20:00:00'));
+        $event->expects($this->once())->method('setDatetimeEnd')->with(new \DateTime('2024-01-01 22:00:00'));
+        $event->expects($this->once())->method('setDescription')->with('Test description');
+        $event->expects($this->once())->method('setImage')->with($image);
+        $event->expects($this->once())->method('setUrl')->with('https://example.com');
+        $event->expects($this->once())->method('setUrlTicket')->with('https://tickets.example.com');
+        $event->expects($this->once())->method('setPricing')->with(null);
+        $event->expects($this->once())->method('setPriceMini')->with(0.0);
+        $event->expects($this->once())->method('setPriceMaxi')->with(50.0);
+        $event->expects($this->once())->method('removeAllCategories');
+        $event->expects($this->exactly(2))->method('addCategory');
+
+        $eventMappingBuilder->mapEventInformations($event, $freemiumEvent);
+    }
+
+    public function testMapEventPlaceInformations(): void
+    {
+        $eventMappingBuilder = $this->getEventMappingBuilderMockForMethod('mapEventPlaceInformations');
+
+        $event = $this->createMock(Event::class);
+        $freemiumEvent = $this->createMock(FreemiumEvent::class);
+        $place = $this->createMock(Place::class);
+
+        $eventMappingBuilder
+            ->expects($this->once())
+            ->method('getPlace')
+            ->with($freemiumEvent)
+            ->willReturn($place);
+
+        $eventMappingBuilder
+            ->expects($this->once())
+            ->method('mapPlaceInformations')
+            ->with($place, $freemiumEvent);
+
+        $this->entityManager
+            ->expects($this->once())
+            ->method('persist')
+            ->with($place);
+
+        $event->expects($this->once())->method('setPlace')->with($place);
+
+        $eventMappingBuilder->mapEventPlaceInformations($event, $freemiumEvent);
+    }
+
+    public function testMapEventPlaceInformationsWithNullPlace(): void
+    {
+        $eventMappingBuilder = $this->getEventMappingBuilderMockForMethod('mapEventPlaceInformations');
+
+        $event = $this->createMock(Event::class);
+        $freemiumEvent = $this->createMock(FreemiumEvent::class);
+
+        $eventMappingBuilder
+            ->expects($this->once())
+            ->method('getPlace')
+            ->with($freemiumEvent)
+            ->willReturn(null);
+
+        $eventMappingBuilder
+            ->expects($this->never())
+            ->method('mapPlaceInformations');
+
+        $this->entityManager
+            ->expects($this->never())
+            ->method('persist');
+
+        $event->expects($this->once())
+            ->method('setPlace')
+            ->with(null);
+
+        $eventMappingBuilder->mapEventPlaceInformations($event, $freemiumEvent);
+    }
+
+    public function testMapPlaceInformations(): void
+    {
+        $eventMappingBuilder = $this->getEventMappingBuilderMockForMethod('mapPlaceInformations');
+
+        $place = $this->createMock(Place::class);
+        $addressPostal = $this->createMock(AddressPostal::class);
+        $organization = $this->createMock(Organization::class);
+        $country = $this->createMock(Country::class);
+
+        $freemiumEvent = new FreemiumEvent();
+        $freemiumEvent->organization = $organization;
+        $freemiumEvent->placeName = 'Test Venue';
+        $freemiumEvent->streetAddress = '123 Test Street';
+        $freemiumEvent->streetAddressSecond = 'Apt 1';
+        $freemiumEvent->streetAddressThird = 'Floor 2';
+        $freemiumEvent->postalCode = '12345';
+        $freemiumEvent->addressCity = 'Test City';
+        $freemiumEvent->addressCountry = $country;
+        $freemiumEvent->latitude = 48.8566;
+        $freemiumEvent->longitude = 2.3522;
+
+        $eventMappingBuilder
+            ->expects($this->once())
+            ->method('getAddressPostal')
+            ->with($place)
+            ->willReturn($addressPostal);
+
+        // Verify address postal setters
+        $addressPostal->expects($this->once())->method('setStreetAddress')->with('123 Test Street')->willReturn($addressPostal);
+        $addressPostal->expects($this->once())->method('setStreetAddressSecond')->with('Apt 1')->willReturn($addressPostal);
+        $addressPostal->expects($this->once())->method('setStreetAddressThird')->with('Floor 2')->willReturn($addressPostal);
+        $addressPostal->expects($this->once())->method('setPostalCode')->with('12345')->willReturn($addressPostal);
+        $addressPostal->expects($this->once())->method('setAddressCity')->with('Test City')->willReturn($addressPostal);
+        $addressPostal->expects($this->once())->method('setAddressCountry')->with($country)->willReturn($addressPostal);
+        $addressPostal->expects($this->once())->method('setLatitude')->with(48.8566)->willReturn($addressPostal);
+        $addressPostal->expects($this->once())->method('setLongitude')->with(2.3522)->willReturn($addressPostal);
+
+        // Verify place setters
+        $place->expects($this->once())->method('setOrganization')->with($organization)->willReturn($place);
+        $place->expects($this->once())->method('setName')->with('Test Venue')->willReturn($place);
+        $place->expects($this->once())->method('setAddressPostal')->with($addressPostal)->willReturn($place);
+
+        $eventMappingBuilder->mapPlaceInformations($place, $freemiumEvent);
+    }
+
+    public function testGetPlaceWithExistingPlace(): void
+    {
+        $eventMappingBuilder = $this->getEventMappingBuilderMockForMethod('getPlace');
+
+        $existingPlace = $this->createMock(Place::class);
+        $freemiumEvent = new FreemiumEvent();
+        $freemiumEvent->place = $existingPlace;
+
+        $result = $eventMappingBuilder->getPlace($freemiumEvent);
+
+        $this->assertSame($existingPlace, $result);
+    }
+
+    public function testGetPlaceCreatesNewPlace(): void
+    {
+        $eventMappingBuilder = $this->getEventMappingBuilderMockForMethod('getPlace');
+
+        $freemiumEvent = new FreemiumEvent();
+        $freemiumEvent->place = null;
+        $freemiumEvent->placeName = 'Test Venue';
+
+        $result = $eventMappingBuilder->getPlace($freemiumEvent);
+
+        $this->assertInstanceOf(Place::class, $result);
+    }
+
+    public function testGetPlaceReturnsNullWhenNoInformation(): void
+    {
+        $eventMappingBuilder = $this->getEventMappingBuilderMockForMethod('getPlace');
+
+        $freemiumEvent = new FreemiumEvent();
+        $freemiumEvent->place = null;
+        $freemiumEvent->placeName = null;
+        $freemiumEvent->streetAddress = null;
+        $freemiumEvent->streetAddressSecond = null;
+        $freemiumEvent->streetAddressThird = null;
+        $freemiumEvent->postalCode = null;
+        $freemiumEvent->addressCity = null;
+
+        $result = $eventMappingBuilder->getPlace($freemiumEvent);
+
+        $this->assertNull($result);
+    }
+
+    public function testGetAddressPostalWithExistingAddress(): void
+    {
+        $eventMappingBuilder = $this->getEventMappingBuilderMockForMethod('getAddressPostal');
+
+        $existingAddress = $this->createMock(AddressPostal::class);
+        $place = $this->createMock(Place::class);
+        $place->method('getAddressPostal')->willReturn($existingAddress);
+
+        $result = $eventMappingBuilder->getAddressPostal($place);
+
+        $this->assertSame($existingAddress, $result);
+    }
+
+    public function testGetAddressPostalCreatesNew(): void
+    {
+        $eventMappingBuilder = $this->getEventMappingBuilderMockForMethod('getAddressPostal');
+
+        $place = $this->createMock(Place::class);
+        $place->method('getAddressPostal')->willReturn(null);
+
+        $result = $eventMappingBuilder->getAddressPostal($place);
+
+        $this->assertInstanceOf(AddressPostal::class, $result);
+    }
+}

+ 259 - 0
tests/Unit/Service/ApiResourceBuilder/Freemium/OrganizationMappingBuilderTest.php

@@ -0,0 +1,259 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Service\ApiResourceBuilder\Freemium;
+
+use App\ApiResources\Freemium\FreemiumOrganization;
+use App\Entity\Core\AddressPostal;
+use App\Entity\Core\ContactPoint;
+use App\Entity\Core\Country;
+use App\Entity\Core\File;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\OrganizationAddressPostal;
+use App\Enum\Core\ContactPointTypeEnum;
+use App\Service\ApiResourceBuilder\Freemium\OrganizationMappingBuilder;
+use libphonenumber\PhoneNumber;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+
+class TestableOrganizationMappingBuilder extends OrganizationMappingBuilder
+{
+    public function mapOrganizationInformations(Organization $organization, FreemiumOrganization $freemiumOrganization): void
+    {
+        parent::mapOrganizationInformations($organization, $freemiumOrganization);
+    }
+
+    public function updateOrganizationTypeOfPractices(Organization $organization, FreemiumOrganization $freemiumOrganization): void
+    {
+        parent::updateOrganizationTypeOfPractices($organization, $freemiumOrganization);
+    }
+
+    public function mapContactPointInformations(ContactPoint $contactPoint, FreemiumOrganization $freemiumOrganization): void
+    {
+        parent::mapContactPointInformations($contactPoint, $freemiumOrganization);
+    }
+
+    public function mapAddressPostalInformations(AddressPostal $address, FreemiumOrganization $freemiumOrganization): void
+    {
+        parent::mapAddressPostalInformations($address, $freemiumOrganization);
+    }
+
+    public function getPrincipalContactPointOrCreateNewOne(Organization $organization): ContactPoint
+    {
+        return parent::getPrincipalContactPointOrCreateNewOne($organization);
+    }
+
+    public function getPrincipalAddressPostalOrCreateNewOne(Organization $organization): AddressPostal
+    {
+        return parent::getPrincipalAddressPostalOrCreateNewOne($organization);
+    }
+}
+
+class OrganizationMappingBuilderTest extends TestCase
+{
+    private function getOrganizationMappingBuilderMockForMethod(string $methodName): TestableOrganizationMappingBuilder|MockObject
+    {
+        return $this
+            ->getMockBuilder(TestableOrganizationMappingBuilder::class)
+            ->setMethodsExcept([$methodName])
+            ->getMock();
+    }
+
+    public function testMapInformations(): void
+    {
+        $organizationMappingBuilder = $this
+            ->getMockBuilder(TestableOrganizationMappingBuilder::class)
+            ->setMethodsExcept(['mapInformations'])
+            ->getMock();
+
+        $organization = $this->createMock(Organization::class);
+        $freemiumOrganization = $this->createMock(FreemiumOrganization::class);
+        $contactPoint = $this->createMock(ContactPoint::class);
+        $addressPostal = $this->createMock(AddressPostal::class);
+
+        // Verify that methods are called in the correct order
+        $organizationMappingBuilder
+            ->expects($this->once())
+            ->method('mapOrganizationInformations')
+            ->with($organization, $freemiumOrganization);
+
+        $organizationMappingBuilder
+            ->expects($this->once())
+            ->method('updateOrganizationTypeOfPractices')
+            ->with($organization, $freemiumOrganization);
+
+        $organizationMappingBuilder
+            ->expects($this->once())
+            ->method('getPrincipalContactPointOrCreateNewOne')
+            ->with($organization)
+            ->willReturn($contactPoint);
+
+        $organizationMappingBuilder
+            ->expects($this->once())
+            ->method('mapContactPointInformations')
+            ->with($contactPoint, $freemiumOrganization);
+
+        $organizationMappingBuilder
+            ->expects($this->once())
+            ->method('getPrincipalAddressPostalOrCreateNewOne')
+            ->with($organization)
+            ->willReturn($addressPostal);
+
+        $organizationMappingBuilder
+            ->expects($this->once())
+            ->method('mapAddressPostalInformations')
+            ->with($addressPostal, $freemiumOrganization);
+
+        $organizationMappingBuilder->mapInformations($organization, $freemiumOrganization);
+    }
+
+    public function testMapOrganizationInformations(): void
+    {
+        $organizationMappingBuilder = $this->getOrganizationMappingBuilderMockForMethod('mapOrganizationInformations');
+
+        $organization = $this->createMock(Organization::class);
+        $logo = $this->createMock(File::class);
+
+        $freemiumOrganization = new FreemiumOrganization();
+        $freemiumOrganization->name = 'Test Organization';
+        $freemiumOrganization->description = 'Test Description';
+        $freemiumOrganization->facebook = 'https://facebook.com/test';
+        $freemiumOrganization->youtube = 'https://youtube.com/test';
+        $freemiumOrganization->instagram = 'https://instagram.com/test';
+        $freemiumOrganization->twitter = 'https://twitter.com/test';
+        $freemiumOrganization->portailVisibility = true;
+        $freemiumOrganization->logo = $logo;
+
+        // Verify setter calls
+        $organization->expects($this->once())->method('setName')->with('Test Organization');
+        $organization->expects($this->once())->method('setDescription')->with('Test Description');
+        $organization->expects($this->once())->method('setFacebook')->with('https://facebook.com/test');
+        $organization->expects($this->once())->method('setYoutube')->with('https://youtube.com/test');
+        $organization->expects($this->once())->method('setInstagram')->with('https://instagram.com/test');
+        $organization->expects($this->once())->method('setTwitter')->with('https://twitter.com/test');
+        $organization->expects($this->once())->method('setPortailVisibility')->with(true);
+        $organization->expects($this->once())->method('setLogo')->with($logo);
+
+        $organizationMappingBuilder->mapOrganizationInformations($organization, $freemiumOrganization);
+    }
+
+    public function testMapContactPointInformations(): void
+    {
+        $organizationMappingBuilder = $this->getOrganizationMappingBuilderMockForMethod('mapContactPointInformations');
+
+        $contactPoint = $this->createMock(ContactPoint::class);
+        $tel = $this->createMock(PhoneNumber::class);
+
+        $freemiumOrganization = new FreemiumOrganization();
+        $freemiumOrganization->tel = $tel;
+        $freemiumOrganization->email = 'test@example.com';
+
+        $contactPoint->expects($this->once())->method('setTelphone')->with($tel);
+        $contactPoint->expects($this->once())->method('setEmail')->with('test@example.com');
+
+        $organizationMappingBuilder->mapContactPointInformations($contactPoint, $freemiumOrganization);
+    }
+
+    public function testMapAddressPostalInformations(): void
+    {
+        $organizationMappingBuilder = $this->getOrganizationMappingBuilderMockForMethod('mapAddressPostalInformations');
+
+        $address = $this->createMock(AddressPostal::class);
+        $country = $this->createMock(Country::class);
+
+        $freemiumOrganization = new FreemiumOrganization();
+        $freemiumOrganization->streetAddress = '123 Test Street';
+        $freemiumOrganization->streetAddressSecond = 'Apt 1';
+        $freemiumOrganization->streetAddressThird = 'Floor 2';
+        $freemiumOrganization->postalCode = '12345';
+        $freemiumOrganization->addressCity = 'Test City';
+        $freemiumOrganization->addressCountry = $country;
+        $freemiumOrganization->longitude = 2.3522;
+        $freemiumOrganization->latitude = 48.8566;
+
+        $address->expects($this->once())->method('setStreetAddress')->with('123 Test Street');
+        $address->expects($this->once())->method('setStreetAddressSecond')->with('Apt 1');
+        $address->expects($this->once())->method('setStreetAddressThird')->with('Floor 2');
+        $address->expects($this->once())->method('setPostalCode')->with('12345');
+        $address->expects($this->once())->method('setAddressCity')->with('Test City');
+        $address->expects($this->once())->method('setAddressCountry')->with($country);
+        $address->expects($this->once())->method('setLongitude')->with(2.3522);
+        $address->expects($this->once())->method('setLatitude')->with(48.8566);
+
+        $organizationMappingBuilder->mapAddressPostalInformations($address, $freemiumOrganization);
+    }
+
+    public function testGetPrincipalContactPointOrCreateNewOneWithExisting(): void
+    {
+        $organizationMappingBuilder = $this->getOrganizationMappingBuilderMockForMethod('getPrincipalContactPointOrCreateNewOne');
+
+        $organization = $this->createMock(Organization::class);
+        $existingContactPoint = $this->createMock(ContactPoint::class);
+
+        $organization->expects($this->once())
+            ->method('getPrincipalContactPoint')
+            ->willReturn($existingContactPoint);
+
+        $result = $organizationMappingBuilder->getPrincipalContactPointOrCreateNewOne($organization);
+
+        $this->assertSame($existingContactPoint, $result);
+    }
+
+    public function testGetPrincipalContactPointOrCreateNewOneCreatesNew(): void
+    {
+        $organizationMappingBuilder = $this->getOrganizationMappingBuilderMockForMethod('getPrincipalContactPointOrCreateNewOne');
+
+        $organization = $this->createMock(Organization::class);
+
+        $organization->expects($this->once())
+            ->method('getPrincipalContactPoint')
+            ->willReturn(null);
+
+        $organization->expects($this->once())
+            ->method('addContactPoint')
+            ->with($this->isInstanceOf(ContactPoint::class));
+
+        $result = $organizationMappingBuilder->getPrincipalContactPointOrCreateNewOne($organization);
+
+        $this->assertSame(ContactPointTypeEnum::PRINCIPAL, $result->getContactType());
+    }
+
+    public function testGetPrincipalAddressPostalOrCreateNewOneWithExisting(): void
+    {
+        $organizationMappingBuilder = $this->getOrganizationMappingBuilderMockForMethod('getPrincipalAddressPostalOrCreateNewOne');
+
+        $organization = $this->createMock(Organization::class);
+        $existingAddressPostal = $this->createMock(AddressPostal::class);
+        $existingOrganizationAddressPostal = $this->createMock(OrganizationAddressPostal::class);
+
+        $existingOrganizationAddressPostal->expects($this->once())
+            ->method('getAddressPostal')
+            ->willReturn($existingAddressPostal);
+
+        $organization->expects($this->once())
+            ->method('getPrincipalAddressPostal')
+            ->willReturn($existingOrganizationAddressPostal);
+
+        $result = $organizationMappingBuilder->getPrincipalAddressPostalOrCreateNewOne($organization);
+
+        $this->assertSame($existingAddressPostal, $result);
+    }
+
+    public function testGetPrincipalAddressPostalOrCreateNewOneCreatesNew(): void
+    {
+        $organizationMappingBuilder = $this->getOrganizationMappingBuilderMockForMethod('getPrincipalAddressPostalOrCreateNewOne');
+
+        $organization = $this->createMock(Organization::class);
+
+        $organization->expects($this->once())
+            ->method('getPrincipalAddressPostal')
+            ->willReturn(null);
+
+        $organization->expects($this->once())
+            ->method('addOrganizationAddressPostal')
+            ->with($this->isInstanceOf(OrganizationAddressPostal::class));
+
+        $result = $organizationMappingBuilder->getPrincipalAddressPostalOrCreateNewOne($organization);
+    }
+}

+ 42 - 3
tests/Unit/Service/Doctrine/FiltersConfigurationServiceTest.php

@@ -44,6 +44,11 @@ class TestableFiltersConfigurationService extends FiltersConfigurationService
     {
         return $this->previousTimeConstraintState;
     }
+
+    public function setFiltersConfigured(bool $value): void
+    {
+        $this->filtersConfigured = $value;
+    }
 }
 
 class FiltersConfigurationServiceTest extends TestCase
@@ -64,7 +69,7 @@ class FiltersConfigurationServiceTest extends TestCase
         return $this
             ->getMockBuilder(TestableFiltersConfigurationService::class)
             ->setConstructorArgs([$this->em, $this->dateTimeConstraint, $this->activityYearConstraint])
-            ->setMethodsExcept([$methodName, 'getPreviousTimeConstraintState', 'setPreviousTimeConstraintState'])
+            ->setMethodsExcept([$methodName, 'getPreviousTimeConstraintState', 'setPreviousTimeConstraintState', 'setFiltersConfigured'])
             ->getMock();
     }
 
@@ -117,6 +122,7 @@ class FiltersConfigurationServiceTest extends TestCase
     public function testSuspendTimeConstraintFilters(): void
     {
         $filterConfigurationService = $this->getFiltersConfigurationServiceMockFor('suspendTimeConstraintFilters');
+        $filterConfigurationService->setFiltersConfigured(true);
 
         $filterConfigurationService->expects(self::once())->method('timeFiltersAlreadyDisabled')->willReturn(false);
         $filterConfigurationService->expects(self::exactly(2))->method('disableFilter')->withConsecutive(['date_time_filter'], ['activity_year_filter']);
@@ -132,11 +138,12 @@ class FiltersConfigurationServiceTest extends TestCase
     public function testSuspendTimeConstraintFiltersAlreadySuspended(): void
     {
         $filterConfigurationService = $this->getFiltersConfigurationServiceMockFor('suspendTimeConstraintFilters');
+        $filterConfigurationService->setFiltersConfigured(true);
 
         $filterConfigurationService->setPreviousTimeConstraintState(true);
 
         $this->expectException(\RuntimeException::class);
-        $this->expectExceptionMessage('time constraints is already suspended');
+        $this->expectExceptionMessage('The time constraints are already suspended');
 
         $filterConfigurationService->suspendTimeConstraintFilters();
     }
@@ -144,6 +151,7 @@ class FiltersConfigurationServiceTest extends TestCase
     public function testSuspendTimeConstraintFiltersAlreadyDisabled(): void
     {
         $filterConfigurationService = $this->getFiltersConfigurationServiceMockFor('suspendTimeConstraintFilters');
+        $filterConfigurationService->setFiltersConfigured(true);
 
         $filterConfigurationService->expects(self::once())->method('timeFiltersAlreadyDisabled')->willReturn(true);
 
@@ -237,6 +245,7 @@ class FiltersConfigurationServiceTest extends TestCase
     public function testRestoreTimeConstraintFilters(): void
     {
         $filterConfigurationService = $this->getFiltersConfigurationServiceMockFor('restoreTimeConstraintFilters');
+        $filterConfigurationService->setFiltersConfigured(true);
 
         $filterConfigurationService->setPreviousTimeConstraintState(true);
 
@@ -253,10 +262,40 @@ class FiltersConfigurationServiceTest extends TestCase
     public function testRestoreTimeConstraintFiltersNotAlreadySuspended(): void
     {
         $filterConfigurationService = $this->getFiltersConfigurationServiceMockFor('restoreTimeConstraintFilters');
+        $filterConfigurationService->setFiltersConfigured(true);
 
         $this->expectException(\RuntimeException::class);
-        $this->expectExceptionMessage('time constraints has not been suspended, can not be restored');
+        $this->expectExceptionMessage('The time constraints have not been suspended, can not be restored');
 
         $filterConfigurationService->restoreTimeConstraintFilters();
     }
+
+    public function testSuspendTimeConstraintFiltersWhenFiltersNotConfigured(): void
+    {
+        $filterConfigurationService = $this->getFiltersConfigurationServiceMockFor('suspendTimeConstraintFilters');
+        $filterConfigurationService->setFiltersConfigured(false);
+
+        // No methods should be called when filtersConfigured is false
+        $filterConfigurationService->expects(self::never())->method('timeFiltersAlreadyDisabled');
+        $filterConfigurationService->expects(self::never())->method('disableFilter');
+
+        $filterConfigurationService->suspendTimeConstraintFilters();
+
+        // previousTimeConstraintState should remain null
+        $this->assertNull($filterConfigurationService->getPreviousTimeConstraintState());
+    }
+
+    public function testRestoreTimeConstraintFiltersWhenFiltersNotConfigured(): void
+    {
+        $filterConfigurationService = $this->getFiltersConfigurationServiceMockFor('restoreTimeConstraintFilters');
+        $filterConfigurationService->setFiltersConfigured(false);
+
+        // No methods should be called when filtersConfigured is false
+        $filterConfigurationService->expects(self::never())->method('enableFilter');
+
+        $filterConfigurationService->restoreTimeConstraintFilters();
+
+        // previousTimeConstraintState should remain null
+        $this->assertNull($filterConfigurationService->getPreviousTimeConstraintState());
+    }
 }

+ 41 - 14
tests/Unit/Service/Export/Encoder/PdfEncoderTest.php

@@ -10,47 +10,74 @@ use PHPUnit\Framework\TestCase;
 
 class TestablePdfEncoder extends PdfEncoder
 {
-    public function setDomPdfOptions(Options $options): void
+    public function getDomPdf(): Dompdf
     {
-        $this->domPdfOptions = $options;
+        return parent::getDomPdf();
     }
 
-    public function setDomPdf(Dompdf $dompdf): void
+    public function getDomPdfOptions(): Options
     {
-        $this->dompdf = $dompdf;
+        return parent::getDomPdfOptions();
     }
 }
 
 class PdfEncoderTest extends TestCase
 {
+    private function getPdfEncoderMockFor(string $method): TestablePdfEncoder
+    {
+        return $this->getMockBuilder(TestablePdfEncoder::class)
+            ->disableOriginalConstructor()
+            ->setMethodsExcept([$method])
+            ->getMock();
+    }
+
     /**
      * @see PdfEncoder::support()
      */
     public function testSupport(): void
     {
-        $encoder = $this->getMockBuilder(PdfEncoder::class)
-            ->disableOriginalConstructor()
-            ->setMethodsExcept(['support'])
-            ->getMock();
+        $encoder = $this->getPdfEncoderMockFor('support');
 
         $this->assertTrue($encoder->support('pdf'));
         $this->assertFalse($encoder->support('txt'));
     }
 
+    /**
+     * @see PdfEncoder::getDomPdf()
+     */
+    public function testGetDomPdf(): void
+    {
+        $encoder = $this->getPdfEncoderMockFor('getDomPdf');
+
+        $dompdf = $encoder->getDomPdf();
+
+        $this->assertInstanceOf(Dompdf::class, $dompdf);
+    }
+
+    /**
+     * @see PdfEncoder::getDomPdfOptions()
+     */
+    public function testGetDomPdfOptions(): void
+    {
+        $encoder = $this->getPdfEncoderMockFor('getDomPdfOptions');
+
+        $options = $encoder->getDomPdfOptions();
+
+        $this->assertInstanceOf(Options::class, $options);
+    }
+
     /**
      * @see PdfEncoder::encode()
      */
     public function testEncode(): void
     {
-        $encoder = $this->getMockBuilder(TestablePdfEncoder::class)
-            ->disableOriginalConstructor()
-            ->setMethodsExcept(['encode', 'setDomPdfOptions', 'setDomPdf'])
-            ->getMock();
+        $encoder = $this->getPdfEncoderMockFor('encode');
 
         $domPdfOptions = $this->getMockBuilder(Options::class)->disableOriginalConstructor()->getMock();
         $domPdf = $this->getMockBuilder(Dompdf::class)->disableOriginalConstructor()->getMock();
-        $encoder->setDomPdfOptions($domPdfOptions);
-        $encoder->setDomPdf($domPdf);
+
+        $encoder->expects(self::once())->method('getDomPdfOptions')->willReturn($domPdfOptions);
+        $encoder->expects(self::once())->method('getDomPdf')->willReturn($domPdf);
 
         $domPdfOptions->expects(self::once())->method('setIsRemoteEnabled')->with(true);
         $domPdfOptions->expects(self::once())->method('setChroot')->with(PathUtils::getProjectDir().'/public');

+ 2 - 2
tests/Unit/Service/File/Storage/ApiLegacyStorageTest.php

@@ -75,7 +75,7 @@ class ApiLegacyStorageTest extends TestCase
         $this->apiLegacyRequestService
             ->expects(self::once())
             ->method('getContent')
-            ->with('api/files/123/download/md?relativePath=1')
+            ->with('api/public/files/123/download/md?relativePath=1')
             ->willReturn('xyz');
 
         $this->assertEquals(
@@ -94,7 +94,7 @@ class ApiLegacyStorageTest extends TestCase
         $this->apiLegacyRequestService
             ->expects(self::once())
             ->method('getContent')
-            ->with('api/files/123/download/lg?relativePath=1')
+            ->with('api/public/files/123/download/lg?relativePath=1')
             ->willReturn('xyz');
 
         $this->assertEquals(

+ 27 - 67
tests/Unit/Service/Organization/OrganizationFactoryTest.php

@@ -970,12 +970,7 @@ class OrganizationFactoryTest extends TestCase
 
         $organization->expects(self::once())->method('addSubdomain')->with($subdomain);
 
-        // Enregistrement du sous domaine dans Parameters (retrocompatibilité v1)
-        $organization->method('getParameters')->willReturn($parameters);
-
         $organizationCreationRequest->method('getSubdomain')->willReturn('foo');
-        $parameters->expects(self::once())->method('setSubDomain')->with('foo');
-        $parameters->expects(self::once())->method('setOtherWebsite')->with('https://foo.opentalent.fr');
 
         $result = $organizationFactory->makeOrganizationWithRelations($organizationCreationRequest);
 
@@ -1142,21 +1137,9 @@ class OrganizationFactoryTest extends TestCase
 
         $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
 
-        $organizationCreationRequest->method('getPhoneNumber')->willReturn('+33102030405');
-        $organizationCreationRequest->method('getEmail')->willReturn('contact@domain.net');
-
-        $this->phoneNumberUtil
-            ->method('isPossibleNumber')
-            ->with('+33102030405')
-            ->willReturn(true);
-
         $phoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
-
-        $this->phoneNumberUtil
-            ->expects(self::once())
-            ->method('parse')
-            ->with('+33102030405')
-            ->willReturn($phoneNumber);
+        $organizationCreationRequest->method('getPhoneNumber')->willReturn($phoneNumber);
+        $organizationCreationRequest->method('getEmail')->willReturn('contact@domain.net');
 
         $contactPoint = $organizationFactory->makeContactPoint($organizationCreationRequest);
 
@@ -1171,32 +1154,6 @@ class OrganizationFactoryTest extends TestCase
         );
     }
 
-    public function testMakeContactPointInvalidPhoneNumber(): void
-    {
-        $organizationFactory = $this->getOrganizationFactoryMockFor('makeContactPoint');
-
-        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
-
-        $organizationCreationRequest->method('getPhoneNumber')->willReturn('invalid');
-        $organizationCreationRequest->method('getEmail')->willReturn('contact@domain.net');
-
-        $this->phoneNumberUtil
-            ->method('isPossibleNumber')
-            ->with('invalid')
-            ->willReturn(false);
-
-        $phoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
-
-        $this->phoneNumberUtil
-            ->expects(self::never())
-            ->method('parse');
-
-        $this->expectException(\RuntimeException::class);
-        $this->expectExceptionMessage('Phone number is invalid or missing');
-
-        $organizationFactory->makeContactPoint($organizationCreationRequest);
-    }
-
     public function testMakeNetworkOrganization(): void
     {
         $organizationFactory = $this->getOrganizationFactoryMockFor('makeNetworkOrganization');
@@ -1635,28 +1592,24 @@ class OrganizationFactoryTest extends TestCase
 
         $organizationMemberCreationRequest = $this->getMockBuilder(OrganizationMemberCreationRequest::class)->getMock();
 
-        $organizationMemberCreationRequest->method('getPhone')->willReturn('+33102030405');
+        $phoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
+        $mobilePhoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
+
+        $organizationMemberCreationRequest->method('getPhone')->willReturn($phoneNumber);
         $organizationMemberCreationRequest->method('getEmail')->willReturn('email@domain.com');
-        $organizationMemberCreationRequest->method('getMobile')->willReturn('+33607080910');
+        $organizationMemberCreationRequest->method('getMobile')->willReturn($mobilePhoneNumber);
 
         $this->phoneNumberUtil
             ->expects(self::exactly(2))
             ->method('isPossibleNumber')
             ->willReturnMap([
-                ['+33102030405', null, true],
-                ['+33607080910', null, true],
+                [$phoneNumber, null, true],
+                [$mobilePhoneNumber, null, true],
             ]);
 
-        $phoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
-        $mobilePhoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
-
         $this->phoneNumberUtil
-            ->expects(self::exactly(2))
-            ->method('parse')
-            ->willReturnMap([
-                ['+33102030405', null, null, false, $phoneNumber],
-                ['+33607080910', null, null, false, $mobilePhoneNumber],
-            ]);
+            ->expects(self::never())
+            ->method('parse');
 
         $creationDate = $this->getMockBuilder(\DateTime::class)->getMock();
 
@@ -1693,15 +1646,18 @@ class OrganizationFactoryTest extends TestCase
 
         $organizationMemberCreationRequest = $this->getMockBuilder(OrganizationMemberCreationRequest::class)->getMock();
 
+        $invalidPhoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
+        $mobilePhoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
+
         $organizationMemberCreationRequest->method('getUsername')->willReturn('bob');
-        $organizationMemberCreationRequest->method('getPhone')->willReturn('invalid');
+        $organizationMemberCreationRequest->method('getPhone')->willReturn($invalidPhoneNumber);
         $organizationMemberCreationRequest->method('getEmail')->willReturn('email@domain.com');
-        $organizationMemberCreationRequest->method('getMobile')->willReturn('+33607080910');
+        $organizationMemberCreationRequest->method('getMobile')->willReturn($mobilePhoneNumber);
 
         $this->phoneNumberUtil
             ->expects(self::once())
             ->method('isPossibleNumber')
-            ->with('invalid')
+            ->with($invalidPhoneNumber)
             ->willReturn(false);
 
         $this->phoneNumberUtil
@@ -1726,17 +1682,20 @@ class OrganizationFactoryTest extends TestCase
 
         $organizationMemberCreationRequest = $this->getMockBuilder(OrganizationMemberCreationRequest::class)->getMock();
 
+        $phoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
+        $invalidMobilePhoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
+
         $organizationMemberCreationRequest->method('getUsername')->willReturn('bob');
-        $organizationMemberCreationRequest->method('getPhone')->willReturn('+33102030405');
+        $organizationMemberCreationRequest->method('getPhone')->willReturn($phoneNumber);
         $organizationMemberCreationRequest->method('getEmail')->willReturn('email@domain.com');
-        $organizationMemberCreationRequest->method('getMobile')->willReturn('invalid');
+        $organizationMemberCreationRequest->method('getMobile')->willReturn($invalidMobilePhoneNumber);
 
         $this->phoneNumberUtil
             ->expects(self::exactly(2))
             ->method('isPossibleNumber')
             ->willReturnMap([
-                ['+33102030405', null, true],
-                ['invalid', null, false],
+                [$phoneNumber, null, true],
+                [$invalidMobilePhoneNumber, null, false],
             ]);
 
         $this->phoneNumberUtil
@@ -1761,13 +1720,14 @@ class OrganizationFactoryTest extends TestCase
 
         $organizationMemberCreationRequest = $this->getMockBuilder(OrganizationMemberCreationRequest::class)->getMock();
 
-        $organizationMemberCreationRequest->method('getPhone')->willReturn('+33102030405');
+        $phoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
+        $organizationMemberCreationRequest->method('getPhone')->willReturn($phoneNumber);
         $organizationMemberCreationRequest->method('getMobile')->willReturn(null);
 
         $this->phoneNumberUtil
             ->expects(self::once())
             ->method('isPossibleNumber')
-            ->with('+33102030405')
+            ->with($phoneNumber)
             ->willReturn(true);
 
         $creationDate = $this->getMockBuilder(\DateTime::class)->getMock();

+ 4 - 1
tests/Unit/Service/Organization/TrialTest.php

@@ -13,6 +13,7 @@ use App\Service\Dolibarr\DolibarrUtils;
 use App\Service\Shop\Trial;
 use App\Service\Utils\DatesUtils;
 use Doctrine\ORM\EntityManagerInterface;
+use libphonenumber\PhoneNumber;
 use PHPUnit\Framework\TestCase;
 
 /**
@@ -126,11 +127,13 @@ class TrialTest extends TestCase
         $organization->method('getId')->willReturn(123);
 
         $request = $this->createMock(NewStructureArtistPremiumTrialRequest::class);
+        $phoneNumber = $this->createMock(PhoneNumber::class);
+        $phoneNumber->method('__toString')->willReturn('+33123456789');
         $request->method('getRepresentativeFirstName')->willReturn('John');
         $request->method('getRepresentativeLastName')->willReturn('Doe');
         $request->method('getRepresentativeFunction')->willReturn('Manager');
         $request->method('getRepresentativeEmail')->willReturn('test@example.com');
-        $request->method('getRepresentativePhone')->willReturn('+33123456789');
+        $request->method('getRepresentativePhone')->willReturn($phoneNumber);
 
         $settings
             ->expects(self::once())

+ 7 - 7
tests/Unit/Service/Security/ModuleTest.php

@@ -200,30 +200,30 @@ class ModuleTest extends TestCase
     }
 
     /**
-     * @see Module::getModuleByResourceName()
+     * @see Module::getModulesByResourceName()
      */
     public function testGetModuleByResourceName(): void
     {
-        $module = $this->getMockForMethod('getModuleByResourceName');
+        $module = $this->getMockForMethod('getModulesByResourceName');
 
         $this->parameterBag->method('get')->with('opentalent.modules')->willReturn(
             ['Core' => ['resources' => ['foo', 'bar']]]
         );
 
-        $this->assertEquals('Core', $module->getModuleByResourceName('foo'));
+        $this->assertEquals(['Core'], $module->getModulesByResourceName('foo'));
     }
 
     /**
-     * @see Module::getModuleByResourceName()
+     * @see Module::getModulesByResourceName()
      */
-    public function testGetModuleByResourceNameNotFound(): void
+    public function testGetModulesByResourceNameNotFound(): void
     {
-        $module = $this->getMockForMethod('getModuleByResourceName');
+        $module = $this->getMockForMethod('getModulesByResourceName');
 
         $this->parameterBag->method('get')->with('opentalent.modules')->willReturn(
             ['Core' => ['resources' => ['bar']]]
         );
 
-        $this->assertNull($module->getModuleByResourceName('foo'));
+        $this->assertEmpty($module->getModulesByResourceName('foo'));
     }
 }

+ 4 - 52
tests/Unit/Service/Shop/ShopServiceTest.php

@@ -20,6 +20,7 @@ use App\Service\Shop\ShopService;
 use App\Service\Shop\Trial;
 use App\Service\Utils\DatesUtils;
 use Doctrine\ORM\EntityManagerInterface;
+use libphonenumber\PhoneNumber;
 use libphonenumber\PhoneNumberUtil;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
@@ -508,16 +509,9 @@ class ShopServiceTest extends TestCase
     {
         $shopService = $this->getShopServiceMockFor('validateNewStructureArtistPremiumTrialRequest');
 
-        $phoneNumberUtil = $this
-            ->getMockBuilder(PhoneNumberUtil::class)
-            ->disableOriginalConstructor()
-            ->getMock();
-        $shopService->phoneNumberUtil = $phoneNumberUtil;
-
         $trialRequest = $this
             ->getMockBuilder(NewStructureArtistPremiumTrialRequest::class)
             ->getMock();
-        $trialRequest->method('getRepresentativePhone')->willReturn('+33123456789');
 
         $organizationCreationRequest = $this
             ->getMockBuilder(OrganizationCreationRequest::class)
@@ -528,11 +522,6 @@ class ShopServiceTest extends TestCase
             ->with($trialRequest)
             ->willReturn($organizationCreationRequest);
 
-        $phoneNumberUtil->expects(self::once())
-            ->method('isPossibleNumber')
-            ->with('+33123456789')
-            ->willReturn(true);
-
         $this->organizationFactory->expects(self::once())
             ->method('interruptIfOrganizationExists')
             ->with($organizationCreationRequest);
@@ -549,44 +538,6 @@ class ShopServiceTest extends TestCase
         $shopService->validateNewStructureArtistPremiumTrialRequest($data);
     }
 
-    /**
-     * Test validateNewStructureArtistPremiumTrialRequest method with invalid phone number.
-     */
-    public function testValidateNewStructureArtistPremiumTrialRequestWithInvalidPhoneNumber(): void
-    {
-        $shopService = $this->getShopServiceMockFor('validateNewStructureArtistPremiumTrialRequest');
-
-        $phoneNumberUtil = $this->getMockBuilder(PhoneNumberUtil::class)
-            ->disableOriginalConstructor()
-            ->getMock();
-        $shopService->phoneNumberUtil = $phoneNumberUtil;
-
-        $trialRequest = $this
-            ->getMockBuilder(NewStructureArtistPremiumTrialRequest::class)
-            ->getMock();
-        $trialRequest->method('getRepresentativePhone')->willReturn('invalid-phone');
-
-        // Mock the phoneNumberUtil to return false for isPossibleNumber
-        $phoneNumberUtil->expects(self::once())
-            ->method('isPossibleNumber')
-            ->with('invalid-phone')
-            ->willReturn(false);
-
-        // Convert the trial request to an array for validation
-        $data = [
-            'representativePhone' => 'invalid-phone',
-        ];
-        $this->serializer->expects(self::once())
-            ->method('deserialize')
-            ->with(json_encode($data), NewStructureArtistPremiumTrialRequest::class, 'json')
-            ->willReturn($trialRequest);
-
-        $this->expectException(\RuntimeException::class);
-        $this->expectExceptionMessage('Invalid phone number');
-
-        $shopService->validateNewStructureArtistPremiumTrialRequest($data);
-    }
-
     /**
      * Test createOrganizationCreationRequestFromTrialRequest method with forValidationOnly=false.
      */
@@ -596,12 +547,13 @@ class ShopServiceTest extends TestCase
 
         $trialRequest = $this->getMockBuilder(NewStructureArtistPremiumTrialRequest::class)
             ->getMock();
+        $phoneNumber = $this->createMock(PhoneNumber::class);
         $trialRequest->method('getStructureName')->willReturn('Test Structure');
         $trialRequest->method('getCity')->willReturn('Test City');
         $trialRequest->method('getPostalCode')->willReturn('12345');
         $trialRequest->method('getAddress')->willReturn('Test Address');
         $trialRequest->method('getAddressComplement')->willReturn('Test Address Complement');
-        $trialRequest->method('getRepresentativePhone')->willReturn('+33123456789');
+        $trialRequest->method('getRepresentativePhone')->willReturn($phoneNumber);
         $trialRequest->method('getStructureEmail')->willReturn('structure@example.com');
         $trialRequest->method('getStructureType')->willReturn(PrincipalTypeEnum::ARTISTIC_EDUCATION_ONLY);
         $trialRequest->method('getLegalStatus')->willReturn(LegalEnum::ASSOCIATION_LAW_1901);
@@ -625,7 +577,7 @@ class ShopServiceTest extends TestCase
         $this->assertEquals(PrincipalTypeEnum::ARTISTIC_EDUCATION_ONLY, $result->getPrincipalType());
         $this->assertEquals(LegalEnum::ASSOCIATION_LAW_1901, $result->getLegalStatus());
         $this->assertEquals('123456789', $result->getSiretNumber());
-        $this->assertEquals('+33123456789', $result->getPhoneNumber());
+        $this->assertEquals($phoneNumber, $result->getPhoneNumber());
         $this->assertEquals('test-structure', $result->getSubdomain());
         $this->assertEquals(SettingsProductEnum::FREEMIUM, $result->getProduct());
         $this->assertFalse($result->getCreateWebsite());

+ 366 - 0
tests/Unit/Service/State/Provider/ProviderUtilsTest.php

@@ -0,0 +1,366 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Service\State\Provider;
+
+use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
+use ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\Pagination\Pagination;
+use ApiPlatform\State\Pagination\TraversablePaginator;
+use App\Service\State\Provider\ProviderUtils;
+use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\Platforms\AbstractPlatform;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\EntityRepository;
+use Doctrine\ORM\Query;
+use Doctrine\ORM\QueryBuilder;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+
+class ProviderUtilsTest extends TestCase
+{
+    private ProviderUtils $providerUtils;
+    private EntityManagerInterface|MockObject $entityManager;
+    private Pagination $pagination;
+    private QueryCollectionExtensionInterface|MockObject $extension1;
+    private QueryCollectionExtensionInterface|MockObject $extension2;
+
+    protected function setUp(): void
+    {
+        $this->entityManager = $this->createMock(EntityManagerInterface::class);
+        $this->pagination = new Pagination(['enabled' => true]);
+        $this->extension1 = $this->createMock(QueryCollectionExtensionInterface::class);
+        $this->extension2 = $this->createMock(QueryCollectionExtensionInterface::class);
+    }
+
+    private function getProviderUtilsMockForMethod(string $methodName): ProviderUtils|MockObject
+    {
+        $providerUtils = $this
+            ->getMockBuilder(ProviderUtils::class)
+            ->setConstructorArgs([
+                $this->entityManager,
+                [$this->extension1, $this->extension2],
+                $this->pagination,
+            ])
+            ->setMethodsExcept([$methodName])
+            ->getMock();
+
+        return $providerUtils;
+    }
+
+    public function testApplyCollectionExtensionsAndPagination(): void
+    {
+        $providerUtils = $this->getProviderUtilsMockForMethod('applyCollectionExtensionsAndPagination');
+
+        $entityClass = 'App\Entity\Test\TestEntity';
+        $operation = $this->createMock(Operation::class);
+        $context = ['filters' => ['name' => 'test']];
+
+        $platform = $this->createMock(AbstractPlatform::class);
+        $connection = $this->createMock(Connection::class);
+        $connection->expects(self::any())->method('getDatabasePlatform')->willReturn($platform);
+
+        $query = $this->createMock(Query::class);
+        $query->method('getEntityManager')->willReturn($this->entityManager);
+
+        $queryBuilder = $this->createMock(QueryBuilder::class);
+        $queryBuilder->method('getQuery')->willReturn($query);
+        $queryBuilder->method('getEntityManager')->willReturn($this->entityManager);
+
+        $repository = $this->createMock(EntityRepository::class);
+
+        $repository
+            ->expects(self::once())
+            ->method('createQueryBuilder')
+            ->with('o')
+            ->willReturn($queryBuilder);
+
+        $this->entityManager
+            ->expects(self::once())
+            ->method('getRepository')
+            ->with($entityClass)
+            ->willReturn($repository);
+
+        $this->entityManager
+            ->method('getConnection')
+            ->willReturn($connection);
+
+        // Mock extensions applying to the query
+        $this->extension1
+            ->expects(self::once())
+            ->method('applyToCollection')
+            ->with(
+                $queryBuilder,
+                self::isInstanceOf(QueryNameGenerator::class),
+                $entityClass,
+                $operation,
+                $context
+            );
+
+        $this->extension2
+            ->expects(self::once())
+            ->method('applyToCollection')
+            ->with(
+                $queryBuilder,
+                self::isInstanceOf(QueryNameGenerator::class),
+                $entityClass,
+                $operation,
+                $context
+            );
+
+        // Real pagination will be used
+
+        $result = $providerUtils->applyCollectionExtensionsAndPagination($entityClass, $operation, $context);
+
+        $this->assertInstanceOf(TraversablePaginator::class, $result);
+    }
+
+    public function testApplyCollectionExtensionsAndPaginationWithoutContext(): void
+    {
+        $providerUtils = $this->getProviderUtilsMockForMethod('applyCollectionExtensionsAndPagination');
+
+        $entityClass = 'App\Entity\Test\TestEntity';
+        $operation = $this->createMock(Operation::class);
+
+        $platform = $this->createMock(AbstractPlatform::class);
+        $connection = $this->createMock(Connection::class);
+        $connection->expects(self::any())->method('getDatabasePlatform')->willReturn($platform);
+
+        $query = $this->createMock(Query::class);
+        $query->method('getEntityManager')->willReturn($this->entityManager);
+
+        $queryBuilder = $this->createMock(QueryBuilder::class);
+        $queryBuilder->method('getQuery')->willReturn($query);
+        $queryBuilder->method('getEntityManager')->willReturn($this->entityManager);
+
+        $repository = $this->createMock(EntityRepository::class);
+
+        $repository
+            ->expects(self::once())
+            ->method('createQueryBuilder')
+            ->with('o')
+            ->willReturn($queryBuilder);
+
+        $this->entityManager
+            ->expects(self::once())
+            ->method('getRepository')
+            ->with($entityClass)
+            ->willReturn($repository);
+
+        $this->entityManager
+            ->method('getConnection')
+            ->willReturn($connection);
+
+        // Extensions should still be applied with empty context
+        $this->extension1
+            ->expects(self::once())
+            ->method('applyToCollection')
+            ->with(
+                $queryBuilder,
+                self::isInstanceOf(QueryNameGenerator::class),
+                $entityClass,
+                $operation,
+                []
+            );
+
+        $this->extension2
+            ->expects(self::once())
+            ->method('applyToCollection')
+            ->with(
+                $queryBuilder,
+                self::isInstanceOf(QueryNameGenerator::class),
+                $entityClass,
+                $operation,
+                []
+            );
+
+        $result = $providerUtils->applyCollectionExtensionsAndPagination($entityClass, $operation);
+
+        $this->assertInstanceOf(TraversablePaginator::class, $result);
+    }
+
+    public function testApplyCollectionExtensionsAndPaginationWithNonQueryCollectionExtension(): void
+    {
+        $entityClass = 'App\Entity\Test\TestEntity';
+        $operation = $this->createMock(Operation::class);
+
+        $nonQueryExtension = new class {};
+
+        $providerUtils = $this
+            ->getMockBuilder(ProviderUtils::class)
+            ->setConstructorArgs([
+                $this->entityManager,
+                [$this->extension1, $nonQueryExtension, $this->extension2],
+                $this->pagination,
+            ])
+            ->setMethodsExcept(['applyCollectionExtensionsAndPagination'])
+            ->getMock();
+
+        $platform = $this->createMock(AbstractPlatform::class);
+        $connection = $this->createMock(Connection::class);
+        $connection->expects(self::any())->method('getDatabasePlatform')->willReturn($platform);
+
+        $query = $this->createMock(Query::class);
+        $query->method('getEntityManager')->willReturn($this->entityManager);
+
+        $queryBuilder = $this->createMock(QueryBuilder::class);
+        $queryBuilder->method('getQuery')->willReturn($query);
+        $queryBuilder->method('getEntityManager')->willReturn($this->entityManager);
+
+        $repository = $this->createMock(EntityRepository::class);
+
+        $repository
+            ->expects(self::once())
+            ->method('createQueryBuilder')
+            ->with('o')
+            ->willReturn($queryBuilder);
+
+        $this->entityManager
+            ->expects(self::once())
+            ->method('getRepository')
+            ->with($entityClass)
+            ->willReturn($repository);
+
+        $this->entityManager
+            ->method('getConnection')
+            ->willReturn($connection);
+
+        $this->extension1
+            ->expects(self::once())
+            ->method('applyToCollection');
+
+        $this->extension2
+            ->expects(self::once())
+            ->method('applyToCollection');
+
+        $result = $providerUtils->applyCollectionExtensionsAndPagination($entityClass, $operation);
+
+        $this->assertInstanceOf(TraversablePaginator::class, $result);
+    }
+
+    public function testApplyCollectionExtensionsAndPaginationCreatesCorrectDoctrinePaginator(): void
+    {
+        $providerUtils = $this->getProviderUtilsMockForMethod('applyCollectionExtensionsAndPagination');
+
+        $entityClass = 'App\Entity\Test\TestEntity';
+        $operation = $this->createMock(Operation::class);
+
+        $platform = $this->createMock(AbstractPlatform::class);
+        $connection = $this->createMock(Connection::class);
+        $connection->expects(self::any())->method('getDatabasePlatform')->willReturn($platform);
+
+        $query = $this->createMock(Query::class);
+        $query->method('getEntityManager')->willReturn($this->entityManager);
+
+        $queryBuilder = $this->createMock(QueryBuilder::class);
+        $queryBuilder->method('getQuery')->willReturn($query);
+        $queryBuilder->method('getEntityManager')->willReturn($this->entityManager);
+
+        $repository = $this->createMock(EntityRepository::class);
+
+        $repository
+            ->expects(self::once())
+            ->method('createQueryBuilder')
+            ->with('o')
+            ->willReturn($queryBuilder);
+
+        $this->entityManager
+            ->expects(self::once())
+            ->method('getRepository')
+            ->with($entityClass)
+            ->willReturn($repository);
+
+        $this->entityManager
+            ->method('getConnection')
+            ->willReturn($connection);
+
+        $this->extension1
+            ->expects(self::once())
+            ->method('applyToCollection');
+
+        $this->extension2
+            ->expects(self::once())
+            ->method('applyToCollection');
+
+        // Real pagination will be used
+
+        $result = $providerUtils->applyCollectionExtensionsAndPagination($entityClass, $operation);
+
+        $this->assertInstanceOf(TraversablePaginator::class, $result);
+    }
+
+    public function testGetTraversablePaginator(): void
+    {
+        $providerUtils = $this->getProviderUtilsMockForMethod('getTraversablePaginator');
+
+        $mappedItems = ['item1', 'item2', 'item3'];
+        $originalPaginator = new TraversablePaginator(
+            new \ArrayIterator(['original1', 'original2']),
+            2,
+            10,
+            50
+        );
+
+        $result = $providerUtils->getTraversablePaginator($mappedItems, $originalPaginator);
+
+        $this->assertInstanceOf(TraversablePaginator::class, $result);
+        $this->assertEquals(2, $result->getCurrentPage());
+        $this->assertEquals(10, $result->getItemsPerPage());
+        $this->assertEquals(50, $result->getTotalItems());
+
+        // Test that the iterator contains the mapped items
+        $resultItems = iterator_to_array($result->getIterator());
+        $this->assertSame($mappedItems, $resultItems);
+    }
+
+    public function testGetTraversablePaginatorWithEmptyMappedItems(): void
+    {
+        $providerUtils = $this->getProviderUtilsMockForMethod('getTraversablePaginator');
+
+        $mappedItems = [];
+        $originalPaginator = new TraversablePaginator(
+            new \ArrayIterator([]),
+            1,
+            30,
+            0
+        );
+
+        $result = $providerUtils->getTraversablePaginator($mappedItems, $originalPaginator);
+
+        $this->assertInstanceOf(TraversablePaginator::class, $result);
+        $this->assertEquals(1, $result->getCurrentPage());
+        $this->assertEquals(30, $result->getItemsPerPage());
+        $this->assertEquals(0, $result->getTotalItems());
+        $this->assertEmpty(iterator_to_array($result->getIterator()));
+    }
+
+    public function testGetTraversablePaginatorWithComplexMappedItems(): void
+    {
+        $providerUtils = $this->getProviderUtilsMockForMethod('getTraversablePaginator');
+
+        $mappedItems = [
+            ['id' => 1, 'name' => 'Item 1'],
+            ['id' => 2, 'name' => 'Item 2'],
+            (object) ['id' => 3, 'name' => 'Item 3'],
+        ];
+        $originalPaginator = new TraversablePaginator(
+            new \ArrayIterator(['original1', 'original2', 'original3']),
+            3,
+            5,
+            100
+        );
+
+        $result = $providerUtils->getTraversablePaginator($mappedItems, $originalPaginator);
+
+        $this->assertInstanceOf(TraversablePaginator::class, $result);
+        $this->assertEquals(3, $result->getCurrentPage());
+        $this->assertEquals(5, $result->getItemsPerPage());
+        $this->assertEquals(100, $result->getTotalItems());
+
+        $resultItems = iterator_to_array($result->getIterator());
+        $this->assertCount(3, $resultItems);
+        $this->assertSame($mappedItems, $resultItems);
+    }
+}

+ 110 - 3
tests/Unit/Service/Twig/AssetsExtensionTest.php

@@ -2,6 +2,7 @@
 
 namespace App\Tests\Unit\Service\Twig;
 
+use App\Entity\Core\File;
 use App\Service\File\FileManager;
 use App\Service\Twig\AssetsExtension;
 use App\Service\Utils\PathUtils;
@@ -10,9 +11,11 @@ use PHPUnit\Framework\TestCase;
 class AssetsExtensionTest extends TestCase
 {
     private FileManager $fileManager;
+    private string $projectDir;
 
     public function setUp(): void
     {
+        $this->projectDir = PathUtils::getProjectDir();
         $this->fileManager = $this->getMockBuilder(FileManager::class)->disableOriginalConstructor()->getMock();
     }
 
@@ -20,23 +23,25 @@ class AssetsExtensionTest extends TestCase
     {
         $assetsExtension = $this
             ->getMockBuilder(AssetsExtension::class)
-            ->setConstructorArgs([$this->fileManager])
+            ->setConstructorArgs([$this->projectDir, $this->fileManager])
             ->setMethodsExcept(['getFunctions'])
             ->getMock();
 
         $functions = $assetsExtension->getFunctions();
 
-        $this->assertCount(2, $functions);
+        $this->assertCount(4, $functions);
 
         $this->assertEquals('absPath', $functions[0]->getName());
         $this->assertEquals('fileImagePath', $functions[1]->getName());
+        $this->assertEquals('asset_absolute', $functions[2]->getName());
+        $this->assertEquals('asset_base64', $functions[3]->getName());
     }
 
     public function testAbsPath(): void
     {
         $assetsExtension = $this
             ->getMockBuilder(AssetsExtension::class)
-            ->setConstructorArgs([$this->fileManager])
+            ->setConstructorArgs([$this->projectDir, $this->fileManager])
             ->setMethodsExcept(['absPath'])
             ->getMock();
 
@@ -57,4 +62,106 @@ class AssetsExtensionTest extends TestCase
             $assetsExtension->absPath('')
         );
     }
+
+    public function testFileImagePath(): void
+    {
+        $file = $this->getMockBuilder(File::class)->disableOriginalConstructor()->getMock();
+        $size = 'sm';
+        $expectedUrl = '/path/to/image.jpg';
+
+        $this->fileManager
+            ->expects($this->once())
+            ->method('getImageUrl')
+            ->with($file, $size, true)
+            ->willReturn($expectedUrl);
+
+        $assetsExtension = new AssetsExtension($this->projectDir, $this->fileManager);
+
+        $this->assertEquals(
+            ltrim($expectedUrl, '/'),
+            $assetsExtension->fileImagePath($file, $size)
+        );
+    }
+
+    public function testGetAssetAbsolutePath(): void
+    {
+        $assetsExtension = new AssetsExtension($this->projectDir, $this->fileManager);
+        $publicDir = $this->projectDir.'/public';
+
+        // Test with path starting with /
+        $this->assertEquals(
+            $publicDir.'/images/logo.png',
+            $assetsExtension->getAssetAbsolutePath('/images/logo.png')
+        );
+
+        // Test with path not starting with /
+        $this->assertEquals(
+            $publicDir.'/images/logo.png',
+            $assetsExtension->getAssetAbsolutePath('images/logo.png')
+        );
+
+        // Test with empty path
+        $this->assertEquals(
+            $publicDir.'/',
+            $assetsExtension->getAssetAbsolutePath('')
+        );
+    }
+
+    public function testGetAssetBase64(): void
+    {
+        // Create a mock for AssetsExtension with partial methods
+        $assetsExtension = $this->getMockBuilder(AssetsExtension::class)
+            ->setConstructorArgs([$this->projectDir, $this->fileManager])
+            ->onlyMethods(['getAssetAbsolutePath'])
+            ->getMock();
+
+        // Set up the mock to return a specific path
+        $testFilePath = sys_get_temp_dir().'/test_image.png';
+        $assetsExtension
+            ->method('getAssetAbsolutePath')
+            ->willReturn($testFilePath);
+
+        // Create a test file
+        $imageContent = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==');
+        file_put_contents($testFilePath, $imageContent);
+
+        try {
+            // Test the method
+            $result = $assetsExtension->getAssetBase64('test_image.png');
+
+            // Check the result
+            $this->assertStringStartsWith('data:image/png;base64,', $result);
+            $this->assertStringEndsWith(base64_encode($imageContent), $result);
+        } finally {
+            // Clean up
+            if (file_exists($testFilePath)) {
+                unlink($testFilePath);
+            }
+        }
+    }
+
+    public function testGetAssetBase64WithNonExistentFile(): void
+    {
+        // Create a mock for AssetsExtension with partial methods
+        $assetsExtension = $this->getMockBuilder(AssetsExtension::class)
+            ->setConstructorArgs([$this->projectDir, $this->fileManager])
+            ->onlyMethods(['getAssetAbsolutePath'])
+            ->getMock();
+
+        // Set up the mock to return a non-existent file path
+        $nonExistentFilePath = sys_get_temp_dir().'/non_existent_file.png';
+        $assetsExtension->method('getAssetAbsolutePath')
+            ->willReturn($nonExistentFilePath);
+
+        // Make sure the file doesn't exist
+        if (file_exists($nonExistentFilePath)) {
+            unlink($nonExistentFilePath);
+        }
+
+        // Test that an exception is thrown
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage("Asset not found: {$nonExistentFilePath}");
+
+        $assetsExtension->getAssetBase64('non_existent_file.png');
+    }
 }

+ 56 - 0
tests/Unit/Service/Twig/ToBase64ExtensionTest.php

@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Service\Twig;
+
+use App\Service\Twig\ToBase64Extension;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Twig\TwigFilter;
+
+class ToBase64ExtensionTest extends TestCase
+{
+    private string $projectDir = '/test/project';
+
+    private function getToBase64ExtensionMockFor(string $methodName): ToBase64Extension|MockObject
+    {
+        return $this->getMockBuilder(ToBase64Extension::class)
+            ->setConstructorArgs([$this->projectDir])
+            ->setMethodsExcept([$methodName])
+            ->getMock();
+    }
+
+    /**
+     * @see ToBase64Extension::getFilters()
+     */
+    public function testGetFilters(): void
+    {
+        $extension = $this->getToBase64ExtensionMockFor('getFilters');
+        $filters = $extension->getFilters();
+
+        $this->assertIsArray($filters);
+        $this->assertCount(1, $filters);
+        $this->assertInstanceOf(TwigFilter::class, $filters[0]);
+        $this->assertSame('img_to_base64', $filters[0]->getName());
+
+        // Test that the callable is correctly set
+        $callable = $filters[0]->getCallable();
+        $this->assertIsArray($callable);
+        $this->assertSame($extension, $callable[0]);
+        $this->assertSame('imgToBase64', $callable[1]);
+    }
+
+    /**
+     * @see ToBase64Extension::imgToBase64()
+     */
+    public function testImgToBase64WithNonExistentFile(): void
+    {
+        $extension = $this->getToBase64ExtensionMockFor('imgToBase64');
+
+        // Test with a non-existent file - this should return empty string without file operations
+        $result = $extension->imgToBase64('non-existent-image.jpg');
+
+        $this->assertSame('', $result);
+    }
+}

+ 26 - 52
tests/Unit/Service/Typo3/Typo3ServiceTest.php

@@ -1,11 +1,10 @@
 <?php
 
-/** @noinspection PhpUnhandledExceptionInspection */
-
 namespace App\Tests\Unit\Service\Typo3;
 
 use App\Service\Typo3\Typo3Service;
 use App\Service\Utils\DatesUtils;
+use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 use Symfony\Contracts\HttpClient\ResponseInterface;
@@ -20,11 +19,22 @@ class TestableTypo3Service extends Typo3Service
 
 class Typo3ServiceTest extends TestCase
 {
-    private HttpClientInterface $typo3Client;
+    private HttpClientInterface|MockObject $typo3Client;
 
     public function setUp(): void
     {
-        $this->typo3Client = $this->getMockBuilder(HttpClientInterface::class)->disableOriginalConstructor()->getMock();
+        $this->typo3Client = $this
+            ->getMockBuilder(HttpClientInterface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    private function getTypo3ServiceMockFor(string $methodName): TestableTypo3Service|MockObject
+    {
+        return $this->getMockBuilder(TestableTypo3Service::class)
+            ->setConstructorArgs([$this->typo3Client])
+            ->setMethodsExcept([$methodName])
+            ->getMock();
     }
 
     /**
@@ -40,10 +50,7 @@ class Typo3ServiceTest extends TestCase
             ->with('GET', '/typo3/foo?param=bar')
             ->willReturn($response);
 
-        $typo3Service = $this->getMockBuilder(TestableTypo3Service::class)
-            ->setConstructorArgs([$this->typo3Client])
-            ->setMethodsExcept(['sendCommand'])
-            ->getMock();
+        $typo3Service = $this->getTypo3ServiceMockFor('sendCommand');
 
         $typo3Service->sendCommand('foo', ['param' => 'bar']);
     }
@@ -53,10 +60,7 @@ class Typo3ServiceTest extends TestCase
      */
     public function testClearSiteCache(): void
     {
-        $typo3Service = $this->getMockBuilder(TestableTypo3Service::class)
-            ->setMethodsExcept(['clearSiteCache'])
-            ->disableOriginalConstructor()
-            ->getMock();
+        $typo3Service = $this->getTypo3ServiceMockFor('clearSiteCache');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
         $typo3Service->expects(self::once())
@@ -72,10 +76,7 @@ class Typo3ServiceTest extends TestCase
      */
     public function testCreateSite(): void
     {
-        $typo3Service = $this->getMockBuilder(TestableTypo3Service::class)
-            ->setMethodsExcept(['createSite'])
-            ->disableOriginalConstructor()
-            ->getMock();
+        $typo3Service = $this->getTypo3ServiceMockFor('createSite');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
         $typo3Service->expects(self::once())
@@ -91,10 +92,7 @@ class Typo3ServiceTest extends TestCase
      */
     public function testUpdateSite(): void
     {
-        $typo3Service = $this->getMockBuilder(TestableTypo3Service::class)
-            ->setMethodsExcept(['updateSite'])
-            ->disableOriginalConstructor()
-            ->getMock();
+        $typo3Service = $this->getTypo3ServiceMockFor('updateSite');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
         $typo3Service->expects(self::once())
@@ -110,10 +108,7 @@ class Typo3ServiceTest extends TestCase
      */
     public function testDeleteSite(): void
     {
-        $typo3Service = $this->getMockBuilder(TestableTypo3Service::class)
-            ->setMethodsExcept(['deleteSite'])
-            ->disableOriginalConstructor()
-            ->getMock();
+        $typo3Service = $this->getTypo3ServiceMockFor('deleteSite');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
         $typo3Service->expects(self::once())
@@ -131,10 +126,7 @@ class Typo3ServiceTest extends TestCase
     {
         DatesUtils::setFakeDatetime('2025-01-01 00:00:00');
 
-        $typo3Service = $this->getMockBuilder(TestableTypo3Service::class)
-            ->setMethodsExcept(['hardDeleteSite'])
-            ->disableOriginalConstructor()
-            ->getMock();
+        $typo3Service = $this->getTypo3ServiceMockFor('hardDeleteSite');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
 
@@ -155,10 +147,7 @@ class Typo3ServiceTest extends TestCase
      */
     public function testUndeleteSite(): void
     {
-        $typo3Service = $this->getMockBuilder(TestableTypo3Service::class)
-            ->setMethodsExcept(['undeleteSite'])
-            ->disableOriginalConstructor()
-            ->getMock();
+        $typo3Service = $this->getTypo3ServiceMockFor('undeleteSite');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
         $typo3Service->expects(self::once())
@@ -174,10 +163,7 @@ class Typo3ServiceTest extends TestCase
      */
     public function testSetSiteDomain(): void
     {
-        $typo3Service = $this->getMockBuilder(TestableTypo3Service::class)
-            ->setMethodsExcept(['setSiteDomain'])
-            ->disableOriginalConstructor()
-            ->getMock();
+        $typo3Service = $this->getTypo3ServiceMockFor('setSiteDomain');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
         $typo3Service->expects(self::once())
@@ -193,10 +179,7 @@ class Typo3ServiceTest extends TestCase
      */
     public function testSetSiteDomainWithRedirection(): void
     {
-        $typo3Service = $this->getMockBuilder(TestableTypo3Service::class)
-            ->setMethodsExcept(['setSiteDomain'])
-            ->disableOriginalConstructor()
-            ->getMock();
+        $typo3Service = $this->getTypo3ServiceMockFor('setSiteDomain');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
         $typo3Service->expects(self::once())
@@ -212,10 +195,7 @@ class Typo3ServiceTest extends TestCase
      */
     public function testResetSitePerms(): void
     {
-        $typo3Service = $this->getMockBuilder(TestableTypo3Service::class)
-            ->setMethodsExcept(['resetSitePerms'])
-            ->disableOriginalConstructor()
-            ->getMock();
+        $typo3Service = $this->getTypo3ServiceMockFor('resetSitePerms');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
         $typo3Service->expects(self::once())
@@ -231,10 +211,7 @@ class Typo3ServiceTest extends TestCase
      */
     public function testGetSiteStatus(): void
     {
-        $typo3Service = $this->getMockBuilder(TestableTypo3Service::class)
-            ->setMethodsExcept(['getSiteStatus'])
-            ->disableOriginalConstructor()
-            ->getMock();
+        $typo3Service = $this->getTypo3ServiceMockFor('getSiteStatus');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
         $typo3Service->expects(self::once())
@@ -250,10 +227,7 @@ class Typo3ServiceTest extends TestCase
      */
     public function testAddRedirection(): void
     {
-        $typo3Service = $this->getMockBuilder(TestableTypo3Service::class)
-            ->setMethodsExcept(['addRedirection'])
-            ->disableOriginalConstructor()
-            ->getMock();
+        $typo3Service = $this->getTypo3ServiceMockFor('addRedirection');
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
         $typo3Service->expects(self::once())

+ 53 - 0
tests/Unit/Service/Utils/StringsUtilsTest.php

@@ -32,4 +32,57 @@ class StringsUtilsTest extends TestCase
     {
         $this->assertEquals('Test contenu', (new StringsUtils())->convertHtmlToText('<table><tr><td>Test</td></tr></table> <br /><p>contenu</p>'));
     }
+
+    /**
+     * @see StringsUtils::elide()
+     */
+    public function testElide(): void
+    {
+        // Test normal truncation case - string longer than length
+        $this->assertEquals('Hello...', StringsUtils::elide('Hello World!', 8));
+
+        // Test case where string is shorter than length - no truncation
+        $this->assertEquals('Hello', StringsUtils::elide('Hello', 10));
+
+        // Test edge case with exact length
+        $this->assertEquals('Hello', StringsUtils::elide('Hello', 6));
+
+        // Test with empty string
+        $this->assertEquals('', StringsUtils::elide('', 10));
+    }
+
+    /**
+     * @see StringsUtils::elide()
+     */
+    public function testElideWithMultibyteCharacters(): void
+    {
+        // Test with UTF-8 multibyte characters
+        $this->assertEquals('Héllo...', StringsUtils::elide('Héllo Wörld!', 8));
+        $this->assertEquals('测试中...', StringsUtils::elide('测试中文字符串', 6));
+        $this->assertEquals('🎉🎊🎈🎆🎇', StringsUtils::elide('🎉🎊🎈🎆🎇', 6));
+    }
+
+    /**
+     * @see StringsUtils::elide()
+     */
+    public function testElideWithInvalidLength(): void
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionMessage('Length must be greater than 5');
+
+        StringsUtils::elide('Hello World!', 1);
+    }
+
+    /**
+     * @see StringsUtils::elide()
+     */
+    public function testElideWithLongString(): void
+    {
+        $longString = str_repeat('A', 1000);
+        $result = StringsUtils::elide($longString, 50);
+
+        $this->assertEquals(50, mb_strlen($result)); // 47 + 3 dots
+        $this->assertTrue(str_ends_with($result, '...'));
+        $this->assertEquals(str_repeat('A', 47).'...', $result);
+    }
 }

+ 147 - 0
tests/Unit/State/Processor/Freemium/FreemiumEventProcessorTest.php

@@ -0,0 +1,147 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\State\Processor\Freemium;
+
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\Metadata\Put;
+use App\ApiResources\Freemium\FreemiumEvent;
+use App\Entity\Booking\Event;
+use App\Repository\Booking\EventRepository;
+use App\Service\ApiResourceBuilder\Freemium\EventMappingBuilder;
+use App\State\Processor\Freemium\FreemiumEventProcessor;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Unit tests for ShopService.
+ */
+class FreemiumEventProcessorTest extends TestCase
+{
+    private readonly MockObject|EntityManagerInterface $entityManager;
+    private readonly MockObject|EventMappingBuilder $eventMappingBuilder;
+    private readonly MockObject|EventRepository $eventRepository;
+
+    public function setUp(): void
+    {
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->eventMappingBuilder = $this->getMockBuilder(EventMappingBuilder::class)->disableOriginalConstructor()->getMock();
+        $this->eventRepository = $this->getMockBuilder(EventRepository::class)->disableOriginalConstructor()->getMock();
+    }
+
+    private function getFreemiumEventProcessorMockFor(string $methodName): FreemiumEventProcessor|MockObject
+    {
+        $freemiumEventProcessor = $this
+            ->getMockBuilder(FreemiumEventProcessor::class)
+            ->setConstructorArgs(
+                [
+                    $this->entityManager,
+                    $this->eventMappingBuilder,
+                    $this->eventRepository,
+                ]
+            )
+            ->setMethodsExcept([$methodName])
+            ->getMock();
+
+        return $freemiumEventProcessor;
+    }
+
+    /**
+     * Test process method for POST operation.
+     */
+    public function testProcessWithPostMethod(): void
+    {
+        $freemiumEventProcessor = $this->getFreemiumEventProcessorMockFor('process');
+
+        $data = $this->getMockBuilder(FreemiumEvent::class)->getMock();
+        $event = $this->getMockBuilder(Event::class)->getMock();
+        $event->method('getId')->willReturn(1);
+
+        $operation = new Post();
+
+        $freemiumEventProcessor->expects(self::once())
+            ->method('getEvent')
+            ->with($operation)
+            ->willReturn($event);
+
+        $this->eventMappingBuilder->expects(self::once())
+            ->method('mapInformations')
+            ->with($event, $data);
+
+        $this->entityManager->expects(self::once())
+            ->method('persist')
+            ->with($event);
+
+        $this->entityManager->expects(self::once())
+            ->method('flush')
+        ;
+
+        $freemiumEvent = $freemiumEventProcessor->process($data, $operation);
+        $this->assertEquals(1, $freemiumEvent->id);
+    }
+
+    /**
+     * Test process method for PUT operation.
+     */
+    public function testProcessWithPutMethod(): void
+    {
+        $freemiumEventProcessor = $this->getFreemiumEventProcessorMockFor('process');
+
+        $data = $this->getMockBuilder(FreemiumEvent::class)->getMock();
+        $event = $this->getMockBuilder(Event::class)->getMock();
+        $data->id = 1;
+
+        $operation = new Put();
+
+        $freemiumEventProcessor->expects(self::once())
+            ->method('getEvent')
+            ->with($operation)
+            ->willReturn($event);
+
+        $this->eventMappingBuilder->expects(self::once())
+            ->method('mapInformations')
+            ->with($event, $data);
+
+        $this->entityManager->expects(self::once())
+            ->method('persist')
+            ->with($event);
+
+        $this->entityManager->expects(self::once())
+            ->method('flush')
+        ;
+
+        $freemiumEvent = $freemiumEventProcessor->process($data, $operation);
+        $this->assertEquals(1, $freemiumEvent->id);
+    }
+
+    /**
+     * Test process method for DELETE operation.
+     */
+    public function testProcessWithDeleteMethod(): void
+    {
+        $freemiumEventProcessor = $this->getFreemiumEventProcessorMockFor('process');
+
+        $freemiumEvent = $this->getMockBuilder(FreemiumEvent::class)->getMock();
+        $event = $this->getMockBuilder(Event::class)->getMock();
+
+        $operation = new Delete();
+
+        $freemiumEventProcessor->expects(self::once())
+            ->method('getEvent')
+            ->with($operation)
+            ->willReturn($event);
+
+        $this->entityManager->expects(self::once())
+            ->method('remove')
+            ->with($event);
+
+        $this->entityManager->expects(self::once())
+            ->method('flush')
+        ;
+
+        $freemiumEventProcessor->process($freemiumEvent, $operation);
+    }
+}

+ 168 - 0
tests/Unit/State/Provider/FreemiumEventProviderTest.php

@@ -0,0 +1,168 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\tests\Unit\State\Provider;
+
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\State\Pagination\TraversablePaginator;
+use App\ApiResources\Freemium\FreemiumEvent;
+use App\Entity\Booking\Event;
+use App\Entity\Core\Categories;
+use App\Repository\Booking\EventRepository;
+use App\Service\Doctrine\FiltersConfigurationService;
+use App\Service\State\Provider\ProviderUtils;
+use App\State\Provider\Freemium\FreemiumEventProvider;
+use Doctrine\Common\Collections\ArrayCollection;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\ObjectMapper\ObjectMapperInterface;
+
+/**
+ * Unit tests for ShopService.
+ */
+class FreemiumEventProviderTest extends TestCase
+{
+    private readonly MockObject|ProviderUtils $providerUtils;
+    private readonly MockObject|ObjectMapperInterface $objectMapper;
+    private readonly MockObject|EventRepository $eventRepository;
+    private readonly MockObject|FiltersConfigurationService $filtersConfigurationService;
+
+    public function setUp(): void
+    {
+        $this->providerUtils = $this->getMockBuilder(ProviderUtils::class)->disableOriginalConstructor()->getMock();
+        $this->objectMapper = $this->getMockBuilder(ObjectMapperInterface::class)->disableOriginalConstructor()->getMock();
+        $this->eventRepository = $this->getMockBuilder(EventRepository::class)->disableOriginalConstructor()->getMock();
+        $this->filtersConfigurationService = $this->getMockBuilder(FiltersConfigurationService::class)->disableOriginalConstructor()->getMock();
+    }
+
+    private function getFreemiumEventProviderMockFor(string $methodName): FreemiumEventProvider|MockObject
+    {
+        $freemiumEventProvider = $this
+            ->getMockBuilder(FreemiumEventProvider::class)
+            ->setConstructorArgs(
+                [
+                    $this->providerUtils,
+                    $this->objectMapper,
+                    $this->eventRepository,
+                    $this->filtersConfigurationService,
+                ]
+            )
+            ->setMethodsExcept([$methodName])
+            ->getMock();
+
+        return $freemiumEventProvider;
+    }
+
+    /**
+     * Test provide method for GetCollection operation.
+     */
+    public function testProvideGetCollectionMethod(): void
+    {
+        $freemiumEventProvide = $this->getFreemiumEventProviderMockFor('provide');
+
+        $operation = new GetCollection();
+
+        $freemiumEventProvide->expects(self::once())
+            ->method('provideCollection')
+            ->with($operation, [])
+            ->willReturn(new TraversablePaginator(new \ArrayIterator([]), 0, 10, 0))
+        ;
+
+        $freemiumEventProvide->provide($operation);
+    }
+
+    /**
+     * Test provide method for Get operation.
+     */
+    public function testProvideGetMethod(): void
+    {
+        $freemiumEventProvide = $this->getFreemiumEventProviderMockFor('provide');
+        $freemiumEvent = $this->getMockBuilder(FreemiumEvent::class)->getMock();
+
+        $operation = new Get();
+
+        $freemiumEventProvide->expects(self::once())
+            ->method('provideItem')
+            ->with([], [])
+            ->willReturn($freemiumEvent)
+        ;
+
+        $freemiumEventProvide->provide($operation);
+    }
+
+    /**
+     * Test provideCollection method.
+     */
+    public function testProvideCollectionMethod(): void
+    {
+        $freemiumEventProvide = $this->getFreemiumEventProviderMockFor('provideCollection');
+        $freemiumEvent = $this->getMockBuilder(FreemiumEvent::class)->getMock();
+
+        $operation = new GetCollection();
+
+        $this->filtersConfigurationService->expects(self::once())
+            ->method('suspendTimeConstraintFilters');
+
+        $traversable = new TraversablePaginator(new \ArrayIterator([$freemiumEvent, $freemiumEvent]), 0, 10, 2);
+        $this->providerUtils->expects(self::once())
+            ->method('applyCollectionExtensionsAndPagination')
+            ->with(Event::class, $operation, [])
+            ->willReturn($traversable);
+
+        $this->objectMapper->expects(self::exactly(2))
+            ->method('map')
+            ->willReturnOnConsecutiveCalls($freemiumEvent, $freemiumEvent);
+
+        $this->filtersConfigurationService->expects(self::once())
+            ->method('restoreTimeConstraintFilters');
+
+        $this->providerUtils->expects(self::once())
+            ->method('getTraversablePaginator')
+            ->with([$freemiumEvent, $freemiumEvent], $traversable)
+            ->willReturn($traversable)
+        ;
+
+        $freemiumEventProvide->provideCollection($operation, []);
+    }
+
+    /**
+     * Test provideItem method.
+     */
+    public function testProvideItemMethod(): void
+    {
+        $uriVariables = ['id' => 1];
+        $freemiumEventProvide = $this->getFreemiumEventProviderMockFor('provideItem');
+
+        $event = $this->getMockBuilder(Event::class)->getMock();
+        $category = $this->getMockBuilder(Categories::class)->getMock();
+        $categories = new ArrayCollection([$category, $category]);
+        $event->method('getCategories')->willReturn($categories);
+
+        $this->filtersConfigurationService->expects(self::once())
+            ->method('suspendTimeConstraintFilters');
+
+        $this->eventRepository->expects(self::once())
+            ->method('find')
+            ->with(1)
+            ->willReturn($event)
+        ;
+
+        $this->filtersConfigurationService->expects(self::once())
+            ->method('restoreTimeConstraintFilters');
+
+        $freemiumEvent = new FreemiumEvent();
+        $this->objectMapper->expects(self::once())
+            ->method('map')
+            ->willReturn($freemiumEvent);
+
+        $event->expects(self::once())
+            ->method('getCategories')
+            ->willReturn($categories)
+        ;
+
+        $freemiumEventProvide->provideItem($uriVariables, []);
+        $this->assertEquals(2, count($freemiumEvent->getCategories()));
+    }
+}